@playcraft/cli 0.1.55-beta → 0.1.57-beta

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 (43) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/build.js +38 -0
  3. package/dist/commands/tools-3d.js +15 -0
  4. package/dist/commands/tools-generation.js +3 -2
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace-runtime/batch/prepare.js +9 -52
  11. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  12. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  13. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  14. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  15. package/dist/workspace-runtime/previews/finalize.js +64 -0
  16. package/dist/workspace-runtime/previews/publish.js +31 -8
  17. package/dist/workspace-runtime/serve.js +8 -30
  18. package/dist/workspace-runtime/server/http.js +5 -5
  19. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  20. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  21. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  22. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  23. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  24. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  25. package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
  26. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  27. package/dist/workspace-runtime/workspaces/git.js +13 -0
  28. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  29. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  30. package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
  31. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  32. package/package.json +3 -3
  33. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  34. package/project-templates/empty/AGENTS.md +1 -1
  35. package/templates/preview-runtime/v1/README.md +12 -0
  36. package/templates/preview-runtime/v1/manifest.json +8 -8
  37. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  38. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  39. package/templates/preview-runtime/v1/runtime.mjs +591 -54
  40. package/templates/preview-runtime/v1/types/adapters.d.ts +6 -1
  41. package/templates/preview-runtime/v1/types/preview.d.ts +2 -0
  42. package/templates/preview-runtime/v1/types/protocol.d.ts +56 -0
  43. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -197,6 +197,8 @@ var BridgeMethods = {
197
197
  restart: "runtime.parameters.restart",
198
198
  status: "runtime.parameters.status",
199
199
  dispose: "runtime.parameters.dispose",
200
+ asset: "runtime.parameters.asset",
201
+ refreshImages: "runtime.parameters.refresh-images",
200
202
  capabilities: "runtime.parameters.capabilities",
201
203
  result: "runtime.parameters.result",
202
204
  resyncRequired: "runtime.parameters.resync-required"
@@ -220,7 +222,9 @@ var BRIDGE_RPC_NAMES = [
220
222
  BridgeMethods.runtimeParameters.patch,
221
223
  BridgeMethods.runtimeParameters.restart,
222
224
  BridgeMethods.runtimeParameters.status,
223
- BridgeMethods.runtimeParameters.dispose
225
+ BridgeMethods.runtimeParameters.dispose,
226
+ BridgeMethods.runtimeParameters.asset,
227
+ BridgeMethods.runtimeParameters.refreshImages
224
228
  ];
225
229
 
226
230
  // ../iframe-bridge/src/configs/perspective.ts
@@ -415,7 +419,7 @@ function safeReply(bridge, message) {
415
419
  try {
416
420
  bridge.postMessage(message);
417
421
  } catch (err) {
418
- if (err instanceof PlaycraftBridgeError && err.code === BridgeErrorCodes.PEER_UNAVAILABLE) {
422
+ if (err instanceof PlaycraftBridgeError && (err.code === BridgeErrorCodes.PEER_UNAVAILABLE || err.code === BridgeErrorCodes.DESTROYED)) {
419
423
  return;
420
424
  }
421
425
  throw err;
@@ -524,6 +528,19 @@ var Bridge = class {
524
528
  };
525
529
 
526
530
  // ../preview-runtime/src/protocol.ts
531
+ var MAX_IMAGE_RESOURCE_BYTES = 32 * 1024 * 1024;
532
+ var ResourceLoadError = class extends Error {
533
+ constructor(message) {
534
+ super(message);
535
+ this.name = "ResourceLoadError";
536
+ }
537
+ };
538
+ var SupersededResourceError = class extends Error {
539
+ constructor() {
540
+ super("A newer image value replaced this resource request");
541
+ this.name = "SupersededResourceError";
542
+ }
543
+ };
527
544
  var ProtocolError = class extends Error {
528
545
  constructor(code, message) {
529
546
  super(message);
@@ -622,6 +639,44 @@ var identityKeys = [
622
639
  "configContractVersion",
623
640
  "locale"
624
641
  ];
642
+ function assertImageRefresh(value) {
643
+ assertIdentity(value);
644
+ const request = value;
645
+ exactKeys(value, [
646
+ ...identityKeys,
647
+ "draftRevision",
648
+ "resourceSequence",
649
+ "images"
650
+ ]);
651
+ if (!revision(request.draftRevision) || !revision(request.resourceSequence) || request.resourceSequence === 0 || !Array.isArray(request.images) || !request.images.length || request.images.length > 64)
652
+ throw new ProtocolError("INVALID", "Invalid image refresh sequence or target count");
653
+ const seen = /* @__PURE__ */ new Set();
654
+ for (const image of request.images) {
655
+ if (!record(image) || !string(image.documentId) || forbidden.has(image.documentId) || !Array.isArray(image.path) || !image.path.length || image.path.length > 32 || image.path.some((part) => !string(part) || forbidden.has(part)) || image.contentHash !== null && (typeof image.contentHash !== "string" || !/^[a-f0-9]{64}$/.test(image.contentHash)))
656
+ throw new ProtocolError("INVALID", "Invalid image refresh target");
657
+ exactKeys(image, ["documentId", "path", "contentHash"]);
658
+ const key = fingerprint([image.documentId, image.path]);
659
+ if (seen.has(key)) throw new ProtocolError("INVALID", "Duplicate image refresh target");
660
+ seen.add(key);
661
+ }
662
+ limit(value, 64 * 1024);
663
+ }
664
+ function assertImageResourceData(value) {
665
+ if (!record(value) || typeof value.mimeType !== "string" || typeof value.dataUrl !== "string")
666
+ throw new ProtocolError("INVALID", "Invalid image resource response");
667
+ const allowed = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/avif", "image/bmp"];
668
+ if (!allowed.includes(value.mimeType))
669
+ throw new ProtocolError("INVALID", "Unsupported image resource type");
670
+ const prefix = `data:${value.mimeType};base64,`;
671
+ const data = value.dataUrl.startsWith(prefix) ? value.dataUrl.slice(prefix.length) : "";
672
+ if (!data || data.length % 4 !== 0 || data.length > Math.ceil(MAX_IMAGE_RESOURCE_BYTES / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data))
673
+ throw new ProtocolError("LIMIT", "Invalid or oversized image resource data");
674
+ const bytes = data.length / 4 * 3 - (data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0);
675
+ if (bytes > MAX_IMAGE_RESOURCE_BYTES)
676
+ throw new ProtocolError("LIMIT", "Image resource exceeds the byte limit");
677
+ if (value.contentHash !== void 0 && !/^[a-f0-9]{64}$/.test(String(value.contentHash)))
678
+ throw new ProtocolError("INVALID", "Invalid image content fingerprint");
679
+ }
625
680
  function assertSnapshot(value) {
626
681
  assertIdentity(value);
627
682
  exactKeys(value, [
@@ -699,12 +754,63 @@ function createParameterRuntime(options) {
699
754
  let error;
700
755
  let disposed = false;
701
756
  let cleanupFailure;
757
+ let recoverable = false;
758
+ const resourceErrors = /* @__PURE__ */ new Map();
702
759
  let resultSequence = 0;
703
760
  const retiredSessions = /* @__PURE__ */ new Set();
704
761
  const groups = /* @__PURE__ */ new Map();
705
762
  const receipts = /* @__PURE__ */ new Map();
706
763
  const listeners = /* @__PURE__ */ new Set();
707
764
  const capabilityListeners = /* @__PURE__ */ new Set();
765
+ const installedImages = /* @__PURE__ */ new Map();
766
+ const imageReads = /* @__PURE__ */ new Map();
767
+ const imageLoads = /* @__PURE__ */ new WeakMap();
768
+ const refreshGoals = /* @__PURE__ */ new Map();
769
+ const refreshWork = /* @__PURE__ */ new Set();
770
+ const refreshReceipts = /* @__PURE__ */ new Map();
771
+ let resourceSequence = 0;
772
+ const imageKey = (field) => fingerprint([field.documentId, field.path]);
773
+ async function readImage(request, signal) {
774
+ signal.throwIfAborted();
775
+ const key = imageKey(request);
776
+ const read = new AbortController();
777
+ const cancel = () => read.abort(signal.reason);
778
+ signal.addEventListener("abort", cancel, { once: true });
779
+ const active = imageReads.get(key) ?? /* @__PURE__ */ new Set();
780
+ active.add(read);
781
+ imageReads.set(key, active);
782
+ let off = () => {
783
+ };
784
+ try {
785
+ return await Promise.race([
786
+ options.loadImage(request, read.signal),
787
+ new Promise((_, reject) => {
788
+ const cancelled = () => reject(read.signal.reason);
789
+ read.signal.addEventListener("abort", cancelled, { once: true });
790
+ off = () => read.signal.removeEventListener("abort", cancelled);
791
+ if (read.signal.aborted) cancelled();
792
+ })
793
+ ]);
794
+ } finally {
795
+ off();
796
+ signal.removeEventListener("abort", cancel);
797
+ active.delete(read);
798
+ if (!active.size && imageReads.get(key) === active) imageReads.delete(key);
799
+ }
800
+ }
801
+ function cancelRefreshes() {
802
+ for (const goal of refreshGoals.values()) goal.controller.abort(new SupersededResourceError());
803
+ refreshGoals.clear();
804
+ }
805
+ function commitImages(ctx) {
806
+ for (const [key, image] of imageLoads.get(ctx) ?? []) {
807
+ installedImages.set(key, image);
808
+ resourceErrors.delete(key);
809
+ }
810
+ for (const field of ctx.fields)
811
+ if (field.resource === "image" && readPath(ctx.config, field.effectivePath) === "")
812
+ installedImages.delete(imageKey(field));
813
+ }
708
814
  async function release(game) {
709
815
  try {
710
816
  await game?.dispose();
@@ -719,6 +825,7 @@ function createParameterRuntime(options) {
719
825
  ...requireIdentity(),
720
826
  stage,
721
827
  canAcceptSnapshot: true,
828
+ ...options.loadImage ? { canRefreshImages: true, canReportMissingImages: true } : {},
722
829
  fields: clone(options.fields)
723
830
  });
724
831
  function requireIdentity() {
@@ -739,7 +846,9 @@ function createParameterRuntime(options) {
739
846
  appliedRevision,
740
847
  status,
741
848
  groups: clone([...groups.values()]),
742
- ...error ? { error } : {}
849
+ ...error ? { error } : {},
850
+ ...recoverable ? { recoverable: true } : {},
851
+ ...resourceErrors.size ? { resourceErrors: clone([...resourceErrors.values()]) } : {}
743
852
  });
744
853
  function publish() {
745
854
  if (!identity || disposed) return;
@@ -747,19 +856,77 @@ function createParameterRuntime(options) {
747
856
  const value = result();
748
857
  listeners.forEach((fn) => fn(value));
749
858
  }
750
- function context(config, beforeConfig, draftRevision, fields, signal) {
751
- return {
859
+ function context(config, beforeConfig, draftRevision, fields, signal, refresh) {
860
+ const images = /* @__PURE__ */ new Map();
861
+ const loaded = /* @__PURE__ */ new Map();
862
+ const assertCurrent = () => {
863
+ signal.throwIfAborted();
864
+ if (signal !== controller.signal || disposed)
865
+ throw new ProtocolError("STALE", "Application task expired");
866
+ refresh?.assertCurrent();
867
+ if (images.size && target) {
868
+ const latest = options.resolve(target);
869
+ for (const [key, { field, value, sequence }] of images)
870
+ if (readPath(latest, field.effectivePath) !== value || (refreshGoals.get(key)?.sequence ?? 0) !== sequence)
871
+ throw new SupersededResourceError();
872
+ }
873
+ };
874
+ const ctx = {
752
875
  config,
753
876
  beforeConfig,
754
877
  draftRevision,
755
878
  fields,
756
879
  signal,
757
- assertCurrent() {
758
- signal.throwIfAborted();
759
- if (signal !== controller.signal || disposed)
760
- throw new ProtocolError("STALE", "Application task expired");
880
+ assertCurrent,
881
+ async loadImage(field, value) {
882
+ const registered = options.fields.find(
883
+ (item) => item.resource === "image" && item.documentId === field.documentId && fingerprint(item.path) === fingerprint(field.path)
884
+ );
885
+ if (!registered || typeof value !== "string" || !value)
886
+ throw new ProtocolError("UNSUPPORTED", "Unregistered image resource field");
887
+ if (!options.loadImage)
888
+ throw new ProtocolError("UNSUPPORTED", "Image resource transport is unavailable");
889
+ const key = fingerprint([registered.documentId, registered.path]);
890
+ const goal = refreshGoals.get(key);
891
+ images.set(key, { field: registered, value, sequence: goal?.sequence ?? 0 });
892
+ assertCurrent();
893
+ try {
894
+ const prepared = refresh?.prepared.get(key);
895
+ const response = prepared?.value === value ? prepared : await readImage(
896
+ {
897
+ ...requireIdentity(),
898
+ draftRevision,
899
+ documentId: registered.documentId,
900
+ path: clone(registered.path),
901
+ value,
902
+ ...goal?.value === value ? {
903
+ resourceSequence: goal.sequence,
904
+ ...goal.contentHash !== null ? { expectedContentHash: goal.contentHash } : {}
905
+ } : {}
906
+ },
907
+ signal
908
+ );
909
+ assertCurrent();
910
+ const image = "image" in response ? response.image : response;
911
+ const contentHash = "image" in response ? response.contentHash : void 0;
912
+ if (goal?.value === value && goal.contentHash !== null && contentHash !== goal.contentHash)
913
+ throw new ResourceLoadError("Image resource content changed during refresh");
914
+ loaded.set(key, { value, image, ...contentHash ? { contentHash } : {} });
915
+ return image;
916
+ } catch (failure) {
917
+ assertCurrent();
918
+ if (failure instanceof ResourceLoadError)
919
+ resourceErrors.set(key, {
920
+ documentId: registered.documentId,
921
+ path: clone(registered.path),
922
+ error: failure.message
923
+ });
924
+ throw failure;
925
+ }
761
926
  }
762
927
  };
928
+ imageLoads.set(ctx, loaded);
929
+ return ctx;
763
930
  }
764
931
  function enqueue(work) {
765
932
  const signal = controller.signal;
@@ -769,13 +936,20 @@ function createParameterRuntime(options) {
769
936
  await work(signal);
770
937
  } catch (e) {
771
938
  if (signal.aborted) return;
939
+ if (e instanceof SupersededResourceError) return;
772
940
  status = "error";
773
- installedRevision = appliedRevision = -1;
941
+ recoverable = e instanceof ResourceLoadError;
942
+ if (!recoverable) installedRevision = appliedRevision = -1;
774
943
  error = e instanceof Error ? e.message : String(e);
775
- for (const group of groups.values()) group.status = "unknown";
944
+ for (const group of groups.values())
945
+ if (!recoverable || options.fields.some(
946
+ (field) => field.groupId === group.groupId && resourceErrors.has(fingerprint([field.documentId, field.path]))
947
+ ))
948
+ group.status = "unknown";
776
949
  publish();
777
950
  }
778
951
  });
952
+ return queue;
779
953
  }
780
954
  function remember(kind, request) {
781
955
  const key = `${kind}:${request.draftRevision}`;
@@ -795,12 +969,26 @@ function createParameterRuntime(options) {
795
969
  return receipt;
796
970
  }
797
971
  function updateState(rev, changed) {
972
+ const previousGroups = new Map(groups);
798
973
  for (const field of changed)
799
974
  groups.set(field.groupId, {
800
975
  groupId: field.groupId,
801
976
  appliedRevision: rev,
802
977
  status: "applied"
803
978
  });
979
+ if (resourceErrors.size) {
980
+ status = "error";
981
+ recoverable = true;
982
+ if (changed.length) installedRevision = appliedRevision = -1;
983
+ error = [...resourceErrors.values()].map((item) => item.error).join("; ");
984
+ for (const field of options.fields)
985
+ if (resourceErrors.has(fingerprint([field.documentId, field.path])))
986
+ groups.set(field.groupId, { ...previousGroups.get(field.groupId), status: "unknown" });
987
+ publish();
988
+ return;
989
+ }
990
+ recoverable = false;
991
+ error = void 0;
804
992
  let pending = false, reload = false;
805
993
  for (const field of options.fields) {
806
994
  if (fingerprint(readPath(installed?.[field.documentId], field.path)) !== fingerprint(readPath(target?.[field.documentId], field.path))) {
@@ -817,13 +1005,11 @@ function createParameterRuntime(options) {
817
1005
  } else status = reload ? "requires-reload" : pending ? "pending-commit" : "applying";
818
1006
  publish();
819
1007
  }
820
- async function applyTarget(documents, rev, commit, signal) {
821
- if (status === "error") return;
822
- if (!adapter || !installed)
823
- throw new ProtocolError("STATE", "Initial snapshot must create the game first");
1008
+ function candidateFor(documents, commit, retryResources) {
824
1009
  const operations = [];
825
1010
  for (const field of options.fields) {
826
1011
  if (field.mode !== "live" && !(commit && field.mode === "restart-scene")) continue;
1012
+ if (resourceErrors.has(imageKey(field)) && !retryResources.has(imageKey(field))) continue;
827
1013
  const value = readPath(documents[field.documentId], field.path);
828
1014
  operations.push(
829
1015
  value === void 0 ? { op: "remove", documentId: field.documentId, path: field.path } : {
@@ -834,17 +1020,34 @@ function createParameterRuntime(options) {
834
1020
  }
835
1021
  );
836
1022
  }
837
- const candidate = applyOperations(installed, operations);
1023
+ return applyOperations(installed, operations);
1024
+ }
1025
+ async function applyTarget(documents, rev, commit, signal, retryResources = /* @__PURE__ */ new Set(), refresh, rereadImages = /* @__PURE__ */ new Set()) {
1026
+ if (status === "error" && !recoverable) return;
1027
+ if (!adapter || !installed)
1028
+ throw new ProtocolError("STATE", "Initial snapshot must create the game first");
1029
+ const candidate = candidateFor(documents, commit, retryResources);
838
1030
  const before = options.source.get();
839
1031
  const config = freeze(options.resolve(freeze(clone(candidate))));
840
1032
  const changed = options.fields.filter(
841
- (field) => fingerprint(readPath(before, field.effectivePath)) !== fingerprint(readPath(config, field.effectivePath))
1033
+ (field) => rereadImages.has(imageKey(field)) || refresh?.prepared.has(imageKey(field)) || resourceErrors.has(fingerprint([field.documentId, field.path])) && retryResources.has(fingerprint([field.documentId, field.path])) && (field.mode === "live" || commit && field.mode === "restart-scene") || fingerprint(readPath(before, field.effectivePath)) !== fingerprint(readPath(config, field.effectivePath))
1034
+ );
1035
+ const ctx = context(
1036
+ config,
1037
+ before,
1038
+ rev,
1039
+ changed,
1040
+ signal,
1041
+ refresh ? {
1042
+ prepared: refresh.prepared,
1043
+ assertCurrent: () => refresh.assertCurrent(config)
1044
+ } : void 0
842
1045
  );
843
- const ctx = context(config, before, rev, changed, signal);
844
1046
  ctx.assertCurrent();
845
1047
  status = "applying";
846
1048
  publish();
847
1049
  options.source.install(config);
1050
+ const previousInstalledRevision = installedRevision;
848
1051
  installedRevision = fingerprint(candidate) === fingerprint(documents) ? rev : -1;
849
1052
  try {
850
1053
  if (changed.some((field) => field.mode === "restart-scene")) await adapter.restartScene(ctx);
@@ -852,10 +1055,29 @@ function createParameterRuntime(options) {
852
1055
  ctx.assertCurrent();
853
1056
  await adapter.afterRender(ctx);
854
1057
  ctx.assertCurrent();
1058
+ commitImages(ctx);
1059
+ for (const field of changed)
1060
+ if (field.resource === "image" && readPath(config, field.effectivePath) === "")
1061
+ resourceErrors.delete(fingerprint([field.documentId, field.path]));
855
1062
  installed = candidate;
856
1063
  updateState(rev, changed);
857
1064
  } catch (e) {
858
- if (!signal.aborted) options.source.install(before);
1065
+ if (!signal.aborted) {
1066
+ options.source.install(before);
1067
+ installedRevision = previousInstalledRevision;
1068
+ if (e instanceof ResourceLoadError)
1069
+ for (const field of changed) {
1070
+ if (field.resource !== "image") continue;
1071
+ const key = imageKey(field);
1072
+ installedImages.delete(key);
1073
+ if (!resourceErrors.has(key))
1074
+ resourceErrors.set(key, {
1075
+ documentId: field.documentId,
1076
+ path: clone(field.path),
1077
+ error: e.message
1078
+ });
1079
+ }
1080
+ }
859
1081
  throw e;
860
1082
  }
861
1083
  }
@@ -882,12 +1104,18 @@ function createParameterRuntime(options) {
882
1104
  cleanupFailure = e;
883
1105
  });
884
1106
  identity = freeze({ ...clone(value), runtimeInstanceId });
1107
+ cancelRefreshes();
1108
+ installedImages.clear();
1109
+ refreshReceipts.clear();
1110
+ resourceSequence = 0;
885
1111
  target = installed = void 0;
886
1112
  receivedRevision = installedRevision = appliedRevision = -1;
887
1113
  receipts.clear();
888
1114
  stage = "bootstrap-ready";
889
1115
  status = "idle";
890
1116
  error = void 0;
1117
+ recoverable = false;
1118
+ resourceErrors.clear();
891
1119
  resultSequence = 0;
892
1120
  for (const group of groups.values()) {
893
1121
  group.appliedRevision = -1;
@@ -907,6 +1135,8 @@ function createParameterRuntime(options) {
907
1135
  const rev = value.draftRevision;
908
1136
  const documents = freeze(clone(value.documents));
909
1137
  options.resolve(documents);
1138
+ cancelRefreshes();
1139
+ installedImages.clear();
910
1140
  target = documents;
911
1141
  const receipt = accept("snapshot", value);
912
1142
  enqueue(async (signal) => {
@@ -915,44 +1145,57 @@ function createParameterRuntime(options) {
915
1145
  "RECOVERY",
916
1146
  "Previous game cleanup failed; reload the preview frame"
917
1147
  );
918
- const before = options.source.get();
919
- const config = freeze(options.resolve(documents));
920
- const ctx = context(config, before, rev, clone(options.fields), signal);
921
- ctx.assertCurrent();
922
1148
  status = "applying";
923
1149
  publish();
924
1150
  const previousAdapter = adapter;
925
1151
  adapter = void 0;
926
1152
  stage = "bootstrap-ready";
927
1153
  await release(previousAdapter);
928
- ctx.assertCurrent();
929
- options.source.install(config);
930
- installedRevision = rev;
931
- let created;
932
- try {
933
- created = await options.createGame(ctx);
934
- if (signal.aborted) {
935
- await release(created);
936
- return;
937
- }
938
- ctx.assertCurrent();
939
- adapter = created;
940
- stage = "game-ready";
941
- capabilityListeners.forEach((fn) => fn(capabilities()));
942
- await created.afterRender(ctx);
1154
+ let nextDocuments = documents;
1155
+ let nextRevision = rev;
1156
+ for (; ; ) {
1157
+ const before = options.source.get();
1158
+ resourceErrors.clear();
1159
+ recoverable = false;
1160
+ const config = freeze(options.resolve(nextDocuments));
1161
+ const ctx = context(config, before, nextRevision, clone(options.fields), signal);
943
1162
  ctx.assertCurrent();
944
- installed = clone(documents);
945
- error = void 0;
946
- updateState(rev, options.fields);
947
- } catch (e) {
948
- if (!signal.aborted) {
949
- adapter = void 0;
950
- stage = "bootstrap-ready";
951
- options.source.install(before);
1163
+ options.source.install(config);
1164
+ installedRevision = nextRevision;
1165
+ let created;
1166
+ try {
1167
+ created = await options.createGame(ctx);
1168
+ if (signal.aborted) {
1169
+ await release(created);
1170
+ return;
1171
+ }
1172
+ ctx.assertCurrent();
1173
+ adapter = created;
1174
+ stage = "game-ready";
952
1175
  capabilityListeners.forEach((fn) => fn(capabilities()));
1176
+ await created.afterRender(ctx);
1177
+ ctx.assertCurrent();
1178
+ commitImages(ctx);
1179
+ installed = clone(nextDocuments);
1180
+ if (resourceErrors.size) installedRevision = -1;
1181
+ error = void 0;
1182
+ updateState(nextRevision, options.fields);
1183
+ return;
1184
+ } catch (e) {
1185
+ if (!signal.aborted) {
1186
+ adapter = void 0;
1187
+ stage = "bootstrap-ready";
1188
+ options.source.install(before);
1189
+ capabilityListeners.forEach((fn) => fn(capabilities()));
1190
+ }
1191
+ await release(created);
1192
+ if (e instanceof SupersededResourceError && !signal.aborted && target) {
1193
+ nextDocuments = freeze(clone(target));
1194
+ nextRevision = receivedRevision;
1195
+ continue;
1196
+ }
1197
+ throw e;
953
1198
  }
954
- await release(created);
955
- throw e;
956
1199
  }
957
1200
  });
958
1201
  return receipt;
@@ -964,7 +1207,7 @@ function createParameterRuntime(options) {
964
1207
  if (previous) return previous;
965
1208
  if (!target || value.baseDraftRevision !== receivedRevision)
966
1209
  throw new ProtocolError("RESYNC", "Patch base does not match received revision");
967
- if (status === "error")
1210
+ if (status === "error" && !recoverable)
968
1211
  throw new ProtocolError(
969
1212
  "RECOVERY",
970
1213
  "Application state is unknown; send a recovery snapshot"
@@ -978,13 +1221,227 @@ function createParameterRuntime(options) {
978
1221
  const rev = value.draftRevision, commit = value.phase === "commit";
979
1222
  options.resolve(documents);
980
1223
  target = documents;
1224
+ const latestConfig = options.resolve(documents);
1225
+ const rereadImages = /* @__PURE__ */ new Set();
1226
+ for (const [key, goal] of refreshGoals)
1227
+ if (readPath(latestConfig, goal.field.effectivePath) !== goal.value || fingerprint(readPath(documents[goal.field.documentId], goal.field.path)) !== fingerprint(goal.raw)) {
1228
+ const image = installedImages.get(key);
1229
+ if (goal.contentHash !== null && (goal.applying || image?.value !== goal.value || image.contentHash !== goal.contentHash))
1230
+ rereadImages.add(key);
1231
+ goal.controller.abort(new SupersededResourceError());
1232
+ refreshGoals.delete(key);
1233
+ }
981
1234
  const receipt = accept("patch", value);
982
- enqueue((signal) => applyTarget(documents, rev, commit, signal));
1235
+ const retryResources = new Set(
1236
+ value.operations.length ? value.operations.map((operation) => fingerprint([operation.documentId, operation.path])) : options.fields.filter((field) => field.resource === "image").map((field) => fingerprint([field.documentId, field.path]))
1237
+ );
1238
+ enqueue(
1239
+ (signal) => applyTarget(documents, rev, commit, signal, retryResources, void 0, rereadImages)
1240
+ );
1241
+ return receipt;
1242
+ },
1243
+ refreshImages(value) {
1244
+ assertImageRefresh(value);
1245
+ check(value);
1246
+ const request = freeze(clone(value));
1247
+ const previous = refreshReceipts.get(request.resourceSequence);
1248
+ if (previous) {
1249
+ if (previous.fingerprint !== fingerprint(request))
1250
+ throw new ProtocolError("CONFLICT", "Image refresh sequence already accepted");
1251
+ return previous.receipt;
1252
+ }
1253
+ if (request.resourceSequence <= resourceSequence || request.draftRevision !== receivedRevision || !target)
1254
+ throw new ProtocolError("STALE", "Image refresh must name the current target and sequence");
1255
+ if (!adapter || !installed || !options.loadImage || status === "error" && !recoverable)
1256
+ throw new ProtocolError("STATE", "Image refresh requires an available game");
1257
+ if (refreshWork.size >= 64)
1258
+ throw new ProtocolError("LIMIT", "Too many pending image refresh requests");
1259
+ const config = options.resolve(target);
1260
+ const uncertain = /* @__PURE__ */ new Set();
1261
+ const goals = request.images.map((image) => {
1262
+ const field = options.fields.find(
1263
+ (field2) => imageKey(field2) === imageKey(image) && field2.resource === "image" && field2.mode === "live"
1264
+ );
1265
+ const current = field && readPath(config, field.effectivePath);
1266
+ if (!field || typeof current !== "string" || !current)
1267
+ throw new ProtocolError("UNSUPPORTED", "Image refresh requires a live nonempty image");
1268
+ if (refreshGoals.get(imageKey(field))?.applying) uncertain.add(imageKey(field));
1269
+ return {
1270
+ field,
1271
+ value: current,
1272
+ raw: clone(readPath(target[field.documentId], field.path)),
1273
+ contentHash: image.contentHash,
1274
+ sequence: request.resourceSequence,
1275
+ controller: new AbortController()
1276
+ };
1277
+ });
1278
+ const receipt = {
1279
+ accepted: true,
1280
+ receivedRevision,
1281
+ resourceSequence: request.resourceSequence
1282
+ };
1283
+ resourceSequence = request.resourceSequence;
1284
+ refreshReceipts.set(resourceSequence, { fingerprint: fingerprint(request), receipt });
1285
+ if (refreshReceipts.size > 256) refreshReceipts.delete(refreshReceipts.keys().next().value);
1286
+ for (const goal of goals) {
1287
+ for (const read of imageReads.get(imageKey(goal.field)) ?? [])
1288
+ read.abort(new SupersededResourceError());
1289
+ refreshGoals.get(imageKey(goal.field))?.controller.abort(new SupersededResourceError());
1290
+ refreshGoals.set(imageKey(goal.field), goal);
1291
+ }
1292
+ const active = goals.filter((goal) => {
1293
+ if (goal.contentHash === null) return false;
1294
+ const image = installedImages.get(imageKey(goal.field));
1295
+ return uncertain.has(imageKey(goal.field)) || resourceErrors.has(imageKey(goal.field)) || image?.value !== goal.value || image.contentHash !== goal.contentHash;
1296
+ });
1297
+ const sessionSignal = controller.signal;
1298
+ const assertSession = () => {
1299
+ sessionSignal.throwIfAborted();
1300
+ if (disposed || sessionSignal !== controller.signal || !target)
1301
+ throw new SupersededResourceError();
1302
+ };
1303
+ const isCurrent = (goal) => {
1304
+ assertSession();
1305
+ const latest = options.resolve(target);
1306
+ return !goal.controller.signal.aborted && refreshGoals.get(imageKey(goal.field)) === goal && readPath(latest, goal.field.effectivePath) === goal.value;
1307
+ };
1308
+ const assertGoal = (goal) => {
1309
+ if (!isCurrent(goal)) throw new SupersededResourceError();
1310
+ };
1311
+ const missing = goals.filter((goal) => goal.contentHash === null);
1312
+ for (const goal of missing)
1313
+ resourceErrors.set(imageKey(goal.field), {
1314
+ documentId: goal.field.documentId,
1315
+ path: clone(goal.field.path),
1316
+ error: "Image resource file is missing"
1317
+ });
1318
+ if (missing.length)
1319
+ void enqueue(async () => {
1320
+ assertSession();
1321
+ if (missing.some(isCurrent)) updateState(receivedRevision, []);
1322
+ });
1323
+ if (!active.length) return receipt;
1324
+ const work = (async () => {
1325
+ const prepared = /* @__PURE__ */ new Map();
1326
+ const cancel = () => goals.forEach((goal) => goal.controller.abort(sessionSignal.reason));
1327
+ sessionSignal.addEventListener("abort", cancel, { once: true });
1328
+ try {
1329
+ await Promise.all(
1330
+ active.map(async (goal) => {
1331
+ try {
1332
+ assertGoal(goal);
1333
+ const response = await readImage(
1334
+ {
1335
+ ...requireIdentity(),
1336
+ draftRevision: request.draftRevision,
1337
+ documentId: goal.field.documentId,
1338
+ path: clone(goal.field.path),
1339
+ value: goal.value,
1340
+ resourceSequence: goal.sequence,
1341
+ expectedContentHash: goal.contentHash
1342
+ },
1343
+ goal.controller.signal
1344
+ );
1345
+ assertGoal(goal);
1346
+ if (!("image" in response) || response.contentHash !== goal.contentHash)
1347
+ throw new ResourceLoadError("Image resource content changed during refresh");
1348
+ prepared.set(imageKey(goal.field), {
1349
+ value: goal.value,
1350
+ image: response.image,
1351
+ contentHash: response.contentHash
1352
+ });
1353
+ } catch (failure) {
1354
+ if (!isCurrent(goal)) return;
1355
+ if (failure instanceof ResourceLoadError)
1356
+ resourceErrors.set(imageKey(goal.field), {
1357
+ documentId: goal.field.documentId,
1358
+ path: clone(goal.field.path),
1359
+ error: failure.message
1360
+ });
1361
+ throw failure;
1362
+ }
1363
+ })
1364
+ );
1365
+ for (let attempt = 0; attempt < 32; attempt++) {
1366
+ let rebase = false;
1367
+ await enqueue(async (signal) => {
1368
+ assertSession();
1369
+ const current = active.filter(
1370
+ (goal) => prepared.has(imageKey(goal.field)) && isCurrent(goal)
1371
+ );
1372
+ if (!current.length) return;
1373
+ const selected = new Map(
1374
+ current.map((goal) => [imageKey(goal.field), prepared.get(imageKey(goal.field))])
1375
+ );
1376
+ const retry = new Set(selected.keys());
1377
+ const documents = clone(target);
1378
+ const rev = receivedRevision;
1379
+ try {
1380
+ current.forEach((goal) => {
1381
+ goal.applying = true;
1382
+ });
1383
+ await applyTarget(documents, rev, false, signal, retry, {
1384
+ prepared: selected,
1385
+ assertCurrent(config2) {
1386
+ assertSession();
1387
+ if (current.some((goal) => !isCurrent(goal))) {
1388
+ rebase = true;
1389
+ throw new SupersededResourceError();
1390
+ }
1391
+ const latest = options.resolve(candidateFor(target, false, retry));
1392
+ if (fingerprint(latest) !== fingerprint(config2)) {
1393
+ rebase = true;
1394
+ throw new SupersededResourceError();
1395
+ }
1396
+ }
1397
+ });
1398
+ } catch (failure) {
1399
+ if (rebase && failure instanceof SupersededResourceError) return;
1400
+ throw failure;
1401
+ } finally {
1402
+ current.forEach((goal) => {
1403
+ goal.applying = false;
1404
+ });
1405
+ }
1406
+ });
1407
+ if (!rebase) return;
1408
+ }
1409
+ throw new ResourceLoadError("Image refresh was interrupted by parameter changes; retry");
1410
+ } catch (failure) {
1411
+ if (!sessionSignal.aborted && !(failure instanceof SupersededResourceError)) {
1412
+ try {
1413
+ assertSession();
1414
+ } catch {
1415
+ return;
1416
+ }
1417
+ if (failure instanceof ResourceLoadError) {
1418
+ for (const goal of active.filter(isCurrent))
1419
+ if (!resourceErrors.has(imageKey(goal.field)))
1420
+ resourceErrors.set(imageKey(goal.field), {
1421
+ documentId: goal.field.documentId,
1422
+ path: clone(goal.field.path),
1423
+ error: failure.message
1424
+ });
1425
+ }
1426
+ await enqueue(async () => {
1427
+ throw failure;
1428
+ });
1429
+ }
1430
+ } finally {
1431
+ goals.forEach((goal) => goal.controller.abort(new SupersededResourceError()));
1432
+ active.forEach((goal) => {
1433
+ goal.applying = false;
1434
+ });
1435
+ sessionSignal.removeEventListener("abort", cancel);
1436
+ }
1437
+ })();
1438
+ refreshWork.add(work);
1439
+ void work.finally(() => refreshWork.delete(work));
983
1440
  return receipt;
984
1441
  },
985
1442
  restart(value) {
986
1443
  check(value);
987
- if (status === "error")
1444
+ if (status === "error" && !recoverable)
988
1445
  throw new ProtocolError(
989
1446
  "RECOVERY",
990
1447
  "Application state is unknown; send a recovery snapshot"
@@ -993,7 +1450,17 @@ function createParameterRuntime(options) {
993
1450
  if (rev !== receivedRevision || !target)
994
1451
  throw new ProtocolError("STALE", "Restart must name the current target");
995
1452
  const documents = clone(target);
996
- enqueue((signal) => applyTarget(documents, rev, true, signal));
1453
+ enqueue(
1454
+ (signal) => applyTarget(
1455
+ documents,
1456
+ rev,
1457
+ true,
1458
+ signal,
1459
+ new Set(
1460
+ options.fields.filter((field) => field.resource === "image").map((field) => fingerprint([field.documentId, field.path]))
1461
+ )
1462
+ )
1463
+ );
997
1464
  return { accepted: true, receivedRevision };
998
1465
  },
999
1466
  status(value) {
@@ -1009,15 +1476,22 @@ function createParameterRuntime(options) {
1009
1476
  return () => capabilityListeners.delete(fn);
1010
1477
  },
1011
1478
  async settled() {
1012
- await queue;
1479
+ for (; ; ) {
1480
+ const current = queue;
1481
+ await Promise.all([...refreshWork]);
1482
+ await queue;
1483
+ if (!refreshWork.size && current === queue) return;
1484
+ }
1013
1485
  },
1014
1486
  async dispose() {
1015
1487
  if (disposed) return;
1016
1488
  disposed = true;
1017
1489
  controller.abort();
1490
+ cancelRefreshes();
1018
1491
  listeners.clear();
1019
1492
  capabilityListeners.clear();
1020
1493
  await queue;
1494
+ await Promise.all([...refreshWork]);
1021
1495
  await adapter?.dispose();
1022
1496
  adapter = void 0;
1023
1497
  }
@@ -1025,6 +1499,42 @@ function createParameterRuntime(options) {
1025
1499
  }
1026
1500
 
1027
1501
  // ../preview-runtime/src/preview.ts
1502
+ function decodeImage(dataUrl, signal) {
1503
+ return new Promise((resolve, reject) => {
1504
+ signal.throwIfAborted();
1505
+ const image = new Image();
1506
+ const timer = setTimeout(
1507
+ () => finish(new ResourceLoadError("Image decoding timed out")),
1508
+ 15e3
1509
+ );
1510
+ const cleanup = () => {
1511
+ clearTimeout(timer);
1512
+ image.onload = image.onerror = null;
1513
+ signal.removeEventListener("abort", cancel);
1514
+ };
1515
+ const finish = (error) => {
1516
+ if (!error && (!Number.isSafeInteger(image.naturalWidth) || !Number.isSafeInteger(image.naturalHeight) || image.naturalWidth <= 0 || image.naturalHeight <= 0 || image.naturalWidth * image.naturalHeight > 32 * 1024 * 1024))
1517
+ error = new ResourceLoadError("Image dimensions exceed the pixel limit");
1518
+ cleanup();
1519
+ if (error) {
1520
+ image.src = "";
1521
+ reject(error);
1522
+ } else resolve(image);
1523
+ };
1524
+ const cancel = () => finish(signal.reason);
1525
+ signal.addEventListener("abort", cancel, { once: true });
1526
+ image.onerror = () => finish(new ResourceLoadError("Image decoding failed"));
1527
+ image.onload = () => {
1528
+ if (typeof image.decode !== "function") finish();
1529
+ };
1530
+ image.src = dataUrl;
1531
+ if (typeof image.decode === "function")
1532
+ void image.decode().then(
1533
+ () => finish(),
1534
+ () => finish(new ResourceLoadError("Image decoding failed"))
1535
+ );
1536
+ });
1537
+ }
1028
1538
  function readWorkspacePreviewContext(expectedContractVersion) {
1029
1539
  try {
1030
1540
  const mark = "playcraft-preview-storage:";
@@ -1065,7 +1575,6 @@ function connectPreviewRuntime(options) {
1065
1575
  const origin = new URL(options.parentOrigin).origin;
1066
1576
  if (origin === "null" || options.parentOrigin !== origin)
1067
1577
  throw new ProtocolError("ORIGIN", "An exact parent origin is required");
1068
- const runtime = createParameterRuntime(options);
1069
1578
  const bridge = new Bridge({
1070
1579
  peer: () => window.parent,
1071
1580
  targetOrigin: origin,
@@ -1073,6 +1582,32 @@ function connectPreviewRuntime(options) {
1073
1582
  strictSource: true
1074
1583
  });
1075
1584
  const names = BridgeMethods.runtimeParameters;
1585
+ const runtime = createParameterRuntime({
1586
+ ...options,
1587
+ async loadImage(request, signal) {
1588
+ const value = await bridge.emit(names.asset, request, { timeout: 16e3, signal });
1589
+ if (!sameIdentity(request, value))
1590
+ throw new ProtocolError("STALE", "Image response belongs to a different runtime");
1591
+ const response = value;
1592
+ if (response.draftRevision !== request.draftRevision || response.documentId !== request.documentId || fingerprint(response.path) !== fingerprint(request.path) || response.value !== request.value || response.resourceSequence !== request.resourceSequence || response.expectedContentHash !== request.expectedContentHash)
1593
+ throw new ProtocolError("STALE", "Image response does not match its request");
1594
+ if (response.status === "stale") throw new SupersededResourceError();
1595
+ if (response.status === "failed") {
1596
+ if (typeof response.error !== "string")
1597
+ throw new ProtocolError("INVALID", "Invalid image failure response");
1598
+ throw new ResourceLoadError(response.error);
1599
+ }
1600
+ if (response.status !== "ready")
1601
+ throw new ProtocolError("INVALID", "Invalid image resource response");
1602
+ assertImageResourceData(response);
1603
+ if (request.expectedContentHash !== void 0 && response.contentHash !== request.expectedContentHash)
1604
+ throw new ResourceLoadError("Image resource content changed during refresh");
1605
+ return {
1606
+ image: await decodeImage(response.dataUrl, signal),
1607
+ contentHash: response.contentHash
1608
+ };
1609
+ }
1610
+ });
1076
1611
  const off = [
1077
1612
  bridge.on(names.hello, (value) => runtime.hello(value)),
1078
1613
  bridge.on(names.snapshot, (value) => runtime.snapshot(value)),
@@ -1091,6 +1626,7 @@ function connectPreviewRuntime(options) {
1091
1626
  }
1092
1627
  }),
1093
1628
  bridge.on(names.restart, (value) => runtime.restart(value)),
1629
+ bridge.on(names.refreshImages, (value) => runtime.refreshImages(value)),
1094
1630
  bridge.on(names.status, (value) => runtime.status(value)),
1095
1631
  bridge.on(names.dispose, async (value) => {
1096
1632
  runtime.status(value);
@@ -1116,6 +1652,7 @@ function connectPreviewRuntime(options) {
1116
1652
  };
1117
1653
  }
1118
1654
  export {
1655
+ ResourceLoadError,
1119
1656
  connectPreviewRuntime,
1120
1657
  readWorkspacePreviewContext
1121
1658
  };