@routevn/creator-model 1.2.10 → 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 +1597 -176
package/src/model.js CHANGED
@@ -35,7 +35,7 @@ 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
39
  const LINE_UPDATE_ACTIONS_PRESERVE_PATHS = ["dialogue.content"];
40
40
  const LINE_UPDATE_ACTIONS_PRESERVE_PATHS_SET = new Set(
41
41
  LINE_UPDATE_ACTIONS_PRESERVE_PATHS,
@@ -44,6 +44,55 @@ const createEmptyCollectionState = () => ({
44
44
  items: {},
45
45
  tree: [],
46
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
+ };
47
96
 
48
97
  const normalizeStateCollections = (state) => {
49
98
  if (!isPlainObject(state)) {
@@ -55,8 +104,10 @@ const normalizeStateCollections = (state) => {
55
104
  "particles",
56
105
  "controls",
57
106
  ].filter((key) => state[key] === undefined);
107
+ const normalizedTags = normalizeTagsState(state.tags);
108
+ const hasNormalizedTags = normalizedTags !== state.tags;
58
109
 
59
- if (missingCollectionKeys.length === 0) {
110
+ if (missingCollectionKeys.length === 0 && !hasNormalizedTags) {
60
111
  return state;
61
112
  }
62
113
 
@@ -68,6 +119,10 @@ const normalizeStateCollections = (state) => {
68
119
  nextState[key] = createEmptyCollectionState();
69
120
  });
70
121
 
122
+ if (hasNormalizedTags) {
123
+ nextState.tags = normalizedTags;
124
+ }
125
+
71
126
  return nextState;
72
127
  };
73
128
  const isString = (value) => typeof value === "string";
@@ -164,7 +219,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
164
219
  "container-ref-confirm-dialog-ok",
165
220
  "container-ref-confirm-dialog-cancel",
166
221
  ];
167
- export const SCHEMA_VERSION = 2;
222
+ export const SCHEMA_VERSION = 3;
168
223
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
169
224
  "folder",
170
225
  "container",
@@ -268,6 +323,7 @@ const isVariableReferenceTarget = (state, variableId) => {
268
323
  };
269
324
 
270
325
  const LAYOUT_ITEM_TARGET_SET = new Set(["item.savedAt"]);
326
+ const LAYOUT_DIALOGUE_TARGET_SET = new Set(["dialogue.characterId"]);
271
327
  const RUNTIME_TARGET_DOT_PATTERN = /^runtime\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
272
328
  const VARIABLE_TARGET_DOT_PATTERN = /^variables\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
273
329
  const VARIABLE_TARGET_BRACKET_PATTERN = /^variables\[(.+)\]$/;
@@ -284,6 +340,13 @@ const parseLayoutConditionTarget = (target) => {
284
340
  };
285
341
  }
286
342
 
343
+ if (LAYOUT_DIALOGUE_TARGET_SET.has(target)) {
344
+ return {
345
+ kind: "dialogue",
346
+ target,
347
+ };
348
+ }
349
+
287
350
  const runtimeMatch = target.match(RUNTIME_TARGET_DOT_PATTERN);
288
351
  if (runtimeMatch && isRuntimeFieldId(runtimeMatch[1])) {
289
352
  return {
@@ -340,7 +403,7 @@ const isLayoutConditionTarget = (state, target) => {
340
403
  return false;
341
404
  }
342
405
 
343
- if (parsedTarget.kind === "runtime") {
406
+ if (parsedTarget.kind !== "variable") {
344
407
  return true;
345
408
  }
346
409
 
@@ -781,6 +844,7 @@ const validateImageItems = ({ items, path, errorFactory }) => {
781
844
  "type",
782
845
  "name",
783
846
  "description",
847
+ "tagIds",
784
848
  "thumbnailFileId",
785
849
  "fileId",
786
850
  "width",
@@ -823,6 +887,18 @@ const validateImageItems = ({ items, path, errorFactory }) => {
823
887
  }
824
888
 
825
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
+
826
902
  if (
827
903
  item.thumbnailFileId !== undefined &&
828
904
  !isNonEmptyString(item.thumbnailFileId)
@@ -1070,6 +1146,7 @@ const validateSoundItems = ({ items, path, errorFactory }) => {
1070
1146
  "type",
1071
1147
  "name",
1072
1148
  "description",
1149
+ "tagIds",
1073
1150
  "fileId",
1074
1151
  "waveformDataFileId",
1075
1152
  "duration",
@@ -1111,6 +1188,18 @@ const validateSoundItems = ({ items, path, errorFactory }) => {
1111
1188
  }
1112
1189
 
1113
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
+
1114
1203
  if (!isNonEmptyString(item.fileId)) {
1115
1204
  return invalidFromErrorFactory(
1116
1205
  errorFactory,
@@ -1161,6 +1250,7 @@ const validateVideoItems = ({ items, path, errorFactory }) => {
1161
1250
  "type",
1162
1251
  "name",
1163
1252
  "description",
1253
+ "tagIds",
1164
1254
  "fileId",
1165
1255
  "thumbnailFileId",
1166
1256
  "duration",
@@ -1204,6 +1294,18 @@ const validateVideoItems = ({ items, path, errorFactory }) => {
1204
1294
  }
1205
1295
 
1206
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
+
1207
1309
  if (!isNonEmptyString(item.fileId)) {
1208
1310
  return invalidFromErrorFactory(
1209
1311
  errorFactory,
@@ -2085,6 +2187,7 @@ const validateTransformItems = ({ items, path, errorFactory }) => {
2085
2187
  "type",
2086
2188
  "name",
2087
2189
  "description",
2190
+ "tagIds",
2088
2191
  "x",
2089
2192
  "y",
2090
2193
  "scaleX",
@@ -2130,6 +2233,18 @@ const validateTransformItems = ({ items, path, errorFactory }) => {
2130
2233
  }
2131
2234
 
2132
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
+
2133
2248
  for (const key of [
2134
2249
  "x",
2135
2250
  "y",
@@ -2594,7 +2709,12 @@ const validateTextStyleItems = ({ items, path, errorFactory }) => {
2594
2709
  }
2595
2710
  };
2596
2711
 
2597
- const validateCharacterSpriteItems = ({ items, path, errorFactory }) => {
2712
+ const validateCharacterSpriteItems = ({
2713
+ items,
2714
+ path,
2715
+ errorFactory,
2716
+ allowTagIds = true,
2717
+ }) => {
2598
2718
  for (const [itemId, item] of Object.entries(items)) {
2599
2719
  const itemPath = `${path}.${itemId}`;
2600
2720
 
@@ -2616,6 +2736,7 @@ const validateCharacterSpriteItems = ({ items, path, errorFactory }) => {
2616
2736
  "type",
2617
2737
  "name",
2618
2738
  "description",
2739
+ ...(allowTagIds ? ["tagIds"] : []),
2619
2740
  "thumbnailFileId",
2620
2741
  "fileId",
2621
2742
  "width",
@@ -2658,6 +2779,20 @@ const validateCharacterSpriteItems = ({ items, path, errorFactory }) => {
2658
2779
  }
2659
2780
 
2660
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
+
2661
2796
  if (
2662
2797
  item.thumbnailFileId !== undefined &&
2663
2798
  !isNonEmptyString(item.thumbnailFileId)
@@ -3453,6 +3588,7 @@ const validateCharacterItems = ({ items, path, errorFactory }) => {
3453
3588
  "type",
3454
3589
  "name",
3455
3590
  "description",
3591
+ "tagIds",
3456
3592
  "shortcut",
3457
3593
  "fileId",
3458
3594
  "sprites",
@@ -3494,6 +3630,18 @@ const validateCharacterItems = ({ items, path, errorFactory }) => {
3494
3630
  }
3495
3631
 
3496
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
+
3497
3645
  if (item.shortcut !== undefined && !isString(item.shortcut)) {
3498
3646
  return invalidFromErrorFactory(
3499
3647
  errorFactory,
@@ -3525,80 +3673,23 @@ const validateCharacterItems = ({ items, path, errorFactory }) => {
3525
3673
  }
3526
3674
  };
3527
3675
 
3528
- const validateKeyboardMap = ({ value, path, errorFactory }) => {
3529
- if (value === undefined) {
3530
- return VALID_RESULT;
3531
- }
3532
-
3533
- if (!isPlainObject(value)) {
3534
- return invalidFromErrorFactory(
3535
- errorFactory,
3536
- `${path} must be an object when provided`,
3537
- );
3538
- }
3539
-
3540
- for (const [key, interaction] of Object.entries(value)) {
3541
- if (!isNonEmptyString(key)) {
3542
- return invalidFromErrorFactory(
3543
- errorFactory,
3544
- `${path} keys must be non-empty strings`,
3545
- );
3546
- }
3547
-
3548
- if (!isPlainObject(interaction)) {
3549
- return invalidFromErrorFactory(
3550
- errorFactory,
3551
- `${path}.${key} must be an object`,
3552
- );
3553
- }
3554
- }
3555
-
3556
- return VALID_RESULT;
3557
- };
3558
-
3559
- const validatePreviewObject = ({ value, path, errorFactory }) => {
3560
- if (value === undefined) {
3561
- return VALID_RESULT;
3562
- }
3563
-
3564
- if (!isPlainObject(value)) {
3565
- return invalidFromErrorFactory(
3566
- errorFactory,
3567
- `${path} must be an object when provided`,
3568
- );
3569
- }
3570
-
3571
- return VALID_RESULT;
3572
- };
3676
+ const validateTagItems = ({ items, path, errorFactory }) => {
3677
+ const seenNames = new Set();
3573
3678
 
3574
- const validateLayoutItems = ({ items, path, errorFactory }) => {
3575
3679
  for (const [itemId, item] of Object.entries(items)) {
3576
3680
  const itemPath = `${path}.${itemId}`;
3577
3681
 
3578
- if (item?.type !== "folder" && item?.type !== "layout") {
3682
+ if (item?.type !== "tag") {
3579
3683
  return invalidFromErrorFactory(
3580
3684
  errorFactory,
3581
- `${itemPath}.type must be 'folder' or 'layout'`,
3685
+ `${itemPath}.type must be 'tag'`,
3582
3686
  );
3583
3687
  }
3584
3688
 
3585
3689
  {
3586
3690
  const result = validateAllowedKeys({
3587
3691
  value: item,
3588
- allowedKeys:
3589
- item.type === "folder"
3590
- ? ["id", "type", "name", "description"]
3591
- : [
3592
- "id",
3593
- "type",
3594
- "name",
3595
- "description",
3596
- "layoutType",
3597
- "isFragment",
3598
- "thumbnailFileId",
3599
- "preview",
3600
- "elements",
3601
- ],
3692
+ allowedKeys: ["id", "type", "name", "color"],
3602
3693
  path: itemPath,
3603
3694
  errorFactory,
3604
3695
  });
@@ -3628,82 +3719,602 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
3628
3719
  );
3629
3720
  }
3630
3721
 
3631
- if (item.description !== undefined && !isString(item.description)) {
3722
+ if (item.color !== undefined && !isHexColor(item.color)) {
3632
3723
  return invalidFromErrorFactory(
3633
3724
  errorFactory,
3634
- `${itemPath}.description must be a string when provided`,
3725
+ `${itemPath}.color must be a hex color when provided`,
3635
3726
  );
3636
3727
  }
3637
3728
 
3638
- if (
3639
- item.thumbnailFileId !== undefined &&
3640
- !isNonEmptyString(item.thumbnailFileId)
3641
- ) {
3729
+ const normalizedName = item.name.trim().toLowerCase();
3730
+ if (seenNames.has(normalizedName)) {
3642
3731
  return invalidFromErrorFactory(
3643
3732
  errorFactory,
3644
- `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
3733
+ `${itemPath}.name must be unique within its tag scope`,
3645
3734
  );
3646
3735
  }
3647
3736
 
3648
- {
3649
- const result = validatePreviewObject({
3650
- value: item.preview,
3651
- path: `${itemPath}.preview`,
3652
- errorFactory,
3653
- });
3654
- if (result?.valid === false) {
3655
- return result;
3656
- }
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;
3657
3745
  }
3658
3746
 
3659
- if (item.type === "layout") {
3660
- if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
3747
+ for (const [index, node] of entries.entries()) {
3748
+ if (Object.hasOwn(node, "children")) {
3661
3749
  return invalidFromErrorFactory(
3662
3750
  errorFactory,
3663
- `${itemPath}.layoutType must be 'general', 'save-load', 'confirmDialog', 'dialogue-adv', 'dialogue-nvl', 'choice', or 'history'`,
3751
+ `${entryPath}[${index}].children is not allowed`,
3664
3752
  );
3665
3753
  }
3754
+ }
3666
3755
 
3667
- if (
3668
- item.isFragment !== undefined &&
3669
- typeof item.isFragment !== "boolean"
3670
- ) {
3671
- return invalidFromErrorFactory(
3672
- errorFactory,
3673
- `${itemPath}.isFragment must be a boolean when provided`,
3674
- );
3675
- }
3756
+ return VALID_RESULT;
3757
+ };
3676
3758
 
3677
- {
3678
- const result = validateNestedCollection({
3679
- collection: item.elements,
3680
- path: `${itemPath}.elements`,
3681
- itemValidator: validateLayoutElementItems,
3682
- treeValidator: validateLayoutElementTreeOwnership,
3683
- treeNodeLabel: "layout element",
3684
- });
3685
- if (result?.valid === false) {
3686
- return result;
3687
- }
3688
- }
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;
3689
3776
  }
3690
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
+ }
3691
3799
  };
3692
3800
 
3693
- const validateControlItems = ({ items, path, errorFactory }) => {
3694
- for (const [itemId, item] of Object.entries(items)) {
3695
- 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
+ }
3696
3817
 
3697
- 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)) {
3698
3851
  return invalidFromErrorFactory(
3699
3852
  errorFactory,
3700
- `${itemPath}.type must be 'folder' or 'control'`,
3853
+ `${path}.${scopeKey} is not allowed`,
3701
3854
  );
3702
3855
  }
3856
+ }
3703
3857
 
3704
- {
3705
- const result = validateAllowedKeys({
3706
- value: item,
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({
4317
+ value: item,
3707
4318
  allowedKeys:
3708
4319
  item.type === "folder"
3709
4320
  ? ["id", "type", "name", "description"]
@@ -4854,6 +5465,22 @@ export const assertInvariants = ({ state }) => {
4854
5465
  return result;
4855
5466
  }
4856
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
+ }
4857
5484
  }
4858
5485
 
4859
5486
  for (const [spritesheetId, spritesheet] of Object.entries(
@@ -4926,6 +5553,22 @@ export const assertInvariants = ({ state }) => {
4926
5553
  return result;
4927
5554
  }
4928
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
+ }
4929
5572
  }
4930
5573
 
4931
5574
  for (const [videoId, video] of Object.entries(state.videos.items)) {
@@ -4958,6 +5601,22 @@ export const assertInvariants = ({ state }) => {
4958
5601
  return result;
4959
5602
  }
4960
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
+ }
4961
5620
  }
4962
5621
 
4963
5622
  for (const [fontId, font] of Object.entries(state.fonts.items)) {
@@ -5016,6 +5675,22 @@ export const assertInvariants = ({ state }) => {
5016
5675
  }
5017
5676
  }
5018
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
+
5019
5694
  for (const [spriteId, sprite] of Object.entries(
5020
5695
  character.sprites?.items || {},
5021
5696
  )) {
@@ -5034,23 +5709,64 @@ export const assertInvariants = ({ state }) => {
5034
5709
  return result;
5035
5710
  }
5036
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
+
5037
5729
  if (sprite.thumbnailFileId === undefined) {
5038
5730
  continue;
5039
5731
  }
5040
5732
 
5041
- const thumbnailResult = validateFileReference({
5733
+ const thumbnailResult = validateFileReference({
5734
+ state,
5735
+ fileId: sprite.thumbnailFileId,
5736
+ path: "character.sprite.thumbnailFileId",
5737
+ details: {
5738
+ characterId,
5739
+ spriteId,
5740
+ thumbnailFileId: sprite.thumbnailFileId,
5741
+ },
5742
+ errorFactory: createInvariantValidationError,
5743
+ });
5744
+ if (!thumbnailResult.valid) {
5745
+ return thumbnailResult;
5746
+ }
5747
+ }
5748
+ }
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({
5042
5759
  state,
5043
- fileId: sprite.thumbnailFileId,
5044
- path: "character.sprite.thumbnailFileId",
5760
+ tagIds: transform.tagIds,
5761
+ scopeKey: "transforms",
5762
+ path: "transform.tagIds",
5045
5763
  details: {
5046
- characterId,
5047
- spriteId,
5048
- thumbnailFileId: sprite.thumbnailFileId,
5764
+ transformId,
5049
5765
  },
5050
5766
  errorFactory: createInvariantValidationError,
5051
5767
  });
5052
- if (!thumbnailResult.valid) {
5053
- return thumbnailResult;
5768
+ if (!result.valid) {
5769
+ return result;
5054
5770
  }
5055
5771
  }
5056
5772
  }
@@ -5366,7 +6082,7 @@ export const assertInvariants = ({ state }) => {
5366
6082
  !isLayoutConditionTarget(state, rule.when.target)
5367
6083
  ) {
5368
6084
  return invalidInvariant(
5369
- `${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`,
5370
6086
  {
5371
6087
  [ownerIdField]: ownerId,
5372
6088
  elementId,
@@ -5511,6 +6227,18 @@ const runValidateState = ({ state }) => {
5511
6227
  );
5512
6228
  }
5513
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
+
5514
6242
  for (const collectionKey of COLLECTION_KEYS) {
5515
6243
  {
5516
6244
  const result = validateCollection({
@@ -5726,6 +6454,46 @@ const validateRequiredUniqueIdArray = ({ value, path, errorFactory }) => {
5726
6454
  }
5727
6455
  };
5728
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
+
5729
6497
  const validateSectionCreateData = ({ data, errorFactory }) => {
5730
6498
  {
5731
6499
  const result = validateExactKeys({
@@ -5948,6 +6716,7 @@ const validateImageCreateData = ({ data, errorFactory }) => {
5948
6716
  "type",
5949
6717
  "name",
5950
6718
  "description",
6719
+ "tagIds",
5951
6720
  "thumbnailFileId",
5952
6721
  "fileId",
5953
6722
  "width",
@@ -5976,6 +6745,17 @@ const validateImageCreateData = ({ data, errorFactory }) => {
5976
6745
  }
5977
6746
 
5978
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
+
5979
6759
  if (
5980
6760
  data.thumbnailFileId !== undefined &&
5981
6761
  !isNonEmptyString(data.thumbnailFileId)
@@ -6016,6 +6796,7 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
6016
6796
  allowedKeys: [
6017
6797
  "name",
6018
6798
  "description",
6799
+ "tagIds",
6019
6800
  "thumbnailFileId",
6020
6801
  "fileId",
6021
6802
  "width",
@@ -6050,6 +6831,17 @@ const validateImageUpdateData = ({ data, errorFactory }) => {
6050
6831
  );
6051
6832
  }
6052
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
+
6053
6845
  if (
6054
6846
  data.thumbnailFileId !== undefined &&
6055
6847
  !isNonEmptyString(data.thumbnailFileId)
@@ -6314,6 +7106,7 @@ const validateSoundCreateData = ({ data, errorFactory }) => {
6314
7106
  "type",
6315
7107
  "name",
6316
7108
  "description",
7109
+ "tagIds",
6317
7110
  "fileId",
6318
7111
  "waveformDataFileId",
6319
7112
  "duration",
@@ -6341,6 +7134,17 @@ const validateSoundCreateData = ({ data, errorFactory }) => {
6341
7134
  }
6342
7135
 
6343
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
+
6344
7148
  if (!isNonEmptyString(data.fileId)) {
6345
7149
  return invalidFromErrorFactory(
6346
7150
  errorFactory,
@@ -6375,6 +7179,7 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
6375
7179
  allowedKeys: [
6376
7180
  "name",
6377
7181
  "description",
7182
+ "tagIds",
6378
7183
  "fileId",
6379
7184
  "waveformDataFileId",
6380
7185
  "duration",
@@ -6408,6 +7213,17 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
6408
7213
  );
6409
7214
  }
6410
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
+
6411
7227
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
6412
7228
  return invalidFromErrorFactory(
6413
7229
  errorFactory,
@@ -6459,6 +7275,7 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
6459
7275
  "type",
6460
7276
  "name",
6461
7277
  "description",
7278
+ "tagIds",
6462
7279
  "fileId",
6463
7280
  "thumbnailFileId",
6464
7281
  "duration",
@@ -6488,6 +7305,17 @@ const validateVideoCreateData = ({ data, errorFactory }) => {
6488
7305
  }
6489
7306
 
6490
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
+
6491
7319
  if (!isNonEmptyString(data.fileId)) {
6492
7320
  return invalidFromErrorFactory(
6493
7321
  errorFactory,
@@ -6532,6 +7360,7 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
6532
7360
  allowedKeys: [
6533
7361
  "name",
6534
7362
  "description",
7363
+ "tagIds",
6535
7364
  "fileId",
6536
7365
  "thumbnailFileId",
6537
7366
  "duration",
@@ -6567,6 +7396,17 @@ const validateVideoUpdateData = ({ data, errorFactory }) => {
6567
7396
  );
6568
7397
  }
6569
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
+
6570
7410
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
6571
7411
  return invalidFromErrorFactory(
6572
7412
  errorFactory,
@@ -7054,6 +7894,7 @@ const validateTransformCreateData = ({ data, errorFactory }) => {
7054
7894
  "type",
7055
7895
  "name",
7056
7896
  "description",
7897
+ "tagIds",
7057
7898
  "x",
7058
7899
  "y",
7059
7900
  "scaleX",
@@ -7085,6 +7926,17 @@ const validateTransformCreateData = ({ data, errorFactory }) => {
7085
7926
  }
7086
7927
 
7087
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
+
7088
7940
  for (const key of [
7089
7941
  "x",
7090
7942
  "y",
@@ -7290,6 +8142,7 @@ const validateTransformUpdateData = ({ data, errorFactory }) => {
7290
8142
  allowedKeys: [
7291
8143
  "name",
7292
8144
  "description",
8145
+ "tagIds",
7293
8146
  "x",
7294
8147
  "y",
7295
8148
  "scaleX",
@@ -7327,6 +8180,17 @@ const validateTransformUpdateData = ({ data, errorFactory }) => {
7327
8180
  );
7328
8181
  }
7329
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
+
7330
8194
  for (const key of [
7331
8195
  "x",
7332
8196
  "y",
@@ -7680,6 +8544,7 @@ const validateCharacterSpriteCreateData = ({ data, errorFactory }) => {
7680
8544
  "type",
7681
8545
  "name",
7682
8546
  "description",
8547
+ "tagIds",
7683
8548
  "fileId",
7684
8549
  "thumbnailFileId",
7685
8550
  "width",
@@ -7708,6 +8573,17 @@ const validateCharacterSpriteCreateData = ({ data, errorFactory }) => {
7708
8573
  }
7709
8574
 
7710
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
+
7711
8587
  if (!isNonEmptyString(data.fileId)) {
7712
8588
  return invalidFromErrorFactory(
7713
8589
  errorFactory,
@@ -7748,6 +8624,7 @@ const validateCharacterSpriteUpdateData = ({ data, errorFactory }) => {
7748
8624
  allowedKeys: [
7749
8625
  "name",
7750
8626
  "description",
8627
+ "tagIds",
7751
8628
  "fileId",
7752
8629
  "thumbnailFileId",
7753
8630
  "width",
@@ -7775,6 +8652,17 @@ const validateCharacterSpriteUpdateData = ({ data, errorFactory }) => {
7775
8652
  );
7776
8653
  }
7777
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
+
7778
8666
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
7779
8667
  return invalidFromErrorFactory(
7780
8668
  errorFactory,
@@ -7839,6 +8727,7 @@ const validateCharacterCreateData = ({ data, errorFactory }) => {
7839
8727
  "type",
7840
8728
  "name",
7841
8729
  "description",
8730
+ "tagIds",
7842
8731
  "shortcut",
7843
8732
  "fileId",
7844
8733
  "sprites",
@@ -7866,6 +8755,17 @@ const validateCharacterCreateData = ({ data, errorFactory }) => {
7866
8755
  }
7867
8756
 
7868
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
+
7869
8769
  if (data.shortcut !== undefined && !isString(data.shortcut)) {
7870
8770
  return invalidFromErrorFactory(
7871
8771
  errorFactory,
@@ -7885,7 +8785,13 @@ const validateCharacterCreateData = ({ data, errorFactory }) => {
7885
8785
  const result = validateNestedCollection({
7886
8786
  collection: data.sprites,
7887
8787
  path: "payload.data.sprites",
7888
- itemValidator: validateCharacterSpriteItems,
8788
+ itemValidator: ({ items, path, errorFactory }) =>
8789
+ validateCharacterSpriteItems({
8790
+ items,
8791
+ path,
8792
+ errorFactory,
8793
+ allowTagIds: false,
8794
+ }),
7889
8795
  treeValidator: validateGenericFolderOwnership,
7890
8796
  folderLabel: "folder sprite item",
7891
8797
  errorFactory,
@@ -7905,6 +8811,7 @@ const validateCharacterUpdateData = ({ data, errorFactory }) => {
7905
8811
  allowedKeys: [
7906
8812
  "name",
7907
8813
  "description",
8814
+ "tagIds",
7908
8815
  "shortcut",
7909
8816
  "fileId",
7910
8817
  ],
@@ -7944,6 +8851,17 @@ const validateCharacterUpdateData = ({ data, errorFactory }) => {
7944
8851
  );
7945
8852
  }
7946
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
+
7947
8865
  if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
7948
8866
  return invalidFromErrorFactory(
7949
8867
  errorFactory,
@@ -8616,7 +9534,7 @@ const validateVisualElementReferenceTargets = ({
8616
9534
  if (!isLayoutConditionTarget(state, rule?.when?.target)) {
8617
9535
  return invalidFromErrorFactory(
8618
9536
  errorFactory,
8619
- `${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`,
8620
9538
  {
8621
9539
  [ownerIdField]: ownerId,
8622
9540
  elementId,
@@ -8790,6 +9708,7 @@ const createFolderedCollectionCommandDefinitions = ({
8790
9708
  validateCreateState = () => {},
8791
9709
  validateUpdateState = () => {},
8792
9710
  validateDeleteState = () => {},
9711
+ afterDelete = () => {},
8793
9712
  includeUpdate = true,
8794
9713
  }) => {
8795
9714
  const existingMessage = `payload.${idField} must reference an existing ${itemLabel}`;
@@ -9037,6 +9956,7 @@ const createFolderedCollectionCommandDefinitions = ({
9037
9956
  },
9038
9957
  reduce: ({ state, payload }) => {
9039
9958
  const deletedIds = new Set();
9959
+ const deletedItemsById = new Map();
9040
9960
 
9041
9961
  for (const itemId of payload[deleteArrayField]) {
9042
9962
  const removedNode = removeTreeNode({
@@ -9052,6 +9972,10 @@ const createFolderedCollectionCommandDefinitions = ({
9052
9972
  node: removedNode,
9053
9973
  })) {
9054
9974
  deletedIds.add(descendantId);
9975
+ deletedItemsById.set(
9976
+ descendantId,
9977
+ state[collectionKey].items[descendantId],
9978
+ );
9055
9979
  }
9056
9980
  }
9057
9981
 
@@ -9059,6 +9983,13 @@ const createFolderedCollectionCommandDefinitions = ({
9059
9983
  delete state[collectionKey].items[itemId];
9060
9984
  }
9061
9985
 
9986
+ afterDelete({
9987
+ state,
9988
+ payload,
9989
+ deletedIds,
9990
+ deletedItemsById,
9991
+ });
9992
+
9062
9993
  return state;
9063
9994
  },
9064
9995
  },
@@ -9389,7 +10320,8 @@ const COMMAND_DEFINITIONS = [
9389
10320
  }
9390
10321
  },
9391
10322
  validateAgainstState: () => {},
9392
- reduce: ({ payload }) => structuredClone(payload.state),
10323
+ reduce: ({ payload }) =>
10324
+ structuredClone(normalizeStateCollections(payload.state)),
9393
10325
  },
9394
10326
  ...createFolderedCollectionCommandDefinitions({
9395
10327
  familyName: "file",
@@ -10881,6 +11813,16 @@ const COMMAND_DEFINITIONS = [
10881
11813
  if (!result.valid) {
10882
11814
  return result;
10883
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
+ });
10884
11826
  }
10885
11827
  },
10886
11828
  reduce: ({ state, payload }) => {
@@ -10905,6 +11847,11 @@ const COMMAND_DEFINITIONS = [
10905
11847
  if (payload.data.height !== undefined) {
10906
11848
  nextImage.height = payload.data.height;
10907
11849
  }
11850
+
11851
+ assignOptionalTagIds({
11852
+ target: nextImage,
11853
+ tagIds: payload.data.tagIds,
11854
+ });
10908
11855
  }
10909
11856
 
10910
11857
  state.images.items[payload.imageId] = nextImage;
@@ -10966,7 +11913,8 @@ const COMMAND_DEFINITIONS = [
10966
11913
  (payload.data.fileId !== undefined ||
10967
11914
  payload.data.thumbnailFileId !== undefined ||
10968
11915
  payload.data.width !== undefined ||
10969
- payload.data.height !== undefined)
11916
+ payload.data.height !== undefined ||
11917
+ payload.data.tagIds !== undefined)
10970
11918
  ) {
10971
11919
  return invalidPrecondition(
10972
11920
  "folder image items cannot update file fields",
@@ -10985,14 +11933,24 @@ const COMMAND_DEFINITIONS = [
10985
11933
  if (!result.valid) {
10986
11934
  return result;
10987
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
+ });
10988
11946
  }
10989
11947
  },
10990
11948
  reduce: ({ state, payload }) => {
10991
11949
  const currentImage = state.images.items[payload.imageId];
10992
- state.images.items[payload.imageId] = {
10993
- ...structuredClone(currentImage),
10994
- ...structuredClone(payload.data),
10995
- };
11950
+ state.images.items[payload.imageId] = applyTagIdsUpdate({
11951
+ currentItem: currentImage,
11952
+ data: payload.data,
11953
+ });
10996
11954
  return state;
10997
11955
  },
10998
11956
  },
@@ -11298,6 +12256,16 @@ const COMMAND_DEFINITIONS = [
11298
12256
  if (!result.valid) {
11299
12257
  return result;
11300
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
+ });
11301
12269
  }
11302
12270
  },
11303
12271
  reduce: ({ state, payload }) => {
@@ -11319,6 +12287,11 @@ const COMMAND_DEFINITIONS = [
11319
12287
  if (payload.data.duration !== undefined) {
11320
12288
  nextSound.duration = payload.data.duration;
11321
12289
  }
12290
+
12291
+ assignOptionalTagIds({
12292
+ target: nextSound,
12293
+ tagIds: payload.data.tagIds,
12294
+ });
11322
12295
  }
11323
12296
 
11324
12297
  state.sounds.items[payload.soundId] = nextSound;
@@ -11379,7 +12352,8 @@ const COMMAND_DEFINITIONS = [
11379
12352
  currentSound.type === "folder" &&
11380
12353
  (payload.data.fileId !== undefined ||
11381
12354
  payload.data.waveformDataFileId !== undefined ||
11382
- payload.data.duration !== undefined)
12355
+ payload.data.duration !== undefined ||
12356
+ payload.data.tagIds !== undefined)
11383
12357
  ) {
11384
12358
  return invalidPrecondition(
11385
12359
  "folder sound items cannot update file fields",
@@ -11399,14 +12373,24 @@ const COMMAND_DEFINITIONS = [
11399
12373
  if (!result.valid) {
11400
12374
  return result;
11401
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
+ });
11402
12386
  }
11403
12387
  },
11404
12388
  reduce: ({ state, payload }) => {
11405
12389
  const currentSound = state.sounds.items[payload.soundId];
11406
- state.sounds.items[payload.soundId] = {
11407
- ...structuredClone(currentSound),
11408
- ...structuredClone(payload.data),
11409
- };
12390
+ state.sounds.items[payload.soundId] = applyTagIdsUpdate({
12391
+ currentItem: currentSound,
12392
+ data: payload.data,
12393
+ });
11410
12394
  return state;
11411
12395
  },
11412
12396
  },
@@ -11711,6 +12695,16 @@ const COMMAND_DEFINITIONS = [
11711
12695
  if (!result.valid) {
11712
12696
  return result;
11713
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
+ });
11714
12708
  }
11715
12709
  },
11716
12710
  reduce: ({ state, payload }) => {
@@ -11736,6 +12730,11 @@ const COMMAND_DEFINITIONS = [
11736
12730
  if (payload.data.height !== undefined) {
11737
12731
  nextVideo.height = payload.data.height;
11738
12732
  }
12733
+
12734
+ assignOptionalTagIds({
12735
+ target: nextVideo,
12736
+ tagIds: payload.data.tagIds,
12737
+ });
11739
12738
  }
11740
12739
 
11741
12740
  state.videos.items[payload.videoId] = nextVideo;
@@ -11798,7 +12797,8 @@ const COMMAND_DEFINITIONS = [
11798
12797
  payload.data.thumbnailFileId !== undefined ||
11799
12798
  payload.data.duration !== undefined ||
11800
12799
  payload.data.width !== undefined ||
11801
- payload.data.height !== undefined)
12800
+ payload.data.height !== undefined ||
12801
+ payload.data.tagIds !== undefined)
11802
12802
  ) {
11803
12803
  return invalidPrecondition(
11804
12804
  "folder video items cannot update file fields",
@@ -11817,14 +12817,24 @@ const COMMAND_DEFINITIONS = [
11817
12817
  if (!result.valid) {
11818
12818
  return result;
11819
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
+ });
11820
12830
  }
11821
12831
  },
11822
12832
  reduce: ({ state, payload }) => {
11823
12833
  const currentVideo = state.videos.items[payload.videoId];
11824
- state.videos.items[payload.videoId] = {
11825
- ...structuredClone(currentVideo),
11826
- ...structuredClone(payload.data),
11827
- };
12834
+ state.videos.items[payload.videoId] = applyTagIdsUpdate({
12835
+ currentItem: currentVideo,
12836
+ data: payload.data,
12837
+ });
11828
12838
  return state;
11829
12839
  },
11830
12840
  },
@@ -13248,7 +14258,7 @@ const COMMAND_DEFINITIONS = [
13248
14258
 
13249
14259
  return nextItem;
13250
14260
  },
13251
- validateUpdateState: ({ payload, currentItem }) => {
14261
+ validateUpdateState: ({ state, payload, currentItem }) => {
13252
14262
  if (
13253
14263
  currentItem.type === "folder" &&
13254
14264
  Object.keys(payload.data).some(
@@ -13268,28 +14278,57 @@ const COMMAND_DEFINITIONS = [
13268
14278
  itemLabel: "transform item",
13269
14279
  createDataValidator: validateTransformCreateData,
13270
14280
  updateDataValidator: validateTransformUpdateData,
13271
- createItem: ({ payload }) => ({
13272
- id: payload.transformId,
13273
- type: payload.data.type,
13274
- name: payload.data.name,
13275
- ...(payload.data.description !== undefined
13276
- ? {
13277
- description: payload.data.description,
13278
- }
13279
- : {}),
13280
- ...(payload.data.type === "transform"
13281
- ? {
13282
- x: payload.data.x,
13283
- y: payload.data.y,
13284
- scaleX: payload.data.scaleX,
13285
- scaleY: payload.data.scaleY,
13286
- anchorX: payload.data.anchorX,
13287
- anchorY: payload.data.anchorY,
13288
- rotation: payload.data.rotation,
13289
- }
13290
- : {}),
13291
- }),
13292
- 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 }) => {
13293
14332
  if (
13294
14333
  currentItem.type === "folder" &&
13295
14334
  Object.keys(payload.data).some(
@@ -13300,6 +14339,18 @@ const COMMAND_DEFINITIONS = [
13300
14339
  "folder transform items cannot update transform fields",
13301
14340
  );
13302
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
+ }
13303
14354
  },
13304
14355
  }),
13305
14356
  ...createFolderedCollectionCommandDefinitions({
@@ -13326,7 +14377,7 @@ const COMMAND_DEFINITIONS = [
13326
14377
  value: payload.data.value,
13327
14378
  }),
13328
14379
  }),
13329
- validateUpdateState: ({ payload, currentItem }) => {
14380
+ validateUpdateState: ({ state, payload, currentItem }) => {
13330
14381
  if (
13331
14382
  currentItem.type === "folder" &&
13332
14383
  Object.keys(payload.data).some(
@@ -13457,6 +14508,11 @@ const COMMAND_DEFINITIONS = [
13457
14508
  item.fileId = payload.data.fileId;
13458
14509
  }
13459
14510
 
14511
+ assignOptionalTagIds({
14512
+ target: item,
14513
+ tagIds: payload.data.tagIds,
14514
+ });
14515
+
13460
14516
  item.sprites =
13461
14517
  payload.data.sprites === undefined
13462
14518
  ? { items: {}, tree: [] }
@@ -13464,6 +14520,11 @@ const COMMAND_DEFINITIONS = [
13464
14520
 
13465
14521
  return item;
13466
14522
  },
14523
+ updateItem: ({ currentItem, payload }) =>
14524
+ applyTagIdsUpdate({
14525
+ currentItem,
14526
+ data: payload.data,
14527
+ }),
13467
14528
  validateCreateState: ({ state, payload }) => {
13468
14529
  if (payload.data.type !== "character") {
13469
14530
  return;
@@ -13483,6 +14544,21 @@ const COMMAND_DEFINITIONS = [
13483
14544
  }
13484
14545
  }
13485
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
+
13486
14562
  for (const [spriteId, sprite] of Object.entries(
13487
14563
  payload.data.sprites?.items || {},
13488
14564
  )) {
@@ -13490,6 +14566,16 @@ const COMMAND_DEFINITIONS = [
13490
14566
  continue;
13491
14567
  }
13492
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
+
13493
14579
  const result = validateFileReference({
13494
14580
  state,
13495
14581
  fileId: sprite.fileId,
@@ -13547,6 +14633,27 @@ const COMMAND_DEFINITIONS = [
13547
14633
  if (!result.valid) {
13548
14634
  return result;
13549
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
+ ];
13550
14657
  }
13551
14658
  },
13552
14659
  }),
@@ -13817,6 +14924,17 @@ const COMMAND_DEFINITIONS = [
13817
14924
  if (!result.valid) {
13818
14925
  return result;
13819
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
+ });
13820
14938
  }
13821
14939
  },
13822
14940
  reduce: ({ state, payload }) => {
@@ -13825,10 +14943,15 @@ const COMMAND_DEFINITIONS = [
13825
14943
  characterId: payload.characterId,
13826
14944
  });
13827
14945
 
13828
- collection.items[payload.spriteId] = {
14946
+ const nextSprite = {
13829
14947
  id: payload.spriteId,
13830
14948
  ...structuredClone(payload.data),
13831
14949
  };
14950
+ if (payload.data.tagIds !== undefined && payload.data.tagIds.length === 0) {
14951
+ delete nextSprite.tagIds;
14952
+ }
14953
+
14954
+ collection.items[payload.spriteId] = nextSprite;
13832
14955
 
13833
14956
  insertTreeNode({
13834
14957
  tree: collection.tree,
@@ -13922,6 +15045,17 @@ const COMMAND_DEFINITIONS = [
13922
15045
  if (!result.valid) {
13923
15046
  return result;
13924
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
+ });
13925
15059
  }
13926
15060
  },
13927
15061
  reduce: ({ state, payload }) => {
@@ -13931,10 +15065,10 @@ const COMMAND_DEFINITIONS = [
13931
15065
  });
13932
15066
  const currentItem = collection.items[payload.spriteId];
13933
15067
 
13934
- collection.items[payload.spriteId] = {
13935
- ...structuredClone(currentItem),
13936
- ...structuredClone(payload.data),
13937
- };
15068
+ collection.items[payload.spriteId] = applyTagIdsUpdate({
15069
+ currentItem,
15070
+ data: payload.data,
15071
+ });
13938
15072
 
13939
15073
  return state;
13940
15074
  },
@@ -14166,6 +15300,297 @@ const COMMAND_DEFINITIONS = [
14166
15300
  return state;
14167
15301
  },
14168
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
+ },
14169
15594
  {
14170
15595
  type: "layout.element.create",
14171
15596
  validatePayload: ({ payload }) => {
@@ -15338,11 +16763,7 @@ export const validateAgainstState = ({ state, command }) => {
15338
16763
  export const processCommand = ({ state, command }) => {
15339
16764
  return captureValidation(() => {
15340
16765
  const normalizedState = normalizeStateCollections(state);
15341
- const shouldMaterializeNormalizedState =
15342
- typeof command?.type === "string" &&
15343
- (command.type.startsWith("control.") ||
15344
- command.type.startsWith("spritesheet.") ||
15345
- command.type.startsWith("particle."));
16766
+ const shouldMaterializeNormalizedState = normalizedState !== state;
15346
16767
 
15347
16768
  const stateResult = validateState({ state: normalizedState });
15348
16769
  if (!stateResult.valid) {