@7ots/cli 0.1.5 → 0.1.6

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 (44) hide show
  1. package/README.md +14 -20
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +122 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +28 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +365 -22
  14. package/cli/lib/screen.mjs +125 -0
  15. package/cli/lib/stt-local.py +14 -0
  16. package/cli/lib/stt.mjs +68 -0
  17. package/cli/lib/tts-local.mjs +110 -0
  18. package/cli/lib/tts-local.py +18 -0
  19. package/cli/lib/vm.mjs +309 -0
  20. package/cli/lib/watchers.mjs +151 -0
  21. package/cli/pet/card.html +294 -0
  22. package/cli/pet/electron/director.cjs +175 -0
  23. package/cli/pet/electron/main.cjs +354 -49
  24. package/cli/pet/pet.html +328 -15
  25. package/cli/pet/settings.html +991 -57
  26. package/dist/7ots.esm.js +3 -3
  27. package/dist/7ots.esm.js.map +3 -3
  28. package/dist/7ots.iife.js +3 -3
  29. package/dist/7ots.iife.js.map +3 -3
  30. package/llms.txt +31 -17
  31. package/package.json +1 -1
  32. package/server/llm.mjs +2 -0
  33. package/server/platform/connect.mjs +3 -1
  34. package/server/platform/db.mjs +77 -0
  35. package/server/platform/device.mjs +125 -0
  36. package/server/platform/orquesta.mjs +5 -0
  37. package/server/platform/routes.mjs +100 -1
  38. package/server/platform/sites.mjs +146 -0
  39. package/server/platform/store.mjs +3 -1
  40. package/server/platform/vm.mjs +386 -0
  41. package/src/character3d/index.js +2 -1
  42. package/src/character3d/stage.js +15 -4
  43. package/src/i18n/messages/character.js +3 -3
  44. package/src/i18n/messages/platform.js +42 -3
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Watchers: things the pet keeps an eye on until they happen, then it tells you (like a reminder, but on a
3
+ * condition instead of a time).
4
+ *
5
+ * price "avísame si BTC llega a 85 mil" { kind: 'price', coin: 'bitcoin', op: '>=', value: 85000, vs: 'usd' }
6
+ * CoinGecko's free simple-price API, every 60 s (all coins in one request).
7
+ * process "avísame cuando termine el build" { kind: 'process', match: 'npm run build', until: 'exit' | 'start' }
8
+ * the running processes (ps), every 5 s.
9
+ *
10
+ * State: ~/.7ots/watchers.json. Also: processes() = what runs on this computer right now (access.processes).
11
+ */
12
+
13
+ import { execFile } from 'node:child_process';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { homeFile, readJson, writeJson } from './paths.mjs';
16
+
17
+ const COINS = {
18
+ btc: 'bitcoin', bitcoin: 'bitcoin', eth: 'ethereum', ethereum: 'ethereum', sol: 'solana', solana: 'solana',
19
+ xrp: 'ripple', ada: 'cardano', doge: 'dogecoin', bnb: 'binancecoin', usdc: 'usd-coin', ton: 'the-open-network',
20
+ avax: 'avalanche-2', dot: 'polkadot', link: 'chainlink', matic: 'matic-network', pol: 'matic-network', ltc: 'litecoin', xlm: 'stellar',
21
+ };
22
+ export const coinId = (s) => {
23
+ const k = String(s || '').trim().toLowerCase();
24
+ return COINS[k] || (/^[a-z0-9-]{2,60}$/.test(k) ? k : null);
25
+ };
26
+ const OPS = { '>=': (a, b) => a >= b, '<=': (a, b) => a <= b, '>': (a, b) => a > b, '<': (a, b) => a < b };
27
+
28
+ /** What runs on this computer: [{ pid, name, cmd, cpu, mem, since }], heaviest first. Linux/macOS (ps). */
29
+ export function processes({ n = 40 } = {}) {
30
+ if (process.platform === 'win32') return Promise.resolve([]);
31
+ return new Promise((resolve) =>
32
+ execFile('ps', ['-eo', 'pid=,pcpu=,pmem=,etimes=,comm=,args='], { maxBuffer: 8 << 20, timeout: 4000 }, (err, out) => {
33
+ if (err) return resolve([]);
34
+ const rows = [];
35
+ for (const line of String(out).split('\n')) {
36
+ const m = line.trim().match(/^(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.*)$/);
37
+ if (!m || Number(m[1]) === process.pid || m[5] === 'ps') continue;
38
+ rows.push({ pid: +m[1], cpu: +m[2], mem: +m[3], since: Date.now() - m[4] * 1000, name: m[5], cmd: redactCmd(m[6]) });
39
+ }
40
+ rows.sort((a, b) => b.cpu - a.cpu || b.mem - a.mem);
41
+ resolve(rows.slice(0, n));
42
+ }),
43
+ );
44
+ }
45
+
46
+ // Command lines can carry secrets (--token=…, URLs with passwords): those never leave this function.
47
+ const redactCmd = (s) =>
48
+ String(s)
49
+ .replace(/(--?[\w-]*(?:token|key|secret|pass(?:word)?|auth)[\w-]*[= ])\S+/gi, '$1***')
50
+ .replace(/\b(sk|pk|ghp|gho|xox[bp]|7d|oek)[-_][A-Za-z0-9_-]{8,}/g, '$1_***')
51
+ .replace(/(\/\/[^/\s:@]+:)[^@\s]+@/g, '$1***@')
52
+ .slice(0, 300);
53
+
54
+ /** A short text for the brain: the busiest processes, no command arguments. */
55
+ export async function processSummary(n = 12) {
56
+ const ps = await processes({ n });
57
+ return ps.map((p) => `${p.name} (cpu ${p.cpu}%, mem ${p.mem}%)`).join(', ');
58
+ }
59
+
60
+ /**
61
+ * @param {{ onFire: (w, now: { price?: number }) => void, log?: Function }} o
62
+ */
63
+ export function createWatchers({ onFire, log = () => {} }) {
64
+ const file = homeFile('watchers.json');
65
+ let list = (readJson(file, []) || []).filter((w) => w && !w.done);
66
+ const save = () => writeJson(file, list);
67
+ const last = new Map(); // id → last value seen (price) or present (process), for the processes tab
68
+
69
+ function add(w) {
70
+ const x = { id: randomBytes(4).toString('hex'), created: Date.now(), ...w };
71
+ list.push(x);
72
+ save();
73
+ return x;
74
+ }
75
+ function fire(w, now) {
76
+ w.done = true;
77
+ list = list.filter((x) => !x.done);
78
+ save();
79
+ onFire(w, now);
80
+ }
81
+
82
+ async function checkPrices() {
83
+ const ws = list.filter((w) => w.kind === 'price');
84
+ if (!ws.length) return;
85
+ const ids = [...new Set(ws.map((w) => w.coin))].join(',');
86
+ const vs = [...new Set(ws.map((w) => w.vs || 'usd'))].join(',');
87
+ try {
88
+ const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=${encodeURIComponent(vs)}`, { signal: AbortSignal.timeout(10_000) });
89
+ if (!r.ok) throw new Error(`coingecko ${r.status}`);
90
+ const j = await r.json();
91
+ for (const w of ws) {
92
+ const price = j[w.coin]?.[w.vs || 'usd'];
93
+ if (!Number.isFinite(price)) continue;
94
+ last.set(w.id, { price, at: Date.now() });
95
+ if (OPS[w.op]?.(price, w.value)) fire(w, { price });
96
+ }
97
+ } catch (e) {
98
+ log(`watchers: ${e.message}`);
99
+ }
100
+ }
101
+
102
+ async function checkProcesses() {
103
+ const ws = list.filter((w) => w.kind === 'process');
104
+ if (!ws.length) return;
105
+ const ps = await processes({ n: 5000 });
106
+ for (const w of ws) {
107
+ const needle = w.match.toLowerCase();
108
+ const hit = ps.find((p) => p.cmd.toLowerCase().includes(needle) || p.name.toLowerCase() === needle);
109
+ const prev = last.get(w.id)?.present;
110
+ last.set(w.id, { present: !!hit, pid: hit?.pid, at: Date.now() });
111
+ // "until it ends": it has to have been seen running first (or be running when the watch starts)
112
+ if (w.until === 'start' ? hit : prev === true && !hit) fire(w, {});
113
+ else if (w.until !== 'start' && prev === undefined && !hit) w.notYet = true;
114
+ }
115
+ }
116
+
117
+ const tPrice = setInterval(checkPrices, 60_000);
118
+ const tProc = setInterval(checkProcesses, 5000);
119
+ tPrice.unref?.();
120
+ tProc.unref?.();
121
+ setTimeout(checkPrices, 1500).unref?.();
122
+
123
+ return {
124
+ get list() {
125
+ return list.map((w) => ({ ...w, last: last.get(w.id) || null }));
126
+ },
127
+ addPrice({ coin, op = '>=', value, vs = 'usd', text = '' }) {
128
+ const id = coinId(coin);
129
+ if (!id || !Number.isFinite(Number(value)) || !OPS[op]) return null;
130
+ const w = add({ kind: 'price', coin: id, op, value: Number(value), vs: String(vs || 'usd').toLowerCase(), text: String(text).slice(0, 200) });
131
+ checkPrices();
132
+ return w;
133
+ },
134
+ addProcess({ match, until = 'exit', text = '' }) {
135
+ if (!match || String(match).length < 2) return null;
136
+ const w = add({ kind: 'process', match: String(match).slice(0, 120), until: until === 'start' ? 'start' : 'exit', text: String(text).slice(0, 200) });
137
+ checkProcesses();
138
+ return w;
139
+ },
140
+ cancel(id) {
141
+ const n = list.length;
142
+ list = id === 'all' ? [] : list.filter((w) => w.id !== id);
143
+ save();
144
+ return n !== list.length;
145
+ },
146
+ stop() {
147
+ clearInterval(tPrice);
148
+ clearInterval(tProc);
149
+ },
150
+ };
151
+ }
@@ -0,0 +1,294 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>7ots card</title>
7
+ <link rel="icon" href="/favicon.svg" />
8
+ <style>
9
+ /* An ot's trading card: its public face. Holo foil in the spirit of Pokémon/Magic cards (color-dodge rainbow
10
+ bands + glitter + a glare that follows the light), tilted by the pointer. ?shot=1 is the still frame the
11
+ daemon screenshots at high resolution for /card.png. */
12
+ :root {
13
+ --c: #6d5dfc; --a: #22d3ee; --ink: #fff;
14
+ --rx: 0deg; --ry: 0deg; --mx: 50%; --my: 50%; --o: 0.6;
15
+ --bg: #0d0b14; --fg: #ece9f6; --mut: #9c96b3; --line: #2a2538; --btn: #1b1726;
16
+ }
17
+ @media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) { --bg: #efedf5; --fg: #17141f; --mut: #6b6580; --line: #d9d4e6; --btn: #fff; } }
18
+ * { box-sizing: border-box; }
19
+ html, body { margin: 0; min-height: 100%; }
20
+ body { background: radial-gradient(1200px 700px at 50% 30%, color-mix(in srgb, var(--c) 18%, var(--bg)), var(--bg)); color: var(--fg);
21
+ font: 14px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; display: flex; flex-direction: column; align-items: center; gap: 22px; padding: 28px 16px 40px; }
22
+ body.shot { background: transparent; padding: 20px; gap: 0; }
23
+ body.shot .controls { display: none; }
24
+ body.shot .card { box-shadow: none; transition: none; }
25
+
26
+ .scene { perspective: 1400px; }
27
+ .card { --w: 420px; width: var(--w); aspect-ratio: 5 / 7; position: relative; border-radius: 22px; transform-style: preserve-3d;
28
+ transform: rotateY(var(--ry)) rotateX(var(--rx)); transition: transform .5s cubic-bezier(.2,.8,.2,1);
29
+ box-shadow: 0 30px 60px -20px rgb(0 0 0 / .65), 0 0 0 1px rgb(255 255 255 / .06), 0 0 60px -10px color-mix(in srgb, var(--c) 60%, transparent); }
30
+ .card.live { transition: transform .08s linear; }
31
+ @media (max-width: 480px) { .card { --w: calc(100vw - 40px); } }
32
+
33
+ /* frame: metallic, in the ot's colours */
34
+ .frame { position: absolute; inset: 0; border-radius: inherit; padding: 12px; overflow: hidden;
35
+ background: linear-gradient(135deg, color-mix(in srgb, var(--c) 70%, #fff) 0%, var(--c) 22%, color-mix(in srgb, var(--a) 80%, #000) 50%, var(--c) 78%, color-mix(in srgb, var(--a) 60%, #fff) 100%); }
36
+ .card[data-foil="gold"] .frame { background: linear-gradient(135deg, #fff3b0, #d4a017 20%, #8a6100 45%, #f7d774 62%, #b8860b 80%, #fff1a8); }
37
+ .card[data-foil="cosmos"] .frame { background: linear-gradient(135deg, #2b1b5e, #120a2e 40%, #3b2380 70%, #0b0720); }
38
+ .face { position: relative; height: 100%; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden;
39
+ background: linear-gradient(180deg, color-mix(in srgb, var(--c) 22%, #15121e), #0f0c17 60%); color: #f4f1ff; }
40
+
41
+ header { display: flex; align-items: baseline; gap: 8px; padding: 10px 14px 8px; }
42
+ .name { font: 800 26px/1 ui-rounded, "SF Pro Rounded", system-ui, sans-serif; letter-spacing: -.01em; text-shadow: 0 2px 0 rgb(0 0 0 / .35); }
43
+ .lvl { margin-left: auto; font: 700 12px/1 ui-monospace, monospace; color: #fff; opacity: .9; }
44
+ .lvl b { font-size: 20px; }
45
+
46
+ .art { position: relative; margin: 0 12px; flex: 1 1 auto; min-height: 0; border-radius: 8px; overflow: hidden; isolation: isolate;
47
+ box-shadow: inset 0 0 0 2px rgb(255 255 255 / .25), 0 6px 14px rgb(0 0 0 / .45);
48
+ background: radial-gradient(120% 90% at 50% 70%, color-mix(in srgb, var(--a) 55%, #fff) 0%, var(--c) 45%, color-mix(in srgb, var(--c) 40%, #000) 100%); }
49
+ .rays { position: absolute; inset: -50%; background: repeating-conic-gradient(from 0deg at 50% 50%, rgb(255 255 255 / .14) 0 6deg, transparent 6deg 18deg); animation: spin 60s linear infinite; }
50
+ .shot .rays { animation: none; }
51
+ @keyframes spin { to { transform: rotate(1turn); } }
52
+ .dots { position: absolute; inset: 0; background-image: radial-gradient(rgb(255 255 255 / .35) 1px, transparent 1.5px); background-size: 14px 14px; mask-image: linear-gradient(transparent, #000 70%); opacity: .5; }
53
+ .floor { position: absolute; left: 18%; right: 18%; bottom: 7%; height: 9%; border-radius: 50%; background: radial-gradient(rgb(0 0 0 / .45), transparent 70%); }
54
+ #fig { position: absolute; left: 10%; right: 10%; top: 6%; bottom: 4%; z-index: 1; }
55
+ #fig canvas { width: 100% !important; height: 100% !important; }
56
+
57
+ .type { display: flex; justify-content: space-between; gap: 8px; margin: 8px 12px 0; padding: 5px 10px; border-radius: 999px;
58
+ background: rgb(255 255 255 / .08); font: 600 11px/1.3 system-ui, sans-serif; letter-spacing: .06em; text-transform: uppercase; color: #e6e1ff; }
59
+ .type span:last-child { opacity: .7; }
60
+ .stats { margin: 10px 14px 0; display: grid; gap: 7px; }
61
+ .stat { display: grid; grid-template-columns: 74px 1fr 30px; align-items: center; gap: 8px; font: 600 12px/1 system-ui, sans-serif; }
62
+ .bar { height: 7px; border-radius: 9px; background: rgb(255 255 255 / .1); overflow: hidden; }
63
+ .bar i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--c), var(--a)); box-shadow: 0 0 10px var(--a); }
64
+ .stat em { font-style: normal; text-align: right; font-family: ui-monospace, monospace; }
65
+ .flavor { margin: 10px 14px 0; padding-top: 8px; font: italic 12px/1.35 Georgia, serif; color: #cfc8ea; border-top: 1px solid rgb(255 255 255 / .12); }
66
+ footer { margin-top: 4px; display: flex; align-items: center; gap: 6px; padding: 8px 14px 10px; font: 600 10px/1 ui-monospace, monospace; color: #b9b1d8; letter-spacing: .04em; }
67
+ footer img { width: 14px; height: 14px; }
68
+ .rar { margin-left: auto; display: flex; align-items: center; gap: 4px; color: #fff; }
69
+ .rar svg { width: 12px; height: 12px; fill: currentColor; }
70
+
71
+ /* ── foil layers ── */
72
+ .holo, .glitter, .glare { position: absolute; inset: 0; border-radius: inherit; pointer-events: none; }
73
+ .holo {
74
+ mix-blend-mode: color-dodge; opacity: var(--o); filter: brightness(.55) contrast(1.6) saturate(1.4);
75
+ background:
76
+ repeating-linear-gradient(110deg, #ff3b6b 0%, #ffd23b 6%, #3bff9a 12%, #3bd6ff 18%, #a63bff 24%, #ff3b6b 30%),
77
+ repeating-linear-gradient(-45deg, #0e152e 0%, #8fa3bf 3.8%, #8fa3bf 4.5%, #0e152e 5.2%, #0e152e 10%);
78
+ background-size: 400% 400%, 300% 300%; background-blend-mode: overlay;
79
+ background-position: calc(var(--mx) * 1) calc(var(--my) * 1), calc(100% - var(--mx)) calc(100% - var(--my));
80
+ }
81
+ /* classic holo: only the art shines */
82
+ .card[data-foil="holo"] .holo { -webkit-mask: var(--artmask); mask: var(--artmask); }
83
+ .card[data-foil="gold"] .holo { background: repeating-linear-gradient(115deg, #3a2500 0%, #ffe9a0 5%, #b8860b 9%, #3a2500 14%); background-size: 300% 300%; filter: brightness(.7) contrast(1.4); }
84
+ .card[data-foil="cosmos"] .holo { background:
85
+ radial-gradient(circle at var(--mx) var(--my), #ff6be6 0%, transparent 35%),
86
+ repeating-radial-gradient(circle at 30% 70%, #2b2b8f 0, #8f2bd6 8%, #2bd6c4 16%, #2b2b8f 24%); background-size: 100% 100%, 200% 200%; }
87
+ .glitter { mix-blend-mode: color-dodge; opacity: calc(var(--o) * .9);
88
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' seed='7'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 9 -6.2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
89
+ background-size: 220px; background-position: var(--mx) var(--my);
90
+ -webkit-mask: radial-gradient(circle at var(--mx) var(--my), #000 0%, rgb(0 0 0 / .25) 55%, transparent 80%); mask: radial-gradient(circle at var(--mx) var(--my), #000 0%, rgb(0 0 0 / .25) 55%, transparent 80%); }
91
+ .card[data-foil="none"] .holo, .card[data-foil="none"] .glitter { display: none; }
92
+ .glare { mix-blend-mode: overlay; background: radial-gradient(farthest-corner circle at var(--mx) var(--my), rgb(255 255 255 / .75) 0%, rgb(255 255 255 / .15) 30%, rgb(0 0 0 / .35) 90%); opacity: calc(.4 + var(--o) * .5); }
93
+
94
+ .controls { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; align-items: center; max-width: 520px; }
95
+ .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
96
+ .seg button, .btn { font: 600 13px/1 system-ui, sans-serif; color: var(--fg); background: var(--btn); border: 0; padding: 9px 12px; cursor: pointer; }
97
+ .seg button + button { border-left: 1px solid var(--line); }
98
+ .seg button[aria-pressed="true"] { background: var(--c); color: #fff; }
99
+ .btn { border: 1px solid var(--line); border-radius: 10px; }
100
+ .btn.pri { background: linear-gradient(135deg, var(--c), var(--a)); color: #fff; border: 0; }
101
+ .btn[disabled] { opacity: .6; cursor: progress; }
102
+ select.btn { appearance: none; }
103
+ .hint { width: 100%; text-align: center; color: var(--mut); font-size: 12px; }
104
+ </style>
105
+ </head>
106
+ <body>
107
+ <div class="scene"><div class="card" id="card" data-foil="holo">
108
+ <div class="frame"><div class="face">
109
+ <header><span class="name" id="name">…</span><span class="lvl" id="lvl"></span></header>
110
+ <div class="art" id="art"><div class="rays"></div><div class="dots"></div><div class="floor"></div><div id="fig"></div></div>
111
+ <div class="type"><span id="role"></span><span id="llm"></span></div>
112
+ <div class="stats" id="stats"></div>
113
+ <div class="flavor" id="flavor"></div>
114
+ <footer><img src="/favicon.svg" alt="" /><span id="serial"></span><span class="rar" id="rar"></span></footer>
115
+ </div></div>
116
+ <div class="holo"></div><div class="glitter"></div><div class="glare"></div>
117
+ </div></div>
118
+
119
+ <div class="controls">
120
+ <div class="seg" id="foils"></div>
121
+ <div class="seg" id="dims"><button data-d="2d">2D</button><button data-d="3d">3D</button></div>
122
+ <select class="btn" id="scale" aria-label="resolution"><option value="3">1320×1800</option><option value="4" selected>1760×2400</option><option value="6">2640×3600</option></select>
123
+ <button class="btn pri" id="dl"></button>
124
+ <div class="hint" id="hint"></div>
125
+ </div>
126
+
127
+ <script type="module">
128
+ const BOOT = __BOOT__;
129
+ const $ = (s) => document.querySelector(s);
130
+ const q = new URLSearchParams(location.search);
131
+ const shot = q.has('shot');
132
+ document.body.classList.toggle('shot', shot);
133
+ const lang = /^(es|pt)/.test(BOOT.lang || '') ? BOOT.lang.slice(0, 2) : 'en';
134
+ const T = {
135
+ en: { foils: { holo: 'Holo', prism: 'Prism', cosmos: 'Cosmos', gold: 'Gold', none: 'Plain' }, dl: 'Download PNG', making: 'Rendering…', hint: 'Move the mouse over the card. The PNG is a still frame at high resolution.', stats: ['Energy', 'Wit', 'Charm', 'Chaos'], rar: ['Common', 'Rare', 'Epic', 'Legendary'], lvl: 'LV', fail: 'Could not render' },
136
+ es: { foils: { holo: 'Holo', prism: 'Prisma', cosmos: 'Cosmos', gold: 'Oro', none: 'Simple' }, dl: 'Descargar PNG', making: 'Renderizando…', hint: 'Pasa el mouse sobre la carta. El PNG es una toma fija en alta resolución.', stats: ['Energía', 'Ingenio', 'Encanto', 'Caos'], rar: ['Común', 'Rara', 'Épica', 'Legendaria'], lvl: 'NV', fail: 'No se pudo renderizar' },
137
+ pt: { foils: { holo: 'Holo', prism: 'Prisma', cosmos: 'Cosmos', gold: 'Ouro', none: 'Simples' }, dl: 'Baixar PNG', making: 'Renderizando…', hint: 'Passe o mouse sobre a carta. O PNG é um quadro fixo em alta resolução.', stats: ['Energia', 'Engenho', 'Charme', 'Caos'], rar: ['Comum', 'Rara', 'Épica', 'Lendária'], lvl: 'NV', fail: 'Não foi possível renderizar' },
138
+ }[lang];
139
+ const api = (p) => fetch(p, { headers: { 'X-7ots-Token': BOOT.token } }).then((r) => r.json());
140
+
141
+ // Which ot: ?id= one of the account's, else the one on this computer.
142
+ let ot = { id: BOOT.otsId || '', identity: BOOT.identity, createdAt: 0 };
143
+ let state = null;
144
+ if (q.get('id')) {
145
+ const acc = await api('/account').catch(() => ({}));
146
+ const o = (acc.ots || []).find((x) => x.id === q.get('id'));
147
+ if (o) ot = o;
148
+ if (acc.current?.otsId === q.get('id')) state = (await api('/state').catch(() => ({}))).state;
149
+ } else state = (await api('/state').catch(() => ({}))).state;
150
+ const id = ot.identity || {};
151
+ const look = id.look || {};
152
+ const hex = (v, d) => (/^#[0-9a-f]{6}$/i.test(v || '') ? v : d);
153
+ const C = hex(look.character?.body?.color, hex(look.color, '#6d5dfc'));
154
+ const A = hex(look.accent, hex(look.character?.cheeks?.color, '#22d3ee'));
155
+ document.documentElement.style.setProperty('--c', C);
156
+ document.documentElement.style.setProperty('--a', A);
157
+
158
+ // Seeded traits: the same ot always gets the same card.
159
+ let h = 2166136261;
160
+ for (const ch of (ot.id || id.name || 'ot') + '|' + (id.name || '')) h = Math.imul(h ^ ch.charCodeAt(0), 16777619);
161
+ const rnd = () => ((h = Math.imul(h ^ (h >>> 15), 2246822507) ^ Math.imul(h ^ (h >>> 13), 3266489909)), (h >>> 0) / 4294967296);
162
+ const traits = T.stats.map((n) => [n, 25 + Math.round(rnd() * 74)]);
163
+ const r = rnd();
164
+ const rarity = r > 0.94 ? 3 : r > 0.75 ? 2 : r > 0.4 ? 1 : 0;
165
+
166
+ $('#name').textContent = id.name || ot.name || 'ot';
167
+ document.title = `${id.name || 'ot'} · 7ots`;
168
+ if (state?.level) $('#lvl').innerHTML = `${T.lvl} <b>${state.level}</b>`;
169
+ $('#role').textContent = id.role || 'ot';
170
+ const model = String(ot.llm || BOOT.brain || '').split(/[·:]/).pop().trim();
171
+ $('#llm').textContent = model.length > 24 ? `${model.slice(0, 23)}…` : model;
172
+ $('#stats').innerHTML = traits.map(([n, v]) => `<div class="stat"><span>${n}</span><div class="bar"><i style="width:${v}%"></i></div><em>${v}</em></div>`).join('');
173
+ $('#flavor').textContent = id.bio || id.tagline || id.persona?.slice?.(0, 140) || id.description || '';
174
+ if (!$('#flavor').textContent) $('#flavor').remove();
175
+ const year = new Date(ot.createdAt || state?.born || Date.now()).getFullYear();
176
+ $('#serial').textContent = `#${(ot.id || 'local').replace(/^ots_/, '').slice(-6).toUpperCase()} · ${year}`;
177
+ const star = '<svg viewBox="0 0 24 24"><path d="M12 2l2.9 6.9 7.1.6-5.4 4.7 1.7 7-6.3-3.9-6.3 3.9 1.7-7L2 9.5l7.1-.6z"/></svg>';
178
+ $('#rar').innerHTML = star.repeat(rarity + 1) + `<span>${T.rar[rarity]}</span>`;
179
+
180
+ // Foil: from ?foil=, else by rarity.
181
+ const FOILS = ['holo', 'prism', 'cosmos', 'gold', 'none'];
182
+ let foil = FOILS.includes(q.get('foil')) ? q.get('foil') : ['holo', 'holo', 'prism', 'cosmos'][rarity];
183
+ let dim = q.get('3d') ? '3d' : '2d';
184
+ const card = $('#card');
185
+ const art = $('#art');
186
+ // the classic holo shines only on the art: a mask with the art window cut out of the card
187
+ const setMask = () => {
188
+ const c = card.getBoundingClientRect();
189
+ const a = art.getBoundingClientRect();
190
+ const [x, y, w, hh] = [a.left - c.left, a.top - c.top, a.width, a.height].map((v) => (v / c.width) * 100);
191
+ const H = (c.height / c.width) * 100;
192
+ card.style.setProperty('--artmask', `url("data:image/svg+xml,${encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 ${H}' preserveAspectRatio='none'><rect x='${x}' y='${y}' width='${w}' height='${hh}' rx='2'/></svg>`)}") 0 0 / 100% 100%`);
193
+ };
194
+ const setFoil = (f) => {
195
+ foil = f;
196
+ card.dataset.foil = f;
197
+ $('#foils').innerHTML = FOILS.map((k) => `<button data-f="${k}" aria-pressed="${k === f}">${T.foils[k]}</button>`).join('');
198
+ };
199
+ $('#foils').addEventListener('click', (e) => e.target.dataset.f && setFoil(e.target.dataset.f));
200
+ setFoil(foil);
201
+ addEventListener('resize', setMask);
202
+
203
+ // The ot itself, big.
204
+ const { createCharacter, loadCharacter3D } = await import('/dist/7ots.esm.js');
205
+ const spec = look.character || {};
206
+ let figure = null;
207
+ async function draw() {
208
+ figure?.destroy?.();
209
+ $('#fig').innerHTML = '';
210
+ figure = null;
211
+ if (dim === '3d') {
212
+ try {
213
+ const m = await loadCharacter3D();
214
+ figure = m.createCharacter3D($('#fig'), spec, { material: look.material || 'plush', backdrop: false, orbit: false, stage: { key: 'card', zIndex: 1, maxDpr: 4 } });
215
+ if (figure.fallback) figure = null;
216
+ } catch {
217
+ figure = null;
218
+ }
219
+ }
220
+ figure ||= createCharacter($('#fig'), spec, { locale: lang, title: id.name, moodFx: false });
221
+ figure.mood?.('happy', { fx: false });
222
+ document.querySelectorAll('#dims button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.d === dim)));
223
+ }
224
+ $('#dims').addEventListener('click', (e) => e.target.dataset.d && e.target.dataset.d !== dim && ((dim = e.target.dataset.d), draw()));
225
+ await draw();
226
+ setMask();
227
+
228
+ // Light: follows the pointer over the card; on its own it sways gently, like turning it in your hand.
229
+ const light = (px, py) => {
230
+ card.style.setProperty('--mx', `${px * 100}%`);
231
+ card.style.setProperty('--my', `${py * 100}%`);
232
+ card.style.setProperty('--ry', `${(px - 0.5) * 26}deg`);
233
+ card.style.setProperty('--rx', `${(0.5 - py) * 26}deg`);
234
+ card.style.setProperty('--o', String(0.35 + Math.min(1, Math.hypot(px - 0.5, py - 0.5) * 1.6) * 0.65));
235
+ };
236
+ // the still frame: lit from the upper right, but flat (a straight card for the PNG)
237
+ if (shot) light(0.68, 0.3), card.style.setProperty('--rx', '0deg'), card.style.setProperty('--ry', '0deg');
238
+ else {
239
+ let hovering = false;
240
+ card.addEventListener('pointermove', (e) => {
241
+ const b = card.getBoundingClientRect();
242
+ hovering = true;
243
+ card.classList.add('live');
244
+ light((e.clientX - b.left) / b.width, (e.clientY - b.top) / b.height);
245
+ });
246
+ card.addEventListener('pointerleave', () => ((hovering = false), card.classList.remove('live')));
247
+ const t0 = performance.now();
248
+ const sway = (t) => {
249
+ if (!hovering) {
250
+ const s = (t - t0) / 1000;
251
+ light(0.5 + Math.sin(s * 0.7) * 0.28, 0.45 + Math.cos(s * 0.5) * 0.2);
252
+ }
253
+ requestAnimationFrame(sway);
254
+ };
255
+ requestAnimationFrame(sway);
256
+ }
257
+
258
+ // High-res PNG: the daemon screenshots ?shot=1 in headless Chrome (blend modes and 3D included).
259
+ $('#dl').textContent = T.dl;
260
+ $('#hint').textContent = T.hint;
261
+ $('#dl').addEventListener('click', async () => {
262
+ const b = $('#dl');
263
+ b.disabled = true;
264
+ b.textContent = T.making;
265
+ try {
266
+ const p = new URLSearchParams({ foil, scale: $('#scale').value });
267
+ if (q.get('id')) p.set('id', q.get('id'));
268
+ if (dim === '3d') p.set('3d', '1');
269
+ const r = await fetch(`/card.png?${p}`, { headers: { 'X-7ots-Token': BOOT.token } });
270
+ if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || r.status);
271
+ const a = Object.assign(document.createElement('a'), { href: URL.createObjectURL(await r.blob()), download: `${(id.name || 'ot').toLowerCase()}-7ots-card.png` });
272
+ a.click();
273
+ setTimeout(() => URL.revokeObjectURL(a.href), 5000);
274
+ } catch (e) {
275
+ $('#hint').textContent = `${T.fail}: ${e.message}`;
276
+ } finally {
277
+ b.disabled = false;
278
+ b.textContent = T.dl;
279
+ }
280
+ });
281
+ window.__ready = true;
282
+ // 3D: let it render a while first (it grows in and settles), up to ~10 s on a software GPU
283
+ const frames = (n, ms) => new Promise((ok) => { const end = performance.now() + ms; const f = () => (--n <= 0 || performance.now() > end ? ok() : requestAnimationFrame(f)); requestAnimationFrame(f); });
284
+ if (shot && window.__cardReady)
285
+ document.fonts.ready.then(() => (dim === '3d' ? frames(40, 10000) : null)).then(() => requestAnimationFrame(() => requestAnimationFrame(() => {
286
+ const b = card.getBoundingClientRect();
287
+ // the still frame: the 3D render loop stops here (the canvas keeps its last frame), so the
288
+ // screenshot isn't queued behind frames that take seconds on a software GPU
289
+ if (dim === '3d') window.requestAnimationFrame = () => 0;
290
+ window.__cardReady(JSON.stringify({ x: b.left, y: b.top, w: b.width, h: b.height }));
291
+ })));
292
+ </script>
293
+ </body>
294
+ </html>
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Scenes between ots on the same desktop: when two meet (or now and then) they square off and play a bit
3
+ * from a famous fight — Rocky, Street Fighter, Dragon Ball vs Matrix, a western duel, Star Wars, a plain
4
+ * argument that ends in hugs, or the Pulp Fiction twist. The director moves both windows (walkTo/face/fling,
5
+ * see makeOt in main.cjs) and tells each page what to show: { line }, { gesture }, { effect }, { act, dir }.
6
+ */
7
+
8
+ const LINES = {
9
+ es: {
10
+ rocky: ['¡Vamos, campeón!', '¡Esto no es nada!', '¡Adriaaanaaa!'],
11
+ sf: ['¡HADOUKEN!', '¡Aaaay!'],
12
+ dbz: ['Kaaa… meee… haaa… meee…', '¡HAAAAA!', 'Esquivé las balas… digo, el rayo.'],
13
+ duel: ['Este escritorio es muy chico para los dos.', '¡Bang!', '…me diste. Era broma 😂'],
14
+ sw: ['Yo soy tu padre.', '¡Noooooo!', 'Bueno, ¿abrazo?'],
15
+ fight: ['¡Me quitaste mi pixel!', '¡Era MI rincón!', 'Ya, perdón 💕', 'Yo también…'],
16
+ twist: ['¿Bailamos?', '¡Twist!'],
17
+ },
18
+ en: {
19
+ rocky: ['Come on, champ!', "Ain't so bad!", 'Adriaaaan!'],
20
+ sf: ['HADOUKEN!', 'Aaargh!'],
21
+ dbz: ['Kaaa… meee… haaa… meee…', 'HAAAAA!', 'I dodged bullets… I mean, the beam.'],
22
+ duel: ["This desktop ain't big enough for the two of us.", 'Bang!', '…you got me. Kidding 😂'],
23
+ sw: ['I am your father.', 'Nooooo!', 'So… hug?'],
24
+ fight: ['You took my pixel!', 'That was MY corner!', 'Fine, sorry 💕', 'Me too…'],
25
+ twist: ['Shall we dance?', 'Twist!'],
26
+ },
27
+ pt: {
28
+ rocky: ['Vamos, campeão!', 'Isso não é nada!', 'Adriaaanaaa!'],
29
+ sf: ['HADOUKEN!', 'Aaai!'],
30
+ dbz: ['Kaaa… meee… raaa… meee…', 'HAAAAA!', 'Desviei das balas… digo, do raio.'],
31
+ duel: ['Esta área de trabalho é pequena demais pra nós dois.', 'Bang!', '…você me pegou. Brincadeira 😂'],
32
+ sw: ['Eu sou seu pai.', 'Nãããão!', 'Então… abraço?'],
33
+ fight: ['Você pegou meu pixel!', 'Era o MEU canto!', 'Tá, desculpa 💕', 'Eu também…'],
34
+ twist: ['Vamos dançar?', 'Twist!'],
35
+ },
36
+ };
37
+
38
+ // Each scene: a list of [delay ms since the previous step, (a, b, L) => …]. a is on the left, facing right (+1).
39
+ const SCENES = {
40
+ rocky: [
41
+ [0, (a, b) => (a.say({ act: 'guard', dir: 1, ms: 2400 }), b.say({ act: 'guard', dir: -1, ms: 2400 }))],
42
+ [300, (a, b, L) => a.say({ line: L.rocky[0] })],
43
+ [1700, (a, b) => (a.say({ act: 'jab', dir: 1, room: 90 }), setTimeout(() => b.say({ act: 'hit', dir: -1 }), 140))],
44
+ [600, (a, b) => (b.say({ act: 'jab', dir: -1, room: 90 }), setTimeout(() => a.say({ act: 'hit', dir: 1 }), 140))],
45
+ [600, (a, b) => (a.say({ act: 'jab', dir: 1, room: 90 }), setTimeout(() => b.say({ act: 'hit', dir: -1 }), 140))],
46
+ [700, (a, b) => (b.say({ act: 'uppercut', dir: -1, room: 90 }), setTimeout(() => (a.say({ act: 'pow', text: 'POW!', ms: 1200 }), a.fling(-260, 60)), 330))],
47
+ [900, (a) => (a.say({ gesture: 'dizzy' }), a.say({ effect: 'dizzy' }))],
48
+ [1600, (a, b, L) => (b.say({ gesture: 'celebrate' }), b.say({ line: L.rocky[2] }))],
49
+ [2600, (a, b, L) => a.say({ line: L.rocky[1] })],
50
+ ],
51
+ sf: [
52
+ [0, (a) => a.say({ act: 'charge', dir: 1, ms: 1800 })],
53
+ [1500, (a, b, L) => (a.say({ line: L.sf[0] }), a.say({ act: 'hadouken', dir: 1, room: 170 }))],
54
+ [650, (a, b, L) => (b.say({ act: 'hit', dir: -1 }), b.say({ effect: 'dizzy' }), b.fling(220, 50), b.say({ line: L.sf[1] }))],
55
+ [1800, (a) => a.say({ gesture: 'thumbsup' })],
56
+ ],
57
+ dbz: [
58
+ [0, (a, b, L) => (a.say({ line: L.dbz[0], ms: 2600 }), a.say({ act: 'charge', dir: 1, ms: 2600 }))],
59
+ [2400, (a, b, L) => (a.say({ line: L.dbz[1] }), a.say({ act: 'beam', dir: 1, room: 170, ms: 3400 }), b.say({ act: 'lean', dir: -1, room: 170, ms: 3400 }))],
60
+ [3400, (a, b, L) => (b.say({ line: L.dbz[2] }), b.say({ effect: 'sparkles' }))],
61
+ [2200, (a) => (a.say({ gesture: 'shrug' }), a.say({ effect: 'sweat' }))],
62
+ ],
63
+ matrix: [
64
+ [0, (a) => a.say({ act: 'bang', dir: 1, room: 170 })],
65
+ [120, (a, b) => b.say({ act: 'lean', dir: -1, room: 170, ms: 3400 })],
66
+ [3300, (a, b) => (b.say({ effect: 'sparkles' }), a.say({ effect: 'exclaim' }), a.say({ gesture: 'surprise' }))],
67
+ ],
68
+ duel: [
69
+ [0, (a, b, L) => (a.say({ line: L.duel[0] }), a.walkTo(a.pos().x - 80, undefined, 60), b.walkTo(b.pos().x + 80, undefined, 60), a.face(-1), b.face(1))],
70
+ [2400, (a, b) => (a.face(1), b.face(-1))],
71
+ [900, (a, b) => (a.say({ effect: 'sweat' }), b.say({ effect: 'sweat' }))],
72
+ [1200, (a, b, L) => (a.say({ act: 'bang', dir: 1, room: 170 }), a.say({ line: L.duel[1] }))],
73
+ [200, (a, b) => b.say({ act: 'fall', dir: -1, ms: 2800 })],
74
+ [2700, (a, b, L) => (b.say({ line: L.duel[2] }), b.say({ gesture: 'laugh' }))],
75
+ [800, (a) => a.say({ gesture: 'laugh' })],
76
+ ],
77
+ sw: [
78
+ [0, (a, b) => (a.say({ act: 'saber', dir: 1, room: 70, color: '#ff3b3b', ms: 2600 }), b.say({ act: 'saber', dir: -1, room: 70, color: '#44a8ff', ms: 2600 }))],
79
+ [2500, (a, b, L) => a.say({ line: L.sw[0] })],
80
+ [1800, (a, b, L) => (b.say({ line: L.sw[1] }), b.say({ gesture: 'shake' }), b.say({ effect: 'tears' }))],
81
+ [2400, (a, b, L) => (a.say({ line: L.sw[2] }), a.say({ effect: 'hearts' }))],
82
+ ],
83
+ fight: [
84
+ [0, (a, b, L) => (a.say({ line: L.fight[0] }), a.say({ gesture: 'stomp' }), a.say({ effect: 'anger' }))],
85
+ [1800, (a, b, L) => (b.say({ line: L.fight[1] }), b.say({ effect: 'steam' }), b.say({ act: 'shove', dir: -1, room: 60 }))],
86
+ [380, (a) => (a.say({ act: 'hit', dir: 1 }), a.fling(-120, 18, 420))],
87
+ [1200, (a, b) => (a.say({ act: 'shove', dir: 1, room: 60 }), setTimeout(() => b.fling(120, 18, 420), 150))],
88
+ [1400, (a, b) => (a.say({ effect: 'question' }), b.say({ effect: 'question' }))],
89
+ [1300, (a, b, L) => (b.say({ line: L.fight[2] }), b.say({ effect: 'hearts' }))],
90
+ [1600, (a, b, L) => (a.say({ line: L.fight[3] }), a.say({ effect: 'hearts' }), a.walkTo(a.pos().x + 50), b.walkTo(b.pos().x - 50))],
91
+ ],
92
+ twist: [
93
+ [0, (a, b, L) => (a.say({ line: L.twist[0] }), a.say({ gesture: 'bow' }))],
94
+ [1600, (a, b, L) => (b.say({ line: L.twist[1] }), a.say({ act: 'twist', ms: 3600 }), b.say({ act: 'twist', ms: 3600 }), a.say({ effect: 'notes' }), b.say({ effect: 'notes' }))],
95
+ [3500, (a, b) => (a.say({ gesture: 'celebrate' }), b.say({ gesture: 'celebrate' }), a.say({ effect: 'confetti' }))],
96
+ ],
97
+ };
98
+
99
+ const GAP = 170; // how far apart they stand (their windows grow for what flies between them)
100
+
101
+ exports.createDirector = ({ ots, lang = 'en' }) => {
102
+ const L = LINES[lang] || LINES.en;
103
+ let running = false;
104
+ const SOON = process.env.SEVENOTS_PET_SCENES_SOON === '1'; // testing: one scene after another
105
+ let nextAt = Date.now() + (SOON ? 4000 : 25000);
106
+ let last = '';
107
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
108
+ // waits, but gives up as soon as the mouse is on either of them (to click or drag it): true = interrupted
109
+ const pause = async (ms, ...pair) => {
110
+ for (const end = Date.now() + ms; Date.now() < end; await wait(Math.min(80, end - Date.now()))) if (pair.some((o) => o.interrupted())) return true;
111
+ return pair.some((o) => o.interrupted());
112
+ };
113
+
114
+ async function play(x, y) {
115
+ running = true;
116
+ // they take turns: a line from one ends the other's (one speech bubble at a time)
117
+ const turns = (o, other) => ({ ...o, say: (m) => (m?.line && other.say({ hush: true }), o.say(m)) });
118
+ [x, y] = [turns(x, y), turns(y, x)];
119
+ let [a, b] = x.pos().x <= y.pos().x ? [x, y] : [y, x];
120
+ for (const o of [x, y]) o.hold({ w: GAP * 2 + 120, h: 400 }); // room for beams, flings and lines, no resizing mid-scene
121
+ try {
122
+ // meet in the middle, GAP apart, then face each other
123
+ const { minX, maxX } = a.bounds();
124
+ const mid = Math.max(minX + GAP / 2, Math.min(maxX - GAP / 2, (a.pos().x + b.pos().x) / 2));
125
+ const fy = (b.host ? b : a).pos().y; // at the host's height (it may live up the screen)
126
+ a.walkTo(mid - GAP / 2, fy, 150);
127
+ b.walkTo(mid + GAP / 2, fy, 150);
128
+ for (let t = 0; t < 120 && !(a.arrivedAt() && b.arrivedAt()); t++) {
129
+ if (a.interrupted() || b.interrupted()) return;
130
+ await wait(100);
131
+ }
132
+ a.face(1);
133
+ b.face(-1);
134
+ if (await pause(500, a, b)) return;
135
+ const names = Object.keys(SCENES).filter((n) => n !== last);
136
+ const name = process.env.SEVENOTS_PET_SCENE || names[Math.floor(Math.random() * names.length)];
137
+ last = name;
138
+ console.log(`[7ots director] ${name}`);
139
+ // half the time the right one starts (scenes are written for the left one)
140
+ if (Math.random() < 0.5 && !['duel', 'fight'].includes(name)) [a, b] = [mirror(b), mirror(a)];
141
+ for (const [ms, step] of SCENES[name]) {
142
+ if (await pause(ms, a, b)) return;
143
+ step(a, b, L);
144
+ }
145
+ await pause(2500, a, b);
146
+ } finally {
147
+ for (const o of [x, y]) (o.hold(null), o.inScene() && o.release());
148
+ running = false;
149
+ nextAt = Date.now() + (SOON ? 3000 : 90000 + Math.random() * 120000);
150
+ }
151
+ }
152
+
153
+ // the same ot with directions flipped, so a scene can be played from the other side
154
+ function mirror(o) {
155
+ const flip = (m) => (m && typeof m.dir === 'number' ? { ...m, dir: -m.dir } : m);
156
+ return { ...o, say: (m) => o.say(flip(m)), face: (d) => o.face(-d), fling: (vx, ...r) => o.fling(-vx, ...r), walkTo: (px, ...r) => o.walkTo(o.pos().x - (px - o.pos().x), ...r) };
157
+ }
158
+
159
+ setInterval(() => {
160
+ if (running) return;
161
+ const free = [...ots.values()].filter((o) => o.free());
162
+ if (free.length < 2) return;
163
+ // two that wandered close start right away; otherwise now and then
164
+ let pair = null;
165
+ for (let i = 0; i < free.length && !pair; i++)
166
+ for (let j = i + 1; j < free.length && !pair; j++) if (Math.abs(free[i].pos().x - free[j].pos().x) < 220 && Date.now() > nextAt - 30000) pair = [free[i], free[j]];
167
+ if (!pair && Date.now() > nextAt) {
168
+ const s = free.sort(() => Math.random() - 0.5);
169
+ pair = [s[0], s[1]];
170
+ }
171
+ if (pair) play(...pair).catch((e) => console.log('[7ots director]', e.message));
172
+ }, 1000);
173
+
174
+ return { play: (i, j) => play(ots.get(i), ots.get(j)) };
175
+ };