@homie-rocks/studio 0.31.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -1
- package/README.md +10 -6
- package/bin/homie-studio.mjs +71 -6
- package/lib/cloudflare.mjs +80 -6
- package/lib/directory.mjs +61 -9
- package/lib/doctor.mjs +10 -9
- package/lib/mcp-tools.mjs +13 -9
- package/lib/parts.mjs +27 -0
- package/lib/routes.mjs +110 -2
- package/lib/scaffold.mjs +3 -3
- package/lib/template-history.json +40 -0
- package/netplay/NETPLAY.md +138 -10
- package/netplay/netplay.ts +220 -29
- package/package.json +1 -1
- package/parts/PARTS.md +8 -2
- package/port/index.ts +1 -1
- package/port/room.ts +228 -23
- package/starters/ember-vale/src/main.ts +4 -0
- package/starters/gem-rush/src/main.ts +29 -7
- package/starters/gem-rush-3d/src/main.ts +36 -7
- package/starters/hero-rush-3d/src/main.ts +36 -7
- package/worker/pages.mjs +19 -10
- package/worker/parts.mjs +43 -2
- package/worker/version.mjs +1 -1
|
@@ -49,6 +49,13 @@
|
|
|
49
49
|
* byte). A model that is refused or not there is drawn as a stand-in in the same colours: a round never waits on, or
|
|
50
50
|
* breaks for, a model.
|
|
51
51
|
*
|
|
52
|
+
* HEADINGS, AND THERE ARE TWO: a body's facing (`fa`, drawn as `yaw`) is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
|
|
53
|
+
* (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
|
|
54
|
+
* `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
|
|
55
|
+
* package takes a body's facing as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
|
|
56
|
+
* `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
|
|
57
|
+
* `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
|
|
58
|
+
*
|
|
52
59
|
* PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
|
|
53
60
|
* renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
|
|
54
61
|
* disc under each body, coin, tree and rock (a computer draws the sun's real shadows). Repeated things are one
|
|
@@ -70,7 +77,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
|
|
70
77
|
import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
|
|
71
78
|
// Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
|
|
72
79
|
import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
|
|
73
|
-
import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
80
|
+
import { createNetplay, guardGestures, Roster, q, lerp, capMove, skillPreset, PALETTE, AI_MARK, type Peer, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
74
81
|
// The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
|
|
75
82
|
// labels that never pile up (port/view.ts).
|
|
76
83
|
import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
@@ -246,9 +253,12 @@ function newZone(n: number): Zone {
|
|
|
246
253
|
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
247
254
|
|
|
248
255
|
/** A round starts with every hero on its spawn ring facing the camera (fa 0): a line-up the hero intro opens on. */
|
|
249
|
-
function startRound(n: number): void {
|
|
256
|
+
function startRound(n: number, rollover = false): void {
|
|
250
257
|
const now = net.now();
|
|
251
258
|
roster.trim();
|
|
259
|
+
// The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
|
|
260
|
+
// here), so whoever was seated with no body is given one now, before the bodies are placed.
|
|
261
|
+
if (rollover && !net.offline) seatWaiting();
|
|
252
262
|
syncBodiesFromRoster();
|
|
253
263
|
let i = 0;
|
|
254
264
|
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
@@ -369,16 +379,31 @@ net.on('role', (e) => {
|
|
|
369
379
|
if (e.role === 'host') becomeHost(e);
|
|
370
380
|
else { hosting = false; }
|
|
371
381
|
});
|
|
372
|
-
|
|
373
|
-
|
|
382
|
+
/** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
|
|
383
|
+
function giveBody(p: Peer): boolean {
|
|
384
|
+
if (p.seat === null) return false;
|
|
374
385
|
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
375
386
|
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
376
|
-
if (!c) return;
|
|
387
|
+
if (!c) return false;
|
|
377
388
|
welcome(p.seat);
|
|
378
389
|
syncBodiesFromRoster();
|
|
379
390
|
const b = bodies.get(c.slot.slot);
|
|
380
391
|
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
381
392
|
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
|
|
397
|
+
* found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
|
|
398
|
+
* rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
|
|
399
|
+
*/
|
|
400
|
+
function seatWaiting(): boolean {
|
|
401
|
+
let any = false;
|
|
402
|
+
for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
|
|
403
|
+
return any;
|
|
404
|
+
}
|
|
405
|
+
net.on('join', (p) => {
|
|
406
|
+
if (!hosting || !giveBody(p)) return;
|
|
382
407
|
publishRoster();
|
|
383
408
|
net.snapshot(buildSnap(), tick, true);
|
|
384
409
|
});
|
|
@@ -387,7 +412,10 @@ net.on('leave', (p) => {
|
|
|
387
412
|
roster.release(p.seat); // their body stays, driven by a bot
|
|
388
413
|
firstRound.delete(p.seat); // whoever sits in this seat next is new here
|
|
389
414
|
syncBodiesFromRoster();
|
|
415
|
+
// A body just went to a bot: somebody seated with none takes it now.
|
|
416
|
+
const seated = seatWaiting();
|
|
390
417
|
publishRoster();
|
|
418
|
+
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
391
419
|
});
|
|
392
420
|
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
393
421
|
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
@@ -889,7 +917,7 @@ function stepHost(dt: number): void {
|
|
|
889
917
|
}
|
|
890
918
|
}
|
|
891
919
|
if (now >= round.endsAt) endRound();
|
|
892
|
-
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
920
|
+
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
893
921
|
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
894
922
|
}
|
|
895
923
|
|
|
@@ -2482,7 +2510,8 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
|
|
|
2482
2510
|
ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
|
|
2483
2511
|
});
|
|
2484
2512
|
}
|
|
2485
|
-
|
|
2513
|
+
// Only a browser that WAS in its room is reconnecting; one still joining, or stopped for good, is not (section 22).
|
|
2514
|
+
if (net.link === 'reconnecting' && net.role !== 'host') {
|
|
2486
2515
|
ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
|
|
2487
2516
|
const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
|
|
2488
2517
|
chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
|
package/worker/pages.mjs
CHANGED
|
@@ -455,6 +455,9 @@ const SHELL_JS = String.raw`(function () {
|
|
|
455
455
|
if (m.what === 'attached') { state.attached = true; lastRects = ''; tellRects(); }
|
|
456
456
|
// The link (section 22): in the room, knocking again, or playing alone because the room never answered.
|
|
457
457
|
if (m.what === 'link' && typeof m.state === 'string') state.link = { state: m.state, why: String(m.why || ''), hosting: m.hosting === true, at: Date.now() };
|
|
458
|
+
// Every seat is taken and this browser asked to play (the room's own word, in its welcome): it watches until one
|
|
459
|
+
// frees up, and the chip says so. Not full any more once it has been seated, or plays by itself.
|
|
460
|
+
if (m.what === 'full') state.full = m.full === true;
|
|
458
461
|
// The game's own word that it was playable (section 21), beside who lifted the card: a late one is a game whose
|
|
459
462
|
// arrival should be 'game'. A performance probe reads both here.
|
|
460
463
|
if (m.what === 'ready') {
|
|
@@ -469,11 +472,13 @@ const SHELL_JS = String.raw`(function () {
|
|
|
469
472
|
if (m.final === true || screenMode) reloadGame();
|
|
470
473
|
else flash('A new version of ' + boot.name + ' is ready: it loads after this round.');
|
|
471
474
|
}
|
|
472
|
-
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
475
|
+
if (m.what === 'token' && typeof m.token === 'string') { state.seat = m.seat; if (typeof m.seat === 'number') state.full = false; try { sessionStorage.setItem(KEY, m.token); } catch (e) {} if (window.__homieChat) window.__homieChat.seat(); }
|
|
473
476
|
if (m.what === 'stats') state.stats = m.stats;
|
|
474
477
|
if (m.what === 'round') { onRound(m.round); if (state.stale && m.round && m.round.phase === 'over') reloadGame(); }
|
|
475
478
|
if (m.what === 'roster') state.roster = m.slots;
|
|
476
|
-
|
|
479
|
+
// Stopped for good. Removed, or the room closed: the notice. A room with no place left at all: the same notice
|
|
480
|
+
// with its own words, because "try another room" is the only thing a player can do about it.
|
|
481
|
+
if (m.what === 'closed') { state.closed = m.why; if (m.why === 'kicked' || m.why === 'room-closed' || m.why === 'agents-off') notice(m.why === 'kicked' ? 'kicked' : 'closed', m); else if (m.why === 'room-full' || m.why === 'too-many') notice('full', m); }
|
|
477
482
|
// A game's net.pickPlayer(seat): only an owner's page listens (its overlay opens that player's card).
|
|
478
483
|
if (m.what === 'pick' && (m.seat === null || typeof m.seat === 'number')) { try { window.dispatchEvent(new CustomEvent('homie-pick', { detail: { seat: m.seat } })); } catch (e) {} }
|
|
479
484
|
paint();
|
|
@@ -520,8 +525,10 @@ const SHELL_JS = String.raw`(function () {
|
|
|
520
525
|
var next = {};
|
|
521
526
|
Object.keys(all).forEach(function (x) { if (x !== k) next[x] = all[x]; });
|
|
522
527
|
if (m.op === 'set' && m.v !== null && m.v !== undefined) next[k] = m.v;
|
|
523
|
-
|
|
524
|
-
|
|
528
|
+
// A NaN or an Infinity would be written as null and read back as a setting (a volume of null plays as 0):
|
|
529
|
+
// refused, whatever helper sent it.
|
|
530
|
+
var how = m.op === 'set' && typeof m.v === 'number' && !isFinite(m.v) ? 'value' : prefsWrite(next);
|
|
531
|
+
if (how === 'too-large' || how === 'value') out.why = how; else { out.ok = true; out.kept = how; }
|
|
525
532
|
} else out.why = 'op';
|
|
526
533
|
} catch (e) { out.why = 'error'; }
|
|
527
534
|
state.prefs = { op: m.op, ok: out.ok, why: out.why || null };
|
|
@@ -656,8 +663,8 @@ const SHELL_JS = String.raw`(function () {
|
|
|
656
663
|
var label = labelOf(state.room || '');
|
|
657
664
|
var box = document.createElement('div'); box.className = 'notice'; box.setAttribute('data-notice', kind); box.setAttribute('role', 'alertdialog'); box.setAttribute('data-keep-focus', '');
|
|
658
665
|
var inner = document.createElement('div'); inner.className = 'box';
|
|
659
|
-
var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : 'This room is closed';
|
|
660
|
-
var p = document.createElement('p'); p.textContent = state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
|
|
666
|
+
var h = document.createElement('h1'); h.textContent = kind === 'kicked' ? 'You were removed from this room' : kind === 'full' ? 'This room is full' : 'This room is closed';
|
|
667
|
+
var p = document.createElement('p'); p.textContent = kind === 'full' ? label + ' has no place left, to play or to watch. Another room is one tap away.' : state.notice.message || (kind === 'kicked' ? 'The studio removed you from this room.' : 'The studio closed this room. Thanks for playing!');
|
|
661
668
|
var w = document.createElement('p'); w.className = 'when';
|
|
662
669
|
w.textContent = mins ? (kind === 'kicked' ? 'You can come back to ' + label + ' in ' + mins + ' min.' : label + ' opens again in ' + mins + ' min.') : '';
|
|
663
670
|
var acts = document.createElement('div'); acts.className = 'acts';
|
|
@@ -666,7 +673,7 @@ const SHELL_JS = String.raw`(function () {
|
|
|
666
673
|
acts.append(other, back);
|
|
667
674
|
inner.append(h, p, w, acts); box.appendChild(inner);
|
|
668
675
|
document.body.appendChild(box);
|
|
669
|
-
say(kind === 'kicked' ? 'removed from this room' : 'room closed');
|
|
676
|
+
say(kind === 'kicked' ? 'removed from this room' : kind === 'full' ? 'room full' : 'room closed');
|
|
670
677
|
}
|
|
671
678
|
|
|
672
679
|
function paint() {
|
|
@@ -680,10 +687,12 @@ const SHELL_JS = String.raw`(function () {
|
|
|
680
687
|
if (aiN) bits.push(aiN + ' AI');
|
|
681
688
|
if (f && f.counts && f.counts.watchers) bits.push(f.counts.watchers + ' watching');
|
|
682
689
|
if (state.full) bits.push('waiting for a seat');
|
|
683
|
-
|
|
684
|
-
|
|
690
|
+
// Stopped for good: nothing is knocking any more, so it never says "reconnecting". What is true, in a word.
|
|
691
|
+
if (state.closed) bits.push({ replaced: 'opened in another tab', 'room-full': 'room full', 'too-many': 'room full', stale: 'updating…', kicked: 'removed from this room', 'room-closed': 'room closed', 'agents-off': 'room closed' }[state.closed] || 'disconnected: reload to play');
|
|
692
|
+
// The link (section 22): cut off from a room it was in and knocking again ("reconnecting" is true only then), or
|
|
693
|
+
// playing by itself because the room never answered, or did not come back in time.
|
|
685
694
|
else if (state.link && state.link.state === 'reconnecting') bits.push('reconnecting');
|
|
686
|
-
else if (state.link && state.link.state === 'alone') bits.push('
|
|
695
|
+
else if (state.link && state.link.state === 'alone') bits.push(state.link.why === 'reconnect-timeout' ? 'playing on your own: the room dropped' : 'playing on your own: looking for the room');
|
|
687
696
|
// The arrival card's line: the room and who is in it ("Room 2 · 3 playing · 2 AI").
|
|
688
697
|
if (!A.done && state.room) A.facts([labelOf(state.room), n ? n + ' playing' : '', aiN ? aiN + ' AI' : ''].filter(Boolean).join(' · '));
|
|
689
698
|
say(bits.join(' · ') || 'joining…');
|
package/worker/parts.mjs
CHANGED
|
@@ -112,7 +112,13 @@ const CSS = `<style>
|
|
|
112
112
|
.pcard h3{margin:0;font:800 19px/1.2 var(--display)}
|
|
113
113
|
.pcard p{margin:0;color:var(--dim);font-size:14px}
|
|
114
114
|
.ptag{font:600 11px/1.4 var(--mono);letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}
|
|
115
|
-
.pframe{width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
|
|
115
|
+
.pframe{display:block;width:100%;aspect-ratio:16/9;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;background:#000}
|
|
116
|
+
.pview{container-type:inline-size}
|
|
117
|
+
.pview .pframe{aspect-ratio:var(--pv,16/9)}
|
|
118
|
+
@container (max-width:599.98px){.pview .pframe{min-height:min(320px,70vh);min-height:min(320px,70svh)}.pview.pview-own .pframe{aspect-ratio:var(--pvn,var(--pv,16/9));min-height:0}}
|
|
119
|
+
.popen{display:inline-flex;align-items:center;gap:8px;min-height:44px;margin:8px 0 0;color:inherit;font-weight:600;text-decoration:underline;text-underline-offset:3px}
|
|
120
|
+
.popen svg{width:16px;height:16px}
|
|
121
|
+
.popen:focus-visible{outline:3px solid var(--fg);outline-offset:3px;border-radius:6px}
|
|
116
122
|
.pfacts{display:grid;grid-template-columns:max-content 1fr;gap:8px 20px;margin:0}
|
|
117
123
|
.pfacts dt{color:var(--dim);font-size:14px}.pfacts dd{margin:0;overflow-wrap:anywhere}
|
|
118
124
|
.pband{max-width:calc(1180px + 2 * var(--gutter));margin:0 auto;padding:clamp(40px,6vw,80px) var(--gutter)}
|
|
@@ -165,10 +171,45 @@ function costLine(c) {
|
|
|
165
171
|
return `${bits.join(' · ')}${c.measuredOn ? ` (measured on ${String(c.measuredOn).slice(0, 80)})` : ''}`;
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
/**
|
|
175
|
+
* A preview's declared shape ("4:3") as two whole numbers, or null. The same reading as previewAspect() in
|
|
176
|
+
* lib/parts.mjs (a Worker cannot import that file: it reads the disk); test/parts.test.mjs holds the two together.
|
|
177
|
+
* The page's CSS is built from these NUMBERS, never from the text in part.json, so nothing a part declares can
|
|
178
|
+
* write CSS or HTML of its own.
|
|
179
|
+
*/
|
|
180
|
+
export function aspectOf(value) {
|
|
181
|
+
const m = typeof value === 'string' ? /^([1-9]\d?):([1-9]\d?)$/.exec(value) : null;
|
|
182
|
+
if (!m) return null;
|
|
183
|
+
const w = Number(m[1]); const h = Number(m[2]);
|
|
184
|
+
return w > 32 || h > 32 || w > 3 * h || h > 2 * w ? null : { w, h };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The part's interactive preview: its frame in the shape the part declares, and a plain link out of the frame.
|
|
189
|
+
*
|
|
190
|
+
* THE SHAPE. `preview.aspect` (default 16:9), and `preview.phoneAspect` when the frame is under 600 px wide. With no
|
|
191
|
+
* phone shape declared a narrow frame still gets a minimum height: 16:9 across a 390 px phone is about 197 px,
|
|
192
|
+
* which hides whatever a preview draws under its heading and controls.
|
|
193
|
+
*
|
|
194
|
+
* THE LINK opens the same preview file in a tab of its own, at the size of the screen. That file is this studio's
|
|
195
|
+
* own packed part on this studio's own address, and it is answered with `content-security-policy: sandbox
|
|
196
|
+
* allow-scripts allow-pointer-lock` (above), so a tab of its own gives it nothing the frame did not: no origin, so
|
|
197
|
+
* none of the site's cookies or storage, no forms, no pop-ups, no navigating the page that opened it (and
|
|
198
|
+
* rel="noopener" gives it no handle on that page either). It is a link, so it needs no script and a keyboard reaches it.
|
|
199
|
+
*/
|
|
200
|
+
function previewOf(p) {
|
|
201
|
+
const src = esc(`${p.url}${p.preview.page}`);
|
|
202
|
+
const shape = aspectOf(p.preview.aspect) ?? { w: 16, h: 9 };
|
|
203
|
+
const phone = aspectOf(p.preview.phoneAspect);
|
|
204
|
+
const vars = `--pv:${Number(shape.w)}/${Number(shape.h)}${phone ? `;--pvn:${Number(phone.w)}/${Number(phone.h)}` : ''}`;
|
|
205
|
+
return `<div class="pview${phone ? ' pview-own' : ''}" style="${vars}"><iframe class="pframe" title="${esc(p.name)}: try it" src="${src}" sandbox="allow-scripts allow-pointer-lock" loading="lazy" referrerpolicy="no-referrer"></iframe></div>
|
|
206
|
+
<a class="popen" href="${src}" target="_blank" rel="noopener noreferrer">${icon('arrow')}<span>Open the preview in its own tab</span></a>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
168
209
|
export function partPage(cat, p, url) {
|
|
169
210
|
const name = studioOf(cat);
|
|
170
211
|
const preview = p.preview?.page
|
|
171
|
-
?
|
|
212
|
+
? previewOf(p)
|
|
172
213
|
: p.preview?.image ? `<img class="pframe" src="${esc(`${p.url}${p.preview.image}`)}" alt="${esc(p.name)}">` : '';
|
|
173
214
|
const facts = [
|
|
174
215
|
['Kind', esc(kindOf(p))],
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.31.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.31.1';
|