@routevn/creator-model 1.0.3 → 1.0.5

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +925 -206
package/src/model.js CHANGED
@@ -30,36 +30,28 @@ const COLLECTION_KEYS = [
30
30
  "textStyles",
31
31
  "variables",
32
32
  "layouts",
33
+ "controls",
33
34
  ];
34
35
  const ROOT_KEYS = ["project", "story", ...COLLECTION_KEYS];
35
- const isString = (value) => typeof value === "string";
36
- const FILE_ITEM_TYPES = [
37
- "image",
38
- "image-thumbnail",
39
- "audio",
40
- "audio-waveform",
41
- "video",
42
- "video-thumbnail",
43
- "font",
44
- ];
45
- const IMAGE_FILE_REFERENCE_TYPES = {
46
- fileId: ["image"],
47
- thumbnailFileId: ["image-thumbnail"],
48
- };
49
- const SOUND_FILE_REFERENCE_TYPES = {
50
- fileId: ["audio"],
51
- waveformDataFileId: ["audio-waveform"],
52
- };
53
- const VIDEO_FILE_REFERENCE_TYPES = {
54
- fileId: ["video"],
55
- thumbnailFileId: ["video-thumbnail"],
56
- };
57
- const FONT_FILE_REFERENCE_TYPES = {
58
- fileId: ["font"],
59
- };
60
- const CHARACTER_FILE_REFERENCE_TYPES = {
61
- fileId: ["image"],
36
+ const createEmptyCollectionState = () => ({
37
+ items: {},
38
+ tree: [],
39
+ });
40
+ const normalizeStateCollections = (state) => {
41
+ if (!isPlainObject(state)) {
42
+ return state;
43
+ }
44
+
45
+ if (state.controls !== undefined) {
46
+ return state;
47
+ }
48
+
49
+ return {
50
+ ...state,
51
+ controls: createEmptyCollectionState(),
52
+ };
62
53
  };
54
+ const isString = (value) => typeof value === "string";
63
55
  const isHexColor = (value) =>
64
56
  typeof value === "string" && /^#[0-9a-fA-F]{6}$/.test(value);
65
57
  const LIVE_TWEEN_PROPERTY_KEYS = [
@@ -115,7 +107,7 @@ const ANIMATION_EASING_KEYS = [
115
107
  ];
116
108
  const VARIABLE_SCOPE_KEYS = ["context", "global-device", "global-account"];
117
109
  const VARIABLE_TYPE_KEYS = ["string", "number", "boolean"];
118
- const LAYOUT_TYPE_KEYS = ["normal", "dialogue", "nvl", "choice", "base"];
110
+ const LAYOUT_TYPE_KEYS = ["normal", "dialogue", "nvl", "choice"];
119
111
  const LAYOUT_ELEMENT_TEXT_STYLE_ALIGN_KEYS = ["left", "center", "right"];
120
112
  const LAYOUT_ELEMENT_BASE_TYPES = [
121
113
  "folder",
@@ -562,10 +554,10 @@ const validateFileItems = ({ items, path, errorFactory }) => {
562
554
  for (const [itemId, item] of Object.entries(items)) {
563
555
  const itemPath = `${path}.${itemId}`;
564
556
 
565
- if (item?.type !== "folder" && !FILE_ITEM_TYPES.includes(item?.type)) {
557
+ if (item?.type !== undefined && !isNonEmptyString(item.type)) {
566
558
  return invalidFromErrorFactory(
567
559
  errorFactory,
568
- `${itemPath}.type must be 'folder' or a supported file type`,
560
+ `${itemPath}.type must be a non-empty string when provided`,
569
561
  );
570
562
  }
571
563
 
@@ -2588,7 +2580,7 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2588
2580
  allowedKeys:
2589
2581
  item.type === "folder"
2590
2582
  ? ["id", "type", "name"]
2591
- : ["id", "type", "name", "layoutType", "elements", "keyboard"],
2583
+ : ["id", "type", "name", "layoutType", "elements"],
2592
2584
  path: itemPath,
2593
2585
  errorFactory,
2594
2586
  });
@@ -2622,7 +2614,7 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2622
2614
  if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
2623
2615
  return invalidFromErrorFactory(
2624
2616
  errorFactory,
2625
- `${itemPath}.layoutType must be 'normal', 'dialogue', 'nvl', 'choice', or 'base'`,
2617
+ `${itemPath}.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'`,
2626
2618
  );
2627
2619
  }
2628
2620
 
@@ -2639,6 +2631,71 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2639
2631
  }
2640
2632
  }
2641
2633
 
2634
+ }
2635
+ }
2636
+ };
2637
+
2638
+ const validateControlItems = ({ items, path, errorFactory }) => {
2639
+ for (const [itemId, item] of Object.entries(items)) {
2640
+ const itemPath = `${path}.${itemId}`;
2641
+
2642
+ if (item?.type !== "folder" && item?.type !== "control") {
2643
+ return invalidFromErrorFactory(
2644
+ errorFactory,
2645
+ `${itemPath}.type must be 'folder' or 'control'`,
2646
+ );
2647
+ }
2648
+
2649
+ {
2650
+ const result = validateAllowedKeys({
2651
+ value: item,
2652
+ allowedKeys:
2653
+ item.type === "folder"
2654
+ ? ["id", "type", "name"]
2655
+ : ["id", "type", "name", "elements", "keyboard"],
2656
+ path: itemPath,
2657
+ errorFactory,
2658
+ });
2659
+ if (result?.valid === false) {
2660
+ return result;
2661
+ }
2662
+ }
2663
+
2664
+ if (!isNonEmptyString(item.id)) {
2665
+ return invalidFromErrorFactory(
2666
+ errorFactory,
2667
+ `${itemPath}.id must be a non-empty string`,
2668
+ );
2669
+ }
2670
+
2671
+ if (item.id !== itemId) {
2672
+ return invalidFromErrorFactory(
2673
+ errorFactory,
2674
+ `${itemPath}.id must match item key '${itemId}'`,
2675
+ );
2676
+ }
2677
+
2678
+ if (!isNonEmptyString(item.name)) {
2679
+ return invalidFromErrorFactory(
2680
+ errorFactory,
2681
+ `${itemPath}.name must be a non-empty string`,
2682
+ );
2683
+ }
2684
+
2685
+ if (item.type === "control") {
2686
+ {
2687
+ const result = validateNestedCollection({
2688
+ collection: item.elements,
2689
+ path: `${itemPath}.elements`,
2690
+ itemValidator: validateLayoutElementItems,
2691
+ treeValidator: validateLayoutElementTreeOwnership,
2692
+ treeNodeLabel: "control element",
2693
+ });
2694
+ if (result?.valid === false) {
2695
+ return result;
2696
+ }
2697
+ }
2698
+
2642
2699
  {
2643
2700
  const result = validateKeyboardMap({
2644
2701
  value: item.keyboard,
@@ -3264,6 +3321,17 @@ const validateCollection = ({ collection, path }) => {
3264
3321
  return result;
3265
3322
  }
3266
3323
  }
3324
+ } else if (path === "state.controls") {
3325
+ {
3326
+ const result = validateControlItems({
3327
+ items: collection.items,
3328
+ path: `${path}.items`,
3329
+ errorFactory: createStateValidationError,
3330
+ });
3331
+ if (result?.valid === false) {
3332
+ return result;
3333
+ }
3334
+ }
3267
3335
  }
3268
3336
 
3269
3337
  if (!Array.isArray(collection.tree)) {
@@ -3366,7 +3434,8 @@ const validateCollection = ({ collection, path }) => {
3366
3434
  path === "state.variables" ||
3367
3435
  path === "state.textStyles" ||
3368
3436
  path === "state.characters" ||
3369
- path === "state.layouts"
3437
+ path === "state.layouts" ||
3438
+ path === "state.controls"
3370
3439
  ) {
3371
3440
  {
3372
3441
  const result = validateGenericFolderOwnership({
@@ -3374,7 +3443,11 @@ const validateCollection = ({ collection, path }) => {
3374
3443
  items: collection.items,
3375
3444
  path: `${path}.tree`,
3376
3445
  folderLabel:
3377
- path === "state.layouts" ? "folder layout item" : "folder item",
3446
+ path === "state.layouts"
3447
+ ? "folder layout item"
3448
+ : path === "state.controls"
3449
+ ? "folder control item"
3450
+ : "folder item",
3378
3451
  });
3379
3452
  if (result?.valid === false) {
3380
3453
  return result;
@@ -3393,7 +3466,6 @@ const validateFileReference = ({
3393
3466
  state,
3394
3467
  fileId,
3395
3468
  path,
3396
- allowedTypes,
3397
3469
  details = {},
3398
3470
  errorFactory = createPreconditionValidationError,
3399
3471
  }) => {
@@ -3401,36 +3473,10 @@ const validateFileReference = ({
3401
3473
  return VALID_RESULT;
3402
3474
  }
3403
3475
 
3404
- const expectedTypeMessage =
3405
- Array.isArray(allowedTypes) && allowedTypes.length > 0
3406
- ? `${path} must reference an existing non-folder file with type ${allowedTypes
3407
- .map((type) => `'${type}'`)
3408
- .join(" or ")}`
3409
- : `${path} must reference an existing non-folder file`;
3476
+ const expectedTypeMessage = `${path} must reference an existing non-folder file`;
3410
3477
  const file = state.files?.items?.[fileId];
3411
3478
  if (!isPlainObject(file) || file.type === "folder") {
3412
- return invalidFromErrorFactory(
3413
- errorFactory,
3414
- expectedTypeMessage,
3415
- Array.isArray(allowedTypes) && allowedTypes.length > 0
3416
- ? {
3417
- ...details,
3418
- expectedFileTypes: [...allowedTypes],
3419
- }
3420
- : details,
3421
- );
3422
- }
3423
-
3424
- if (
3425
- Array.isArray(allowedTypes) &&
3426
- allowedTypes.length > 0 &&
3427
- !allowedTypes.includes(file.type)
3428
- ) {
3429
- return invalidFromErrorFactory(errorFactory, expectedTypeMessage, {
3430
- ...details,
3431
- expectedFileTypes: [...allowedTypes],
3432
- actualFileType: file.type,
3433
- });
3479
+ return invalidFromErrorFactory(errorFactory, expectedTypeMessage, details);
3434
3480
  }
3435
3481
 
3436
3482
  return VALID_RESULT;
@@ -3559,7 +3605,6 @@ export const assertInvariants = ({ state }) => {
3559
3605
  state,
3560
3606
  fileId: image.fileId,
3561
3607
  path: "image.fileId",
3562
- allowedTypes: IMAGE_FILE_REFERENCE_TYPES.fileId,
3563
3608
  details: { imageId, fileId: image.fileId },
3564
3609
  errorFactory: createInvariantValidationError,
3565
3610
  });
@@ -3573,7 +3618,6 @@ export const assertInvariants = ({ state }) => {
3573
3618
  state,
3574
3619
  fileId: image.thumbnailFileId,
3575
3620
  path: "image.thumbnailFileId",
3576
- allowedTypes: IMAGE_FILE_REFERENCE_TYPES.thumbnailFileId,
3577
3621
  details: { imageId, thumbnailFileId: image.thumbnailFileId },
3578
3622
  errorFactory: createInvariantValidationError,
3579
3623
  });
@@ -3593,7 +3637,6 @@ export const assertInvariants = ({ state }) => {
3593
3637
  state,
3594
3638
  fileId: sound.fileId,
3595
3639
  path: "sound.fileId",
3596
- allowedTypes: SOUND_FILE_REFERENCE_TYPES.fileId,
3597
3640
  details: { soundId, fileId: sound.fileId },
3598
3641
  errorFactory: createInvariantValidationError,
3599
3642
  });
@@ -3610,7 +3653,6 @@ export const assertInvariants = ({ state }) => {
3610
3653
  state,
3611
3654
  fileId: sound.waveformDataFileId,
3612
3655
  path: "sound.waveformDataFileId",
3613
- allowedTypes: SOUND_FILE_REFERENCE_TYPES.waveformDataFileId,
3614
3656
  details: { soundId, waveformDataFileId: sound.waveformDataFileId },
3615
3657
  errorFactory: createInvariantValidationError,
3616
3658
  });
@@ -3630,7 +3672,6 @@ export const assertInvariants = ({ state }) => {
3630
3672
  state,
3631
3673
  fileId: video.fileId,
3632
3674
  path: "video.fileId",
3633
- allowedTypes: VIDEO_FILE_REFERENCE_TYPES.fileId,
3634
3675
  details: { videoId, fileId: video.fileId },
3635
3676
  errorFactory: createInvariantValidationError,
3636
3677
  });
@@ -3644,7 +3685,6 @@ export const assertInvariants = ({ state }) => {
3644
3685
  state,
3645
3686
  fileId: video.thumbnailFileId,
3646
3687
  path: "video.thumbnailFileId",
3647
- allowedTypes: VIDEO_FILE_REFERENCE_TYPES.thumbnailFileId,
3648
3688
  details: { videoId, thumbnailFileId: video.thumbnailFileId },
3649
3689
  errorFactory: createInvariantValidationError,
3650
3690
  });
@@ -3663,7 +3703,6 @@ export const assertInvariants = ({ state }) => {
3663
3703
  state,
3664
3704
  fileId: font.fileId,
3665
3705
  path: "font.fileId",
3666
- allowedTypes: FONT_FILE_REFERENCE_TYPES.fileId,
3667
3706
  details: { fontId, fileId: font.fileId },
3668
3707
  errorFactory: createInvariantValidationError,
3669
3708
  });
@@ -3684,7 +3723,6 @@ export const assertInvariants = ({ state }) => {
3684
3723
  state,
3685
3724
  fileId: character.fileId,
3686
3725
  path: "character.fileId",
3687
- allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
3688
3726
  details: { characterId, fileId: character.fileId },
3689
3727
  errorFactory: createInvariantValidationError,
3690
3728
  });
@@ -3704,7 +3742,6 @@ export const assertInvariants = ({ state }) => {
3704
3742
  state,
3705
3743
  fileId: sprite.fileId,
3706
3744
  path: "character.sprite.fileId",
3707
- allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
3708
3745
  details: { characterId, spriteId, fileId: sprite.fileId },
3709
3746
  errorFactory: createInvariantValidationError,
3710
3747
  });
@@ -3714,13 +3751,20 @@ export const assertInvariants = ({ state }) => {
3714
3751
  }
3715
3752
  }
3716
3753
 
3717
- const assertImageReference = ({ layoutId, elementId, field, targetId }) => {
3754
+ const assertImageReference = ({
3755
+ ownerIdField,
3756
+ ownerId,
3757
+ ownerLabel,
3758
+ elementId,
3759
+ field,
3760
+ targetId,
3761
+ }) => {
3718
3762
  const image = state.images.items[targetId];
3719
3763
  if (!isPlainObject(image) || image.type === "folder") {
3720
3764
  return invalidInvariant(
3721
- `layout element ${field} must reference an existing non-folder image`,
3765
+ `${ownerLabel} element ${field} must reference an existing non-folder image`,
3722
3766
  {
3723
- layoutId,
3767
+ [ownerIdField]: ownerId,
3724
3768
  elementId,
3725
3769
  field,
3726
3770
  targetId,
@@ -3732,7 +3776,9 @@ export const assertInvariants = ({ state }) => {
3732
3776
  };
3733
3777
 
3734
3778
  const assertTextStyleReference = ({
3735
- layoutId,
3779
+ ownerIdField,
3780
+ ownerId,
3781
+ ownerLabel,
3736
3782
  elementId,
3737
3783
  field,
3738
3784
  targetId,
@@ -3740,9 +3786,9 @@ export const assertInvariants = ({ state }) => {
3740
3786
  const textStyle = state.textStyles.items[targetId];
3741
3787
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
3742
3788
  return invalidInvariant(
3743
- `layout element ${field} must reference an existing non-folder text style`,
3789
+ `${ownerLabel} element ${field} must reference an existing non-folder text style`,
3744
3790
  {
3745
- layoutId,
3791
+ [ownerIdField]: ownerId,
3746
3792
  elementId,
3747
3793
  field,
3748
3794
  targetId,
@@ -3753,13 +3799,19 @@ export const assertInvariants = ({ state }) => {
3753
3799
  return VALID_RESULT;
3754
3800
  };
3755
3801
 
3756
- const assertVariableReference = ({ layoutId, elementId, targetId }) => {
3802
+ const assertVariableReference = ({
3803
+ ownerIdField,
3804
+ ownerId,
3805
+ ownerLabel,
3806
+ elementId,
3807
+ targetId,
3808
+ }) => {
3757
3809
  const variable = state.variables.items[targetId];
3758
3810
  if (!isPlainObject(variable) || variable.type === "folder") {
3759
3811
  return invalidInvariant(
3760
- "layout element variableId must reference an existing non-folder variable",
3812
+ `${ownerLabel} element variableId must reference an existing non-folder variable`,
3761
3813
  {
3762
- layoutId,
3814
+ [ownerIdField]: ownerId,
3763
3815
  elementId,
3764
3816
  variableId: targetId,
3765
3817
  },
@@ -3769,62 +3821,101 @@ export const assertInvariants = ({ state }) => {
3769
3821
  return VALID_RESULT;
3770
3822
  };
3771
3823
 
3772
- for (const [layoutId, layout] of Object.entries(state.layouts.items)) {
3773
- if (layout.type === "folder") {
3774
- continue;
3775
- }
3824
+ const assertElementReferencesForCollection = ({
3825
+ items,
3826
+ ownerIdField,
3827
+ ownerLabel,
3828
+ ownerType,
3829
+ }) => {
3830
+ for (const [ownerId, owner] of Object.entries(items)) {
3831
+ if (owner.type !== ownerType) {
3832
+ continue;
3833
+ }
3776
3834
 
3777
- for (const [elementId, element] of Object.entries(layout.elements.items)) {
3778
- for (const field of [
3779
- "imageId",
3780
- "hoverImageId",
3781
- "clickImageId",
3782
- "thumbImageId",
3783
- "barImageId",
3784
- "hoverThumbImageId",
3785
- "hoverBarImageId",
3786
- ]) {
3787
- if (element[field] !== undefined) {
3788
- const result = assertImageReference({
3789
- layoutId,
3790
- elementId,
3791
- field,
3792
- targetId: element[field],
3793
- });
3794
- if (!result.valid) {
3795
- return result;
3835
+ for (const [elementId, element] of Object.entries(owner.elements.items)) {
3836
+ for (const field of [
3837
+ "imageId",
3838
+ "hoverImageId",
3839
+ "clickImageId",
3840
+ "thumbImageId",
3841
+ "barImageId",
3842
+ "hoverThumbImageId",
3843
+ "hoverBarImageId",
3844
+ ]) {
3845
+ if (element[field] !== undefined) {
3846
+ const result = assertImageReference({
3847
+ ownerIdField,
3848
+ ownerId,
3849
+ ownerLabel,
3850
+ elementId,
3851
+ field,
3852
+ targetId: element[field],
3853
+ });
3854
+ if (!result.valid) {
3855
+ return result;
3856
+ }
3796
3857
  }
3797
3858
  }
3798
- }
3799
3859
 
3800
- for (const field of [
3801
- "textStyleId",
3802
- "hoverTextStyleId",
3803
- "clickTextStyleId",
3804
- ]) {
3805
- if (element[field] !== undefined) {
3806
- const result = assertTextStyleReference({
3807
- layoutId,
3860
+ for (const field of [
3861
+ "textStyleId",
3862
+ "hoverTextStyleId",
3863
+ "clickTextStyleId",
3864
+ ]) {
3865
+ if (element[field] !== undefined) {
3866
+ const result = assertTextStyleReference({
3867
+ ownerIdField,
3868
+ ownerId,
3869
+ ownerLabel,
3870
+ elementId,
3871
+ field,
3872
+ targetId: element[field],
3873
+ });
3874
+ if (!result.valid) {
3875
+ return result;
3876
+ }
3877
+ }
3878
+ }
3879
+
3880
+ if (element.variableId !== undefined) {
3881
+ const result = assertVariableReference({
3882
+ ownerIdField,
3883
+ ownerId,
3884
+ ownerLabel,
3808
3885
  elementId,
3809
- field,
3810
- targetId: element[field],
3886
+ targetId: element.variableId,
3811
3887
  });
3812
3888
  if (!result.valid) {
3813
3889
  return result;
3814
3890
  }
3815
3891
  }
3816
3892
  }
3893
+ }
3817
3894
 
3818
- if (element.variableId !== undefined) {
3819
- const result = assertVariableReference({
3820
- layoutId,
3821
- elementId,
3822
- targetId: element.variableId,
3823
- });
3824
- if (!result.valid) {
3825
- return result;
3826
- }
3827
- }
3895
+ return VALID_RESULT;
3896
+ };
3897
+
3898
+ {
3899
+ const result = assertElementReferencesForCollection({
3900
+ items: state.layouts.items,
3901
+ ownerIdField: "layoutId",
3902
+ ownerLabel: "layout",
3903
+ ownerType: "layout",
3904
+ });
3905
+ if (!result.valid) {
3906
+ return result;
3907
+ }
3908
+ }
3909
+
3910
+ {
3911
+ const result = assertElementReferencesForCollection({
3912
+ items: state.controls.items,
3913
+ ownerIdField: "controlId",
3914
+ ownerLabel: "control",
3915
+ ownerType: "control",
3916
+ });
3917
+ if (!result.valid) {
3918
+ return result;
3828
3919
  }
3829
3920
  }
3830
3921
 
@@ -3833,9 +3924,11 @@ export const assertInvariants = ({ state }) => {
3833
3924
 
3834
3925
  const runValidateState = ({ state }) => {
3835
3926
  return captureValidation(() => {
3927
+ const normalizedState = normalizeStateCollections(state);
3928
+
3836
3929
  {
3837
3930
  const result = validateExactKeys({
3838
- value: state,
3931
+ value: normalizedState,
3839
3932
  expectedKeys: ROOT_KEYS,
3840
3933
  path: "state",
3841
3934
  errorFactory: createStateValidationError,
@@ -3847,7 +3940,7 @@ const runValidateState = ({ state }) => {
3847
3940
 
3848
3941
  {
3849
3942
  const result = validateAllowedKeys({
3850
- value: state.project,
3943
+ value: normalizedState.project,
3851
3944
  allowedKeys: ["resolution"],
3852
3945
  path: "state.project",
3853
3946
  errorFactory: createStateValidationError,
@@ -3857,10 +3950,10 @@ const runValidateState = ({ state }) => {
3857
3950
  }
3858
3951
  }
3859
3952
 
3860
- if (state.project.resolution !== undefined) {
3953
+ if (normalizedState.project.resolution !== undefined) {
3861
3954
  {
3862
3955
  const result = validateExactKeys({
3863
- value: state.project.resolution,
3956
+ value: normalizedState.project.resolution,
3864
3957
  expectedKeys: ["width", "height"],
3865
3958
  path: "state.project.resolution",
3866
3959
  errorFactory: createStateValidationError,
@@ -3870,13 +3963,13 @@ const runValidateState = ({ state }) => {
3870
3963
  }
3871
3964
  }
3872
3965
 
3873
- if (!isFiniteNumber(state.project.resolution.width)) {
3966
+ if (!isFiniteNumber(normalizedState.project.resolution.width)) {
3874
3967
  return invalidState(
3875
3968
  "state.project.resolution.width must be a finite number",
3876
3969
  );
3877
3970
  }
3878
3971
 
3879
- if (!isFiniteNumber(state.project.resolution.height)) {
3972
+ if (!isFiniteNumber(normalizedState.project.resolution.height)) {
3880
3973
  return invalidState(
3881
3974
  "state.project.resolution.height must be a finite number",
3882
3975
  );
@@ -3885,7 +3978,7 @@ const runValidateState = ({ state }) => {
3885
3978
 
3886
3979
  {
3887
3980
  const result = validateExactKeys({
3888
- value: state.story,
3981
+ value: normalizedState.story,
3889
3982
  expectedKeys: ["initialSceneId"],
3890
3983
  path: "state.story",
3891
3984
  errorFactory: createStateValidationError,
@@ -3896,8 +3989,8 @@ const runValidateState = ({ state }) => {
3896
3989
  }
3897
3990
 
3898
3991
  if (
3899
- state.story.initialSceneId !== null &&
3900
- !isNonEmptyString(state.story.initialSceneId)
3992
+ normalizedState.story.initialSceneId !== null &&
3993
+ !isNonEmptyString(normalizedState.story.initialSceneId)
3901
3994
  ) {
3902
3995
  return invalidState(
3903
3996
  "state.story.initialSceneId must be a non-empty string or null",
@@ -3907,7 +4000,7 @@ const runValidateState = ({ state }) => {
3907
4000
  for (const collectionKey of COLLECTION_KEYS) {
3908
4001
  {
3909
4002
  const result = validateCollection({
3910
- collection: state[collectionKey],
4003
+ collection: normalizedState[collectionKey],
3911
4004
  path: `state.${collectionKey}`,
3912
4005
  });
3913
4006
  if (result?.valid === false) {
@@ -3916,7 +4009,7 @@ const runValidateState = ({ state }) => {
3916
4009
  }
3917
4010
  }
3918
4011
 
3919
- const invariantResult = assertInvariants({ state });
4012
+ const invariantResult = assertInvariants({ state: normalizedState });
3920
4013
  if (!invariantResult.valid) {
3921
4014
  return invariantResult;
3922
4015
  }
@@ -4904,10 +4997,10 @@ const validateFileCreateData = ({ data, errorFactory }) => {
4904
4997
  );
4905
4998
  }
4906
4999
 
4907
- if (data.type !== "folder" && !FILE_ITEM_TYPES.includes(data.type)) {
5000
+ if (data.type !== undefined && !isNonEmptyString(data.type)) {
4908
5001
  return invalidFromErrorFactory(
4909
5002
  errorFactory,
4910
- "payload.data.type must be 'folder' or a supported file type",
5003
+ "payload.data.type must be a non-empty string when provided",
4911
5004
  );
4912
5005
  }
4913
5006
 
@@ -4962,7 +5055,6 @@ const validateReferencedFilesInData = ({
4962
5055
  state,
4963
5056
  data,
4964
5057
  fields,
4965
- fieldTypes = {},
4966
5058
  nullableFields = [],
4967
5059
  details = {},
4968
5060
  errorFactory = createPreconditionValidationError,
@@ -4982,7 +5074,6 @@ const validateReferencedFilesInData = ({
4982
5074
  state,
4983
5075
  fileId,
4984
5076
  path: `payload.data.${field}`,
4985
- allowedTypes: fieldTypes[field],
4986
5077
  details: {
4987
5078
  ...details,
4988
5079
  field,
@@ -5928,7 +6019,7 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
5928
6019
  allowedKeys:
5929
6020
  data.type === "folder"
5930
6021
  ? ["type", "name"]
5931
- : ["type", "name", "layoutType", "elements", "keyboard"],
6022
+ : ["type", "name", "layoutType", "elements"],
5932
6023
  path: "payload.data",
5933
6024
  errorFactory,
5934
6025
  });
@@ -5948,7 +6039,7 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
5948
6039
  if (!LAYOUT_TYPE_KEYS.includes(data.layoutType)) {
5949
6040
  return invalidFromErrorFactory(
5950
6041
  errorFactory,
5951
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', 'choice', or 'base'",
6042
+ "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'",
5952
6043
  );
5953
6044
  }
5954
6045
 
@@ -5965,16 +6056,6 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
5965
6056
  }
5966
6057
  }
5967
6058
 
5968
- {
5969
- const result = validateKeyboardMap({
5970
- value: data.keyboard,
5971
- path: "payload.data.keyboard",
5972
- errorFactory,
5973
- });
5974
- if (result?.valid === false) {
5975
- return result;
5976
- }
5977
- }
5978
6059
  }
5979
6060
  };
5980
6061
 
@@ -5982,7 +6063,7 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
5982
6063
  {
5983
6064
  const result = validateAllowedKeys({
5984
6065
  value: data,
5985
- allowedKeys: ["name", "layoutType", "keyboard"],
6066
+ allowedKeys: ["name", "layoutType"],
5986
6067
  path: "payload.data",
5987
6068
  errorFactory,
5988
6069
  });
@@ -6011,7 +6092,100 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
6011
6092
  ) {
6012
6093
  return invalidFromErrorFactory(
6013
6094
  errorFactory,
6014
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', 'choice', or 'base' when provided",
6095
+ "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice' when provided",
6096
+ );
6097
+ }
6098
+
6099
+ };
6100
+
6101
+ const validateControlCreateData = ({ data, errorFactory }) => {
6102
+ if (!isPlainObject(data)) {
6103
+ return invalidFromErrorFactory(
6104
+ errorFactory,
6105
+ "payload.data must be an object",
6106
+ );
6107
+ }
6108
+
6109
+ if (data.type !== "folder" && data.type !== "control") {
6110
+ return invalidFromErrorFactory(
6111
+ errorFactory,
6112
+ "payload.data.type must be 'folder' or 'control'",
6113
+ );
6114
+ }
6115
+
6116
+ {
6117
+ const result = validateAllowedKeys({
6118
+ value: data,
6119
+ allowedKeys:
6120
+ data.type === "folder"
6121
+ ? ["type", "name"]
6122
+ : ["type", "name", "elements", "keyboard"],
6123
+ path: "payload.data",
6124
+ errorFactory,
6125
+ });
6126
+ if (result?.valid === false) {
6127
+ return result;
6128
+ }
6129
+ }
6130
+
6131
+ if (!isNonEmptyString(data.name)) {
6132
+ return invalidFromErrorFactory(
6133
+ errorFactory,
6134
+ "payload.data.name must be a non-empty string",
6135
+ );
6136
+ }
6137
+
6138
+ if (data.type === "control") {
6139
+ {
6140
+ const result = validateNestedCollection({
6141
+ collection: data.elements,
6142
+ path: "payload.data.elements",
6143
+ itemValidator: validateLayoutElementItems,
6144
+ treeValidator: validateLayoutElementTreeOwnership,
6145
+ errorFactory,
6146
+ });
6147
+ if (result?.valid === false) {
6148
+ return result;
6149
+ }
6150
+ }
6151
+
6152
+ {
6153
+ const result = validateKeyboardMap({
6154
+ value: data.keyboard,
6155
+ path: "payload.data.keyboard",
6156
+ errorFactory,
6157
+ });
6158
+ if (result?.valid === false) {
6159
+ return result;
6160
+ }
6161
+ }
6162
+ }
6163
+ };
6164
+
6165
+ const validateControlUpdateData = ({ data, errorFactory }) => {
6166
+ {
6167
+ const result = validateAllowedKeys({
6168
+ value: data,
6169
+ allowedKeys: ["name", "keyboard"],
6170
+ path: "payload.data",
6171
+ errorFactory,
6172
+ });
6173
+ if (result?.valid === false) {
6174
+ return result;
6175
+ }
6176
+ }
6177
+
6178
+ if (Object.keys(data).length === 0) {
6179
+ return invalidFromErrorFactory(
6180
+ errorFactory,
6181
+ "payload.data must include at least one updatable field",
6182
+ );
6183
+ }
6184
+
6185
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
6186
+ return invalidFromErrorFactory(
6187
+ errorFactory,
6188
+ "payload.data.name must be a non-empty string when provided",
6015
6189
  );
6016
6190
  }
6017
6191
 
@@ -6061,8 +6235,10 @@ const validateLayoutElementUpdateData = ({ data, errorFactory, replace }) => {
6061
6235
  }
6062
6236
  };
6063
6237
 
6064
- const validateLayoutElementReferenceTargets = ({
6065
- layoutId,
6238
+ const validateVisualElementReferenceTargets = ({
6239
+ ownerIdField,
6240
+ ownerId,
6241
+ ownerLabel,
6066
6242
  elementId,
6067
6243
  data,
6068
6244
  state,
@@ -6073,9 +6249,9 @@ const validateLayoutElementReferenceTargets = ({
6073
6249
  if (!isPlainObject(image) || image.type === "folder") {
6074
6250
  return invalidFromErrorFactory(
6075
6251
  errorFactory,
6076
- "layout element imageId must reference an existing non-folder image",
6252
+ `${ownerLabel} element imageId must reference an existing non-folder image`,
6077
6253
  {
6078
- layoutId,
6254
+ [ownerIdField]: ownerId,
6079
6255
  elementId,
6080
6256
  field: "imageId",
6081
6257
  targetId: data.imageId,
@@ -6089,9 +6265,9 @@ const validateLayoutElementReferenceTargets = ({
6089
6265
  if (!isPlainObject(image) || image.type === "folder") {
6090
6266
  return invalidFromErrorFactory(
6091
6267
  errorFactory,
6092
- "layout element hoverImageId must reference an existing non-folder image",
6268
+ `${ownerLabel} element hoverImageId must reference an existing non-folder image`,
6093
6269
  {
6094
- layoutId,
6270
+ [ownerIdField]: ownerId,
6095
6271
  elementId,
6096
6272
  field: "hoverImageId",
6097
6273
  targetId: data.hoverImageId,
@@ -6105,9 +6281,9 @@ const validateLayoutElementReferenceTargets = ({
6105
6281
  if (!isPlainObject(image) || image.type === "folder") {
6106
6282
  return invalidFromErrorFactory(
6107
6283
  errorFactory,
6108
- "layout element clickImageId must reference an existing non-folder image",
6284
+ `${ownerLabel} element clickImageId must reference an existing non-folder image`,
6109
6285
  {
6110
- layoutId,
6286
+ [ownerIdField]: ownerId,
6111
6287
  elementId,
6112
6288
  field: "clickImageId",
6113
6289
  targetId: data.clickImageId,
@@ -6121,9 +6297,9 @@ const validateLayoutElementReferenceTargets = ({
6121
6297
  if (!isPlainObject(image) || image.type === "folder") {
6122
6298
  return invalidFromErrorFactory(
6123
6299
  errorFactory,
6124
- "layout element thumbImageId must reference an existing non-folder image",
6300
+ `${ownerLabel} element thumbImageId must reference an existing non-folder image`,
6125
6301
  {
6126
- layoutId,
6302
+ [ownerIdField]: ownerId,
6127
6303
  elementId,
6128
6304
  field: "thumbImageId",
6129
6305
  targetId: data.thumbImageId,
@@ -6137,9 +6313,9 @@ const validateLayoutElementReferenceTargets = ({
6137
6313
  if (!isPlainObject(image) || image.type === "folder") {
6138
6314
  return invalidFromErrorFactory(
6139
6315
  errorFactory,
6140
- "layout element hoverThumbImageId must reference an existing non-folder image",
6316
+ `${ownerLabel} element hoverThumbImageId must reference an existing non-folder image`,
6141
6317
  {
6142
- layoutId,
6318
+ [ownerIdField]: ownerId,
6143
6319
  elementId,
6144
6320
  field: "hoverThumbImageId",
6145
6321
  targetId: data.hoverThumbImageId,
@@ -6153,9 +6329,9 @@ const validateLayoutElementReferenceTargets = ({
6153
6329
  if (!isPlainObject(image) || image.type === "folder") {
6154
6330
  return invalidFromErrorFactory(
6155
6331
  errorFactory,
6156
- "layout element barImageId must reference an existing non-folder image",
6332
+ `${ownerLabel} element barImageId must reference an existing non-folder image`,
6157
6333
  {
6158
- layoutId,
6334
+ [ownerIdField]: ownerId,
6159
6335
  elementId,
6160
6336
  field: "barImageId",
6161
6337
  targetId: data.barImageId,
@@ -6169,9 +6345,9 @@ const validateLayoutElementReferenceTargets = ({
6169
6345
  if (!isPlainObject(image) || image.type === "folder") {
6170
6346
  return invalidFromErrorFactory(
6171
6347
  errorFactory,
6172
- "layout element hoverBarImageId must reference an existing non-folder image",
6348
+ `${ownerLabel} element hoverBarImageId must reference an existing non-folder image`,
6173
6349
  {
6174
- layoutId,
6350
+ [ownerIdField]: ownerId,
6175
6351
  elementId,
6176
6352
  field: "hoverBarImageId",
6177
6353
  targetId: data.hoverBarImageId,
@@ -6185,9 +6361,9 @@ const validateLayoutElementReferenceTargets = ({
6185
6361
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
6186
6362
  return invalidFromErrorFactory(
6187
6363
  errorFactory,
6188
- "layout element textStyleId must reference an existing non-folder text style",
6364
+ `${ownerLabel} element textStyleId must reference an existing non-folder text style`,
6189
6365
  {
6190
- layoutId,
6366
+ [ownerIdField]: ownerId,
6191
6367
  elementId,
6192
6368
  field: "textStyleId",
6193
6369
  targetId: data.textStyleId,
@@ -6201,9 +6377,9 @@ const validateLayoutElementReferenceTargets = ({
6201
6377
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
6202
6378
  return invalidFromErrorFactory(
6203
6379
  errorFactory,
6204
- "layout element hoverTextStyleId must reference an existing non-folder text style",
6380
+ `${ownerLabel} element hoverTextStyleId must reference an existing non-folder text style`,
6205
6381
  {
6206
- layoutId,
6382
+ [ownerIdField]: ownerId,
6207
6383
  elementId,
6208
6384
  field: "hoverTextStyleId",
6209
6385
  targetId: data.hoverTextStyleId,
@@ -6217,9 +6393,9 @@ const validateLayoutElementReferenceTargets = ({
6217
6393
  if (!isPlainObject(textStyle) || textStyle.type === "folder") {
6218
6394
  return invalidFromErrorFactory(
6219
6395
  errorFactory,
6220
- "layout element clickTextStyleId must reference an existing non-folder text style",
6396
+ `${ownerLabel} element clickTextStyleId must reference an existing non-folder text style`,
6221
6397
  {
6222
- layoutId,
6398
+ [ownerIdField]: ownerId,
6223
6399
  elementId,
6224
6400
  field: "clickTextStyleId",
6225
6401
  targetId: data.clickTextStyleId,
@@ -6233,9 +6409,9 @@ const validateLayoutElementReferenceTargets = ({
6233
6409
  if (!isPlainObject(variable) || variable.type === "folder") {
6234
6410
  return invalidFromErrorFactory(
6235
6411
  errorFactory,
6236
- "layout element variableId must reference an existing non-folder variable",
6412
+ `${ownerLabel} element variableId must reference an existing non-folder variable`,
6237
6413
  {
6238
- layoutId,
6414
+ [ownerIdField]: ownerId,
6239
6415
  elementId,
6240
6416
  variableId: data.variableId,
6241
6417
  },
@@ -6774,6 +6950,9 @@ const getCharacterSpriteCollection = ({ state, characterId }) =>
6774
6950
  const getLayoutElementCollection = ({ state, layoutId }) =>
6775
6951
  state.layouts.items[layoutId]?.elements;
6776
6952
 
6953
+ const getControlElementCollection = ({ state, controlId }) =>
6954
+ state.controls.items[controlId]?.elements;
6955
+
6777
6956
  const findReferencedFileUsage = ({ state, fileId }) => {
6778
6957
  for (const [imageId, image] of Object.entries(state.images.items)) {
6779
6958
  if (image.type !== "image") {
@@ -6954,7 +7133,6 @@ const COMMAND_DEFINITIONS = [
6954
7133
  }
6955
7134
  : {
6956
7135
  id: payload.fileId,
6957
- type: payload.data.type,
6958
7136
  mimeType: payload.data.mimeType,
6959
7137
  size: payload.data.size,
6960
7138
  sha256: payload.data.sha256,
@@ -8304,7 +8482,6 @@ const COMMAND_DEFINITIONS = [
8304
8482
  state,
8305
8483
  data: payload.data,
8306
8484
  fields: ["fileId", "thumbnailFileId"],
8307
- fieldTypes: IMAGE_FILE_REFERENCE_TYPES,
8308
8485
  details: {
8309
8486
  imageId: payload.imageId,
8310
8487
  },
@@ -8417,7 +8594,6 @@ const COMMAND_DEFINITIONS = [
8417
8594
  state,
8418
8595
  data: payload.data,
8419
8596
  fields: ["fileId", "thumbnailFileId"],
8420
- fieldTypes: IMAGE_FILE_REFERENCE_TYPES,
8421
8597
  details: {
8422
8598
  imageId: payload.imageId,
8423
8599
  },
@@ -8730,7 +8906,6 @@ const COMMAND_DEFINITIONS = [
8730
8906
  state,
8731
8907
  data: payload.data,
8732
8908
  fields: ["fileId", "waveformDataFileId"],
8733
- fieldTypes: SOUND_FILE_REFERENCE_TYPES,
8734
8909
  nullableFields: ["waveformDataFileId"],
8735
8910
  details: {
8736
8911
  soundId: payload.soundId,
@@ -8840,7 +9015,6 @@ const COMMAND_DEFINITIONS = [
8840
9015
  state,
8841
9016
  data: payload.data,
8842
9017
  fields: ["fileId", "waveformDataFileId"],
8843
- fieldTypes: SOUND_FILE_REFERENCE_TYPES,
8844
9018
  nullableFields: ["waveformDataFileId"],
8845
9019
  details: {
8846
9020
  soundId: payload.soundId,
@@ -9154,7 +9328,6 @@ const COMMAND_DEFINITIONS = [
9154
9328
  state,
9155
9329
  data: payload.data,
9156
9330
  fields: ["fileId", "thumbnailFileId"],
9157
- fieldTypes: VIDEO_FILE_REFERENCE_TYPES,
9158
9331
  details: {
9159
9332
  videoId: payload.videoId,
9160
9333
  },
@@ -9265,7 +9438,6 @@ const COMMAND_DEFINITIONS = [
9265
9438
  state,
9266
9439
  data: payload.data,
9267
9440
  fields: ["fileId", "thumbnailFileId"],
9268
- fieldTypes: VIDEO_FILE_REFERENCE_TYPES,
9269
9441
  details: {
9270
9442
  videoId: payload.videoId,
9271
9443
  },
@@ -9952,7 +10124,6 @@ const COMMAND_DEFINITIONS = [
9952
10124
  state,
9953
10125
  data: payload.data,
9954
10126
  fields: ["fileId"],
9955
- fieldTypes: FONT_FILE_REFERENCE_TYPES,
9956
10127
  details: {
9957
10128
  fontId: payload.fontId,
9958
10129
  },
@@ -10051,7 +10222,6 @@ const COMMAND_DEFINITIONS = [
10051
10222
  state,
10052
10223
  data: payload.data,
10053
10224
  fields: ["fileId"],
10054
- fieldTypes: FONT_FILE_REFERENCE_TYPES,
10055
10225
  details: {
10056
10226
  fontId: payload.fontId,
10057
10227
  },
@@ -10837,7 +11007,6 @@ const COMMAND_DEFINITIONS = [
10837
11007
  state,
10838
11008
  data: payload.data,
10839
11009
  fields: ["fileId"],
10840
- fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10841
11010
  details: {
10842
11011
  characterId: payload.characterId,
10843
11012
  },
@@ -10858,7 +11027,6 @@ const COMMAND_DEFINITIONS = [
10858
11027
  state,
10859
11028
  fileId: sprite.fileId,
10860
11029
  path: "payload.data.sprites.items.*.fileId",
10861
- allowedTypes: CHARACTER_FILE_REFERENCE_TYPES.fileId,
10862
11030
  details: {
10863
11031
  characterId: payload.characterId,
10864
11032
  spriteId,
@@ -10885,7 +11053,6 @@ const COMMAND_DEFINITIONS = [
10885
11053
  state,
10886
11054
  data: payload.data,
10887
11055
  fields: ["fileId"],
10888
- fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
10889
11056
  details: {
10890
11057
  characterId: payload.characterId,
10891
11058
  },
@@ -10911,11 +11078,6 @@ const COMMAND_DEFINITIONS = [
10911
11078
  ? {
10912
11079
  layoutType: payload.data.layoutType,
10913
11080
  elements: structuredClone(payload.data.elements),
10914
- ...(payload.data.keyboard !== undefined
10915
- ? {
10916
- keyboard: structuredClone(payload.data.keyboard),
10917
- }
10918
- : {}),
10919
11081
  }
10920
11082
  : {}),
10921
11083
  }),
@@ -10930,6 +11092,39 @@ const COMMAND_DEFINITIONS = [
10930
11092
  }
10931
11093
  },
10932
11094
  }),
11095
+ ...createFolderedCollectionCommandDefinitions({
11096
+ familyName: "control",
11097
+ collectionKey: "controls",
11098
+ idField: "controlId",
11099
+ itemLabel: "control item",
11100
+ createDataValidator: validateControlCreateData,
11101
+ updateDataValidator: validateControlUpdateData,
11102
+ createItem: ({ payload }) => ({
11103
+ id: payload.controlId,
11104
+ type: payload.data.type,
11105
+ name: payload.data.name,
11106
+ ...(payload.data.type === "control"
11107
+ ? {
11108
+ elements: structuredClone(payload.data.elements),
11109
+ ...(payload.data.keyboard !== undefined
11110
+ ? {
11111
+ keyboard: structuredClone(payload.data.keyboard),
11112
+ }
11113
+ : {}),
11114
+ }
11115
+ : {}),
11116
+ }),
11117
+ validateUpdateState: ({ payload, currentItem }) => {
11118
+ if (
11119
+ currentItem.type === "folder" &&
11120
+ Object.keys(payload.data).some((key) => key !== "name")
11121
+ ) {
11122
+ return invalidPrecondition(
11123
+ "folder control items cannot update control fields",
11124
+ );
11125
+ }
11126
+ },
11127
+ }),
10933
11128
  {
10934
11129
  type: "character.sprite.create",
10935
11130
  validatePayload: ({ payload }) => {
@@ -11042,7 +11237,6 @@ const COMMAND_DEFINITIONS = [
11042
11237
  state,
11043
11238
  data: payload.data,
11044
11239
  fields: ["fileId"],
11045
- fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
11046
11240
  details: {
11047
11241
  characterId: payload.characterId,
11048
11242
  spriteId: payload.spriteId,
@@ -11146,7 +11340,6 @@ const COMMAND_DEFINITIONS = [
11146
11340
  state,
11147
11341
  data: payload.data,
11148
11342
  fields: ["fileId"],
11149
- fieldTypes: CHARACTER_FILE_REFERENCE_TYPES,
11150
11343
  details: {
11151
11344
  characterId: payload.characterId,
11152
11345
  spriteId: payload.spriteId,
@@ -11510,8 +11703,10 @@ const COMMAND_DEFINITIONS = [
11510
11703
  }
11511
11704
 
11512
11705
  {
11513
- const result = validateLayoutElementReferenceTargets({
11514
- layoutId: payload.layoutId,
11706
+ const result = validateVisualElementReferenceTargets({
11707
+ ownerIdField: "layoutId",
11708
+ ownerId: payload.layoutId,
11709
+ ownerLabel: "layout",
11515
11710
  elementId: payload.elementId,
11516
11711
  data: payload.data,
11517
11712
  state,
@@ -11628,8 +11823,10 @@ const COMMAND_DEFINITIONS = [
11628
11823
  };
11629
11824
 
11630
11825
  {
11631
- const result = validateLayoutElementReferenceTargets({
11632
- layoutId: payload.layoutId,
11826
+ const result = validateVisualElementReferenceTargets({
11827
+ ownerIdField: "layoutId",
11828
+ ownerId: payload.layoutId,
11829
+ ownerLabel: "layout",
11633
11830
  elementId: payload.elementId,
11634
11831
  data: mergedData,
11635
11832
  state,
@@ -11754,6 +11951,515 @@ const COMMAND_DEFINITIONS = [
11754
11951
  return state;
11755
11952
  },
11756
11953
  },
11954
+ {
11955
+ type: "control.element.create",
11956
+ validatePayload: ({ payload }) => {
11957
+ {
11958
+ const result = validateAllowedKeys({
11959
+ value: payload,
11960
+ allowedKeys: [
11961
+ "controlId",
11962
+ "elementId",
11963
+ "parentId",
11964
+ "data",
11965
+ "index",
11966
+ "position",
11967
+ "positionTargetId",
11968
+ ],
11969
+ path: "payload",
11970
+ errorFactory: createPayloadValidationError,
11971
+ });
11972
+ if (result?.valid === false) {
11973
+ return result;
11974
+ }
11975
+ }
11976
+
11977
+ if (!isNonEmptyString(payload.controlId)) {
11978
+ return invalidPayload("payload.controlId must be a non-empty string");
11979
+ }
11980
+
11981
+ if (!isNonEmptyString(payload.elementId)) {
11982
+ return invalidPayload("payload.elementId must be a non-empty string");
11983
+ }
11984
+
11985
+ if (
11986
+ payload.parentId !== undefined &&
11987
+ payload.parentId !== null &&
11988
+ !isNonEmptyString(payload.parentId)
11989
+ ) {
11990
+ return invalidPayload(
11991
+ "payload.parentId must be a non-empty string when provided",
11992
+ );
11993
+ }
11994
+
11995
+ {
11996
+ const result = validateLayoutElementCreateData({
11997
+ data: payload.data,
11998
+ errorFactory: createPayloadValidationError,
11999
+ });
12000
+ if (result?.valid === false) {
12001
+ return result;
12002
+ }
12003
+ }
12004
+
12005
+ {
12006
+ const result = validatePlacementFields({
12007
+ payload,
12008
+ errorFactory: createPayloadValidationError,
12009
+ });
12010
+ if (result?.valid === false) {
12011
+ return result;
12012
+ }
12013
+ }
12014
+ },
12015
+ validateAgainstState: ({ state, payload }) => {
12016
+ const control = state.controls.items[payload.controlId];
12017
+ if (!isPlainObject(control) || control.type !== "control") {
12018
+ return invalidPrecondition(
12019
+ "payload.controlId must reference an existing control",
12020
+ );
12021
+ }
12022
+
12023
+ const collection = getControlElementCollection({
12024
+ state,
12025
+ controlId: payload.controlId,
12026
+ });
12027
+
12028
+ if (isPlainObject(collection.items[payload.elementId])) {
12029
+ return invalidPrecondition("payload.elementId must not already exist");
12030
+ }
12031
+
12032
+ const parentId = payload.parentId ?? null;
12033
+ if (parentId !== null) {
12034
+ const parentItem = collection.items[parentId];
12035
+ if (
12036
+ !isPlainObject(parentItem) ||
12037
+ !LAYOUT_CONTAINER_ELEMENT_TYPES.includes(parentItem.type)
12038
+ ) {
12039
+ return invalidPrecondition(
12040
+ "payload.parentId must reference a folder or container control element",
12041
+ );
12042
+ }
12043
+ }
12044
+
12045
+ if (payload.positionTargetId !== undefined) {
12046
+ if (!isPlainObject(collection.items[payload.positionTargetId])) {
12047
+ return invalidPrecondition(
12048
+ "payload.positionTargetId must reference an existing control element",
12049
+ );
12050
+ }
12051
+
12052
+ const targetParentId = getNodeParentId({
12053
+ tree: collection.tree,
12054
+ nodeId: payload.positionTargetId,
12055
+ });
12056
+
12057
+ if (targetParentId !== parentId) {
12058
+ return invalidPrecondition(
12059
+ "payload.positionTargetId must reference a sibling under payload.parentId",
12060
+ );
12061
+ }
12062
+ }
12063
+
12064
+ {
12065
+ const result = validateVisualElementReferenceTargets({
12066
+ ownerIdField: "controlId",
12067
+ ownerId: payload.controlId,
12068
+ ownerLabel: "control",
12069
+ elementId: payload.elementId,
12070
+ data: payload.data,
12071
+ state,
12072
+ errorFactory: createPreconditionValidationError,
12073
+ });
12074
+ if (result?.valid === false) {
12075
+ return result;
12076
+ }
12077
+ }
12078
+ },
12079
+ reduce: ({ state, payload }) => {
12080
+ const collection = getControlElementCollection({
12081
+ state,
12082
+ controlId: payload.controlId,
12083
+ });
12084
+
12085
+ collection.items[payload.elementId] = {
12086
+ id: payload.elementId,
12087
+ ...structuredClone(payload.data),
12088
+ };
12089
+
12090
+ insertTreeNode({
12091
+ tree: collection.tree,
12092
+ node: {
12093
+ id: payload.elementId,
12094
+ children: [],
12095
+ },
12096
+ parentId: payload.parentId ?? null,
12097
+ index: payload.index,
12098
+ position: payload.position,
12099
+ positionTargetId: payload.positionTargetId,
12100
+ });
12101
+
12102
+ return state;
12103
+ },
12104
+ },
12105
+ {
12106
+ type: "control.element.update",
12107
+ validatePayload: ({ payload }) => {
12108
+ let result = captureValidation(() =>
12109
+ validateAllowedKeys({
12110
+ value: payload,
12111
+ allowedKeys: ["controlId", "elementId", "data", "replace"],
12112
+ path: "payload",
12113
+ errorFactory: createPayloadValidationError,
12114
+ }),
12115
+ );
12116
+ if (!result.valid) {
12117
+ return result;
12118
+ }
12119
+
12120
+ if (!isNonEmptyString(payload.controlId)) {
12121
+ return invalidPayload("payload.controlId must be a non-empty string");
12122
+ }
12123
+
12124
+ if (!isNonEmptyString(payload.elementId)) {
12125
+ return invalidPayload("payload.elementId must be a non-empty string");
12126
+ }
12127
+
12128
+ if (
12129
+ payload.replace !== undefined &&
12130
+ typeof payload.replace !== "boolean"
12131
+ ) {
12132
+ return invalidPayload(
12133
+ "payload.replace must be a boolean when provided",
12134
+ );
12135
+ }
12136
+
12137
+ result = captureValidation(() =>
12138
+ validateLayoutElementUpdateData({
12139
+ data: payload.data,
12140
+ replace: payload.replace,
12141
+ errorFactory: createPayloadValidationError,
12142
+ }),
12143
+ );
12144
+ if (!result.valid) {
12145
+ return result;
12146
+ }
12147
+
12148
+ return VALID_RESULT;
12149
+ },
12150
+ validateAgainstState: ({ state, payload }) => {
12151
+ const control = state.controls.items[payload.controlId];
12152
+ if (!isPlainObject(control) || control.type !== "control") {
12153
+ return invalidPrecondition(
12154
+ "payload.controlId must reference an existing control",
12155
+ );
12156
+ }
12157
+
12158
+ const collection = getControlElementCollection({
12159
+ state,
12160
+ controlId: payload.controlId,
12161
+ });
12162
+ const currentItem = collection.items[payload.elementId];
12163
+ if (!isPlainObject(currentItem)) {
12164
+ return invalidPrecondition(
12165
+ "payload.elementId must reference an existing control element",
12166
+ );
12167
+ }
12168
+
12169
+ if (
12170
+ payload.data.type !== undefined &&
12171
+ payload.data.type !== currentItem.type
12172
+ ) {
12173
+ return invalidPrecondition("control element type cannot be changed");
12174
+ }
12175
+
12176
+ if (currentItem.type !== "folder") {
12177
+ const mergedData = payload.replace
12178
+ ? { ...structuredClone(payload.data) }
12179
+ : {
12180
+ ...structuredClone(currentItem),
12181
+ ...structuredClone(payload.data),
12182
+ };
12183
+
12184
+ {
12185
+ const result = validateVisualElementReferenceTargets({
12186
+ ownerIdField: "controlId",
12187
+ ownerId: payload.controlId,
12188
+ ownerLabel: "control",
12189
+ elementId: payload.elementId,
12190
+ data: mergedData,
12191
+ state,
12192
+ errorFactory: createPreconditionValidationError,
12193
+ });
12194
+ if (result?.valid === false) {
12195
+ return result;
12196
+ }
12197
+ }
12198
+ }
12199
+
12200
+ if (
12201
+ currentItem.type === "folder" &&
12202
+ Object.keys(payload.data).some((key) => key !== "name")
12203
+ ) {
12204
+ return invalidPrecondition(
12205
+ "folder control elements cannot update non-name fields",
12206
+ );
12207
+ }
12208
+
12209
+ return VALID_RESULT;
12210
+ },
12211
+ reduce: ({ state, payload }) => {
12212
+ const collection = getControlElementCollection({
12213
+ state,
12214
+ controlId: payload.controlId,
12215
+ });
12216
+ const currentItem = collection.items[payload.elementId];
12217
+
12218
+ collection.items[payload.elementId] =
12219
+ payload.replace === true
12220
+ ? {
12221
+ id: payload.elementId,
12222
+ ...structuredClone(payload.data),
12223
+ }
12224
+ : {
12225
+ ...structuredClone(currentItem),
12226
+ ...structuredClone(payload.data),
12227
+ };
12228
+
12229
+ return state;
12230
+ },
12231
+ },
12232
+ {
12233
+ type: "control.element.delete",
12234
+ validatePayload: ({ payload }) => {
12235
+ {
12236
+ const result = validateExactKeys({
12237
+ value: payload,
12238
+ expectedKeys: ["controlId", "elementIds"],
12239
+ path: "payload",
12240
+ errorFactory: createPayloadValidationError,
12241
+ });
12242
+ if (result?.valid === false) {
12243
+ return result;
12244
+ }
12245
+ }
12246
+
12247
+ if (!isNonEmptyString(payload.controlId)) {
12248
+ return invalidPayload("payload.controlId must be a non-empty string");
12249
+ }
12250
+
12251
+ {
12252
+ const result = validateRequiredUniqueIdArray({
12253
+ value: payload.elementIds,
12254
+ path: "payload.elementIds",
12255
+ errorFactory: createPayloadValidationError,
12256
+ });
12257
+ if (result?.valid === false) {
12258
+ return result;
12259
+ }
12260
+ }
12261
+ },
12262
+ validateAgainstState: ({ state, payload }) => {
12263
+ const control = state.controls.items[payload.controlId];
12264
+ if (!isPlainObject(control) || control.type !== "control") {
12265
+ return invalidPrecondition(
12266
+ "payload.controlId must reference an existing control",
12267
+ );
12268
+ }
12269
+
12270
+ const collection = getControlElementCollection({
12271
+ state,
12272
+ controlId: payload.controlId,
12273
+ });
12274
+
12275
+ for (const elementId of payload.elementIds) {
12276
+ if (!isPlainObject(collection.items[elementId])) {
12277
+ return invalidPrecondition(
12278
+ "payload.elementIds must reference existing control elements",
12279
+ { elementId },
12280
+ );
12281
+ }
12282
+ }
12283
+ },
12284
+ reduce: ({ state, payload }) => {
12285
+ const collection = getControlElementCollection({
12286
+ state,
12287
+ controlId: payload.controlId,
12288
+ });
12289
+ const deletedIds = new Set();
12290
+
12291
+ for (const elementId of payload.elementIds) {
12292
+ const removedNode = removeTreeNode({
12293
+ nodes: collection.tree,
12294
+ nodeId: elementId,
12295
+ });
12296
+
12297
+ if (!removedNode) {
12298
+ continue;
12299
+ }
12300
+
12301
+ for (const id of collectTreeDescendantIds({ node: removedNode })) {
12302
+ deletedIds.add(id);
12303
+ }
12304
+ }
12305
+
12306
+ for (const elementId of deletedIds) {
12307
+ delete collection.items[elementId];
12308
+ }
12309
+
12310
+ return state;
12311
+ },
12312
+ },
12313
+ {
12314
+ type: "control.element.move",
12315
+ validatePayload: ({ payload }) => {
12316
+ {
12317
+ const result = validateAllowedKeys({
12318
+ value: payload,
12319
+ allowedKeys: [
12320
+ "controlId",
12321
+ "elementId",
12322
+ "parentId",
12323
+ "index",
12324
+ "position",
12325
+ "positionTargetId",
12326
+ ],
12327
+ path: "payload",
12328
+ errorFactory: createPayloadValidationError,
12329
+ });
12330
+ if (result?.valid === false) {
12331
+ return result;
12332
+ }
12333
+ }
12334
+
12335
+ if (!isNonEmptyString(payload.controlId)) {
12336
+ return invalidPayload("payload.controlId must be a non-empty string");
12337
+ }
12338
+
12339
+ if (!isNonEmptyString(payload.elementId)) {
12340
+ return invalidPayload("payload.elementId must be a non-empty string");
12341
+ }
12342
+
12343
+ if (
12344
+ payload.parentId !== undefined &&
12345
+ payload.parentId !== null &&
12346
+ !isNonEmptyString(payload.parentId)
12347
+ ) {
12348
+ return invalidPayload(
12349
+ "payload.parentId must be a non-empty string when provided",
12350
+ );
12351
+ }
12352
+
12353
+ {
12354
+ const result = validatePlacementFields({
12355
+ payload,
12356
+ errorFactory: createPayloadValidationError,
12357
+ });
12358
+ if (result?.valid === false) {
12359
+ return result;
12360
+ }
12361
+ }
12362
+ },
12363
+ validateAgainstState: ({ state, payload }) => {
12364
+ const control = state.controls.items[payload.controlId];
12365
+ if (!isPlainObject(control) || control.type !== "control") {
12366
+ return invalidPrecondition(
12367
+ "payload.controlId must reference an existing control",
12368
+ );
12369
+ }
12370
+
12371
+ const collection = getControlElementCollection({
12372
+ state,
12373
+ controlId: payload.controlId,
12374
+ });
12375
+ const currentItem = collection.items[payload.elementId];
12376
+
12377
+ if (!isPlainObject(currentItem)) {
12378
+ return invalidPrecondition(
12379
+ "payload.elementId must reference an existing control element",
12380
+ );
12381
+ }
12382
+
12383
+ const currentNode = findTreeNode({
12384
+ nodes: collection.tree,
12385
+ nodeId: payload.elementId,
12386
+ });
12387
+
12388
+ if (payload.parentId !== undefined && payload.parentId !== null) {
12389
+ const parentItem = collection.items[payload.parentId];
12390
+ if (
12391
+ !isPlainObject(parentItem) ||
12392
+ !LAYOUT_CONTAINER_ELEMENT_TYPES.includes(parentItem.type)
12393
+ ) {
12394
+ return invalidPrecondition(
12395
+ "payload.parentId must reference a folder or container control element",
12396
+ );
12397
+ }
12398
+
12399
+ const descendantIds = new Set(
12400
+ collectTreeDescendantIds({
12401
+ node: currentNode,
12402
+ }),
12403
+ );
12404
+
12405
+ if (descendantIds.has(payload.parentId)) {
12406
+ return invalidPrecondition(
12407
+ "payload.parentId must not target the moved control element or its descendants",
12408
+ );
12409
+ }
12410
+ }
12411
+
12412
+ if (payload.positionTargetId !== undefined) {
12413
+ if (payload.positionTargetId === payload.elementId) {
12414
+ return invalidPrecondition(
12415
+ "payload.positionTargetId must not reference the moved control element",
12416
+ );
12417
+ }
12418
+
12419
+ if (!isPlainObject(collection.items[payload.positionTargetId])) {
12420
+ return invalidPrecondition(
12421
+ "payload.positionTargetId must reference an existing control element",
12422
+ );
12423
+ }
12424
+
12425
+ const targetParentId = getNodeParentId({
12426
+ tree: collection.tree,
12427
+ nodeId: payload.positionTargetId,
12428
+ });
12429
+
12430
+ if (targetParentId !== (payload.parentId ?? null)) {
12431
+ return invalidPrecondition(
12432
+ "payload.positionTargetId must reference a sibling under payload.parentId",
12433
+ );
12434
+ }
12435
+ }
12436
+ },
12437
+ reduce: ({ state, payload }) => {
12438
+ const collection = getControlElementCollection({
12439
+ state,
12440
+ controlId: payload.controlId,
12441
+ });
12442
+ const nodeResult = removeNodeOrResult({
12443
+ tree: collection.tree,
12444
+ nodeId: payload.elementId,
12445
+ errorMessage: "control element move target missing from tree",
12446
+ });
12447
+ if (!nodeResult.valid) {
12448
+ return nodeResult;
12449
+ }
12450
+
12451
+ insertTreeNode({
12452
+ tree: collection.tree,
12453
+ node: nodeResult.node,
12454
+ parentId: payload.parentId ?? null,
12455
+ index: payload.index,
12456
+ position: payload.position,
12457
+ positionTargetId: payload.positionTargetId,
12458
+ });
12459
+
12460
+ return state;
12461
+ },
12462
+ },
11757
12463
  {
11758
12464
  type: "layout.element.move",
11759
12465
  validatePayload: ({ payload }) => {
@@ -11942,6 +12648,8 @@ export const validatePayload = ({ type, payload }) => {
11942
12648
 
11943
12649
  export const validateAgainstState = ({ state, command }) => {
11944
12650
  return captureValidation(() => {
12651
+ const normalizedState = normalizeStateCollections(state);
12652
+
11945
12653
  if (!isPlainObject(command)) {
11946
12654
  return invalidPrecondition("command must be an object");
11947
12655
  }
@@ -11954,7 +12662,7 @@ export const validateAgainstState = ({ state, command }) => {
11954
12662
  );
11955
12663
  }
11956
12664
 
11957
- const stateResult = validateState({ state });
12665
+ const stateResult = validateState({ state: normalizedState });
11958
12666
  if (!stateResult.valid) {
11959
12667
  if (stateResult.error.kind === "invariant") {
11960
12668
  return invalidInvariant(
@@ -11976,7 +12684,7 @@ export const validateAgainstState = ({ state, command }) => {
11976
12684
 
11977
12685
  const validationResult = captureValidation(() =>
11978
12686
  definition.validateAgainstState({
11979
- state,
12687
+ state: normalizedState,
11980
12688
  payload: command.payload,
11981
12689
  }),
11982
12690
  );
@@ -11987,12 +12695,16 @@ export const validateAgainstState = ({ state, command }) => {
11987
12695
 
11988
12696
  export const processCommand = ({ state, command }) => {
11989
12697
  return captureValidation(() => {
12698
+ const normalizedState = normalizeStateCollections(state);
12699
+ const shouldMaterializeControls =
12700
+ typeof command?.type === "string" && command.type.startsWith("control.");
12701
+
11990
12702
  if (!isPlainObject(command)) {
11991
12703
  return invalidPrecondition("command must be an object");
11992
12704
  }
11993
12705
 
11994
12706
  const preconditionResult = validateAgainstState({
11995
- state,
12707
+ state: normalizedState,
11996
12708
  command,
11997
12709
  });
11998
12710
  if (!preconditionResult.valid) {
@@ -12005,14 +12717,21 @@ export const processCommand = ({ state, command }) => {
12005
12717
  }
12006
12718
 
12007
12719
  const nextState = definition.reduce({
12008
- state: structuredClone(state),
12720
+ state: structuredClone(
12721
+ shouldMaterializeControls ? normalizedState : state,
12722
+ ),
12009
12723
  payload: command.payload,
12010
12724
  });
12011
12725
  if (nextState?.valid === false) {
12012
12726
  return nextState;
12013
12727
  }
12014
12728
 
12015
- const finalState = nextState === undefined ? state : nextState;
12729
+ const finalState =
12730
+ nextState === undefined
12731
+ ? shouldMaterializeControls
12732
+ ? normalizedState
12733
+ : state
12734
+ : nextState;
12016
12735
  const stateResult = validateState({
12017
12736
  state: finalState,
12018
12737
  });