@bitmagic/cli 0.1.25 → 0.1.26

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.
@@ -108,7 +108,41 @@ export function renderEditorShell(options) {
108
108
  .banner.show { display: flex; }
109
109
  #terrain-banner, #build-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
110
110
  #build-banner code { font-family: ui-monospace, monospace; }
111
- #frame { display: block; flex: 1; width: 100%; border: 0; min-height: 0; }
111
+ /* The game and the panel share one row, so the panel takes width from the game rather than
112
+ overlaying it — an overlay would cover the thing you opened the panel to watch. */
113
+ #stage { flex: 1; display: flex; min-height: 0; }
114
+ #frame { display: block; flex: 1; border: 0; min-width: 0; height: 100%; }
115
+ #assets { width: 264px; flex: none; overflow-y: auto; background: #131318;
116
+ border-left: 1px solid #26262e; }
117
+ body.assets-closed #assets { display: none; }
118
+ #assets-head { position: sticky; top: 0; display: flex; align-items: center; gap: 8px;
119
+ padding: 8px 10px; background: #17171d; border-bottom: 1px solid #26262e;
120
+ font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: #8a8a99; }
121
+ #assets-head .count { margin-left: auto; color: #6a6a7a; letter-spacing: 0; }
122
+ #assets-empty { padding: 14px 10px; color: #6a6a7a; }
123
+ .row { display: flex; gap: 9px; padding: 8px 10px; border-bottom: 1px solid #1e1e26; }
124
+ .row .thumb { width: 46px; height: 46px; flex: none; border-radius: 4px; background: #1e1e26;
125
+ display: flex; align-items: center; justify-content: center; font-size: 18px;
126
+ color: #55556a; overflow: hidden; }
127
+ .row .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
128
+ .row .body { min-width: 0; flex: 1; }
129
+ .row .name { color: #e6e6ea; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
130
+ .row .meta { color: #6a6a7a; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
131
+ white-space: nowrap; }
132
+ .row .chip { display: inline-block; margin-left: 5px; padding: 0 5px; border-radius: 3px;
133
+ font-size: 10px; background: #2a2a34; color: #9a9aab; vertical-align: 1px; }
134
+ .row .chip.hq { background: #1f3a2a; color: #8fd0a4; }
135
+ .row .chip.placeholder { background: #3a3320; color: #d9c08a; }
136
+ /* A new asset during a forge is the whole show, so it announces itself and then settles. */
137
+ .row.fresh { animation: landed 2.4s ease-out; }
138
+ @keyframes landed { from { background: #22314f; } to { background: transparent; } }
139
+ .row.job .thumb { color: #d9a441; }
140
+ .row.job .message { color: #9a9aab; font-size: 11px; overflow: hidden; text-overflow: ellipsis;
141
+ white-space: nowrap; }
142
+ .row.job.failed .thumb { color: #e0564f; }
143
+ .row.job.failed .message { color: #e0938e; white-space: normal; }
144
+ .spin { display: inline-block; animation: spin 1.4s linear infinite; }
145
+ @keyframes spin { to { transform: rotate(360deg); } }
112
146
  #hq-overlay { display: none; position: fixed; inset: 0; background: rgba(8,8,12,.72);
113
147
  align-items: center; justify-content: center; z-index: 10; }
114
148
  #hq-overlay.show { display: flex; }
@@ -139,6 +173,7 @@ export function renderEditorShell(options) {
139
173
  <input id="auto-toggle" type="checkbox" checked> Auto-reload
140
174
  </label>
141
175
  <span id="status"><span id="dot"></span><span id="status-text">Loading…</span></span>
176
+ <button id="assets-toggle" type="button" title="Show the assets in this project and what is being generated">Assets</button>
142
177
  <button id="shot" type="button" title="Copy the current frame to the clipboard">Screenshot</button>
143
178
  <button id="reload" type="button">Reload</button>
144
179
  </div>
@@ -156,7 +191,15 @@ export function renderEditorShell(options) {
156
191
  voxel changes will be lost. Discard them, or press Cancel in the terrain toolbar.</span>
157
192
  <button id="terrain-discard" type="button">Discard terrain edits</button>
158
193
  </div>
159
- <iframe id="frame" allow="autoplay; fullscreen; xr-spatial-tracking; clipboard-write"></iframe>
194
+ <div id="stage">
195
+ <iframe id="frame" allow="autoplay; fullscreen; xr-spatial-tracking; clipboard-write"></iframe>
196
+ <aside id="assets">
197
+ <div id="assets-head">Assets<span class="count" id="assets-count"></span></div>
198
+ <div id="assets-jobs"></div>
199
+ <div id="assets-list"></div>
200
+ <div id="assets-empty">Nothing yet. Assets appear here as they are generated.</div>
201
+ </aside>
202
+ </div>
160
203
  <div id="hq-overlay">
161
204
  <div id="hq-dialog">
162
205
  <h2>Generate a high-quality <span id="hq-name"></span></h2>
@@ -181,6 +224,7 @@ export function renderEditorShell(options) {
181
224
  var ACTIVE_EDIT_MS = ${ACTIVE_EDIT_WINDOW_MS};
182
225
  var TAB_KEY = 'bitmagic.dev.tab';
183
226
  var AUTO_KEY = 'bitmagic.dev.autoReload';
227
+ var ASSETS_KEY = 'bitmagic.dev.assets';
184
228
 
185
229
  var frame = document.getElementById('frame');
186
230
  var dot = document.getElementById('dot');
@@ -282,9 +326,8 @@ export function renderEditorShell(options) {
282
326
  // Re-read rather than serving the copy captured at boot: an agent may have generated an
283
327
  // asset since this page loaded, and a stale list is an empty asset palette with no
284
328
  // explanation.
285
- fetch('/api/game-data').then(function (r) { return r.json(); }).then(function (payload) {
286
- var data = payload && payload.gameData;
287
- postFlat({ type: 'ASSETS_RESPONSE', assets: (data && data.assets) || [] });
329
+ fetch('/api/assets').then(function (r) { return r.json(); }).then(function (payload) {
330
+ postFlat({ type: 'ASSETS_RESPONSE', assets: withThumbnails(payload.assets || [], payload.previews || []) });
288
331
  }).catch(function () {
289
332
  postFlat({ type: 'ASSETS_RESPONSE', assets: [] });
290
333
  });
@@ -517,6 +560,9 @@ export function renderEditorShell(options) {
517
560
  loaded = false;
518
561
  markersDirty = false;
519
562
  gameState = '';
563
+ // A fresh iframe is a fresh engine with no memory of what was glowing, so the next push must
564
+ // not be suppressed as a repeat of what the PREVIOUS one was told.
565
+ lastGlowing = '';
520
566
  banner.classList.remove('show');
521
567
  terrainBanner.classList.remove('show');
522
568
  setStatus('', 'Loading…');
@@ -559,6 +605,10 @@ export function renderEditorShell(options) {
559
605
 
560
606
  loaded = true;
561
607
  applyTab(activeTab);
608
+ // A boot almost always follows a world.json change, so the list has moved with it. Deliberately
609
+ // not resetting the seen-ids set: the asset that caused the reload is exactly the one worth
610
+ // flashing.
611
+ void refreshAssets();
562
612
  }
563
613
 
564
614
  async function flush() {
@@ -660,6 +710,347 @@ export function renderEditorShell(options) {
660
710
  start();
661
711
  }
662
712
 
713
+ /**
714
+ * The assets panel: what this project HAS, and what is being made for it right now.
715
+ *
716
+ * The second half is the reason it exists. Generating a game is mostly waiting — a forge bakes a
717
+ * level over twenty minutes, a prop takes a few — and during all of it the world on screen has
718
+ * not changed yet, so the dev view used to look identical to an idle one. The jobs come from
719
+ * '.bitmagic/jobs/', which every generate command writes, so a 'bitmagic generate' running in the
720
+ * creator's OTHER window shows up here with its live progress line.
721
+ *
722
+ * Fed by the sidecar's 'assets' SSE event (a bare "the list moved" ping — the panel re-fetches
723
+ * rather than the server diffing for it) with a slow poll behind it, because elapsed times have
724
+ * to tick even when nothing changed.
725
+ */
726
+ var assetsOpen = true;
727
+ var assetsList = document.getElementById('assets-list');
728
+ var assetsJobs = document.getElementById('assets-jobs');
729
+ /** This page's own origin, for the absolute URL the game's asset palette needs. */
730
+ var SHELL_ORIGIN = window.location.origin;
731
+ var assetsEmpty = document.getElementById('assets-empty');
732
+ var assetsCount = document.getElementById('assets-count');
733
+ var assetsToggle = document.getElementById('assets-toggle');
734
+ // Ids rendered on the previous pass, so an asset that has just landed can announce itself. Null
735
+ // until the first render: on a cold open EVERY asset is new, and flashing all of them says
736
+ // nothing.
737
+ var seenAssetIds = null;
738
+ var lastJobs = [];
739
+ var assetsTimer = null;
740
+
741
+ var TYPE_GLYPHS = {
742
+ image: '🖼', skybox: '🖼',
743
+ glb: '🧊', gltf: '🧊', 'polygon-mesh': '🧊', vxl: '🧊', voxels: '🧊',
744
+ animation: '🏃', audio: '🔊', sound: '🔊', 'sound-effect': '🔊',
745
+ vwld: '🗺', spz: '✨'
746
+ };
747
+
748
+ function glyphFor(type) {
749
+ return TYPE_GLYPHS[String(type || '').toLowerCase()] || '📦';
750
+ }
751
+
752
+ function formatSize(bytes) {
753
+ if (typeof bytes !== 'number' || !isFinite(bytes) || bytes <= 0) return '';
754
+ if (bytes < 1024) return bytes + ' B';
755
+ if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB';
756
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
757
+ }
758
+
759
+ function formatElapsed(since) {
760
+ var ms = Date.now() - Date.parse(since);
761
+ if (!isFinite(ms) || ms < 0) return '';
762
+ var seconds = Math.floor(ms / 1000);
763
+ if (seconds < 60) return seconds + 's';
764
+ return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
765
+ }
766
+
767
+ /** When an asset was made, or 0 for the ones that came with the template. */
768
+ function madeAt(asset) {
769
+ var stamp = asset.hqGeneratedAt || (asset.production && asset.production.at);
770
+ var parsed = typeof stamp === 'string' ? Date.parse(stamp) : NaN;
771
+ return isFinite(parsed) ? parsed : 0;
772
+ }
773
+
774
+ /**
775
+ * Newest first, but only among assets that know when they were made.
776
+ *
777
+ * A forged world arrives with hundreds of undated assets and no meaningful order beyond the one
778
+ * world.json already gives them, so they keep it. The handful generated during this session are
779
+ * the ones the creator is looking for, and they go on top.
780
+ */
781
+ function sortAssets(assets) {
782
+ var dated = [];
783
+ var undated = [];
784
+ for (var i = 0; i < assets.length; i++) {
785
+ (madeAt(assets[i]) > 0 ? dated : undated).push(assets[i]);
786
+ }
787
+ dated.sort(function (a, b) { return madeAt(b) - madeAt(a); });
788
+ return dated.concat(undated);
789
+ }
790
+
791
+ function el(tag, className, text) {
792
+ var node = document.createElement(tag);
793
+ if (className) node.className = className;
794
+ // textContent throughout: names, prompts and error strings are creator and model input, and
795
+ // this panel is not a place to start interpreting markup found in them.
796
+ if (text !== undefined) node.textContent = text;
797
+ return node;
798
+ }
799
+
800
+ function jobRow(job) {
801
+ var row = el('div', 'row job' + (job.status === 'failed' ? ' failed' : ''));
802
+ var thumb = el('div', 'thumb');
803
+ thumb.appendChild(el('span', job.status === 'failed' ? '' : 'spin', job.status === 'failed' ? '!' : '⟳'));
804
+ row.appendChild(thumb);
805
+ var body = el('div', 'body');
806
+ body.appendChild(el('div', 'name', job.label || job.kind));
807
+ var meta = el('div', 'meta', job.kind + (job.status === 'failed' ? '' : ' · ' + formatElapsed(job.startedAt)));
808
+ body.appendChild(meta);
809
+ body.appendChild(el('div', 'message', job.status === 'failed' ? (job.error || 'Failed') : (job.message || '')));
810
+ row.appendChild(body);
811
+ return row;
812
+ }
813
+
814
+ /**
815
+ * The URL the engine should draw a thumbnail from, or null when there is nothing to draw.
816
+ *
817
+ * 'sourceGlbUrl' before 'url' because most CLI-lane meshes are voxelized: 'url' is then a .vxl,
818
+ * which the GLB preview renderer cannot read, while 'sourceGlbUrl' is the original the forger or
819
+ * the generator started from. An asset with neither — a sound, an animation, a level — keeps its
820
+ * glyph, which is the honest answer for something with no appearance.
821
+ */
822
+ function previewSourceFor(asset) {
823
+ var url = asset.sourceGlbUrl || asset.url;
824
+ if (typeof url !== 'string' || url === '') return null;
825
+ // Double-escaped because this whole page is a template literal: a single backslash is consumed
826
+ // by the literal, and the browser would receive the invalid regex /.(glb|gltf)(?|$)/.
827
+ return /\\.(glb|gltf)(\\?|$)/i.test(url) ? url : null;
828
+ }
829
+
830
+ function assetRow(asset, isFresh, hasPreview) {
831
+ var row = el('div', 'row' + (isFresh ? ' fresh' : ''));
832
+ var thumb = el('div', 'thumb');
833
+ var type = String(asset.type || '').toLowerCase();
834
+ if (type === 'image' && typeof asset.url === 'string' && asset.url !== '') {
835
+ // An image asset IS its own thumbnail — skyboxes and cover art included. Nothing to render.
836
+ var direct = document.createElement('img');
837
+ direct.src = asset.url;
838
+ direct.alt = '';
839
+ thumb.appendChild(direct);
840
+ } else if (hasPreview) {
841
+ var cached = document.createElement('img');
842
+ cached.src = '/api/assets/preview/' + encodeURIComponent(asset.id) + '.png';
843
+ cached.alt = '';
844
+ // A cached file that has since been deleted falls back rather than showing a broken image.
845
+ cached.onerror = function () { thumb.textContent = glyphFor(asset.type); };
846
+ thumb.appendChild(cached);
847
+ } else {
848
+ thumb.appendChild(el('span', '', glyphFor(asset.type)));
849
+ if (previewSourceFor(asset) !== null) queuePreview(asset);
850
+ }
851
+ row.appendChild(thumb);
852
+
853
+ var body = el('div', 'body');
854
+ var name = el('div', 'name', asset.name || asset.id || 'unnamed');
855
+ if (asset.hqGeneratedAt) name.appendChild(el('span', 'chip hq', 'HQ'));
856
+ else if (asset.placeholder === true) name.appendChild(el('span', 'chip placeholder', 'placeholder'));
857
+ body.appendChild(name);
858
+ var bits = [asset.type || 'unknown'];
859
+ var size = formatSize(asset.size);
860
+ if (size) bits.push(size);
861
+ body.appendChild(el('div', 'meta', bits.join(' · ')));
862
+ row.appendChild(body);
863
+ return row;
864
+ }
865
+
866
+ /**
867
+ * Jobs and assets render separately, and that split is not cosmetic.
868
+ *
869
+ * The job rows carry an elapsed time, so they have to be redrawn every second whether or not
870
+ * anything changed. The asset rows must NOT be: they hold <img> elements, a forged world has
871
+ * hundreds of them, and rebuilding that list once a second would refetch and visibly flicker
872
+ * every thumbnail for as long as a generation runs — which is precisely when the creator is
873
+ * looking at it. So the asset list is rebuilt only when the set it is showing actually moved.
874
+ */
875
+ var lastAssetSignature = null;
876
+
877
+ function renderAssets(assets, jobs, previews) {
878
+ var sorted = sortAssets(assets);
879
+ var cached = {};
880
+ for (var p = 0; p < previews.length; p++) cached[previews[p]] = true;
881
+
882
+ assetsJobs.textContent = '';
883
+ for (var j = 0; j < jobs.length; j++) assetsJobs.appendChild(jobRow(jobs[j]));
884
+
885
+ var ids = {};
886
+ var signature = [];
887
+ for (var i = 0; i < sorted.length; i++) {
888
+ ids[sorted[i].id] = true;
889
+ // The preview state is part of the signature: a thumbnail landing is a change worth redrawing
890
+ // for, and it is the only one that alters a row without altering the asset set.
891
+ signature.push(sorted[i].id + (cached[sorted[i].id] ? '*' : ''));
892
+ }
893
+ var joined = signature.join(',');
894
+
895
+ if (joined !== lastAssetSignature) {
896
+ assetsList.textContent = '';
897
+ for (var k = 0; k < sorted.length; k++) {
898
+ var fresh = seenAssetIds !== null && !seenAssetIds[sorted[k].id];
899
+ assetsList.appendChild(assetRow(sorted[k], fresh, cached[sorted[k].id] === true));
900
+ }
901
+ seenAssetIds = ids;
902
+ lastAssetSignature = joined;
903
+ }
904
+
905
+ lastJobs = jobs;
906
+ if (loaded) pushGeneratingAssets(jobs);
907
+ assetsCount.textContent = String(sorted.length);
908
+ assetsEmpty.style.display = (sorted.length === 0 && jobs.length === 0) ? 'block' : 'none';
909
+ }
910
+
911
+ /**
912
+ * Ask the game to draw the thumbnails the cache is missing — one at a time, and never twice for
913
+ * the same asset in one session.
914
+ *
915
+ * Serial on purpose. Each render is a GLB fetch and a draw in the SAME renderer the creator is
916
+ * playing in, and a forged city has hundreds of assets: firing them all at once would stutter the
917
+ * game to decorate a side panel. 'attempted' covers the failures too, so an asset whose GLB 404s
918
+ * is asked for once rather than on every render pass forever.
919
+ */
920
+ var previewQueue = [];
921
+ var previewAttempted = {};
922
+ var previewRunning = false;
923
+ var previewRequestId = 0;
924
+
925
+ function queuePreview(asset) {
926
+ if (previewAttempted[asset.id]) return;
927
+ previewAttempted[asset.id] = true;
928
+ previewQueue.push(asset);
929
+ void drainPreviewQueue();
930
+ }
931
+
932
+ async function drainPreviewQueue() {
933
+ if (previewRunning) return;
934
+ previewRunning = true;
935
+ var stored = 0;
936
+ try {
937
+ while (previewQueue.length > 0) {
938
+ // The engine can only draw once it exists. Anything queued before then waits for the next
939
+ // pass rather than being dropped — refreshAssets re-queues on every render.
940
+ if (!loaded) return;
941
+ var asset = previewQueue.shift();
942
+ var source = previewSourceFor(asset);
943
+ if (source === null) continue;
944
+
945
+ previewRequestId += 1;
946
+ var requestId = 'preview-' + previewRequestId;
947
+ postFlat({ type: 'GENERATE_GLB_PREVIEW', requestId: requestId, glbUrl: source });
948
+ var response = await await_('GLB_PREVIEW_RESPONSE', 15000);
949
+ // Verified rather than assumed: a late answer from a request that already timed out would
950
+ // otherwise be cached under the NEXT asset's id, which is a wrong picture rather than a
951
+ // missing one — much the worse failure.
952
+ if (!response || response.requestId !== requestId) continue;
953
+ if (!response.success || !response.previewBase64) continue;
954
+
955
+ var saved = await (await fetch('/api/assets/preview', {
956
+ method: 'POST',
957
+ headers: { 'Content-Type': 'application/json' },
958
+ body: JSON.stringify({ assetId: asset.id, image: response.previewBase64 })
959
+ })).json();
960
+ if (saved.stored === true) stored += 1;
961
+ }
962
+ } catch (error) {
963
+ // A thumbnail is a decoration; losing the queue must not take the panel with it.
964
+ } finally {
965
+ previewRunning = false;
966
+ }
967
+ // Only when something actually landed. refreshAssets kicks this queue in turn, so an
968
+ // unconditional re-render here would be an endless render/drain ping-pong over an empty queue.
969
+ if (stored > 0) void refreshAssets();
970
+ }
971
+
972
+ async function refreshAssets() {
973
+ if (!assetsOpen) return;
974
+ try {
975
+ var payload = await (await fetch('/api/assets')).json();
976
+ renderAssets(payload.assets || [], payload.jobs || [], payload.previews || []);
977
+ // Rendering is what discovers the missing thumbnails, and anything queued while the game was
978
+ // still booting has been sitting there since. Guarded on both sides, so this is a no-op
979
+ // whenever there is nothing to draw.
980
+ void drainPreviewQueue();
981
+ } catch (error) {
982
+ // The sidecar is this page's own server. If it is gone the reload button is the only useful
983
+ // action left, and the status bar already says so.
984
+ }
985
+ }
986
+
987
+ /**
988
+ * A slow tick behind the SSE push, for the two things an event cannot cover: elapsed times, which
989
+ * change with no write anywhere, and a missed event on a stream that dropped.
990
+ */
991
+ function startAssetsTicker() {
992
+ if (assetsTimer) clearInterval(assetsTimer);
993
+ assetsTimer = setInterval(function () {
994
+ if (!assetsOpen) return;
995
+ // Cheap while idle: with nothing generating there is nothing whose label changes on its own.
996
+ if (lastJobs.length === 0) return;
997
+ void refreshAssets();
998
+ }, 1000);
999
+ }
1000
+
1001
+ /**
1002
+ * Lend the cached thumbnails to the engine's own asset palette.
1003
+ *
1004
+ * Right-click in the editor offers "Add object", and that grid renders 'asset.screenshotUrl'
1005
+ * (SceneEditor.buildAssetGrid) — a field only the web Creator ever writes, so in a CLI project
1006
+ * every tile there reads "No Preview". The panel has just rendered those images anyway, so
1007
+ * handing them over on the way past costs nothing and needs no change under game/.
1008
+ *
1009
+ * Copies rather than mutating: this list is served straight out of world.json, and putting a
1010
+ * local URL onto the records the save path also reads would be a field the CLI invented leaking
1011
+ * into the creator's file.
1012
+ */
1013
+ function withThumbnails(assets, previews) {
1014
+ var cached = {};
1015
+ for (var i = 0; i < previews.length; i++) cached[previews[i]] = true;
1016
+ return assets.map(function (asset) {
1017
+ if (asset.screenshotUrl || !cached[asset.id]) return asset;
1018
+ var copy = {};
1019
+ for (var key in asset) if (Object.prototype.hasOwnProperty.call(asset, key)) copy[key] = asset[key];
1020
+ copy.screenshotUrl = SHELL_ORIGIN + '/api/assets/preview/' + encodeURIComponent(asset.id) + '.png';
1021
+ return copy;
1022
+ });
1023
+ }
1024
+
1025
+ /**
1026
+ * Make the meshes being regenerated glow in the world itself.
1027
+ *
1028
+ * The engine has handled this message since before the CLI lane existed — HqGeneratingHandler
1029
+ * maps asset ids to their placed instances and drives HqGenerationGlow over them — and the web
1030
+ * Creator has always sent it. Nothing in the CLI ever did, so a 'generate prop' upgrading a
1031
+ * building gave no sign in the scene. The job records now carry the asset id, so it is free.
1032
+ */
1033
+ var lastGlowing = '';
1034
+ function pushGeneratingAssets(jobs) {
1035
+ var ids = [];
1036
+ for (var i = 0; i < jobs.length; i++) {
1037
+ if (jobs[i].status === 'running' && jobs[i].assetId) ids.push(jobs[i].assetId);
1038
+ }
1039
+ // The engine walks every mesh on a change, so it is worth not repeating ourselves — its own
1040
+ // setGeneratingHqAssetIds returns early on an unchanged set, and this saves even the message.
1041
+ var key = ids.join(',');
1042
+ if (key === lastGlowing) return;
1043
+ lastGlowing = key;
1044
+ postFlat({ type: 'HQ_ASSET_GENERATING', assetIds: ids });
1045
+ }
1046
+
1047
+ function setAssetsOpen(open) {
1048
+ assetsOpen = open;
1049
+ document.body.classList.toggle('assets-closed', !open);
1050
+ writePref(ASSETS_KEY, open ? '1' : '0');
1051
+ if (open) void refreshAssets();
1052
+ }
1053
+
663
1054
  /**
664
1055
  * Screenshot → clipboard.
665
1056
  *
@@ -802,6 +1193,12 @@ export function renderEditorShell(options) {
802
1193
  try { payload = JSON.parse(event.data); } catch (error) { /* reason is a label */ }
803
1194
  onChangeSignal(payload.reason || 'change');
804
1195
  });
1196
+ // A bare "the list moved" ping: a generation started, made progress, failed, or world.json was
1197
+ // rewritten. The panel re-reads rather than the server sending a diff, which keeps the whole
1198
+ // question of what changed on the side that is about to render it.
1199
+ events.addEventListener('assets', function () {
1200
+ void refreshAssets();
1201
+ });
805
1202
  events.addEventListener('build', function (event) {
806
1203
  var payload = {};
807
1204
  try { payload = JSON.parse(event.data); } catch (error) { /* see above */ }
@@ -835,8 +1232,12 @@ export function renderEditorShell(options) {
835
1232
  if (autoReload) banner.classList.remove('show');
836
1233
  });
837
1234
 
1235
+ assetsToggle.addEventListener('click', function () { setAssetsOpen(!assetsOpen); });
1236
+
838
1237
  autoReload = readPref(AUTO_KEY, '1') !== '0';
839
1238
  autoToggle.checked = autoReload;
1239
+ setAssetsOpen(readPref(ASSETS_KEY, '1') !== '0');
1240
+ startAssetsTicker();
840
1241
  // Applied through selectTab so the button states and the stored preference cannot disagree with
841
1242
  // the variable. 'loaded' is false here, so it only sets up the UI — boot() applies the mode.
842
1243
  activeTab = readPref(TAB_KEY, 'game') === 'editor' ? 'editor' : 'game';
@@ -1 +1 @@
1
- {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAyD3B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBA+C9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6pB7C,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA2F3B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAwD9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmgC7C,CAAC;AACF,CAAC"}
@@ -0,0 +1,67 @@
1
+ /** Which command is running. Shown as-is in the dev view, so these are creator-facing words. */
2
+ export type JobKind = 'skybox' | 'sound' | 'image' | 'character' | 'animation' | 'prop' | 'vehicle' | 'forge' | 'cover' | 'hq';
3
+ export interface JobRecord {
4
+ jobId: string;
5
+ kind: JobKind;
6
+ /** What the creator asked for — a prompt or a description. Trimmed for display, not parsed. */
7
+ label: string;
8
+ /** Known up front only for the kinds that upgrade an asset in place (prop, vehicle, hq). */
9
+ assetId?: string;
10
+ status: 'running' | 'failed';
11
+ /** The generation's most recent progress line. See `withJobRecord`. */
12
+ message: string;
13
+ pid: number;
14
+ startedAt: string;
15
+ updatedAt: string;
16
+ error?: string;
17
+ }
18
+ export declare const JOBS_DIR: string;
19
+ export declare function jobsDirPath(root: string): string;
20
+ /**
21
+ * Every job this project knows about, newest first, with dead `running` records reported as
22
+ * failed rather than left spinning in the dev view forever.
23
+ *
24
+ * Unreadable and unparseable files are skipped, not thrown: a record being written right now is a
25
+ * normal thing for a directory that several processes append to, and losing one tick of one job is
26
+ * not worth failing a read that also carries five healthy ones.
27
+ */
28
+ export declare function readJobs(root: string): JobRecord[];
29
+ /**
30
+ * Drop failed records older than a day. Called by `dev` at startup rather than on a timer: this is
31
+ * housekeeping, and the moment someone opens the dev view is the moment a day-old failure stops
32
+ * being worth showing.
33
+ */
34
+ export declare function pruneJobs(root: string, now?: number): number;
35
+ export interface JobHandle {
36
+ readonly jobId: string;
37
+ /** Record the latest progress line. */
38
+ update(message: string): void;
39
+ }
40
+ export interface StartJobOptions {
41
+ root: string;
42
+ kind: JobKind;
43
+ label: string;
44
+ assetId?: string;
45
+ }
46
+ export declare function startJob(options: StartJobOptions): JobHandle;
47
+ /**
48
+ * Run `body` with its progress recorded for anyone watching.
49
+ *
50
+ * The `log` handed to the body is the caller's own logger with a tap on it, so every line a command
51
+ * already prints also becomes the job's current message. That is deliberate reuse rather than a
52
+ * second progress vocabulary to keep in step — the dev sidecar's in-process HQ job has worked this
53
+ * way since it shipped, and it means a command gains live progress in the dev view without a single
54
+ * new call site inside it.
55
+ *
56
+ * **Never fails the generation it is watching.** Bookkeeping that breaks a paid, minutes-long
57
+ * generation would be a far worse bug than the missing progress line it is trying to provide, so
58
+ * every filesystem error here is swallowed. The body's own errors propagate untouched, after being
59
+ * recorded.
60
+ */
61
+ export declare function withJobRecord<T>(options: StartJobOptions & {
62
+ log: (message: string) => void;
63
+ }, body: (log: (message: string) => void) => Promise<T>): Promise<T>;
64
+ /** Success: the asset is in world.json, and that is the record. */
65
+ export declare function removeJob(root: string, jobId: string): void;
66
+ /** Failure: nothing else records it, so the record stays and carries the reason. */
67
+ export declare function failJob(root: string, jobId: string, error: string): void;