@routevn/creator-model 1.1.11 → 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 +37 -0
  2. package/package.json +5 -2
  3. package/src/model.js +852 -26
package/README.md CHANGED
@@ -5,6 +5,9 @@ Shared RouteVN domain model package.
5
5
  Repo rules and contribution expectations are in
6
6
  [GUIDELINES.md](./GUIDELINES.md).
7
7
 
8
+ Schema compatibility maintenance rules are in
9
+ [docs/schema-compatibility.md](./docs/schema-compatibility.md).
10
+
8
11
  This repo is intended to be the single source of truth for:
9
12
 
10
13
  - state validation
@@ -79,6 +82,9 @@ Design rules:
79
82
  - pure functions whenever possible
80
83
  - command payload shape is validated separately from state-aware preconditions
81
84
  - `SCHEMA_VERSION` is the source of truth for persisted command schema versioning
85
+ - `SCHEMA_VERSION` must stay aligned with the minor version from `package.json`
86
+ - patch releases must not change persisted schema compatibility
87
+ - `bun run test:compat` is the required compatibility gate for model changes
82
88
  - `processCommand()` is the authoritative state transition
83
89
  - model state should contain project-owned runtime data only
84
90
  - app-owned metadata like project id, name, and description should stay out of
@@ -190,6 +196,25 @@ YAML Puty specs use [tests/support/putyApi.js](./tests/support/putyApi.js) as a
190
196
  small adapter so the declarative `throws:` assertions can stay concise while the
191
197
  real public API returns `{ valid: ... }` result objects.
192
198
 
199
+ Compatibility fixtures live under `tests/compat/schema-<n>/`.
200
+
201
+ - `payloads/` fixtures are frozen command payload shapes for that schema version
202
+ - `states/` fixtures are frozen persisted-state snapshots for that schema version
203
+ - `streams/` fixtures are frozen command sequences for that schema version
204
+ - current tests must continue to validate/replay every archived compatibility
205
+ fixture from the same or older schema versions
206
+ - current schema payload coverage must include `minimal.yaml` and `full.yaml` for
207
+ every public command type
208
+
209
+ See also:
210
+
211
+ - [docs/schema-compatibility.md](./docs/schema-compatibility.md)
212
+
213
+ Current animation update tween properties support either:
214
+
215
+ - `keyframes`
216
+ - `auto: { duration, easing }`
217
+
193
218
  ## Current Scope
194
219
 
195
220
  Currently implemented command types:
@@ -212,6 +237,10 @@ Currently implemented command types:
212
237
  - `image.update`
213
238
  - `image.delete`
214
239
  - `image.move`
240
+ - `spritesheet.create`
241
+ - `spritesheet.update`
242
+ - `spritesheet.delete`
243
+ - `spritesheet.move`
215
244
  - `sound.create`
216
245
  - `sound.update`
217
246
  - `sound.delete`
@@ -260,6 +289,14 @@ Currently implemented command types:
260
289
  - `layout.element.update`
261
290
  - `layout.element.delete`
262
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`
263
300
 
264
301
  The rest of the future command surface should be added only when full
265
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.11",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,10 +27,13 @@
27
27
  },
28
28
  "scripts": {
29
29
  "test": "bunx vitest run",
30
+ "test:compat": "bunx vitest run tests/compatibility-fixtures.test.js",
30
31
  "test:watch": "bunx vitest",
31
- "bench": "bun scripts/benchmark-process-command.js"
32
+ "bench": "bun scripts/benchmark-process-command.js",
33
+ "generate:compat-fixtures": "node scripts/generate-compat-fixtures.js"
32
34
  },
33
35
  "devDependencies": {
36
+ "js-yaml": "^4.1.0",
34
37
  "puty": "0.1.2",
35
38
  "vitest": "^3.2.1"
36
39
  }
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",
@@ -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) {
@@ -1357,30 +1644,21 @@ const validateMaskDefinition = ({ mask, path, errorFactory }) => {
1357
1644
  }
1358
1645
  }
1359
1646
 
1360
- if (
1361
- item.texture !== undefined &&
1362
- !isNonEmptyString(item.texture)
1363
- ) {
1647
+ if (item.texture !== undefined && !isNonEmptyString(item.texture)) {
1364
1648
  return invalidFromErrorFactory(
1365
1649
  errorFactory,
1366
1650
  `${itemPath}.texture must be a non-empty string when provided`,
1367
1651
  );
1368
1652
  }
1369
1653
 
1370
- if (
1371
- item.imageId !== undefined &&
1372
- !isNonEmptyString(item.imageId)
1373
- ) {
1654
+ if (item.imageId !== undefined && !isNonEmptyString(item.imageId)) {
1374
1655
  return invalidFromErrorFactory(
1375
1656
  errorFactory,
1376
1657
  `${itemPath}.imageId must be a non-empty string when provided`,
1377
1658
  );
1378
1659
  }
1379
1660
 
1380
- if (
1381
- !isNonEmptyString(item.texture) &&
1382
- !isNonEmptyString(item.imageId)
1383
- ) {
1661
+ if (!isNonEmptyString(item.texture) && !isNonEmptyString(item.imageId)) {
1384
1662
  return invalidFromErrorFactory(
1385
1663
  errorFactory,
1386
1664
  `${itemPath} must define texture or imageId`,
@@ -1556,6 +1834,8 @@ const validateAnimationDefinition = ({ animation, path, errorFactory }) => {
1556
1834
  allowedProperties: UPDATE_TWEEN_PROPERTY_KEYS,
1557
1835
  path: `${path}.tween`,
1558
1836
  unsupportedMessage: "is not a supported update tween property",
1837
+ allowEmptyKeyframes: true,
1838
+ allowAuto: true,
1559
1839
  errorFactory,
1560
1840
  });
1561
1841
  if (result?.valid === false) {
@@ -1987,7 +2267,15 @@ const validateVariableItems = ({ items, path, errorFactory }) => {
1987
2267
  allowedKeys:
1988
2268
  variableType === "folder"
1989
2269
  ? ["id", "type", "name", "description"]
1990
- : ["id", "type", "name", "description", "scope", "default", "value"],
2270
+ : [
2271
+ "id",
2272
+ "type",
2273
+ "name",
2274
+ "description",
2275
+ "scope",
2276
+ "default",
2277
+ "value",
2278
+ ],
1991
2279
  path: itemPath,
1992
2280
  errorFactory,
1993
2281
  });
@@ -2487,6 +2775,8 @@ const validateLayoutElementData = ({
2487
2775
  "textStyle",
2488
2776
  "displaySpeed",
2489
2777
  "revealEffect",
2778
+ "resourceId",
2779
+ "animationName",
2490
2780
  "imageId",
2491
2781
  "hoverImageId",
2492
2782
  "clickImageId",
@@ -2608,6 +2898,8 @@ const validateLayoutElementData = ({
2608
2898
 
2609
2899
  for (const key of [
2610
2900
  "text",
2901
+ "resourceId",
2902
+ "animationName",
2611
2903
  "imageId",
2612
2904
  "hoverImageId",
2613
2905
  "clickImageId",
@@ -2634,6 +2926,28 @@ const validateLayoutElementData = ({
2634
2926
  }
2635
2927
  }
2636
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
+
2637
2951
  if (data.conditionalOverrides !== undefined) {
2638
2952
  if (!Array.isArray(data.conditionalOverrides)) {
2639
2953
  return invalidFromErrorFactory(
@@ -2983,6 +3297,8 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2983
3297
  "textStyle",
2984
3298
  "displaySpeed",
2985
3299
  "revealEffect",
3300
+ "resourceId",
3301
+ "animationName",
2986
3302
  "imageId",
2987
3303
  "hoverImageId",
2988
3304
  "clickImageId",
@@ -3889,6 +4205,17 @@ const validateCollection = ({ collection, path }) => {
3889
4205
  return result;
3890
4206
  }
3891
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
+ }
3892
4219
  } else if (path === "state.files") {
3893
4220
  {
3894
4221
  const result = validateFileItems({
@@ -4062,9 +4389,21 @@ const validateCollection = ({ collection, path }) => {
4062
4389
  return result;
4063
4390
  }
4064
4391
  }
4065
- } else if (path === "state.sounds") {
4392
+ } else if (path === "state.spritesheets") {
4066
4393
  {
4067
- const result = validateSoundTreeFolderOwnership({
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
+ }
4404
+ } else if (path === "state.sounds") {
4405
+ {
4406
+ const result = validateSoundTreeFolderOwnership({
4068
4407
  nodes: collection.tree,
4069
4408
  items: collection.items,
4070
4409
  path: `${path}.tree`,
@@ -4418,6 +4757,43 @@ export const assertInvariants = ({ state }) => {
4418
4757
  }
4419
4758
  }
4420
4759
 
4760
+ for (const [spritesheetId, spritesheet] of Object.entries(
4761
+ state.spritesheets.items,
4762
+ )) {
4763
+ if (spritesheet.type !== "spritesheet") {
4764
+ continue;
4765
+ }
4766
+
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
+
4421
4797
  for (const [soundId, sound] of Object.entries(state.sounds.items)) {
4422
4798
  if (sound.type !== "sound") {
4423
4799
  continue;
@@ -4502,7 +4878,9 @@ export const assertInvariants = ({ state }) => {
4502
4878
  }
4503
4879
  }
4504
4880
 
4505
- for (const [animationId, animation] of Object.entries(state.animations.items)) {
4881
+ for (const [animationId, animation] of Object.entries(
4882
+ state.animations.items,
4883
+ )) {
4506
4884
  if (animation.type !== "animation") {
4507
4885
  continue;
4508
4886
  }
@@ -4707,6 +5085,45 @@ export const assertInvariants = ({ state }) => {
4707
5085
  return VALID_RESULT;
4708
5086
  };
4709
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
+
4710
5127
  const assertElementReferencesForCollection = ({
4711
5128
  items,
4712
5129
  ownerIdField,
@@ -4719,6 +5136,24 @@ export const assertInvariants = ({ state }) => {
4719
5136
  }
4720
5137
 
4721
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
+
4722
5157
  for (const field of [
4723
5158
  "imageId",
4724
5159
  "hoverImageId",
@@ -5459,6 +5894,233 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
5459
5894
  }
5460
5895
  };
5461
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
+
5462
6124
  const validateSoundCreateData = ({ data, errorFactory }) => {
5463
6125
  if (!isPlainObject(data)) {
5464
6126
  return invalidFromErrorFactory(
@@ -7432,6 +8094,42 @@ const validateVisualElementReferenceTargets = ({
7432
8094
  state,
7433
8095
  errorFactory,
7434
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
+
7435
8133
  if (data.imageId !== undefined) {
7436
8134
  const image = state.images.items[data.imageId];
7437
8135
  if (!isPlainObject(image) || image.type === "folder") {
@@ -8245,6 +8943,30 @@ const findReferencedFileUsage = ({ state, fileId }) => {
8245
8943
  }
8246
8944
  }
8247
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
+
8248
8970
  for (const [soundId, sound] of Object.entries(state.sounds.items)) {
8249
8971
  if (sound.type !== "sound") {
8250
8972
  continue;
@@ -8438,6 +9160,108 @@ const COMMAND_DEFINITIONS = [
8438
9160
  }
8439
9161
  },
8440
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
+ }),
8441
9265
  {
8442
9266
  type: "story.update",
8443
9267
  validatePayload: ({ payload }) => {
@@ -14133,8 +14957,10 @@ export const validateAgainstState = ({ state, command }) => {
14133
14957
  export const processCommand = ({ state, command }) => {
14134
14958
  return captureValidation(() => {
14135
14959
  const normalizedState = normalizeStateCollections(state);
14136
- const shouldMaterializeControls =
14137
- 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."));
14138
14964
 
14139
14965
  if (!isPlainObject(command)) {
14140
14966
  return invalidPrecondition("command must be an object");
@@ -14155,7 +14981,7 @@ export const processCommand = ({ state, command }) => {
14155
14981
 
14156
14982
  const nextState = definition.reduce({
14157
14983
  state: structuredClone(
14158
- shouldMaterializeControls ? normalizedState : state,
14984
+ shouldMaterializeNormalizedState ? normalizedState : state,
14159
14985
  ),
14160
14986
  payload: command.payload,
14161
14987
  });
@@ -14165,7 +14991,7 @@ export const processCommand = ({ state, command }) => {
14165
14991
 
14166
14992
  const finalState =
14167
14993
  nextState === undefined
14168
- ? shouldMaterializeControls
14994
+ ? shouldMaterializeNormalizedState
14169
14995
  ? normalizedState
14170
14996
  : state
14171
14997
  : nextState;