@routevn/creator-model 1.1.12 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/model.js CHANGED
@@ -15,15 +15,17 @@ import {
15
15
  isPlainObject,
16
16
  removeTreeNode,
17
17
  } from "./helpers.js";
18
- import { isSystemVariableId } from "./systemVariables.js";
18
+ import { isRuntimeFieldId } from "./runtimeFields.js";
19
19
 
20
20
  const COLLECTION_KEYS = [
21
21
  "scenes",
22
22
  "files",
23
23
  "images",
24
+ "spritesheets",
24
25
  "sounds",
25
26
  "videos",
26
27
  "animations",
28
+ "particles",
27
29
  "characters",
28
30
  "fonts",
29
31
  "transforms",
@@ -44,14 +46,25 @@ const normalizeStateCollections = (state) => {
44
46
  return state;
45
47
  }
46
48
 
47
- if (state.controls !== undefined) {
49
+ const missingCollectionKeys = [
50
+ "spritesheets",
51
+ "particles",
52
+ "controls",
53
+ ].filter((key) => state[key] === undefined);
54
+
55
+ if (missingCollectionKeys.length === 0) {
48
56
  return state;
49
57
  }
50
58
 
51
- return {
59
+ const nextState = {
52
60
  ...state,
53
- controls: createEmptyCollectionState(),
54
61
  };
62
+
63
+ missingCollectionKeys.forEach((key) => {
64
+ nextState[key] = createEmptyCollectionState();
65
+ });
66
+
67
+ return nextState;
55
68
  };
56
69
  const isString = (value) => typeof value === "string";
57
70
  const isHexColor = (value) =>
@@ -107,7 +120,7 @@ const ANIMATION_EASING_KEYS = [
107
120
  "easeOutElastic",
108
121
  "easeInOutElastic",
109
122
  ];
110
- const VARIABLE_SCOPE_KEYS = ["context", "global-device", "global-account"];
123
+ const VARIABLE_SCOPE_KEYS = ["context", "device", "account"];
111
124
  const VARIABLE_TYPE_KEYS = ["string", "number", "boolean"];
112
125
  const LAYOUT_TYPE_KEYS = [
113
126
  "normal",
@@ -125,6 +138,8 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
125
138
  "container",
126
139
  "rect",
127
140
  "sprite",
141
+ "particle",
142
+ "spritesheet-animation",
128
143
  "text",
129
144
  "text-revealing",
130
145
  "slider",
@@ -145,7 +160,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
145
160
  "container-ref-confirm-dialog-ok",
146
161
  "container-ref-confirm-dialog-cancel",
147
162
  ];
148
- export const SCHEMA_VERSION = 1;
163
+ export const SCHEMA_VERSION = 2;
149
164
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
150
165
  "folder",
151
166
  "container",
@@ -244,20 +259,12 @@ const invalidInvariant = (message, details = {}) =>
244
259
  });
245
260
 
246
261
  const isVariableReferenceTarget = (state, variableId) => {
247
- if (isSystemVariableId(variableId)) {
248
- return true;
249
- }
250
-
251
262
  const variable = state.variables.items[variableId];
252
263
  return isPlainObject(variable) && variable.type !== "folder";
253
264
  };
254
265
 
255
- const LAYOUT_CONDITION_TARGET_SET = new Set([
256
- "item.savedAt",
257
- "isLineCompleted",
258
- "autoMode",
259
- "skipMode",
260
- ]);
266
+ const LAYOUT_ITEM_TARGET_SET = new Set(["item.savedAt"]);
267
+ const RUNTIME_TARGET_DOT_PATTERN = /^runtime\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
261
268
  const VARIABLE_TARGET_DOT_PATTERN = /^variables\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
262
269
  const VARIABLE_TARGET_BRACKET_PATTERN = /^variables\[(.+)\]$/;
263
270
 
@@ -266,10 +273,19 @@ const parseLayoutConditionTarget = (target) => {
266
273
  return undefined;
267
274
  }
268
275
 
269
- if (LAYOUT_CONDITION_TARGET_SET.has(target)) {
276
+ if (LAYOUT_ITEM_TARGET_SET.has(target)) {
277
+ return {
278
+ kind: "item",
279
+ target,
280
+ };
281
+ }
282
+
283
+ const runtimeMatch = target.match(RUNTIME_TARGET_DOT_PATTERN);
284
+ if (runtimeMatch && isRuntimeFieldId(runtimeMatch[1])) {
270
285
  return {
271
286
  kind: "runtime",
272
287
  target,
288
+ runtimeId: runtimeMatch[1],
273
289
  };
274
290
  }
275
291
 
@@ -853,6 +869,207 @@ const validateImageItems = ({ items, path, errorFactory }) => {
853
869
  }
854
870
  };
855
871
 
872
+ const validateSpritesheetAnimationMap = ({
873
+ animations,
874
+ path,
875
+ errorFactory,
876
+ }) => {
877
+ if (!isPlainObject(animations)) {
878
+ return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
879
+ }
880
+
881
+ for (const [animationName, animation] of Object.entries(animations)) {
882
+ const animationPath = `${path}.${animationName}`;
883
+
884
+ if (!isNonEmptyString(animationName)) {
885
+ return invalidFromErrorFactory(
886
+ errorFactory,
887
+ `${animationPath} must use a non-empty animation name`,
888
+ );
889
+ }
890
+
891
+ {
892
+ const result = validateAllowedKeys({
893
+ value: animation,
894
+ allowedKeys: ["frames", "animationSpeed", "loop"],
895
+ path: animationPath,
896
+ errorFactory,
897
+ });
898
+ if (result?.valid === false) {
899
+ return result;
900
+ }
901
+ }
902
+
903
+ if (!Array.isArray(animation.frames)) {
904
+ return invalidFromErrorFactory(
905
+ errorFactory,
906
+ `${animationPath}.frames must be an array`,
907
+ );
908
+ }
909
+
910
+ for (let index = 0; index < animation.frames.length; index += 1) {
911
+ const frame = animation.frames[index];
912
+ if (!Number.isInteger(frame) || frame < 0) {
913
+ return invalidFromErrorFactory(
914
+ errorFactory,
915
+ `${animationPath}.frames.${index} must be an integer greater than or equal to 0`,
916
+ );
917
+ }
918
+ }
919
+
920
+ if (
921
+ animation.animationSpeed !== undefined &&
922
+ !isFiniteNumber(animation.animationSpeed)
923
+ ) {
924
+ return invalidFromErrorFactory(
925
+ errorFactory,
926
+ `${animationPath}.animationSpeed must be a finite number when provided`,
927
+ );
928
+ }
929
+
930
+ if (animation.loop !== undefined && typeof animation.loop !== "boolean") {
931
+ return invalidFromErrorFactory(
932
+ errorFactory,
933
+ `${animationPath}.loop must be a boolean when provided`,
934
+ );
935
+ }
936
+ }
937
+
938
+ return VALID_RESULT;
939
+ };
940
+
941
+ const validateSpritesheetItems = ({ items, path, errorFactory }) => {
942
+ for (const [itemId, item] of Object.entries(items)) {
943
+ const itemPath = `${path}.${itemId}`;
944
+
945
+ if (item?.type !== "folder" && item?.type !== "spritesheet") {
946
+ return invalidFromErrorFactory(
947
+ errorFactory,
948
+ `${itemPath}.type must be 'folder' or 'spritesheet'`,
949
+ );
950
+ }
951
+
952
+ {
953
+ const result = validateAllowedKeys({
954
+ value: item,
955
+ allowedKeys:
956
+ item.type === "folder"
957
+ ? ["id", "type", "name", "description"]
958
+ : [
959
+ "id",
960
+ "type",
961
+ "name",
962
+ "description",
963
+ "thumbnailFileId",
964
+ "fileId",
965
+ "fileType",
966
+ "fileSize",
967
+ "sheetWidth",
968
+ "sheetHeight",
969
+ "frameCount",
970
+ "width",
971
+ "height",
972
+ "jsonData",
973
+ "animations",
974
+ ],
975
+ path: itemPath,
976
+ errorFactory,
977
+ });
978
+ if (result?.valid === false) {
979
+ return result;
980
+ }
981
+ }
982
+
983
+ if (!isNonEmptyString(item.id)) {
984
+ return invalidFromErrorFactory(
985
+ errorFactory,
986
+ `${itemPath}.id must be a non-empty string`,
987
+ );
988
+ }
989
+
990
+ if (item.id !== itemId) {
991
+ return invalidFromErrorFactory(
992
+ errorFactory,
993
+ `${itemPath}.id must match item key '${itemId}'`,
994
+ );
995
+ }
996
+
997
+ if (!isNonEmptyString(item.name)) {
998
+ return invalidFromErrorFactory(
999
+ errorFactory,
1000
+ `${itemPath}.name must be a non-empty string`,
1001
+ );
1002
+ }
1003
+
1004
+ if (item.description !== undefined && !isString(item.description)) {
1005
+ return invalidFromErrorFactory(
1006
+ errorFactory,
1007
+ `${itemPath}.description must be a string when provided`,
1008
+ );
1009
+ }
1010
+
1011
+ if (item.type === "spritesheet") {
1012
+ if (
1013
+ item.thumbnailFileId !== undefined &&
1014
+ !isNonEmptyString(item.thumbnailFileId)
1015
+ ) {
1016
+ return invalidFromErrorFactory(
1017
+ errorFactory,
1018
+ `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
1019
+ );
1020
+ }
1021
+
1022
+ if (!isNonEmptyString(item.fileId)) {
1023
+ return invalidFromErrorFactory(
1024
+ errorFactory,
1025
+ `${itemPath}.fileId must be a non-empty string`,
1026
+ );
1027
+ }
1028
+
1029
+ if (item.fileType !== undefined && !isString(item.fileType)) {
1030
+ return invalidFromErrorFactory(
1031
+ errorFactory,
1032
+ `${itemPath}.fileType must be a string when provided`,
1033
+ );
1034
+ }
1035
+
1036
+ for (const key of [
1037
+ "fileSize",
1038
+ "sheetWidth",
1039
+ "sheetHeight",
1040
+ "frameCount",
1041
+ "width",
1042
+ "height",
1043
+ ]) {
1044
+ if (item[key] !== undefined && !isFiniteNumber(item[key])) {
1045
+ return invalidFromErrorFactory(
1046
+ errorFactory,
1047
+ `${itemPath}.${key} must be a finite number when provided`,
1048
+ );
1049
+ }
1050
+ }
1051
+
1052
+ if (!isPlainObject(item.jsonData)) {
1053
+ return invalidFromErrorFactory(
1054
+ errorFactory,
1055
+ `${itemPath}.jsonData must be an object`,
1056
+ );
1057
+ }
1058
+
1059
+ {
1060
+ const result = validateSpritesheetAnimationMap({
1061
+ animations: item.animations,
1062
+ path: `${itemPath}.animations`,
1063
+ errorFactory,
1064
+ });
1065
+ if (result?.valid === false) {
1066
+ return result;
1067
+ }
1068
+ }
1069
+ }
1070
+ }
1071
+ };
1072
+
856
1073
  const validateSoundItems = ({ items, path, errorFactory }) => {
857
1074
  for (const [itemId, item] of Object.entries(items)) {
858
1075
  const itemPath = `${path}.${itemId}`;
@@ -1141,16 +1358,57 @@ const validateAnimationKeyframes = ({ keyframes, path, errorFactory }) => {
1141
1358
  }
1142
1359
  };
1143
1360
 
1361
+ const validateAutoTweenProperty = ({ auto, path, errorFactory }) => {
1362
+ {
1363
+ const result = validateAllowedKeys({
1364
+ value: auto,
1365
+ allowedKeys: ["duration", "easing"],
1366
+ path,
1367
+ errorFactory,
1368
+ });
1369
+ if (result?.valid === false) {
1370
+ return result;
1371
+ }
1372
+ }
1373
+
1374
+ if (!("duration" in auto)) {
1375
+ return invalidFromErrorFactory(
1376
+ errorFactory,
1377
+ `${path}.duration is required`,
1378
+ );
1379
+ }
1380
+
1381
+ if (!isFiniteNumber(auto.duration) || auto.duration < 1) {
1382
+ return invalidFromErrorFactory(
1383
+ errorFactory,
1384
+ `${path}.duration must be a finite number >= 1`,
1385
+ );
1386
+ }
1387
+
1388
+ if (
1389
+ auto.easing !== undefined &&
1390
+ !ANIMATION_EASING_KEYS.includes(auto.easing)
1391
+ ) {
1392
+ return invalidFromErrorFactory(
1393
+ errorFactory,
1394
+ `${path}.easing must be a supported Route Graphics easing`,
1395
+ );
1396
+ }
1397
+ };
1398
+
1144
1399
  const validateTweenProperty = ({
1145
1400
  config,
1146
1401
  path,
1147
1402
  allowEmptyKeyframes = false,
1403
+ allowAuto = false,
1148
1404
  errorFactory,
1149
1405
  }) => {
1150
1406
  {
1151
1407
  const result = validateAllowedKeys({
1152
1408
  value: config,
1153
- allowedKeys: ["initialValue", "keyframes"],
1409
+ allowedKeys: allowAuto
1410
+ ? ["initialValue", "keyframes", "auto"]
1411
+ : ["initialValue", "keyframes"],
1154
1412
  path,
1155
1413
  errorFactory,
1156
1414
  });
@@ -1159,10 +1417,20 @@ const validateTweenProperty = ({
1159
1417
  }
1160
1418
  }
1161
1419
 
1162
- if (!("keyframes" in config)) {
1420
+ const hasKeyframes = "keyframes" in config;
1421
+ const hasAuto = "auto" in config;
1422
+
1423
+ if (!hasKeyframes && !hasAuto) {
1424
+ return invalidFromErrorFactory(
1425
+ errorFactory,
1426
+ `${path}.keyframes or ${path}.auto is required`,
1427
+ );
1428
+ }
1429
+
1430
+ if (hasKeyframes && hasAuto) {
1163
1431
  return invalidFromErrorFactory(
1164
1432
  errorFactory,
1165
- `${path}.keyframes is required`,
1433
+ `${path}.keyframes and ${path}.auto cannot both be defined`,
1166
1434
  );
1167
1435
  }
1168
1436
 
@@ -1176,6 +1444,28 @@ const validateTweenProperty = ({
1176
1444
  );
1177
1445
  }
1178
1446
 
1447
+ if (hasAuto) {
1448
+ if (config.initialValue !== undefined) {
1449
+ return invalidFromErrorFactory(
1450
+ errorFactory,
1451
+ `${path}.initialValue is not supported when ${path}.auto is defined`,
1452
+ );
1453
+ }
1454
+
1455
+ {
1456
+ const result = validateAutoTweenProperty({
1457
+ auto: config.auto,
1458
+ path: `${path}.auto`,
1459
+ errorFactory,
1460
+ });
1461
+ if (result?.valid === false) {
1462
+ return result;
1463
+ }
1464
+ }
1465
+
1466
+ return;
1467
+ }
1468
+
1179
1469
  {
1180
1470
  const result = validateAnimationKeyframes({
1181
1471
  keyframes: config.keyframes,
@@ -1201,6 +1491,7 @@ const validateTweenDefinition = ({
1201
1491
  path,
1202
1492
  unsupportedMessage,
1203
1493
  allowEmptyKeyframes = false,
1494
+ allowAuto = false,
1204
1495
  errorFactory,
1205
1496
  }) => {
1206
1497
  if (!isPlainObject(tween)) {
@@ -1229,6 +1520,7 @@ const validateTweenDefinition = ({
1229
1520
  config,
1230
1521
  path: propertyPath,
1231
1522
  allowEmptyKeyframes,
1523
+ allowAuto,
1232
1524
  errorFactory,
1233
1525
  });
1234
1526
  if (result?.valid === false) {
@@ -1547,6 +1839,8 @@ const validateAnimationDefinition = ({ animation, path, errorFactory }) => {
1547
1839
  allowedProperties: UPDATE_TWEEN_PROPERTY_KEYS,
1548
1840
  path: `${path}.tween`,
1549
1841
  unsupportedMessage: "is not a supported update tween property",
1842
+ allowEmptyKeyframes: true,
1843
+ allowAuto: true,
1550
1844
  errorFactory,
1551
1845
  });
1552
1846
  if (result?.valid === false) {
@@ -1935,36 +2229,171 @@ const validateTransformItems = ({ items, path, errorFactory }) => {
1935
2229
  }
1936
2230
  };
1937
2231
 
1938
- const validateVariableTypedValue = ({
1939
- value,
1940
- variableType,
1941
- path,
1942
- errorFactory,
1943
- }) => {
1944
- if (variableType === "string" && !isString(value)) {
1945
- return invalidFromErrorFactory(errorFactory, `${path} must be a string`);
2232
+ const validateParticleModules = ({ modules, path, errorFactory }) => {
2233
+ if (!isPlainObject(modules)) {
2234
+ return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
1946
2235
  }
1947
2236
 
1948
- if (variableType === "number" && !isFiniteNumber(value)) {
2237
+ if (!isPlainObject(modules.emission)) {
1949
2238
  return invalidFromErrorFactory(
1950
2239
  errorFactory,
1951
- `${path} must be a finite number`,
2240
+ `${path}.emission must be an object`,
1952
2241
  );
1953
2242
  }
1954
2243
 
1955
- if (variableType === "boolean" && typeof value !== "boolean") {
1956
- return invalidFromErrorFactory(errorFactory, `${path} must be a boolean`);
2244
+ if (!isPlainObject(modules.appearance)) {
2245
+ return invalidFromErrorFactory(
2246
+ errorFactory,
2247
+ `${path}.appearance must be an object`,
2248
+ );
2249
+ }
2250
+
2251
+ if (modules.movement !== undefined && !isPlainObject(modules.movement)) {
2252
+ return invalidFromErrorFactory(
2253
+ errorFactory,
2254
+ `${path}.movement must be an object when provided`,
2255
+ );
2256
+ }
2257
+
2258
+ if (modules.bounds !== undefined && !isPlainObject(modules.bounds)) {
2259
+ return invalidFromErrorFactory(
2260
+ errorFactory,
2261
+ `${path}.bounds must be an object when provided`,
2262
+ );
1957
2263
  }
1958
2264
  };
1959
2265
 
1960
- const validateVariableItems = ({ items, path, errorFactory }) => {
2266
+ const validateParticleItems = ({ items, path, errorFactory }) => {
1961
2267
  for (const [itemId, item] of Object.entries(items)) {
1962
2268
  const itemPath = `${path}.${itemId}`;
1963
- const variableType = item?.type;
1964
2269
 
1965
- if (
1966
- variableType !== "folder" &&
1967
- !VARIABLE_TYPE_KEYS.includes(variableType)
2270
+ if (item?.type !== "folder" && item?.type !== "particle") {
2271
+ return invalidFromErrorFactory(
2272
+ errorFactory,
2273
+ `${itemPath}.type must be 'folder' or 'particle'`,
2274
+ );
2275
+ }
2276
+
2277
+ {
2278
+ const result = validateAllowedKeys({
2279
+ value: item,
2280
+ allowedKeys:
2281
+ item.type === "folder"
2282
+ ? ["id", "type", "name", "description"]
2283
+ : [
2284
+ "id",
2285
+ "type",
2286
+ "name",
2287
+ "description",
2288
+ "width",
2289
+ "height",
2290
+ "seed",
2291
+ "modules",
2292
+ ],
2293
+ path: itemPath,
2294
+ errorFactory,
2295
+ });
2296
+ if (result?.valid === false) {
2297
+ return result;
2298
+ }
2299
+ }
2300
+
2301
+ if (!isNonEmptyString(item.id)) {
2302
+ return invalidFromErrorFactory(
2303
+ errorFactory,
2304
+ `${itemPath}.id must be a non-empty string`,
2305
+ );
2306
+ }
2307
+
2308
+ if (item.id !== itemId) {
2309
+ return invalidFromErrorFactory(
2310
+ errorFactory,
2311
+ `${itemPath}.id must match item key '${itemId}'`,
2312
+ );
2313
+ }
2314
+
2315
+ if (!isNonEmptyString(item.name)) {
2316
+ return invalidFromErrorFactory(
2317
+ errorFactory,
2318
+ `${itemPath}.name must be a non-empty string`,
2319
+ );
2320
+ }
2321
+
2322
+ if (item.description !== undefined && !isString(item.description)) {
2323
+ return invalidFromErrorFactory(
2324
+ errorFactory,
2325
+ `${itemPath}.description must be a string when provided`,
2326
+ );
2327
+ }
2328
+
2329
+ if (item.type !== "particle") {
2330
+ continue;
2331
+ }
2332
+
2333
+ if (!isFiniteNumber(item.width) || item.width <= 0) {
2334
+ return invalidFromErrorFactory(
2335
+ errorFactory,
2336
+ `${itemPath}.width must be a positive finite number`,
2337
+ );
2338
+ }
2339
+
2340
+ if (!isFiniteNumber(item.height) || item.height <= 0) {
2341
+ return invalidFromErrorFactory(
2342
+ errorFactory,
2343
+ `${itemPath}.height must be a positive finite number`,
2344
+ );
2345
+ }
2346
+
2347
+ if (item.seed !== undefined && !isFiniteNumber(item.seed)) {
2348
+ return invalidFromErrorFactory(
2349
+ errorFactory,
2350
+ `${itemPath}.seed must be a finite number when provided`,
2351
+ );
2352
+ }
2353
+
2354
+ {
2355
+ const result = validateParticleModules({
2356
+ modules: item.modules,
2357
+ path: `${itemPath}.modules`,
2358
+ errorFactory,
2359
+ });
2360
+ if (result?.valid === false) {
2361
+ return result;
2362
+ }
2363
+ }
2364
+ }
2365
+ };
2366
+
2367
+ const validateVariableTypedValue = ({
2368
+ value,
2369
+ variableType,
2370
+ path,
2371
+ errorFactory,
2372
+ }) => {
2373
+ if (variableType === "string" && !isString(value)) {
2374
+ return invalidFromErrorFactory(errorFactory, `${path} must be a string`);
2375
+ }
2376
+
2377
+ if (variableType === "number" && !isFiniteNumber(value)) {
2378
+ return invalidFromErrorFactory(
2379
+ errorFactory,
2380
+ `${path} must be a finite number`,
2381
+ );
2382
+ }
2383
+
2384
+ if (variableType === "boolean" && typeof value !== "boolean") {
2385
+ return invalidFromErrorFactory(errorFactory, `${path} must be a boolean`);
2386
+ }
2387
+ };
2388
+
2389
+ const validateVariableItems = ({ items, path, errorFactory }) => {
2390
+ for (const [itemId, item] of Object.entries(items)) {
2391
+ const itemPath = `${path}.${itemId}`;
2392
+ const variableType = item?.type;
2393
+
2394
+ if (
2395
+ variableType !== "folder" &&
2396
+ !VARIABLE_TYPE_KEYS.includes(variableType)
1968
2397
  ) {
1969
2398
  return invalidFromErrorFactory(
1970
2399
  errorFactory,
@@ -2027,7 +2456,7 @@ const validateVariableItems = ({ items, path, errorFactory }) => {
2027
2456
  if (!VARIABLE_SCOPE_KEYS.includes(item.scope)) {
2028
2457
  return invalidFromErrorFactory(
2029
2458
  errorFactory,
2030
- `${itemPath}.scope must be 'context', 'global-device', or 'global-account'`,
2459
+ `${itemPath}.scope must be 'context', 'device', or 'account'`,
2031
2460
  );
2032
2461
  }
2033
2462
 
@@ -2486,6 +2915,8 @@ const validateLayoutElementData = ({
2486
2915
  "textStyle",
2487
2916
  "displaySpeed",
2488
2917
  "revealEffect",
2918
+ "resourceId",
2919
+ "animationName",
2489
2920
  "imageId",
2490
2921
  "hoverImageId",
2491
2922
  "clickImageId",
@@ -2510,6 +2941,7 @@ const validateLayoutElementData = ({
2510
2941
  "step",
2511
2942
  "initialValue",
2512
2943
  "variableId",
2944
+ "particleId",
2513
2945
  "fragmentLayoutId",
2514
2946
  "paginationMode",
2515
2947
  "paginationVariableId",
@@ -2607,6 +3039,9 @@ const validateLayoutElementData = ({
2607
3039
 
2608
3040
  for (const key of [
2609
3041
  "text",
3042
+ "resourceId",
3043
+ "animationName",
3044
+ "particleId",
2610
3045
  "imageId",
2611
3046
  "hoverImageId",
2612
3047
  "clickImageId",
@@ -2633,6 +3068,40 @@ const validateLayoutElementData = ({
2633
3068
  }
2634
3069
  }
2635
3070
 
3071
+ if (data.type === "spritesheet-animation") {
3072
+ if (
3073
+ (!allowPartial || data.resourceId !== undefined) &&
3074
+ !isNonEmptyString(data.resourceId)
3075
+ ) {
3076
+ return invalidFromErrorFactory(
3077
+ errorFactory,
3078
+ `${path}.resourceId must be a non-empty string`,
3079
+ );
3080
+ }
3081
+
3082
+ if (
3083
+ (!allowPartial || data.animationName !== undefined) &&
3084
+ !isNonEmptyString(data.animationName)
3085
+ ) {
3086
+ return invalidFromErrorFactory(
3087
+ errorFactory,
3088
+ `${path}.animationName must be a non-empty string`,
3089
+ );
3090
+ }
3091
+ }
3092
+
3093
+ if (data.type === "particle") {
3094
+ if (
3095
+ (!allowPartial || data.particleId !== undefined) &&
3096
+ !isNonEmptyString(data.particleId)
3097
+ ) {
3098
+ return invalidFromErrorFactory(
3099
+ errorFactory,
3100
+ `${path}.particleId must be a non-empty string`,
3101
+ );
3102
+ }
3103
+ }
3104
+
2636
3105
  if (data.conditionalOverrides !== undefined) {
2637
3106
  if (!Array.isArray(data.conditionalOverrides)) {
2638
3107
  return invalidFromErrorFactory(
@@ -2982,6 +3451,9 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2982
3451
  "textStyle",
2983
3452
  "displaySpeed",
2984
3453
  "revealEffect",
3454
+ "resourceId",
3455
+ "animationName",
3456
+ "particleId",
2985
3457
  "imageId",
2986
3458
  "hoverImageId",
2987
3459
  "clickImageId",
@@ -3888,6 +4360,17 @@ const validateCollection = ({ collection, path }) => {
3888
4360
  return result;
3889
4361
  }
3890
4362
  }
4363
+ } else if (path === "state.spritesheets") {
4364
+ {
4365
+ const result = validateSpritesheetItems({
4366
+ items: collection.items,
4367
+ path: `${path}.items`,
4368
+ errorFactory: createStateValidationError,
4369
+ });
4370
+ if (result?.valid === false) {
4371
+ return result;
4372
+ }
4373
+ }
3891
4374
  } else if (path === "state.files") {
3892
4375
  {
3893
4376
  const result = validateFileItems({
@@ -3965,6 +4448,17 @@ const validateCollection = ({ collection, path }) => {
3965
4448
  return result;
3966
4449
  }
3967
4450
  }
4451
+ } else if (path === "state.particles") {
4452
+ {
4453
+ const result = validateParticleItems({
4454
+ items: collection.items,
4455
+ path: `${path}.items`,
4456
+ errorFactory: createStateValidationError,
4457
+ });
4458
+ if (result?.valid === false) {
4459
+ return result;
4460
+ }
4461
+ }
3968
4462
  } else if (path === "state.variables") {
3969
4463
  {
3970
4464
  const result = validateVariableItems({
@@ -4061,6 +4555,18 @@ const validateCollection = ({ collection, path }) => {
4061
4555
  return result;
4062
4556
  }
4063
4557
  }
4558
+ } else if (path === "state.spritesheets") {
4559
+ {
4560
+ const result = validateGenericFolderOwnership({
4561
+ nodes: collection.tree,
4562
+ items: collection.items,
4563
+ path: `${path}.tree`,
4564
+ folderLabel: "folder spritesheet item",
4565
+ });
4566
+ if (result?.valid === false) {
4567
+ return result;
4568
+ }
4569
+ }
4064
4570
  } else if (path === "state.sounds") {
4065
4571
  {
4066
4572
  const result = validateSoundTreeFolderOwnership({
@@ -4118,6 +4624,7 @@ const validateCollection = ({ collection, path }) => {
4118
4624
  }
4119
4625
  } else if (
4120
4626
  path === "state.files" ||
4627
+ path === "state.particles" ||
4121
4628
  path === "state.transforms" ||
4122
4629
  path === "state.variables" ||
4123
4630
  path === "state.textStyles" ||
@@ -4417,6 +4924,43 @@ export const assertInvariants = ({ state }) => {
4417
4924
  }
4418
4925
  }
4419
4926
 
4927
+ for (const [spritesheetId, spritesheet] of Object.entries(
4928
+ state.spritesheets.items,
4929
+ )) {
4930
+ if (spritesheet.type !== "spritesheet") {
4931
+ continue;
4932
+ }
4933
+
4934
+ const result = validateFileReference({
4935
+ state,
4936
+ fileId: spritesheet.fileId,
4937
+ path: "spritesheet.fileId",
4938
+ details: { spritesheetId, fileId: spritesheet.fileId },
4939
+ errorFactory: createInvariantValidationError,
4940
+ });
4941
+ if (!result.valid) {
4942
+ return result;
4943
+ }
4944
+
4945
+ if (spritesheet.thumbnailFileId === undefined) {
4946
+ continue;
4947
+ }
4948
+
4949
+ const thumbnailResult = validateFileReference({
4950
+ state,
4951
+ fileId: spritesheet.thumbnailFileId,
4952
+ path: "spritesheet.thumbnailFileId",
4953
+ details: {
4954
+ spritesheetId,
4955
+ thumbnailFileId: spritesheet.thumbnailFileId,
4956
+ },
4957
+ errorFactory: createInvariantValidationError,
4958
+ });
4959
+ if (!thumbnailResult.valid) {
4960
+ return thumbnailResult;
4961
+ }
4962
+ }
4963
+
4420
4964
  for (const [soundId, sound] of Object.entries(state.sounds.items)) {
4421
4965
  if (sound.type !== "sound") {
4422
4966
  continue;
@@ -4708,6 +5252,68 @@ export const assertInvariants = ({ state }) => {
4708
5252
  return VALID_RESULT;
4709
5253
  };
4710
5254
 
5255
+ const assertSpritesheetAnimationReference = ({
5256
+ ownerIdField,
5257
+ ownerId,
5258
+ ownerLabel,
5259
+ elementId,
5260
+ targetId,
5261
+ animationName,
5262
+ }) => {
5263
+ const spritesheet = state.spritesheets?.items?.[targetId];
5264
+ if (!isPlainObject(spritesheet) || spritesheet.type === "folder") {
5265
+ return invalidInvariant(
5266
+ `${ownerLabel} element resourceId must reference an existing non-folder spritesheet`,
5267
+ {
5268
+ [ownerIdField]: ownerId,
5269
+ elementId,
5270
+ field: "resourceId",
5271
+ targetId,
5272
+ },
5273
+ );
5274
+ }
5275
+
5276
+ if (
5277
+ !isNonEmptyString(animationName) ||
5278
+ !isPlainObject(spritesheet.animations?.[animationName])
5279
+ ) {
5280
+ return invalidInvariant(
5281
+ `${ownerLabel} element animationName must reference an existing spritesheet animation`,
5282
+ {
5283
+ [ownerIdField]: ownerId,
5284
+ elementId,
5285
+ field: "animationName",
5286
+ targetId: animationName,
5287
+ },
5288
+ );
5289
+ }
5290
+
5291
+ return VALID_RESULT;
5292
+ };
5293
+
5294
+ const assertParticleReference = ({
5295
+ ownerIdField,
5296
+ ownerId,
5297
+ ownerLabel,
5298
+ elementId,
5299
+ targetId,
5300
+ }) => {
5301
+ const particle = state.particles?.items?.[targetId];
5302
+ if (!isPlainObject(particle) || particle.type === "folder") {
5303
+ return invalidInvariant(
5304
+ `${ownerLabel} element particleId must reference an existing non-folder particle`,
5305
+ {
5306
+ [ownerIdField]: ownerId,
5307
+ elementId,
5308
+ field: "particleId",
5309
+ targetId,
5310
+ },
5311
+ );
5312
+ }
5313
+
5314
+ return VALID_RESULT;
5315
+ };
5316
+
4711
5317
  const assertElementReferencesForCollection = ({
4712
5318
  items,
4713
5319
  ownerIdField,
@@ -4720,6 +5326,37 @@ export const assertInvariants = ({ state }) => {
4720
5326
  }
4721
5327
 
4722
5328
  for (const [elementId, element] of Object.entries(owner.elements.items)) {
5329
+ if (
5330
+ element.type === "spritesheet-animation" ||
5331
+ element.resourceId !== undefined ||
5332
+ element.animationName !== undefined
5333
+ ) {
5334
+ const result = assertSpritesheetAnimationReference({
5335
+ ownerIdField,
5336
+ ownerId,
5337
+ ownerLabel,
5338
+ elementId,
5339
+ targetId: element.resourceId,
5340
+ animationName: element.animationName,
5341
+ });
5342
+ if (!result.valid) {
5343
+ return result;
5344
+ }
5345
+ }
5346
+
5347
+ if (element.type === "particle" || element.particleId !== undefined) {
5348
+ const result = assertParticleReference({
5349
+ ownerIdField,
5350
+ ownerId,
5351
+ ownerLabel,
5352
+ elementId,
5353
+ targetId: element.particleId,
5354
+ });
5355
+ if (!result.valid) {
5356
+ return result;
5357
+ }
5358
+ }
5359
+
4723
5360
  for (const field of [
4724
5361
  "imageId",
4725
5362
  "hoverImageId",
@@ -5460,7 +6097,234 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
5460
6097
  }
5461
6098
  };
5462
6099
 
5463
- const validateSoundCreateData = ({ data, errorFactory }) => {
6100
+ const validateSpritesheetCreateData = ({ data, errorFactory }) => {
6101
+ if (!isPlainObject(data)) {
6102
+ return invalidFromErrorFactory(
6103
+ errorFactory,
6104
+ "payload.data must be an object",
6105
+ );
6106
+ }
6107
+
6108
+ if (data.type !== "folder" && data.type !== "spritesheet") {
6109
+ return invalidFromErrorFactory(
6110
+ errorFactory,
6111
+ "payload.data.type must be 'folder' or 'spritesheet'",
6112
+ );
6113
+ }
6114
+
6115
+ {
6116
+ const result = validateAllowedKeys({
6117
+ value: data,
6118
+ allowedKeys:
6119
+ data.type === "folder"
6120
+ ? ["type", "name", "description"]
6121
+ : [
6122
+ "type",
6123
+ "name",
6124
+ "description",
6125
+ "thumbnailFileId",
6126
+ "fileId",
6127
+ "fileType",
6128
+ "fileSize",
6129
+ "sheetWidth",
6130
+ "sheetHeight",
6131
+ "frameCount",
6132
+ "width",
6133
+ "height",
6134
+ "jsonData",
6135
+ "animations",
6136
+ ],
6137
+ path: "payload.data",
6138
+ errorFactory,
6139
+ });
6140
+ if (result?.valid === false) {
6141
+ return result;
6142
+ }
6143
+ }
6144
+
6145
+ if (!isNonEmptyString(data.name)) {
6146
+ return invalidFromErrorFactory(
6147
+ errorFactory,
6148
+ "payload.data.name must be a non-empty string",
6149
+ );
6150
+ }
6151
+
6152
+ if (data.description !== undefined && !isString(data.description)) {
6153
+ return invalidFromErrorFactory(
6154
+ errorFactory,
6155
+ "payload.data.description must be a string when provided",
6156
+ );
6157
+ }
6158
+
6159
+ if (data.type === "spritesheet") {
6160
+ if (
6161
+ data.thumbnailFileId !== undefined &&
6162
+ !isNonEmptyString(data.thumbnailFileId)
6163
+ ) {
6164
+ return invalidFromErrorFactory(
6165
+ errorFactory,
6166
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6167
+ );
6168
+ }
6169
+
6170
+ if (!isNonEmptyString(data.fileId)) {
6171
+ return invalidFromErrorFactory(
6172
+ errorFactory,
6173
+ "payload.data.fileId must be a non-empty string",
6174
+ );
6175
+ }
6176
+
6177
+ if (data.fileType !== undefined && !isString(data.fileType)) {
6178
+ return invalidFromErrorFactory(
6179
+ errorFactory,
6180
+ "payload.data.fileType must be a string when provided",
6181
+ );
6182
+ }
6183
+
6184
+ for (const key of [
6185
+ "fileSize",
6186
+ "sheetWidth",
6187
+ "sheetHeight",
6188
+ "frameCount",
6189
+ "width",
6190
+ "height",
6191
+ ]) {
6192
+ if (data[key] !== undefined && !isFiniteNumber(data[key])) {
6193
+ return invalidFromErrorFactory(
6194
+ errorFactory,
6195
+ `payload.data.${key} must be a finite number`,
6196
+ );
6197
+ }
6198
+ }
6199
+
6200
+ if (!isPlainObject(data.jsonData)) {
6201
+ return invalidFromErrorFactory(
6202
+ errorFactory,
6203
+ "payload.data.jsonData must be an object",
6204
+ );
6205
+ }
6206
+
6207
+ {
6208
+ const result = validateSpritesheetAnimationMap({
6209
+ animations: data.animations,
6210
+ path: "payload.data.animations",
6211
+ errorFactory,
6212
+ });
6213
+ if (result?.valid === false) {
6214
+ return result;
6215
+ }
6216
+ }
6217
+ }
6218
+ };
6219
+
6220
+ const validateSpritesheetUpdateData = ({ data, errorFactory }) => {
6221
+ {
6222
+ const result = validateAllowedKeys({
6223
+ value: data,
6224
+ allowedKeys: [
6225
+ "name",
6226
+ "description",
6227
+ "thumbnailFileId",
6228
+ "fileId",
6229
+ "fileType",
6230
+ "fileSize",
6231
+ "sheetWidth",
6232
+ "sheetHeight",
6233
+ "frameCount",
6234
+ "width",
6235
+ "height",
6236
+ "jsonData",
6237
+ "animations",
6238
+ ],
6239
+ path: "payload.data",
6240
+ errorFactory,
6241
+ });
6242
+ if (result?.valid === false) {
6243
+ return result;
6244
+ }
6245
+ }
6246
+
6247
+ if (Object.keys(data).length === 0) {
6248
+ return invalidFromErrorFactory(
6249
+ errorFactory,
6250
+ "payload.data must include at least one updatable field",
6251
+ );
6252
+ }
6253
+
6254
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
6255
+ return invalidFromErrorFactory(
6256
+ errorFactory,
6257
+ "payload.data.name must be a non-empty string when provided",
6258
+ );
6259
+ }
6260
+
6261
+ if (data.description !== undefined && !isString(data.description)) {
6262
+ return invalidFromErrorFactory(
6263
+ errorFactory,
6264
+ "payload.data.description must be a string when provided",
6265
+ );
6266
+ }
6267
+
6268
+ if (
6269
+ data.thumbnailFileId !== undefined &&
6270
+ !isNonEmptyString(data.thumbnailFileId)
6271
+ ) {
6272
+ return invalidFromErrorFactory(
6273
+ errorFactory,
6274
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6275
+ );
6276
+ }
6277
+
6278
+ if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
6279
+ return invalidFromErrorFactory(
6280
+ errorFactory,
6281
+ "payload.data.fileId must be a non-empty string when provided",
6282
+ );
6283
+ }
6284
+
6285
+ if (data.fileType !== undefined && !isString(data.fileType)) {
6286
+ return invalidFromErrorFactory(
6287
+ errorFactory,
6288
+ "payload.data.fileType must be a string when provided",
6289
+ );
6290
+ }
6291
+
6292
+ for (const key of [
6293
+ "fileSize",
6294
+ "sheetWidth",
6295
+ "sheetHeight",
6296
+ "frameCount",
6297
+ "width",
6298
+ "height",
6299
+ ]) {
6300
+ if (data[key] !== undefined && !isFiniteNumber(data[key])) {
6301
+ return invalidFromErrorFactory(
6302
+ errorFactory,
6303
+ `payload.data.${key} must be a finite number`,
6304
+ );
6305
+ }
6306
+ }
6307
+
6308
+ if (data.jsonData !== undefined && !isPlainObject(data.jsonData)) {
6309
+ return invalidFromErrorFactory(
6310
+ errorFactory,
6311
+ "payload.data.jsonData must be an object when provided",
6312
+ );
6313
+ }
6314
+
6315
+ if (data.animations !== undefined) {
6316
+ const result = validateSpritesheetAnimationMap({
6317
+ animations: data.animations,
6318
+ path: "payload.data.animations",
6319
+ errorFactory,
6320
+ });
6321
+ if (result?.valid === false) {
6322
+ return result;
6323
+ }
6324
+ }
6325
+ };
6326
+
6327
+ const validateSoundCreateData = ({ data, errorFactory }) => {
5464
6328
  if (!isPlainObject(data)) {
5465
6329
  return invalidFromErrorFactory(
5466
6330
  errorFactory,
@@ -6230,11 +7094,234 @@ const validateAnimationCreateData = ({ data, errorFactory }) => {
6230
7094
  }
6231
7095
  };
6232
7096
 
6233
- const validateAnimationUpdateData = ({ data, errorFactory }) => {
7097
+ const validateAnimationUpdateData = ({ data, errorFactory }) => {
7098
+ {
7099
+ const result = validateAllowedKeys({
7100
+ value: data,
7101
+ allowedKeys: ["name", "description", "animation"],
7102
+ path: "payload.data",
7103
+ errorFactory,
7104
+ });
7105
+ if (result?.valid === false) {
7106
+ return result;
7107
+ }
7108
+ }
7109
+
7110
+ if (Object.keys(data).length === 0) {
7111
+ return invalidFromErrorFactory(
7112
+ errorFactory,
7113
+ "payload.data must include at least one updatable field",
7114
+ );
7115
+ }
7116
+
7117
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
7118
+ return invalidFromErrorFactory(
7119
+ errorFactory,
7120
+ "payload.data.name must be a non-empty string when provided",
7121
+ );
7122
+ }
7123
+
7124
+ if (data.description !== undefined && !isString(data.description)) {
7125
+ return invalidFromErrorFactory(
7126
+ errorFactory,
7127
+ "payload.data.description must be a string when provided",
7128
+ );
7129
+ }
7130
+
7131
+ if (data.animation !== undefined) {
7132
+ {
7133
+ const result = validateAnimationDefinition({
7134
+ animation: data.animation,
7135
+ path: "payload.data.animation",
7136
+ errorFactory,
7137
+ });
7138
+ if (result?.valid === false) {
7139
+ return result;
7140
+ }
7141
+ }
7142
+ }
7143
+ };
7144
+
7145
+ const validateTransformCreateData = ({ data, errorFactory }) => {
7146
+ if (!isPlainObject(data)) {
7147
+ return invalidFromErrorFactory(
7148
+ errorFactory,
7149
+ "payload.data must be an object",
7150
+ );
7151
+ }
7152
+
7153
+ if (data.type !== "folder" && data.type !== "transform") {
7154
+ return invalidFromErrorFactory(
7155
+ errorFactory,
7156
+ "payload.data.type must be 'folder' or 'transform'",
7157
+ );
7158
+ }
7159
+
7160
+ {
7161
+ const result = validateAllowedKeys({
7162
+ value: data,
7163
+ allowedKeys:
7164
+ data.type === "folder"
7165
+ ? ["type", "name", "description"]
7166
+ : [
7167
+ "type",
7168
+ "name",
7169
+ "description",
7170
+ "x",
7171
+ "y",
7172
+ "scaleX",
7173
+ "scaleY",
7174
+ "anchorX",
7175
+ "anchorY",
7176
+ "rotation",
7177
+ ],
7178
+ path: "payload.data",
7179
+ errorFactory,
7180
+ });
7181
+ if (result?.valid === false) {
7182
+ return result;
7183
+ }
7184
+ }
7185
+
7186
+ if (!isNonEmptyString(data.name)) {
7187
+ return invalidFromErrorFactory(
7188
+ errorFactory,
7189
+ "payload.data.name must be a non-empty string",
7190
+ );
7191
+ }
7192
+
7193
+ if (data.description !== undefined && !isString(data.description)) {
7194
+ return invalidFromErrorFactory(
7195
+ errorFactory,
7196
+ "payload.data.description must be a string when provided",
7197
+ );
7198
+ }
7199
+
7200
+ if (data.type === "transform") {
7201
+ for (const key of [
7202
+ "x",
7203
+ "y",
7204
+ "scaleX",
7205
+ "scaleY",
7206
+ "anchorX",
7207
+ "anchorY",
7208
+ "rotation",
7209
+ ]) {
7210
+ if (!isFiniteNumber(data[key])) {
7211
+ return invalidFromErrorFactory(
7212
+ errorFactory,
7213
+ `payload.data.${key} must be a finite number`,
7214
+ );
7215
+ }
7216
+ }
7217
+ }
7218
+ };
7219
+
7220
+ const validateParticleCreateData = ({ data, errorFactory }) => {
7221
+ if (!isPlainObject(data)) {
7222
+ return invalidFromErrorFactory(
7223
+ errorFactory,
7224
+ "payload.data must be an object",
7225
+ );
7226
+ }
7227
+
7228
+ if (data.type !== "folder" && data.type !== "particle") {
7229
+ return invalidFromErrorFactory(
7230
+ errorFactory,
7231
+ "payload.data.type must be 'folder' or 'particle'",
7232
+ );
7233
+ }
7234
+
7235
+ {
7236
+ const result = validateAllowedKeys({
7237
+ value: data,
7238
+ allowedKeys:
7239
+ data.type === "folder"
7240
+ ? ["type", "name", "description"]
7241
+ : [
7242
+ "type",
7243
+ "name",
7244
+ "description",
7245
+ "width",
7246
+ "height",
7247
+ "seed",
7248
+ "modules",
7249
+ ],
7250
+ path: "payload.data",
7251
+ errorFactory,
7252
+ });
7253
+ if (result?.valid === false) {
7254
+ return result;
7255
+ }
7256
+ }
7257
+
7258
+ if (!isNonEmptyString(data.name)) {
7259
+ return invalidFromErrorFactory(
7260
+ errorFactory,
7261
+ "payload.data.name must be a non-empty string",
7262
+ );
7263
+ }
7264
+
7265
+ if (data.description !== undefined && !isString(data.description)) {
7266
+ return invalidFromErrorFactory(
7267
+ errorFactory,
7268
+ "payload.data.description must be a string when provided",
7269
+ );
7270
+ }
7271
+
7272
+ if (data.type !== "particle") {
7273
+ return;
7274
+ }
7275
+
7276
+ if (!isFiniteNumber(data.width) || data.width <= 0) {
7277
+ return invalidFromErrorFactory(
7278
+ errorFactory,
7279
+ "payload.data.width must be a positive finite number",
7280
+ );
7281
+ }
7282
+
7283
+ if (!isFiniteNumber(data.height) || data.height <= 0) {
7284
+ return invalidFromErrorFactory(
7285
+ errorFactory,
7286
+ "payload.data.height must be a positive finite number",
7287
+ );
7288
+ }
7289
+
7290
+ if (
7291
+ data.seed !== undefined &&
7292
+ data.seed !== null &&
7293
+ !isFiniteNumber(data.seed)
7294
+ ) {
7295
+ return invalidFromErrorFactory(
7296
+ errorFactory,
7297
+ "payload.data.seed must be a finite number when provided",
7298
+ );
7299
+ }
7300
+
7301
+ {
7302
+ const result = validateParticleModules({
7303
+ modules: data.modules,
7304
+ path: "payload.data.modules",
7305
+ errorFactory,
7306
+ });
7307
+ if (result?.valid === false) {
7308
+ return result;
7309
+ }
7310
+ }
7311
+ };
7312
+
7313
+ const validateParticleUpdateData = ({ data, errorFactory }) => {
6234
7314
  {
6235
7315
  const result = validateAllowedKeys({
6236
7316
  value: data,
6237
- allowedKeys: ["name", "description", "animation"],
7317
+ allowedKeys: [
7318
+ "name",
7319
+ "description",
7320
+ "width",
7321
+ "height",
7322
+ "seed",
7323
+ "modules",
7324
+ ],
6238
7325
  path: "payload.data",
6239
7326
  errorFactory,
6240
7327
  });
@@ -6264,90 +7351,46 @@ const validateAnimationUpdateData = ({ data, errorFactory }) => {
6264
7351
  );
6265
7352
  }
6266
7353
 
6267
- if (data.animation !== undefined) {
6268
- {
6269
- const result = validateAnimationDefinition({
6270
- animation: data.animation,
6271
- path: "payload.data.animation",
6272
- errorFactory,
6273
- });
6274
- if (result?.valid === false) {
6275
- return result;
6276
- }
6277
- }
6278
- }
6279
- };
6280
-
6281
- const validateTransformCreateData = ({ data, errorFactory }) => {
6282
- if (!isPlainObject(data)) {
6283
- return invalidFromErrorFactory(
6284
- errorFactory,
6285
- "payload.data must be an object",
6286
- );
6287
- }
6288
-
6289
- if (data.type !== "folder" && data.type !== "transform") {
7354
+ if (
7355
+ data.width !== undefined &&
7356
+ (!isFiniteNumber(data.width) || data.width <= 0)
7357
+ ) {
6290
7358
  return invalidFromErrorFactory(
6291
7359
  errorFactory,
6292
- "payload.data.type must be 'folder' or 'transform'",
7360
+ "payload.data.width must be a positive finite number when provided",
6293
7361
  );
6294
7362
  }
6295
7363
 
6296
- {
6297
- const result = validateAllowedKeys({
6298
- value: data,
6299
- allowedKeys:
6300
- data.type === "folder"
6301
- ? ["type", "name", "description"]
6302
- : [
6303
- "type",
6304
- "name",
6305
- "description",
6306
- "x",
6307
- "y",
6308
- "scaleX",
6309
- "scaleY",
6310
- "anchorX",
6311
- "anchorY",
6312
- "rotation",
6313
- ],
6314
- path: "payload.data",
6315
- errorFactory,
6316
- });
6317
- if (result?.valid === false) {
6318
- return result;
6319
- }
6320
- }
6321
-
6322
- if (!isNonEmptyString(data.name)) {
7364
+ if (
7365
+ data.height !== undefined &&
7366
+ (!isFiniteNumber(data.height) || data.height <= 0)
7367
+ ) {
6323
7368
  return invalidFromErrorFactory(
6324
7369
  errorFactory,
6325
- "payload.data.name must be a non-empty string",
7370
+ "payload.data.height must be a positive finite number when provided",
6326
7371
  );
6327
7372
  }
6328
7373
 
6329
- if (data.description !== undefined && !isString(data.description)) {
7374
+ if (
7375
+ data.seed !== undefined &&
7376
+ data.seed !== null &&
7377
+ !isFiniteNumber(data.seed)
7378
+ ) {
6330
7379
  return invalidFromErrorFactory(
6331
7380
  errorFactory,
6332
- "payload.data.description must be a string when provided",
7381
+ "payload.data.seed must be a finite number when provided",
6333
7382
  );
6334
7383
  }
6335
7384
 
6336
- if (data.type === "transform") {
6337
- for (const key of [
6338
- "x",
6339
- "y",
6340
- "scaleX",
6341
- "scaleY",
6342
- "anchorX",
6343
- "anchorY",
6344
- "rotation",
6345
- ]) {
6346
- if (!isFiniteNumber(data[key])) {
6347
- return invalidFromErrorFactory(
6348
- errorFactory,
6349
- `payload.data.${key} must be a finite number`,
6350
- );
7385
+ if (data.modules !== undefined) {
7386
+ {
7387
+ const result = validateParticleModules({
7388
+ modules: data.modules,
7389
+ path: "payload.data.modules",
7390
+ errorFactory,
7391
+ });
7392
+ if (result?.valid === false) {
7393
+ return result;
6351
7394
  }
6352
7395
  }
6353
7396
  }
@@ -6463,7 +7506,7 @@ const validateVariableCreateData = ({ data, errorFactory }) => {
6463
7506
  if (!VARIABLE_SCOPE_KEYS.includes(data.scope)) {
6464
7507
  return invalidFromErrorFactory(
6465
7508
  errorFactory,
6466
- "payload.data.scope must be 'context', 'global-device', or 'global-account'",
7509
+ "payload.data.scope must be 'context', 'device', or 'account'",
6467
7510
  );
6468
7511
  }
6469
7512
 
@@ -6529,7 +7572,7 @@ const validateVariableUpdateData = ({ data, errorFactory }) => {
6529
7572
  if (data.scope !== undefined && !VARIABLE_SCOPE_KEYS.includes(data.scope)) {
6530
7573
  return invalidFromErrorFactory(
6531
7574
  errorFactory,
6532
- "payload.data.scope must be 'context', 'global-device', or 'global-account' when provided",
7575
+ "payload.data.scope must be 'context', 'device', or 'account' when provided",
6533
7576
  );
6534
7577
  }
6535
7578
  };
@@ -7433,6 +8476,58 @@ const validateVisualElementReferenceTargets = ({
7433
8476
  state,
7434
8477
  errorFactory,
7435
8478
  }) => {
8479
+ if (
8480
+ data.type === "spritesheet-animation" ||
8481
+ data.resourceId !== undefined ||
8482
+ data.animationName !== undefined
8483
+ ) {
8484
+ const spritesheet = state.spritesheets?.items?.[data.resourceId];
8485
+ if (!isPlainObject(spritesheet) || spritesheet.type === "folder") {
8486
+ return invalidFromErrorFactory(
8487
+ errorFactory,
8488
+ `${ownerLabel} element resourceId must reference an existing non-folder spritesheet`,
8489
+ {
8490
+ [ownerIdField]: ownerId,
8491
+ elementId,
8492
+ field: "resourceId",
8493
+ targetId: data.resourceId,
8494
+ },
8495
+ );
8496
+ }
8497
+
8498
+ if (
8499
+ !isNonEmptyString(data.animationName) ||
8500
+ !isPlainObject(spritesheet.animations?.[data.animationName])
8501
+ ) {
8502
+ return invalidFromErrorFactory(
8503
+ errorFactory,
8504
+ `${ownerLabel} element animationName must reference an existing spritesheet animation`,
8505
+ {
8506
+ [ownerIdField]: ownerId,
8507
+ elementId,
8508
+ field: "animationName",
8509
+ targetId: data.animationName,
8510
+ },
8511
+ );
8512
+ }
8513
+ }
8514
+
8515
+ if (data.type === "particle" || data.particleId !== undefined) {
8516
+ const particle = state.particles?.items?.[data.particleId];
8517
+ if (!isPlainObject(particle) || particle.type === "folder") {
8518
+ return invalidFromErrorFactory(
8519
+ errorFactory,
8520
+ `${ownerLabel} element particleId must reference an existing non-folder particle`,
8521
+ {
8522
+ [ownerIdField]: ownerId,
8523
+ elementId,
8524
+ field: "particleId",
8525
+ targetId: data.particleId,
8526
+ },
8527
+ );
8528
+ }
8529
+ }
8530
+
7436
8531
  if (data.imageId !== undefined) {
7437
8532
  const image = state.images.items[data.imageId];
7438
8533
  if (!isPlainObject(image) || image.type === "folder") {
@@ -8246,6 +9341,30 @@ const findReferencedFileUsage = ({ state, fileId }) => {
8246
9341
  }
8247
9342
  }
8248
9343
 
9344
+ for (const [spritesheetId, spritesheet] of Object.entries(
9345
+ state.spritesheets.items,
9346
+ )) {
9347
+ if (spritesheet.type !== "spritesheet") {
9348
+ continue;
9349
+ }
9350
+
9351
+ if (spritesheet.fileId === fileId) {
9352
+ return {
9353
+ kind: "spritesheet",
9354
+ field: "fileId",
9355
+ ownerId: spritesheetId,
9356
+ };
9357
+ }
9358
+
9359
+ if (spritesheet.thumbnailFileId === fileId) {
9360
+ return {
9361
+ kind: "spritesheet",
9362
+ field: "thumbnailFileId",
9363
+ ownerId: spritesheetId,
9364
+ };
9365
+ }
9366
+ }
9367
+
8249
9368
  for (const [soundId, sound] of Object.entries(state.sounds.items)) {
8250
9369
  if (sound.type !== "sound") {
8251
9370
  continue;
@@ -8439,6 +9558,108 @@ const COMMAND_DEFINITIONS = [
8439
9558
  }
8440
9559
  },
8441
9560
  }),
9561
+ ...createFolderedCollectionCommandDefinitions({
9562
+ familyName: "spritesheet",
9563
+ collectionKey: "spritesheets",
9564
+ idField: "spritesheetId",
9565
+ itemLabel: "spritesheet item",
9566
+ createDataValidator: validateSpritesheetCreateData,
9567
+ updateDataValidator: validateSpritesheetUpdateData,
9568
+ createItem: ({ payload }) => ({
9569
+ id: payload.spritesheetId,
9570
+ type: payload.data.type,
9571
+ name: payload.data.name,
9572
+ ...(payload.data.description !== undefined
9573
+ ? {
9574
+ description: payload.data.description,
9575
+ }
9576
+ : {}),
9577
+ ...(payload.data.type === "spritesheet"
9578
+ ? {
9579
+ fileId: payload.data.fileId,
9580
+ ...(payload.data.thumbnailFileId !== undefined
9581
+ ? {
9582
+ thumbnailFileId: payload.data.thumbnailFileId,
9583
+ }
9584
+ : {}),
9585
+ ...(payload.data.fileType !== undefined
9586
+ ? {
9587
+ fileType: payload.data.fileType,
9588
+ }
9589
+ : {}),
9590
+ ...(payload.data.fileSize !== undefined
9591
+ ? {
9592
+ fileSize: payload.data.fileSize,
9593
+ }
9594
+ : {}),
9595
+ ...(payload.data.sheetWidth !== undefined
9596
+ ? {
9597
+ sheetWidth: payload.data.sheetWidth,
9598
+ }
9599
+ : {}),
9600
+ ...(payload.data.sheetHeight !== undefined
9601
+ ? {
9602
+ sheetHeight: payload.data.sheetHeight,
9603
+ }
9604
+ : {}),
9605
+ ...(payload.data.frameCount !== undefined
9606
+ ? {
9607
+ frameCount: payload.data.frameCount,
9608
+ }
9609
+ : {}),
9610
+ ...(payload.data.width !== undefined
9611
+ ? {
9612
+ width: payload.data.width,
9613
+ }
9614
+ : {}),
9615
+ ...(payload.data.height !== undefined
9616
+ ? {
9617
+ height: payload.data.height,
9618
+ }
9619
+ : {}),
9620
+ jsonData: structuredClone(payload.data.jsonData),
9621
+ animations: structuredClone(payload.data.animations),
9622
+ }
9623
+ : {}),
9624
+ }),
9625
+ validateCreateState: ({ state, payload }) => {
9626
+ if (payload.data.type !== "spritesheet") {
9627
+ return;
9628
+ }
9629
+
9630
+ return validateReferencedFilesInData({
9631
+ state,
9632
+ data: payload.data,
9633
+ fields: ["fileId", "thumbnailFileId"],
9634
+ details: {
9635
+ spritesheetId: payload.spritesheetId,
9636
+ },
9637
+ });
9638
+ },
9639
+ validateUpdateState: ({ state, payload, currentItem }) => {
9640
+ if (
9641
+ currentItem.type === "folder" &&
9642
+ Object.keys(payload.data).some(
9643
+ (key) => key !== "name" && key !== "description",
9644
+ )
9645
+ ) {
9646
+ return invalidPrecondition(
9647
+ "folder spritesheet items cannot update spritesheet fields",
9648
+ );
9649
+ }
9650
+
9651
+ if (currentItem.type === "spritesheet") {
9652
+ return validateReferencedFilesInData({
9653
+ state,
9654
+ data: payload.data,
9655
+ fields: ["fileId", "thumbnailFileId"],
9656
+ details: {
9657
+ spritesheetId: payload.spritesheetId,
9658
+ },
9659
+ });
9660
+ }
9661
+ },
9662
+ }),
8442
9663
  {
8443
9664
  type: "story.update",
8444
9665
  validatePayload: ({ payload }) => {
@@ -12127,6 +13348,63 @@ const COMMAND_DEFINITIONS = [
12127
13348
  return state;
12128
13349
  },
12129
13350
  },
13351
+ ...createFolderedCollectionCommandDefinitions({
13352
+ familyName: "particle",
13353
+ collectionKey: "particles",
13354
+ idField: "particleId",
13355
+ itemLabel: "particle item",
13356
+ createDataValidator: validateParticleCreateData,
13357
+ updateDataValidator: validateParticleUpdateData,
13358
+ createItem: ({ payload }) => {
13359
+ const item = {
13360
+ id: payload.particleId,
13361
+ type: payload.data.type,
13362
+ name: payload.data.name,
13363
+ };
13364
+
13365
+ if (payload.data.description !== undefined) {
13366
+ item.description = payload.data.description;
13367
+ }
13368
+
13369
+ if (payload.data.type !== "particle") {
13370
+ return item;
13371
+ }
13372
+
13373
+ item.width = payload.data.width;
13374
+ item.height = payload.data.height;
13375
+ item.modules = structuredClone(payload.data.modules);
13376
+
13377
+ if (payload.data.seed !== undefined && payload.data.seed !== null) {
13378
+ item.seed = payload.data.seed;
13379
+ }
13380
+
13381
+ return item;
13382
+ },
13383
+ updateItem: ({ currentItem, payload }) => {
13384
+ const nextItem = {
13385
+ ...structuredClone(currentItem),
13386
+ ...structuredClone(payload.data),
13387
+ };
13388
+
13389
+ if (payload.data.seed === null) {
13390
+ delete nextItem.seed;
13391
+ }
13392
+
13393
+ return nextItem;
13394
+ },
13395
+ validateUpdateState: ({ payload, currentItem }) => {
13396
+ if (
13397
+ currentItem.type === "folder" &&
13398
+ Object.keys(payload.data).some(
13399
+ (key) => key !== "name" && key !== "description",
13400
+ )
13401
+ ) {
13402
+ return invalidPrecondition(
13403
+ "folder particle items cannot update particle fields",
13404
+ );
13405
+ }
13406
+ },
13407
+ }),
12130
13408
  ...createFolderedCollectionCommandDefinitions({
12131
13409
  familyName: "transform",
12132
13410
  collectionKey: "transforms",
@@ -14134,8 +15412,11 @@ export const validateAgainstState = ({ state, command }) => {
14134
15412
  export const processCommand = ({ state, command }) => {
14135
15413
  return captureValidation(() => {
14136
15414
  const normalizedState = normalizeStateCollections(state);
14137
- const shouldMaterializeControls =
14138
- typeof command?.type === "string" && command.type.startsWith("control.");
15415
+ const shouldMaterializeNormalizedState =
15416
+ typeof command?.type === "string" &&
15417
+ (command.type.startsWith("control.") ||
15418
+ command.type.startsWith("spritesheet.") ||
15419
+ command.type.startsWith("particle."));
14139
15420
 
14140
15421
  if (!isPlainObject(command)) {
14141
15422
  return invalidPrecondition("command must be an object");
@@ -14156,7 +15437,7 @@ export const processCommand = ({ state, command }) => {
14156
15437
 
14157
15438
  const nextState = definition.reduce({
14158
15439
  state: structuredClone(
14159
- shouldMaterializeControls ? normalizedState : state,
15440
+ shouldMaterializeNormalizedState ? normalizedState : state,
14160
15441
  ),
14161
15442
  payload: command.payload,
14162
15443
  });
@@ -14166,7 +15447,7 @@ export const processCommand = ({ state, command }) => {
14166
15447
 
14167
15448
  const finalState =
14168
15449
  nextState === undefined
14169
- ? shouldMaterializeControls
15450
+ ? shouldMaterializeNormalizedState
14170
15451
  ? normalizedState
14171
15452
  : state
14172
15453
  : nextState;