@routevn/creator-model 1.7.3 → 1.8.1

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 +2288 -993
package/src/model.js CHANGED
@@ -23,6 +23,7 @@ const COLLECTION_KEYS = [
23
23
  "images",
24
24
  "spritesheets",
25
25
  "sounds",
26
+ "voices",
26
27
  "videos",
27
28
  "animations",
28
29
  "particles",
@@ -245,6 +246,7 @@ const normalizeStateCollections = (state) => {
245
246
  "spritesheets",
246
247
  "particles",
247
248
  "controls",
249
+ "voices",
248
250
  ].filter((key) => state[key] === undefined);
249
251
  const normalizedTags = normalizeTagsState(state.tags);
250
252
  const hasNormalizedTags = normalizedTags !== state.tags;
@@ -392,7 +394,7 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
392
394
  "container-ref-confirm-dialog-ok",
393
395
  "container-ref-confirm-dialog-cancel",
394
396
  ];
395
- export const SCHEMA_VERSION = 7;
397
+ export const SCHEMA_VERSION = 8;
396
398
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
397
399
  "folder",
398
400
  "container",
@@ -404,6 +406,18 @@ const LAYOUT_CONTAINER_ELEMENT_TYPES = [
404
406
  "container-ref-confirm-dialog-ok",
405
407
  "container-ref-confirm-dialog-cancel",
406
408
  ];
409
+ const LAYOUT_TEXT_CONTENT_ELEMENT_TYPES = [
410
+ "text",
411
+ "text-revealing",
412
+ "text-ref-character-name",
413
+ "text-revealing-ref-dialogue-content",
414
+ "text-ref-choice-item-content",
415
+ "text-ref-save-load-slot-date",
416
+ "text-ref-dialogue-line-character-name",
417
+ "text-ref-dialogue-line-content",
418
+ "text-ref-history-line-character-name",
419
+ "text-ref-history-line-content",
420
+ ];
407
421
  const DOMAIN_ERROR_KIND_BY_NAME = {
408
422
  PayloadValidationError: "payload",
409
423
  PreconditionValidationError: "precondition",
@@ -1431,6 +1445,105 @@ const validateSoundItems = ({ items, path, errorFactory }) => {
1431
1445
  }
1432
1446
  };
1433
1447
 
1448
+ const validateVoiceItems = ({ items, path, errorFactory }) => {
1449
+ for (const [itemId, item] of Object.entries(items)) {
1450
+ const itemPath = `${path}.${itemId}`;
1451
+
1452
+ if (item?.type !== "folder" && item?.type !== "voice") {
1453
+ return invalidFromErrorFactory(
1454
+ errorFactory,
1455
+ `${itemPath}.type must be 'folder' or 'voice'`,
1456
+ );
1457
+ }
1458
+
1459
+ {
1460
+ const result = validateAllowedKeys({
1461
+ value: item,
1462
+ allowedKeys:
1463
+ item.type === "folder"
1464
+ ? ["id", "type", "name", "description"]
1465
+ : [
1466
+ "id",
1467
+ "type",
1468
+ "name",
1469
+ "description",
1470
+ "sceneId",
1471
+ "fileId",
1472
+ "waveformDataFileId",
1473
+ "duration",
1474
+ ],
1475
+ path: itemPath,
1476
+ errorFactory,
1477
+ });
1478
+ if (result?.valid === false) {
1479
+ return result;
1480
+ }
1481
+ }
1482
+
1483
+ if (!isNonEmptyString(item.id)) {
1484
+ return invalidFromErrorFactory(
1485
+ errorFactory,
1486
+ `${itemPath}.id must be a non-empty string`,
1487
+ );
1488
+ }
1489
+
1490
+ if (item.id !== itemId) {
1491
+ return invalidFromErrorFactory(
1492
+ errorFactory,
1493
+ `${itemPath}.id must match item key '${itemId}'`,
1494
+ );
1495
+ }
1496
+
1497
+ if (!isNonEmptyString(item.name)) {
1498
+ return invalidFromErrorFactory(
1499
+ errorFactory,
1500
+ `${itemPath}.name must be a non-empty string`,
1501
+ );
1502
+ }
1503
+
1504
+ if (item.description !== undefined && !isString(item.description)) {
1505
+ return invalidFromErrorFactory(
1506
+ errorFactory,
1507
+ `${itemPath}.description must be a string when provided`,
1508
+ );
1509
+ }
1510
+
1511
+ if (item.type === "voice") {
1512
+ if (!isNonEmptyString(item.sceneId)) {
1513
+ return invalidFromErrorFactory(
1514
+ errorFactory,
1515
+ `${itemPath}.sceneId must be a non-empty string`,
1516
+ );
1517
+ }
1518
+
1519
+ if (!isNonEmptyString(item.fileId)) {
1520
+ return invalidFromErrorFactory(
1521
+ errorFactory,
1522
+ `${itemPath}.fileId must be a non-empty string`,
1523
+ );
1524
+ }
1525
+
1526
+ if (
1527
+ item.waveformDataFileId !== undefined &&
1528
+ item.waveformDataFileId !== null &&
1529
+ !isNonEmptyString(item.waveformDataFileId)
1530
+ ) {
1531
+ return invalidFromErrorFactory(
1532
+ errorFactory,
1533
+ `${itemPath}.waveformDataFileId must be a non-empty string or null when provided`,
1534
+ );
1535
+ }
1536
+
1537
+ if (item.duration !== undefined && !isFiniteNumber(item.duration)) {
1538
+ return invalidFromErrorFactory(
1539
+ errorFactory,
1540
+ `${itemPath}.duration must be a finite number`,
1541
+ );
1542
+ }
1543
+ }
1544
+ }
1545
+ };
1546
+
1434
1547
  const validateVideoItems = ({ items, path, errorFactory }) => {
1435
1548
  for (const [itemId, item] of Object.entries(items)) {
1436
1549
  const itemPath = `${path}.${itemId}`;
@@ -3419,97 +3532,36 @@ const validateLayoutElementBlur = ({ blur, path, errorFactory }) => {
3419
3532
  }
3420
3533
  };
3421
3534
 
3422
- const validateLayoutElementData = ({
3423
- data,
3535
+ const LAYOUT_TEXT_REVEAL_INDICATOR_ELEMENT_TYPES = [
3536
+ "text-revealing",
3537
+ "text-revealing-ref-dialogue-content",
3538
+ ];
3539
+
3540
+ const validateLayoutTextRevealIndicatorVisual = ({
3541
+ visual,
3424
3542
  path,
3425
3543
  errorFactory,
3426
- allowPartial = false,
3427
3544
  }) => {
3428
- if (!isPlainObject(data)) {
3429
- return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
3545
+ if (!isPlainObject(visual)) {
3546
+ return invalidFromErrorFactory(
3547
+ errorFactory,
3548
+ `${path} must be an object when provided`,
3549
+ );
3430
3550
  }
3431
3551
 
3432
- const allowedKeys = [
3433
- "type",
3434
- "name",
3435
- "x",
3436
- "y",
3437
- "width",
3438
- "height",
3439
- "aspectRatioLock",
3440
- "anchorX",
3441
- "anchorY",
3442
- "scaleX",
3443
- "scaleY",
3444
- "rotation",
3445
- "opacity",
3446
- "blur",
3447
- "fill",
3448
- "border",
3449
- "text",
3450
- "textStyle",
3451
- "displaySpeed",
3452
- "revealEffect",
3453
- "resourceId",
3454
- "animationName",
3455
- "imageId",
3456
- "hoverImageId",
3457
- "clickImageId",
3458
- "hoverSoundId",
3459
- "clickSoundId",
3460
- "textStyleId",
3461
- "hoverTextStyleId",
3462
- "clickTextStyleId",
3463
- "field",
3464
- "value",
3465
- "placeholder",
3466
- "multiline",
3467
- "disabled",
3468
- "maxLength",
3469
- "formRole",
3470
- "padding",
3471
- "conditionalOverrides",
3472
- "direction",
3473
- "gapX",
3474
- "gapY",
3475
- "containerType",
3476
- "scroll",
3477
- "hover",
3478
- "click",
3479
- "rightClick",
3480
- "scrollUp",
3481
- "scrollDown",
3482
- "submit",
3483
- "focusEvent",
3484
- "blurEvent",
3485
- "selectionChange",
3486
- "compositionStart",
3487
- "compositionUpdate",
3488
- "compositionEnd",
3489
- "anchorToBottom",
3490
- "thumbImageId",
3491
- "barImageId",
3492
- "hoverThumbImageId",
3493
- "hoverBarImageId",
3494
- "min",
3495
- "max",
3496
- "step",
3497
- "initialValue",
3498
- "variableId",
3499
- "particleId",
3500
- "fragmentLayoutId",
3501
- "paginationMode",
3502
- "paginationVariableId",
3503
- "paginationSize",
3504
- "choiceItemIndex",
3505
- "$when",
3506
- "change",
3507
- ];
3508
-
3509
3552
  {
3510
3553
  const result = validateAllowedKeys({
3511
- value: data,
3512
- allowedKeys,
3554
+ value: visual,
3555
+ allowedKeys: [
3556
+ "kind",
3557
+ "imageId",
3558
+ "resourceId",
3559
+ "animationName",
3560
+ "width",
3561
+ "height",
3562
+ "offsetX",
3563
+ "offsetY",
3564
+ ],
3513
3565
  path,
3514
3566
  errorFactory,
3515
3567
  });
@@ -3518,101 +3570,140 @@ const validateLayoutElementData = ({
3518
3570
  }
3519
3571
  }
3520
3572
 
3521
- if (!allowPartial || data.type !== undefined) {
3522
- if (!LAYOUT_ELEMENT_BASE_TYPES.includes(data.type)) {
3573
+ const kind =
3574
+ visual.kind === "spritesheet" ||
3575
+ visual.resourceId !== undefined ||
3576
+ visual.animationName !== undefined
3577
+ ? "spritesheet"
3578
+ : "image";
3579
+
3580
+ if (
3581
+ visual.kind !== undefined &&
3582
+ visual.kind !== "image" &&
3583
+ visual.kind !== "spritesheet"
3584
+ ) {
3585
+ return invalidFromErrorFactory(
3586
+ errorFactory,
3587
+ `${path}.kind must be 'image' or 'spritesheet' when provided`,
3588
+ );
3589
+ }
3590
+
3591
+ if (kind === "image" && !isNonEmptyString(visual.imageId)) {
3592
+ return invalidFromErrorFactory(
3593
+ errorFactory,
3594
+ `${path}.imageId must be a non-empty string`,
3595
+ );
3596
+ }
3597
+
3598
+ if (kind === "spritesheet") {
3599
+ if (!isNonEmptyString(visual.resourceId)) {
3523
3600
  return invalidFromErrorFactory(
3524
3601
  errorFactory,
3525
- `${path}.type must be a supported layout element type`,
3602
+ `${path}.resourceId must be a non-empty string`,
3603
+ );
3604
+ }
3605
+
3606
+ if (!isNonEmptyString(visual.animationName)) {
3607
+ return invalidFromErrorFactory(
3608
+ errorFactory,
3609
+ `${path}.animationName must be a non-empty string`,
3526
3610
  );
3527
3611
  }
3528
3612
  }
3529
3613
 
3530
- if (!allowPartial || data.name !== undefined) {
3531
- if (!isNonEmptyString(data.name)) {
3614
+ for (const key of ["width", "height"]) {
3615
+ if (
3616
+ visual[key] !== undefined &&
3617
+ (!isFiniteNumber(visual[key]) || visual[key] <= 0)
3618
+ ) {
3532
3619
  return invalidFromErrorFactory(
3533
3620
  errorFactory,
3534
- `${path}.name must be a non-empty string`,
3621
+ `${path}.${key} must be a finite number greater than 0 when provided`,
3535
3622
  );
3536
3623
  }
3537
3624
  }
3538
3625
 
3539
- for (const key of [
3540
- "x",
3541
- "y",
3542
- "width",
3543
- "height",
3544
- "anchorX",
3545
- "anchorY",
3546
- "scaleX",
3547
- "scaleY",
3548
- "rotation",
3549
- "displaySpeed",
3550
- "gapX",
3551
- "gapY",
3552
- "min",
3553
- "max",
3554
- "step",
3555
- "paginationSize",
3556
- "choiceItemIndex",
3557
- "opacity",
3558
- "maxLength",
3559
- ]) {
3560
- if (data[key] !== undefined && !isFiniteNumber(data[key])) {
3626
+ for (const key of ["offsetX", "offsetY"]) {
3627
+ if (visual[key] !== undefined && !isFiniteNumber(visual[key])) {
3561
3628
  return invalidFromErrorFactory(
3562
3629
  errorFactory,
3563
3630
  `${path}.${key} must be a finite number when provided`,
3564
3631
  );
3565
3632
  }
3566
3633
  }
3634
+ };
3567
3635
 
3568
- if (
3569
- data.choiceItemIndex !== undefined &&
3570
- (!Number.isInteger(data.choiceItemIndex) ||
3571
- data.choiceItemIndex < 0 ||
3572
- data.choiceItemIndex > 19)
3573
- ) {
3636
+ const validateLayoutTextRevealIndicator = ({
3637
+ indicator,
3638
+ path,
3639
+ errorFactory,
3640
+ }) => {
3641
+ if (!isPlainObject(indicator)) {
3574
3642
  return invalidFromErrorFactory(
3575
3643
  errorFactory,
3576
- `${path}.choiceItemIndex must be an integer between 0 and 19 when provided`,
3644
+ `${path} must be an object when provided`,
3577
3645
  );
3578
3646
  }
3579
3647
 
3580
- if (
3581
- data.aspectRatioLock !== undefined &&
3582
- (!isFiniteNumber(data.aspectRatioLock) || data.aspectRatioLock <= 0)
3583
- ) {
3584
- return invalidFromErrorFactory(
3648
+ {
3649
+ const result = validateAllowedKeys({
3650
+ value: indicator,
3651
+ allowedKeys: ["revealing", "complete", "offsetX", "offsetY"],
3652
+ path,
3585
3653
  errorFactory,
3586
- `${path}.aspectRatioLock must be a finite number greater than 0 when provided`,
3587
- );
3654
+ });
3655
+ if (result?.valid === false) {
3656
+ return result;
3657
+ }
3588
3658
  }
3589
3659
 
3590
- if (
3591
- data.initialValue !== undefined &&
3592
- !isFiniteNumber(data.initialValue) &&
3593
- !isString(data.initialValue)
3594
- ) {
3595
- return invalidFromErrorFactory(
3596
- errorFactory,
3597
- `${path}.initialValue must be a finite number or string when provided`,
3598
- );
3660
+ for (const key of ["revealing", "complete"]) {
3661
+ if (indicator[key] !== undefined) {
3662
+ const result = validateLayoutTextRevealIndicatorVisual({
3663
+ visual: indicator[key],
3664
+ path: `${path}.${key}`,
3665
+ errorFactory,
3666
+ });
3667
+ if (result?.valid === false) {
3668
+ return result;
3669
+ }
3670
+ }
3599
3671
  }
3600
3672
 
3601
- if (
3602
- data.opacity !== undefined &&
3603
- (!isFiniteNumber(data.opacity) || data.opacity < 0 || data.opacity > 1)
3604
- ) {
3673
+ for (const key of ["offsetX", "offsetY"]) {
3674
+ if (indicator[key] !== undefined && !isFiniteNumber(indicator[key])) {
3675
+ return invalidFromErrorFactory(
3676
+ errorFactory,
3677
+ `${path}.${key} must be a finite number when provided`,
3678
+ );
3679
+ }
3680
+ }
3681
+ };
3682
+
3683
+ const validateLayoutTextContent = ({ content, path, errorFactory }) => {
3684
+ if (!Array.isArray(content)) {
3605
3685
  return invalidFromErrorFactory(
3606
3686
  errorFactory,
3607
- `${path}.opacity must be a finite number between 0 and 1 when provided`,
3687
+ `${path} must be an array when provided`,
3608
3688
  );
3609
3689
  }
3610
3690
 
3611
- if (data.blur !== undefined) {
3691
+ for (let index = 0; index < content.length; index += 1) {
3692
+ const item = content[index];
3693
+ const itemPath = `${path}[${index}]`;
3694
+
3695
+ if (!isPlainObject(item)) {
3696
+ return invalidFromErrorFactory(
3697
+ errorFactory,
3698
+ `${itemPath} must be an object`,
3699
+ );
3700
+ }
3701
+
3612
3702
  {
3613
- const result = validateLayoutElementBlur({
3614
- blur: data.blur,
3615
- path: `${path}.blur`,
3703
+ const result = validateAllowedKeys({
3704
+ value: item,
3705
+ allowedKeys: ["text", "reference"],
3706
+ path: itemPath,
3616
3707
  errorFactory,
3617
3708
  });
3618
3709
  if (result?.valid === false) {
@@ -3620,19 +3711,107 @@ const validateLayoutElementData = ({
3620
3711
  }
3621
3712
  }
3622
3713
 
3623
- if (data.type !== undefined && data.type !== "sprite") {
3714
+ const hasText = Object.hasOwn(item, "text");
3715
+ const hasReference = Object.hasOwn(item, "reference");
3716
+
3717
+ if (hasText === hasReference) {
3624
3718
  return invalidFromErrorFactory(
3625
3719
  errorFactory,
3626
- `${path}.blur can only be provided for sprite elements`,
3720
+ `${itemPath} must include exactly one of text or reference`,
3721
+ );
3722
+ }
3723
+
3724
+ if (hasText) {
3725
+ if (!isString(item.text)) {
3726
+ return invalidFromErrorFactory(
3727
+ errorFactory,
3728
+ `${itemPath}.text must be a string`,
3729
+ );
3730
+ }
3731
+ continue;
3732
+ }
3733
+
3734
+ if (!isPlainObject(item.reference)) {
3735
+ return invalidFromErrorFactory(
3736
+ errorFactory,
3737
+ `${itemPath}.reference must be an object`,
3738
+ );
3739
+ }
3740
+
3741
+ {
3742
+ const result = validateAllowedKeys({
3743
+ value: item.reference,
3744
+ allowedKeys: ["resourceId"],
3745
+ path: `${itemPath}.reference`,
3746
+ errorFactory,
3747
+ });
3748
+ if (result?.valid === false) {
3749
+ return result;
3750
+ }
3751
+ }
3752
+
3753
+ if (!isNonEmptyString(item.reference.resourceId)) {
3754
+ return invalidFromErrorFactory(
3755
+ errorFactory,
3756
+ `${itemPath}.reference.resourceId must be a non-empty string`,
3627
3757
  );
3628
3758
  }
3629
3759
  }
3760
+ };
3761
+
3762
+ const getLayoutTextContentReferenceEntries = (content) => {
3763
+ if (!Array.isArray(content)) {
3764
+ return [];
3765
+ }
3766
+
3767
+ const entries = [];
3768
+ for (let index = 0; index < content.length; index += 1) {
3769
+ const resourceId = content[index]?.reference?.resourceId;
3770
+ if (resourceId !== undefined) {
3771
+ entries.push({
3772
+ index,
3773
+ resourceId,
3774
+ });
3775
+ }
3776
+ }
3777
+ return entries;
3778
+ };
3630
3779
 
3631
- for (const key of [
3780
+ const validateLayoutElementData = ({
3781
+ data,
3782
+ path,
3783
+ errorFactory,
3784
+ allowPartial = false,
3785
+ }) => {
3786
+ if (!isPlainObject(data)) {
3787
+ return invalidFromErrorFactory(errorFactory, `${path} must be an object`);
3788
+ }
3789
+
3790
+ const allowedKeys = [
3791
+ "type",
3792
+ "name",
3793
+ "x",
3794
+ "y",
3795
+ "width",
3796
+ "height",
3797
+ "aspectRatioLock",
3798
+ "anchorX",
3799
+ "anchorY",
3800
+ "scaleX",
3801
+ "scaleY",
3802
+ "rotation",
3803
+ "opacity",
3804
+ "blur",
3805
+ "fill",
3806
+ "border",
3632
3807
  "text",
3808
+ "content",
3809
+ "textStyle",
3810
+ "displaySpeed",
3811
+ "revealEffect",
3812
+ "indicator",
3633
3813
  "resourceId",
3634
3814
  "animationName",
3635
- "particleId",
3636
3815
  "imageId",
3637
3816
  "hoverImageId",
3638
3817
  "clickImageId",
@@ -3644,79 +3823,306 @@ const validateLayoutElementData = ({
3644
3823
  "field",
3645
3824
  "value",
3646
3825
  "placeholder",
3826
+ "multiline",
3827
+ "disabled",
3828
+ "maxLength",
3829
+ "formRole",
3830
+ "padding",
3831
+ "conditionalOverrides",
3832
+ "direction",
3833
+ "gapX",
3834
+ "gapY",
3647
3835
  "containerType",
3648
- "variableId",
3649
- "fragmentLayoutId",
3650
- "paginationMode",
3651
- "paginationVariableId",
3652
- "revealEffect",
3836
+ "scroll",
3837
+ "hover",
3838
+ "click",
3839
+ "rightClick",
3840
+ "scrollUp",
3841
+ "scrollDown",
3842
+ "submit",
3843
+ "focusEvent",
3844
+ "blurEvent",
3845
+ "selectionChange",
3846
+ "compositionStart",
3847
+ "compositionUpdate",
3848
+ "compositionEnd",
3849
+ "anchorToBottom",
3653
3850
  "thumbImageId",
3654
3851
  "barImageId",
3655
3852
  "hoverThumbImageId",
3656
3853
  "hoverBarImageId",
3854
+ "min",
3855
+ "max",
3856
+ "step",
3857
+ "initialValue",
3858
+ "variableId",
3859
+ "particleId",
3860
+ "fragmentLayoutId",
3861
+ "paginationMode",
3862
+ "paginationVariableId",
3863
+ "paginationSize",
3864
+ "choiceItemIndex",
3657
3865
  "$when",
3658
- ]) {
3659
- if (data[key] !== undefined && !isString(data[key])) {
3660
- return invalidFromErrorFactory(
3661
- errorFactory,
3662
- `${path}.${key} must be a string when provided`,
3663
- );
3664
- }
3665
- }
3866
+ "change",
3867
+ ];
3666
3868
 
3667
- if (data.type === "spritesheet-animation") {
3668
- if (
3669
- (!allowPartial || data.resourceId !== undefined) &&
3670
- !isNonEmptyString(data.resourceId)
3671
- ) {
3672
- return invalidFromErrorFactory(
3673
- errorFactory,
3674
- `${path}.resourceId must be a non-empty string`,
3675
- );
3869
+ {
3870
+ const result = validateAllowedKeys({
3871
+ value: data,
3872
+ allowedKeys,
3873
+ path,
3874
+ errorFactory,
3875
+ });
3876
+ if (result?.valid === false) {
3877
+ return result;
3676
3878
  }
3879
+ }
3677
3880
 
3678
- if (
3679
- (!allowPartial || data.animationName !== undefined) &&
3680
- !isNonEmptyString(data.animationName)
3681
- ) {
3881
+ if (!allowPartial || data.type !== undefined) {
3882
+ if (!LAYOUT_ELEMENT_BASE_TYPES.includes(data.type)) {
3682
3883
  return invalidFromErrorFactory(
3683
3884
  errorFactory,
3684
- `${path}.animationName must be a non-empty string`,
3885
+ `${path}.type must be a supported layout element type`,
3685
3886
  );
3686
3887
  }
3687
3888
  }
3688
3889
 
3689
- if (data.type === "particle") {
3690
- if (
3691
- (!allowPartial || data.particleId !== undefined) &&
3692
- !isNonEmptyString(data.particleId)
3693
- ) {
3890
+ if (!allowPartial || data.name !== undefined) {
3891
+ if (!isNonEmptyString(data.name)) {
3694
3892
  return invalidFromErrorFactory(
3695
3893
  errorFactory,
3696
- `${path}.particleId must be a non-empty string`,
3894
+ `${path}.name must be a non-empty string`,
3697
3895
  );
3698
3896
  }
3699
3897
  }
3700
3898
 
3701
- if (data.type === "input") {
3702
- if (
3703
- (!allowPartial || data.field !== undefined) &&
3704
- !isNonEmptyString(data.field)
3705
- ) {
3899
+ for (const key of [
3900
+ "x",
3901
+ "y",
3902
+ "width",
3903
+ "height",
3904
+ "anchorX",
3905
+ "anchorY",
3906
+ "scaleX",
3907
+ "scaleY",
3908
+ "rotation",
3909
+ "displaySpeed",
3910
+ "gapX",
3911
+ "gapY",
3912
+ "min",
3913
+ "max",
3914
+ "step",
3915
+ "paginationSize",
3916
+ "choiceItemIndex",
3917
+ "opacity",
3918
+ "maxLength",
3919
+ ]) {
3920
+ if (data[key] !== undefined && !isFiniteNumber(data[key])) {
3706
3921
  return invalidFromErrorFactory(
3707
3922
  errorFactory,
3708
- `${path}.field must be a non-empty string`,
3923
+ `${path}.${key} must be a finite number when provided`,
3709
3924
  );
3710
3925
  }
3711
3926
  }
3712
3927
 
3713
- if (data.conditionalOverrides !== undefined) {
3714
- if (!Array.isArray(data.conditionalOverrides)) {
3715
- return invalidFromErrorFactory(
3716
- errorFactory,
3717
- `${path}.conditionalOverrides must be an array when provided`,
3718
- );
3719
- }
3928
+ if (
3929
+ data.choiceItemIndex !== undefined &&
3930
+ (!Number.isInteger(data.choiceItemIndex) ||
3931
+ data.choiceItemIndex < 0 ||
3932
+ data.choiceItemIndex > 19)
3933
+ ) {
3934
+ return invalidFromErrorFactory(
3935
+ errorFactory,
3936
+ `${path}.choiceItemIndex must be an integer between 0 and 19 when provided`,
3937
+ );
3938
+ }
3939
+
3940
+ if (
3941
+ data.aspectRatioLock !== undefined &&
3942
+ (!isFiniteNumber(data.aspectRatioLock) || data.aspectRatioLock <= 0)
3943
+ ) {
3944
+ return invalidFromErrorFactory(
3945
+ errorFactory,
3946
+ `${path}.aspectRatioLock must be a finite number greater than 0 when provided`,
3947
+ );
3948
+ }
3949
+
3950
+ if (
3951
+ data.initialValue !== undefined &&
3952
+ !isFiniteNumber(data.initialValue) &&
3953
+ !isString(data.initialValue)
3954
+ ) {
3955
+ return invalidFromErrorFactory(
3956
+ errorFactory,
3957
+ `${path}.initialValue must be a finite number or string when provided`,
3958
+ );
3959
+ }
3960
+
3961
+ if (
3962
+ data.opacity !== undefined &&
3963
+ (!isFiniteNumber(data.opacity) || data.opacity < 0 || data.opacity > 1)
3964
+ ) {
3965
+ return invalidFromErrorFactory(
3966
+ errorFactory,
3967
+ `${path}.opacity must be a finite number between 0 and 1 when provided`,
3968
+ );
3969
+ }
3970
+
3971
+ if (data.blur !== undefined) {
3972
+ {
3973
+ const result = validateLayoutElementBlur({
3974
+ blur: data.blur,
3975
+ path: `${path}.blur`,
3976
+ errorFactory,
3977
+ });
3978
+ if (result?.valid === false) {
3979
+ return result;
3980
+ }
3981
+ }
3982
+
3983
+ if (data.type !== undefined && data.type !== "sprite") {
3984
+ return invalidFromErrorFactory(
3985
+ errorFactory,
3986
+ `${path}.blur can only be provided for sprite elements`,
3987
+ );
3988
+ }
3989
+ }
3990
+
3991
+ if (data.content !== undefined) {
3992
+ {
3993
+ const result = validateLayoutTextContent({
3994
+ content: data.content,
3995
+ path: `${path}.content`,
3996
+ errorFactory,
3997
+ });
3998
+ if (result?.valid === false) {
3999
+ return result;
4000
+ }
4001
+ }
4002
+
4003
+ if (
4004
+ data.type !== undefined &&
4005
+ !LAYOUT_TEXT_CONTENT_ELEMENT_TYPES.includes(data.type)
4006
+ ) {
4007
+ return invalidFromErrorFactory(
4008
+ errorFactory,
4009
+ `${path}.content can only be provided for text elements`,
4010
+ );
4011
+ }
4012
+ }
4013
+
4014
+ if (data.indicator !== undefined) {
4015
+ {
4016
+ const result = validateLayoutTextRevealIndicator({
4017
+ indicator: data.indicator,
4018
+ path: `${path}.indicator`,
4019
+ errorFactory,
4020
+ });
4021
+ if (result?.valid === false) {
4022
+ return result;
4023
+ }
4024
+ }
4025
+
4026
+ if (
4027
+ data.type !== undefined &&
4028
+ !LAYOUT_TEXT_REVEAL_INDICATOR_ELEMENT_TYPES.includes(data.type)
4029
+ ) {
4030
+ return invalidFromErrorFactory(
4031
+ errorFactory,
4032
+ `${path}.indicator can only be provided for text revealing elements`,
4033
+ );
4034
+ }
4035
+ }
4036
+
4037
+ for (const key of [
4038
+ "text",
4039
+ "resourceId",
4040
+ "animationName",
4041
+ "particleId",
4042
+ "imageId",
4043
+ "hoverImageId",
4044
+ "clickImageId",
4045
+ "hoverSoundId",
4046
+ "clickSoundId",
4047
+ "textStyleId",
4048
+ "hoverTextStyleId",
4049
+ "clickTextStyleId",
4050
+ "field",
4051
+ "value",
4052
+ "placeholder",
4053
+ "containerType",
4054
+ "variableId",
4055
+ "fragmentLayoutId",
4056
+ "paginationMode",
4057
+ "paginationVariableId",
4058
+ "revealEffect",
4059
+ "thumbImageId",
4060
+ "barImageId",
4061
+ "hoverThumbImageId",
4062
+ "hoverBarImageId",
4063
+ "$when",
4064
+ ]) {
4065
+ if (data[key] !== undefined && !isString(data[key])) {
4066
+ return invalidFromErrorFactory(
4067
+ errorFactory,
4068
+ `${path}.${key} must be a string when provided`,
4069
+ );
4070
+ }
4071
+ }
4072
+
4073
+ if (data.type === "spritesheet-animation") {
4074
+ if (
4075
+ (!allowPartial || data.resourceId !== undefined) &&
4076
+ !isNonEmptyString(data.resourceId)
4077
+ ) {
4078
+ return invalidFromErrorFactory(
4079
+ errorFactory,
4080
+ `${path}.resourceId must be a non-empty string`,
4081
+ );
4082
+ }
4083
+
4084
+ if (
4085
+ (!allowPartial || data.animationName !== undefined) &&
4086
+ !isNonEmptyString(data.animationName)
4087
+ ) {
4088
+ return invalidFromErrorFactory(
4089
+ errorFactory,
4090
+ `${path}.animationName must be a non-empty string`,
4091
+ );
4092
+ }
4093
+ }
4094
+
4095
+ if (data.type === "particle") {
4096
+ if (
4097
+ (!allowPartial || data.particleId !== undefined) &&
4098
+ !isNonEmptyString(data.particleId)
4099
+ ) {
4100
+ return invalidFromErrorFactory(
4101
+ errorFactory,
4102
+ `${path}.particleId must be a non-empty string`,
4103
+ );
4104
+ }
4105
+ }
4106
+
4107
+ if (data.type === "input") {
4108
+ if (
4109
+ (!allowPartial || data.field !== undefined) &&
4110
+ !isNonEmptyString(data.field)
4111
+ ) {
4112
+ return invalidFromErrorFactory(
4113
+ errorFactory,
4114
+ `${path}.field must be a non-empty string`,
4115
+ );
4116
+ }
4117
+ }
4118
+
4119
+ if (data.conditionalOverrides !== undefined) {
4120
+ if (!Array.isArray(data.conditionalOverrides)) {
4121
+ return invalidFromErrorFactory(
4122
+ errorFactory,
4123
+ `${path}.conditionalOverrides must be an array when provided`,
4124
+ );
4125
+ }
3720
4126
 
3721
4127
  for (let index = 0; index < data.conditionalOverrides.length; index += 1) {
3722
4128
  const rule = data.conditionalOverrides[index];
@@ -4143,9 +4549,11 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
4143
4549
  "fill",
4144
4550
  "border",
4145
4551
  "text",
4552
+ "content",
4146
4553
  "textStyle",
4147
4554
  "displaySpeed",
4148
4555
  "revealEffect",
4556
+ "indicator",
4149
4557
  "resourceId",
4150
4558
  "animationName",
4151
4559
  "particleId",
@@ -6104,6 +6512,17 @@ const validateCollection = ({ collection, path }) => {
6104
6512
  return result;
6105
6513
  }
6106
6514
  }
6515
+ } else if (path === "state.voices") {
6516
+ {
6517
+ const result = validateVoiceItems({
6518
+ items: collection.items,
6519
+ path: `${path}.items`,
6520
+ errorFactory: createStateValidationError,
6521
+ });
6522
+ if (result?.valid === false) {
6523
+ return result;
6524
+ }
6525
+ }
6107
6526
  } else if (path === "state.videos") {
6108
6527
  {
6109
6528
  const result = validateVideoItems({
@@ -6289,6 +6708,18 @@ const validateCollection = ({ collection, path }) => {
6289
6708
  return result;
6290
6709
  }
6291
6710
  }
6711
+ } else if (path === "state.voices") {
6712
+ {
6713
+ const result = validateGenericFolderOwnership({
6714
+ nodes: collection.tree,
6715
+ items: collection.items,
6716
+ path: `${path}.tree`,
6717
+ folderLabel: "folder voice item",
6718
+ });
6719
+ if (result?.valid === false) {
6720
+ return result;
6721
+ }
6722
+ }
6292
6723
  } else if (path === "state.videos") {
6293
6724
  {
6294
6725
  const result = validateVideoTreeFolderOwnership({
@@ -6809,6 +7240,55 @@ export const assertInvariants = ({ state }) => {
6809
7240
  }
6810
7241
  }
6811
7242
 
7243
+ for (const [voiceId, voice] of Object.entries(state.voices.items)) {
7244
+ if (voice.type !== "voice") {
7245
+ continue;
7246
+ }
7247
+
7248
+ if (!isPlainObject(sceneItems[voice.sceneId])) {
7249
+ return invalidInvariant(
7250
+ "voice.sceneId must reference an existing scene",
7251
+ { voiceId, sceneId: voice.sceneId },
7252
+ );
7253
+ }
7254
+
7255
+ if (sceneItems[voice.sceneId].type === "folder") {
7256
+ return invalidInvariant(
7257
+ "voice.sceneId must reference a non-folder scene",
7258
+ { voiceId, sceneId: voice.sceneId },
7259
+ );
7260
+ }
7261
+
7262
+ {
7263
+ const result = validateFileReference({
7264
+ state,
7265
+ fileId: voice.fileId,
7266
+ path: "voice.fileId",
7267
+ details: { voiceId, fileId: voice.fileId },
7268
+ errorFactory: createInvariantValidationError,
7269
+ });
7270
+ if (!result.valid) {
7271
+ return result;
7272
+ }
7273
+ }
7274
+
7275
+ if (
7276
+ voice.waveformDataFileId !== undefined &&
7277
+ voice.waveformDataFileId !== null
7278
+ ) {
7279
+ const result = validateFileReference({
7280
+ state,
7281
+ fileId: voice.waveformDataFileId,
7282
+ path: "voice.waveformDataFileId",
7283
+ details: { voiceId, waveformDataFileId: voice.waveformDataFileId },
7284
+ errorFactory: createInvariantValidationError,
7285
+ });
7286
+ if (!result.valid) {
7287
+ return result;
7288
+ }
7289
+ }
7290
+ }
7291
+
6812
7292
  for (const [videoId, video] of Object.entries(state.videos.items)) {
6813
7293
  if (video.type !== "video") {
6814
7294
  continue;
@@ -7420,15 +7900,16 @@ export const assertInvariants = ({ state }) => {
7420
7900
  elementId,
7421
7901
  targetId,
7422
7902
  animationName,
7903
+ fieldPrefix = "",
7423
7904
  }) => {
7424
7905
  const spritesheet = state.spritesheets?.items?.[targetId];
7425
7906
  if (!isPlainObject(spritesheet) || spritesheet.type === "folder") {
7426
7907
  return invalidInvariant(
7427
- `${ownerLabel} element resourceId must reference an existing non-folder spritesheet`,
7908
+ `${ownerLabel} element ${fieldPrefix}resourceId must reference an existing non-folder spritesheet`,
7428
7909
  {
7429
7910
  [ownerIdField]: ownerId,
7430
7911
  elementId,
7431
- field: "resourceId",
7912
+ field: `${fieldPrefix}resourceId`,
7432
7913
  targetId,
7433
7914
  },
7434
7915
  );
@@ -7439,11 +7920,11 @@ export const assertInvariants = ({ state }) => {
7439
7920
  !isPlainObject(spritesheet.animations?.[animationName])
7440
7921
  ) {
7441
7922
  return invalidInvariant(
7442
- `${ownerLabel} element animationName must reference an existing spritesheet animation`,
7923
+ `${ownerLabel} element ${fieldPrefix}animationName must reference an existing spritesheet animation`,
7443
7924
  {
7444
7925
  [ownerIdField]: ownerId,
7445
7926
  elementId,
7446
- field: "animationName",
7927
+ field: `${fieldPrefix}animationName`,
7447
7928
  targetId: animationName,
7448
7929
  },
7449
7930
  );
@@ -7542,29 +8023,66 @@ export const assertInvariants = ({ state }) => {
7542
8023
  }
7543
8024
  }
7544
8025
 
7545
- for (const field of ["hoverSoundId", "clickSoundId"]) {
7546
- if (element[field] !== undefined) {
7547
- const result = assertSoundReference({
8026
+ for (const stateName of ["revealing", "complete"]) {
8027
+ const visual = element.indicator?.[stateName];
8028
+ if (
8029
+ visual?.kind === "spritesheet" ||
8030
+ visual?.resourceId !== undefined ||
8031
+ visual?.animationName !== undefined
8032
+ ) {
8033
+ const result = assertSpritesheetAnimationReference({
7548
8034
  ownerIdField,
7549
8035
  ownerId,
7550
8036
  ownerLabel,
7551
8037
  elementId,
7552
- field,
7553
- targetId: element[field],
8038
+ targetId: visual.resourceId,
8039
+ animationName: visual.animationName,
8040
+ fieldPrefix: `indicator.${stateName}.`,
7554
8041
  });
7555
8042
  if (!result.valid) {
7556
8043
  return result;
7557
8044
  }
8045
+ continue;
7558
8046
  }
7559
- }
7560
8047
 
7561
- for (const field of [
7562
- "textStyleId",
7563
- "hoverTextStyleId",
7564
- "clickTextStyleId",
7565
- ]) {
7566
- if (element[field] !== undefined) {
7567
- const result = assertTextStyleReference({
8048
+ if (visual?.imageId !== undefined) {
8049
+ const result = assertImageReference({
8050
+ ownerIdField,
8051
+ ownerId,
8052
+ ownerLabel,
8053
+ elementId,
8054
+ field: `indicator.${stateName}.imageId`,
8055
+ targetId: visual.imageId,
8056
+ });
8057
+ if (!result.valid) {
8058
+ return result;
8059
+ }
8060
+ }
8061
+ }
8062
+
8063
+ for (const field of ["hoverSoundId", "clickSoundId"]) {
8064
+ if (element[field] !== undefined) {
8065
+ const result = assertSoundReference({
8066
+ ownerIdField,
8067
+ ownerId,
8068
+ ownerLabel,
8069
+ elementId,
8070
+ field,
8071
+ targetId: element[field],
8072
+ });
8073
+ if (!result.valid) {
8074
+ return result;
8075
+ }
8076
+ }
8077
+ }
8078
+
8079
+ for (const field of [
8080
+ "textStyleId",
8081
+ "hoverTextStyleId",
8082
+ "clickTextStyleId",
8083
+ ]) {
8084
+ if (element[field] !== undefined) {
8085
+ const result = assertTextStyleReference({
7568
8086
  ownerIdField,
7569
8087
  ownerId,
7570
8088
  ownerLabel,
@@ -7639,6 +8157,23 @@ export const assertInvariants = ({ state }) => {
7639
8157
  }
7640
8158
  }
7641
8159
 
8160
+ for (const {
8161
+ index,
8162
+ resourceId,
8163
+ } of getLayoutTextContentReferenceEntries(element.content)) {
8164
+ if (!isVariableReferenceTarget(state, resourceId)) {
8165
+ return invalidInvariant(
8166
+ `${ownerLabel} element content.${index}.reference.resourceId must reference an existing non-folder variable`,
8167
+ {
8168
+ [ownerIdField]: ownerId,
8169
+ elementId,
8170
+ field: `content.${index}.reference.resourceId`,
8171
+ targetId: resourceId,
8172
+ },
8173
+ );
8174
+ }
8175
+ }
8176
+
7642
8177
  if (element.variableId !== undefined) {
7643
8178
  const result = assertVariableReference({
7644
8179
  ownerIdField,
@@ -8912,6 +9447,167 @@ const validateSoundUpdateData = ({ data, errorFactory }) => {
8912
9447
  }
8913
9448
  };
8914
9449
 
9450
+ const validateVoiceCreateData = ({ data, errorFactory }) => {
9451
+ if (!isPlainObject(data)) {
9452
+ return invalidFromErrorFactory(
9453
+ errorFactory,
9454
+ "payload.data must be an object",
9455
+ );
9456
+ }
9457
+
9458
+ if (data.type !== "folder" && data.type !== "voice") {
9459
+ return invalidFromErrorFactory(
9460
+ errorFactory,
9461
+ "payload.data.type must be 'folder' or 'voice'",
9462
+ );
9463
+ }
9464
+
9465
+ {
9466
+ const result = validateAllowedKeys({
9467
+ value: data,
9468
+ allowedKeys:
9469
+ data.type === "folder"
9470
+ ? ["type", "name", "description"]
9471
+ : [
9472
+ "type",
9473
+ "name",
9474
+ "description",
9475
+ "sceneId",
9476
+ "fileId",
9477
+ "waveformDataFileId",
9478
+ "duration",
9479
+ ],
9480
+ path: "payload.data",
9481
+ errorFactory,
9482
+ });
9483
+ if (result?.valid === false) {
9484
+ return result;
9485
+ }
9486
+ }
9487
+
9488
+ if (!isNonEmptyString(data.name)) {
9489
+ return invalidFromErrorFactory(
9490
+ errorFactory,
9491
+ "payload.data.name must be a non-empty string",
9492
+ );
9493
+ }
9494
+
9495
+ if (data.description !== undefined && !isString(data.description)) {
9496
+ return invalidFromErrorFactory(
9497
+ errorFactory,
9498
+ "payload.data.description must be a string when provided",
9499
+ );
9500
+ }
9501
+
9502
+ if (data.type === "voice") {
9503
+ if (!isNonEmptyString(data.sceneId)) {
9504
+ return invalidFromErrorFactory(
9505
+ errorFactory,
9506
+ "payload.data.sceneId must be a non-empty string",
9507
+ );
9508
+ }
9509
+
9510
+ if (!isNonEmptyString(data.fileId)) {
9511
+ return invalidFromErrorFactory(
9512
+ errorFactory,
9513
+ "payload.data.fileId must be a non-empty string",
9514
+ );
9515
+ }
9516
+
9517
+ if (
9518
+ data.waveformDataFileId !== undefined &&
9519
+ data.waveformDataFileId !== null &&
9520
+ !isNonEmptyString(data.waveformDataFileId)
9521
+ ) {
9522
+ return invalidFromErrorFactory(
9523
+ errorFactory,
9524
+ "payload.data.waveformDataFileId must be a non-empty string or null when provided",
9525
+ );
9526
+ }
9527
+
9528
+ if (data.duration !== undefined && !isFiniteNumber(data.duration)) {
9529
+ return invalidFromErrorFactory(
9530
+ errorFactory,
9531
+ "payload.data.duration must be a finite number",
9532
+ );
9533
+ }
9534
+ }
9535
+ };
9536
+
9537
+ const validateVoiceUpdateData = ({ data, errorFactory }) => {
9538
+ {
9539
+ const result = validateAllowedKeys({
9540
+ value: data,
9541
+ allowedKeys: [
9542
+ "name",
9543
+ "description",
9544
+ "sceneId",
9545
+ "fileId",
9546
+ "waveformDataFileId",
9547
+ "duration",
9548
+ ],
9549
+ path: "payload.data",
9550
+ errorFactory,
9551
+ });
9552
+ if (result?.valid === false) {
9553
+ return result;
9554
+ }
9555
+ }
9556
+
9557
+ if (Object.keys(data).length === 0) {
9558
+ return invalidFromErrorFactory(
9559
+ errorFactory,
9560
+ "payload.data must include at least one updatable field",
9561
+ );
9562
+ }
9563
+
9564
+ if (data.name !== undefined && !isNonEmptyString(data.name)) {
9565
+ return invalidFromErrorFactory(
9566
+ errorFactory,
9567
+ "payload.data.name must be a non-empty string when provided",
9568
+ );
9569
+ }
9570
+
9571
+ if (data.description !== undefined && !isString(data.description)) {
9572
+ return invalidFromErrorFactory(
9573
+ errorFactory,
9574
+ "payload.data.description must be a string when provided",
9575
+ );
9576
+ }
9577
+
9578
+ if (data.sceneId !== undefined && !isNonEmptyString(data.sceneId)) {
9579
+ return invalidFromErrorFactory(
9580
+ errorFactory,
9581
+ "payload.data.sceneId must be a non-empty string when provided",
9582
+ );
9583
+ }
9584
+
9585
+ if (data.fileId !== undefined && !isNonEmptyString(data.fileId)) {
9586
+ return invalidFromErrorFactory(
9587
+ errorFactory,
9588
+ "payload.data.fileId must be a non-empty string when provided",
9589
+ );
9590
+ }
9591
+
9592
+ if (
9593
+ data.waveformDataFileId !== undefined &&
9594
+ data.waveformDataFileId !== null &&
9595
+ !isNonEmptyString(data.waveformDataFileId)
9596
+ ) {
9597
+ return invalidFromErrorFactory(
9598
+ errorFactory,
9599
+ "payload.data.waveformDataFileId must be a non-empty string or null when provided",
9600
+ );
9601
+ }
9602
+
9603
+ if (data.duration !== undefined && !isFiniteNumber(data.duration)) {
9604
+ return invalidFromErrorFactory(
9605
+ errorFactory,
9606
+ "payload.data.duration must be a finite number",
9607
+ );
9608
+ }
9609
+ };
9610
+
8915
9611
  const validateVideoCreateData = ({ data, errorFactory }) => {
8916
9612
  if (!isPlainObject(data)) {
8917
9613
  return invalidFromErrorFactory(
@@ -11383,6 +12079,36 @@ const validateVisualElementReferenceTargets = ({
11383
12079
  );
11384
12080
  }
11385
12081
 
12082
+ if (
12083
+ data.indicator !== undefined &&
12084
+ !LAYOUT_TEXT_REVEAL_INDICATOR_ELEMENT_TYPES.includes(data.type)
12085
+ ) {
12086
+ return invalidFromErrorFactory(
12087
+ errorFactory,
12088
+ `${ownerLabel} element indicator can only be provided for text revealing elements`,
12089
+ {
12090
+ [ownerIdField]: ownerId,
12091
+ elementId,
12092
+ field: "indicator",
12093
+ },
12094
+ );
12095
+ }
12096
+
12097
+ if (
12098
+ data.content !== undefined &&
12099
+ !LAYOUT_TEXT_CONTENT_ELEMENT_TYPES.includes(data.type)
12100
+ ) {
12101
+ return invalidFromErrorFactory(
12102
+ errorFactory,
12103
+ `${ownerLabel} element content can only be provided for text elements`,
12104
+ {
12105
+ [ownerIdField]: ownerId,
12106
+ elementId,
12107
+ field: "content",
12108
+ },
12109
+ );
12110
+ }
12111
+
11386
12112
  if (
11387
12113
  data.type === "spritesheet-animation" ||
11388
12114
  data.resourceId !== undefined ||
@@ -11483,6 +12209,66 @@ const validateVisualElementReferenceTargets = ({
11483
12209
  }
11484
12210
  }
11485
12211
 
12212
+ for (const stateName of ["revealing", "complete"]) {
12213
+ const visual = data.indicator?.[stateName];
12214
+ if (
12215
+ visual?.kind === "spritesheet" ||
12216
+ visual?.resourceId !== undefined ||
12217
+ visual?.animationName !== undefined
12218
+ ) {
12219
+ const spritesheet = state.spritesheets?.items?.[visual.resourceId];
12220
+ if (!isPlainObject(spritesheet) || spritesheet.type === "folder") {
12221
+ return invalidFromErrorFactory(
12222
+ errorFactory,
12223
+ `${ownerLabel} element indicator.${stateName}.resourceId must reference an existing non-folder spritesheet`,
12224
+ {
12225
+ [ownerIdField]: ownerId,
12226
+ elementId,
12227
+ field: `indicator.${stateName}.resourceId`,
12228
+ targetId: visual.resourceId,
12229
+ },
12230
+ );
12231
+ }
12232
+
12233
+ if (
12234
+ !isNonEmptyString(visual.animationName) ||
12235
+ !isPlainObject(spritesheet.animations?.[visual.animationName])
12236
+ ) {
12237
+ return invalidFromErrorFactory(
12238
+ errorFactory,
12239
+ `${ownerLabel} element indicator.${stateName}.animationName must reference an existing spritesheet animation`,
12240
+ {
12241
+ [ownerIdField]: ownerId,
12242
+ elementId,
12243
+ field: `indicator.${stateName}.animationName`,
12244
+ targetId: visual.animationName,
12245
+ },
12246
+ );
12247
+ }
12248
+
12249
+ continue;
12250
+ }
12251
+
12252
+ const imageId = visual?.imageId;
12253
+ if (imageId === undefined) {
12254
+ continue;
12255
+ }
12256
+
12257
+ const image = state.images.items[imageId];
12258
+ if (!isPlainObject(image) || image.type === "folder") {
12259
+ return invalidFromErrorFactory(
12260
+ errorFactory,
12261
+ `${ownerLabel} element indicator.${stateName}.imageId must reference an existing non-folder image`,
12262
+ {
12263
+ [ownerIdField]: ownerId,
12264
+ elementId,
12265
+ field: `indicator.${stateName}.imageId`,
12266
+ targetId: imageId,
12267
+ },
12268
+ );
12269
+ }
12270
+ }
12271
+
11486
12272
  if (data.hoverSoundId !== undefined) {
11487
12273
  const sound = state.sounds.items[data.hoverSoundId];
11488
12274
  if (!isPlainObject(sound) || sound.type === "folder") {
@@ -11710,22 +12496,39 @@ const validateVisualElementReferenceTargets = ({
11710
12496
  }
11711
12497
  }
11712
12498
 
11713
- if (data.variableId !== undefined) {
11714
- if (!isVariableReferenceTarget(state, data.variableId)) {
12499
+ for (const { index, resourceId } of getLayoutTextContentReferenceEntries(
12500
+ data.content,
12501
+ )) {
12502
+ if (!isVariableReferenceTarget(state, resourceId)) {
11715
12503
  return invalidFromErrorFactory(
11716
12504
  errorFactory,
11717
- `${ownerLabel} element variableId must reference an existing non-folder variable`,
12505
+ `${ownerLabel} element content.${index}.reference.resourceId must reference an existing non-folder variable`,
11718
12506
  {
11719
12507
  [ownerIdField]: ownerId,
11720
12508
  elementId,
11721
- variableId: data.variableId,
12509
+ field: `content.${index}.reference.resourceId`,
12510
+ targetId: resourceId,
11722
12511
  },
11723
12512
  );
11724
12513
  }
11725
12514
  }
11726
12515
 
11727
- if (data.fragmentLayoutId !== undefined) {
11728
- const fragmentLayout = state.layouts.items[data.fragmentLayoutId];
12516
+ if (data.variableId !== undefined) {
12517
+ if (!isVariableReferenceTarget(state, data.variableId)) {
12518
+ return invalidFromErrorFactory(
12519
+ errorFactory,
12520
+ `${ownerLabel} element variableId must reference an existing non-folder variable`,
12521
+ {
12522
+ [ownerIdField]: ownerId,
12523
+ elementId,
12524
+ variableId: data.variableId,
12525
+ },
12526
+ );
12527
+ }
12528
+ }
12529
+
12530
+ if (data.fragmentLayoutId !== undefined) {
12531
+ const fragmentLayout = state.layouts.items[data.fragmentLayoutId];
11729
12532
  if (
11730
12533
  !isPlainObject(fragmentLayout) ||
11731
12534
  fragmentLayout.type !== "layout" ||
@@ -12385,6 +13188,28 @@ const findReferencedFileUsage = ({ state, fileId }) => {
12385
13188
  }
12386
13189
  }
12387
13190
 
13191
+ for (const [voiceId, voice] of Object.entries(state.voices.items)) {
13192
+ if (voice.type !== "voice") {
13193
+ continue;
13194
+ }
13195
+
13196
+ if (voice.fileId === fileId) {
13197
+ return {
13198
+ kind: "voice",
13199
+ field: "fileId",
13200
+ ownerId: voiceId,
13201
+ };
13202
+ }
13203
+
13204
+ if (voice.waveformDataFileId === fileId) {
13205
+ return {
13206
+ kind: "voice",
13207
+ field: "waveformDataFileId",
13208
+ ownerId: voiceId,
13209
+ };
13210
+ }
13211
+ }
13212
+
12388
13213
  for (const [videoId, video] of Object.entries(state.videos.items)) {
12389
13214
  if (video.type !== "video") {
12390
13215
  continue;
@@ -12775,31 +13600,425 @@ const COMMAND_DEFINITIONS = [
12775
13600
  return;
12776
13601
  }
12777
13602
 
12778
- const scene = state.scenes.items[initialSceneId];
12779
- if (!isPlainObject(scene)) {
12780
- return invalidPrecondition(
12781
- "payload.data.initialSceneId must reference an existing scene",
12782
- );
12783
- }
13603
+ const scene = state.scenes.items[initialSceneId];
13604
+ if (!isPlainObject(scene)) {
13605
+ return invalidPrecondition(
13606
+ "payload.data.initialSceneId must reference an existing scene",
13607
+ );
13608
+ }
13609
+
13610
+ if (scene.type === "folder") {
13611
+ return invalidPrecondition(
13612
+ "payload.data.initialSceneId must reference a non-folder scene",
13613
+ );
13614
+ }
13615
+ },
13616
+ reduce: ({ state, payload }) => {
13617
+ state.story.initialSceneId = payload.data.initialSceneId;
13618
+ return state;
13619
+ },
13620
+ },
13621
+ {
13622
+ type: "scene.create",
13623
+ validatePayload: ({ payload }) => {
13624
+ {
13625
+ const result = validateAllowedKeys({
13626
+ value: payload,
13627
+ allowedKeys: [
13628
+ "sceneId",
13629
+ "parentId",
13630
+ "data",
13631
+ "index",
13632
+ "position",
13633
+ "positionTargetId",
13634
+ ],
13635
+ path: "payload",
13636
+ errorFactory: createPayloadValidationError,
13637
+ });
13638
+ if (result?.valid === false) {
13639
+ return result;
13640
+ }
13641
+ }
13642
+
13643
+ if (!isNonEmptyString(payload.sceneId)) {
13644
+ return invalidPayload("payload.sceneId must be a non-empty string");
13645
+ }
13646
+
13647
+ if (
13648
+ payload.parentId !== undefined &&
13649
+ payload.parentId !== null &&
13650
+ !isNonEmptyString(payload.parentId)
13651
+ ) {
13652
+ return invalidPayload(
13653
+ "payload.parentId must be a non-empty string when provided",
13654
+ );
13655
+ }
13656
+
13657
+ {
13658
+ const result = validateSceneCreateData({
13659
+ data: payload.data,
13660
+ errorFactory: createPayloadValidationError,
13661
+ });
13662
+ if (result?.valid === false) {
13663
+ return result;
13664
+ }
13665
+ }
13666
+
13667
+ {
13668
+ const result = validatePlacementFields({
13669
+ payload,
13670
+ errorFactory: createPayloadValidationError,
13671
+ });
13672
+ if (result?.valid === false) {
13673
+ return result;
13674
+ }
13675
+ }
13676
+ },
13677
+ validateAgainstState: ({ state, payload }) => {
13678
+ if (Object.hasOwn(state.scenes.items, payload.sceneId)) {
13679
+ return invalidPrecondition("payload.sceneId must not already exist");
13680
+ }
13681
+
13682
+ const parentId = payload.parentId ?? null;
13683
+
13684
+ if (parentId !== null) {
13685
+ const parentScene = state.scenes.items[parentId];
13686
+ if (!isPlainObject(parentScene)) {
13687
+ return invalidPrecondition(
13688
+ "payload.parentId must reference an existing scene",
13689
+ );
13690
+ }
13691
+
13692
+ if (parentScene.type !== "folder") {
13693
+ return invalidPrecondition(
13694
+ "payload.parentId must reference a folder scene",
13695
+ );
13696
+ }
13697
+ }
13698
+
13699
+ if (payload.positionTargetId !== undefined) {
13700
+ if (!isPlainObject(state.scenes.items[payload.positionTargetId])) {
13701
+ return invalidPrecondition(
13702
+ "payload.positionTargetId must reference an existing scene",
13703
+ );
13704
+ }
13705
+
13706
+ const targetParentId =
13707
+ findTreeParentId({
13708
+ nodes: state.scenes.tree,
13709
+ nodeId: payload.positionTargetId,
13710
+ }) ?? null;
13711
+
13712
+ if (targetParentId !== parentId) {
13713
+ return invalidPrecondition(
13714
+ "payload.positionTargetId must reference a sibling under payload.parentId",
13715
+ );
13716
+ }
13717
+ }
13718
+ },
13719
+ reduce: ({ state, payload }) => {
13720
+ const nextScene = {
13721
+ id: payload.sceneId,
13722
+ type: payload.data.type ?? "scene",
13723
+ name: payload.data.name,
13724
+ };
13725
+
13726
+ if (payload.data.description !== undefined) {
13727
+ nextScene.description = payload.data.description;
13728
+ }
13729
+
13730
+ if (nextScene.type === "scene") {
13731
+ nextScene.sections = createEmptyNestedCollection();
13732
+ }
13733
+
13734
+ if (payload.data.position !== undefined) {
13735
+ nextScene.position = structuredClone(payload.data.position);
13736
+ }
13737
+
13738
+ state.scenes.items[payload.sceneId] = nextScene;
13739
+
13740
+ insertTreeNode({
13741
+ tree: state.scenes.tree,
13742
+ node: {
13743
+ id: payload.sceneId,
13744
+ children: [],
13745
+ },
13746
+ parentId: payload.parentId ?? null,
13747
+ index: payload.index,
13748
+ position: payload.position,
13749
+ positionTargetId: payload.positionTargetId,
13750
+ });
13751
+
13752
+ return state;
13753
+ },
13754
+ },
13755
+ {
13756
+ type: "scene.update",
13757
+ validatePayload: ({ payload }) => {
13758
+ {
13759
+ const result = validateExactKeys({
13760
+ value: payload,
13761
+ expectedKeys: ["sceneId", "data"],
13762
+ path: "payload",
13763
+ errorFactory: createPayloadValidationError,
13764
+ });
13765
+ if (result?.valid === false) {
13766
+ return result;
13767
+ }
13768
+ }
13769
+
13770
+ if (!isNonEmptyString(payload.sceneId)) {
13771
+ return invalidPayload("payload.sceneId must be a non-empty string");
13772
+ }
13773
+
13774
+ {
13775
+ const result = validateSceneUpdateData({
13776
+ data: payload.data,
13777
+ errorFactory: createPayloadValidationError,
13778
+ });
13779
+ if (result?.valid === false) {
13780
+ return result;
13781
+ }
13782
+ }
13783
+ },
13784
+ validateAgainstState: ({ state, payload }) => {
13785
+ if (!isPlainObject(state.scenes.items[payload.sceneId])) {
13786
+ return invalidPrecondition(
13787
+ "payload.sceneId must reference an existing scene",
13788
+ );
13789
+ }
13790
+ },
13791
+ reduce: ({ state, payload }) => {
13792
+ const currentScene = state.scenes.items[payload.sceneId];
13793
+ const nextScene = structuredClone(currentScene);
13794
+
13795
+ if (payload.data.name !== undefined) {
13796
+ nextScene.name = payload.data.name;
13797
+ }
13798
+
13799
+ if (payload.data.description !== undefined) {
13800
+ nextScene.description = payload.data.description;
13801
+ }
13802
+
13803
+ if (payload.data.position !== undefined) {
13804
+ nextScene.position = {
13805
+ ...(isPlainObject(nextScene.position) ? nextScene.position : {}),
13806
+ ...structuredClone(payload.data.position),
13807
+ };
13808
+ }
13809
+
13810
+ state.scenes.items[payload.sceneId] = nextScene;
13811
+ return state;
13812
+ },
13813
+ },
13814
+ {
13815
+ type: "scene.delete",
13816
+ validatePayload: ({ payload }) => {
13817
+ {
13818
+ const result = validateExactKeys({
13819
+ value: payload,
13820
+ expectedKeys: ["sceneIds"],
13821
+ path: "payload",
13822
+ errorFactory: createPayloadValidationError,
13823
+ });
13824
+ if (result?.valid === false) {
13825
+ return result;
13826
+ }
13827
+ }
13828
+
13829
+ {
13830
+ const result = validateRequiredUniqueIdArray({
13831
+ value: payload.sceneIds,
13832
+ path: "payload.sceneIds",
13833
+ errorFactory: createPayloadValidationError,
13834
+ });
13835
+ if (result?.valid === false) {
13836
+ return result;
13837
+ }
13838
+ }
13839
+ },
13840
+ validateAgainstState: ({ state, payload }) => {
13841
+ for (const sceneId of payload.sceneIds) {
13842
+ if (!isPlainObject(state.scenes.items[sceneId])) {
13843
+ return invalidPrecondition(
13844
+ "payload.sceneIds must reference existing scenes",
13845
+ { sceneId },
13846
+ );
13847
+ }
13848
+ }
13849
+ },
13850
+ reduce: ({ state, payload }) => {
13851
+ const deletedSceneIds = new Set();
13852
+
13853
+ for (const sceneId of payload.sceneIds) {
13854
+ const removedNode = removeTreeNode({
13855
+ nodes: state.scenes.tree,
13856
+ nodeId: sceneId,
13857
+ });
13858
+
13859
+ if (!removedNode) {
13860
+ continue;
13861
+ }
13862
+
13863
+ for (const id of collectTreeDescendantIds({ node: removedNode })) {
13864
+ deletedSceneIds.add(id);
13865
+ }
13866
+ }
13867
+
13868
+ for (const sceneId of deletedSceneIds) {
13869
+ delete state.scenes.items[sceneId];
13870
+ }
13871
+
13872
+ if (
13873
+ state.story.initialSceneId !== null &&
13874
+ !isPlainObject(state.scenes.items[state.story.initialSceneId])
13875
+ ) {
13876
+ state.story.initialSceneId = findFirstNonFolderSceneId({ state });
13877
+ }
13878
+
13879
+ return state;
13880
+ },
13881
+ },
13882
+ {
13883
+ type: "scene.move",
13884
+ validatePayload: ({ payload }) => {
13885
+ {
13886
+ const result = validateAllowedKeys({
13887
+ value: payload,
13888
+ allowedKeys: [
13889
+ "sceneId",
13890
+ "parentId",
13891
+ "index",
13892
+ "position",
13893
+ "positionTargetId",
13894
+ ],
13895
+ path: "payload",
13896
+ errorFactory: createPayloadValidationError,
13897
+ });
13898
+ if (result?.valid === false) {
13899
+ return result;
13900
+ }
13901
+ }
13902
+
13903
+ if (!isNonEmptyString(payload.sceneId)) {
13904
+ return invalidPayload("payload.sceneId must be a non-empty string");
13905
+ }
13906
+
13907
+ if (
13908
+ payload.parentId !== undefined &&
13909
+ payload.parentId !== null &&
13910
+ !isNonEmptyString(payload.parentId)
13911
+ ) {
13912
+ return invalidPayload(
13913
+ "payload.parentId must be a non-empty string when provided",
13914
+ );
13915
+ }
13916
+
13917
+ {
13918
+ const result = validatePlacementFields({
13919
+ payload,
13920
+ errorFactory: createPayloadValidationError,
13921
+ });
13922
+ if (result?.valid === false) {
13923
+ return result;
13924
+ }
13925
+ }
13926
+ },
13927
+ validateAgainstState: ({ state, payload }) => {
13928
+ const scene = state.scenes.items[payload.sceneId];
13929
+ if (!isPlainObject(scene)) {
13930
+ return invalidPrecondition(
13931
+ "payload.sceneId must reference an existing scene",
13932
+ );
13933
+ }
13934
+
13935
+ const sceneNode = findTreeNode({
13936
+ nodes: state.scenes.tree,
13937
+ nodeId: payload.sceneId,
13938
+ });
13939
+
13940
+ if (payload.parentId !== undefined && payload.parentId !== null) {
13941
+ const parentScene = state.scenes.items[payload.parentId];
13942
+ if (!isPlainObject(parentScene)) {
13943
+ return invalidPrecondition(
13944
+ "payload.parentId must reference an existing scene",
13945
+ );
13946
+ }
13947
+
13948
+ if (parentScene.type !== "folder") {
13949
+ return invalidPrecondition(
13950
+ "payload.parentId must reference a folder scene",
13951
+ );
13952
+ }
13953
+
13954
+ const descendantIds = new Set(
13955
+ collectTreeDescendantIds({
13956
+ node: sceneNode,
13957
+ }),
13958
+ );
13959
+
13960
+ if (descendantIds.has(payload.parentId)) {
13961
+ return invalidPrecondition(
13962
+ "payload.parentId must not target the moved scene or its descendants",
13963
+ );
13964
+ }
13965
+ }
13966
+
13967
+ if (payload.positionTargetId !== undefined) {
13968
+ if (payload.positionTargetId === payload.sceneId) {
13969
+ return invalidPrecondition(
13970
+ "payload.positionTargetId must not reference the moved scene",
13971
+ );
13972
+ }
13973
+
13974
+ if (!isPlainObject(state.scenes.items[payload.positionTargetId])) {
13975
+ return invalidPrecondition(
13976
+ "payload.positionTargetId must reference an existing scene",
13977
+ );
13978
+ }
13979
+
13980
+ const targetParentId = getNodeParentId({
13981
+ tree: state.scenes.tree,
13982
+ nodeId: payload.positionTargetId,
13983
+ });
12784
13984
 
12785
- if (scene.type === "folder") {
12786
- return invalidPrecondition(
12787
- "payload.data.initialSceneId must reference a non-folder scene",
12788
- );
13985
+ if (targetParentId !== (payload.parentId ?? null)) {
13986
+ return invalidPrecondition(
13987
+ "payload.positionTargetId must reference a sibling under payload.parentId",
13988
+ );
13989
+ }
12789
13990
  }
12790
13991
  },
12791
13992
  reduce: ({ state, payload }) => {
12792
- state.story.initialSceneId = payload.data.initialSceneId;
13993
+ const sceneNodeResult = removeNodeOrResult({
13994
+ tree: state.scenes.tree,
13995
+ nodeId: payload.sceneId,
13996
+ errorMessage: "scene move target missing from tree",
13997
+ });
13998
+ if (!sceneNodeResult.valid) {
13999
+ return sceneNodeResult;
14000
+ }
14001
+
14002
+ insertTreeNode({
14003
+ tree: state.scenes.tree,
14004
+ node: sceneNodeResult.node,
14005
+ parentId: payload.parentId ?? null,
14006
+ index: payload.index,
14007
+ position: payload.position,
14008
+ positionTargetId: payload.positionTargetId,
14009
+ });
14010
+
12793
14011
  return state;
12794
14012
  },
12795
14013
  },
12796
14014
  {
12797
- type: "scene.create",
14015
+ type: "section.create",
12798
14016
  validatePayload: ({ payload }) => {
12799
14017
  {
12800
14018
  const result = validateAllowedKeys({
12801
14019
  value: payload,
12802
14020
  allowedKeys: [
14021
+ "sectionId",
12803
14022
  "sceneId",
12804
14023
  "parentId",
12805
14024
  "data",
@@ -12815,6 +14034,10 @@ const COMMAND_DEFINITIONS = [
12815
14034
  }
12816
14035
  }
12817
14036
 
14037
+ if (!isNonEmptyString(payload.sectionId)) {
14038
+ return invalidPayload("payload.sectionId must be a non-empty string");
14039
+ }
14040
+
12818
14041
  if (!isNonEmptyString(payload.sceneId)) {
12819
14042
  return invalidPayload("payload.sceneId must be a non-empty string");
12820
14043
  }
@@ -12830,7 +14053,7 @@ const COMMAND_DEFINITIONS = [
12830
14053
  }
12831
14054
 
12832
14055
  {
12833
- const result = validateSceneCreateData({
14056
+ const result = validateSectionCreateData({
12834
14057
  data: payload.data,
12835
14058
  errorFactory: createPayloadValidationError,
12836
14059
  });
@@ -12850,41 +14073,65 @@ const COMMAND_DEFINITIONS = [
12850
14073
  }
12851
14074
  },
12852
14075
  validateAgainstState: ({ state, payload }) => {
12853
- if (Object.hasOwn(state.scenes.items, payload.sceneId)) {
12854
- return invalidPrecondition("payload.sceneId must not already exist");
14076
+ const scene = state.scenes.items[payload.sceneId];
14077
+ if (!isPlainObject(scene)) {
14078
+ return invalidPrecondition(
14079
+ "payload.sceneId must reference an existing scene",
14080
+ );
12855
14081
  }
12856
14082
 
12857
- const parentId = payload.parentId ?? null;
14083
+ if (scene.type === "folder") {
14084
+ return invalidPrecondition(
14085
+ "payload.sceneId must reference a non-folder scene",
14086
+ );
14087
+ }
12858
14088
 
12859
- if (parentId !== null) {
12860
- const parentScene = state.scenes.items[parentId];
12861
- if (!isPlainObject(parentScene)) {
14089
+ if (findSectionLocation({ state, sectionId: payload.sectionId })) {
14090
+ return invalidPrecondition("payload.sectionId must not already exist");
14091
+ }
14092
+
14093
+ if (payload.parentId !== undefined && payload.parentId !== null) {
14094
+ const parentLocation = findSectionLocation({
14095
+ state,
14096
+ sectionId: payload.parentId,
14097
+ });
14098
+ if (!parentLocation) {
12862
14099
  return invalidPrecondition(
12863
- "payload.parentId must reference an existing scene",
14100
+ "payload.parentId must reference an existing section",
12864
14101
  );
12865
14102
  }
12866
14103
 
12867
- if (parentScene.type !== "folder") {
14104
+ if (parentLocation.sceneId !== payload.sceneId) {
12868
14105
  return invalidPrecondition(
12869
- "payload.parentId must reference a folder scene",
14106
+ "payload.parentId must reference a section in the same scene",
12870
14107
  );
12871
14108
  }
12872
14109
  }
12873
14110
 
12874
14111
  if (payload.positionTargetId !== undefined) {
12875
- if (!isPlainObject(state.scenes.items[payload.positionTargetId])) {
14112
+ const targetLocation = findSectionLocation({
14113
+ state,
14114
+ sectionId: payload.positionTargetId,
14115
+ });
14116
+ if (!targetLocation) {
12876
14117
  return invalidPrecondition(
12877
- "payload.positionTargetId must reference an existing scene",
14118
+ "payload.positionTargetId must reference an existing section",
12878
14119
  );
12879
14120
  }
12880
14121
 
12881
- const targetParentId =
12882
- findTreeParentId({
12883
- nodes: state.scenes.tree,
12884
- nodeId: payload.positionTargetId,
12885
- }) ?? null;
14122
+ if (targetLocation.sceneId !== payload.sceneId) {
14123
+ return invalidPrecondition(
14124
+ "payload.positionTargetId must reference a section in the same scene",
14125
+ );
14126
+ }
12886
14127
 
12887
- if (targetParentId !== parentId) {
14128
+ const sections = scene.sections ?? createEmptyNestedCollection();
14129
+ const targetParentId = getNodeParentId({
14130
+ tree: sections.tree,
14131
+ nodeId: payload.positionTargetId,
14132
+ });
14133
+
14134
+ if (targetParentId !== (payload.parentId ?? null)) {
12888
14135
  return invalidPrecondition(
12889
14136
  "payload.positionTargetId must reference a sibling under payload.parentId",
12890
14137
  );
@@ -12892,30 +14139,19 @@ const COMMAND_DEFINITIONS = [
12892
14139
  }
12893
14140
  },
12894
14141
  reduce: ({ state, payload }) => {
12895
- const nextScene = {
12896
- id: payload.sceneId,
12897
- type: payload.data.type ?? "scene",
14142
+ const scene = state.scenes.items[payload.sceneId];
14143
+ scene.sections ??= createEmptyNestedCollection();
14144
+ const sections = scene.sections;
14145
+ sections.items[payload.sectionId] = {
14146
+ id: payload.sectionId,
12898
14147
  name: payload.data.name,
14148
+ lines: createEmptyNestedCollection(),
12899
14149
  };
12900
14150
 
12901
- if (payload.data.description !== undefined) {
12902
- nextScene.description = payload.data.description;
12903
- }
12904
-
12905
- if (nextScene.type === "scene") {
12906
- nextScene.sections = createEmptyNestedCollection();
12907
- }
12908
-
12909
- if (payload.data.position !== undefined) {
12910
- nextScene.position = structuredClone(payload.data.position);
12911
- }
12912
-
12913
- state.scenes.items[payload.sceneId] = nextScene;
12914
-
12915
14151
  insertTreeNode({
12916
- tree: state.scenes.tree,
14152
+ tree: sections.tree,
12917
14153
  node: {
12918
- id: payload.sceneId,
14154
+ id: payload.sectionId,
12919
14155
  children: [],
12920
14156
  },
12921
14157
  parentId: payload.parentId ?? null,
@@ -12928,12 +14164,12 @@ const COMMAND_DEFINITIONS = [
12928
14164
  },
12929
14165
  },
12930
14166
  {
12931
- type: "scene.update",
14167
+ type: "section.update",
12932
14168
  validatePayload: ({ payload }) => {
12933
14169
  {
12934
14170
  const result = validateExactKeys({
12935
14171
  value: payload,
12936
- expectedKeys: ["sceneId", "data"],
14172
+ expectedKeys: ["sectionId", "data"],
12937
14173
  path: "payload",
12938
14174
  errorFactory: createPayloadValidationError,
12939
14175
  });
@@ -12942,12 +14178,12 @@ const COMMAND_DEFINITIONS = [
12942
14178
  }
12943
14179
  }
12944
14180
 
12945
- if (!isNonEmptyString(payload.sceneId)) {
12946
- return invalidPayload("payload.sceneId must be a non-empty string");
14181
+ if (!isNonEmptyString(payload.sectionId)) {
14182
+ return invalidPayload("payload.sectionId must be a non-empty string");
12947
14183
  }
12948
14184
 
12949
14185
  {
12950
- const result = validateSceneUpdateData({
14186
+ const result = validateSectionUpdateData({
12951
14187
  data: payload.data,
12952
14188
  errorFactory: createPayloadValidationError,
12953
14189
  });
@@ -12957,42 +14193,32 @@ const COMMAND_DEFINITIONS = [
12957
14193
  }
12958
14194
  },
12959
14195
  validateAgainstState: ({ state, payload }) => {
12960
- if (!isPlainObject(state.scenes.items[payload.sceneId])) {
14196
+ if (!findSectionLocation({ state, sectionId: payload.sectionId })) {
12961
14197
  return invalidPrecondition(
12962
- "payload.sceneId must reference an existing scene",
14198
+ "payload.sectionId must reference an existing section",
12963
14199
  );
12964
14200
  }
12965
14201
  },
12966
14202
  reduce: ({ state, payload }) => {
12967
- const currentScene = state.scenes.items[payload.sceneId];
12968
- const nextScene = structuredClone(currentScene);
12969
-
12970
- if (payload.data.name !== undefined) {
12971
- nextScene.name = payload.data.name;
12972
- }
12973
-
12974
- if (payload.data.description !== undefined) {
12975
- nextScene.description = payload.data.description;
12976
- }
12977
-
12978
- if (payload.data.position !== undefined) {
12979
- nextScene.position = {
12980
- ...(isPlainObject(nextScene.position) ? nextScene.position : {}),
12981
- ...structuredClone(payload.data.position),
12982
- };
12983
- }
12984
-
12985
- state.scenes.items[payload.sceneId] = nextScene;
14203
+ const location = findSectionLocation({
14204
+ state,
14205
+ sectionId: payload.sectionId,
14206
+ });
14207
+ const section = location.section;
14208
+ location.sections.items[payload.sectionId] = {
14209
+ ...section,
14210
+ name: payload.data.name,
14211
+ };
12986
14212
  return state;
12987
14213
  },
12988
14214
  },
12989
14215
  {
12990
- type: "scene.delete",
14216
+ type: "section.delete",
12991
14217
  validatePayload: ({ payload }) => {
12992
14218
  {
12993
14219
  const result = validateExactKeys({
12994
14220
  value: payload,
12995
- expectedKeys: ["sceneIds"],
14221
+ expectedKeys: ["sectionIds"],
12996
14222
  path: "payload",
12997
14223
  errorFactory: createPayloadValidationError,
12998
14224
  });
@@ -13003,8 +14229,8 @@ const COMMAND_DEFINITIONS = [
13003
14229
 
13004
14230
  {
13005
14231
  const result = validateRequiredUniqueIdArray({
13006
- value: payload.sceneIds,
13007
- path: "payload.sceneIds",
14232
+ value: payload.sectionIds,
14233
+ path: "payload.sectionIds",
13008
14234
  errorFactory: createPayloadValidationError,
13009
14235
  });
13010
14236
  if (result?.valid === false) {
@@ -13013,54 +14239,66 @@ const COMMAND_DEFINITIONS = [
13013
14239
  }
13014
14240
  },
13015
14241
  validateAgainstState: ({ state, payload }) => {
13016
- for (const sceneId of payload.sceneIds) {
13017
- if (!isPlainObject(state.scenes.items[sceneId])) {
14242
+ for (const sectionId of payload.sectionIds) {
14243
+ if (!findSectionLocation({ state, sectionId })) {
13018
14244
  return invalidPrecondition(
13019
- "payload.sceneIds must reference existing scenes",
13020
- { sceneId },
14245
+ "payload.sectionIds must reference existing sections",
14246
+ { sectionId },
13021
14247
  );
13022
14248
  }
13023
14249
  }
13024
14250
  },
13025
14251
  reduce: ({ state, payload }) => {
13026
- const deletedSceneIds = new Set();
14252
+ const deletedSectionIdsByScene = new Map();
14253
+
14254
+ for (const sectionId of payload.sectionIds) {
14255
+ const location = findSectionLocation({ state, sectionId });
14256
+ if (!location) {
14257
+ continue;
14258
+ }
13027
14259
 
13028
- for (const sceneId of payload.sceneIds) {
13029
14260
  const removedNode = removeTreeNode({
13030
- nodes: state.scenes.tree,
13031
- nodeId: sceneId,
14261
+ nodes: location.sections.tree,
14262
+ nodeId: sectionId,
13032
14263
  });
13033
14264
 
13034
14265
  if (!removedNode) {
13035
14266
  continue;
13036
14267
  }
13037
14268
 
14269
+ let deletedSectionIds = deletedSectionIdsByScene.get(location.sceneId);
14270
+ if (!deletedSectionIds) {
14271
+ deletedSectionIds = new Set();
14272
+ deletedSectionIdsByScene.set(location.sceneId, deletedSectionIds);
14273
+ }
14274
+
13038
14275
  for (const id of collectTreeDescendantIds({ node: removedNode })) {
13039
- deletedSceneIds.add(id);
14276
+ deletedSectionIds.add(id);
13040
14277
  }
13041
14278
  }
13042
14279
 
13043
- for (const sceneId of deletedSceneIds) {
13044
- delete state.scenes.items[sceneId];
13045
- }
14280
+ for (const [sceneId, deletedSectionIds] of deletedSectionIdsByScene) {
14281
+ const sections = state.scenes.items[sceneId]?.sections;
14282
+ if (!sections) {
14283
+ continue;
14284
+ }
13046
14285
 
13047
- if (
13048
- state.story.initialSceneId !== null &&
13049
- !isPlainObject(state.scenes.items[state.story.initialSceneId])
13050
- ) {
13051
- state.story.initialSceneId = findFirstNonFolderSceneId({ state });
14286
+ for (const sectionId of deletedSectionIds) {
14287
+ delete sections.items[sectionId];
14288
+ }
13052
14289
  }
13053
14290
 
13054
14291
  return state;
13055
14292
  },
13056
14293
  },
13057
14294
  {
13058
- type: "scene.move",
14295
+ type: "section.move",
13059
14296
  validatePayload: ({ payload }) => {
13060
14297
  {
13061
14298
  const result = validateAllowedKeys({
13062
14299
  value: payload,
13063
14300
  allowedKeys: [
14301
+ "sectionId",
13064
14302
  "sceneId",
13065
14303
  "parentId",
13066
14304
  "index",
@@ -13075,8 +14313,14 @@ const COMMAND_DEFINITIONS = [
13075
14313
  }
13076
14314
  }
13077
14315
 
13078
- if (!isNonEmptyString(payload.sceneId)) {
13079
- return invalidPayload("payload.sceneId must be a non-empty string");
14316
+ if (!isNonEmptyString(payload.sectionId)) {
14317
+ return invalidPayload("payload.sectionId must be a non-empty string");
14318
+ }
14319
+
14320
+ if (payload.sceneId !== undefined && !isNonEmptyString(payload.sceneId)) {
14321
+ return invalidPayload(
14322
+ "payload.sceneId must be a non-empty string when provided",
14323
+ );
13080
14324
  }
13081
14325
 
13082
14326
  if (
@@ -13100,60 +14344,108 @@ const COMMAND_DEFINITIONS = [
13100
14344
  }
13101
14345
  },
13102
14346
  validateAgainstState: ({ state, payload }) => {
13103
- const scene = state.scenes.items[payload.sceneId];
13104
- if (!isPlainObject(scene)) {
14347
+ const location = findSectionLocation({
14348
+ state,
14349
+ sectionId: payload.sectionId,
14350
+ });
14351
+ if (!location) {
14352
+ return invalidPrecondition(
14353
+ "payload.sectionId must reference an existing section",
14354
+ );
14355
+ }
14356
+
14357
+ const targetSceneId = payload.sceneId ?? location.sceneId;
14358
+ const targetScene = state.scenes.items[targetSceneId];
14359
+ if (!isPlainObject(targetScene)) {
13105
14360
  return invalidPrecondition(
13106
14361
  "payload.sceneId must reference an existing scene",
13107
14362
  );
13108
14363
  }
13109
14364
 
13110
- const sceneNode = findTreeNode({
13111
- nodes: state.scenes.tree,
13112
- nodeId: payload.sceneId,
14365
+ if (targetScene.type !== "scene") {
14366
+ return invalidPrecondition(
14367
+ "payload.sceneId must reference a non-folder scene",
14368
+ );
14369
+ }
14370
+
14371
+ const targetSections =
14372
+ targetScene.sections ?? createEmptyNestedCollection();
14373
+ const sectionNode = findTreeNode({
14374
+ nodes: location.sections.tree,
14375
+ nodeId: payload.sectionId,
13113
14376
  });
14377
+ const isCrossSceneMove = targetSceneId !== location.sceneId;
14378
+
14379
+ if (isCrossSceneMove) {
14380
+ const sourceSectionCount = Object.keys(
14381
+ location.sections?.items ?? {},
14382
+ ).length;
14383
+ const movedSectionCount = collectTreeDescendantIds({
14384
+ node: sectionNode,
14385
+ }).length;
14386
+
14387
+ if (sourceSectionCount <= movedSectionCount) {
14388
+ return invalidPrecondition(
14389
+ "payload.sectionId must not move the last section out of a scene",
14390
+ );
14391
+ }
14392
+ }
13114
14393
 
13115
14394
  if (payload.parentId !== undefined && payload.parentId !== null) {
13116
- const parentScene = state.scenes.items[payload.parentId];
13117
- if (!isPlainObject(parentScene)) {
14395
+ const parentLocation = findSectionLocation({
14396
+ state,
14397
+ sectionId: payload.parentId,
14398
+ });
14399
+ if (!parentLocation) {
13118
14400
  return invalidPrecondition(
13119
- "payload.parentId must reference an existing scene",
14401
+ "payload.parentId must reference an existing section",
13120
14402
  );
13121
14403
  }
13122
14404
 
13123
- if (parentScene.type !== "folder") {
14405
+ if (parentLocation.sceneId !== targetSceneId) {
13124
14406
  return invalidPrecondition(
13125
- "payload.parentId must reference a folder scene",
14407
+ "payload.parentId must reference a section in the target scene",
13126
14408
  );
13127
14409
  }
13128
14410
 
13129
14411
  const descendantIds = new Set(
13130
14412
  collectTreeDescendantIds({
13131
- node: sceneNode,
14413
+ node: sectionNode,
13132
14414
  }),
13133
14415
  );
13134
14416
 
13135
14417
  if (descendantIds.has(payload.parentId)) {
13136
14418
  return invalidPrecondition(
13137
- "payload.parentId must not target the moved scene or its descendants",
14419
+ "payload.parentId must not target the moved section or its descendants",
13138
14420
  );
13139
14421
  }
13140
14422
  }
13141
14423
 
13142
14424
  if (payload.positionTargetId !== undefined) {
13143
- if (payload.positionTargetId === payload.sceneId) {
14425
+ if (payload.positionTargetId === payload.sectionId) {
13144
14426
  return invalidPrecondition(
13145
- "payload.positionTargetId must not reference the moved scene",
14427
+ "payload.positionTargetId must not reference the moved section",
13146
14428
  );
13147
14429
  }
13148
14430
 
13149
- if (!isPlainObject(state.scenes.items[payload.positionTargetId])) {
14431
+ const targetLocation = findSectionLocation({
14432
+ state,
14433
+ sectionId: payload.positionTargetId,
14434
+ });
14435
+ if (!targetLocation) {
13150
14436
  return invalidPrecondition(
13151
- "payload.positionTargetId must reference an existing scene",
14437
+ "payload.positionTargetId must reference an existing section",
14438
+ );
14439
+ }
14440
+
14441
+ if (targetLocation.sceneId !== targetSceneId) {
14442
+ return invalidPrecondition(
14443
+ "payload.positionTargetId must reference a section in the target scene",
13152
14444
  );
13153
14445
  }
13154
14446
 
13155
14447
  const targetParentId = getNodeParentId({
13156
- tree: state.scenes.tree,
14448
+ tree: targetSections.tree,
13157
14449
  nodeId: payload.positionTargetId,
13158
14450
  });
13159
14451
 
@@ -13165,18 +14457,38 @@ const COMMAND_DEFINITIONS = [
13165
14457
  }
13166
14458
  },
13167
14459
  reduce: ({ state, payload }) => {
13168
- const sceneNodeResult = removeNodeOrResult({
13169
- tree: state.scenes.tree,
13170
- nodeId: payload.sceneId,
13171
- errorMessage: "scene move target missing from tree",
14460
+ const location = findSectionLocation({
14461
+ state,
14462
+ sectionId: payload.sectionId,
13172
14463
  });
13173
- if (!sceneNodeResult.valid) {
13174
- return sceneNodeResult;
14464
+ const targetSceneId = payload.sceneId ?? location.sceneId;
14465
+ const targetScene = state.scenes.items[targetSceneId];
14466
+ targetScene.sections ??= createEmptyNestedCollection();
14467
+ const targetSections = targetScene.sections;
14468
+ const sectionNodeResult = removeNodeOrResult({
14469
+ tree: location.sections.tree,
14470
+ nodeId: payload.sectionId,
14471
+ errorMessage: "section move target missing from tree",
14472
+ });
14473
+ if (!sectionNodeResult.valid) {
14474
+ return sectionNodeResult;
14475
+ }
14476
+
14477
+ if (targetSections !== location.sections) {
14478
+ const movedSectionIds = collectTreeDescendantIds({
14479
+ node: sectionNodeResult.node,
14480
+ });
14481
+
14482
+ for (const movedSectionId of movedSectionIds) {
14483
+ targetSections.items[movedSectionId] =
14484
+ location.sections.items[movedSectionId];
14485
+ delete location.sections.items[movedSectionId];
14486
+ }
13175
14487
  }
13176
14488
 
13177
14489
  insertTreeNode({
13178
- tree: state.scenes.tree,
13179
- node: sceneNodeResult.node,
14490
+ tree: targetSections.tree,
14491
+ node: sectionNodeResult.node,
13180
14492
  parentId: payload.parentId ?? null,
13181
14493
  index: payload.index,
13182
14494
  position: payload.position,
@@ -13187,16 +14499,14 @@ const COMMAND_DEFINITIONS = [
13187
14499
  },
13188
14500
  },
13189
14501
  {
13190
- type: "section.create",
14502
+ type: "line.create",
13191
14503
  validatePayload: ({ payload }) => {
13192
14504
  {
13193
14505
  const result = validateAllowedKeys({
13194
14506
  value: payload,
13195
14507
  allowedKeys: [
13196
14508
  "sectionId",
13197
- "sceneId",
13198
- "parentId",
13199
- "data",
14509
+ "lines",
13200
14510
  "index",
13201
14511
  "position",
13202
14512
  "positionTargetId",
@@ -13213,23 +14523,9 @@ const COMMAND_DEFINITIONS = [
13213
14523
  return invalidPayload("payload.sectionId must be a non-empty string");
13214
14524
  }
13215
14525
 
13216
- if (!isNonEmptyString(payload.sceneId)) {
13217
- return invalidPayload("payload.sceneId must be a non-empty string");
13218
- }
13219
-
13220
- if (
13221
- payload.parentId !== undefined &&
13222
- payload.parentId !== null &&
13223
- !isNonEmptyString(payload.parentId)
13224
- ) {
13225
- return invalidPayload(
13226
- "payload.parentId must be a non-empty string when provided",
13227
- );
13228
- }
13229
-
13230
14526
  {
13231
- const result = validateSectionCreateData({
13232
- data: payload.data,
14527
+ const result = validateLineCreatePayload({
14528
+ payload,
13233
14529
  errorFactory: createPayloadValidationError,
13234
14530
  });
13235
14531
  if (result?.valid === false) {
@@ -13248,103 +14544,82 @@ const COMMAND_DEFINITIONS = [
13248
14544
  }
13249
14545
  },
13250
14546
  validateAgainstState: ({ state, payload }) => {
13251
- const scene = state.scenes.items[payload.sceneId];
13252
- if (!isPlainObject(scene)) {
13253
- return invalidPrecondition(
13254
- "payload.sceneId must reference an existing scene",
13255
- );
13256
- }
13257
-
13258
- if (scene.type === "folder") {
14547
+ const sectionLocation = findSectionLocation({
14548
+ state,
14549
+ sectionId: payload.sectionId,
14550
+ });
14551
+ if (!sectionLocation) {
13259
14552
  return invalidPrecondition(
13260
- "payload.sceneId must reference a non-folder scene",
14553
+ "payload.sectionId must reference an existing section",
13261
14554
  );
13262
14555
  }
13263
14556
 
13264
- if (findSectionLocation({ state, sectionId: payload.sectionId })) {
13265
- return invalidPrecondition("payload.sectionId must not already exist");
13266
- }
13267
-
13268
- if (payload.parentId !== undefined && payload.parentId !== null) {
13269
- const parentLocation = findSectionLocation({
13270
- state,
13271
- sectionId: payload.parentId,
13272
- });
13273
- if (!parentLocation) {
13274
- return invalidPrecondition(
13275
- "payload.parentId must reference an existing section",
13276
- );
13277
- }
13278
-
13279
- if (parentLocation.sceneId !== payload.sceneId) {
14557
+ for (const item of payload.lines) {
14558
+ if (findLineLocation({ state, lineId: item.lineId })) {
13280
14559
  return invalidPrecondition(
13281
- "payload.parentId must reference a section in the same scene",
14560
+ "payload.lines.lineId must not already exist",
14561
+ { lineId: item.lineId },
13282
14562
  );
13283
14563
  }
13284
14564
  }
13285
14565
 
13286
14566
  if (payload.positionTargetId !== undefined) {
13287
- const targetLocation = findSectionLocation({
14567
+ const targetLocation = findLineLocation({
13288
14568
  state,
13289
- sectionId: payload.positionTargetId,
14569
+ lineId: payload.positionTargetId,
13290
14570
  });
13291
14571
  if (!targetLocation) {
13292
14572
  return invalidPrecondition(
13293
- "payload.positionTargetId must reference an existing section",
13294
- );
13295
- }
13296
-
13297
- if (targetLocation.sceneId !== payload.sceneId) {
13298
- return invalidPrecondition(
13299
- "payload.positionTargetId must reference a section in the same scene",
14573
+ "payload.positionTargetId must reference an existing line",
13300
14574
  );
13301
14575
  }
13302
14576
 
13303
- const sections = scene.sections ?? createEmptyNestedCollection();
13304
- const targetParentId = getNodeParentId({
13305
- tree: sections.tree,
13306
- nodeId: payload.positionTargetId,
13307
- });
13308
-
13309
- if (targetParentId !== (payload.parentId ?? null)) {
14577
+ if (targetLocation.sectionId !== payload.sectionId) {
13310
14578
  return invalidPrecondition(
13311
- "payload.positionTargetId must reference a sibling under payload.parentId",
14579
+ "payload.positionTargetId must reference a line in the target section",
13312
14580
  );
13313
14581
  }
13314
14582
  }
13315
14583
  },
13316
14584
  reduce: ({ state, payload }) => {
13317
- const scene = state.scenes.items[payload.sceneId];
13318
- scene.sections ??= createEmptyNestedCollection();
13319
- const sections = scene.sections;
13320
- sections.items[payload.sectionId] = {
13321
- id: payload.sectionId,
13322
- name: payload.data.name,
13323
- lines: createEmptyNestedCollection(),
13324
- };
14585
+ const sectionLocation = findSectionLocation({
14586
+ state,
14587
+ sectionId: payload.sectionId,
14588
+ });
14589
+ sectionLocation.section.lines ??= createEmptyNestedCollection();
14590
+ const lines = sectionLocation.section.lines;
14591
+ let previousLineId = payload.positionTargetId;
13325
14592
 
13326
- insertTreeNode({
13327
- tree: sections.tree,
13328
- node: {
13329
- id: payload.sectionId,
13330
- children: [],
13331
- },
13332
- parentId: payload.parentId ?? null,
13333
- index: payload.index,
13334
- position: payload.position,
13335
- positionTargetId: payload.positionTargetId,
14593
+ payload.lines.forEach((item, index) => {
14594
+ lines.items[item.lineId] = {
14595
+ id: item.lineId,
14596
+ actions: structuredClone(item.data.actions || {}),
14597
+ };
14598
+
14599
+ insertTreeNode({
14600
+ tree: lines.tree,
14601
+ node: { id: item.lineId },
14602
+ index: Number.isInteger(payload.index)
14603
+ ? payload.index + index
14604
+ : undefined,
14605
+ position: index === 0 ? payload.position : "after",
14606
+ positionTargetId:
14607
+ index === 0 ? payload.positionTargetId : previousLineId,
14608
+ });
14609
+
14610
+ previousLineId = item.lineId;
13336
14611
  });
13337
14612
 
13338
14613
  return state;
13339
14614
  },
13340
14615
  },
13341
14616
  {
13342
- type: "section.update",
14617
+ type: "line.update_actions",
13343
14618
  validatePayload: ({ payload }) => {
13344
14619
  {
13345
- const result = validateExactKeys({
14620
+ const result = validateAllowedKeys({
13346
14621
  value: payload,
13347
- expectedKeys: ["sectionId", "data"],
14622
+ allowedKeys: ["lineId", "data", "replace", "preserve"],
13348
14623
  path: "payload",
13349
14624
  errorFactory: createPayloadValidationError,
13350
14625
  });
@@ -13353,13 +14628,34 @@ const COMMAND_DEFINITIONS = [
13353
14628
  }
13354
14629
  }
13355
14630
 
13356
- if (!isNonEmptyString(payload.sectionId)) {
13357
- return invalidPayload("payload.sectionId must be a non-empty string");
14631
+ if (!isNonEmptyString(payload.lineId)) {
14632
+ return invalidPayload("payload.lineId must be a non-empty string");
14633
+ }
14634
+
14635
+ {
14636
+ const result = validateLineUpdateActionsData({
14637
+ data: payload.data,
14638
+ errorFactory: createPayloadValidationError,
14639
+ });
14640
+ if (result?.valid === false) {
14641
+ return result;
14642
+ }
14643
+ }
14644
+
14645
+ if (
14646
+ payload.replace !== undefined &&
14647
+ typeof payload.replace !== "boolean"
14648
+ ) {
14649
+ return invalidPayload(
14650
+ "payload.replace must be a boolean when provided",
14651
+ );
13358
14652
  }
13359
14653
 
13360
14654
  {
13361
- const result = validateSectionUpdateData({
14655
+ const result = validateLineUpdateActionsPreserve({
14656
+ preserve: payload.preserve,
13362
14657
  data: payload.data,
14658
+ replace: payload.replace,
13363
14659
  errorFactory: createPayloadValidationError,
13364
14660
  });
13365
14661
  if (result?.valid === false) {
@@ -13368,32 +14664,37 @@ const COMMAND_DEFINITIONS = [
13368
14664
  }
13369
14665
  },
13370
14666
  validateAgainstState: ({ state, payload }) => {
13371
- if (!findSectionLocation({ state, sectionId: payload.sectionId })) {
14667
+ if (!findLineLocation({ state, lineId: payload.lineId })) {
13372
14668
  return invalidPrecondition(
13373
- "payload.sectionId must reference an existing section",
14669
+ "payload.lineId must reference an existing line",
13374
14670
  );
13375
14671
  }
13376
14672
  },
13377
14673
  reduce: ({ state, payload }) => {
13378
- const location = findSectionLocation({
13379
- state,
13380
- sectionId: payload.sectionId,
14674
+ const location = findLineLocation({ state, lineId: payload.lineId });
14675
+ const line = location.line;
14676
+ const nextData = applyLineUpdateActionsPreserve({
14677
+ currentActions: line.actions,
14678
+ data: payload.data,
14679
+ preserve: payload.preserve,
13381
14680
  });
13382
- const section = location.section;
13383
- location.sections.items[payload.sectionId] = {
13384
- ...section,
13385
- name: payload.data.name,
13386
- };
14681
+ line.actions =
14682
+ payload.replace === true
14683
+ ? structuredClone(nextData)
14684
+ : {
14685
+ ...structuredClone(line.actions),
14686
+ ...structuredClone(nextData),
14687
+ };
13387
14688
  return state;
13388
14689
  },
13389
14690
  },
13390
14691
  {
13391
- type: "section.delete",
14692
+ type: "line.delete",
13392
14693
  validatePayload: ({ payload }) => {
13393
14694
  {
13394
14695
  const result = validateExactKeys({
13395
14696
  value: payload,
13396
- expectedKeys: ["sectionIds"],
14697
+ expectedKeys: ["lineIds"],
13397
14698
  path: "payload",
13398
14699
  errorFactory: createPayloadValidationError,
13399
14700
  });
@@ -13404,8 +14705,8 @@ const COMMAND_DEFINITIONS = [
13404
14705
 
13405
14706
  {
13406
14707
  const result = validateRequiredUniqueIdArray({
13407
- value: payload.sectionIds,
13408
- path: "payload.sectionIds",
14708
+ value: payload.lineIds,
14709
+ path: "payload.lineIds",
13409
14710
  errorFactory: createPayloadValidationError,
13410
14711
  });
13411
14712
  if (result?.valid === false) {
@@ -13414,68 +14715,38 @@ const COMMAND_DEFINITIONS = [
13414
14715
  }
13415
14716
  },
13416
14717
  validateAgainstState: ({ state, payload }) => {
13417
- for (const sectionId of payload.sectionIds) {
13418
- if (!findSectionLocation({ state, sectionId })) {
14718
+ for (const lineId of payload.lineIds) {
14719
+ if (!findLineLocation({ state, lineId })) {
13419
14720
  return invalidPrecondition(
13420
- "payload.sectionIds must reference existing sections",
13421
- { sectionId },
14721
+ "payload.lineIds must reference existing lines",
14722
+ { lineId },
13422
14723
  );
13423
14724
  }
13424
14725
  }
13425
14726
  },
13426
14727
  reduce: ({ state, payload }) => {
13427
- const deletedSectionIdsByScene = new Map();
13428
-
13429
- for (const sectionId of payload.sectionIds) {
13430
- const location = findSectionLocation({ state, sectionId });
13431
- if (!location) {
13432
- continue;
13433
- }
13434
-
13435
- const removedNode = removeTreeNode({
13436
- nodes: location.sections.tree,
13437
- nodeId: sectionId,
14728
+ for (const lineId of payload.lineIds) {
14729
+ const location = findLineLocation({ state, lineId });
14730
+ location.section.lines ??= createEmptyNestedCollection();
14731
+ delete location.section.lines.items[lineId];
14732
+ removeTreeNode({
14733
+ nodes: location.section.lines.tree,
14734
+ nodeId: lineId,
13438
14735
  });
13439
-
13440
- if (!removedNode) {
13441
- continue;
13442
- }
13443
-
13444
- let deletedSectionIds = deletedSectionIdsByScene.get(location.sceneId);
13445
- if (!deletedSectionIds) {
13446
- deletedSectionIds = new Set();
13447
- deletedSectionIdsByScene.set(location.sceneId, deletedSectionIds);
13448
- }
13449
-
13450
- for (const id of collectTreeDescendantIds({ node: removedNode })) {
13451
- deletedSectionIds.add(id);
13452
- }
13453
- }
13454
-
13455
- for (const [sceneId, deletedSectionIds] of deletedSectionIdsByScene) {
13456
- const sections = state.scenes.items[sceneId]?.sections;
13457
- if (!sections) {
13458
- continue;
13459
- }
13460
-
13461
- for (const sectionId of deletedSectionIds) {
13462
- delete sections.items[sectionId];
13463
- }
13464
14736
  }
13465
14737
 
13466
14738
  return state;
13467
14739
  },
13468
14740
  },
13469
14741
  {
13470
- type: "section.move",
14742
+ type: "line.move",
13471
14743
  validatePayload: ({ payload }) => {
13472
14744
  {
13473
14745
  const result = validateAllowedKeys({
13474
14746
  value: payload,
13475
14747
  allowedKeys: [
13476
- "sectionId",
13477
- "sceneId",
13478
- "parentId",
14748
+ "lineId",
14749
+ "toSectionId",
13479
14750
  "index",
13480
14751
  "position",
13481
14752
  "positionTargetId",
@@ -13488,24 +14759,12 @@ const COMMAND_DEFINITIONS = [
13488
14759
  }
13489
14760
  }
13490
14761
 
13491
- if (!isNonEmptyString(payload.sectionId)) {
13492
- return invalidPayload("payload.sectionId must be a non-empty string");
13493
- }
13494
-
13495
- if (payload.sceneId !== undefined && !isNonEmptyString(payload.sceneId)) {
13496
- return invalidPayload(
13497
- "payload.sceneId must be a non-empty string when provided",
13498
- );
14762
+ if (!isNonEmptyString(payload.lineId)) {
14763
+ return invalidPayload("payload.lineId must be a non-empty string");
13499
14764
  }
13500
14765
 
13501
- if (
13502
- payload.parentId !== undefined &&
13503
- payload.parentId !== null &&
13504
- !isNonEmptyString(payload.parentId)
13505
- ) {
13506
- return invalidPayload(
13507
- "payload.parentId must be a non-empty string when provided",
13508
- );
14766
+ if (!isNonEmptyString(payload.toSectionId)) {
14767
+ return invalidPayload("payload.toSectionId must be a non-empty string");
13509
14768
  }
13510
14769
 
13511
14770
  {
@@ -13519,152 +14778,78 @@ const COMMAND_DEFINITIONS = [
13519
14778
  }
13520
14779
  },
13521
14780
  validateAgainstState: ({ state, payload }) => {
13522
- const location = findSectionLocation({
14781
+ const lineLocation = findLineLocation({
13523
14782
  state,
13524
- sectionId: payload.sectionId,
14783
+ lineId: payload.lineId,
13525
14784
  });
13526
- if (!location) {
13527
- return invalidPrecondition(
13528
- "payload.sectionId must reference an existing section",
13529
- );
13530
- }
13531
-
13532
- const targetSceneId = payload.sceneId ?? location.sceneId;
13533
- const targetScene = state.scenes.items[targetSceneId];
13534
- if (!isPlainObject(targetScene)) {
13535
- return invalidPrecondition(
13536
- "payload.sceneId must reference an existing scene",
13537
- );
13538
- }
13539
-
13540
- if (targetScene.type !== "scene") {
14785
+ if (!lineLocation) {
13541
14786
  return invalidPrecondition(
13542
- "payload.sceneId must reference a non-folder scene",
14787
+ "payload.lineId must reference an existing line",
13543
14788
  );
13544
14789
  }
13545
14790
 
13546
- const targetSections =
13547
- targetScene.sections ?? createEmptyNestedCollection();
13548
- const sectionNode = findTreeNode({
13549
- nodes: location.sections.tree,
13550
- nodeId: payload.sectionId,
14791
+ const targetSectionLocation = findSectionLocation({
14792
+ state,
14793
+ sectionId: payload.toSectionId,
13551
14794
  });
13552
- const isCrossSceneMove = targetSceneId !== location.sceneId;
13553
-
13554
- if (isCrossSceneMove) {
13555
- const sourceSectionCount = Object.keys(
13556
- location.sections?.items ?? {},
13557
- ).length;
13558
- const movedSectionCount = collectTreeDescendantIds({
13559
- node: sectionNode,
13560
- }).length;
13561
-
13562
- if (sourceSectionCount <= movedSectionCount) {
13563
- return invalidPrecondition(
13564
- "payload.sectionId must not move the last section out of a scene",
13565
- );
13566
- }
13567
- }
13568
-
13569
- if (payload.parentId !== undefined && payload.parentId !== null) {
13570
- const parentLocation = findSectionLocation({
13571
- state,
13572
- sectionId: payload.parentId,
13573
- });
13574
- if (!parentLocation) {
13575
- return invalidPrecondition(
13576
- "payload.parentId must reference an existing section",
13577
- );
13578
- }
13579
-
13580
- if (parentLocation.sceneId !== targetSceneId) {
13581
- return invalidPrecondition(
13582
- "payload.parentId must reference a section in the target scene",
13583
- );
13584
- }
13585
-
13586
- const descendantIds = new Set(
13587
- collectTreeDescendantIds({
13588
- node: sectionNode,
13589
- }),
14795
+ if (!targetSectionLocation) {
14796
+ return invalidPrecondition(
14797
+ "payload.toSectionId must reference an existing section",
13590
14798
  );
13591
-
13592
- if (descendantIds.has(payload.parentId)) {
13593
- return invalidPrecondition(
13594
- "payload.parentId must not target the moved section or its descendants",
13595
- );
13596
- }
13597
14799
  }
13598
14800
 
13599
14801
  if (payload.positionTargetId !== undefined) {
13600
- if (payload.positionTargetId === payload.sectionId) {
14802
+ if (payload.positionTargetId === payload.lineId) {
13601
14803
  return invalidPrecondition(
13602
- "payload.positionTargetId must not reference the moved section",
14804
+ "payload.positionTargetId must not reference the moved line",
13603
14805
  );
13604
14806
  }
13605
14807
 
13606
- const targetLocation = findSectionLocation({
14808
+ const targetLocation = findLineLocation({
13607
14809
  state,
13608
- sectionId: payload.positionTargetId,
14810
+ lineId: payload.positionTargetId,
13609
14811
  });
13610
14812
  if (!targetLocation) {
13611
14813
  return invalidPrecondition(
13612
- "payload.positionTargetId must reference an existing section",
13613
- );
13614
- }
13615
-
13616
- if (targetLocation.sceneId !== targetSceneId) {
13617
- return invalidPrecondition(
13618
- "payload.positionTargetId must reference a section in the target scene",
14814
+ "payload.positionTargetId must reference an existing line",
13619
14815
  );
13620
14816
  }
13621
14817
 
13622
- const targetParentId = getNodeParentId({
13623
- tree: targetSections.tree,
13624
- nodeId: payload.positionTargetId,
13625
- });
13626
-
13627
- if (targetParentId !== (payload.parentId ?? null)) {
14818
+ if (targetLocation.sectionId !== payload.toSectionId) {
13628
14819
  return invalidPrecondition(
13629
- "payload.positionTargetId must reference a sibling under payload.parentId",
14820
+ "payload.positionTargetId must reference a line in the target section",
13630
14821
  );
13631
14822
  }
13632
14823
  }
13633
14824
  },
13634
14825
  reduce: ({ state, payload }) => {
13635
- const location = findSectionLocation({
14826
+ const lineLocation = findLineLocation({
13636
14827
  state,
13637
- sectionId: payload.sectionId,
14828
+ lineId: payload.lineId,
13638
14829
  });
13639
- const targetSceneId = payload.sceneId ?? location.sceneId;
13640
- const targetScene = state.scenes.items[targetSceneId];
13641
- targetScene.sections ??= createEmptyNestedCollection();
13642
- const targetSections = targetScene.sections;
13643
- const sectionNodeResult = removeNodeOrResult({
13644
- tree: location.sections.tree,
13645
- nodeId: payload.sectionId,
13646
- errorMessage: "section move target missing from tree",
14830
+ lineLocation.section.lines ??= createEmptyNestedCollection();
14831
+ const lineNodeResult = removeNodeOrResult({
14832
+ tree: lineLocation.section.lines.tree,
14833
+ nodeId: payload.lineId,
14834
+ errorMessage: "line move target missing from tree",
13647
14835
  });
13648
- if (!sectionNodeResult.valid) {
13649
- return sectionNodeResult;
13650
- }
13651
-
13652
- if (targetSections !== location.sections) {
13653
- const movedSectionIds = collectTreeDescendantIds({
13654
- node: sectionNodeResult.node,
13655
- });
13656
-
13657
- for (const movedSectionId of movedSectionIds) {
13658
- targetSections.items[movedSectionId] =
13659
- location.sections.items[movedSectionId];
13660
- delete location.sections.items[movedSectionId];
13661
- }
14836
+ if (!lineNodeResult.valid) {
14837
+ return lineNodeResult;
13662
14838
  }
14839
+ const lineValue = lineLocation.line;
14840
+
14841
+ delete lineLocation.section.lines.items[payload.lineId];
14842
+
14843
+ const targetSectionLocation = findSectionLocation({
14844
+ state,
14845
+ sectionId: payload.toSectionId,
14846
+ });
14847
+ targetSectionLocation.section.lines ??= createEmptyNestedCollection();
14848
+ targetSectionLocation.section.lines.items[payload.lineId] = lineValue;
13663
14849
 
13664
14850
  insertTreeNode({
13665
- tree: targetSections.tree,
13666
- node: sectionNodeResult.node,
13667
- parentId: payload.parentId ?? null,
14851
+ tree: targetSectionLocation.section.lines.tree,
14852
+ node: lineNodeResult.node,
13668
14853
  index: payload.index,
13669
14854
  position: payload.position,
13670
14855
  positionTargetId: payload.positionTargetId,
@@ -13674,14 +14859,15 @@ const COMMAND_DEFINITIONS = [
13674
14859
  },
13675
14860
  },
13676
14861
  {
13677
- type: "line.create",
14862
+ type: "image.create",
13678
14863
  validatePayload: ({ payload }) => {
13679
14864
  {
13680
14865
  const result = validateAllowedKeys({
13681
14866
  value: payload,
13682
14867
  allowedKeys: [
13683
- "sectionId",
13684
- "lines",
14868
+ "imageId",
14869
+ "parentId",
14870
+ "data",
13685
14871
  "index",
13686
14872
  "position",
13687
14873
  "positionTargetId",
@@ -13694,13 +14880,23 @@ const COMMAND_DEFINITIONS = [
13694
14880
  }
13695
14881
  }
13696
14882
 
13697
- if (!isNonEmptyString(payload.sectionId)) {
13698
- return invalidPayload("payload.sectionId must be a non-empty string");
14883
+ if (!isNonEmptyString(payload.imageId)) {
14884
+ return invalidPayload("payload.imageId must be a non-empty string");
14885
+ }
14886
+
14887
+ if (
14888
+ payload.parentId !== undefined &&
14889
+ payload.parentId !== null &&
14890
+ !isNonEmptyString(payload.parentId)
14891
+ ) {
14892
+ return invalidPayload(
14893
+ "payload.parentId must be a non-empty string when provided",
14894
+ );
13699
14895
  }
13700
14896
 
13701
14897
  {
13702
- const result = validateLineCreatePayload({
13703
- payload,
14898
+ const result = validateImageCreateData({
14899
+ data: payload.data,
13704
14900
  errorFactory: createPayloadValidationError,
13705
14901
  });
13706
14902
  if (result?.valid === false) {
@@ -13719,82 +14915,122 @@ const COMMAND_DEFINITIONS = [
13719
14915
  }
13720
14916
  },
13721
14917
  validateAgainstState: ({ state, payload }) => {
13722
- const sectionLocation = findSectionLocation({
13723
- state,
13724
- sectionId: payload.sectionId,
13725
- });
13726
- if (!sectionLocation) {
13727
- return invalidPrecondition(
13728
- "payload.sectionId must reference an existing section",
13729
- );
14918
+ if (isPlainObject(state.images.items[payload.imageId])) {
14919
+ return invalidPrecondition("payload.imageId must not already exist");
13730
14920
  }
13731
14921
 
13732
- for (const item of payload.lines) {
13733
- if (findLineLocation({ state, lineId: item.lineId })) {
14922
+ const parentId = payload.parentId ?? null;
14923
+ if (parentId !== null) {
14924
+ const parentImage = state.images.items[parentId];
14925
+ if (!isPlainObject(parentImage)) {
13734
14926
  return invalidPrecondition(
13735
- "payload.lines.lineId must not already exist",
13736
- { lineId: item.lineId },
14927
+ "payload.parentId must reference an existing image item",
14928
+ );
14929
+ }
14930
+
14931
+ if (parentImage.type !== "folder") {
14932
+ return invalidPrecondition(
14933
+ "payload.parentId must reference a folder image item",
13737
14934
  );
13738
14935
  }
13739
14936
  }
13740
14937
 
13741
14938
  if (payload.positionTargetId !== undefined) {
13742
- const targetLocation = findLineLocation({
13743
- state,
13744
- lineId: payload.positionTargetId,
13745
- });
13746
- if (!targetLocation) {
14939
+ if (!isPlainObject(state.images.items[payload.positionTargetId])) {
13747
14940
  return invalidPrecondition(
13748
- "payload.positionTargetId must reference an existing line",
14941
+ "payload.positionTargetId must reference an existing image item",
13749
14942
  );
13750
14943
  }
13751
14944
 
13752
- if (targetLocation.sectionId !== payload.sectionId) {
14945
+ const targetParentId = getNodeParentId({
14946
+ tree: state.images.tree,
14947
+ nodeId: payload.positionTargetId,
14948
+ });
14949
+
14950
+ if (targetParentId !== parentId) {
13753
14951
  return invalidPrecondition(
13754
- "payload.positionTargetId must reference a line in the target section",
14952
+ "payload.positionTargetId must reference a sibling under payload.parentId",
13755
14953
  );
13756
14954
  }
13757
14955
  }
14956
+
14957
+ if (payload.data.type === "image") {
14958
+ const result = validateReferencedFilesInData({
14959
+ state,
14960
+ data: payload.data,
14961
+ fields: ["fileId", "thumbnailFileId"],
14962
+ details: {
14963
+ imageId: payload.imageId,
14964
+ },
14965
+ });
14966
+ if (!result.valid) {
14967
+ return result;
14968
+ }
14969
+
14970
+ return validateTagIdsAgainstScope({
14971
+ state,
14972
+ tagIds: payload.data.tagIds,
14973
+ scopeKey: "images",
14974
+ path: "payload.data.tagIds",
14975
+ details: {
14976
+ imageId: payload.imageId,
14977
+ },
14978
+ });
14979
+ }
13758
14980
  },
13759
14981
  reduce: ({ state, payload }) => {
13760
- const sectionLocation = findSectionLocation({
13761
- state,
13762
- sectionId: payload.sectionId,
13763
- });
13764
- sectionLocation.section.lines ??= createEmptyNestedCollection();
13765
- const lines = sectionLocation.section.lines;
13766
- let previousLineId = payload.positionTargetId;
14982
+ const nextImage = {
14983
+ id: payload.imageId,
14984
+ type: payload.data.type,
14985
+ name: payload.data.name,
14986
+ };
13767
14987
 
13768
- payload.lines.forEach((item, index) => {
13769
- lines.items[item.lineId] = {
13770
- id: item.lineId,
13771
- actions: structuredClone(item.data.actions || {}),
13772
- };
14988
+ if (payload.data.description !== undefined) {
14989
+ nextImage.description = payload.data.description;
14990
+ }
13773
14991
 
13774
- insertTreeNode({
13775
- tree: lines.tree,
13776
- node: { id: item.lineId },
13777
- index: Number.isInteger(payload.index)
13778
- ? payload.index + index
13779
- : undefined,
13780
- position: index === 0 ? payload.position : "after",
13781
- positionTargetId:
13782
- index === 0 ? payload.positionTargetId : previousLineId,
14992
+ if (payload.data.type === "image") {
14993
+ nextImage.fileId = payload.data.fileId;
14994
+ if (payload.data.thumbnailFileId !== undefined) {
14995
+ nextImage.thumbnailFileId = payload.data.thumbnailFileId;
14996
+ }
14997
+ if (payload.data.width !== undefined) {
14998
+ nextImage.width = payload.data.width;
14999
+ }
15000
+ if (payload.data.height !== undefined) {
15001
+ nextImage.height = payload.data.height;
15002
+ }
15003
+
15004
+ assignOptionalTagIds({
15005
+ target: nextImage,
15006
+ tagIds: payload.data.tagIds,
13783
15007
  });
15008
+ }
13784
15009
 
13785
- previousLineId = item.lineId;
15010
+ state.images.items[payload.imageId] = nextImage;
15011
+
15012
+ insertTreeNode({
15013
+ tree: state.images.tree,
15014
+ node: {
15015
+ id: payload.imageId,
15016
+ children: [],
15017
+ },
15018
+ parentId: payload.parentId ?? null,
15019
+ index: payload.index,
15020
+ position: payload.position,
15021
+ positionTargetId: payload.positionTargetId,
13786
15022
  });
13787
15023
 
13788
15024
  return state;
13789
15025
  },
13790
15026
  },
13791
15027
  {
13792
- type: "line.update_actions",
15028
+ type: "image.update",
13793
15029
  validatePayload: ({ payload }) => {
13794
15030
  {
13795
- const result = validateAllowedKeys({
15031
+ const result = validateExactKeys({
13796
15032
  value: payload,
13797
- allowedKeys: ["lineId", "data", "replace", "preserve"],
15033
+ expectedKeys: ["imageId", "data"],
13798
15034
  path: "payload",
13799
15035
  errorFactory: createPayloadValidationError,
13800
15036
  });
@@ -13803,12 +15039,12 @@ const COMMAND_DEFINITIONS = [
13803
15039
  }
13804
15040
  }
13805
15041
 
13806
- if (!isNonEmptyString(payload.lineId)) {
13807
- return invalidPayload("payload.lineId must be a non-empty string");
15042
+ if (!isNonEmptyString(payload.imageId)) {
15043
+ return invalidPayload("payload.imageId must be a non-empty string");
13808
15044
  }
13809
15045
 
13810
15046
  {
13811
- const result = validateLineUpdateActionsData({
15047
+ const result = validateImageUpdateData({
13812
15048
  data: payload.data,
13813
15049
  errorFactory: createPayloadValidationError,
13814
15050
  });
@@ -13816,60 +15052,68 @@ const COMMAND_DEFINITIONS = [
13816
15052
  return result;
13817
15053
  }
13818
15054
  }
15055
+ },
15056
+ validateAgainstState: ({ state, payload }) => {
15057
+ const currentImage = state.images.items[payload.imageId];
15058
+ if (!isPlainObject(currentImage)) {
15059
+ return invalidPrecondition(
15060
+ "payload.imageId must reference an existing image item",
15061
+ );
15062
+ }
13819
15063
 
13820
15064
  if (
13821
- payload.replace !== undefined &&
13822
- typeof payload.replace !== "boolean"
15065
+ currentImage.type === "folder" &&
15066
+ (payload.data.fileId !== undefined ||
15067
+ payload.data.thumbnailFileId !== undefined ||
15068
+ payload.data.width !== undefined ||
15069
+ payload.data.height !== undefined ||
15070
+ payload.data.tagIds !== undefined)
13823
15071
  ) {
13824
- return invalidPayload(
13825
- "payload.replace must be a boolean when provided",
15072
+ return invalidPrecondition(
15073
+ "folder image items cannot update file fields",
13826
15074
  );
13827
15075
  }
13828
15076
 
13829
- {
13830
- const result = validateLineUpdateActionsPreserve({
13831
- preserve: payload.preserve,
15077
+ if (currentImage.type === "image") {
15078
+ const result = validateReferencedFilesInData({
15079
+ state,
13832
15080
  data: payload.data,
13833
- replace: payload.replace,
13834
- errorFactory: createPayloadValidationError,
15081
+ fields: ["fileId", "thumbnailFileId"],
15082
+ details: {
15083
+ imageId: payload.imageId,
15084
+ },
13835
15085
  });
13836
- if (result?.valid === false) {
15086
+ if (!result.valid) {
13837
15087
  return result;
13838
15088
  }
13839
- }
13840
- },
13841
- validateAgainstState: ({ state, payload }) => {
13842
- if (!findLineLocation({ state, lineId: payload.lineId })) {
13843
- return invalidPrecondition(
13844
- "payload.lineId must reference an existing line",
13845
- );
15089
+
15090
+ return validateTagIdsAgainstScope({
15091
+ state,
15092
+ tagIds: payload.data.tagIds,
15093
+ scopeKey: "images",
15094
+ path: "payload.data.tagIds",
15095
+ details: {
15096
+ imageId: payload.imageId,
15097
+ },
15098
+ });
13846
15099
  }
13847
15100
  },
13848
15101
  reduce: ({ state, payload }) => {
13849
- const location = findLineLocation({ state, lineId: payload.lineId });
13850
- const line = location.line;
13851
- const nextData = applyLineUpdateActionsPreserve({
13852
- currentActions: line.actions,
15102
+ const currentImage = state.images.items[payload.imageId];
15103
+ state.images.items[payload.imageId] = applyTagIdsUpdate({
15104
+ currentItem: currentImage,
13853
15105
  data: payload.data,
13854
- preserve: payload.preserve,
13855
15106
  });
13856
- line.actions =
13857
- payload.replace === true
13858
- ? structuredClone(nextData)
13859
- : {
13860
- ...structuredClone(line.actions),
13861
- ...structuredClone(nextData),
13862
- };
13863
15107
  return state;
13864
15108
  },
13865
15109
  },
13866
15110
  {
13867
- type: "line.delete",
15111
+ type: "image.delete",
13868
15112
  validatePayload: ({ payload }) => {
13869
15113
  {
13870
15114
  const result = validateExactKeys({
13871
15115
  value: payload,
13872
- expectedKeys: ["lineIds"],
15116
+ expectedKeys: ["imageIds"],
13873
15117
  path: "payload",
13874
15118
  errorFactory: createPayloadValidationError,
13875
15119
  });
@@ -13880,8 +15124,8 @@ const COMMAND_DEFINITIONS = [
13880
15124
 
13881
15125
  {
13882
15126
  const result = validateRequiredUniqueIdArray({
13883
- value: payload.lineIds,
13884
- path: "payload.lineIds",
15127
+ value: payload.imageIds,
15128
+ path: "payload.imageIds",
13885
15129
  errorFactory: createPayloadValidationError,
13886
15130
  });
13887
15131
  if (result?.valid === false) {
@@ -13890,38 +15134,49 @@ const COMMAND_DEFINITIONS = [
13890
15134
  }
13891
15135
  },
13892
15136
  validateAgainstState: ({ state, payload }) => {
13893
- for (const lineId of payload.lineIds) {
13894
- if (!findLineLocation({ state, lineId })) {
15137
+ for (const imageId of payload.imageIds) {
15138
+ if (!isPlainObject(state.images.items[imageId])) {
13895
15139
  return invalidPrecondition(
13896
- "payload.lineIds must reference existing lines",
13897
- { lineId },
15140
+ "payload.imageIds must reference existing image items",
15141
+ { imageId },
13898
15142
  );
13899
15143
  }
13900
15144
  }
13901
15145
  },
13902
15146
  reduce: ({ state, payload }) => {
13903
- for (const lineId of payload.lineIds) {
13904
- const location = findLineLocation({ state, lineId });
13905
- location.section.lines ??= createEmptyNestedCollection();
13906
- delete location.section.lines.items[lineId];
13907
- removeTreeNode({
13908
- nodes: location.section.lines.tree,
13909
- nodeId: lineId,
15147
+ const deletedImageIds = new Set();
15148
+
15149
+ for (const imageId of payload.imageIds) {
15150
+ const removedNode = removeTreeNode({
15151
+ nodes: state.images.tree,
15152
+ nodeId: imageId,
13910
15153
  });
15154
+
15155
+ if (!removedNode) {
15156
+ continue;
15157
+ }
15158
+
15159
+ for (const id of collectTreeDescendantIds({ node: removedNode })) {
15160
+ deletedImageIds.add(id);
15161
+ }
15162
+ }
15163
+
15164
+ for (const imageId of deletedImageIds) {
15165
+ delete state.images.items[imageId];
13911
15166
  }
13912
15167
 
13913
15168
  return state;
13914
15169
  },
13915
15170
  },
13916
15171
  {
13917
- type: "line.move",
15172
+ type: "image.move",
13918
15173
  validatePayload: ({ payload }) => {
13919
15174
  {
13920
15175
  const result = validateAllowedKeys({
13921
15176
  value: payload,
13922
15177
  allowedKeys: [
13923
- "lineId",
13924
- "toSectionId",
15178
+ "imageId",
15179
+ "parentId",
13925
15180
  "index",
13926
15181
  "position",
13927
15182
  "positionTargetId",
@@ -13934,12 +15189,18 @@ const COMMAND_DEFINITIONS = [
13934
15189
  }
13935
15190
  }
13936
15191
 
13937
- if (!isNonEmptyString(payload.lineId)) {
13938
- return invalidPayload("payload.lineId must be a non-empty string");
15192
+ if (!isNonEmptyString(payload.imageId)) {
15193
+ return invalidPayload("payload.imageId must be a non-empty string");
13939
15194
  }
13940
15195
 
13941
- if (!isNonEmptyString(payload.toSectionId)) {
13942
- return invalidPayload("payload.toSectionId must be a non-empty string");
15196
+ if (
15197
+ payload.parentId !== undefined &&
15198
+ payload.parentId !== null &&
15199
+ !isNonEmptyString(payload.parentId)
15200
+ ) {
15201
+ return invalidPayload(
15202
+ "payload.parentId must be a non-empty string when provided",
15203
+ );
13943
15204
  }
13944
15205
 
13945
15206
  {
@@ -13953,78 +15214,84 @@ const COMMAND_DEFINITIONS = [
13953
15214
  }
13954
15215
  },
13955
15216
  validateAgainstState: ({ state, payload }) => {
13956
- const lineLocation = findLineLocation({
13957
- state,
13958
- lineId: payload.lineId,
13959
- });
13960
- if (!lineLocation) {
15217
+ const image = state.images.items[payload.imageId];
15218
+ if (!isPlainObject(image)) {
13961
15219
  return invalidPrecondition(
13962
- "payload.lineId must reference an existing line",
15220
+ "payload.imageId must reference an existing image item",
13963
15221
  );
13964
15222
  }
13965
15223
 
13966
- const targetSectionLocation = findSectionLocation({
13967
- state,
13968
- sectionId: payload.toSectionId,
15224
+ const imageNode = findTreeNode({
15225
+ nodes: state.images.tree,
15226
+ nodeId: payload.imageId,
13969
15227
  });
13970
- if (!targetSectionLocation) {
13971
- return invalidPrecondition(
13972
- "payload.toSectionId must reference an existing section",
15228
+
15229
+ if (payload.parentId !== undefined && payload.parentId !== null) {
15230
+ const parentImage = state.images.items[payload.parentId];
15231
+ if (!isPlainObject(parentImage)) {
15232
+ return invalidPrecondition(
15233
+ "payload.parentId must reference an existing image item",
15234
+ );
15235
+ }
15236
+
15237
+ if (parentImage.type !== "folder") {
15238
+ return invalidPrecondition(
15239
+ "payload.parentId must reference a folder image item",
15240
+ );
15241
+ }
15242
+
15243
+ const descendantIds = new Set(
15244
+ collectTreeDescendantIds({
15245
+ node: imageNode,
15246
+ }),
13973
15247
  );
15248
+
15249
+ if (descendantIds.has(payload.parentId)) {
15250
+ return invalidPrecondition(
15251
+ "payload.parentId must not target the moved image item or its descendants",
15252
+ );
15253
+ }
13974
15254
  }
13975
15255
 
13976
15256
  if (payload.positionTargetId !== undefined) {
13977
- if (payload.positionTargetId === payload.lineId) {
15257
+ if (payload.positionTargetId === payload.imageId) {
13978
15258
  return invalidPrecondition(
13979
- "payload.positionTargetId must not reference the moved line",
15259
+ "payload.positionTargetId must not reference the moved image item",
13980
15260
  );
13981
15261
  }
13982
15262
 
13983
- const targetLocation = findLineLocation({
13984
- state,
13985
- lineId: payload.positionTargetId,
13986
- });
13987
- if (!targetLocation) {
15263
+ if (!isPlainObject(state.images.items[payload.positionTargetId])) {
13988
15264
  return invalidPrecondition(
13989
- "payload.positionTargetId must reference an existing line",
15265
+ "payload.positionTargetId must reference an existing image item",
13990
15266
  );
13991
15267
  }
13992
15268
 
13993
- if (targetLocation.sectionId !== payload.toSectionId) {
15269
+ const targetParentId = getNodeParentId({
15270
+ tree: state.images.tree,
15271
+ nodeId: payload.positionTargetId,
15272
+ });
15273
+
15274
+ if (targetParentId !== (payload.parentId ?? null)) {
13994
15275
  return invalidPrecondition(
13995
- "payload.positionTargetId must reference a line in the target section",
15276
+ "payload.positionTargetId must reference a sibling under payload.parentId",
13996
15277
  );
13997
15278
  }
13998
15279
  }
13999
15280
  },
14000
15281
  reduce: ({ state, payload }) => {
14001
- const lineLocation = findLineLocation({
14002
- state,
14003
- lineId: payload.lineId,
14004
- });
14005
- lineLocation.section.lines ??= createEmptyNestedCollection();
14006
- const lineNodeResult = removeNodeOrResult({
14007
- tree: lineLocation.section.lines.tree,
14008
- nodeId: payload.lineId,
14009
- errorMessage: "line move target missing from tree",
15282
+ const imageNodeResult = removeNodeOrResult({
15283
+ tree: state.images.tree,
15284
+ nodeId: payload.imageId,
15285
+ errorMessage: "image move target missing from tree",
14010
15286
  });
14011
- if (!lineNodeResult.valid) {
14012
- return lineNodeResult;
15287
+ if (!imageNodeResult.valid) {
15288
+ return imageNodeResult;
14013
15289
  }
14014
- const lineValue = lineLocation.line;
14015
-
14016
- delete lineLocation.section.lines.items[payload.lineId];
14017
-
14018
- const targetSectionLocation = findSectionLocation({
14019
- state,
14020
- sectionId: payload.toSectionId,
14021
- });
14022
- targetSectionLocation.section.lines ??= createEmptyNestedCollection();
14023
- targetSectionLocation.section.lines.items[payload.lineId] = lineValue;
14024
15290
 
14025
15291
  insertTreeNode({
14026
- tree: targetSectionLocation.section.lines.tree,
14027
- node: lineNodeResult.node,
15292
+ tree: state.images.tree,
15293
+ node: imageNodeResult.node,
15294
+ parentId: payload.parentId ?? null,
14028
15295
  index: payload.index,
14029
15296
  position: payload.position,
14030
15297
  positionTargetId: payload.positionTargetId,
@@ -14034,13 +15301,13 @@ const COMMAND_DEFINITIONS = [
14034
15301
  },
14035
15302
  },
14036
15303
  {
14037
- type: "image.create",
15304
+ type: "sound.create",
14038
15305
  validatePayload: ({ payload }) => {
14039
15306
  {
14040
15307
  const result = validateAllowedKeys({
14041
15308
  value: payload,
14042
15309
  allowedKeys: [
14043
- "imageId",
15310
+ "soundId",
14044
15311
  "parentId",
14045
15312
  "data",
14046
15313
  "index",
@@ -14055,8 +15322,8 @@ const COMMAND_DEFINITIONS = [
14055
15322
  }
14056
15323
  }
14057
15324
 
14058
- if (!isNonEmptyString(payload.imageId)) {
14059
- return invalidPayload("payload.imageId must be a non-empty string");
15325
+ if (!isNonEmptyString(payload.soundId)) {
15326
+ return invalidPayload("payload.soundId must be a non-empty string");
14060
15327
  }
14061
15328
 
14062
15329
  if (
@@ -14070,7 +15337,7 @@ const COMMAND_DEFINITIONS = [
14070
15337
  }
14071
15338
 
14072
15339
  {
14073
- const result = validateImageCreateData({
15340
+ const result = validateSoundCreateData({
14074
15341
  data: payload.data,
14075
15342
  errorFactory: createPayloadValidationError,
14076
15343
  });
@@ -14090,35 +15357,35 @@ const COMMAND_DEFINITIONS = [
14090
15357
  }
14091
15358
  },
14092
15359
  validateAgainstState: ({ state, payload }) => {
14093
- if (isPlainObject(state.images.items[payload.imageId])) {
14094
- return invalidPrecondition("payload.imageId must not already exist");
15360
+ if (isPlainObject(state.sounds.items[payload.soundId])) {
15361
+ return invalidPrecondition("payload.soundId must not already exist");
14095
15362
  }
14096
15363
 
14097
15364
  const parentId = payload.parentId ?? null;
14098
15365
  if (parentId !== null) {
14099
- const parentImage = state.images.items[parentId];
14100
- if (!isPlainObject(parentImage)) {
15366
+ const parentSound = state.sounds.items[parentId];
15367
+ if (!isPlainObject(parentSound)) {
14101
15368
  return invalidPrecondition(
14102
- "payload.parentId must reference an existing image item",
15369
+ "payload.parentId must reference an existing sound item",
14103
15370
  );
14104
15371
  }
14105
15372
 
14106
- if (parentImage.type !== "folder") {
15373
+ if (parentSound.type !== "folder") {
14107
15374
  return invalidPrecondition(
14108
- "payload.parentId must reference a folder image item",
15375
+ "payload.parentId must reference a folder sound item",
14109
15376
  );
14110
15377
  }
14111
15378
  }
14112
15379
 
14113
15380
  if (payload.positionTargetId !== undefined) {
14114
- if (!isPlainObject(state.images.items[payload.positionTargetId])) {
15381
+ if (!isPlainObject(state.sounds.items[payload.positionTargetId])) {
14115
15382
  return invalidPrecondition(
14116
- "payload.positionTargetId must reference an existing image item",
15383
+ "payload.positionTargetId must reference an existing sound item",
14117
15384
  );
14118
15385
  }
14119
15386
 
14120
15387
  const targetParentId = getNodeParentId({
14121
- tree: state.images.tree,
15388
+ tree: state.sounds.tree,
14122
15389
  nodeId: payload.positionTargetId,
14123
15390
  });
14124
15391
 
@@ -14129,13 +15396,14 @@ const COMMAND_DEFINITIONS = [
14129
15396
  }
14130
15397
  }
14131
15398
 
14132
- if (payload.data.type === "image") {
15399
+ if (payload.data.type === "sound") {
14133
15400
  const result = validateReferencedFilesInData({
14134
15401
  state,
14135
15402
  data: payload.data,
14136
- fields: ["fileId", "thumbnailFileId"],
15403
+ fields: ["fileId", "waveformDataFileId"],
15404
+ nullableFields: ["waveformDataFileId"],
14137
15405
  details: {
14138
- imageId: payload.imageId,
15406
+ soundId: payload.soundId,
14139
15407
  },
14140
15408
  });
14141
15409
  if (!result.valid) {
@@ -14145,49 +15413,46 @@ const COMMAND_DEFINITIONS = [
14145
15413
  return validateTagIdsAgainstScope({
14146
15414
  state,
14147
15415
  tagIds: payload.data.tagIds,
14148
- scopeKey: "images",
15416
+ scopeKey: "sounds",
14149
15417
  path: "payload.data.tagIds",
14150
15418
  details: {
14151
- imageId: payload.imageId,
15419
+ soundId: payload.soundId,
14152
15420
  },
14153
15421
  });
14154
15422
  }
14155
15423
  },
14156
15424
  reduce: ({ state, payload }) => {
14157
- const nextImage = {
14158
- id: payload.imageId,
15425
+ const nextSound = {
15426
+ id: payload.soundId,
14159
15427
  type: payload.data.type,
14160
15428
  name: payload.data.name,
14161
15429
  };
14162
15430
 
14163
15431
  if (payload.data.description !== undefined) {
14164
- nextImage.description = payload.data.description;
15432
+ nextSound.description = payload.data.description;
14165
15433
  }
14166
15434
 
14167
- if (payload.data.type === "image") {
14168
- nextImage.fileId = payload.data.fileId;
14169
- if (payload.data.thumbnailFileId !== undefined) {
14170
- nextImage.thumbnailFileId = payload.data.thumbnailFileId;
14171
- }
14172
- if (payload.data.width !== undefined) {
14173
- nextImage.width = payload.data.width;
15435
+ if (payload.data.type === "sound") {
15436
+ nextSound.fileId = payload.data.fileId;
15437
+ if (payload.data.waveformDataFileId !== undefined) {
15438
+ nextSound.waveformDataFileId = payload.data.waveformDataFileId;
14174
15439
  }
14175
- if (payload.data.height !== undefined) {
14176
- nextImage.height = payload.data.height;
15440
+ if (payload.data.duration !== undefined) {
15441
+ nextSound.duration = payload.data.duration;
14177
15442
  }
14178
15443
 
14179
15444
  assignOptionalTagIds({
14180
- target: nextImage,
15445
+ target: nextSound,
14181
15446
  tagIds: payload.data.tagIds,
14182
15447
  });
14183
15448
  }
14184
15449
 
14185
- state.images.items[payload.imageId] = nextImage;
15450
+ state.sounds.items[payload.soundId] = nextSound;
14186
15451
 
14187
15452
  insertTreeNode({
14188
- tree: state.images.tree,
15453
+ tree: state.sounds.tree,
14189
15454
  node: {
14190
- id: payload.imageId,
15455
+ id: payload.soundId,
14191
15456
  children: [],
14192
15457
  },
14193
15458
  parentId: payload.parentId ?? null,
@@ -14200,12 +15465,12 @@ const COMMAND_DEFINITIONS = [
14200
15465
  },
14201
15466
  },
14202
15467
  {
14203
- type: "image.update",
15468
+ type: "sound.update",
14204
15469
  validatePayload: ({ payload }) => {
14205
15470
  {
14206
15471
  const result = validateExactKeys({
14207
15472
  value: payload,
14208
- expectedKeys: ["imageId", "data"],
15473
+ expectedKeys: ["soundId", "data"],
14209
15474
  path: "payload",
14210
15475
  errorFactory: createPayloadValidationError,
14211
15476
  });
@@ -14214,12 +15479,12 @@ const COMMAND_DEFINITIONS = [
14214
15479
  }
14215
15480
  }
14216
15481
 
14217
- if (!isNonEmptyString(payload.imageId)) {
14218
- return invalidPayload("payload.imageId must be a non-empty string");
15482
+ if (!isNonEmptyString(payload.soundId)) {
15483
+ return invalidPayload("payload.soundId must be a non-empty string");
14219
15484
  }
14220
15485
 
14221
15486
  {
14222
- const result = validateImageUpdateData({
15487
+ const result = validateSoundUpdateData({
14223
15488
  data: payload.data,
14224
15489
  errorFactory: createPayloadValidationError,
14225
15490
  });
@@ -14229,33 +15494,33 @@ const COMMAND_DEFINITIONS = [
14229
15494
  }
14230
15495
  },
14231
15496
  validateAgainstState: ({ state, payload }) => {
14232
- const currentImage = state.images.items[payload.imageId];
14233
- if (!isPlainObject(currentImage)) {
15497
+ const currentSound = state.sounds.items[payload.soundId];
15498
+ if (!isPlainObject(currentSound)) {
14234
15499
  return invalidPrecondition(
14235
- "payload.imageId must reference an existing image item",
15500
+ "payload.soundId must reference an existing sound item",
14236
15501
  );
14237
15502
  }
14238
15503
 
14239
15504
  if (
14240
- currentImage.type === "folder" &&
15505
+ currentSound.type === "folder" &&
14241
15506
  (payload.data.fileId !== undefined ||
14242
- payload.data.thumbnailFileId !== undefined ||
14243
- payload.data.width !== undefined ||
14244
- payload.data.height !== undefined ||
15507
+ payload.data.waveformDataFileId !== undefined ||
15508
+ payload.data.duration !== undefined ||
14245
15509
  payload.data.tagIds !== undefined)
14246
15510
  ) {
14247
15511
  return invalidPrecondition(
14248
- "folder image items cannot update file fields",
15512
+ "folder sound items cannot update file fields",
14249
15513
  );
14250
15514
  }
14251
15515
 
14252
- if (currentImage.type === "image") {
15516
+ if (currentSound.type === "sound") {
14253
15517
  const result = validateReferencedFilesInData({
14254
15518
  state,
14255
15519
  data: payload.data,
14256
- fields: ["fileId", "thumbnailFileId"],
15520
+ fields: ["fileId", "waveformDataFileId"],
15521
+ nullableFields: ["waveformDataFileId"],
14257
15522
  details: {
14258
- imageId: payload.imageId,
15523
+ soundId: payload.soundId,
14259
15524
  },
14260
15525
  });
14261
15526
  if (!result.valid) {
@@ -14265,30 +15530,30 @@ const COMMAND_DEFINITIONS = [
14265
15530
  return validateTagIdsAgainstScope({
14266
15531
  state,
14267
15532
  tagIds: payload.data.tagIds,
14268
- scopeKey: "images",
15533
+ scopeKey: "sounds",
14269
15534
  path: "payload.data.tagIds",
14270
15535
  details: {
14271
- imageId: payload.imageId,
15536
+ soundId: payload.soundId,
14272
15537
  },
14273
15538
  });
14274
15539
  }
14275
15540
  },
14276
15541
  reduce: ({ state, payload }) => {
14277
- const currentImage = state.images.items[payload.imageId];
14278
- state.images.items[payload.imageId] = applyTagIdsUpdate({
14279
- currentItem: currentImage,
15542
+ const currentSound = state.sounds.items[payload.soundId];
15543
+ state.sounds.items[payload.soundId] = applyTagIdsUpdate({
15544
+ currentItem: currentSound,
14280
15545
  data: payload.data,
14281
15546
  });
14282
15547
  return state;
14283
15548
  },
14284
15549
  },
14285
15550
  {
14286
- type: "image.delete",
15551
+ type: "sound.delete",
14287
15552
  validatePayload: ({ payload }) => {
14288
15553
  {
14289
15554
  const result = validateExactKeys({
14290
15555
  value: payload,
14291
- expectedKeys: ["imageIds"],
15556
+ expectedKeys: ["soundIds"],
14292
15557
  path: "payload",
14293
15558
  errorFactory: createPayloadValidationError,
14294
15559
  });
@@ -14299,8 +15564,8 @@ const COMMAND_DEFINITIONS = [
14299
15564
 
14300
15565
  {
14301
15566
  const result = validateRequiredUniqueIdArray({
14302
- value: payload.imageIds,
14303
- path: "payload.imageIds",
15567
+ value: payload.soundIds,
15568
+ path: "payload.soundIds",
14304
15569
  errorFactory: createPayloadValidationError,
14305
15570
  });
14306
15571
  if (result?.valid === false) {
@@ -14309,22 +15574,22 @@ const COMMAND_DEFINITIONS = [
14309
15574
  }
14310
15575
  },
14311
15576
  validateAgainstState: ({ state, payload }) => {
14312
- for (const imageId of payload.imageIds) {
14313
- if (!isPlainObject(state.images.items[imageId])) {
15577
+ for (const soundId of payload.soundIds) {
15578
+ if (!isPlainObject(state.sounds.items[soundId])) {
14314
15579
  return invalidPrecondition(
14315
- "payload.imageIds must reference existing image items",
14316
- { imageId },
15580
+ "payload.soundIds must reference existing sound items",
15581
+ { soundId },
14317
15582
  );
14318
15583
  }
14319
15584
  }
14320
15585
  },
14321
15586
  reduce: ({ state, payload }) => {
14322
- const deletedImageIds = new Set();
15587
+ const deletedSoundIds = new Set();
14323
15588
 
14324
- for (const imageId of payload.imageIds) {
15589
+ for (const soundId of payload.soundIds) {
14325
15590
  const removedNode = removeTreeNode({
14326
- nodes: state.images.tree,
14327
- nodeId: imageId,
15591
+ nodes: state.sounds.tree,
15592
+ nodeId: soundId,
14328
15593
  });
14329
15594
 
14330
15595
  if (!removedNode) {
@@ -14332,25 +15597,25 @@ const COMMAND_DEFINITIONS = [
14332
15597
  }
14333
15598
 
14334
15599
  for (const id of collectTreeDescendantIds({ node: removedNode })) {
14335
- deletedImageIds.add(id);
15600
+ deletedSoundIds.add(id);
14336
15601
  }
14337
15602
  }
14338
15603
 
14339
- for (const imageId of deletedImageIds) {
14340
- delete state.images.items[imageId];
15604
+ for (const soundId of deletedSoundIds) {
15605
+ delete state.sounds.items[soundId];
14341
15606
  }
14342
15607
 
14343
15608
  return state;
14344
15609
  },
14345
15610
  },
14346
15611
  {
14347
- type: "image.move",
15612
+ type: "sound.move",
14348
15613
  validatePayload: ({ payload }) => {
14349
15614
  {
14350
15615
  const result = validateAllowedKeys({
14351
15616
  value: payload,
14352
15617
  allowedKeys: [
14353
- "imageId",
15618
+ "soundId",
14354
15619
  "parentId",
14355
15620
  "index",
14356
15621
  "position",
@@ -14364,8 +15629,8 @@ const COMMAND_DEFINITIONS = [
14364
15629
  }
14365
15630
  }
14366
15631
 
14367
- if (!isNonEmptyString(payload.imageId)) {
14368
- return invalidPayload("payload.imageId must be a non-empty string");
15632
+ if (!isNonEmptyString(payload.soundId)) {
15633
+ return invalidPayload("payload.soundId must be a non-empty string");
14369
15634
  }
14370
15635
 
14371
15636
  if (
@@ -14389,60 +15654,60 @@ const COMMAND_DEFINITIONS = [
14389
15654
  }
14390
15655
  },
14391
15656
  validateAgainstState: ({ state, payload }) => {
14392
- const image = state.images.items[payload.imageId];
14393
- if (!isPlainObject(image)) {
15657
+ const sound = state.sounds.items[payload.soundId];
15658
+ if (!isPlainObject(sound)) {
14394
15659
  return invalidPrecondition(
14395
- "payload.imageId must reference an existing image item",
15660
+ "payload.soundId must reference an existing sound item",
14396
15661
  );
14397
15662
  }
14398
15663
 
14399
- const imageNode = findTreeNode({
14400
- nodes: state.images.tree,
14401
- nodeId: payload.imageId,
15664
+ const soundNode = findTreeNode({
15665
+ nodes: state.sounds.tree,
15666
+ nodeId: payload.soundId,
14402
15667
  });
14403
15668
 
14404
15669
  if (payload.parentId !== undefined && payload.parentId !== null) {
14405
- const parentImage = state.images.items[payload.parentId];
14406
- if (!isPlainObject(parentImage)) {
15670
+ const parentSound = state.sounds.items[payload.parentId];
15671
+ if (!isPlainObject(parentSound)) {
14407
15672
  return invalidPrecondition(
14408
- "payload.parentId must reference an existing image item",
15673
+ "payload.parentId must reference an existing sound item",
14409
15674
  );
14410
15675
  }
14411
15676
 
14412
- if (parentImage.type !== "folder") {
15677
+ if (parentSound.type !== "folder") {
14413
15678
  return invalidPrecondition(
14414
- "payload.parentId must reference a folder image item",
15679
+ "payload.parentId must reference a folder sound item",
14415
15680
  );
14416
15681
  }
14417
15682
 
14418
15683
  const descendantIds = new Set(
14419
15684
  collectTreeDescendantIds({
14420
- node: imageNode,
15685
+ node: soundNode,
14421
15686
  }),
14422
15687
  );
14423
15688
 
14424
15689
  if (descendantIds.has(payload.parentId)) {
14425
15690
  return invalidPrecondition(
14426
- "payload.parentId must not target the moved image item or its descendants",
15691
+ "payload.parentId must not target the moved sound item or its descendants",
14427
15692
  );
14428
15693
  }
14429
15694
  }
14430
15695
 
14431
15696
  if (payload.positionTargetId !== undefined) {
14432
- if (payload.positionTargetId === payload.imageId) {
15697
+ if (payload.positionTargetId === payload.soundId) {
14433
15698
  return invalidPrecondition(
14434
- "payload.positionTargetId must not reference the moved image item",
15699
+ "payload.positionTargetId must not reference the moved sound item",
14435
15700
  );
14436
15701
  }
14437
15702
 
14438
- if (!isPlainObject(state.images.items[payload.positionTargetId])) {
15703
+ if (!isPlainObject(state.sounds.items[payload.positionTargetId])) {
14439
15704
  return invalidPrecondition(
14440
- "payload.positionTargetId must reference an existing image item",
15705
+ "payload.positionTargetId must reference an existing sound item",
14441
15706
  );
14442
15707
  }
14443
15708
 
14444
15709
  const targetParentId = getNodeParentId({
14445
- tree: state.images.tree,
15710
+ tree: state.sounds.tree,
14446
15711
  nodeId: payload.positionTargetId,
14447
15712
  });
14448
15713
 
@@ -14454,18 +15719,18 @@ const COMMAND_DEFINITIONS = [
14454
15719
  }
14455
15720
  },
14456
15721
  reduce: ({ state, payload }) => {
14457
- const imageNodeResult = removeNodeOrResult({
14458
- tree: state.images.tree,
14459
- nodeId: payload.imageId,
14460
- errorMessage: "image move target missing from tree",
15722
+ const soundNodeResult = removeNodeOrResult({
15723
+ tree: state.sounds.tree,
15724
+ nodeId: payload.soundId,
15725
+ errorMessage: "sound move target missing from tree",
14461
15726
  });
14462
- if (!imageNodeResult.valid) {
14463
- return imageNodeResult;
15727
+ if (!soundNodeResult.valid) {
15728
+ return soundNodeResult;
14464
15729
  }
14465
15730
 
14466
15731
  insertTreeNode({
14467
- tree: state.images.tree,
14468
- node: imageNodeResult.node,
15732
+ tree: state.sounds.tree,
15733
+ node: soundNodeResult.node,
14469
15734
  parentId: payload.parentId ?? null,
14470
15735
  index: payload.index,
14471
15736
  position: payload.position,
@@ -14476,13 +15741,13 @@ const COMMAND_DEFINITIONS = [
14476
15741
  },
14477
15742
  },
14478
15743
  {
14479
- type: "sound.create",
15744
+ type: "voice.create",
14480
15745
  validatePayload: ({ payload }) => {
14481
15746
  {
14482
15747
  const result = validateAllowedKeys({
14483
15748
  value: payload,
14484
15749
  allowedKeys: [
14485
- "soundId",
15750
+ "voiceId",
14486
15751
  "parentId",
14487
15752
  "data",
14488
15753
  "index",
@@ -14497,8 +15762,8 @@ const COMMAND_DEFINITIONS = [
14497
15762
  }
14498
15763
  }
14499
15764
 
14500
- if (!isNonEmptyString(payload.soundId)) {
14501
- return invalidPayload("payload.soundId must be a non-empty string");
15765
+ if (!isNonEmptyString(payload.voiceId)) {
15766
+ return invalidPayload("payload.voiceId must be a non-empty string");
14502
15767
  }
14503
15768
 
14504
15769
  if (
@@ -14512,7 +15777,7 @@ const COMMAND_DEFINITIONS = [
14512
15777
  }
14513
15778
 
14514
15779
  {
14515
- const result = validateSoundCreateData({
15780
+ const result = validateVoiceCreateData({
14516
15781
  data: payload.data,
14517
15782
  errorFactory: createPayloadValidationError,
14518
15783
  });
@@ -14532,35 +15797,35 @@ const COMMAND_DEFINITIONS = [
14532
15797
  }
14533
15798
  },
14534
15799
  validateAgainstState: ({ state, payload }) => {
14535
- if (isPlainObject(state.sounds.items[payload.soundId])) {
14536
- return invalidPrecondition("payload.soundId must not already exist");
15800
+ if (isPlainObject(state.voices.items[payload.voiceId])) {
15801
+ return invalidPrecondition("payload.voiceId must not already exist");
14537
15802
  }
14538
15803
 
14539
15804
  const parentId = payload.parentId ?? null;
14540
15805
  if (parentId !== null) {
14541
- const parentSound = state.sounds.items[parentId];
14542
- if (!isPlainObject(parentSound)) {
15806
+ const parentVoice = state.voices.items[parentId];
15807
+ if (!isPlainObject(parentVoice)) {
14543
15808
  return invalidPrecondition(
14544
- "payload.parentId must reference an existing sound item",
15809
+ "payload.parentId must reference an existing voice item",
14545
15810
  );
14546
15811
  }
14547
15812
 
14548
- if (parentSound.type !== "folder") {
15813
+ if (parentVoice.type !== "folder") {
14549
15814
  return invalidPrecondition(
14550
- "payload.parentId must reference a folder sound item",
15815
+ "payload.parentId must reference a folder voice item",
14551
15816
  );
14552
15817
  }
14553
15818
  }
14554
15819
 
14555
15820
  if (payload.positionTargetId !== undefined) {
14556
- if (!isPlainObject(state.sounds.items[payload.positionTargetId])) {
15821
+ if (!isPlainObject(state.voices.items[payload.positionTargetId])) {
14557
15822
  return invalidPrecondition(
14558
- "payload.positionTargetId must reference an existing sound item",
15823
+ "payload.positionTargetId must reference an existing voice item",
14559
15824
  );
14560
15825
  }
14561
15826
 
14562
15827
  const targetParentId = getNodeParentId({
14563
- tree: state.sounds.tree,
15828
+ tree: state.voices.tree,
14564
15829
  nodeId: payload.positionTargetId,
14565
15830
  });
14566
15831
 
@@ -14571,63 +15836,53 @@ const COMMAND_DEFINITIONS = [
14571
15836
  }
14572
15837
  }
14573
15838
 
14574
- if (payload.data.type === "sound") {
14575
- const result = validateReferencedFilesInData({
15839
+ if (payload.data.type === "voice") {
15840
+ const scene = state.scenes.items[payload.data.sceneId];
15841
+ if (!isPlainObject(scene) || scene.type === "folder") {
15842
+ return invalidPrecondition(
15843
+ "payload.data.sceneId must reference an existing non-folder scene",
15844
+ );
15845
+ }
15846
+
15847
+ return validateReferencedFilesInData({
14576
15848
  state,
14577
15849
  data: payload.data,
14578
15850
  fields: ["fileId", "waveformDataFileId"],
14579
15851
  nullableFields: ["waveformDataFileId"],
14580
15852
  details: {
14581
- soundId: payload.soundId,
14582
- },
14583
- });
14584
- if (!result.valid) {
14585
- return result;
14586
- }
14587
-
14588
- return validateTagIdsAgainstScope({
14589
- state,
14590
- tagIds: payload.data.tagIds,
14591
- scopeKey: "sounds",
14592
- path: "payload.data.tagIds",
14593
- details: {
14594
- soundId: payload.soundId,
15853
+ voiceId: payload.voiceId,
14595
15854
  },
14596
15855
  });
14597
15856
  }
14598
15857
  },
14599
15858
  reduce: ({ state, payload }) => {
14600
- const nextSound = {
14601
- id: payload.soundId,
15859
+ const nextVoice = {
15860
+ id: payload.voiceId,
14602
15861
  type: payload.data.type,
14603
15862
  name: payload.data.name,
14604
15863
  };
14605
15864
 
14606
15865
  if (payload.data.description !== undefined) {
14607
- nextSound.description = payload.data.description;
15866
+ nextVoice.description = payload.data.description;
14608
15867
  }
14609
15868
 
14610
- if (payload.data.type === "sound") {
14611
- nextSound.fileId = payload.data.fileId;
15869
+ if (payload.data.type === "voice") {
15870
+ nextVoice.sceneId = payload.data.sceneId;
15871
+ nextVoice.fileId = payload.data.fileId;
14612
15872
  if (payload.data.waveformDataFileId !== undefined) {
14613
- nextSound.waveformDataFileId = payload.data.waveformDataFileId;
15873
+ nextVoice.waveformDataFileId = payload.data.waveformDataFileId;
14614
15874
  }
14615
15875
  if (payload.data.duration !== undefined) {
14616
- nextSound.duration = payload.data.duration;
15876
+ nextVoice.duration = payload.data.duration;
14617
15877
  }
14618
-
14619
- assignOptionalTagIds({
14620
- target: nextSound,
14621
- tagIds: payload.data.tagIds,
14622
- });
14623
15878
  }
14624
15879
 
14625
- state.sounds.items[payload.soundId] = nextSound;
15880
+ state.voices.items[payload.voiceId] = nextVoice;
14626
15881
 
14627
15882
  insertTreeNode({
14628
- tree: state.sounds.tree,
15883
+ tree: state.voices.tree,
14629
15884
  node: {
14630
- id: payload.soundId,
15885
+ id: payload.voiceId,
14631
15886
  children: [],
14632
15887
  },
14633
15888
  parentId: payload.parentId ?? null,
@@ -14640,12 +15895,12 @@ const COMMAND_DEFINITIONS = [
14640
15895
  },
14641
15896
  },
14642
15897
  {
14643
- type: "sound.update",
15898
+ type: "voice.update",
14644
15899
  validatePayload: ({ payload }) => {
14645
15900
  {
14646
15901
  const result = validateExactKeys({
14647
15902
  value: payload,
14648
- expectedKeys: ["soundId", "data"],
15903
+ expectedKeys: ["voiceId", "data"],
14649
15904
  path: "payload",
14650
15905
  errorFactory: createPayloadValidationError,
14651
15906
  });
@@ -14654,12 +15909,12 @@ const COMMAND_DEFINITIONS = [
14654
15909
  }
14655
15910
  }
14656
15911
 
14657
- if (!isNonEmptyString(payload.soundId)) {
14658
- return invalidPayload("payload.soundId must be a non-empty string");
15912
+ if (!isNonEmptyString(payload.voiceId)) {
15913
+ return invalidPayload("payload.voiceId must be a non-empty string");
14659
15914
  }
14660
15915
 
14661
15916
  {
14662
- const result = validateSoundUpdateData({
15917
+ const result = validateVoiceUpdateData({
14663
15918
  data: payload.data,
14664
15919
  errorFactory: createPayloadValidationError,
14665
15920
  });
@@ -14669,66 +15924,61 @@ const COMMAND_DEFINITIONS = [
14669
15924
  }
14670
15925
  },
14671
15926
  validateAgainstState: ({ state, payload }) => {
14672
- const currentSound = state.sounds.items[payload.soundId];
14673
- if (!isPlainObject(currentSound)) {
15927
+ const currentVoice = state.voices.items[payload.voiceId];
15928
+ if (!isPlainObject(currentVoice)) {
14674
15929
  return invalidPrecondition(
14675
- "payload.soundId must reference an existing sound item",
15930
+ "payload.voiceId must reference an existing voice item",
14676
15931
  );
14677
15932
  }
14678
15933
 
14679
15934
  if (
14680
- currentSound.type === "folder" &&
14681
- (payload.data.fileId !== undefined ||
15935
+ currentVoice.type === "folder" &&
15936
+ (payload.data.sceneId !== undefined ||
15937
+ payload.data.fileId !== undefined ||
14682
15938
  payload.data.waveformDataFileId !== undefined ||
14683
- payload.data.duration !== undefined ||
14684
- payload.data.tagIds !== undefined)
15939
+ payload.data.duration !== undefined)
14685
15940
  ) {
14686
15941
  return invalidPrecondition(
14687
- "folder sound items cannot update file fields",
15942
+ "folder voice items cannot update file fields",
14688
15943
  );
14689
15944
  }
14690
15945
 
14691
- if (currentSound.type === "sound") {
14692
- const result = validateReferencedFilesInData({
15946
+ if (currentVoice.type === "voice") {
15947
+ if (payload.data.sceneId !== undefined) {
15948
+ const scene = state.scenes.items[payload.data.sceneId];
15949
+ if (!isPlainObject(scene) || scene.type === "folder") {
15950
+ return invalidPrecondition(
15951
+ "payload.data.sceneId must reference an existing non-folder scene",
15952
+ );
15953
+ }
15954
+ }
15955
+
15956
+ return validateReferencedFilesInData({
14693
15957
  state,
14694
15958
  data: payload.data,
14695
15959
  fields: ["fileId", "waveformDataFileId"],
14696
15960
  nullableFields: ["waveformDataFileId"],
14697
15961
  details: {
14698
- soundId: payload.soundId,
14699
- },
14700
- });
14701
- if (!result.valid) {
14702
- return result;
14703
- }
14704
-
14705
- return validateTagIdsAgainstScope({
14706
- state,
14707
- tagIds: payload.data.tagIds,
14708
- scopeKey: "sounds",
14709
- path: "payload.data.tagIds",
14710
- details: {
14711
- soundId: payload.soundId,
15962
+ voiceId: payload.voiceId,
14712
15963
  },
14713
15964
  });
14714
15965
  }
14715
15966
  },
14716
15967
  reduce: ({ state, payload }) => {
14717
- const currentSound = state.sounds.items[payload.soundId];
14718
- state.sounds.items[payload.soundId] = applyTagIdsUpdate({
14719
- currentItem: currentSound,
14720
- data: payload.data,
14721
- });
15968
+ state.voices.items[payload.voiceId] = {
15969
+ ...structuredClone(state.voices.items[payload.voiceId]),
15970
+ ...structuredClone(payload.data),
15971
+ };
14722
15972
  return state;
14723
15973
  },
14724
15974
  },
14725
15975
  {
14726
- type: "sound.delete",
15976
+ type: "voice.delete",
14727
15977
  validatePayload: ({ payload }) => {
14728
15978
  {
14729
15979
  const result = validateExactKeys({
14730
15980
  value: payload,
14731
- expectedKeys: ["soundIds"],
15981
+ expectedKeys: ["voiceIds"],
14732
15982
  path: "payload",
14733
15983
  errorFactory: createPayloadValidationError,
14734
15984
  });
@@ -14739,8 +15989,8 @@ const COMMAND_DEFINITIONS = [
14739
15989
 
14740
15990
  {
14741
15991
  const result = validateRequiredUniqueIdArray({
14742
- value: payload.soundIds,
14743
- path: "payload.soundIds",
15992
+ value: payload.voiceIds,
15993
+ path: "payload.voiceIds",
14744
15994
  errorFactory: createPayloadValidationError,
14745
15995
  });
14746
15996
  if (result?.valid === false) {
@@ -14749,22 +15999,22 @@ const COMMAND_DEFINITIONS = [
14749
15999
  }
14750
16000
  },
14751
16001
  validateAgainstState: ({ state, payload }) => {
14752
- for (const soundId of payload.soundIds) {
14753
- if (!isPlainObject(state.sounds.items[soundId])) {
16002
+ for (const voiceId of payload.voiceIds) {
16003
+ if (!isPlainObject(state.voices.items[voiceId])) {
14754
16004
  return invalidPrecondition(
14755
- "payload.soundIds must reference existing sound items",
14756
- { soundId },
16005
+ "payload.voiceIds must reference existing voice items",
16006
+ { voiceId },
14757
16007
  );
14758
16008
  }
14759
16009
  }
14760
16010
  },
14761
16011
  reduce: ({ state, payload }) => {
14762
- const deletedSoundIds = new Set();
16012
+ const deletedVoiceIds = new Set();
14763
16013
 
14764
- for (const soundId of payload.soundIds) {
16014
+ for (const voiceId of payload.voiceIds) {
14765
16015
  const removedNode = removeTreeNode({
14766
- nodes: state.sounds.tree,
14767
- nodeId: soundId,
16016
+ nodes: state.voices.tree,
16017
+ nodeId: voiceId,
14768
16018
  });
14769
16019
 
14770
16020
  if (!removedNode) {
@@ -14772,25 +16022,25 @@ const COMMAND_DEFINITIONS = [
14772
16022
  }
14773
16023
 
14774
16024
  for (const id of collectTreeDescendantIds({ node: removedNode })) {
14775
- deletedSoundIds.add(id);
16025
+ deletedVoiceIds.add(id);
14776
16026
  }
14777
16027
  }
14778
16028
 
14779
- for (const soundId of deletedSoundIds) {
14780
- delete state.sounds.items[soundId];
16029
+ for (const voiceId of deletedVoiceIds) {
16030
+ delete state.voices.items[voiceId];
14781
16031
  }
14782
16032
 
14783
16033
  return state;
14784
16034
  },
14785
16035
  },
14786
16036
  {
14787
- type: "sound.move",
16037
+ type: "voice.move",
14788
16038
  validatePayload: ({ payload }) => {
14789
16039
  {
14790
16040
  const result = validateAllowedKeys({
14791
16041
  value: payload,
14792
16042
  allowedKeys: [
14793
- "soundId",
16043
+ "voiceId",
14794
16044
  "parentId",
14795
16045
  "index",
14796
16046
  "position",
@@ -14804,8 +16054,8 @@ const COMMAND_DEFINITIONS = [
14804
16054
  }
14805
16055
  }
14806
16056
 
14807
- if (!isNonEmptyString(payload.soundId)) {
14808
- return invalidPayload("payload.soundId must be a non-empty string");
16057
+ if (!isNonEmptyString(payload.voiceId)) {
16058
+ return invalidPayload("payload.voiceId must be a non-empty string");
14809
16059
  }
14810
16060
 
14811
16061
  if (
@@ -14829,60 +16079,60 @@ const COMMAND_DEFINITIONS = [
14829
16079
  }
14830
16080
  },
14831
16081
  validateAgainstState: ({ state, payload }) => {
14832
- const sound = state.sounds.items[payload.soundId];
14833
- if (!isPlainObject(sound)) {
16082
+ const voice = state.voices.items[payload.voiceId];
16083
+ if (!isPlainObject(voice)) {
14834
16084
  return invalidPrecondition(
14835
- "payload.soundId must reference an existing sound item",
16085
+ "payload.voiceId must reference an existing voice item",
14836
16086
  );
14837
16087
  }
14838
16088
 
14839
- const soundNode = findTreeNode({
14840
- nodes: state.sounds.tree,
14841
- nodeId: payload.soundId,
16089
+ const voiceNode = findTreeNode({
16090
+ nodes: state.voices.tree,
16091
+ nodeId: payload.voiceId,
14842
16092
  });
14843
16093
 
14844
16094
  if (payload.parentId !== undefined && payload.parentId !== null) {
14845
- const parentSound = state.sounds.items[payload.parentId];
14846
- if (!isPlainObject(parentSound)) {
16095
+ const parentVoice = state.voices.items[payload.parentId];
16096
+ if (!isPlainObject(parentVoice)) {
14847
16097
  return invalidPrecondition(
14848
- "payload.parentId must reference an existing sound item",
16098
+ "payload.parentId must reference an existing voice item",
14849
16099
  );
14850
16100
  }
14851
16101
 
14852
- if (parentSound.type !== "folder") {
16102
+ if (parentVoice.type !== "folder") {
14853
16103
  return invalidPrecondition(
14854
- "payload.parentId must reference a folder sound item",
16104
+ "payload.parentId must reference a folder voice item",
14855
16105
  );
14856
16106
  }
14857
16107
 
14858
16108
  const descendantIds = new Set(
14859
16109
  collectTreeDescendantIds({
14860
- node: soundNode,
16110
+ node: voiceNode,
14861
16111
  }),
14862
16112
  );
14863
16113
 
14864
16114
  if (descendantIds.has(payload.parentId)) {
14865
16115
  return invalidPrecondition(
14866
- "payload.parentId must not target the moved sound item or its descendants",
16116
+ "payload.parentId must not target the moved voice item or its descendants",
14867
16117
  );
14868
16118
  }
14869
16119
  }
14870
16120
 
14871
16121
  if (payload.positionTargetId !== undefined) {
14872
- if (payload.positionTargetId === payload.soundId) {
16122
+ if (payload.positionTargetId === payload.voiceId) {
14873
16123
  return invalidPrecondition(
14874
- "payload.positionTargetId must not reference the moved sound item",
16124
+ "payload.positionTargetId must not reference the moved voice item",
14875
16125
  );
14876
16126
  }
14877
16127
 
14878
- if (!isPlainObject(state.sounds.items[payload.positionTargetId])) {
16128
+ if (!isPlainObject(state.voices.items[payload.positionTargetId])) {
14879
16129
  return invalidPrecondition(
14880
- "payload.positionTargetId must reference an existing sound item",
16130
+ "payload.positionTargetId must reference an existing voice item",
14881
16131
  );
14882
16132
  }
14883
16133
 
14884
16134
  const targetParentId = getNodeParentId({
14885
- tree: state.sounds.tree,
16135
+ tree: state.voices.tree,
14886
16136
  nodeId: payload.positionTargetId,
14887
16137
  });
14888
16138
 
@@ -14894,18 +16144,18 @@ const COMMAND_DEFINITIONS = [
14894
16144
  }
14895
16145
  },
14896
16146
  reduce: ({ state, payload }) => {
14897
- const soundNodeResult = removeNodeOrResult({
14898
- tree: state.sounds.tree,
14899
- nodeId: payload.soundId,
14900
- errorMessage: "sound move target missing from tree",
16147
+ const voiceNodeResult = removeNodeOrResult({
16148
+ tree: state.voices.tree,
16149
+ nodeId: payload.voiceId,
16150
+ errorMessage: "voice move target missing from tree",
14901
16151
  });
14902
- if (!soundNodeResult.valid) {
14903
- return soundNodeResult;
16152
+ if (!voiceNodeResult.valid) {
16153
+ return voiceNodeResult;
14904
16154
  }
14905
16155
 
14906
16156
  insertTreeNode({
14907
- tree: state.sounds.tree,
14908
- node: soundNodeResult.node,
16157
+ tree: state.voices.tree,
16158
+ node: voiceNodeResult.node,
14909
16159
  parentId: payload.parentId ?? null,
14910
16160
  index: payload.index,
14911
16161
  position: payload.position,
@@ -17736,6 +18986,16 @@ const COMMAND_DEFINITIONS = [
17736
18986
  "payload.parentId must reference a folder sprite item",
17737
18987
  );
17738
18988
  }
18989
+
18990
+ const parentNode = findTreeNode({
18991
+ nodes: collection.tree,
18992
+ nodeId: parentId,
18993
+ });
18994
+ if (!parentNode) {
18995
+ return invalidPrecondition(
18996
+ "payload.parentId must reference a folder sprite item in the tree",
18997
+ );
18998
+ }
17739
18999
  }
17740
19000
 
17741
19001
  if (payload.positionTargetId !== undefined) {
@@ -17745,6 +19005,16 @@ const COMMAND_DEFINITIONS = [
17745
19005
  );
17746
19006
  }
17747
19007
 
19008
+ const targetNode = findTreeNode({
19009
+ nodes: collection.tree,
19010
+ nodeId: payload.positionTargetId,
19011
+ });
19012
+ if (!targetNode) {
19013
+ return invalidPrecondition(
19014
+ "payload.positionTargetId must reference an existing sprite item in the tree",
19015
+ );
19016
+ }
19017
+
17748
19018
  const targetParentId = getNodeParentId({
17749
19019
  tree: collection.tree,
17750
19020
  nodeId: payload.positionTargetId,
@@ -18076,6 +19346,11 @@ const COMMAND_DEFINITIONS = [
18076
19346
  nodes: collection.tree,
18077
19347
  nodeId: payload.spriteId,
18078
19348
  });
19349
+ if (!currentNode) {
19350
+ return invalidPrecondition(
19351
+ "payload.spriteId must reference an existing sprite item in the tree",
19352
+ );
19353
+ }
18079
19354
 
18080
19355
  if (payload.parentId !== undefined && payload.parentId !== null) {
18081
19356
  const parentItem = collection.items[payload.parentId];
@@ -18085,6 +19360,16 @@ const COMMAND_DEFINITIONS = [
18085
19360
  );
18086
19361
  }
18087
19362
 
19363
+ const parentNode = findTreeNode({
19364
+ nodes: collection.tree,
19365
+ nodeId: payload.parentId,
19366
+ });
19367
+ if (!parentNode) {
19368
+ return invalidPrecondition(
19369
+ "payload.parentId must reference a folder sprite item in the tree",
19370
+ );
19371
+ }
19372
+
18088
19373
  const descendantIds = new Set(
18089
19374
  collectTreeDescendantIds({
18090
19375
  node: currentNode,
@@ -18111,6 +19396,16 @@ const COMMAND_DEFINITIONS = [
18111
19396
  );
18112
19397
  }
18113
19398
 
19399
+ const targetNode = findTreeNode({
19400
+ nodes: collection.tree,
19401
+ nodeId: payload.positionTargetId,
19402
+ });
19403
+ if (!targetNode) {
19404
+ return invalidPrecondition(
19405
+ "payload.positionTargetId must reference an existing sprite item in the tree",
19406
+ );
19407
+ }
19408
+
18114
19409
  const targetParentId = getNodeParentId({
18115
19410
  tree: collection.tree,
18116
19411
  nodeId: payload.positionTargetId,