@routevn/creator-model 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routevn/creator-model",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -5,3 +5,9 @@ export {
5
5
  validatePayload,
6
6
  validateState,
7
7
  } from "./model.js";
8
+ export {
9
+ SYSTEM_VARIABLE_GROUPS,
10
+ SYSTEM_VARIABLE_IDS,
11
+ getSystemVariableDefinitions,
12
+ isSystemVariableId,
13
+ } from "./systemVariables.js";
package/src/model.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  isPlainObject,
16
16
  removeTreeNode,
17
17
  } from "./helpers.js";
18
+ import { isSystemVariableId } from "./systemVariables.js";
18
19
 
19
20
  const COLLECTION_KEYS = [
20
21
  "scenes",
@@ -37,6 +38,7 @@ const createEmptyCollectionState = () => ({
37
38
  items: {},
38
39
  tree: [],
39
40
  });
41
+
40
42
  const normalizeStateCollections = (state) => {
41
43
  if (!isPlainObject(state)) {
42
44
  return state;
@@ -107,7 +109,14 @@ const ANIMATION_EASING_KEYS = [
107
109
  ];
108
110
  const VARIABLE_SCOPE_KEYS = ["context", "global-device", "global-account"];
109
111
  const VARIABLE_TYPE_KEYS = ["string", "number", "boolean"];
110
- 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
+ ];
111
120
  const LAYOUT_ELEMENT_TEXT_STYLE_ALIGN_KEYS = ["left", "center", "right"];
112
121
  const LAYOUT_TEXT_REVEAL_EFFECT_KEYS = ["typewriter", "softWipe", "none"];
113
122
  const LAYOUT_ELEMENT_BASE_TYPES = [
@@ -121,17 +130,26 @@ const LAYOUT_ELEMENT_BASE_TYPES = [
121
130
  "text-ref-character-name",
122
131
  "text-revealing-ref-dialogue-content",
123
132
  "text-ref-choice-item-content",
133
+ "text-ref-save-load-slot-date",
124
134
  "text-ref-dialogue-line-character-name",
125
135
  "text-ref-dialogue-line-content",
136
+ "sprite-ref-save-load-slot-image",
137
+ "fragment-ref",
126
138
  "container-ref-choice-item",
139
+ "container-ref-save-load-slot",
127
140
  "container-ref-dialogue-line",
141
+ "container-ref-confirm-dialog-ok",
142
+ "container-ref-confirm-dialog-cancel",
128
143
  ];
129
144
  export const SCHEMA_VERSION = 2;
130
145
  const LAYOUT_CONTAINER_ELEMENT_TYPES = [
131
146
  "folder",
132
147
  "container",
133
148
  "container-ref-choice-item",
149
+ "container-ref-save-load-slot",
134
150
  "container-ref-dialogue-line",
151
+ "container-ref-confirm-dialog-ok",
152
+ "container-ref-confirm-dialog-cancel",
135
153
  ];
136
154
  const DOMAIN_ERROR_KIND_BY_NAME = {
137
155
  PayloadValidationError: "payload",
@@ -220,6 +238,90 @@ const invalidInvariant = (message, details = {}) =>
220
238
  details,
221
239
  });
222
240
 
241
+ const isVariableReferenceTarget = (state, variableId) => {
242
+ if (isSystemVariableId(variableId)) {
243
+ return true;
244
+ }
245
+
246
+ const variable = state.variables.items[variableId];
247
+ return isPlainObject(variable) && variable.type !== "folder";
248
+ };
249
+
250
+ const LAYOUT_CONDITION_TARGET_SET = new Set([
251
+ "item.savedAt",
252
+ "isLineCompleted",
253
+ "autoMode",
254
+ "skipMode",
255
+ ]);
256
+ const VARIABLE_TARGET_DOT_PATTERN = /^variables\.([A-Za-z_$][A-Za-z0-9_$]*)$/;
257
+ const VARIABLE_TARGET_BRACKET_PATTERN = /^variables\[(.+)\]$/;
258
+
259
+ const parseLayoutConditionTarget = (target) => {
260
+ if (!isNonEmptyString(target)) {
261
+ return undefined;
262
+ }
263
+
264
+ if (LAYOUT_CONDITION_TARGET_SET.has(target)) {
265
+ return {
266
+ kind: "runtime",
267
+ target,
268
+ };
269
+ }
270
+
271
+ const dotMatch = target.match(VARIABLE_TARGET_DOT_PATTERN);
272
+ if (dotMatch) {
273
+ return {
274
+ kind: "variable",
275
+ target,
276
+ variableId: dotMatch[1],
277
+ };
278
+ }
279
+
280
+ const bracketMatch = target.match(VARIABLE_TARGET_BRACKET_PATTERN);
281
+ if (!bracketMatch) {
282
+ return undefined;
283
+ }
284
+
285
+ const rawValue = bracketMatch[1].trim();
286
+
287
+ try {
288
+ const variableId = JSON.parse(rawValue);
289
+ if (isNonEmptyString(variableId)) {
290
+ return {
291
+ kind: "variable",
292
+ target,
293
+ variableId,
294
+ };
295
+ }
296
+ } catch {}
297
+
298
+ if (rawValue.startsWith("'") && rawValue.endsWith("'")) {
299
+ const variableId = rawValue.slice(1, -1);
300
+ if (isNonEmptyString(variableId)) {
301
+ return {
302
+ kind: "variable",
303
+ target,
304
+ variableId,
305
+ };
306
+ }
307
+ }
308
+
309
+ return undefined;
310
+ };
311
+
312
+ const isLayoutConditionTarget = (state, target) => {
313
+ const parsedTarget = parseLayoutConditionTarget(target);
314
+ if (!parsedTarget) {
315
+ return false;
316
+ }
317
+
318
+ if (parsedTarget.kind === "runtime") {
319
+ return true;
320
+ }
321
+
322
+ return isVariableReferenceTarget(state, parsedTarget.variableId);
323
+ };
324
+
223
325
  const toDomainErrorDetails = (publicError) => {
224
326
  const details = isPlainObject(publicError?.details)
225
327
  ? { ...publicError.details }
@@ -2171,6 +2273,29 @@ const validateLayoutElementBorder = ({ border, path, errorFactory }) => {
2171
2273
  }
2172
2274
  };
2173
2275
 
2276
+ const validateLayoutElementInteraction = ({
2277
+ interaction,
2278
+ path,
2279
+ errorFactory,
2280
+ }) => {
2281
+ if (!isPlainObject(interaction)) {
2282
+ return invalidFromErrorFactory(
2283
+ errorFactory,
2284
+ `${path} must be an object when provided`,
2285
+ );
2286
+ }
2287
+
2288
+ if (
2289
+ interaction.inheritToChildren !== undefined &&
2290
+ typeof interaction.inheritToChildren !== "boolean"
2291
+ ) {
2292
+ return invalidFromErrorFactory(
2293
+ errorFactory,
2294
+ `${path}.inheritToChildren must be a boolean when provided`,
2295
+ );
2296
+ }
2297
+ };
2298
+
2174
2299
  const validateLayoutElementData = ({
2175
2300
  data,
2176
2301
  path,
@@ -2206,10 +2331,14 @@ const validateLayoutElementData = ({
2206
2331
  "textStyleId",
2207
2332
  "hoverTextStyleId",
2208
2333
  "clickTextStyleId",
2334
+ "conditionalOverrides",
2209
2335
  "direction",
2210
2336
  "gap",
2211
2337
  "containerType",
2212
2338
  "scroll",
2339
+ "hover",
2340
+ "click",
2341
+ "rightClick",
2213
2342
  "anchorToBottom",
2214
2343
  "thumbImageId",
2215
2344
  "barImageId",
@@ -2220,9 +2349,11 @@ const validateLayoutElementData = ({
2220
2349
  "step",
2221
2350
  "initialValue",
2222
2351
  "variableId",
2352
+ "fragmentLayoutId",
2353
+ "paginationMode",
2354
+ "paginationVariableId",
2355
+ "paginationSize",
2223
2356
  "$when",
2224
- "click",
2225
- "rightClick",
2226
2357
  "change",
2227
2358
  ];
2228
2359
 
@@ -2271,6 +2402,7 @@ const validateLayoutElementData = ({
2271
2402
  "min",
2272
2403
  "max",
2273
2404
  "step",
2405
+ "paginationSize",
2274
2406
  "opacity",
2275
2407
  ]) {
2276
2408
  if (data[key] !== undefined && !isFiniteNumber(data[key])) {
@@ -2312,6 +2444,9 @@ const validateLayoutElementData = ({
2312
2444
  "clickTextStyleId",
2313
2445
  "containerType",
2314
2446
  "variableId",
2447
+ "fragmentLayoutId",
2448
+ "paginationMode",
2449
+ "paginationVariableId",
2315
2450
  "revealEffect",
2316
2451
  "thumbImageId",
2317
2452
  "barImageId",
@@ -2327,6 +2462,191 @@ const validateLayoutElementData = ({
2327
2462
  }
2328
2463
  }
2329
2464
 
2465
+ if (data.conditionalOverrides !== undefined) {
2466
+ if (!Array.isArray(data.conditionalOverrides)) {
2467
+ return invalidFromErrorFactory(
2468
+ errorFactory,
2469
+ `${path}.conditionalOverrides must be an array when provided`,
2470
+ );
2471
+ }
2472
+
2473
+ for (let index = 0; index < data.conditionalOverrides.length; index += 1) {
2474
+ const rule = data.conditionalOverrides[index];
2475
+ const rulePath = `${path}.conditionalOverrides.${index}`;
2476
+
2477
+ if (!isPlainObject(rule)) {
2478
+ return invalidFromErrorFactory(
2479
+ errorFactory,
2480
+ `${rulePath} must be an object`,
2481
+ );
2482
+ }
2483
+
2484
+ {
2485
+ const result = validateAllowedKeys({
2486
+ value: rule,
2487
+ allowedKeys: ["when", "set"],
2488
+ path: rulePath,
2489
+ errorFactory,
2490
+ });
2491
+ if (result?.valid === false) {
2492
+ return result;
2493
+ }
2494
+ }
2495
+
2496
+ if (!isPlainObject(rule.when)) {
2497
+ return invalidFromErrorFactory(
2498
+ errorFactory,
2499
+ `${rulePath}.when must be an object`,
2500
+ );
2501
+ }
2502
+
2503
+ {
2504
+ const result = validateAllowedKeys({
2505
+ value: rule.when,
2506
+ allowedKeys: ["target", "op", "value"],
2507
+ path: `${rulePath}.when`,
2508
+ errorFactory,
2509
+ });
2510
+ if (result?.valid === false) {
2511
+ return result;
2512
+ }
2513
+ }
2514
+
2515
+ if (!isNonEmptyString(rule.when.target)) {
2516
+ return invalidFromErrorFactory(
2517
+ errorFactory,
2518
+ `${rulePath}.when.target must be a non-empty string`,
2519
+ );
2520
+ }
2521
+
2522
+ if (!parseLayoutConditionTarget(rule.when.target)) {
2523
+ return invalidFromErrorFactory(
2524
+ errorFactory,
2525
+ `${rulePath}.when.target must be a supported layout condition target`,
2526
+ );
2527
+ }
2528
+
2529
+ if (
2530
+ !isString(rule.when.value) &&
2531
+ typeof rule.when.value !== "boolean" &&
2532
+ !isFiniteNumber(rule.when.value)
2533
+ ) {
2534
+ return invalidFromErrorFactory(
2535
+ errorFactory,
2536
+ `${rulePath}.when.value must be a string, boolean, or finite number`,
2537
+ );
2538
+ }
2539
+
2540
+ if (rule.when.op !== "eq") {
2541
+ return invalidFromErrorFactory(
2542
+ errorFactory,
2543
+ `${rulePath}.when.op must be "eq"`,
2544
+ );
2545
+ }
2546
+
2547
+ if (!isPlainObject(rule.set)) {
2548
+ return invalidFromErrorFactory(
2549
+ errorFactory,
2550
+ `${rulePath}.set must be an object`,
2551
+ );
2552
+ }
2553
+
2554
+ {
2555
+ const result = validateAllowedKeys({
2556
+ value: rule.set,
2557
+ allowedKeys: [
2558
+ "textStyleId",
2559
+ "hoverTextStyleId",
2560
+ "clickTextStyleId",
2561
+ "imageId",
2562
+ "hoverImageId",
2563
+ "clickImageId",
2564
+ "opacity",
2565
+ "anchorX",
2566
+ "anchorY",
2567
+ "visible",
2568
+ "textStyle",
2569
+ ],
2570
+ path: `${rulePath}.set`,
2571
+ errorFactory,
2572
+ });
2573
+ if (result?.valid === false) {
2574
+ return result;
2575
+ }
2576
+ }
2577
+
2578
+ for (const field of [
2579
+ "textStyleId",
2580
+ "hoverTextStyleId",
2581
+ "clickTextStyleId",
2582
+ "imageId",
2583
+ "hoverImageId",
2584
+ "clickImageId",
2585
+ ]) {
2586
+ if (
2587
+ rule.set[field] !== undefined &&
2588
+ !isNonEmptyString(rule.set[field])
2589
+ ) {
2590
+ return invalidFromErrorFactory(
2591
+ errorFactory,
2592
+ `${rulePath}.set.${field} must be a non-empty string when provided`,
2593
+ );
2594
+ }
2595
+ }
2596
+
2597
+ if (
2598
+ rule.set.opacity !== undefined &&
2599
+ (!isFiniteNumber(rule.set.opacity) ||
2600
+ rule.set.opacity < 0 ||
2601
+ rule.set.opacity > 1)
2602
+ ) {
2603
+ return invalidFromErrorFactory(
2604
+ errorFactory,
2605
+ `${rulePath}.set.opacity must be a finite number between 0 and 1 when provided`,
2606
+ );
2607
+ }
2608
+
2609
+ for (const field of ["anchorX", "anchorY"]) {
2610
+ if (rule.set[field] !== undefined && !isFiniteNumber(rule.set[field])) {
2611
+ return invalidFromErrorFactory(
2612
+ errorFactory,
2613
+ `${rulePath}.set.${field} must be a finite number when provided`,
2614
+ );
2615
+ }
2616
+ }
2617
+
2618
+ if (
2619
+ rule.set.visible !== undefined &&
2620
+ typeof rule.set.visible !== "boolean"
2621
+ ) {
2622
+ return invalidFromErrorFactory(
2623
+ errorFactory,
2624
+ `${rulePath}.set.visible must be a boolean when provided`,
2625
+ );
2626
+ }
2627
+
2628
+ if (rule.set.textStyle !== undefined) {
2629
+ if (!isPlainObject(rule.set.textStyle)) {
2630
+ return invalidFromErrorFactory(
2631
+ errorFactory,
2632
+ `${rulePath}.set.textStyle must be an object when provided`,
2633
+ );
2634
+ }
2635
+
2636
+ {
2637
+ const result = validateLayoutElementTextStyle({
2638
+ textStyle: rule.set.textStyle,
2639
+ path: `${rulePath}.set.textStyle`,
2640
+ errorFactory,
2641
+ });
2642
+ if (result?.valid === false) {
2643
+ return result;
2644
+ }
2645
+ }
2646
+ }
2647
+ }
2648
+ }
2649
+
2330
2650
  if (data.fill !== undefined && !isString(data.fill)) {
2331
2651
  return invalidFromErrorFactory(
2332
2652
  errorFactory,
@@ -2355,6 +2675,17 @@ const validateLayoutElementData = ({
2355
2675
  );
2356
2676
  }
2357
2677
 
2678
+ if (
2679
+ data.paginationMode !== undefined &&
2680
+ data.paginationMode !== "continuous" &&
2681
+ data.paginationMode !== "paginated"
2682
+ ) {
2683
+ return invalidFromErrorFactory(
2684
+ errorFactory,
2685
+ `${path}.paginationMode must be 'continuous' or 'paginated' when provided`,
2686
+ );
2687
+ }
2688
+
2358
2689
  if (data.scroll !== undefined && typeof data.scroll !== "boolean") {
2359
2690
  return invalidFromErrorFactory(
2360
2691
  errorFactory,
@@ -2362,6 +2693,45 @@ const validateLayoutElementData = ({
2362
2693
  );
2363
2694
  }
2364
2695
 
2696
+ if (data.hover !== undefined) {
2697
+ {
2698
+ const result = validateLayoutElementInteraction({
2699
+ interaction: data.hover,
2700
+ path: `${path}.hover`,
2701
+ errorFactory,
2702
+ });
2703
+ if (result?.valid === false) {
2704
+ return result;
2705
+ }
2706
+ }
2707
+ }
2708
+
2709
+ if (data.click !== undefined) {
2710
+ {
2711
+ const result = validateLayoutElementInteraction({
2712
+ interaction: data.click,
2713
+ path: `${path}.click`,
2714
+ errorFactory,
2715
+ });
2716
+ if (result?.valid === false) {
2717
+ return result;
2718
+ }
2719
+ }
2720
+ }
2721
+
2722
+ if (data.rightClick !== undefined) {
2723
+ {
2724
+ const result = validateLayoutElementInteraction({
2725
+ interaction: data.rightClick,
2726
+ path: `${path}.rightClick`,
2727
+ errorFactory,
2728
+ });
2729
+ if (result?.valid === false) {
2730
+ return result;
2731
+ }
2732
+ }
2733
+ }
2734
+
2365
2735
  if (
2366
2736
  data.anchorToBottom !== undefined &&
2367
2737
  typeof data.anchorToBottom !== "boolean"
@@ -2405,20 +2775,6 @@ const validateLayoutElementData = ({
2405
2775
  }
2406
2776
  }
2407
2777
 
2408
- if (data.click !== undefined && !isPlainObject(data.click)) {
2409
- return invalidFromErrorFactory(
2410
- errorFactory,
2411
- `${path}.click must be an object when provided`,
2412
- );
2413
- }
2414
-
2415
- if (data.rightClick !== undefined && !isPlainObject(data.rightClick)) {
2416
- return invalidFromErrorFactory(
2417
- errorFactory,
2418
- `${path}.rightClick must be an object when provided`,
2419
- );
2420
- }
2421
-
2422
2778
  if (data.change !== undefined && !isPlainObject(data.change)) {
2423
2779
  return invalidFromErrorFactory(
2424
2780
  errorFactory,
@@ -2460,10 +2816,14 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2460
2816
  "textStyleId",
2461
2817
  "hoverTextStyleId",
2462
2818
  "clickTextStyleId",
2819
+ "conditionalOverrides",
2463
2820
  "direction",
2464
2821
  "gap",
2465
2822
  "containerType",
2466
2823
  "scroll",
2824
+ "hover",
2825
+ "click",
2826
+ "rightClick",
2467
2827
  "anchorToBottom",
2468
2828
  "thumbImageId",
2469
2829
  "barImageId",
@@ -2474,9 +2834,11 @@ const validateLayoutElementItems = ({ items, path, errorFactory }) => {
2474
2834
  "step",
2475
2835
  "initialValue",
2476
2836
  "variableId",
2837
+ "fragmentLayoutId",
2838
+ "paginationMode",
2839
+ "paginationVariableId",
2840
+ "paginationSize",
2477
2841
  "$when",
2478
- "click",
2479
- "rightClick",
2480
2842
  "change",
2481
2843
  ],
2482
2844
  path: itemPath,
@@ -2674,7 +3036,15 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2674
3036
  allowedKeys:
2675
3037
  item.type === "folder"
2676
3038
  ? ["id", "type", "name"]
2677
- : ["id", "type", "name", "layoutType", "elements"],
3039
+ : [
3040
+ "id",
3041
+ "type",
3042
+ "name",
3043
+ "description",
3044
+ "layoutType",
3045
+ "isFragment",
3046
+ "elements",
3047
+ ],
2678
3048
  path: itemPath,
2679
3049
  errorFactory,
2680
3050
  });
@@ -2704,11 +3074,28 @@ const validateLayoutItems = ({ items, path, errorFactory }) => {
2704
3074
  );
2705
3075
  }
2706
3076
 
3077
+ if (item.description !== undefined && !isString(item.description)) {
3078
+ return invalidFromErrorFactory(
3079
+ errorFactory,
3080
+ `${itemPath}.description must be a string when provided`,
3081
+ );
3082
+ }
3083
+
2707
3084
  if (item.type === "layout") {
2708
3085
  if (!LAYOUT_TYPE_KEYS.includes(item.layoutType)) {
2709
3086
  return invalidFromErrorFactory(
2710
3087
  errorFactory,
2711
- `${itemPath}.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'`,
3088
+ `${itemPath}.layoutType must be 'normal', 'save-load', 'confirmDialog', 'dialogue', 'nvl', or 'choice'`,
3089
+ );
3090
+ }
3091
+
3092
+ if (
3093
+ item.isFragment !== undefined &&
3094
+ typeof item.isFragment !== "boolean"
3095
+ ) {
3096
+ return invalidFromErrorFactory(
3097
+ errorFactory,
3098
+ `${itemPath}.isFragment must be a boolean when provided`,
2712
3099
  );
2713
3100
  }
2714
3101
 
@@ -3899,8 +4286,7 @@ export const assertInvariants = ({ state }) => {
3899
4286
  elementId,
3900
4287
  targetId,
3901
4288
  }) => {
3902
- const variable = state.variables.items[targetId];
3903
- if (!isPlainObject(variable) || variable.type === "folder") {
4289
+ if (!isVariableReferenceTarget(state, targetId)) {
3904
4290
  return invalidInvariant(
3905
4291
  `${ownerLabel} element variableId must reference an existing non-folder variable`,
3906
4292
  {
@@ -3914,6 +4300,32 @@ export const assertInvariants = ({ state }) => {
3914
4300
  return VALID_RESULT;
3915
4301
  };
3916
4302
 
4303
+ const assertFragmentLayoutReference = ({
4304
+ ownerIdField,
4305
+ ownerId,
4306
+ ownerLabel,
4307
+ elementId,
4308
+ targetId,
4309
+ }) => {
4310
+ const layout = state.layouts.items[targetId];
4311
+ if (
4312
+ !isPlainObject(layout) ||
4313
+ layout.type !== "layout" ||
4314
+ layout.isFragment !== true
4315
+ ) {
4316
+ return invalidInvariant(
4317
+ `${ownerLabel} element fragmentLayoutId must reference an existing fragment layout`,
4318
+ {
4319
+ [ownerIdField]: ownerId,
4320
+ elementId,
4321
+ fragmentLayoutId: targetId,
4322
+ },
4323
+ );
4324
+ }
4325
+
4326
+ return VALID_RESULT;
4327
+ };
4328
+
3917
4329
  const assertElementReferencesForCollection = ({
3918
4330
  items,
3919
4331
  ownerIdField,
@@ -3970,6 +4382,51 @@ export const assertInvariants = ({ state }) => {
3970
4382
  }
3971
4383
  }
3972
4384
 
4385
+ if (Array.isArray(element.conditionalOverrides)) {
4386
+ for (
4387
+ let index = 0;
4388
+ index < element.conditionalOverrides.length;
4389
+ index += 1
4390
+ ) {
4391
+ const rule = element.conditionalOverrides[index];
4392
+
4393
+ for (const field of [
4394
+ "textStyleId",
4395
+ "hoverTextStyleId",
4396
+ "clickTextStyleId",
4397
+ ]) {
4398
+ if (rule?.set?.[field] !== undefined) {
4399
+ const result = assertTextStyleReference({
4400
+ ownerIdField,
4401
+ ownerId,
4402
+ ownerLabel,
4403
+ elementId,
4404
+ field: `conditionalOverrides.${index}.set.${field}`,
4405
+ targetId: rule.set[field],
4406
+ });
4407
+ if (!result.valid) {
4408
+ return result;
4409
+ }
4410
+ }
4411
+ }
4412
+
4413
+ if (
4414
+ rule?.when?.target !== undefined &&
4415
+ !isLayoutConditionTarget(state, rule.when.target)
4416
+ ) {
4417
+ return invalidInvariant(
4418
+ `${ownerLabel} element conditionalOverrides when target must reference an existing variable or supported runtime condition`,
4419
+ {
4420
+ [ownerIdField]: ownerId,
4421
+ elementId,
4422
+ field: `conditionalOverrides.${index}.when.target`,
4423
+ targetId: rule.when.target,
4424
+ },
4425
+ );
4426
+ }
4427
+ }
4428
+ }
4429
+
3973
4430
  if (element.variableId !== undefined) {
3974
4431
  const result = assertVariableReference({
3975
4432
  ownerIdField,
@@ -3982,6 +4439,19 @@ export const assertInvariants = ({ state }) => {
3982
4439
  return result;
3983
4440
  }
3984
4441
  }
4442
+
4443
+ if (element.fragmentLayoutId !== undefined) {
4444
+ const result = assertFragmentLayoutReference({
4445
+ ownerIdField,
4446
+ ownerId,
4447
+ ownerLabel,
4448
+ elementId,
4449
+ targetId: element.fragmentLayoutId,
4450
+ });
4451
+ if (!result.valid) {
4452
+ return result;
4453
+ }
4454
+ }
3985
4455
  }
3986
4456
  }
3987
4457
 
@@ -6127,7 +6597,14 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
6127
6597
  allowedKeys:
6128
6598
  data.type === "folder"
6129
6599
  ? ["type", "name"]
6130
- : ["type", "name", "layoutType", "elements"],
6600
+ : [
6601
+ "type",
6602
+ "name",
6603
+ "description",
6604
+ "layoutType",
6605
+ "isFragment",
6606
+ "elements",
6607
+ ],
6131
6608
  path: "payload.data",
6132
6609
  errorFactory,
6133
6610
  });
@@ -6143,11 +6620,25 @@ const validateLayoutCreateData = ({ data, errorFactory }) => {
6143
6620
  );
6144
6621
  }
6145
6622
 
6623
+ if (data.description !== undefined && !isString(data.description)) {
6624
+ return invalidFromErrorFactory(
6625
+ errorFactory,
6626
+ "payload.data.description must be a string when provided",
6627
+ );
6628
+ }
6629
+
6146
6630
  if (data.type === "layout") {
6147
6631
  if (!LAYOUT_TYPE_KEYS.includes(data.layoutType)) {
6148
6632
  return invalidFromErrorFactory(
6149
6633
  errorFactory,
6150
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice'",
6634
+ "payload.data.layoutType must be 'normal', 'save-load', 'confirmDialog', 'dialogue', 'nvl', or 'choice'",
6635
+ );
6636
+ }
6637
+
6638
+ if (data.isFragment !== undefined && typeof data.isFragment !== "boolean") {
6639
+ return invalidFromErrorFactory(
6640
+ errorFactory,
6641
+ "payload.data.isFragment must be a boolean when provided",
6151
6642
  );
6152
6643
  }
6153
6644
 
@@ -6170,7 +6661,7 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
6170
6661
  {
6171
6662
  const result = validateAllowedKeys({
6172
6663
  value: data,
6173
- allowedKeys: ["name", "layoutType"],
6664
+ allowedKeys: ["name", "description", "layoutType", "isFragment"],
6174
6665
  path: "payload.data",
6175
6666
  errorFactory,
6176
6667
  });
@@ -6193,13 +6684,27 @@ const validateLayoutUpdateData = ({ data, errorFactory }) => {
6193
6684
  );
6194
6685
  }
6195
6686
 
6687
+ if (data.description !== undefined && !isString(data.description)) {
6688
+ return invalidFromErrorFactory(
6689
+ errorFactory,
6690
+ "payload.data.description must be a string when provided",
6691
+ );
6692
+ }
6693
+
6196
6694
  if (
6197
6695
  data.layoutType !== undefined &&
6198
6696
  !LAYOUT_TYPE_KEYS.includes(data.layoutType)
6199
6697
  ) {
6200
6698
  return invalidFromErrorFactory(
6201
6699
  errorFactory,
6202
- "payload.data.layoutType must be 'normal', 'dialogue', 'nvl', or 'choice' when provided",
6700
+ "payload.data.layoutType must be 'normal', 'save-load', 'confirmDialog', 'dialogue', 'nvl', or 'choice' when provided",
6701
+ );
6702
+ }
6703
+
6704
+ if (data.isFragment !== undefined && typeof data.isFragment !== "boolean") {
6705
+ return invalidFromErrorFactory(
6706
+ errorFactory,
6707
+ "payload.data.isFragment must be a boolean when provided",
6203
6708
  );
6204
6709
  }
6205
6710
  };
@@ -6510,9 +7015,71 @@ const validateVisualElementReferenceTargets = ({
6510
7015
  }
6511
7016
  }
6512
7017
 
7018
+ if (Array.isArray(data.conditionalOverrides)) {
7019
+ for (let index = 0; index < data.conditionalOverrides.length; index += 1) {
7020
+ const rule = data.conditionalOverrides[index];
7021
+
7022
+ for (const field of [
7023
+ "textStyleId",
7024
+ "hoverTextStyleId",
7025
+ "clickTextStyleId",
7026
+ ]) {
7027
+ if (rule?.set?.[field] === undefined) {
7028
+ continue;
7029
+ }
7030
+
7031
+ const textStyle = state.textStyles.items[rule.set[field]];
7032
+ if (!isPlainObject(textStyle) || textStyle.type === "folder") {
7033
+ return invalidFromErrorFactory(
7034
+ errorFactory,
7035
+ `${ownerLabel} element conditionalOverrides.${index}.set.${field} must reference an existing non-folder text style`,
7036
+ {
7037
+ [ownerIdField]: ownerId,
7038
+ elementId,
7039
+ field: `conditionalOverrides.${index}.set.${field}`,
7040
+ targetId: rule.set[field],
7041
+ },
7042
+ );
7043
+ }
7044
+ }
7045
+
7046
+ for (const field of ["imageId", "hoverImageId", "clickImageId"]) {
7047
+ if (rule?.set?.[field] === undefined) {
7048
+ continue;
7049
+ }
7050
+
7051
+ const image = state.images.items[rule.set[field]];
7052
+ if (!isPlainObject(image) || image.type === "folder") {
7053
+ return invalidFromErrorFactory(
7054
+ errorFactory,
7055
+ `${ownerLabel} element conditionalOverrides.${index}.set.${field} must reference an existing non-folder image`,
7056
+ {
7057
+ [ownerIdField]: ownerId,
7058
+ elementId,
7059
+ field: `conditionalOverrides.${index}.set.${field}`,
7060
+ targetId: rule.set[field],
7061
+ },
7062
+ );
7063
+ }
7064
+ }
7065
+
7066
+ if (!isLayoutConditionTarget(state, rule?.when?.target)) {
7067
+ return invalidFromErrorFactory(
7068
+ errorFactory,
7069
+ `${ownerLabel} element conditionalOverrides.${index}.when.target must reference an existing variable or supported runtime condition`,
7070
+ {
7071
+ [ownerIdField]: ownerId,
7072
+ elementId,
7073
+ field: `conditionalOverrides.${index}.when.target`,
7074
+ targetId: rule?.when?.target,
7075
+ },
7076
+ );
7077
+ }
7078
+ }
7079
+ }
7080
+
6513
7081
  if (data.variableId !== undefined) {
6514
- const variable = state.variables.items[data.variableId];
6515
- if (!isPlainObject(variable) || variable.type === "folder") {
7082
+ if (!isVariableReferenceTarget(state, data.variableId)) {
6516
7083
  return invalidFromErrorFactory(
6517
7084
  errorFactory,
6518
7085
  `${ownerLabel} element variableId must reference an existing non-folder variable`,
@@ -6524,6 +7091,25 @@ const validateVisualElementReferenceTargets = ({
6524
7091
  );
6525
7092
  }
6526
7093
  }
7094
+
7095
+ if (data.fragmentLayoutId !== undefined) {
7096
+ const fragmentLayout = state.layouts.items[data.fragmentLayoutId];
7097
+ if (
7098
+ !isPlainObject(fragmentLayout) ||
7099
+ fragmentLayout.type !== "layout" ||
7100
+ fragmentLayout.isFragment !== true
7101
+ ) {
7102
+ return invalidFromErrorFactory(
7103
+ errorFactory,
7104
+ `${ownerLabel} element fragmentLayoutId must reference an existing fragment layout`,
7105
+ {
7106
+ [ownerIdField]: ownerId,
7107
+ elementId,
7108
+ fragmentLayoutId: data.fragmentLayoutId,
7109
+ },
7110
+ );
7111
+ }
7112
+ }
6527
7113
  };
6528
7114
 
6529
7115
  const getTreeIdsInOrder = ({ nodes }) => {
@@ -11190,7 +11776,9 @@ const COMMAND_DEFINITIONS = [
11190
11776
  name: payload.data.name,
11191
11777
  ...(payload.data.type === "layout"
11192
11778
  ? {
11779
+ description: payload.data.description,
11193
11780
  layoutType: payload.data.layoutType,
11781
+ isFragment: payload.data.isFragment,
11194
11782
  elements: structuredClone(payload.data.elements),
11195
11783
  }
11196
11784
  : {}),
@@ -0,0 +1,51 @@
1
+ export const SYSTEM_VARIABLE_GROUPS = Object.freeze([
2
+ {
3
+ id: "routeEngine",
4
+ name: "Route Engine",
5
+ variables: Object.freeze([
6
+ {
7
+ id: "_skipUnseenText",
8
+ name: "Skip Unseen Text",
9
+ scope: "global-device",
10
+ type: "boolean",
11
+ default: false,
12
+ description:
13
+ "When enabled, skip mode can continue through lines the player has not viewed yet.",
14
+ },
15
+ {
16
+ id: "_dialogueTextSpeed",
17
+ name: "Dialogue Text Speed",
18
+ scope: "global-device",
19
+ type: "number",
20
+ default: 50,
21
+ description:
22
+ "Controls the default dialogue text speed stored for this device.",
23
+ },
24
+ {
25
+ id: "_currentSaveLoadPagination",
26
+ name: "Current Save/Load Pagination",
27
+ scope: "runtime-screen",
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
+ },
35
+ ]);
36
+
37
+ export const SYSTEM_VARIABLE_IDS = Object.freeze(
38
+ SYSTEM_VARIABLE_GROUPS.flatMap((group) =>
39
+ (group.variables || []).map((variable) => variable.id),
40
+ ),
41
+ );
42
+
43
+ const SYSTEM_VARIABLE_ID_SET = new Set(SYSTEM_VARIABLE_IDS);
44
+
45
+ export const isSystemVariableId = (value) => {
46
+ return typeof value === "string" && SYSTEM_VARIABLE_ID_SET.has(value);
47
+ };
48
+
49
+ export const getSystemVariableDefinitions = () => {
50
+ return SYSTEM_VARIABLE_GROUPS.flatMap((group) => group.variables || []);
51
+ };