@routevn/creator-model 1.1.4 → 1.1.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/model.js CHANGED
@@ -38,6 +38,7 @@ const createEmptyCollectionState = () => ({
38
38
  items: {},
39
39
  tree: [],
40
40
  });
41
+
41
42
  const normalizeStateCollections = (state) => {
42
43
  if (!isPlainObject(state)) {
43
44
  return state;
@@ -108,7 +109,15 @@ const ANIMATION_EASING_KEYS = [
108
109
  ];
109
110
  const VARIABLE_SCOPE_KEYS = ["context", "global-device", "global-account"];
110
111
  const VARIABLE_TYPE_KEYS = ["string", "number", "boolean"];
111
- const LAYOUT_TYPE_KEYS = ["normal", "dialogue", "nvl", "choice"];
112
+ const LAYOUT_TYPE_KEYS = [
113
+ "normal",
114
+ "save-load",
115
+ "confirmDialog",
116
+ "dialogue",
117
+ "nvl",
118
+ "choice",
119
+ "history",
120
+ ];
112
121
  const LAYOUT_ELEMENT_TEXT_STYLE_ALIGN_KEYS = ["left", "center", "right"];
113
122
  const LAYOUT_TEXT_REVEAL_EFFECT_KEYS = ["typewriter", "softWipe", "none"];
114
123
  const LAYOUT_ELEMENT_BASE_TYPES = [
@@ -122,17 +131,30 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
122
131
  "text-ref-character-name",
123
132
  "text-revealing-ref-dialogue-content",
124
133
  "text-ref-choice-item-content",
134
+ "text-ref-save-load-slot-date",
125
135
  "text-ref-dialogue-line-character-name",
126
136
  "text-ref-dialogue-line-content",
137
+ "text-ref-history-line-character-name",
138
+ "text-ref-history-line-content",
139
+ "sprite-ref-save-load-slot-image",
140
+ "fragment-ref",
127
141
  "container-ref-choice-item",
142
+ "container-ref-save-load-slot",
128
143
  "container-ref-dialogue-line",
144
+ "container-ref-history-line",
145
+ "container-ref-confirm-dialog-ok",
146
+ "container-ref-confirm-dialog-cancel",
129
147
  ];
130
148
  export const SCHEMA_VERSION = 2;
131
149
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
132
150
  "folder",
133
151
  "container",
134
152
  "container-ref-choice-item",
153
+ "container-ref-save-load-slot",
135
154
  "container-ref-dialogue-line",
155
+ "container-ref-history-line",
156
+ "container-ref-confirm-dialog-ok",
157
+ "container-ref-confirm-dialog-cancel",
136
158
  ];
137
159
  const DOMAIN_ERROR_KIND_BY_NAME = {
138
160
  PayloadValidationError: "payload",
@@ -230,6 +252,81 @@ const isVariableReferenceTarget = (state, variableId) => {
230
252
  return isPlainObject(variable) && variable.type !== "folder";
231
253
  };
232
254
 
255
+ const LAYOUT_CONDITION_TARGET_SET = new Set([
256
+ "item.savedAt",
257
+ "isLineCompleted",
258
+ "autoMode",
259
+ "skipMode",
260
+ ]);
261
+ const VARIABLE_TARGET_DOT_PATTERN = /^variables\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
262
+ const VARIABLE_TARGET_BRACKET_PATTERN = /^variables\[(.+)\]$/;
263
+
264
+ const parseLayoutConditionTarget = (target) => {
265
+ if (!isNonEmptyString(target)) {
266
+ return undefined;
267
+ }
268
+
269
+ if (LAYOUT_CONDITION_TARGET_SET.has(target)) {
270
+ return {
271
+ kind: "runtime",
272
+ target,
273
+ };
274
+ }
275
+
276
+ const dotMatch = target.match(VARIABLE_TARGET_DOT_PATTERN);
277
+ if (dotMatch) {
278
+ return {
279
+ kind: "variable",
280
+ target,
281
+ variableId: dotMatch[1],
282
+ };
283
+ }
284
+
285
+ const bracketMatch = target.match(VARIABLE_TARGET_BRACKET_PATTERN);
286
+ if (!bracketMatch) {
287
+ return undefined;
288
+ }
289
+
290
+ const rawValue = bracketMatch[1].trim();
291
+
292
+ try {
293
+ const variableId = JSON.parse(rawValue);
294
+ if (isNonEmptyString(variableId)) {
295
+ return {
296
+ kind: "variable",
297
+ target,
298
+ variableId,
299
+ };
300
+ }
301
+ } catch {}
302
+
303
+ if (rawValue.startsWith("'") && rawValue.endsWith("'")) {
304
+ const variableId = rawValue.slice(1, -1);
305
+ if (isNonEmptyString(variableId)) {
306
+ return {
307
+ kind: "variable",
308
+ target,
309
+ variableId,
310
+ };
311
+ }
312
+ }
313
+
314
+ return undefined;
315
+ };
316
+
317
+ const isLayoutConditionTarget = (state, target) => {
318
+ const parsedTarget = parseLayoutConditionTarget(target);
319
+ if (!parsedTarget) {
320
+ return false;
321
+ }
322
+
323
+ if (parsedTarget.kind === "runtime") {
324
+ return true;
325
+ }
326
+
327
+ return isVariableReferenceTarget(state, parsedTarget.variableId);
328
+ };
329
+
233
330
  const toDomainErrorDetails = (publicError) => {
234
331
  const details = isPlainObject(publicError?.details)
235
332
  ? { ...publicError.details }
@@ -2181,6 +2278,29 @@ const validateLayoutElementBorder = ({ border, path, errorFactory }) => {
2181
2278
  }
2182
2279
  };
2183
2280
 
2281
+ const validateLayoutElementInteraction = ({
2282
+ interaction,
2283
+ path,
2284
+ errorFactory,
2285
+ }) => {
2286
+ if (!isPlainObject(interaction)) {
2287
+ return invalidFromErrorFactory(
2288
+ errorFactory,
2289
+ `${path} must be an object when provided`,
2290
+ );
2291
+ }
2292
+
2293
+ if (
2294
+ interaction.inheritToChildren !== undefined &&
2295
+ typeof interaction.inheritToChildren !== "boolean"
2296
+ ) {
2297
+ return invalidFromErrorFactory(
2298
+ errorFactory,
2299
+ `${path}.inheritToChildren must be a boolean when provided`,
2300
+ );
2301
+ }
2302
+ };
2303
+
2184
2304
  const validateLayoutElementData = ({
2185
2305
  data,
2186
2306
  path,
@@ -2216,10 +2336,14 @@ const validateLayoutElementData = ({
2216
2336
  "textStyleId",
2217
2337
  "hoverTextStyleId",
2218
2338
  "clickTextStyleId",
2339
+ "conditionalOverrides",
2219
2340
  "direction",
2220
2341
  "gap",
2221
2342
  "containerType",
2222
2343
  "scroll",
2344
+ "hover",
2345
+ "click",
2346
+ "rightClick",
2223
2347
  "anchorToBottom",
2224
2348
  "thumbImageId",
2225
2349
  "barImageId",
@@ -2230,9 +2354,11 @@ const validateLayoutElementData = ({
2230
2354
  "step",
2231
2355
  "initialValue",
2232
2356
  "variableId",
2357
+ "fragmentLayoutId",
2358
+ "paginationMode",
2359
+ "paginationVariableId",
2360
+ "paginationSize",
2233
2361
  "$when",
2234
- "click",
2235
- "rightClick",
2236
2362
  "change",
2237
2363
  ];
2238
2364
 
@@ -2281,6 +2407,7 @@ const validateLayoutElementData = ({
2281
2407
  "min",
2282
2408
  "max",
2283
2409
  "step",
2410
+ "paginationSize",
2284
2411
  "opacity",
2285
2412
  ]) {
2286
2413
  if (data[key] !== undefined && !isFiniteNumber(data[key])) {
@@ -2322,6 +2449,9 @@ const validateLayoutElementData = ({
2322
2449
  "clickTextStyleId",
2323
2450
  "containerType",
2324
2451
  "variableId",
2452
+ "fragmentLayoutId",
2453
+ "paginationMode",
2454
+ "paginationVariableId",
2325
2455
  "revealEffect",
2326
2456
  "thumbImageId",
2327
2457
  "barImageId",
@@ -2337,6 +2467,191 @@ const validateLayoutElementData = ({
2337
2467
  }
2338
2468
  }
2339
2469
 
2470
+ if (data.conditionalOverrides !== undefined) {
2471
+ if (!Array.isArray(data.conditionalOverrides)) {
2472
+ return invalidFromErrorFactory(
2473
+ errorFactory,
2474
+ `${path}.conditionalOverrides must be an array when provided`,
2475
+ );
2476
+ }
2477
+
2478
+ for (let index = 0; index < data.conditionalOverrides.length; index += 1) {
2479
+ const rule = data.conditionalOverrides[index];
2480
+ const rulePath = `${path}.conditionalOverrides.${index}`;
2481
+
2482
+ if (!isPlainObject(rule)) {
2483
+ return invalidFromErrorFactory(
2484
+ errorFactory,
2485
+ `${rulePath} must be an object`,
2486
+ );
2487
+ }
2488
+
2489
+ {
2490
+ const result = validateAllowedKeys({
2491
+ value: rule,
2492
+ allowedKeys: ["when", "set"],
2493
+ path: rulePath,
2494
+ errorFactory,
2495
+ });
2496
+ if (result?.valid === false) {
2497
+ return result;
2498
+ }
2499
+ }
2500
+
2501
+ if (!isPlainObject(rule.when)) {
2502
+ return invalidFromErrorFactory(
2503
+ errorFactory,
2504
+ `${rulePath}.when must be an object`,
2505
+ );
2506
+ }
2507
+
2508
+ {
2509
+ const result = validateAllowedKeys({
2510
+ value: rule.when,
2511
+ allowedKeys: ["target", "op", "value"],
2512
+ path: `${rulePath}.when`,
2513
+ errorFactory,
2514
+ });
2515
+ if (result?.valid === false) {
2516
+ return result;
2517
+ }
2518
+ }
2519
+
2520
+ if (!isNonEmptyString(rule.when.target)) {
2521
+ return invalidFromErrorFactory(
2522
+ errorFactory,
2523
+ `${rulePath}.when.target must be a non-empty string`,
2524
+ );
2525
+ }
2526
+
2527
+ if (!parseLayoutConditionTarget(rule.when.target)) {
2528
+ return invalidFromErrorFactory(
2529
+ errorFactory,
2530
+ `${rulePath}.when.target must be a supported layout condition target`,
2531
+ );
2532
+ }
2533
+
2534
+ if (
2535
+ !isString(rule.when.value) &&
2536
+ typeof rule.when.value !== "boolean" &&
2537
+ !isFiniteNumber(rule.when.value)
2538
+ ) {
2539
+ return invalidFromErrorFactory(
2540
+ errorFactory,
2541
+ `${rulePath}.when.value must be a string, boolean, or finite number`,
2542
+ );
2543
+ }
2544
+
2545
+ if (rule.when.op !== "eq") {
2546
+ return invalidFromErrorFactory(
2547
+ errorFactory,
2548
+ `${rulePath}.when.op must be "eq"`,
2549
+ );
2550
+ }
2551
+
2552
+ if (!isPlainObject(rule.set)) {
2553
+ return invalidFromErrorFactory(
2554
+ errorFactory,
2555
+ `${rulePath}.set must be an object`,
2556
+ );
2557
+ }
2558
+
2559
+ {
2560
+ const result = validateAllowedKeys({
2561
+ value: rule.set,
2562
+ allowedKeys: [
2563
+ "textStyleId",
2564
+ "hoverTextStyleId",
2565
+ "clickTextStyleId",
2566
+ "imageId",
2567
+ "hoverImageId",
2568
+ "clickImageId",
2569
+ "opacity",
2570
+ "anchorX",
2571
+ "anchorY",
2572
+ "visible",
2573
+ "textStyle",
2574
+ ],
2575
+ path: `${rulePath}.set`,
2576
+ errorFactory,
2577
+ });
2578
+ if (result?.valid === false) {
2579
+ return result;
2580
+ }
2581
+ }
2582
+
2583
+ for (const field of [
2584
+ "textStyleId",
2585
+ "hoverTextStyleId",
2586
+ "clickTextStyleId",
2587
+ "imageId",
2588
+ "hoverImageId",
2589
+ "clickImageId",
2590
+ ]) {
2591
+ if (
2592
+ rule.set[field] !== undefined &&
2593
+ !isNonEmptyString(rule.set[field])
2594
+ ) {
2595
+ return invalidFromErrorFactory(
2596
+ errorFactory,
2597
+ `${rulePath}.set.${field} must be a non-empty string when provided`,
2598
+ );
2599
+ }
2600
+ }
2601
+
2602
+ if (
2603
+ rule.set.opacity !== undefined &&
2604
+ (!isFiniteNumber(rule.set.opacity) ||
2605
+ rule.set.opacity < 0 ||
2606
+ rule.set.opacity > 1)
2607
+ ) {
2608
+ return invalidFromErrorFactory(
2609
+ errorFactory,
2610
+ `${rulePath}.set.opacity must be a finite number between 0 and 1 when provided`,
2611
+ );
2612
+ }
2613
+
2614
+ for (const field of ["anchorX", "anchorY"]) {
2615
+ if (rule.set[field] !== undefined && !isFiniteNumber(rule.set[field])) {
2616
+ return invalidFromErrorFactory(
2617
+ errorFactory,
2618
+ `${rulePath}.set.${field} must be a finite number when provided`,
2619
+ );
2620
+ }
2621
+ }
2622
+
2623
+ if (
2624
+ rule.set.visible !== undefined &&
2625
+ typeof rule.set.visible !== "boolean"
2626
+ ) {
2627
+ return invalidFromErrorFactory(
2628
+ errorFactory,
2629
+ `${rulePath}.set.visible must be a boolean when provided`,
2630
+ );
2631
+ }
2632
+
2633
+ if (rule.set.textStyle !== undefined) {
2634
+ if (!isPlainObject(rule.set.textStyle)) {
2635
+ return invalidFromErrorFactory(
2636
+ errorFactory,
2637
+ `${rulePath}.set.textStyle must be an object when provided`,
2638
+ );
2639
+ }
2640
+
2641
+ {
2642
+ const result = validateLayoutElementTextStyle({
2643
+ textStyle: rule.set.textStyle,
2644
+ path: `${rulePath}.set.textStyle`,
2645
+ errorFactory,
2646
+ });
2647
+ if (result?.valid === false) {
2648
+ return result;
2649
+ }
2650
+ }
2651
+ }
2652
+ }
2653
+ }
2654
+
2340
2655
  if (data.fill !== undefined && !isString(data.fill)) {
2341
2656
  return invalidFromErrorFactory(
2342
2657
  errorFactory,
@@ -2365,6 +2680,17 @@ const validateLayoutElementData = ({
2365
2680
  );
2366
2681
  }
2367
2682
 
2683
+ if (
2684
+ data.paginationMode !== undefined &&
2685
+ data.paginationMode !== "continuous" &&
2686
+ data.paginationMode !== "paginated"
2687
+ ) {
2688
+ return invalidFromErrorFactory(
2689
+ errorFactory,
2690
+ `${path}.paginationMode must be 'continuous' or 'paginated' when provided`,
2691
+ );
2692
+ }
2693
+
2368
2694
  if (data.scroll !== undefined && typeof data.scroll !== "boolean") {
2369
2695
  return invalidFromErrorFactory(
2370
2696
  errorFactory,
@@ -2372,6 +2698,45 @@ const validateLayoutElementData = ({
2372
2698
  );
2373
2699
  }
2374
2700
 
2701
+ if (data.hover !== undefined) {
2702
+ {
2703
+ const result = validateLayoutElementInteraction({
2704
+ interaction: data.hover,
2705
+ path: `${path}.hover`,
2706
+ errorFactory,
2707
+ });
2708
+ if (result?.valid === false) {
2709
+ return result;
2710
+ }
2711
+ }
2712
+ }
2713
+
2714
+ if (data.click !== undefined) {
2715
+ {
2716
+ const result = validateLayoutElementInteraction({
2717
+ interaction: data.click,
2718
+ path: `${path}.click`,
2719
+ errorFactory,
2720
+ });
2721
+ if (result?.valid === false) {
2722
+ return result;
2723
+ }
2724
+ }
2725
+ }
2726
+
2727
+ if (data.rightClick !== undefined) {
2728
+ {
2729
+ const result = validateLayoutElementInteraction({
2730
+ interaction: data.rightClick,
2731
+ path: `${path}.rightClick`,
2732
+ errorFactory,
2733
+ });
2734
+ if (result?.valid === false) {
2735
+ return result;
2736
+ }
2737
+ }
2738
+ }
2739
+
2375
2740
  if (
2376
2741
  data.anchorToBottom !== undefined &&
2377
2742
  typeof data.anchorToBottom !== "boolean"
@@ -2415,20 +2780,6 @@ const validateLayoutElementData = ({
2415
2780
  }
2416
2781
  }
2417
2782
 
2418
- if (data.click !== undefined && !isPlainObject(data.click)) {
2419
- return invalidFromErrorFactory(
2420
- errorFactory,
2421
- `${path}.click must be an object when provided`,
2422
- );
2423
- }
2424
-
2425
- if (data.rightClick !== undefined && !isPlainObject(data.rightClick)) {
2426
- return invalidFromErrorFactory(
2427
- errorFactory,
2428
- `${path}.rightClick must be an object when provided`,
2429
- );
2430
- }
2431
-
2432
2783
  if (data.change !== undefined && !isPlainObject(data.change)) {
2433
2784
  return invalidFromErrorFactory(
2434
2785
  errorFactory,
@@ -2470,10 +2821,14 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2470
2821
  "textStyleId",
2471
2822
  "hoverTextStyleId",
2472
2823
  "clickTextStyleId",
2824
+ "conditionalOverrides",
2473
2825
  "direction",
2474
2826
  "gap",
2475
2827
  "containerType",
2476
2828
  "scroll",
2829
+ "hover",
2830
+ "click",
2831
+ "rightClick",
2477
2832
  "anchorToBottom",
2478
2833
  "thumbImageId",
2479
2834
  "barImageId",
@@ -2484,9 +2839,11 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2484
2839
  "step",
2485
2840
  "initialValue",
2486
2841
  "variableId",
2842
+ "fragmentLayoutId",
2843
+ "paginationMode",
2844
+ "paginationVariableId",
2845
+ "paginationSize",
2487
2846
  "$when",
2488
- "click",
2489
- "rightClick",
2490
2847
  "change",
2491
2848
  ],
2492
2849
  path: itemPath,
@@ -2684,7 +3041,16 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2684
3041
  allowedKeys:
2685
3042
  item.type === "folder"
2686
3043
  ? ["id", "type", "name"]
2687
- : ["id", "type", "name", "layoutType", "elements"],
3044
+ : [
3045
+ "id",
3046
+ "type",
3047
+ "name",
3048
+ "description",
3049
+ "layoutType",
3050
+ "isFragment",
3051
+ "thumbnailFileId",
3052
+ "elements",
3053
+ ],
2688
3054
  path: itemPath,
2689
3055
  errorFactory,
2690
3056
  });
@@ -2714,11 +3080,38 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2714
3080
  );
2715
3081
  }
2716
3082
 
3083
+ if (item.description !== undefined && !isString(item.description)) {
3084
+ return invalidFromErrorFactory(
3085
+ errorFactory,
3086
+ `${itemPath}.description must be a string when provided`,
3087
+ );
3088
+ }
3089
+
3090
+ if (
3091
+ item.thumbnailFileId !== undefined &&
3092
+ !isNonEmptyString(item.thumbnailFileId)
3093
+ ) {
3094
+ return invalidFromErrorFactory(
3095
+ errorFactory,
3096
+ `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
3097
+ );
3098
+ }
3099
+
2717
3100
  if (item.type === "layout") {
2718
3101
  if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
2719
3102
  return invalidFromErrorFactory(
2720
3103
  errorFactory,
2721
- `${itemPath}.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'`,
3104
+ `${itemPath}.layoutType must be 'normal', 'save-load', 'confirmDialog', 'dialogue', 'nvl', 'choice', or 'history'`,
3105
+ );
3106
+ }
3107
+
3108
+ if (
3109
+ item.isFragment !== undefined &&
3110
+ typeof item.isFragment !== "boolean"
3111
+ ) {
3112
+ return invalidFromErrorFactory(
3113
+ errorFactory,
3114
+ `${itemPath}.isFragment must be a boolean when provided`,
2722
3115
  );
2723
3116
  }
2724
3117
 
@@ -2755,7 +3148,7 @@ const validateControlItems = ({ items, path, errorFactory }) => {
2755
3148
  allowedKeys:
2756
3149
  item.type === "folder"
2757
3150
  ? ["id", "type", "name"]
2758
- : ["id", "type", "name", "elements", "keyboard"],
3151
+ : ["id", "type", "name", "thumbnailFileId", "elements", "keyboard"],
2759
3152
  path: itemPath,
2760
3153
  errorFactory,
2761
3154
  });
@@ -2785,6 +3178,16 @@ const validateControlItems = ({ items, path, errorFactory }) => {
2785
3178
  );
2786
3179
  }
2787
3180
 
3181
+ if (
3182
+ item.thumbnailFileId !== undefined &&
3183
+ !isNonEmptyString(item.thumbnailFileId)
3184
+ ) {
3185
+ return invalidFromErrorFactory(
3186
+ errorFactory,
3187
+ `${itemPath}.thumbnailFileId must be a non-empty string when provided`,
3188
+ );
3189
+ }
3190
+
2788
3191
  if (item.type === "control") {
2789
3192
  {
2790
3193
  const result = validateNestedCollection({
@@ -3854,6 +4257,40 @@ export const assertInvariants = ({ state }) => {
3854
4257
  }
3855
4258
  }
3856
4259
 
4260
+ for (const [layoutId, layout] of Object.entries(state.layouts.items)) {
4261
+ if (layout.type !== "layout" || layout.thumbnailFileId === undefined) {
4262
+ continue;
4263
+ }
4264
+
4265
+ const result = validateFileReference({
4266
+ state,
4267
+ fileId: layout.thumbnailFileId,
4268
+ path: "layout.thumbnailFileId",
4269
+ details: { layoutId, thumbnailFileId: layout.thumbnailFileId },
4270
+ errorFactory: createInvariantValidationError,
4271
+ });
4272
+ if (!result.valid) {
4273
+ return result;
4274
+ }
4275
+ }
4276
+
4277
+ for (const [controlId, control] of Object.entries(state.controls.items)) {
4278
+ if (control.type !== "control" || control.thumbnailFileId === undefined) {
4279
+ continue;
4280
+ }
4281
+
4282
+ const result = validateFileReference({
4283
+ state,
4284
+ fileId: control.thumbnailFileId,
4285
+ path: "control.thumbnailFileId",
4286
+ details: { controlId, thumbnailFileId: control.thumbnailFileId },
4287
+ errorFactory: createInvariantValidationError,
4288
+ });
4289
+ if (!result.valid) {
4290
+ return result;
4291
+ }
4292
+ }
4293
+
3857
4294
  const assertImageReference = ({
3858
4295
  ownerIdField,
3859
4296
  ownerId,
@@ -3923,6 +4360,32 @@ export const assertInvariants = ({ state }) => {
3923
4360
  return VALID_RESULT;
3924
4361
  };
3925
4362
 
4363
+ const assertFragmentLayoutReference = ({
4364
+ ownerIdField,
4365
+ ownerId,
4366
+ ownerLabel,
4367
+ elementId,
4368
+ targetId,
4369
+ }) => {
4370
+ const layout = state.layouts.items[targetId];
4371
+ if (
4372
+ !isPlainObject(layout) ||
4373
+ layout.type !== "layout" ||
4374
+ layout.isFragment !== true
4375
+ ) {
4376
+ return invalidInvariant(
4377
+ `${ownerLabel} element fragmentLayoutId must reference an existing fragment layout`,
4378
+ {
4379
+ [ownerIdField]: ownerId,
4380
+ elementId,
4381
+ fragmentLayoutId: targetId,
4382
+ },
4383
+ );
4384
+ }
4385
+
4386
+ return VALID_RESULT;
4387
+ };
4388
+
3926
4389
  const assertElementReferencesForCollection = ({
3927
4390
  items,
3928
4391
  ownerIdField,
@@ -3979,6 +4442,51 @@ export const assertInvariants = ({ state }) => {
3979
4442
  }
3980
4443
  }
3981
4444
 
4445
+ if (Array.isArray(element.conditionalOverrides)) {
4446
+ for (
4447
+ let index = 0;
4448
+ index < element.conditionalOverrides.length;
4449
+ index += 1
4450
+ ) {
4451
+ const rule = element.conditionalOverrides[index];
4452
+
4453
+ for (const field of [
4454
+ "textStyleId",
4455
+ "hoverTextStyleId",
4456
+ "clickTextStyleId",
4457
+ ]) {
4458
+ if (rule?.set?.[field] !== undefined) {
4459
+ const result = assertTextStyleReference({
4460
+ ownerIdField,
4461
+ ownerId,
4462
+ ownerLabel,
4463
+ elementId,
4464
+ field: `conditionalOverrides.${index}.set.${field}`,
4465
+ targetId: rule.set[field],
4466
+ });
4467
+ if (!result.valid) {
4468
+ return result;
4469
+ }
4470
+ }
4471
+ }
4472
+
4473
+ if (
4474
+ rule?.when?.target !== undefined &&
4475
+ !isLayoutConditionTarget(state, rule.when.target)
4476
+ ) {
4477
+ return invalidInvariant(
4478
+ `${ownerLabel} element conditionalOverrides when target must reference an existing variable or supported runtime condition`,
4479
+ {
4480
+ [ownerIdField]: ownerId,
4481
+ elementId,
4482
+ field: `conditionalOverrides.${index}.when.target`,
4483
+ targetId: rule.when.target,
4484
+ },
4485
+ );
4486
+ }
4487
+ }
4488
+ }
4489
+
3982
4490
  if (element.variableId !== undefined) {
3983
4491
  const result = assertVariableReference({
3984
4492
  ownerIdField,
@@ -3991,6 +4499,19 @@ export const assertInvariants = ({ state }) => {
3991
4499
  return result;
3992
4500
  }
3993
4501
  }
4502
+
4503
+ if (element.fragmentLayoutId !== undefined) {
4504
+ const result = assertFragmentLayoutReference({
4505
+ ownerIdField,
4506
+ ownerId,
4507
+ ownerLabel,
4508
+ elementId,
4509
+ targetId: element.fragmentLayoutId,
4510
+ });
4511
+ if (!result.valid) {
4512
+ return result;
4513
+ }
4514
+ }
3994
4515
  }
3995
4516
  }
3996
4517
 
@@ -6136,7 +6657,15 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
6136
6657
  allowedKeys:
6137
6658
  data.type === "folder"
6138
6659
  ? ["type", "name"]
6139
- : ["type", "name", "layoutType", "elements"],
6660
+ : [
6661
+ "type",
6662
+ "name",
6663
+ "description",
6664
+ "layoutType",
6665
+ "isFragment",
6666
+ "thumbnailFileId",
6667
+ "elements",
6668
+ ],
6140
6669
  path: "payload.data",
6141
6670
  errorFactory,
6142
6671
  });
@@ -6152,11 +6681,35 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
6152
6681
  );
6153
6682
  }
6154
6683
 
6684
+ if (data.description !== undefined && !isString(data.description)) {
6685
+ return invalidFromErrorFactory(
6686
+ errorFactory,
6687
+ "payload.data.description must be a string when provided",
6688
+ );
6689
+ }
6690
+
6691
+ if (
6692
+ data.thumbnailFileId !== undefined &&
6693
+ !isNonEmptyString(data.thumbnailFileId)
6694
+ ) {
6695
+ return invalidFromErrorFactory(
6696
+ errorFactory,
6697
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6698
+ );
6699
+ }
6700
+
6155
6701
  if (data.type === "layout") {
6156
6702
  if (!LAYOUT_TYPE_KEYS.includes(data.layoutType)) {
6157
6703
  return invalidFromErrorFactory(
6158
6704
  errorFactory,
6159
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'",
6705
+ "payload.data.layoutType must be 'normal', 'save-load', 'confirmDialog', 'dialogue', 'nvl', 'choice', or 'history'",
6706
+ );
6707
+ }
6708
+
6709
+ if (data.isFragment !== undefined && typeof data.isFragment !== "boolean") {
6710
+ return invalidFromErrorFactory(
6711
+ errorFactory,
6712
+ "payload.data.isFragment must be a boolean when provided",
6160
6713
  );
6161
6714
  }
6162
6715
 
@@ -6179,7 +6732,13 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
6179
6732
  {
6180
6733
  const result = validateAllowedKeys({
6181
6734
  value: data,
6182
- allowedKeys: ["name", "layoutType"],
6735
+ allowedKeys: [
6736
+ "name",
6737
+ "description",
6738
+ "layoutType",
6739
+ "isFragment",
6740
+ "thumbnailFileId",
6741
+ ],
6183
6742
  path: "payload.data",
6184
6743
  errorFactory,
6185
6744
  });
@@ -6202,13 +6761,37 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
6202
6761
  );
6203
6762
  }
6204
6763
 
6764
+ if (data.description !== undefined && !isString(data.description)) {
6765
+ return invalidFromErrorFactory(
6766
+ errorFactory,
6767
+ "payload.data.description must be a string when provided",
6768
+ );
6769
+ }
6770
+
6771
+ if (
6772
+ data.thumbnailFileId !== undefined &&
6773
+ !isNonEmptyString(data.thumbnailFileId)
6774
+ ) {
6775
+ return invalidFromErrorFactory(
6776
+ errorFactory,
6777
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6778
+ );
6779
+ }
6780
+
6205
6781
  if (
6206
6782
  data.layoutType !== undefined &&
6207
6783
  !LAYOUT_TYPE_KEYS.includes(data.layoutType)
6208
6784
  ) {
6209
6785
  return invalidFromErrorFactory(
6210
6786
  errorFactory,
6211
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice' when provided",
6787
+ "payload.data.layoutType must be 'normal', 'save-load', 'confirmDialog', 'dialogue', 'nvl', 'choice', or 'history' when provided",
6788
+ );
6789
+ }
6790
+
6791
+ if (data.isFragment !== undefined && typeof data.isFragment !== "boolean") {
6792
+ return invalidFromErrorFactory(
6793
+ errorFactory,
6794
+ "payload.data.isFragment must be a boolean when provided",
6212
6795
  );
6213
6796
  }
6214
6797
  };
@@ -6234,7 +6817,7 @@ const validateControlCreateData = ({ data, errorFactory }) => {
6234
6817
  allowedKeys:
6235
6818
  data.type === "folder"
6236
6819
  ? ["type", "name"]
6237
- : ["type", "name", "elements", "keyboard"],
6820
+ : ["type", "name", "thumbnailFileId", "elements", "keyboard"],
6238
6821
  path: "payload.data",
6239
6822
  errorFactory,
6240
6823
  });
@@ -6250,6 +6833,16 @@ const validateControlCreateData = ({ data, errorFactory }) => {
6250
6833
  );
6251
6834
  }
6252
6835
 
6836
+ if (
6837
+ data.thumbnailFileId !== undefined &&
6838
+ !isNonEmptyString(data.thumbnailFileId)
6839
+ ) {
6840
+ return invalidFromErrorFactory(
6841
+ errorFactory,
6842
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6843
+ );
6844
+ }
6845
+
6253
6846
  if (data.type === "control") {
6254
6847
  {
6255
6848
  const result = validateNestedCollection({
@@ -6281,7 +6874,7 @@ const validateControlUpdateData = ({ data, errorFactory }) => {
6281
6874
  {
6282
6875
  const result = validateAllowedKeys({
6283
6876
  value: data,
6284
- allowedKeys: ["name", "keyboard"],
6877
+ allowedKeys: ["name", "keyboard", "thumbnailFileId"],
6285
6878
  path: "payload.data",
6286
6879
  errorFactory,
6287
6880
  });
@@ -6304,6 +6897,16 @@ const validateControlUpdateData = ({ data, errorFactory }) => {
6304
6897
  );
6305
6898
  }
6306
6899
 
6900
+ if (
6901
+ data.thumbnailFileId !== undefined &&
6902
+ !isNonEmptyString(data.thumbnailFileId)
6903
+ ) {
6904
+ return invalidFromErrorFactory(
6905
+ errorFactory,
6906
+ "payload.data.thumbnailFileId must be a non-empty string when provided",
6907
+ );
6908
+ }
6909
+
6307
6910
  {
6308
6911
  const result = validateKeyboardMap({
6309
6912
  value: data.keyboard,
@@ -6519,6 +7122,69 @@ const validateVisualElementReferenceTargets = ({
6519
7122
  }
6520
7123
  }
6521
7124
 
7125
+ if (Array.isArray(data.conditionalOverrides)) {
7126
+ for (let index = 0; index < data.conditionalOverrides.length; index += 1) {
7127
+ const rule = data.conditionalOverrides[index];
7128
+
7129
+ for (const field of [
7130
+ "textStyleId",
7131
+ "hoverTextStyleId",
7132
+ "clickTextStyleId",
7133
+ ]) {
7134
+ if (rule?.set?.[field] === undefined) {
7135
+ continue;
7136
+ }
7137
+
7138
+ const textStyle = state.textStyles.items[rule.set[field]];
7139
+ if (!isPlainObject(textStyle) || textStyle.type === "folder") {
7140
+ return invalidFromErrorFactory(
7141
+ errorFactory,
7142
+ `${ownerLabel} element conditionalOverrides.${index}.set.${field} must reference an existing non-folder text style`,
7143
+ {
7144
+ [ownerIdField]: ownerId,
7145
+ elementId,
7146
+ field: `conditionalOverrides.${index}.set.${field}`,
7147
+ targetId: rule.set[field],
7148
+ },
7149
+ );
7150
+ }
7151
+ }
7152
+
7153
+ for (const field of ["imageId", "hoverImageId", "clickImageId"]) {
7154
+ if (rule?.set?.[field] === undefined) {
7155
+ continue;
7156
+ }
7157
+
7158
+ const image = state.images.items[rule.set[field]];
7159
+ if (!isPlainObject(image) || image.type === "folder") {
7160
+ return invalidFromErrorFactory(
7161
+ errorFactory,
7162
+ `${ownerLabel} element conditionalOverrides.${index}.set.${field} must reference an existing non-folder image`,
7163
+ {
7164
+ [ownerIdField]: ownerId,
7165
+ elementId,
7166
+ field: `conditionalOverrides.${index}.set.${field}`,
7167
+ targetId: rule.set[field],
7168
+ },
7169
+ );
7170
+ }
7171
+ }
7172
+
7173
+ if (!isLayoutConditionTarget(state, rule?.when?.target)) {
7174
+ return invalidFromErrorFactory(
7175
+ errorFactory,
7176
+ `${ownerLabel} element conditionalOverrides.${index}.when.target must reference an existing variable or supported runtime condition`,
7177
+ {
7178
+ [ownerIdField]: ownerId,
7179
+ elementId,
7180
+ field: `conditionalOverrides.${index}.when.target`,
7181
+ targetId: rule?.when?.target,
7182
+ },
7183
+ );
7184
+ }
7185
+ }
7186
+ }
7187
+
6522
7188
  if (data.variableId !== undefined) {
6523
7189
  if (!isVariableReferenceTarget(state, data.variableId)) {
6524
7190
  return invalidFromErrorFactory(
@@ -6532,6 +7198,25 @@ const validateVisualElementReferenceTargets = ({
6532
7198
  );
6533
7199
  }
6534
7200
  }
7201
+
7202
+ if (data.fragmentLayoutId !== undefined) {
7203
+ const fragmentLayout = state.layouts.items[data.fragmentLayoutId];
7204
+ if (
7205
+ !isPlainObject(fragmentLayout) ||
7206
+ fragmentLayout.type !== "layout" ||
7207
+ fragmentLayout.isFragment !== true
7208
+ ) {
7209
+ return invalidFromErrorFactory(
7210
+ errorFactory,
7211
+ `${ownerLabel} element fragmentLayoutId must reference an existing fragment layout`,
7212
+ {
7213
+ [ownerIdField]: ownerId,
7214
+ elementId,
7215
+ fragmentLayoutId: data.fragmentLayoutId,
7216
+ },
7217
+ );
7218
+ }
7219
+ }
6535
7220
  };
6536
7221
 
6537
7222
  const getTreeIdsInOrder = ({ nodes }) => {
@@ -11198,12 +11883,33 @@ const COMMAND_DEFINITIONS = [
11198
11883
  name: payload.data.name,
11199
11884
  ...(payload.data.type === "layout"
11200
11885
  ? {
11886
+ description: payload.data.description,
11201
11887
  layoutType: payload.data.layoutType,
11888
+ isFragment: payload.data.isFragment,
11889
+ ...(payload.data.thumbnailFileId !== undefined
11890
+ ? {
11891
+ thumbnailFileId: payload.data.thumbnailFileId,
11892
+ }
11893
+ : {}),
11202
11894
  elements: structuredClone(payload.data.elements),
11203
11895
  }
11204
11896
  : {}),
11205
11897
  }),
11206
- validateUpdateState: ({ payload, currentItem }) => {
11898
+ validateCreateState: ({ state, payload }) => {
11899
+ if (payload.data.type !== "layout") {
11900
+ return;
11901
+ }
11902
+
11903
+ return validateReferencedFilesInData({
11904
+ state,
11905
+ data: payload.data,
11906
+ fields: ["thumbnailFileId"],
11907
+ details: {
11908
+ layoutId: payload.layoutId,
11909
+ },
11910
+ });
11911
+ },
11912
+ validateUpdateState: ({ state, payload, currentItem }) => {
11207
11913
  if (
11208
11914
  currentItem.type === "folder" &&
11209
11915
  Object.keys(payload.data).some((key) => key !== "name")
@@ -11212,6 +11918,17 @@ const COMMAND_DEFINITIONS = [
11212
11918
  "folder layout items cannot update layout fields",
11213
11919
  );
11214
11920
  }
11921
+
11922
+ if (currentItem.type === "layout") {
11923
+ return validateReferencedFilesInData({
11924
+ state,
11925
+ data: payload.data,
11926
+ fields: ["thumbnailFileId"],
11927
+ details: {
11928
+ layoutId: payload.layoutId,
11929
+ },
11930
+ });
11931
+ }
11215
11932
  },
11216
11933
  }),
11217
11934
  ...createFolderedCollectionCommandDefinitions({
@@ -11227,6 +11944,11 @@ const COMMAND_DEFINITIONS = [
11227
11944
  name: payload.data.name,
11228
11945
  ...(payload.data.type === "control"
11229
11946
  ? {
11947
+ ...(payload.data.thumbnailFileId !== undefined
11948
+ ? {
11949
+ thumbnailFileId: payload.data.thumbnailFileId,
11950
+ }
11951
+ : {}),
11230
11952
  elements: structuredClone(payload.data.elements),
11231
11953
  ...(payload.data.keyboard !== undefined
11232
11954
  ? {
@@ -11236,7 +11958,21 @@ const COMMAND_DEFINITIONS = [
11236
11958
  }
11237
11959
  : {}),
11238
11960
  }),
11239
- validateUpdateState: ({ payload, currentItem }) => {
11961
+ validateCreateState: ({ state, payload }) => {
11962
+ if (payload.data.type !== "control") {
11963
+ return;
11964
+ }
11965
+
11966
+ return validateReferencedFilesInData({
11967
+ state,
11968
+ data: payload.data,
11969
+ fields: ["thumbnailFileId"],
11970
+ details: {
11971
+ controlId: payload.controlId,
11972
+ },
11973
+ });
11974
+ },
11975
+ validateUpdateState: ({ state, payload, currentItem }) => {
11240
11976
  if (
11241
11977
  currentItem.type === "folder" &&
11242
11978
  Object.keys(payload.data).some((key) => key !== "name")
@@ -11245,6 +11981,17 @@ const COMMAND_DEFINITIONS = [
11245
11981
  "folder control items cannot update control fields",
11246
11982
  );
11247
11983
  }
11984
+
11985
+ if (currentItem.type === "control") {
11986
+ return validateReferencedFilesInData({
11987
+ state,
11988
+ data: payload.data,
11989
+ fields: ["thumbnailFileId"],
11990
+ details: {
11991
+ controlId: payload.controlId,
11992
+ },
11993
+ });
11994
+ }
11248
11995
  },
11249
11996
  }),
11250
11997
  {
@@ -21,6 +21,33 @@ export const SYSTEM_VARIABLE_GROUPS = Object.freeze([
21
21
  description:
22
22
  "Controls the default dialogue text speed stored for this device.",
23
23
  },
24
+ {
25
+ id: "_currentSaveLoadPagination",
26
+ name: "Current Save/Load Pagination",
27
+ scope: "context",
28
+ type: "number",
29
+ default: 0,
30
+ description:
31
+ "The current save/load pagination index. Resolves to 0, 1, 2, 3, and so on for the active save/load page.",
32
+ },
33
+ {
34
+ id: "_currentMenuPage",
35
+ name: "Current Menu Page",
36
+ scope: "context",
37
+ type: "string",
38
+ default: "",
39
+ description:
40
+ "The current menu page id for the active UI flow. Typical values include options, save, load, and history.",
41
+ },
42
+ {
43
+ id: "_menuEntryPoint",
44
+ name: "Menu Entry Point",
45
+ scope: "context",
46
+ type: "string",
47
+ default: "",
48
+ description:
49
+ "Indicates how the current menu flow was opened. Typical values include title and read.",
50
+ },
24
51
  ]),
25
52
  },
26
53
  ]);