@routevn/creator-model 1.1.12 → 1.2.0

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 (3) hide show
  1. package/README.md +17 -0
  2. package/package.json +1 -1
  3. package/src/model.js +840 -15
package/README.md CHANGED
@@ -210,6 +210,11 @@ See also:
210
210
 
211
211
  - [docs/schema-compatibility.md](./docs/schema-compatibility.md)
212
212
 
213
+ Current animation update tween properties support either:
214
+
215
+ - `keyframes`
216
+ - `auto: { duration, easing }`
217
+
213
218
  ## Current Scope
214
219
 
215
220
  Currently implemented command types:
@@ -232,6 +237,10 @@ Currently implemented command types:
232
237
  - `image.update`
233
238
  - `image.delete`
234
239
  - `image.move`
240
+ - `spritesheet.create`
241
+ - `spritesheet.update`
242
+ - `spritesheet.delete`
243
+ - `spritesheet.move`
235
244
  - `sound.create`
236
245
  - `sound.update`
237
246
  - `sound.delete`
@@ -280,6 +289,14 @@ Currently implemented command types:
280
289
  - `layout.element.update`
281
290
  - `layout.element.delete`
282
291
  - `layout.element.move`
292
+ - `control.create`
293
+ - `control.update`
294
+ - `control.delete`
295
+ - `control.move`
296
+ - `control.element.create`
297
+ - `control.element.update`
298
+ - `control.element.delete`
299
+ - `control.element.move`
283
300
 
284
301
  The rest of the future command surface should be added only when full
285
302
  validation, preconditions, reducer behavior, and tests are added together.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.1.12",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/model.js CHANGED
@@ -21,6 +21,7 @@ const COLLECTION_KEYS = [
21
21
  "scenes",
22
22
  "files",
23
23
  "images",
24
+ "spritesheets",
24
25
  "sounds",
25
26
  "videos",
26
27
  "animations",
@@ -44,14 +45,23 @@ const normalizeStateCollections = (state) => {
44
45
  return state;
45
46
  }
46
47
 
47
- if (state.controls !== undefined) {
48
+ const missingCollectionKeys = ["spritesheets", "controls"].filter(
49
+ (key) => state[key] === undefined,
50
+ );
51
+
52
+ if (missingCollectionKeys.length === 0) {
48
53
  return state;
49
54
  }
50
55
 
51
- return {
56
+ const nextState = {
52
57
  ...state,
53
- controls: createEmptyCollectionState(),
54
58
  };
59
+
60
+ missingCollectionKeys.forEach((key) => {
61
+ nextState[key] = createEmptyCollectionState();
62
+ });
63
+
64
+ return nextState;
55
65
  };
56
66
  const isString = (value) => typeof value === "string";
57
67
  const isHexColor = (value) =>
@@ -125,6 +135,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
125
135
  "container",
126
136
  "rect",
127
137
  "sprite",
138
+ "spritesheet-animation",
128
139
  "text",
129
140
  "text-revealing",
130
141
  "slider",
@@ -145,7 +156,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
145
156
  "container-ref-confirm-dialog-ok",
146
157
  "container-ref-confirm-dialog-cancel",
147
158
  ];
148
- export const SCHEMA_VERSION = 1;
159
+ export const SCHEMA_VERSION = 2;
149
160
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
150
161
  "folder",
151
162
  "container",
@@ -853,6 +864,207 @@ const validateImageItems = ({ items, path, errorFactory }) => {
853
864
  }
854
865
  };
855
866
 
867
+ const validateSpritesheetAnimationMap = ({
868
+ animations,
869
+ path,
870
+ errorFactory,
871
+ }) => {
872
+ if (!isPlainObject(animations)) {
873
+ return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
874
+ }
875
+
876
+ for (const [animationName, animation] of Object.entries(animations)) {
877
+ const animationPath = `${path}.${animationName}`;
878
+
879
+ if (!isNonEmptyString(animationName)) {
880
+ return invalidFromErrorFactory(
881
+ errorFactory,
882
+ `${animationPath} must use a non-empty animation name`,
883
+ );
884
+ }
885
+
886
+ {
887
+ const result = validateAllowedKeys({
888
+ value: animation,
889
+ allowedKeys: ["frames", "animationSpeed", "loop"],
890
+ path: animationPath,
891
+ errorFactory,
892
+ });
893
+ if (result?.valid === false) {
894
+ return result;
895
+ }
896
+ }
897
+
898
+ if (!Array.isArray(animation.frames)) {
899
+ return invalidFromErrorFactory(
900
+ errorFactory,
901
+ `${animationPath}.frames must be an array`,
902
+ );
903
+ }
904
+
905
+ for (let index = 0; index < animation.frames.length; index += 1) {
906
+ const frame = animation.frames[index];
907
+ if (!Number.isInteger(frame) || frame < 0) {
908
+ return invalidFromErrorFactory(
909
+ errorFactory,
910
+ `${animationPath}.frames.${index} must be an integer greater than or equal to 0`,
911
+ );
912
+ }
913
+ }
914
+
915
+ if (
916
+ animation.animationSpeed !== undefined &&
917
+ !isFiniteNumber(animation.animationSpeed)
918
+ ) {
919
+ return invalidFromErrorFactory(
920
+ errorFactory,
921
+ `${animationPath}.animationSpeed must be a finite number when provided`,
922
+ );
923
+ }
924
+
925
+ if (animation.loop !== undefined && typeof animation.loop !== "boolean") {
926
+ return invalidFromErrorFactory(
927
+ errorFactory,
928
+ `${animationPath}.loop must be a boolean when provided`,
929
+ );
930
+ }
931
+ }
932
+
933
+ return VALID_RESULT;
934
+ };
935
+
936
+ const validateSpritesheetItems = ({ items, path, errorFactory }) => {
937
+ for (const [itemId, item] of Object.entries(items)) {
938
+ const itemPath = `${path}.${itemId}`;
939
+
940
+ if (item?.type !== "folder" && item?.type !== "spritesheet") {
941
+ return invalidFromErrorFactory(
942
+ errorFactory,
943
+ `${itemPath}.type must be 'folder' or 'spritesheet'`,
944
+ );
945
+ }
946
+
947
+ {
948
+ const result = validateAllowedKeys({
949
+ value: item,
950
+ allowedKeys:
951
+ item.type === "folder"
952
+ ? ["id", "type", "name", "description"]
953
+ : [
954
+ "id",
955
+ "type",
956
+ "name",
957
+ "description",
958
+ "thumbnailFileId",
959
+ "fileId",
960
+ "fileType",
961
+ "fileSize",
962
+ "sheetWidth",
963
+ "sheetHeight",
964
+ "frameCount",
965
+ "width",
966
+ "height",
967
+ "jsonData",
968
+ "animations",
969
+ ],
970
+ path: itemPath,
971
+ errorFactory,
972
+ });
973
+ if (result?.valid === false) {
974
+ return result;
975
+ }
976
+ }
977
+
978
+ if (!isNonEmptyString(item.id)) {
979
+ return invalidFromErrorFactory(
980
+ errorFactory,
981
+ `${itemPath}.id must be a non-empty string`,
982
+ );
983
+ }
984
+
985
+ if (item.id !== itemId) {
986
+ return invalidFromErrorFactory(
987
+ errorFactory,
988
+ `${itemPath}.id must match item key '${itemId}'`,
989
+ );
990
+ }
991
+
992
+ if (!isNonEmptyString(item.name)) {
993
+ return invalidFromErrorFactory(
994
+ errorFactory,
995
+ `${itemPath}.name must be a non-empty string`,
996
+ );
997
+ }
998
+
999
+ if (item.description !== undefined && !isString(item.description)) {
1000
+ return invalidFromErrorFactory(
1001
+ errorFactory,
1002
+ `${itemPath}.description must be a string when provided`,
1003
+ );
1004
+ }
1005
+
1006
+ if (item.type === "spritesheet") {
1007
+ if (
1008
+ item.thumbnailFileId !== undefined &&
1009
+ !isNonEmptyString(item.thumbnailFileId)
1010
+ ) {
1011
+ return invalidFromErrorFactory(
1012
+ errorFactory,
1013
+ `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
1014
+ );
1015
+ }
1016
+
1017
+ if (!isNonEmptyString(item.fileId)) {
1018
+ return invalidFromErrorFactory(
1019
+ errorFactory,
1020
+ `${itemPath}.fileId must be a non-empty string`,
1021
+ );
1022
+ }
1023
+
1024
+ if (item.fileType !== undefined && !isString(item.fileType)) {
1025
+ return invalidFromErrorFactory(
1026
+ errorFactory,
1027
+ `${itemPath}.fileType must be a string when provided`,
1028
+ );
1029
+ }
1030
+
1031
+ for (const key of [
1032
+ "fileSize",
1033
+ "sheetWidth",
1034
+ "sheetHeight",
1035
+ "frameCount",
1036
+ "width",
1037
+ "height",
1038
+ ]) {
1039
+ if (item[key] !== undefined && !isFiniteNumber(item[key])) {
1040
+ return invalidFromErrorFactory(
1041
+ errorFactory,
1042
+ `${itemPath}.${key} must be a finite number when provided`,
1043
+ );
1044
+ }
1045
+ }
1046
+
1047
+ if (!isPlainObject(item.jsonData)) {
1048
+ return invalidFromErrorFactory(
1049
+ errorFactory,
1050
+ `${itemPath}.jsonData must be an object`,
1051
+ );
1052
+ }
1053
+
1054
+ {
1055
+ const result = validateSpritesheetAnimationMap({
1056
+ animations: item.animations,
1057
+ path: `${itemPath}.animations`,
1058
+ errorFactory,
1059
+ });
1060
+ if (result?.valid === false) {
1061
+ return result;
1062
+ }
1063
+ }
1064
+ }
1065
+ }
1066
+ };
1067
+
856
1068
  const validateSoundItems = ({ items, path, errorFactory }) => {
857
1069
  for (const [itemId, item] of Object.entries(items)) {
858
1070
  const itemPath = `${path}.${itemId}`;
@@ -1141,16 +1353,57 @@ const validateAnimationKeyframes = ({ keyframes, path, errorFactory }) => {
1141
1353
  }
1142
1354
  };
1143
1355
 
1356
+ const validateAutoTweenProperty = ({ auto, path, errorFactory }) => {
1357
+ {
1358
+ const result = validateAllowedKeys({
1359
+ value: auto,
1360
+ allowedKeys: ["duration", "easing"],
1361
+ path,
1362
+ errorFactory,
1363
+ });
1364
+ if (result?.valid === false) {
1365
+ return result;
1366
+ }
1367
+ }
1368
+
1369
+ if (!("duration" in auto)) {
1370
+ return invalidFromErrorFactory(
1371
+ errorFactory,
1372
+ `${path}.duration is required`,
1373
+ );
1374
+ }
1375
+
1376
+ if (!isFiniteNumber(auto.duration) || auto.duration < 1) {
1377
+ return invalidFromErrorFactory(
1378
+ errorFactory,
1379
+ `${path}.duration must be a finite number >= 1`,
1380
+ );
1381
+ }
1382
+
1383
+ if (
1384
+ auto.easing !== undefined &&
1385
+ !ANIMATION_EASING_KEYS.includes(auto.easing)
1386
+ ) {
1387
+ return invalidFromErrorFactory(
1388
+ errorFactory,
1389
+ `${path}.easing must be a supported Route Graphics easing`,
1390
+ );
1391
+ }
1392
+ };
1393
+
1144
1394
  const validateTweenProperty = ({
1145
1395
  config,
1146
1396
  path,
1147
1397
  allowEmptyKeyframes = false,
1398
+ allowAuto = false,
1148
1399
  errorFactory,
1149
1400
  }) => {
1150
1401
  {
1151
1402
  const result = validateAllowedKeys({
1152
1403
  value: config,
1153
- allowedKeys: ["initialValue", "keyframes"],
1404
+ allowedKeys: allowAuto
1405
+ ? ["initialValue", "keyframes", "auto"]
1406
+ : ["initialValue", "keyframes"],
1154
1407
  path,
1155
1408
  errorFactory,
1156
1409
  });
@@ -1159,10 +1412,20 @@ const validateTweenProperty = ({
1159
1412
  }
1160
1413
  }
1161
1414
 
1162
- if (!("keyframes" in config)) {
1415
+ const hasKeyframes = "keyframes" in config;
1416
+ const hasAuto = "auto" in config;
1417
+
1418
+ if (!hasKeyframes && !hasAuto) {
1419
+ return invalidFromErrorFactory(
1420
+ errorFactory,
1421
+ `${path}.keyframes or ${path}.auto is required`,
1422
+ );
1423
+ }
1424
+
1425
+ if (hasKeyframes && hasAuto) {
1163
1426
  return invalidFromErrorFactory(
1164
1427
  errorFactory,
1165
- `${path}.keyframes is required`,
1428
+ `${path}.keyframes and ${path}.auto cannot both be defined`,
1166
1429
  );
1167
1430
  }
1168
1431
 
@@ -1176,6 +1439,28 @@ const validateTweenProperty = ({
1176
1439
  );
1177
1440
  }
1178
1441
 
1442
+ if (hasAuto) {
1443
+ if (config.initialValue !== undefined) {
1444
+ return invalidFromErrorFactory(
1445
+ errorFactory,
1446
+ `${path}.initialValue is not supported when ${path}.auto is defined`,
1447
+ );
1448
+ }
1449
+
1450
+ {
1451
+ const result = validateAutoTweenProperty({
1452
+ auto: config.auto,
1453
+ path: `${path}.auto`,
1454
+ errorFactory,
1455
+ });
1456
+ if (result?.valid === false) {
1457
+ return result;
1458
+ }
1459
+ }
1460
+
1461
+ return;
1462
+ }
1463
+
1179
1464
  {
1180
1465
  const result = validateAnimationKeyframes({
1181
1466
  keyframes: config.keyframes,
@@ -1201,6 +1486,7 @@ const validateTweenDefinition = ({
1201
1486
  path,
1202
1487
  unsupportedMessage,
1203
1488
  allowEmptyKeyframes = false,
1489
+ allowAuto = false,
1204
1490
  errorFactory,
1205
1491
  }) => {
1206
1492
  if (!isPlainObject(tween)) {
@@ -1229,6 +1515,7 @@ const validateTweenDefinition = ({
1229
1515
  config,
1230
1516
  path: propertyPath,
1231
1517
  allowEmptyKeyframes,
1518
+ allowAuto,
1232
1519
  errorFactory,
1233
1520
  });
1234
1521
  if (result?.valid === false) {
@@ -1547,6 +1834,8 @@ const validateAnimationDefinition = ({ animation, path, errorFactory }) => {
1547
1834
  allowedProperties: UPDATE_TWEEN_PROPERTY_KEYS,
1548
1835
  path: `${path}.tween`,
1549
1836
  unsupportedMessage: "is not a supported update tween property",
1837
+ allowEmptyKeyframes: true,
1838
+ allowAuto: true,
1550
1839
  errorFactory,
1551
1840
  });
1552
1841
  if (result?.valid === false) {
@@ -2486,6 +2775,8 @@ const validateLayoutElementData = ({
2486
2775
  "textStyle",
2487
2776
  "displaySpeed",
2488
2777
  "revealEffect",
2778
+ "resourceId",
2779
+ "animationName",
2489
2780
  "imageId",
2490
2781
  "hoverImageId",
2491
2782
  "clickImageId",
@@ -2607,6 +2898,8 @@ const validateLayoutElementData = ({
2607
2898
 
2608
2899
  for (const key of [
2609
2900
  "text",
2901
+ "resourceId",
2902
+ "animationName",
2610
2903
  "imageId",
2611
2904
  "hoverImageId",
2612
2905
  "clickImageId",
@@ -2633,6 +2926,28 @@ const validateLayoutElementData = ({
2633
2926
  }
2634
2927
  }
2635
2928
 
2929
+ if (data.type === "spritesheet-animation") {
2930
+ if (
2931
+ (!allowPartial || data.resourceId !== undefined) &&
2932
+ !isNonEmptyString(data.resourceId)
2933
+ ) {
2934
+ return invalidFromErrorFactory(
2935
+ errorFactory,
2936
+ `${path}.resourceId must be a non-empty string`,
2937
+ );
2938
+ }
2939
+
2940
+ if (
2941
+ (!allowPartial || data.animationName !== undefined) &&
2942
+ !isNonEmptyString(data.animationName)
2943
+ ) {
2944
+ return invalidFromErrorFactory(
2945
+ errorFactory,
2946
+ `${path}.animationName must be a non-empty string`,
2947
+ );
2948
+ }
2949
+ }
2950
+
2636
2951
  if (data.conditionalOverrides !== undefined) {
2637
2952
  if (!Array.isArray(data.conditionalOverrides)) {
2638
2953
  return invalidFromErrorFactory(
@@ -2982,6 +3297,8 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2982
3297
  "textStyle",
2983
3298
  "displaySpeed",
2984
3299
  "revealEffect",
3300
+ "resourceId",
3301
+ "animationName",
2985
3302
  "imageId",
2986
3303
  "hoverImageId",
2987
3304
  "clickImageId",
@@ -3888,6 +4205,17 @@ const validateCollection = ({ collection, path }) => {
3888
4205
  return result;
3889
4206
  }
3890
4207
  }
4208
+ } else if (path === "state.spritesheets") {
4209
+ {
4210
+ const result = validateSpritesheetItems({
4211
+ items: collection.items,
4212
+ path: `${path}.items`,
4213
+ errorFactory: createStateValidationError,
4214
+ });
4215
+ if (result?.valid === false) {
4216
+ return result;
4217
+ }
4218
+ }
3891
4219
  } else if (path === "state.files") {
3892
4220
  {
3893
4221
  const result = validateFileItems({
@@ -4061,6 +4389,18 @@ const validateCollection = ({ collection, path }) => {
4061
4389
  return result;
4062
4390
  }
4063
4391
  }
4392
+ } else if (path === "state.spritesheets") {
4393
+ {
4394
+ const result = validateGenericFolderOwnership({
4395
+ nodes: collection.tree,
4396
+ items: collection.items,
4397
+ path: `${path}.tree`,
4398
+ folderLabel: "folder spritesheet item",
4399
+ });
4400
+ if (result?.valid === false) {
4401
+ return result;
4402
+ }
4403
+ }
4064
4404
  } else if (path === "state.sounds") {
4065
4405
  {
4066
4406
  const result = validateSoundTreeFolderOwnership({
@@ -4417,13 +4757,50 @@ export const assertInvariants = ({ state }) => {
4417
4757
  }
4418
4758
  }
4419
4759
 
4420
- for (const [soundId, sound] of Object.entries(state.sounds.items)) {
4421
- if (sound.type !== "sound") {
4760
+ for (const [spritesheetId, spritesheet] of Object.entries(
4761
+ state.spritesheets.items,
4762
+ )) {
4763
+ if (spritesheet.type !== "spritesheet") {
4422
4764
  continue;
4423
4765
  }
4424
4766
 
4425
- {
4426
- const result = validateFileReference({
4767
+ const result = validateFileReference({
4768
+ state,
4769
+ fileId: spritesheet.fileId,
4770
+ path: "spritesheet.fileId",
4771
+ details: { spritesheetId, fileId: spritesheet.fileId },
4772
+ errorFactory: createInvariantValidationError,
4773
+ });
4774
+ if (!result.valid) {
4775
+ return result;
4776
+ }
4777
+
4778
+ if (spritesheet.thumbnailFileId === undefined) {
4779
+ continue;
4780
+ }
4781
+
4782
+ const thumbnailResult = validateFileReference({
4783
+ state,
4784
+ fileId: spritesheet.thumbnailFileId,
4785
+ path: "spritesheet.thumbnailFileId",
4786
+ details: {
4787
+ spritesheetId,
4788
+ thumbnailFileId: spritesheet.thumbnailFileId,
4789
+ },
4790
+ errorFactory: createInvariantValidationError,
4791
+ });
4792
+ if (!thumbnailResult.valid) {
4793
+ return thumbnailResult;
4794
+ }
4795
+ }
4796
+
4797
+ for (const [soundId, sound] of Object.entries(state.sounds.items)) {
4798
+ if (sound.type !== "sound") {
4799
+ continue;
4800
+ }
4801
+
4802
+ {
4803
+ const result = validateFileReference({
4427
4804
  state,
4428
4805
  fileId: sound.fileId,
4429
4806
  path: "sound.fileId",
@@ -4708,6 +5085,45 @@ export const assertInvariants = ({ state }) => {
4708
5085
  return VALID_RESULT;
4709
5086
  };
4710
5087
 
5088
+ const assertSpritesheetAnimationReference = ({
5089
+ ownerIdField,
5090
+ ownerId,
5091
+ ownerLabel,
5092
+ elementId,
5093
+ targetId,
5094
+ animationName,
5095
+ }) => {
5096
+ const spritesheet = state.spritesheets?.items?.[targetId];
5097
+ if (!isPlainObject(spritesheet) || spritesheet.type === "folder") {
5098
+ return invalidInvariant(
5099
+ `${ownerLabel} element resourceId must reference an existing non-folder spritesheet`,
5100
+ {
5101
+ [ownerIdField]: ownerId,
5102
+ elementId,
5103
+ field: "resourceId",
5104
+ targetId,
5105
+ },
5106
+ );
5107
+ }
5108
+
5109
+ if (
5110
+ !isNonEmptyString(animationName) ||
5111
+ !isPlainObject(spritesheet.animations?.[animationName])
5112
+ ) {
5113
+ return invalidInvariant(
5114
+ `${ownerLabel} element animationName must reference an existing spritesheet animation`,
5115
+ {
5116
+ [ownerIdField]: ownerId,
5117
+ elementId,
5118
+ field: "animationName",
5119
+ targetId: animationName,
5120
+ },
5121
+ );
5122
+ }
5123
+
5124
+ return VALID_RESULT;
5125
+ };
5126
+
4711
5127
  const assertElementReferencesForCollection = ({
4712
5128
  items,
4713
5129
  ownerIdField,
@@ -4720,6 +5136,24 @@ export const assertInvariants = ({ state }) => {
4720
5136
  }
4721
5137
 
4722
5138
  for (const [elementId, element] of Object.entries(owner.elements.items)) {
5139
+ if (
5140
+ element.type === "spritesheet-animation" ||
5141
+ element.resourceId !== undefined ||
5142
+ element.animationName !== undefined
5143
+ ) {
5144
+ const result = assertSpritesheetAnimationReference({
5145
+ ownerIdField,
5146
+ ownerId,
5147
+ ownerLabel,
5148
+ elementId,
5149
+ targetId: element.resourceId,
5150
+ animationName: element.animationName,
5151
+ });
5152
+ if (!result.valid) {
5153
+ return result;
5154
+ }
5155
+ }
5156
+
4723
5157
  for (const field of [
4724
5158
  "imageId",
4725
5159
  "hoverImageId",
@@ -5460,6 +5894,233 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
5460
5894
  }
5461
5895
  };
5462
5896
 
5897
+ const validateSpritesheetCreateData = ({ data, errorFactory }) => {
5898
+ if (!isPlainObject(data)) {
5899
+ return invalidFromErrorFactory(
5900
+ errorFactory,
5901
+ "payload.data must be an object",
5902
+ );
5903
+ }
5904
+
5905
+ if (data.type !== "folder" && data.type !== "spritesheet") {
5906
+ return invalidFromErrorFactory(
5907
+ errorFactory,
5908
+ "payload.data.type must be 'folder' or 'spritesheet'",
5909
+ );
5910
+ }
5911
+
5912
+ {
5913
+ const result = validateAllowedKeys({
5914
+ value: data,
5915
+ allowedKeys:
5916
+ data.type === "folder"
5917
+ ? ["type", "name", "description"]
5918
+ : [
5919
+ "type",
5920
+ "name",
5921
+ "description",
5922
+ "thumbnailFileId",
5923
+ "fileId",
5924
+ "fileType",
5925
+ "fileSize",
5926
+ "sheetWidth",
5927
+ "sheetHeight",
5928
+ "frameCount",
5929
+ "width",
5930
+ "height",
5931
+ "jsonData",
5932
+ "animations",
5933
+ ],
5934
+ path: "payload.data",
5935
+ errorFactory,
5936
+ });
5937
+ if (result?.valid === false) {
5938
+ return result;
5939
+ }
5940
+ }
5941
+
5942
+ if (!isNonEmptyString(data.name)) {
5943
+ return invalidFromErrorFactory(
5944
+ errorFactory,
5945
+ "payload.data.name must be a non-empty string",
5946
+ );
5947
+ }
5948
+
5949
+ if (data.description !== undefined && !isString(data.description)) {
5950
+ return invalidFromErrorFactory(
5951
+ errorFactory,
5952
+ "payload.data.description must be a string when provided",
5953
+ );
5954
+ }
5955
+
5956
+ if (data.type === "spritesheet") {
5957
+ if (
5958
+ data.thumbnailFileId !== undefined &&
5959
+ !isNonEmptyString(data.thumbnailFileId)
5960
+ ) {
5961
+ return invalidFromErrorFactory(
5962
+ errorFactory,
5963
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
5964
+ );
5965
+ }
5966
+
5967
+ if (!isNonEmptyString(data.fileId)) {
5968
+ return invalidFromErrorFactory(
5969
+ errorFactory,
5970
+ "payload.data.fileId must be a non-empty string",
5971
+ );
5972
+ }
5973
+
5974
+ if (data.fileType !== undefined && !isString(data.fileType)) {
5975
+ return invalidFromErrorFactory(
5976
+ errorFactory,
5977
+ "payload.data.fileType must be a string when provided",
5978
+ );
5979
+ }
5980
+
5981
+ for (const key of [
5982
+ "fileSize",
5983
+ "sheetWidth",
5984
+ "sheetHeight",
5985
+ "frameCount",
5986
+ "width",
5987
+ "height",
5988
+ ]) {
5989
+ if (data[key] !== undefined && !isFiniteNumber(data[key])) {
5990
+ return invalidFromErrorFactory(
5991
+ errorFactory,
5992
+ `payload.data.${key} must be a finite number`,
5993
+ );
5994
+ }
5995
+ }
5996
+
5997
+ if (!isPlainObject(data.jsonData)) {
5998
+ return invalidFromErrorFactory(
5999
+ errorFactory,
6000
+ "payload.data.jsonData must be an object",
6001
+ );
6002
+ }
6003
+
6004
+ {
6005
+ const result = validateSpritesheetAnimationMap({
6006
+ animations: data.animations,
6007
+ path: "payload.data.animations",
6008
+ errorFactory,
6009
+ });
6010
+ if (result?.valid === false) {
6011
+ return result;
6012
+ }
6013
+ }
6014
+ }
6015
+ };
6016
+
6017
+ const validateSpritesheetUpdateData = ({ data, errorFactory }) => {
6018
+ {
6019
+ const result = validateAllowedKeys({
6020
+ value: data,
6021
+ allowedKeys: [
6022
+ "name",
6023
+ "description",
6024
+ "thumbnailFileId",
6025
+ "fileId",
6026
+ "fileType",
6027
+ "fileSize",
6028
+ "sheetWidth",
6029
+ "sheetHeight",
6030
+ "frameCount",
6031
+ "width",
6032
+ "height",
6033
+ "jsonData",
6034
+ "animations",
6035
+ ],
6036
+ path: "payload.data",
6037
+ errorFactory,
6038
+ });
6039
+ if (result?.valid === false) {
6040
+ return result;
6041
+ }
6042
+ }
6043
+
6044
+ if (Object.keys(data).length === 0) {
6045
+ return invalidFromErrorFactory(
6046
+ errorFactory,
6047
+ "payload.data must include at least one updatable field",
6048
+ );
6049
+ }
6050
+
6051
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
6052
+ return invalidFromErrorFactory(
6053
+ errorFactory,
6054
+ "payload.data.name must be a non-empty string when provided",
6055
+ );
6056
+ }
6057
+
6058
+ if (data.description !== undefined && !isString(data.description)) {
6059
+ return invalidFromErrorFactory(
6060
+ errorFactory,
6061
+ "payload.data.description must be a string when provided",
6062
+ );
6063
+ }
6064
+
6065
+ if (
6066
+ data.thumbnailFileId !== undefined &&
6067
+ !isNonEmptyString(data.thumbnailFileId)
6068
+ ) {
6069
+ return invalidFromErrorFactory(
6070
+ errorFactory,
6071
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6072
+ );
6073
+ }
6074
+
6075
+ if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
6076
+ return invalidFromErrorFactory(
6077
+ errorFactory,
6078
+ "payload.data.fileId must be a non-empty string when provided",
6079
+ );
6080
+ }
6081
+
6082
+ if (data.fileType !== undefined && !isString(data.fileType)) {
6083
+ return invalidFromErrorFactory(
6084
+ errorFactory,
6085
+ "payload.data.fileType must be a string when provided",
6086
+ );
6087
+ }
6088
+
6089
+ for (const key of [
6090
+ "fileSize",
6091
+ "sheetWidth",
6092
+ "sheetHeight",
6093
+ "frameCount",
6094
+ "width",
6095
+ "height",
6096
+ ]) {
6097
+ if (data[key] !== undefined && !isFiniteNumber(data[key])) {
6098
+ return invalidFromErrorFactory(
6099
+ errorFactory,
6100
+ `payload.data.${key} must be a finite number`,
6101
+ );
6102
+ }
6103
+ }
6104
+
6105
+ if (data.jsonData !== undefined && !isPlainObject(data.jsonData)) {
6106
+ return invalidFromErrorFactory(
6107
+ errorFactory,
6108
+ "payload.data.jsonData must be an object when provided",
6109
+ );
6110
+ }
6111
+
6112
+ if (data.animations !== undefined) {
6113
+ const result = validateSpritesheetAnimationMap({
6114
+ animations: data.animations,
6115
+ path: "payload.data.animations",
6116
+ errorFactory,
6117
+ });
6118
+ if (result?.valid === false) {
6119
+ return result;
6120
+ }
6121
+ }
6122
+ };
6123
+
5463
6124
  const validateSoundCreateData = ({ data, errorFactory }) => {
5464
6125
  if (!isPlainObject(data)) {
5465
6126
  return invalidFromErrorFactory(
@@ -7433,6 +8094,42 @@ const validateVisualElementReferenceTargets = ({
7433
8094
  state,
7434
8095
  errorFactory,
7435
8096
  }) => {
8097
+ if (
8098
+ data.type === "spritesheet-animation" ||
8099
+ data.resourceId !== undefined ||
8100
+ data.animationName !== undefined
8101
+ ) {
8102
+ const spritesheet = state.spritesheets?.items?.[data.resourceId];
8103
+ if (!isPlainObject(spritesheet) || spritesheet.type === "folder") {
8104
+ return invalidFromErrorFactory(
8105
+ errorFactory,
8106
+ `${ownerLabel} element resourceId must reference an existing non-folder spritesheet`,
8107
+ {
8108
+ [ownerIdField]: ownerId,
8109
+ elementId,
8110
+ field: "resourceId",
8111
+ targetId: data.resourceId,
8112
+ },
8113
+ );
8114
+ }
8115
+
8116
+ if (
8117
+ !isNonEmptyString(data.animationName) ||
8118
+ !isPlainObject(spritesheet.animations?.[data.animationName])
8119
+ ) {
8120
+ return invalidFromErrorFactory(
8121
+ errorFactory,
8122
+ `${ownerLabel} element animationName must reference an existing spritesheet animation`,
8123
+ {
8124
+ [ownerIdField]: ownerId,
8125
+ elementId,
8126
+ field: "animationName",
8127
+ targetId: data.animationName,
8128
+ },
8129
+ );
8130
+ }
8131
+ }
8132
+
7436
8133
  if (data.imageId !== undefined) {
7437
8134
  const image = state.images.items[data.imageId];
7438
8135
  if (!isPlainObject(image) || image.type === "folder") {
@@ -8246,6 +8943,30 @@ const findReferencedFileUsage = ({ state, fileId }) => {
8246
8943
  }
8247
8944
  }
8248
8945
 
8946
+ for (const [spritesheetId, spritesheet] of Object.entries(
8947
+ state.spritesheets.items,
8948
+ )) {
8949
+ if (spritesheet.type !== "spritesheet") {
8950
+ continue;
8951
+ }
8952
+
8953
+ if (spritesheet.fileId === fileId) {
8954
+ return {
8955
+ kind: "spritesheet",
8956
+ field: "fileId",
8957
+ ownerId: spritesheetId,
8958
+ };
8959
+ }
8960
+
8961
+ if (spritesheet.thumbnailFileId === fileId) {
8962
+ return {
8963
+ kind: "spritesheet",
8964
+ field: "thumbnailFileId",
8965
+ ownerId: spritesheetId,
8966
+ };
8967
+ }
8968
+ }
8969
+
8249
8970
  for (const [soundId, sound] of Object.entries(state.sounds.items)) {
8250
8971
  if (sound.type !== "sound") {
8251
8972
  continue;
@@ -8439,6 +9160,108 @@ const COMMAND_DEFINITIONS = [
8439
9160
  }
8440
9161
  },
8441
9162
  }),
9163
+ ...createFolderedCollectionCommandDefinitions({
9164
+ familyName: "spritesheet",
9165
+ collectionKey: "spritesheets",
9166
+ idField: "spritesheetId",
9167
+ itemLabel: "spritesheet item",
9168
+ createDataValidator: validateSpritesheetCreateData,
9169
+ updateDataValidator: validateSpritesheetUpdateData,
9170
+ createItem: ({ payload }) => ({
9171
+ id: payload.spritesheetId,
9172
+ type: payload.data.type,
9173
+ name: payload.data.name,
9174
+ ...(payload.data.description !== undefined
9175
+ ? {
9176
+ description: payload.data.description,
9177
+ }
9178
+ : {}),
9179
+ ...(payload.data.type === "spritesheet"
9180
+ ? {
9181
+ fileId: payload.data.fileId,
9182
+ ...(payload.data.thumbnailFileId !== undefined
9183
+ ? {
9184
+ thumbnailFileId: payload.data.thumbnailFileId,
9185
+ }
9186
+ : {}),
9187
+ ...(payload.data.fileType !== undefined
9188
+ ? {
9189
+ fileType: payload.data.fileType,
9190
+ }
9191
+ : {}),
9192
+ ...(payload.data.fileSize !== undefined
9193
+ ? {
9194
+ fileSize: payload.data.fileSize,
9195
+ }
9196
+ : {}),
9197
+ ...(payload.data.sheetWidth !== undefined
9198
+ ? {
9199
+ sheetWidth: payload.data.sheetWidth,
9200
+ }
9201
+ : {}),
9202
+ ...(payload.data.sheetHeight !== undefined
9203
+ ? {
9204
+ sheetHeight: payload.data.sheetHeight,
9205
+ }
9206
+ : {}),
9207
+ ...(payload.data.frameCount !== undefined
9208
+ ? {
9209
+ frameCount: payload.data.frameCount,
9210
+ }
9211
+ : {}),
9212
+ ...(payload.data.width !== undefined
9213
+ ? {
9214
+ width: payload.data.width,
9215
+ }
9216
+ : {}),
9217
+ ...(payload.data.height !== undefined
9218
+ ? {
9219
+ height: payload.data.height,
9220
+ }
9221
+ : {}),
9222
+ jsonData: structuredClone(payload.data.jsonData),
9223
+ animations: structuredClone(payload.data.animations),
9224
+ }
9225
+ : {}),
9226
+ }),
9227
+ validateCreateState: ({ state, payload }) => {
9228
+ if (payload.data.type !== "spritesheet") {
9229
+ return;
9230
+ }
9231
+
9232
+ return validateReferencedFilesInData({
9233
+ state,
9234
+ data: payload.data,
9235
+ fields: ["fileId", "thumbnailFileId"],
9236
+ details: {
9237
+ spritesheetId: payload.spritesheetId,
9238
+ },
9239
+ });
9240
+ },
9241
+ validateUpdateState: ({ state, payload, currentItem }) => {
9242
+ if (
9243
+ currentItem.type === "folder" &&
9244
+ Object.keys(payload.data).some(
9245
+ (key) => key !== "name" && key !== "description",
9246
+ )
9247
+ ) {
9248
+ return invalidPrecondition(
9249
+ "folder spritesheet items cannot update spritesheet fields",
9250
+ );
9251
+ }
9252
+
9253
+ if (currentItem.type === "spritesheet") {
9254
+ return validateReferencedFilesInData({
9255
+ state,
9256
+ data: payload.data,
9257
+ fields: ["fileId", "thumbnailFileId"],
9258
+ details: {
9259
+ spritesheetId: payload.spritesheetId,
9260
+ },
9261
+ });
9262
+ }
9263
+ },
9264
+ }),
8442
9265
  {
8443
9266
  type: "story.update",
8444
9267
  validatePayload: ({ payload }) => {
@@ -14134,8 +14957,10 @@ export const validateAgainstState = ({ state, command }) => {
14134
14957
  export const processCommand = ({ state, command }) => {
14135
14958
  return captureValidation(() => {
14136
14959
  const normalizedState = normalizeStateCollections(state);
14137
- const shouldMaterializeControls =
14138
- typeof command?.type === "string" && command.type.startsWith("control.");
14960
+ const shouldMaterializeNormalizedState =
14961
+ typeof command?.type === "string" &&
14962
+ (command.type.startsWith("control.") ||
14963
+ command.type.startsWith("spritesheet."));
14139
14964
 
14140
14965
  if (!isPlainObject(command)) {
14141
14966
  return invalidPrecondition("command must be an object");
@@ -14156,7 +14981,7 @@ export const processCommand = ({ state, command }) => {
14156
14981
 
14157
14982
  const nextState = definition.reduce({
14158
14983
  state: structuredClone(
14159
- shouldMaterializeControls ? normalizedState : state,
14984
+ shouldMaterializeNormalizedState ? normalizedState : state,
14160
14985
  ),
14161
14986
  payload: command.payload,
14162
14987
  });
@@ -14166,7 +14991,7 @@ export const processCommand = ({ state, command }) => {
14166
14991
 
14167
14992
  const finalState =
14168
14993
  nextState === undefined
14169
- ? shouldMaterializeControls
14994
+ ? shouldMaterializeNormalizedState
14170
14995
  ? normalizedState
14171
14996
  : state
14172
14997
  : nextState;