@routevn/creator-model 1.2.9 → 1.3.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/model.js +1719 -192
package/src/model.js CHANGED
@@ -35,11 +35,64 @@ const COLLECTION_KEYS = [
35
35
  "layouts",
36
36
  "controls",
37
37
  ];
38
- const ROOT_KEYS = ["project", "story", ...COLLECTION_KEYS];
38
+ const ROOT_KEYS = ["project", "story", "tags", ...COLLECTION_KEYS];
39
+ const LINE_UPDATE_ACTIONS_PRESERVE_PATHS = ["dialogue.content"];
40
+ const LINE_UPDATE_ACTIONS_PRESERVE_PATHS_SET = new Set(
41
+ LINE_UPDATE_ACTIONS_PRESERVE_PATHS,
42
+ );
39
43
  const createEmptyCollectionState = () => ({
40
44
  items: {},
41
45
  tree: [],
42
46
  });
47
+ const TAG_SCOPE_BASE_KEYS = [
48
+ "images",
49
+ "sounds",
50
+ "videos",
51
+ "characters",
52
+ "transforms",
53
+ ];
54
+ const CHARACTER_SPRITE_TAG_SCOPE_PREFIX = "characterSprites:";
55
+ const createEmptyTagsState = () => ({
56
+ images: createEmptyCollectionState(),
57
+ sounds: createEmptyCollectionState(),
58
+ videos: createEmptyCollectionState(),
59
+ characters: createEmptyCollectionState(),
60
+ transforms: createEmptyCollectionState(),
61
+ });
62
+ const isCharacterSpriteTagScopeKey = (value) =>
63
+ isNonEmptyString(value) &&
64
+ value.startsWith(CHARACTER_SPRITE_TAG_SCOPE_PREFIX) &&
65
+ value.length > CHARACTER_SPRITE_TAG_SCOPE_PREFIX.length;
66
+ const getCharacterSpriteTagScopeCharacterId = (scopeKey) =>
67
+ isCharacterSpriteTagScopeKey(scopeKey)
68
+ ? scopeKey.slice(CHARACTER_SPRITE_TAG_SCOPE_PREFIX.length)
69
+ : undefined;
70
+ const isBaseTagScopeKey = (scopeKey) => TAG_SCOPE_BASE_KEYS.includes(scopeKey);
71
+ const normalizeTagsState = (tags) => {
72
+ if (tags === undefined) {
73
+ return createEmptyTagsState();
74
+ }
75
+
76
+ if (!isPlainObject(tags)) {
77
+ return tags;
78
+ }
79
+
80
+ const missingBaseScopeKeys = TAG_SCOPE_BASE_KEYS.filter(
81
+ (scopeKey) => tags[scopeKey] === undefined,
82
+ );
83
+ if (missingBaseScopeKeys.length === 0) {
84
+ return tags;
85
+ }
86
+
87
+ const nextTags = {
88
+ ...tags,
89
+ };
90
+ for (const scopeKey of missingBaseScopeKeys) {
91
+ nextTags[scopeKey] = createEmptyCollectionState();
92
+ }
93
+
94
+ return nextTags;
95
+ };
43
96
 
44
97
  const normalizeStateCollections = (state) => {
45
98
  if (!isPlainObject(state)) {
@@ -51,8 +104,10 @@ const normalizeStateCollections = (state) => {
51
104
  "particles",
52
105
  "controls",
53
106
  ].filter((key) => state[key] === undefined);
107
+ const normalizedTags = normalizeTagsState(state.tags);
108
+ const hasNormalizedTags = normalizedTags !== state.tags;
54
109
 
55
- if (missingCollectionKeys.length === 0) {
110
+ if (missingCollectionKeys.length === 0 && !hasNormalizedTags) {
56
111
  return state;
57
112
  }
58
113
 
@@ -64,6 +119,10 @@ const normalizeStateCollections = (state) => {
64
119
  nextState[key] = createEmptyCollectionState();
65
120
  });
66
121
 
122
+ if (hasNormalizedTags) {
123
+ nextState.tags = normalizedTags;
124
+ }
125
+
67
126
  return nextState;
68
127
  };
69
128
  const isString = (value) => typeof value === "string";
@@ -160,7 +219,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
160
219
  "container-ref-confirm-dialog-ok",
161
220
  "container-ref-confirm-dialog-cancel",
162
221
  ];
163
- export const SCHEMA_VERSION = 2;
222
+ export const SCHEMA_VERSION = 3;
164
223
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
165
224
  "folder",
166
225
  "container",
@@ -264,6 +323,7 @@ const isVariableReferenceTarget = (state, variableId) => {
264
323
  };
265
324
 
266
325
  const LAYOUT_ITEM_TARGET_SET = new Set(["item.savedAt"]);
326
+ const LAYOUT_DIALOGUE_TARGET_SET = new Set(["dialogue.characterId"]);
267
327
  const RUNTIME_TARGET_DOT_PATTERN = /^runtime\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
268
328
  const VARIABLE_TARGET_DOT_PATTERN = /^variables\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
269
329
  const VARIABLE_TARGET_BRACKET_PATTERN = /^variables\[(.+)\]$/;
@@ -280,6 +340,13 @@ const parseLayoutConditionTarget = (target) => {
280
340
  };
281
341
  }
282
342
 
343
+ if (LAYOUT_DIALOGUE_TARGET_SET.has(target)) {
344
+ return {
345
+ kind: "dialogue",
346
+ target,
347
+ };
348
+ }
349
+
283
350
  const runtimeMatch = target.match(RUNTIME_TARGET_DOT_PATTERN);
284
351
  if (runtimeMatch && isRuntimeFieldId(runtimeMatch[1])) {
285
352
  return {
@@ -336,7 +403,7 @@ const isLayoutConditionTarget = (state, target) => {
336
403
  return false;
337
404
  }
338
405
 
339
- if (parsedTarget.kind === "runtime") {
406
+ if (parsedTarget.kind !== "variable") {
340
407
  return true;
341
408
  }
342
409
 
@@ -777,6 +844,7 @@ const validateImageItems = ({ items, path, errorFactory }) => {
777
844
  "type",
778
845
  "name",
779
846
  "description",
847
+ "tagIds",
780
848
  "thumbnailFileId",
781
849
  "fileId",
782
850
  "width",
@@ -819,6 +887,18 @@ const validateImageItems = ({ items, path, errorFactory }) => {
819
887
  }
820
888
 
821
889
  if (item.type === "image") {
890
+ {
891
+ const result = validateOptionalUniqueIdArray({
892
+ value: item.tagIds,
893
+ path: `${itemPath}.tagIds`,
894
+ errorFactory,
895
+ allowEmpty: false,
896
+ });
897
+ if (result?.valid === false) {
898
+ return result;
899
+ }
900
+ }
901
+
822
902
  if (
823
903
  item.thumbnailFileId !== undefined &&
824
904
  !isNonEmptyString(item.thumbnailFileId)
@@ -1066,6 +1146,7 @@ const validateSoundItems = ({ items, path, errorFactory }) => {
1066
1146
  "type",
1067
1147
  "name",
1068
1148
  "description",
1149
+ "tagIds",
1069
1150
  "fileId",
1070
1151
  "waveformDataFileId",
1071
1152
  "duration",
@@ -1107,6 +1188,18 @@ const validateSoundItems = ({ items, path, errorFactory }) => {
1107
1188
  }
1108
1189
 
1109
1190
  if (item.type === "sound") {
1191
+ {
1192
+ const result = validateOptionalUniqueIdArray({
1193
+ value: item.tagIds,
1194
+ path: `${itemPath}.tagIds`,
1195
+ errorFactory,
1196
+ allowEmpty: false,
1197
+ });
1198
+ if (result?.valid === false) {
1199
+ return result;
1200
+ }
1201
+ }
1202
+
1110
1203
  if (!isNonEmptyString(item.fileId)) {
1111
1204
  return invalidFromErrorFactory(
1112
1205
  errorFactory,
@@ -1157,6 +1250,7 @@ const validateVideoItems = ({ items, path, errorFactory }) => {
1157
1250
  "type",
1158
1251
  "name",
1159
1252
  "description",
1253
+ "tagIds",
1160
1254
  "fileId",
1161
1255
  "thumbnailFileId",
1162
1256
  "duration",
@@ -1200,6 +1294,18 @@ const validateVideoItems = ({ items, path, errorFactory }) => {
1200
1294
  }
1201
1295
 
1202
1296
  if (item.type === "video") {
1297
+ {
1298
+ const result = validateOptionalUniqueIdArray({
1299
+ value: item.tagIds,
1300
+ path: `${itemPath}.tagIds`,
1301
+ errorFactory,
1302
+ allowEmpty: false,
1303
+ });
1304
+ if (result?.valid === false) {
1305
+ return result;
1306
+ }
1307
+ }
1308
+
1203
1309
  if (!isNonEmptyString(item.fileId)) {
1204
1310
  return invalidFromErrorFactory(
1205
1311
  errorFactory,
@@ -2081,6 +2187,7 @@ const validateTransformItems = ({ items, path, errorFactory }) => {
2081
2187
  "type",
2082
2188
  "name",
2083
2189
  "description",
2190
+ "tagIds",
2084
2191
  "x",
2085
2192
  "y",
2086
2193
  "scaleX",
@@ -2126,6 +2233,18 @@ const validateTransformItems = ({ items, path, errorFactory }) => {
2126
2233
  }
2127
2234
 
2128
2235
  if (item.type === "transform") {
2236
+ {
2237
+ const result = validateOptionalUniqueIdArray({
2238
+ value: item.tagIds,
2239
+ path: `${itemPath}.tagIds`,
2240
+ errorFactory,
2241
+ allowEmpty: false,
2242
+ });
2243
+ if (result?.valid === false) {
2244
+ return result;
2245
+ }
2246
+ }
2247
+
2129
2248
  for (const key of [
2130
2249
  "x",
2131
2250
  "y",
@@ -2590,7 +2709,12 @@ const validateTextStyleItems = ({ items, path, errorFactory }) => {
2590
2709
  }
2591
2710
  };
2592
2711
 
2593
- const validateCharacterSpriteItems = ({ items, path, errorFactory }) => {
2712
+ const validateCharacterSpriteItems = ({
2713
+ items,
2714
+ path,
2715
+ errorFactory,
2716
+ allowTagIds = true,
2717
+ }) => {
2594
2718
  for (const [itemId, item] of Object.entries(items)) {
2595
2719
  const itemPath = `${path}.${itemId}`;
2596
2720
 
@@ -2612,6 +2736,7 @@ const validateCharacterSpriteItems = ({ items, path, errorFactory }) => {
2612
2736
  "type",
2613
2737
  "name",
2614
2738
  "description",
2739
+ ...(allowTagIds ? ["tagIds"] : []),
2615
2740
  "thumbnailFileId",
2616
2741
  "fileId",
2617
2742
  "width",
@@ -2654,6 +2779,20 @@ const validateCharacterSpriteItems = ({ items, path, errorFactory }) => {
2654
2779
  }
2655
2780
 
2656
2781
  if (item.type === "image") {
2782
+ if (allowTagIds) {
2783
+ {
2784
+ const result = validateOptionalUniqueIdArray({
2785
+ value: item.tagIds,
2786
+ path: `${itemPath}.tagIds`,
2787
+ errorFactory,
2788
+ allowEmpty: false,
2789
+ });
2790
+ if (result?.valid === false) {
2791
+ return result;
2792
+ }
2793
+ }
2794
+ }
2795
+
2657
2796
  if (
2658
2797
  item.thumbnailFileId !== undefined &&
2659
2798
  !isNonEmptyString(item.thumbnailFileId)
@@ -3449,6 +3588,7 @@ const validateCharacterItems = ({ items, path, errorFactory }) => {
3449
3588
  "type",
3450
3589
  "name",
3451
3590
  "description",
3591
+ "tagIds",
3452
3592
  "shortcut",
3453
3593
  "fileId",
3454
3594
  "sprites",
@@ -3490,6 +3630,18 @@ const validateCharacterItems = ({ items, path, errorFactory }) => {
3490
3630
  }
3491
3631
 
3492
3632
  if (item.type === "character") {
3633
+ {
3634
+ const result = validateOptionalUniqueIdArray({
3635
+ value: item.tagIds,
3636
+ path: `${itemPath}.tagIds`,
3637
+ errorFactory,
3638
+ allowEmpty: false,
3639
+ });
3640
+ if (result?.valid === false) {
3641
+ return result;
3642
+ }
3643
+ }
3644
+
3493
3645
  if (item.shortcut !== undefined && !isString(item.shortcut)) {
3494
3646
  return invalidFromErrorFactory(
3495
3647
  errorFactory,
@@ -3521,80 +3673,23 @@ const validateCharacterItems = ({ items, path, errorFactory }) => {
3521
3673
  }
3522
3674
  };
3523
3675
 
3524
- const validateKeyboardMap = ({ value, path, errorFactory }) => {
3525
- if (value === undefined) {
3526
- return VALID_RESULT;
3527
- }
3528
-
3529
- if (!isPlainObject(value)) {
3530
- return invalidFromErrorFactory(
3531
- errorFactory,
3532
- `${path} must be an object when provided`,
3533
- );
3534
- }
3535
-
3536
- for (const [key, interaction] of Object.entries(value)) {
3537
- if (!isNonEmptyString(key)) {
3538
- return invalidFromErrorFactory(
3539
- errorFactory,
3540
- `${path} keys must be non-empty strings`,
3541
- );
3542
- }
3543
-
3544
- if (!isPlainObject(interaction)) {
3545
- return invalidFromErrorFactory(
3546
- errorFactory,
3547
- `${path}.${key} must be an object`,
3548
- );
3549
- }
3550
- }
3551
-
3552
- return VALID_RESULT;
3553
- };
3554
-
3555
- const validatePreviewObject = ({ value, path, errorFactory }) => {
3556
- if (value === undefined) {
3557
- return VALID_RESULT;
3558
- }
3559
-
3560
- if (!isPlainObject(value)) {
3561
- return invalidFromErrorFactory(
3562
- errorFactory,
3563
- `${path} must be an object when provided`,
3564
- );
3565
- }
3566
-
3567
- return VALID_RESULT;
3568
- };
3676
+ const validateTagItems = ({ items, path, errorFactory }) => {
3677
+ const seenNames = new Set();
3569
3678
 
3570
- const validateLayoutItems = ({ items, path, errorFactory }) => {
3571
3679
  for (const [itemId, item] of Object.entries(items)) {
3572
3680
  const itemPath = `${path}.${itemId}`;
3573
3681
 
3574
- if (item?.type !== "folder" && item?.type !== "layout") {
3682
+ if (item?.type !== "tag") {
3575
3683
  return invalidFromErrorFactory(
3576
3684
  errorFactory,
3577
- `${itemPath}.type must be 'folder' or 'layout'`,
3685
+ `${itemPath}.type must be 'tag'`,
3578
3686
  );
3579
3687
  }
3580
3688
 
3581
3689
  {
3582
3690
  const result = validateAllowedKeys({
3583
3691
  value: item,
3584
- allowedKeys:
3585
- item.type === "folder"
3586
- ? ["id", "type", "name", "description"]
3587
- : [
3588
- "id",
3589
- "type",
3590
- "name",
3591
- "description",
3592
- "layoutType",
3593
- "isFragment",
3594
- "thumbnailFileId",
3595
- "preview",
3596
- "elements",
3597
- ],
3692
+ allowedKeys: ["id", "type", "name", "color"],
3598
3693
  path: itemPath,
3599
3694
  errorFactory,
3600
3695
  });
@@ -3624,81 +3719,601 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
3624
3719
  );
3625
3720
  }
3626
3721
 
3627
- if (item.description !== undefined && !isString(item.description)) {
3722
+ if (item.color !== undefined && !isHexColor(item.color)) {
3628
3723
  return invalidFromErrorFactory(
3629
3724
  errorFactory,
3630
- `${itemPath}.description must be a string when provided`,
3725
+ `${itemPath}.color must be a hex color when provided`,
3631
3726
  );
3632
3727
  }
3633
3728
 
3634
- if (
3635
- item.thumbnailFileId !== undefined &&
3636
- !isNonEmptyString(item.thumbnailFileId)
3637
- ) {
3729
+ const normalizedName = item.name.trim().toLowerCase();
3730
+ if (seenNames.has(normalizedName)) {
3638
3731
  return invalidFromErrorFactory(
3639
3732
  errorFactory,
3640
- `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
3733
+ `${itemPath}.name must be unique within its tag scope`,
3641
3734
  );
3642
3735
  }
3643
3736
 
3644
- {
3645
- const result = validatePreviewObject({
3646
- value: item.preview,
3647
- path: `${itemPath}.preview`,
3648
- errorFactory,
3649
- });
3650
- if (result?.valid === false) {
3651
- return result;
3652
- }
3737
+ seenNames.add(normalizedName);
3738
+ }
3739
+ };
3740
+
3741
+ const validateFlatTagTree = ({ nodes, path, errorFactory }) => {
3742
+ const visitNodes = (entries, entryPath) => {
3743
+ if (!Array.isArray(entries)) {
3744
+ return VALID_RESULT;
3653
3745
  }
3654
3746
 
3655
- if (item.type === "layout") {
3656
- if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
3747
+ for (const [index, node] of entries.entries()) {
3748
+ if (Object.hasOwn(node, "children")) {
3657
3749
  return invalidFromErrorFactory(
3658
3750
  errorFactory,
3659
- `${itemPath}.layoutType must be 'general', 'save-load', 'confirmDialog', 'dialogue-adv', 'dialogue-nvl', 'choice', or 'history'`,
3751
+ `${entryPath}[${index}].children is not allowed`,
3660
3752
  );
3661
3753
  }
3754
+ }
3662
3755
 
3663
- if (
3664
- item.isFragment !== undefined &&
3665
- typeof item.isFragment !== "boolean"
3666
- ) {
3667
- return invalidFromErrorFactory(
3668
- errorFactory,
3669
- `${itemPath}.isFragment must be a boolean when provided`,
3670
- );
3671
- }
3756
+ return VALID_RESULT;
3757
+ };
3672
3758
 
3673
- {
3674
- const result = validateNestedCollection({
3675
- collection: item.elements,
3676
- path: `${itemPath}.elements`,
3677
- itemValidator: validateLayoutElementItems,
3678
- treeValidator: validateLayoutElementTreeOwnership,
3679
- treeNodeLabel: "layout element",
3680
- });
3681
- if (result?.valid === false) {
3682
- return result;
3683
- }
3684
- }
3759
+ return visitNodes(nodes, path);
3760
+ };
3761
+
3762
+ const validateTagCreateData = ({
3763
+ data,
3764
+ path = "payload.data",
3765
+ errorFactory = createPayloadValidationError,
3766
+ }) => {
3767
+ {
3768
+ const result = validateAllowedKeys({
3769
+ value: data,
3770
+ allowedKeys: ["type", "name", "color"],
3771
+ path,
3772
+ errorFactory,
3773
+ });
3774
+ if (result?.valid === false) {
3775
+ return result;
3685
3776
  }
3686
3777
  }
3778
+
3779
+ if (data?.type !== "tag") {
3780
+ return invalidFromErrorFactory(
3781
+ errorFactory,
3782
+ `${path}.type must be 'tag'`,
3783
+ );
3784
+ }
3785
+
3786
+ if (!isNonEmptyString(data.name)) {
3787
+ return invalidFromErrorFactory(
3788
+ errorFactory,
3789
+ `${path}.name must be a non-empty string`,
3790
+ );
3791
+ }
3792
+
3793
+ if (data.color !== undefined && !isHexColor(data.color)) {
3794
+ return invalidFromErrorFactory(
3795
+ errorFactory,
3796
+ `${path}.color must be a hex color when provided`,
3797
+ );
3798
+ }
3687
3799
  };
3688
3800
 
3689
- const validateControlItems = ({ items, path, errorFactory }) => {
3690
- for (const [itemId, item] of Object.entries(items)) {
3691
- const itemPath = `${path}.${itemId}`;
3801
+ const validateTagUpdateData = ({
3802
+ data,
3803
+ path = "payload.data",
3804
+ errorFactory = createPayloadValidationError,
3805
+ }) => {
3806
+ {
3807
+ const result = validateAllowedKeys({
3808
+ value: data,
3809
+ allowedKeys: ["name", "color"],
3810
+ path,
3811
+ errorFactory,
3812
+ });
3813
+ if (result?.valid === false) {
3814
+ return result;
3815
+ }
3816
+ }
3692
3817
 
3693
- if (item?.type !== "folder" && item?.type !== "control") {
3818
+ if (Object.keys(data).length === 0) {
3819
+ return invalidFromErrorFactory(
3820
+ errorFactory,
3821
+ `${path} must include at least one field`,
3822
+ );
3823
+ }
3824
+
3825
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
3826
+ return invalidFromErrorFactory(
3827
+ errorFactory,
3828
+ `${path}.name must be a non-empty string when provided`,
3829
+ );
3830
+ }
3831
+
3832
+ if (
3833
+ data.color !== undefined &&
3834
+ data.color !== null &&
3835
+ !isHexColor(data.color)
3836
+ ) {
3837
+ return invalidFromErrorFactory(
3838
+ errorFactory,
3839
+ `${path}.color must be a hex color or null when provided`,
3840
+ );
3841
+ }
3842
+ };
3843
+
3844
+ const validateTagsRoot = ({ state, tags, path, errorFactory }) => {
3845
+ if (!isPlainObject(tags)) {
3846
+ return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
3847
+ }
3848
+
3849
+ for (const scopeKey of Object.keys(tags)) {
3850
+ if (!isBaseTagScopeKey(scopeKey) && !isCharacterSpriteTagScopeKey(scopeKey)) {
3694
3851
  return invalidFromErrorFactory(
3695
3852
  errorFactory,
3696
- `${itemPath}.type must be 'folder' or 'control'`,
3853
+ `${path}.${scopeKey} is not allowed`,
3697
3854
  );
3698
3855
  }
3856
+ }
3699
3857
 
3700
- {
3701
- const result = validateAllowedKeys({
3858
+ for (const scopeKey of TAG_SCOPE_BASE_KEYS) {
3859
+ if (!Object.hasOwn(tags, scopeKey)) {
3860
+ return invalidFromErrorFactory(
3861
+ errorFactory,
3862
+ `${path}.${scopeKey} is required`,
3863
+ );
3864
+ }
3865
+ }
3866
+
3867
+ for (const [scopeKey, collection] of Object.entries(tags)) {
3868
+ {
3869
+ const result = validateNestedCollection({
3870
+ collection,
3871
+ path: `${path}.${scopeKey}`,
3872
+ itemValidator: validateTagItems,
3873
+ treeValidator: validateFlatTagTree,
3874
+ errorFactory,
3875
+ });
3876
+ if (result?.valid === false) {
3877
+ return result;
3878
+ }
3879
+ }
3880
+
3881
+ if (!isCharacterSpriteTagScopeKey(scopeKey)) {
3882
+ continue;
3883
+ }
3884
+
3885
+ const characterId = getCharacterSpriteTagScopeCharacterId(scopeKey);
3886
+ const character = state.characters?.items?.[characterId];
3887
+ if (!isPlainObject(character) || character.type !== "character") {
3888
+ return invalidFromErrorFactory(
3889
+ errorFactory,
3890
+ `${path}.${scopeKey} must reference an existing character`,
3891
+ );
3892
+ }
3893
+ }
3894
+ };
3895
+
3896
+ const getTagScopeCollection = ({ state, scopeKey }) => state.tags?.[scopeKey];
3897
+
3898
+ const validateTagScopeKey = ({ scopeKey, path, errorFactory }) => {
3899
+ if (!isNonEmptyString(scopeKey)) {
3900
+ return invalidFromErrorFactory(
3901
+ errorFactory,
3902
+ `${path} must be a non-empty string`,
3903
+ );
3904
+ }
3905
+
3906
+ if (isBaseTagScopeKey(scopeKey) || isCharacterSpriteTagScopeKey(scopeKey)) {
3907
+ return VALID_RESULT;
3908
+ }
3909
+
3910
+ return invalidFromErrorFactory(
3911
+ errorFactory,
3912
+ `${path} must be a supported tag scope key`,
3913
+ );
3914
+ };
3915
+
3916
+ const validateTagScopeAgainstState = ({
3917
+ state,
3918
+ scopeKey,
3919
+ path,
3920
+ details = {},
3921
+ errorFactory = createPreconditionValidationError,
3922
+ }) => {
3923
+ const scopeKeyResult = validateTagScopeKey({
3924
+ scopeKey,
3925
+ path,
3926
+ errorFactory,
3927
+ });
3928
+ if (!scopeKeyResult.valid) {
3929
+ return scopeKeyResult;
3930
+ }
3931
+
3932
+ if (!isCharacterSpriteTagScopeKey(scopeKey)) {
3933
+ return VALID_RESULT;
3934
+ }
3935
+
3936
+ const characterId = getCharacterSpriteTagScopeCharacterId(scopeKey);
3937
+ const character = state.characters?.items?.[characterId];
3938
+ if (!isPlainObject(character) || character.type !== "character") {
3939
+ return invalidFromErrorFactory(
3940
+ errorFactory,
3941
+ `${path} must reference an existing character sprite tag scope`,
3942
+ {
3943
+ ...details,
3944
+ characterId,
3945
+ scopeKey,
3946
+ },
3947
+ );
3948
+ }
3949
+
3950
+ return VALID_RESULT;
3951
+ };
3952
+
3953
+ const validateTagIdsAgainstScope = ({
3954
+ state,
3955
+ tagIds,
3956
+ scopeKey,
3957
+ path,
3958
+ details = {},
3959
+ errorFactory = createPreconditionValidationError,
3960
+ }) => {
3961
+ if (tagIds === undefined) {
3962
+ return VALID_RESULT;
3963
+ }
3964
+
3965
+ const scopeResult = validateTagScopeAgainstState({
3966
+ state,
3967
+ scopeKey,
3968
+ path,
3969
+ details,
3970
+ errorFactory,
3971
+ });
3972
+ if (!scopeResult.valid) {
3973
+ return scopeResult;
3974
+ }
3975
+
3976
+ const collection = getTagScopeCollection({ state, scopeKey });
3977
+ for (const [index, tagId] of tagIds.entries()) {
3978
+ const tag = collection?.items?.[tagId];
3979
+ if (!isPlainObject(tag) || tag.type !== "tag") {
3980
+ return invalidFromErrorFactory(
3981
+ errorFactory,
3982
+ `${path}[${index}] must reference an existing tag in scope '${scopeKey}'`,
3983
+ {
3984
+ ...details,
3985
+ scopeKey,
3986
+ tagId,
3987
+ },
3988
+ );
3989
+ }
3990
+ }
3991
+
3992
+ return VALID_RESULT;
3993
+ };
3994
+
3995
+ const validateUniqueTagNameInScope = ({
3996
+ collection,
3997
+ name,
3998
+ path,
3999
+ excludeTagId,
4000
+ errorFactory = createPreconditionValidationError,
4001
+ }) => {
4002
+ const normalizedName = name.trim().toLowerCase();
4003
+
4004
+ for (const [tagId, tag] of Object.entries(collection?.items || {})) {
4005
+ if (tagId === excludeTagId || tag?.type !== "tag") {
4006
+ continue;
4007
+ }
4008
+
4009
+ if (tag.name?.trim?.().toLowerCase?.() === normalizedName) {
4010
+ return invalidFromErrorFactory(
4011
+ errorFactory,
4012
+ `${path} must be unique within its tag scope`,
4013
+ );
4014
+ }
4015
+ }
4016
+
4017
+ return VALID_RESULT;
4018
+ };
4019
+
4020
+ const ensureTagScopeCollection = ({ state, scopeKey }) => {
4021
+ state.tags ??= createEmptyTagsState();
4022
+ state.tags[scopeKey] ??= createEmptyCollectionState();
4023
+ return state.tags[scopeKey];
4024
+ };
4025
+
4026
+ const assignOptionalTagIds = ({ target, tagIds }) => {
4027
+ if (Array.isArray(tagIds) && tagIds.length > 0) {
4028
+ target.tagIds = structuredClone(tagIds);
4029
+ }
4030
+ };
4031
+
4032
+ const applyTagIdsUpdate = ({ currentItem, data }) => {
4033
+ const nextData = structuredClone(data);
4034
+ if (nextData.tagIds === undefined) {
4035
+ delete nextData.tagIds;
4036
+ }
4037
+
4038
+ const nextItem = {
4039
+ ...structuredClone(currentItem),
4040
+ ...nextData,
4041
+ };
4042
+
4043
+ if (data.tagIds !== undefined) {
4044
+ if (Array.isArray(data.tagIds) && data.tagIds.length > 0) {
4045
+ nextItem.tagIds = structuredClone(data.tagIds);
4046
+ } else {
4047
+ delete nextItem.tagIds;
4048
+ }
4049
+ }
4050
+
4051
+ return nextItem;
4052
+ };
4053
+
4054
+ const stripDeletedTagIdsFromItem = ({ item, deletedTagIds }) => {
4055
+ if (!Array.isArray(item?.tagIds) || item.tagIds.length === 0) {
4056
+ return;
4057
+ }
4058
+
4059
+ const remainingTagIds = item.tagIds.filter((tagId) => !deletedTagIds.has(tagId));
4060
+ if (remainingTagIds.length === item.tagIds.length) {
4061
+ return;
4062
+ }
4063
+
4064
+ if (remainingTagIds.length === 0) {
4065
+ delete item.tagIds;
4066
+ return;
4067
+ }
4068
+
4069
+ item.tagIds = remainingTagIds;
4070
+ };
4071
+
4072
+ const stripDeletedTagIdsFromScopeItems = ({ state, scopeKey, deletedTagIds }) => {
4073
+ if (deletedTagIds.size === 0) {
4074
+ return;
4075
+ }
4076
+
4077
+ if (scopeKey === "images") {
4078
+ for (const item of Object.values(state.images.items)) {
4079
+ if (item?.type === "image") {
4080
+ stripDeletedTagIdsFromItem({ item, deletedTagIds });
4081
+ }
4082
+ }
4083
+ return;
4084
+ }
4085
+
4086
+ if (scopeKey === "sounds") {
4087
+ for (const item of Object.values(state.sounds.items)) {
4088
+ if (item?.type === "sound") {
4089
+ stripDeletedTagIdsFromItem({ item, deletedTagIds });
4090
+ }
4091
+ }
4092
+ return;
4093
+ }
4094
+
4095
+ if (scopeKey === "videos") {
4096
+ for (const item of Object.values(state.videos.items)) {
4097
+ if (item?.type === "video") {
4098
+ stripDeletedTagIdsFromItem({ item, deletedTagIds });
4099
+ }
4100
+ }
4101
+ return;
4102
+ }
4103
+
4104
+ if (scopeKey === "characters") {
4105
+ for (const item of Object.values(state.characters.items)) {
4106
+ if (item?.type === "character") {
4107
+ stripDeletedTagIdsFromItem({ item, deletedTagIds });
4108
+ }
4109
+ }
4110
+ return;
4111
+ }
4112
+
4113
+ if (scopeKey === "transforms") {
4114
+ for (const item of Object.values(state.transforms.items)) {
4115
+ if (item?.type === "transform") {
4116
+ stripDeletedTagIdsFromItem({ item, deletedTagIds });
4117
+ }
4118
+ }
4119
+ return;
4120
+ }
4121
+
4122
+ if (!isCharacterSpriteTagScopeKey(scopeKey)) {
4123
+ return;
4124
+ }
4125
+
4126
+ const characterId = getCharacterSpriteTagScopeCharacterId(scopeKey);
4127
+ const collection = getCharacterSpriteCollection({
4128
+ state,
4129
+ characterId,
4130
+ });
4131
+
4132
+ for (const item of Object.values(collection?.items || {})) {
4133
+ if (item?.type === "image") {
4134
+ stripDeletedTagIdsFromItem({ item, deletedTagIds });
4135
+ }
4136
+ }
4137
+ };
4138
+
4139
+ const validateKeyboardMap = ({ value, path, errorFactory }) => {
4140
+ if (value === undefined) {
4141
+ return VALID_RESULT;
4142
+ }
4143
+
4144
+ if (!isPlainObject(value)) {
4145
+ return invalidFromErrorFactory(
4146
+ errorFactory,
4147
+ `${path} must be an object when provided`,
4148
+ );
4149
+ }
4150
+
4151
+ for (const [key, interaction] of Object.entries(value)) {
4152
+ if (!isNonEmptyString(key)) {
4153
+ return invalidFromErrorFactory(
4154
+ errorFactory,
4155
+ `${path} keys must be non-empty strings`,
4156
+ );
4157
+ }
4158
+
4159
+ if (!isPlainObject(interaction)) {
4160
+ return invalidFromErrorFactory(
4161
+ errorFactory,
4162
+ `${path}.${key} must be an object`,
4163
+ );
4164
+ }
4165
+ }
4166
+
4167
+ return VALID_RESULT;
4168
+ };
4169
+
4170
+ const validatePreviewObject = ({ value, path, errorFactory }) => {
4171
+ if (value === undefined) {
4172
+ return VALID_RESULT;
4173
+ }
4174
+
4175
+ if (!isPlainObject(value)) {
4176
+ return invalidFromErrorFactory(
4177
+ errorFactory,
4178
+ `${path} must be an object when provided`,
4179
+ );
4180
+ }
4181
+
4182
+ return VALID_RESULT;
4183
+ };
4184
+
4185
+ const validateLayoutItems = ({ items, path, errorFactory }) => {
4186
+ for (const [itemId, item] of Object.entries(items)) {
4187
+ const itemPath = `${path}.${itemId}`;
4188
+
4189
+ if (item?.type !== "folder" && item?.type !== "layout") {
4190
+ return invalidFromErrorFactory(
4191
+ errorFactory,
4192
+ `${itemPath}.type must be 'folder' or 'layout'`,
4193
+ );
4194
+ }
4195
+
4196
+ {
4197
+ const result = validateAllowedKeys({
4198
+ value: item,
4199
+ allowedKeys:
4200
+ item.type === "folder"
4201
+ ? ["id", "type", "name", "description"]
4202
+ : [
4203
+ "id",
4204
+ "type",
4205
+ "name",
4206
+ "description",
4207
+ "layoutType",
4208
+ "isFragment",
4209
+ "thumbnailFileId",
4210
+ "preview",
4211
+ "elements",
4212
+ ],
4213
+ path: itemPath,
4214
+ errorFactory,
4215
+ });
4216
+ if (result?.valid === false) {
4217
+ return result;
4218
+ }
4219
+ }
4220
+
4221
+ if (!isNonEmptyString(item.id)) {
4222
+ return invalidFromErrorFactory(
4223
+ errorFactory,
4224
+ `${itemPath}.id must be a non-empty string`,
4225
+ );
4226
+ }
4227
+
4228
+ if (item.id !== itemId) {
4229
+ return invalidFromErrorFactory(
4230
+ errorFactory,
4231
+ `${itemPath}.id must match item key '${itemId}'`,
4232
+ );
4233
+ }
4234
+
4235
+ if (!isNonEmptyString(item.name)) {
4236
+ return invalidFromErrorFactory(
4237
+ errorFactory,
4238
+ `${itemPath}.name must be a non-empty string`,
4239
+ );
4240
+ }
4241
+
4242
+ if (item.description !== undefined && !isString(item.description)) {
4243
+ return invalidFromErrorFactory(
4244
+ errorFactory,
4245
+ `${itemPath}.description must be a string when provided`,
4246
+ );
4247
+ }
4248
+
4249
+ if (
4250
+ item.thumbnailFileId !== undefined &&
4251
+ !isNonEmptyString(item.thumbnailFileId)
4252
+ ) {
4253
+ return invalidFromErrorFactory(
4254
+ errorFactory,
4255
+ `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
4256
+ );
4257
+ }
4258
+
4259
+ {
4260
+ const result = validatePreviewObject({
4261
+ value: item.preview,
4262
+ path: `${itemPath}.preview`,
4263
+ errorFactory,
4264
+ });
4265
+ if (result?.valid === false) {
4266
+ return result;
4267
+ }
4268
+ }
4269
+
4270
+ if (item.type === "layout") {
4271
+ if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
4272
+ return invalidFromErrorFactory(
4273
+ errorFactory,
4274
+ `${itemPath}.layoutType must be 'general', 'save-load', 'confirmDialog', 'dialogue-adv', 'dialogue-nvl', 'choice', or 'history'`,
4275
+ );
4276
+ }
4277
+
4278
+ if (
4279
+ item.isFragment !== undefined &&
4280
+ typeof item.isFragment !== "boolean"
4281
+ ) {
4282
+ return invalidFromErrorFactory(
4283
+ errorFactory,
4284
+ `${itemPath}.isFragment must be a boolean when provided`,
4285
+ );
4286
+ }
4287
+
4288
+ {
4289
+ const result = validateNestedCollection({
4290
+ collection: item.elements,
4291
+ path: `${itemPath}.elements`,
4292
+ itemValidator: validateLayoutElementItems,
4293
+ treeValidator: validateLayoutElementTreeOwnership,
4294
+ treeNodeLabel: "layout element",
4295
+ });
4296
+ if (result?.valid === false) {
4297
+ return result;
4298
+ }
4299
+ }
4300
+ }
4301
+ }
4302
+ };
4303
+
4304
+ const validateControlItems = ({ items, path, errorFactory }) => {
4305
+ for (const [itemId, item] of Object.entries(items)) {
4306
+ const itemPath = `${path}.${itemId}`;
4307
+
4308
+ if (item?.type !== "folder" && item?.type !== "control") {
4309
+ return invalidFromErrorFactory(
4310
+ errorFactory,
4311
+ `${itemPath}.type must be 'folder' or 'control'`,
4312
+ );
4313
+ }
4314
+
4315
+ {
4316
+ const result = validateAllowedKeys({
3702
4317
  value: item,
3703
4318
  allowedKeys:
3704
4319
  item.type === "folder"
@@ -4850,6 +5465,22 @@ export const assertInvariants = ({ state }) => {
4850
5465
  return result;
4851
5466
  }
4852
5467
  }
5468
+
5469
+ {
5470
+ const result = validateTagIdsAgainstScope({
5471
+ state,
5472
+ tagIds: image.tagIds,
5473
+ scopeKey: "images",
5474
+ path: "image.tagIds",
5475
+ details: {
5476
+ imageId,
5477
+ },
5478
+ errorFactory: createInvariantValidationError,
5479
+ });
5480
+ if (!result.valid) {
5481
+ return result;
5482
+ }
5483
+ }
4853
5484
  }
4854
5485
 
4855
5486
  for (const [spritesheetId, spritesheet] of Object.entries(
@@ -4922,6 +5553,22 @@ export const assertInvariants = ({ state }) => {
4922
5553
  return result;
4923
5554
  }
4924
5555
  }
5556
+
5557
+ {
5558
+ const result = validateTagIdsAgainstScope({
5559
+ state,
5560
+ tagIds: sound.tagIds,
5561
+ scopeKey: "sounds",
5562
+ path: "sound.tagIds",
5563
+ details: {
5564
+ soundId,
5565
+ },
5566
+ errorFactory: createInvariantValidationError,
5567
+ });
5568
+ if (!result.valid) {
5569
+ return result;
5570
+ }
5571
+ }
4925
5572
  }
4926
5573
 
4927
5574
  for (const [videoId, video] of Object.entries(state.videos.items)) {
@@ -4954,6 +5601,22 @@ export const assertInvariants = ({ state }) => {
4954
5601
  return result;
4955
5602
  }
4956
5603
  }
5604
+
5605
+ {
5606
+ const result = validateTagIdsAgainstScope({
5607
+ state,
5608
+ tagIds: video.tagIds,
5609
+ scopeKey: "videos",
5610
+ path: "video.tagIds",
5611
+ details: {
5612
+ videoId,
5613
+ },
5614
+ errorFactory: createInvariantValidationError,
5615
+ });
5616
+ if (!result.valid) {
5617
+ return result;
5618
+ }
5619
+ }
4957
5620
  }
4958
5621
 
4959
5622
  for (const [fontId, font] of Object.entries(state.fonts.items)) {
@@ -5012,6 +5675,22 @@ export const assertInvariants = ({ state }) => {
5012
5675
  }
5013
5676
  }
5014
5677
 
5678
+ {
5679
+ const result = validateTagIdsAgainstScope({
5680
+ state,
5681
+ tagIds: character.tagIds,
5682
+ scopeKey: "characters",
5683
+ path: "character.tagIds",
5684
+ details: {
5685
+ characterId,
5686
+ },
5687
+ errorFactory: createInvariantValidationError,
5688
+ });
5689
+ if (!result.valid) {
5690
+ return result;
5691
+ }
5692
+ }
5693
+
5015
5694
  for (const [spriteId, sprite] of Object.entries(
5016
5695
  character.sprites?.items || {},
5017
5696
  )) {
@@ -5030,6 +5709,23 @@ export const assertInvariants = ({ state }) => {
5030
5709
  return result;
5031
5710
  }
5032
5711
 
5712
+ {
5713
+ const tagResult = validateTagIdsAgainstScope({
5714
+ state,
5715
+ tagIds: sprite.tagIds,
5716
+ scopeKey: `${CHARACTER_SPRITE_TAG_SCOPE_PREFIX}${characterId}`,
5717
+ path: "character.sprite.tagIds",
5718
+ details: {
5719
+ characterId,
5720
+ spriteId,
5721
+ },
5722
+ errorFactory: createInvariantValidationError,
5723
+ });
5724
+ if (!tagResult.valid) {
5725
+ return tagResult;
5726
+ }
5727
+ }
5728
+
5033
5729
  if (sprite.thumbnailFileId === undefined) {
5034
5730
  continue;
5035
5731
  }
@@ -5051,6 +5747,30 @@ export const assertInvariants = ({ state }) => {
5051
5747
  }
5052
5748
  }
5053
5749
 
5750
+ for (const [transformId, transform] of Object.entries(
5751
+ state.transforms.items,
5752
+ )) {
5753
+ if (transform.type !== "transform") {
5754
+ continue;
5755
+ }
5756
+
5757
+ {
5758
+ const result = validateTagIdsAgainstScope({
5759
+ state,
5760
+ tagIds: transform.tagIds,
5761
+ scopeKey: "transforms",
5762
+ path: "transform.tagIds",
5763
+ details: {
5764
+ transformId,
5765
+ },
5766
+ errorFactory: createInvariantValidationError,
5767
+ });
5768
+ if (!result.valid) {
5769
+ return result;
5770
+ }
5771
+ }
5772
+ }
5773
+
5054
5774
  for (const [layoutId, layout] of Object.entries(state.layouts.items)) {
5055
5775
  if (layout.type !== "layout" || layout.thumbnailFileId === undefined) {
5056
5776
  continue;
@@ -5362,7 +6082,7 @@ export const assertInvariants = ({ state }) => {
5362
6082
  !isLayoutConditionTarget(state, rule.when.target)
5363
6083
  ) {
5364
6084
  return invalidInvariant(
5365
- `${ownerLabel} element conditionalOverrides when target must reference an existing variable or supported runtime condition`,
6085
+ `${ownerLabel} element conditionalOverrides when target must reference an existing variable or supported layout condition`,
5366
6086
  {
5367
6087
  [ownerIdField]: ownerId,
5368
6088
  elementId,
@@ -5507,6 +6227,18 @@ const runValidateState = ({ state }) => {
5507
6227
  );
5508
6228
  }
5509
6229
 
6230
+ {
6231
+ const result = validateTagsRoot({
6232
+ state: normalizedState,
6233
+ tags: normalizedState.tags,
6234
+ path: "state.tags",
6235
+ errorFactory: createStateValidationError,
6236
+ });
6237
+ if (result?.valid === false) {
6238
+ return result;
6239
+ }
6240
+ }
6241
+
5510
6242
  for (const collectionKey of COLLECTION_KEYS) {
5511
6243
  {
5512
6244
  const result = validateCollection({
@@ -5722,6 +6454,46 @@ const validateRequiredUniqueIdArray = ({ value, path, errorFactory }) => {
5722
6454
  }
5723
6455
  };
5724
6456
 
6457
+ const validateOptionalUniqueIdArray = ({
6458
+ value,
6459
+ path,
6460
+ errorFactory,
6461
+ allowEmpty = true,
6462
+ }) => {
6463
+ if (value === undefined) {
6464
+ return VALID_RESULT;
6465
+ }
6466
+
6467
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
6468
+ return invalidFromErrorFactory(
6469
+ errorFactory,
6470
+ allowEmpty
6471
+ ? `${path} must be an array when provided`
6472
+ : `${path} must be a non-empty array when provided`,
6473
+ );
6474
+ }
6475
+
6476
+ const seen = new Set();
6477
+
6478
+ for (const [index, entry] of value.entries()) {
6479
+ if (!isNonEmptyString(entry)) {
6480
+ return invalidFromErrorFactory(
6481
+ errorFactory,
6482
+ `${path}[${index}] must be a non-empty string`,
6483
+ );
6484
+ }
6485
+
6486
+ if (seen.has(entry)) {
6487
+ return invalidFromErrorFactory(
6488
+ errorFactory,
6489
+ `${path}[${index}] must be unique`,
6490
+ );
6491
+ }
6492
+
6493
+ seen.add(entry);
6494
+ }
6495
+ };
6496
+
5725
6497
  const validateSectionCreateData = ({ data, errorFactory }) => {
5726
6498
  {
5727
6499
  const result = validateExactKeys({
@@ -5789,49 +6561,134 @@ const validateLineCreatePayload = ({ payload, errorFactory }) => {
5789
6561
  }
5790
6562
  }
5791
6563
 
5792
- if (!isNonEmptyString(item.lineId)) {
6564
+ if (!isNonEmptyString(item.lineId)) {
6565
+ return invalidFromErrorFactory(
6566
+ errorFactory,
6567
+ `${itemPath}.lineId must be a non-empty string`,
6568
+ );
6569
+ }
6570
+
6571
+ if (seenLineIds.has(item.lineId)) {
6572
+ return invalidFromErrorFactory(
6573
+ errorFactory,
6574
+ `${itemPath}.lineId must be unique`,
6575
+ );
6576
+ }
6577
+ seenLineIds.add(item.lineId);
6578
+
6579
+ {
6580
+ const result = validateAllowedKeys({
6581
+ value: item.data,
6582
+ allowedKeys: ["actions"],
6583
+ path: `${itemPath}.data`,
6584
+ errorFactory,
6585
+ });
6586
+ if (result?.valid === false) {
6587
+ return result;
6588
+ }
6589
+ }
6590
+
6591
+ if (item.data.actions !== undefined && !isPlainObject(item.data.actions)) {
6592
+ return invalidFromErrorFactory(
6593
+ errorFactory,
6594
+ `${itemPath}.data.actions must be an object`,
6595
+ );
6596
+ }
6597
+ }
6598
+ };
6599
+
6600
+ const validateLineUpdateActionsData = ({ data, errorFactory }) => {
6601
+ if (!isPlainObject(data)) {
6602
+ return invalidFromErrorFactory(
6603
+ errorFactory,
6604
+ "payload.data must be an object",
6605
+ );
6606
+ }
6607
+ };
6608
+
6609
+ const validateLineUpdateActionsPreserve = ({
6610
+ preserve,
6611
+ data,
6612
+ replace,
6613
+ errorFactory,
6614
+ }) => {
6615
+ if (preserve === undefined) {
6616
+ return VALID_RESULT;
6617
+ }
6618
+
6619
+ if (replace === true) {
6620
+ return invalidFromErrorFactory(
6621
+ errorFactory,
6622
+ "payload.preserve is only supported when payload.replace is not true",
6623
+ );
6624
+ }
6625
+
6626
+ if (!Array.isArray(preserve)) {
6627
+ return invalidFromErrorFactory(
6628
+ errorFactory,
6629
+ "payload.preserve must be an array when provided",
6630
+ );
6631
+ }
6632
+
6633
+ const seen = new Set();
6634
+ for (let index = 0; index < preserve.length; index += 1) {
6635
+ const path = preserve[index];
6636
+ const preservePath = `payload.preserve[${index}]`;
6637
+
6638
+ if (!isNonEmptyString(path)) {
5793
6639
  return invalidFromErrorFactory(
5794
6640
  errorFactory,
5795
- `${itemPath}.lineId must be a non-empty string`,
6641
+ `${preservePath} must be a non-empty string`,
5796
6642
  );
5797
6643
  }
5798
6644
 
5799
- if (seenLineIds.has(item.lineId)) {
6645
+ if (!LINE_UPDATE_ACTIONS_PRESERVE_PATHS_SET.has(path)) {
5800
6646
  return invalidFromErrorFactory(
5801
6647
  errorFactory,
5802
- `${itemPath}.lineId must be unique`,
6648
+ `${preservePath} must be one of: ${LINE_UPDATE_ACTIONS_PRESERVE_PATHS.join(", ")}`,
5803
6649
  );
5804
6650
  }
5805
- seenLineIds.add(item.lineId);
5806
6651
 
5807
- {
5808
- const result = validateAllowedKeys({
5809
- value: item.data,
5810
- allowedKeys: ["actions"],
5811
- path: `${itemPath}.data`,
6652
+ if (seen.has(path)) {
6653
+ return invalidFromErrorFactory(
5812
6654
  errorFactory,
5813
- });
5814
- if (result?.valid === false) {
5815
- return result;
5816
- }
6655
+ `${preservePath} must not duplicate another preserve path`,
6656
+ );
5817
6657
  }
6658
+ seen.add(path);
5818
6659
 
5819
- if (item.data.actions !== undefined && !isPlainObject(item.data.actions)) {
6660
+ if (path === "dialogue.content" && !isPlainObject(data?.dialogue)) {
5820
6661
  return invalidFromErrorFactory(
5821
6662
  errorFactory,
5822
- `${itemPath}.data.actions must be an object`,
6663
+ "payload.data.dialogue must be an object when preserving dialogue.content",
5823
6664
  );
5824
6665
  }
5825
6666
  }
6667
+
6668
+ return VALID_RESULT;
5826
6669
  };
5827
6670
 
5828
- const validateLineUpdateActionsData = ({ data, errorFactory }) => {
5829
- if (!isPlainObject(data)) {
5830
- return invalidFromErrorFactory(
5831
- errorFactory,
5832
- "payload.data must be an object",
5833
- );
6671
+ const applyLineUpdateActionsPreserve = ({ currentActions, data, preserve }) => {
6672
+ const nextData = structuredClone(data || {});
6673
+ if (!Array.isArray(preserve) || preserve.length === 0) {
6674
+ return nextData;
6675
+ }
6676
+
6677
+ if (
6678
+ preserve.includes("dialogue.content") &&
6679
+ isPlainObject(nextData.dialogue) &&
6680
+ !Object.hasOwn(nextData.dialogue, "content")
6681
+ ) {
6682
+ const currentContent = currentActions?.dialogue?.content;
6683
+ if (currentContent !== undefined) {
6684
+ nextData.dialogue = {
6685
+ ...structuredClone(nextData.dialogue),
6686
+ content: structuredClone(currentContent),
6687
+ };
6688
+ }
5834
6689
  }
6690
+
6691
+ return nextData;
5835
6692
  };
5836
6693
 
5837
6694
  const validateImageCreateData = ({ data, errorFactory }) => {
@@ -5859,6 +6716,7 @@ const validateImageCreateData = ({ data, errorFactory }) => {
5859
6716
  "type",
5860
6717
  "name",
5861
6718
  "description",
6719
+ "tagIds",
5862
6720
  "thumbnailFileId",
5863
6721
  "fileId",
5864
6722
  "width",
@@ -5887,6 +6745,17 @@ const validateImageCreateData = ({ data, errorFactory }) => {
5887
6745
  }
5888
6746
 
5889
6747
  if (data.type === "image") {
6748
+ {
6749
+ const result = validateOptionalUniqueIdArray({
6750
+ value: data.tagIds,
6751
+ path: "payload.data.tagIds",
6752
+ errorFactory,
6753
+ });
6754
+ if (result?.valid === false) {
6755
+ return result;
6756
+ }
6757
+ }
6758
+
5890
6759
  if (
5891
6760
  data.thumbnailFileId !== undefined &&
5892
6761
  !isNonEmptyString(data.thumbnailFileId)
@@ -5927,6 +6796,7 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
5927
6796
  allowedKeys: [
5928
6797
  "name",
5929
6798
  "description",
6799
+ "tagIds",
5930
6800
  "thumbnailFileId",
5931
6801
  "fileId",
5932
6802
  "width",
@@ -5961,6 +6831,17 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
5961
6831
  );
5962
6832
  }
5963
6833
 
6834
+ {
6835
+ const result = validateOptionalUniqueIdArray({
6836
+ value: data.tagIds,
6837
+ path: "payload.data.tagIds",
6838
+ errorFactory,
6839
+ });
6840
+ if (result?.valid === false) {
6841
+ return result;
6842
+ }
6843
+ }
6844
+
5964
6845
  if (
5965
6846
  data.thumbnailFileId !== undefined &&
5966
6847
  !isNonEmptyString(data.thumbnailFileId)
@@ -6225,6 +7106,7 @@ const validateSoundCreateData = ({ data, errorFactory }) => {
6225
7106
  "type",
6226
7107
  "name",
6227
7108
  "description",
7109
+ "tagIds",
6228
7110
  "fileId",
6229
7111
  "waveformDataFileId",
6230
7112
  "duration",
@@ -6252,6 +7134,17 @@ const validateSoundCreateData = ({ data, errorFactory }) => {
6252
7134
  }
6253
7135
 
6254
7136
  if (data.type === "sound") {
7137
+ {
7138
+ const result = validateOptionalUniqueIdArray({
7139
+ value: data.tagIds,
7140
+ path: "payload.data.tagIds",
7141
+ errorFactory,
7142
+ });
7143
+ if (result?.valid === false) {
7144
+ return result;
7145
+ }
7146
+ }
7147
+
6255
7148
  if (!isNonEmptyString(data.fileId)) {
6256
7149
  return invalidFromErrorFactory(
6257
7150
  errorFactory,
@@ -6286,6 +7179,7 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
6286
7179
  allowedKeys: [
6287
7180
  "name",
6288
7181
  "description",
7182
+ "tagIds",
6289
7183
  "fileId",
6290
7184
  "waveformDataFileId",
6291
7185
  "duration",
@@ -6319,6 +7213,17 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
6319
7213
  );
6320
7214
  }
6321
7215
 
7216
+ {
7217
+ const result = validateOptionalUniqueIdArray({
7218
+ value: data.tagIds,
7219
+ path: "payload.data.tagIds",
7220
+ errorFactory,
7221
+ });
7222
+ if (result?.valid === false) {
7223
+ return result;
7224
+ }
7225
+ }
7226
+
6322
7227
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
6323
7228
  return invalidFromErrorFactory(
6324
7229
  errorFactory,
@@ -6370,6 +7275,7 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
6370
7275
  "type",
6371
7276
  "name",
6372
7277
  "description",
7278
+ "tagIds",
6373
7279
  "fileId",
6374
7280
  "thumbnailFileId",
6375
7281
  "duration",
@@ -6399,6 +7305,17 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
6399
7305
  }
6400
7306
 
6401
7307
  if (data.type === "video") {
7308
+ {
7309
+ const result = validateOptionalUniqueIdArray({
7310
+ value: data.tagIds,
7311
+ path: "payload.data.tagIds",
7312
+ errorFactory,
7313
+ });
7314
+ if (result?.valid === false) {
7315
+ return result;
7316
+ }
7317
+ }
7318
+
6402
7319
  if (!isNonEmptyString(data.fileId)) {
6403
7320
  return invalidFromErrorFactory(
6404
7321
  errorFactory,
@@ -6443,6 +7360,7 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
6443
7360
  allowedKeys: [
6444
7361
  "name",
6445
7362
  "description",
7363
+ "tagIds",
6446
7364
  "fileId",
6447
7365
  "thumbnailFileId",
6448
7366
  "duration",
@@ -6478,6 +7396,17 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
6478
7396
  );
6479
7397
  }
6480
7398
 
7399
+ {
7400
+ const result = validateOptionalUniqueIdArray({
7401
+ value: data.tagIds,
7402
+ path: "payload.data.tagIds",
7403
+ errorFactory,
7404
+ });
7405
+ if (result?.valid === false) {
7406
+ return result;
7407
+ }
7408
+ }
7409
+
6481
7410
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
6482
7411
  return invalidFromErrorFactory(
6483
7412
  errorFactory,
@@ -6965,6 +7894,7 @@ const validateTransformCreateData = ({ data, errorFactory }) => {
6965
7894
  "type",
6966
7895
  "name",
6967
7896
  "description",
7897
+ "tagIds",
6968
7898
  "x",
6969
7899
  "y",
6970
7900
  "scaleX",
@@ -6996,6 +7926,17 @@ const validateTransformCreateData = ({ data, errorFactory }) => {
6996
7926
  }
6997
7927
 
6998
7928
  if (data.type === "transform") {
7929
+ {
7930
+ const result = validateOptionalUniqueIdArray({
7931
+ value: data.tagIds,
7932
+ path: "payload.data.tagIds",
7933
+ errorFactory,
7934
+ });
7935
+ if (result?.valid === false) {
7936
+ return result;
7937
+ }
7938
+ }
7939
+
6999
7940
  for (const key of [
7000
7941
  "x",
7001
7942
  "y",
@@ -7201,6 +8142,7 @@ const validateTransformUpdateData = ({ data, errorFactory }) => {
7201
8142
  allowedKeys: [
7202
8143
  "name",
7203
8144
  "description",
8145
+ "tagIds",
7204
8146
  "x",
7205
8147
  "y",
7206
8148
  "scaleX",
@@ -7238,6 +8180,17 @@ const validateTransformUpdateData = ({ data, errorFactory }) => {
7238
8180
  );
7239
8181
  }
7240
8182
 
8183
+ {
8184
+ const result = validateOptionalUniqueIdArray({
8185
+ value: data.tagIds,
8186
+ path: "payload.data.tagIds",
8187
+ errorFactory,
8188
+ });
8189
+ if (result?.valid === false) {
8190
+ return result;
8191
+ }
8192
+ }
8193
+
7241
8194
  for (const key of [
7242
8195
  "x",
7243
8196
  "y",
@@ -7591,6 +8544,7 @@ const validateCharacterSpriteCreateData = ({ data, errorFactory }) => {
7591
8544
  "type",
7592
8545
  "name",
7593
8546
  "description",
8547
+ "tagIds",
7594
8548
  "fileId",
7595
8549
  "thumbnailFileId",
7596
8550
  "width",
@@ -7619,6 +8573,17 @@ const validateCharacterSpriteCreateData = ({ data, errorFactory }) => {
7619
8573
  }
7620
8574
 
7621
8575
  if (data.type === "image") {
8576
+ {
8577
+ const result = validateOptionalUniqueIdArray({
8578
+ value: data.tagIds,
8579
+ path: "payload.data.tagIds",
8580
+ errorFactory,
8581
+ });
8582
+ if (result?.valid === false) {
8583
+ return result;
8584
+ }
8585
+ }
8586
+
7622
8587
  if (!isNonEmptyString(data.fileId)) {
7623
8588
  return invalidFromErrorFactory(
7624
8589
  errorFactory,
@@ -7659,6 +8624,7 @@ const validateCharacterSpriteUpdateData = ({ data, errorFactory }) => {
7659
8624
  allowedKeys: [
7660
8625
  "name",
7661
8626
  "description",
8627
+ "tagIds",
7662
8628
  "fileId",
7663
8629
  "thumbnailFileId",
7664
8630
  "width",
@@ -7686,6 +8652,17 @@ const validateCharacterSpriteUpdateData = ({ data, errorFactory }) => {
7686
8652
  );
7687
8653
  }
7688
8654
 
8655
+ {
8656
+ const result = validateOptionalUniqueIdArray({
8657
+ value: data.tagIds,
8658
+ path: "payload.data.tagIds",
8659
+ errorFactory,
8660
+ });
8661
+ if (result?.valid === false) {
8662
+ return result;
8663
+ }
8664
+ }
8665
+
7689
8666
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
7690
8667
  return invalidFromErrorFactory(
7691
8668
  errorFactory,
@@ -7750,6 +8727,7 @@ const validateCharacterCreateData = ({ data, errorFactory }) => {
7750
8727
  "type",
7751
8728
  "name",
7752
8729
  "description",
8730
+ "tagIds",
7753
8731
  "shortcut",
7754
8732
  "fileId",
7755
8733
  "sprites",
@@ -7777,6 +8755,17 @@ const validateCharacterCreateData = ({ data, errorFactory }) => {
7777
8755
  }
7778
8756
 
7779
8757
  if (data.type === "character") {
8758
+ {
8759
+ const result = validateOptionalUniqueIdArray({
8760
+ value: data.tagIds,
8761
+ path: "payload.data.tagIds",
8762
+ errorFactory,
8763
+ });
8764
+ if (result?.valid === false) {
8765
+ return result;
8766
+ }
8767
+ }
8768
+
7780
8769
  if (data.shortcut !== undefined && !isString(data.shortcut)) {
7781
8770
  return invalidFromErrorFactory(
7782
8771
  errorFactory,
@@ -7796,7 +8785,13 @@ const validateCharacterCreateData = ({ data, errorFactory }) => {
7796
8785
  const result = validateNestedCollection({
7797
8786
  collection: data.sprites,
7798
8787
  path: "payload.data.sprites",
7799
- itemValidator: validateCharacterSpriteItems,
8788
+ itemValidator: ({ items, path, errorFactory }) =>
8789
+ validateCharacterSpriteItems({
8790
+ items,
8791
+ path,
8792
+ errorFactory,
8793
+ allowTagIds: false,
8794
+ }),
7800
8795
  treeValidator: validateGenericFolderOwnership,
7801
8796
  folderLabel: "folder sprite item",
7802
8797
  errorFactory,
@@ -7816,6 +8811,7 @@ const validateCharacterUpdateData = ({ data, errorFactory }) => {
7816
8811
  allowedKeys: [
7817
8812
  "name",
7818
8813
  "description",
8814
+ "tagIds",
7819
8815
  "shortcut",
7820
8816
  "fileId",
7821
8817
  ],
@@ -7855,6 +8851,17 @@ const validateCharacterUpdateData = ({ data, errorFactory }) => {
7855
8851
  );
7856
8852
  }
7857
8853
 
8854
+ {
8855
+ const result = validateOptionalUniqueIdArray({
8856
+ value: data.tagIds,
8857
+ path: "payload.data.tagIds",
8858
+ errorFactory,
8859
+ });
8860
+ if (result?.valid === false) {
8861
+ return result;
8862
+ }
8863
+ }
8864
+
7858
8865
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
7859
8866
  return invalidFromErrorFactory(
7860
8867
  errorFactory,
@@ -8527,7 +9534,7 @@ const validateVisualElementReferenceTargets = ({
8527
9534
  if (!isLayoutConditionTarget(state, rule?.when?.target)) {
8528
9535
  return invalidFromErrorFactory(
8529
9536
  errorFactory,
8530
- `${ownerLabel} element conditionalOverrides.${index}.when.target must reference an existing variable or supported runtime condition`,
9537
+ `${ownerLabel} element conditionalOverrides.${index}.when.target must reference an existing variable or supported layout condition`,
8531
9538
  {
8532
9539
  [ownerIdField]: ownerId,
8533
9540
  elementId,
@@ -8701,6 +9708,7 @@ const createFolderedCollectionCommandDefinitions = ({
8701
9708
  validateCreateState = () => {},
8702
9709
  validateUpdateState = () => {},
8703
9710
  validateDeleteState = () => {},
9711
+ afterDelete = () => {},
8704
9712
  includeUpdate = true,
8705
9713
  }) => {
8706
9714
  const existingMessage = `payload.${idField} must reference an existing ${itemLabel}`;
@@ -8948,6 +9956,7 @@ const createFolderedCollectionCommandDefinitions = ({
8948
9956
  },
8949
9957
  reduce: ({ state, payload }) => {
8950
9958
  const deletedIds = new Set();
9959
+ const deletedItemsById = new Map();
8951
9960
 
8952
9961
  for (const itemId of payload[deleteArrayField]) {
8953
9962
  const removedNode = removeTreeNode({
@@ -8963,6 +9972,10 @@ const createFolderedCollectionCommandDefinitions = ({
8963
9972
  node: removedNode,
8964
9973
  })) {
8965
9974
  deletedIds.add(descendantId);
9975
+ deletedItemsById.set(
9976
+ descendantId,
9977
+ state[collectionKey].items[descendantId],
9978
+ );
8966
9979
  }
8967
9980
  }
8968
9981
 
@@ -8970,6 +9983,13 @@ const createFolderedCollectionCommandDefinitions = ({
8970
9983
  delete state[collectionKey].items[itemId];
8971
9984
  }
8972
9985
 
9986
+ afterDelete({
9987
+ state,
9988
+ payload,
9989
+ deletedIds,
9990
+ deletedItemsById,
9991
+ });
9992
+
8973
9993
  return state;
8974
9994
  },
8975
9995
  },
@@ -9300,7 +10320,8 @@ const COMMAND_DEFINITIONS = [
9300
10320
  }
9301
10321
  },
9302
10322
  validateAgainstState: () => {},
9303
- reduce: ({ payload }) => structuredClone(payload.state),
10323
+ reduce: ({ payload }) =>
10324
+ structuredClone(normalizeStateCollections(payload.state)),
9304
10325
  },
9305
10326
  ...createFolderedCollectionCommandDefinitions({
9306
10327
  familyName: "file",
@@ -10445,7 +11466,7 @@ const COMMAND_DEFINITIONS = [
10445
11466
  {
10446
11467
  const result = validateAllowedKeys({
10447
11468
  value: payload,
10448
- allowedKeys: ["lineId", "data", "replace"],
11469
+ allowedKeys: ["lineId", "data", "replace", "preserve"],
10449
11470
  path: "payload",
10450
11471
  errorFactory: createPayloadValidationError,
10451
11472
  });
@@ -10476,6 +11497,18 @@ const COMMAND_DEFINITIONS = [
10476
11497
  "payload.replace must be a boolean when provided",
10477
11498
  );
10478
11499
  }
11500
+
11501
+ {
11502
+ const result = validateLineUpdateActionsPreserve({
11503
+ preserve: payload.preserve,
11504
+ data: payload.data,
11505
+ replace: payload.replace,
11506
+ errorFactory: createPayloadValidationError,
11507
+ });
11508
+ if (result?.valid === false) {
11509
+ return result;
11510
+ }
11511
+ }
10479
11512
  },
10480
11513
  validateAgainstState: ({ state, payload }) => {
10481
11514
  if (!findLineLocation({ state, lineId: payload.lineId })) {
@@ -10487,12 +11520,17 @@ const COMMAND_DEFINITIONS = [
10487
11520
  reduce: ({ state, payload }) => {
10488
11521
  const location = findLineLocation({ state, lineId: payload.lineId });
10489
11522
  const line = location.line;
11523
+ const nextData = applyLineUpdateActionsPreserve({
11524
+ currentActions: line.actions,
11525
+ data: payload.data,
11526
+ preserve: payload.preserve,
11527
+ });
10490
11528
  line.actions =
10491
11529
  payload.replace === true
10492
- ? structuredClone(payload.data)
11530
+ ? structuredClone(nextData)
10493
11531
  : {
10494
11532
  ...structuredClone(line.actions),
10495
- ...structuredClone(payload.data),
11533
+ ...structuredClone(nextData),
10496
11534
  };
10497
11535
  return state;
10498
11536
  },
@@ -10775,6 +11813,16 @@ const COMMAND_DEFINITIONS = [
10775
11813
  if (!result.valid) {
10776
11814
  return result;
10777
11815
  }
11816
+
11817
+ return validateTagIdsAgainstScope({
11818
+ state,
11819
+ tagIds: payload.data.tagIds,
11820
+ scopeKey: "images",
11821
+ path: "payload.data.tagIds",
11822
+ details: {
11823
+ imageId: payload.imageId,
11824
+ },
11825
+ });
10778
11826
  }
10779
11827
  },
10780
11828
  reduce: ({ state, payload }) => {
@@ -10799,6 +11847,11 @@ const COMMAND_DEFINITIONS = [
10799
11847
  if (payload.data.height !== undefined) {
10800
11848
  nextImage.height = payload.data.height;
10801
11849
  }
11850
+
11851
+ assignOptionalTagIds({
11852
+ target: nextImage,
11853
+ tagIds: payload.data.tagIds,
11854
+ });
10802
11855
  }
10803
11856
 
10804
11857
  state.images.items[payload.imageId] = nextImage;
@@ -10860,7 +11913,8 @@ const COMMAND_DEFINITIONS = [
10860
11913
  (payload.data.fileId !== undefined ||
10861
11914
  payload.data.thumbnailFileId !== undefined ||
10862
11915
  payload.data.width !== undefined ||
10863
- payload.data.height !== undefined)
11916
+ payload.data.height !== undefined ||
11917
+ payload.data.tagIds !== undefined)
10864
11918
  ) {
10865
11919
  return invalidPrecondition(
10866
11920
  "folder image items cannot update file fields",
@@ -10879,14 +11933,24 @@ const COMMAND_DEFINITIONS = [
10879
11933
  if (!result.valid) {
10880
11934
  return result;
10881
11935
  }
11936
+
11937
+ return validateTagIdsAgainstScope({
11938
+ state,
11939
+ tagIds: payload.data.tagIds,
11940
+ scopeKey: "images",
11941
+ path: "payload.data.tagIds",
11942
+ details: {
11943
+ imageId: payload.imageId,
11944
+ },
11945
+ });
10882
11946
  }
10883
11947
  },
10884
11948
  reduce: ({ state, payload }) => {
10885
11949
  const currentImage = state.images.items[payload.imageId];
10886
- state.images.items[payload.imageId] = {
10887
- ...structuredClone(currentImage),
10888
- ...structuredClone(payload.data),
10889
- };
11950
+ state.images.items[payload.imageId] = applyTagIdsUpdate({
11951
+ currentItem: currentImage,
11952
+ data: payload.data,
11953
+ });
10890
11954
  return state;
10891
11955
  },
10892
11956
  },
@@ -11192,6 +12256,16 @@ const COMMAND_DEFINITIONS = [
11192
12256
  if (!result.valid) {
11193
12257
  return result;
11194
12258
  }
12259
+
12260
+ return validateTagIdsAgainstScope({
12261
+ state,
12262
+ tagIds: payload.data.tagIds,
12263
+ scopeKey: "sounds",
12264
+ path: "payload.data.tagIds",
12265
+ details: {
12266
+ soundId: payload.soundId,
12267
+ },
12268
+ });
11195
12269
  }
11196
12270
  },
11197
12271
  reduce: ({ state, payload }) => {
@@ -11213,6 +12287,11 @@ const COMMAND_DEFINITIONS = [
11213
12287
  if (payload.data.duration !== undefined) {
11214
12288
  nextSound.duration = payload.data.duration;
11215
12289
  }
12290
+
12291
+ assignOptionalTagIds({
12292
+ target: nextSound,
12293
+ tagIds: payload.data.tagIds,
12294
+ });
11216
12295
  }
11217
12296
 
11218
12297
  state.sounds.items[payload.soundId] = nextSound;
@@ -11273,7 +12352,8 @@ const COMMAND_DEFINITIONS = [
11273
12352
  currentSound.type === "folder" &&
11274
12353
  (payload.data.fileId !== undefined ||
11275
12354
  payload.data.waveformDataFileId !== undefined ||
11276
- payload.data.duration !== undefined)
12355
+ payload.data.duration !== undefined ||
12356
+ payload.data.tagIds !== undefined)
11277
12357
  ) {
11278
12358
  return invalidPrecondition(
11279
12359
  "folder sound items cannot update file fields",
@@ -11293,14 +12373,24 @@ const COMMAND_DEFINITIONS = [
11293
12373
  if (!result.valid) {
11294
12374
  return result;
11295
12375
  }
12376
+
12377
+ return validateTagIdsAgainstScope({
12378
+ state,
12379
+ tagIds: payload.data.tagIds,
12380
+ scopeKey: "sounds",
12381
+ path: "payload.data.tagIds",
12382
+ details: {
12383
+ soundId: payload.soundId,
12384
+ },
12385
+ });
11296
12386
  }
11297
12387
  },
11298
12388
  reduce: ({ state, payload }) => {
11299
12389
  const currentSound = state.sounds.items[payload.soundId];
11300
- state.sounds.items[payload.soundId] = {
11301
- ...structuredClone(currentSound),
11302
- ...structuredClone(payload.data),
11303
- };
12390
+ state.sounds.items[payload.soundId] = applyTagIdsUpdate({
12391
+ currentItem: currentSound,
12392
+ data: payload.data,
12393
+ });
11304
12394
  return state;
11305
12395
  },
11306
12396
  },
@@ -11605,6 +12695,16 @@ const COMMAND_DEFINITIONS = [
11605
12695
  if (!result.valid) {
11606
12696
  return result;
11607
12697
  }
12698
+
12699
+ return validateTagIdsAgainstScope({
12700
+ state,
12701
+ tagIds: payload.data.tagIds,
12702
+ scopeKey: "videos",
12703
+ path: "payload.data.tagIds",
12704
+ details: {
12705
+ videoId: payload.videoId,
12706
+ },
12707
+ });
11608
12708
  }
11609
12709
  },
11610
12710
  reduce: ({ state, payload }) => {
@@ -11630,6 +12730,11 @@ const COMMAND_DEFINITIONS = [
11630
12730
  if (payload.data.height !== undefined) {
11631
12731
  nextVideo.height = payload.data.height;
11632
12732
  }
12733
+
12734
+ assignOptionalTagIds({
12735
+ target: nextVideo,
12736
+ tagIds: payload.data.tagIds,
12737
+ });
11633
12738
  }
11634
12739
 
11635
12740
  state.videos.items[payload.videoId] = nextVideo;
@@ -11692,7 +12797,8 @@ const COMMAND_DEFINITIONS = [
11692
12797
  payload.data.thumbnailFileId !== undefined ||
11693
12798
  payload.data.duration !== undefined ||
11694
12799
  payload.data.width !== undefined ||
11695
- payload.data.height !== undefined)
12800
+ payload.data.height !== undefined ||
12801
+ payload.data.tagIds !== undefined)
11696
12802
  ) {
11697
12803
  return invalidPrecondition(
11698
12804
  "folder video items cannot update file fields",
@@ -11711,14 +12817,24 @@ const COMMAND_DEFINITIONS = [
11711
12817
  if (!result.valid) {
11712
12818
  return result;
11713
12819
  }
12820
+
12821
+ return validateTagIdsAgainstScope({
12822
+ state,
12823
+ tagIds: payload.data.tagIds,
12824
+ scopeKey: "videos",
12825
+ path: "payload.data.tagIds",
12826
+ details: {
12827
+ videoId: payload.videoId,
12828
+ },
12829
+ });
11714
12830
  }
11715
12831
  },
11716
12832
  reduce: ({ state, payload }) => {
11717
12833
  const currentVideo = state.videos.items[payload.videoId];
11718
- state.videos.items[payload.videoId] = {
11719
- ...structuredClone(currentVideo),
11720
- ...structuredClone(payload.data),
11721
- };
12834
+ state.videos.items[payload.videoId] = applyTagIdsUpdate({
12835
+ currentItem: currentVideo,
12836
+ data: payload.data,
12837
+ });
11722
12838
  return state;
11723
12839
  },
11724
12840
  },
@@ -13142,7 +14258,7 @@ const COMMAND_DEFINITIONS = [
13142
14258
 
13143
14259
  return nextItem;
13144
14260
  },
13145
- validateUpdateState: ({ payload, currentItem }) => {
14261
+ validateUpdateState: ({ state, payload, currentItem }) => {
13146
14262
  if (
13147
14263
  currentItem.type === "folder" &&
13148
14264
  Object.keys(payload.data).some(
@@ -13162,28 +14278,57 @@ const COMMAND_DEFINITIONS = [
13162
14278
  itemLabel: "transform item",
13163
14279
  createDataValidator: validateTransformCreateData,
13164
14280
  updateDataValidator: validateTransformUpdateData,
13165
- createItem: ({ payload }) => ({
13166
- id: payload.transformId,
13167
- type: payload.data.type,
13168
- name: payload.data.name,
13169
- ...(payload.data.description !== undefined
13170
- ? {
13171
- description: payload.data.description,
13172
- }
13173
- : {}),
13174
- ...(payload.data.type === "transform"
13175
- ? {
13176
- x: payload.data.x,
13177
- y: payload.data.y,
13178
- scaleX: payload.data.scaleX,
13179
- scaleY: payload.data.scaleY,
13180
- anchorX: payload.data.anchorX,
13181
- anchorY: payload.data.anchorY,
13182
- rotation: payload.data.rotation,
13183
- }
13184
- : {}),
13185
- }),
13186
- validateUpdateState: ({ payload, currentItem }) => {
14281
+ createItem: ({ payload }) => {
14282
+ const item = {
14283
+ id: payload.transformId,
14284
+ type: payload.data.type,
14285
+ name: payload.data.name,
14286
+ ...(payload.data.description !== undefined
14287
+ ? {
14288
+ description: payload.data.description,
14289
+ }
14290
+ : {}),
14291
+ };
14292
+
14293
+ if (payload.data.type !== "transform") {
14294
+ return item;
14295
+ }
14296
+
14297
+ item.x = payload.data.x;
14298
+ item.y = payload.data.y;
14299
+ item.scaleX = payload.data.scaleX;
14300
+ item.scaleY = payload.data.scaleY;
14301
+ item.anchorX = payload.data.anchorX;
14302
+ item.anchorY = payload.data.anchorY;
14303
+ item.rotation = payload.data.rotation;
14304
+ assignOptionalTagIds({
14305
+ target: item,
14306
+ tagIds: payload.data.tagIds,
14307
+ });
14308
+
14309
+ return item;
14310
+ },
14311
+ updateItem: ({ currentItem, payload }) =>
14312
+ applyTagIdsUpdate({
14313
+ currentItem,
14314
+ data: payload.data,
14315
+ }),
14316
+ validateCreateState: ({ state, payload }) => {
14317
+ if (payload.data.type !== "transform") {
14318
+ return;
14319
+ }
14320
+
14321
+ return validateTagIdsAgainstScope({
14322
+ state,
14323
+ tagIds: payload.data.tagIds,
14324
+ scopeKey: "transforms",
14325
+ path: "payload.data.tagIds",
14326
+ details: {
14327
+ transformId: payload.transformId,
14328
+ },
14329
+ });
14330
+ },
14331
+ validateUpdateState: ({ state, payload, currentItem }) => {
13187
14332
  if (
13188
14333
  currentItem.type === "folder" &&
13189
14334
  Object.keys(payload.data).some(
@@ -13194,6 +14339,18 @@ const COMMAND_DEFINITIONS = [
13194
14339
  "folder transform items cannot update transform fields",
13195
14340
  );
13196
14341
  }
14342
+
14343
+ if (currentItem.type === "transform") {
14344
+ return validateTagIdsAgainstScope({
14345
+ state,
14346
+ tagIds: payload.data.tagIds,
14347
+ scopeKey: "transforms",
14348
+ path: "payload.data.tagIds",
14349
+ details: {
14350
+ transformId: payload.transformId,
14351
+ },
14352
+ });
14353
+ }
13197
14354
  },
13198
14355
  }),
13199
14356
  ...createFolderedCollectionCommandDefinitions({
@@ -13220,7 +14377,7 @@ const COMMAND_DEFINITIONS = [
13220
14377
  value: payload.data.value,
13221
14378
  }),
13222
14379
  }),
13223
- validateUpdateState: ({ payload, currentItem }) => {
14380
+ validateUpdateState: ({ state, payload, currentItem }) => {
13224
14381
  if (
13225
14382
  currentItem.type === "folder" &&
13226
14383
  Object.keys(payload.data).some(
@@ -13351,6 +14508,11 @@ const COMMAND_DEFINITIONS = [
13351
14508
  item.fileId = payload.data.fileId;
13352
14509
  }
13353
14510
 
14511
+ assignOptionalTagIds({
14512
+ target: item,
14513
+ tagIds: payload.data.tagIds,
14514
+ });
14515
+
13354
14516
  item.sprites =
13355
14517
  payload.data.sprites === undefined
13356
14518
  ? { items: {}, tree: [] }
@@ -13358,6 +14520,11 @@ const COMMAND_DEFINITIONS = [
13358
14520
 
13359
14521
  return item;
13360
14522
  },
14523
+ updateItem: ({ currentItem, payload }) =>
14524
+ applyTagIdsUpdate({
14525
+ currentItem,
14526
+ data: payload.data,
14527
+ }),
13361
14528
  validateCreateState: ({ state, payload }) => {
13362
14529
  if (payload.data.type !== "character") {
13363
14530
  return;
@@ -13377,6 +14544,21 @@ const COMMAND_DEFINITIONS = [
13377
14544
  }
13378
14545
  }
13379
14546
 
14547
+ {
14548
+ const result = validateTagIdsAgainstScope({
14549
+ state,
14550
+ tagIds: payload.data.tagIds,
14551
+ scopeKey: "characters",
14552
+ path: "payload.data.tagIds",
14553
+ details: {
14554
+ characterId: payload.characterId,
14555
+ },
14556
+ });
14557
+ if (!result.valid) {
14558
+ return result;
14559
+ }
14560
+ }
14561
+
13380
14562
  for (const [spriteId, sprite] of Object.entries(
13381
14563
  payload.data.sprites?.items || {},
13382
14564
  )) {
@@ -13384,6 +14566,16 @@ const COMMAND_DEFINITIONS = [
13384
14566
  continue;
13385
14567
  }
13386
14568
 
14569
+ if (sprite.tagIds !== undefined) {
14570
+ return invalidPrecondition(
14571
+ "payload.data.sprites.items.*.tagIds are not supported during character.create",
14572
+ {
14573
+ characterId: payload.characterId,
14574
+ spriteId,
14575
+ },
14576
+ );
14577
+ }
14578
+
13387
14579
  const result = validateFileReference({
13388
14580
  state,
13389
14581
  fileId: sprite.fileId,
@@ -13441,6 +14633,27 @@ const COMMAND_DEFINITIONS = [
13441
14633
  if (!result.valid) {
13442
14634
  return result;
13443
14635
  }
14636
+
14637
+ return validateTagIdsAgainstScope({
14638
+ state,
14639
+ tagIds: payload.data.tagIds,
14640
+ scopeKey: "characters",
14641
+ path: "payload.data.tagIds",
14642
+ details: {
14643
+ characterId: payload.characterId,
14644
+ },
14645
+ });
14646
+ }
14647
+ },
14648
+ afterDelete: ({ state, deletedItemsById }) => {
14649
+ for (const [characterId, item] of deletedItemsById.entries()) {
14650
+ if (item?.type !== "character") {
14651
+ continue;
14652
+ }
14653
+
14654
+ delete state.tags[
14655
+ `${CHARACTER_SPRITE_TAG_SCOPE_PREFIX}${characterId}`
14656
+ ];
13444
14657
  }
13445
14658
  },
13446
14659
  }),
@@ -13711,6 +14924,17 @@ const COMMAND_DEFINITIONS = [
13711
14924
  if (!result.valid) {
13712
14925
  return result;
13713
14926
  }
14927
+
14928
+ return validateTagIdsAgainstScope({
14929
+ state,
14930
+ tagIds: payload.data.tagIds,
14931
+ scopeKey: `${CHARACTER_SPRITE_TAG_SCOPE_PREFIX}${payload.characterId}`,
14932
+ path: "payload.data.tagIds",
14933
+ details: {
14934
+ characterId: payload.characterId,
14935
+ spriteId: payload.spriteId,
14936
+ },
14937
+ });
13714
14938
  }
13715
14939
  },
13716
14940
  reduce: ({ state, payload }) => {
@@ -13719,10 +14943,15 @@ const COMMAND_DEFINITIONS = [
13719
14943
  characterId: payload.characterId,
13720
14944
  });
13721
14945
 
13722
- collection.items[payload.spriteId] = {
14946
+ const nextSprite = {
13723
14947
  id: payload.spriteId,
13724
14948
  ...structuredClone(payload.data),
13725
14949
  };
14950
+ if (payload.data.tagIds !== undefined && payload.data.tagIds.length === 0) {
14951
+ delete nextSprite.tagIds;
14952
+ }
14953
+
14954
+ collection.items[payload.spriteId] = nextSprite;
13726
14955
 
13727
14956
  insertTreeNode({
13728
14957
  tree: collection.tree,
@@ -13816,6 +15045,17 @@ const COMMAND_DEFINITIONS = [
13816
15045
  if (!result.valid) {
13817
15046
  return result;
13818
15047
  }
15048
+
15049
+ return validateTagIdsAgainstScope({
15050
+ state,
15051
+ tagIds: payload.data.tagIds,
15052
+ scopeKey: `${CHARACTER_SPRITE_TAG_SCOPE_PREFIX}${payload.characterId}`,
15053
+ path: "payload.data.tagIds",
15054
+ details: {
15055
+ characterId: payload.characterId,
15056
+ spriteId: payload.spriteId,
15057
+ },
15058
+ });
13819
15059
  }
13820
15060
  },
13821
15061
  reduce: ({ state, payload }) => {
@@ -13825,10 +15065,10 @@ const COMMAND_DEFINITIONS = [
13825
15065
  });
13826
15066
  const currentItem = collection.items[payload.spriteId];
13827
15067
 
13828
- collection.items[payload.spriteId] = {
13829
- ...structuredClone(currentItem),
13830
- ...structuredClone(payload.data),
13831
- };
15068
+ collection.items[payload.spriteId] = applyTagIdsUpdate({
15069
+ currentItem,
15070
+ data: payload.data,
15071
+ });
13832
15072
 
13833
15073
  return state;
13834
15074
  },
@@ -14060,6 +15300,297 @@ const COMMAND_DEFINITIONS = [
14060
15300
  return state;
14061
15301
  },
14062
15302
  },
15303
+ {
15304
+ type: "tag.create",
15305
+ validatePayload: ({ payload }) => {
15306
+ {
15307
+ const result = validateExactKeys({
15308
+ value: payload,
15309
+ expectedKeys: ["scopeKey", "tagId", "data"],
15310
+ path: "payload",
15311
+ errorFactory: createPayloadValidationError,
15312
+ });
15313
+ if (result?.valid === false) {
15314
+ return result;
15315
+ }
15316
+ }
15317
+
15318
+ {
15319
+ const result = validateTagScopeKey({
15320
+ scopeKey: payload.scopeKey,
15321
+ path: "payload.scopeKey",
15322
+ errorFactory: createPayloadValidationError,
15323
+ });
15324
+ if (result?.valid === false) {
15325
+ return result;
15326
+ }
15327
+ }
15328
+
15329
+ if (!isNonEmptyString(payload.tagId)) {
15330
+ return invalidPayload("payload.tagId must be a non-empty string");
15331
+ }
15332
+
15333
+ {
15334
+ const result = validateTagCreateData({
15335
+ data: payload.data,
15336
+ errorFactory: createPayloadValidationError,
15337
+ });
15338
+ if (result?.valid === false) {
15339
+ return result;
15340
+ }
15341
+ }
15342
+ },
15343
+ validateAgainstState: ({ state, payload }) => {
15344
+ {
15345
+ const result = validateTagScopeAgainstState({
15346
+ state,
15347
+ scopeKey: payload.scopeKey,
15348
+ path: "payload.scopeKey",
15349
+ });
15350
+ if (!result.valid) {
15351
+ return result;
15352
+ }
15353
+ }
15354
+
15355
+ const collection = getTagScopeCollection({
15356
+ state,
15357
+ scopeKey: payload.scopeKey,
15358
+ });
15359
+ if (isPlainObject(collection?.items?.[payload.tagId])) {
15360
+ return invalidPrecondition("payload.tagId must not already exist");
15361
+ }
15362
+
15363
+ return validateUniqueTagNameInScope({
15364
+ collection,
15365
+ name: payload.data.name,
15366
+ path: "payload.data.name",
15367
+ });
15368
+ },
15369
+ reduce: ({ state, payload }) => {
15370
+ const collection = ensureTagScopeCollection({
15371
+ state,
15372
+ scopeKey: payload.scopeKey,
15373
+ });
15374
+ const nextTag = {
15375
+ id: payload.tagId,
15376
+ type: "tag",
15377
+ name: payload.data.name,
15378
+ };
15379
+
15380
+ if (payload.data.color !== undefined) {
15381
+ nextTag.color = payload.data.color;
15382
+ }
15383
+
15384
+ collection.items[payload.tagId] = nextTag;
15385
+ collection.tree.push({
15386
+ id: payload.tagId,
15387
+ });
15388
+
15389
+ return state;
15390
+ },
15391
+ },
15392
+ {
15393
+ type: "tag.update",
15394
+ validatePayload: ({ payload }) => {
15395
+ {
15396
+ const result = validateExactKeys({
15397
+ value: payload,
15398
+ expectedKeys: ["scopeKey", "tagId", "data"],
15399
+ path: "payload",
15400
+ errorFactory: createPayloadValidationError,
15401
+ });
15402
+ if (result?.valid === false) {
15403
+ return result;
15404
+ }
15405
+ }
15406
+
15407
+ {
15408
+ const result = validateTagScopeKey({
15409
+ scopeKey: payload.scopeKey,
15410
+ path: "payload.scopeKey",
15411
+ errorFactory: createPayloadValidationError,
15412
+ });
15413
+ if (result?.valid === false) {
15414
+ return result;
15415
+ }
15416
+ }
15417
+
15418
+ if (!isNonEmptyString(payload.tagId)) {
15419
+ return invalidPayload("payload.tagId must be a non-empty string");
15420
+ }
15421
+
15422
+ {
15423
+ const result = validateTagUpdateData({
15424
+ data: payload.data,
15425
+ errorFactory: createPayloadValidationError,
15426
+ });
15427
+ if (result?.valid === false) {
15428
+ return result;
15429
+ }
15430
+ }
15431
+ },
15432
+ validateAgainstState: ({ state, payload }) => {
15433
+ {
15434
+ const result = validateTagScopeAgainstState({
15435
+ state,
15436
+ scopeKey: payload.scopeKey,
15437
+ path: "payload.scopeKey",
15438
+ });
15439
+ if (!result.valid) {
15440
+ return result;
15441
+ }
15442
+ }
15443
+
15444
+ const collection = getTagScopeCollection({
15445
+ state,
15446
+ scopeKey: payload.scopeKey,
15447
+ });
15448
+ const currentTag = collection?.items?.[payload.tagId];
15449
+ if (!isPlainObject(currentTag) || currentTag.type !== "tag") {
15450
+ return invalidPrecondition(
15451
+ "payload.tagId must reference an existing tag in payload.scopeKey",
15452
+ );
15453
+ }
15454
+
15455
+ if (payload.data.name === undefined) {
15456
+ return VALID_RESULT;
15457
+ }
15458
+
15459
+ return validateUniqueTagNameInScope({
15460
+ collection,
15461
+ name: payload.data.name,
15462
+ path: "payload.data.name",
15463
+ excludeTagId: payload.tagId,
15464
+ });
15465
+ },
15466
+ reduce: ({ state, payload }) => {
15467
+ const collection = ensureTagScopeCollection({
15468
+ state,
15469
+ scopeKey: payload.scopeKey,
15470
+ });
15471
+ const currentTag = collection.items[payload.tagId];
15472
+ const nextTag = {
15473
+ ...structuredClone(currentTag),
15474
+ };
15475
+
15476
+ if (payload.data.name !== undefined) {
15477
+ nextTag.name = payload.data.name;
15478
+ }
15479
+
15480
+ if (payload.data.color === null) {
15481
+ delete nextTag.color;
15482
+ } else if (payload.data.color !== undefined) {
15483
+ nextTag.color = payload.data.color;
15484
+ }
15485
+
15486
+ collection.items[payload.tagId] = nextTag;
15487
+ return state;
15488
+ },
15489
+ },
15490
+ {
15491
+ type: "tag.delete",
15492
+ validatePayload: ({ payload }) => {
15493
+ {
15494
+ const result = validateExactKeys({
15495
+ value: payload,
15496
+ expectedKeys: ["scopeKey", "tagIds"],
15497
+ path: "payload",
15498
+ errorFactory: createPayloadValidationError,
15499
+ });
15500
+ if (result?.valid === false) {
15501
+ return result;
15502
+ }
15503
+ }
15504
+
15505
+ {
15506
+ const result = validateTagScopeKey({
15507
+ scopeKey: payload.scopeKey,
15508
+ path: "payload.scopeKey",
15509
+ errorFactory: createPayloadValidationError,
15510
+ });
15511
+ if (result?.valid === false) {
15512
+ return result;
15513
+ }
15514
+ }
15515
+
15516
+ {
15517
+ const result = validateRequiredUniqueIdArray({
15518
+ value: payload.tagIds,
15519
+ path: "payload.tagIds",
15520
+ errorFactory: createPayloadValidationError,
15521
+ });
15522
+ if (result?.valid === false) {
15523
+ return result;
15524
+ }
15525
+ }
15526
+ },
15527
+ validateAgainstState: ({ state, payload }) => {
15528
+ {
15529
+ const result = validateTagScopeAgainstState({
15530
+ state,
15531
+ scopeKey: payload.scopeKey,
15532
+ path: "payload.scopeKey",
15533
+ });
15534
+ if (!result.valid) {
15535
+ return result;
15536
+ }
15537
+ }
15538
+
15539
+ const collection = getTagScopeCollection({
15540
+ state,
15541
+ scopeKey: payload.scopeKey,
15542
+ });
15543
+ for (const tagId of payload.tagIds) {
15544
+ const tag = collection?.items?.[tagId];
15545
+ if (!isPlainObject(tag) || tag.type !== "tag") {
15546
+ return invalidPrecondition(
15547
+ "payload.tagIds must reference existing tags in payload.scopeKey",
15548
+ {
15549
+ scopeKey: payload.scopeKey,
15550
+ tagId,
15551
+ },
15552
+ );
15553
+ }
15554
+ }
15555
+
15556
+ return VALID_RESULT;
15557
+ },
15558
+ reduce: ({ state, payload }) => {
15559
+ const collection = ensureTagScopeCollection({
15560
+ state,
15561
+ scopeKey: payload.scopeKey,
15562
+ });
15563
+ const deletedTagIds = new Set();
15564
+
15565
+ for (const tagId of payload.tagIds) {
15566
+ const removedNode = removeTreeNode({
15567
+ nodes: collection.tree,
15568
+ nodeId: tagId,
15569
+ });
15570
+ if (!removedNode) {
15571
+ continue;
15572
+ }
15573
+
15574
+ for (const deletedTagId of collectTreeDescendantIds({
15575
+ node: removedNode,
15576
+ })) {
15577
+ deletedTagIds.add(deletedTagId);
15578
+ }
15579
+ }
15580
+
15581
+ for (const tagId of deletedTagIds) {
15582
+ delete collection.items[tagId];
15583
+ }
15584
+
15585
+ stripDeletedTagIdsFromScopeItems({
15586
+ state,
15587
+ scopeKey: payload.scopeKey,
15588
+ deletedTagIds,
15589
+ });
15590
+
15591
+ return state;
15592
+ },
15593
+ },
14063
15594
  {
14064
15595
  type: "layout.element.create",
14065
15596
  validatePayload: ({ payload }) => {
@@ -15232,11 +16763,7 @@ export const validateAgainstState = ({ state, command }) => {
15232
16763
  export const processCommand = ({ state, command }) => {
15233
16764
  return captureValidation(() => {
15234
16765
  const normalizedState = normalizeStateCollections(state);
15235
- const shouldMaterializeNormalizedState =
15236
- typeof command?.type === "string" &&
15237
- (command.type.startsWith("control.") ||
15238
- command.type.startsWith("spritesheet.") ||
15239
- command.type.startsWith("particle."));
16766
+ const shouldMaterializeNormalizedState = normalizedState !== state;
15240
16767
 
15241
16768
  const stateResult = validateState({ state: normalizedState });
15242
16769
  if (!stateResult.valid) {