From 8b0ba633f68dacf9bd081853e75f61d031db970e Mon Sep 17 00:00:00 2001
From: Arkerthan <arkerthan@mailbox.org>
Date: Wed, 22 Jun 2022 17:48:25 +0200
Subject: [PATCH] Add a simple mode to the RA condition editor

---
 devTools/types/FC/RA.d.ts                     |   6 +-
 js/rulesAssistant/conditionEditorSimple.js    | 335 ++++++++++++++++++
 js/rulesAssistant/conditionEditorTree.js      |  11 +-
 .../z1-conditionEditorController.js           |  78 +++-
 .../backwardsCompatibility/datatypeCleanup.js |   4 +
 src/js/rulesAssistant.js                      |   1 +
 src/js/rulesAssistantOptions.js               |   4 +-
 7 files changed, 417 insertions(+), 22 deletions(-)
 create mode 100644 js/rulesAssistant/conditionEditorSimple.js

diff --git a/devTools/types/FC/RA.d.ts b/devTools/types/FC/RA.d.ts
index d9c75fff29f..4552816cf06 100644
--- a/devTools/types/FC/RA.d.ts
+++ b/devTools/types/FC/RA.d.ts
@@ -8,8 +8,12 @@ declare namespace FC {
 		type NumericTarget = GenericNumericTarget<number>;
 		type ExpressiveNumericTarget = GenericNumericTarget<number | string>;
 
-		interface RuleConditions {
+		interface RuleConditionEditorArguments {
 			activation: PostFixRule;
+			advancedMode: boolean;
+		}
+
+		interface RuleConditions extends RuleConditionEditorArguments{
 			selectedSlaves: number[];
 			excludedSlaves: number[];
 			applyRuleOnce: boolean;
diff --git a/js/rulesAssistant/conditionEditorSimple.js b/js/rulesAssistant/conditionEditorSimple.js
new file mode 100644
index 00000000000..0dd064745d8
--- /dev/null
+++ b/js/rulesAssistant/conditionEditorSimple.js
@@ -0,0 +1,335 @@
+/**
+ * All functions should only be called from z1-conditionEditorController.js
+ */
+App.RA.Activation.SimpleEditor = (function() {
+	/**
+	 * @typedef {object} RuleState
+	 * @property {"always"|"never"|"boolean"|"number"|"string"} activeRuleType
+	 * @property {string} boolGetter
+	 * @property {boolean} negateBool
+	 * @property {string} stringGetter
+	 * @property {string} stringComparator
+	 * @property {string} stringValue
+	 * @property {string} numberGetter
+	 * @property {number} numberUpperValue
+	 * @property {"lt"|"lte"|""} numberUpperComparator
+	 * @property {number} numberLowerValue
+	 * @property {"gt"|"gte"|""} numberLowerComparator
+	 */
+
+	/**
+	 * @type {HTMLDivElement}
+	 */
+	let editorNode = null;
+	/**
+	 * @type {RuleState}
+	 */
+	let currentRule = null;
+
+	/**
+	 * @param {FC.RA.PostFixRule} rule
+	 * @param {HTMLDivElement}parent
+	 */
+	function editor(rule, parent) {
+		currentRule = deserializeRule(rule);
+		editorNode = parent;
+		editorNode.append(buildEditor());
+	}
+
+	function refreshEditor() {
+		if (editorNode !== null) {
+			$(editorNode).empty().append(buildEditor());
+		}
+	}
+
+	/**
+	 * If the rule is valid, returns the serialized rule, otherwise null.
+	 *
+	 * @returns {FC.RA.PostFixRule}
+	 */
+	function saveEditor() {
+		if (currentRule == null) {
+			return null;
+		}
+		return serializeRule(currentRule);
+	}
+
+	function resetEditor() {
+		currentRule = null;
+		editorNode = null;
+	}
+
+	/**
+	 * @returns {HTMLElement}
+	 */
+	function buildEditor() {
+		const outerDiv = document.createElement("div");
+		// selector
+		const selectorDiv = document.createElement("div");
+		selectorDiv.classList.add("button-group");
+		outerDiv.append(selectorDiv);
+		App.UI.DOM.appendNewElement("button", selectorDiv, "Always", currentRule.activeRuleType === "always" ? ["selected", "disabled"] : []).onclick = () => {
+			currentRule.activeRuleType = "always";
+			refreshEditor();
+		};
+		App.UI.DOM.appendNewElement("button", selectorDiv, "Never", currentRule.activeRuleType === "never" ? ["selected", "disabled"] : []).onclick = () => {
+			currentRule.activeRuleType = "never";
+			refreshEditor();
+		};
+		App.UI.DOM.appendNewElement("button", selectorDiv, "Boolean", currentRule.activeRuleType === "boolean" ? ["selected", "disabled"] : []).onclick = () => {
+			currentRule.activeRuleType = "boolean";
+			refreshEditor();
+		};
+		App.UI.DOM.appendNewElement("button", selectorDiv, "Number", currentRule.activeRuleType === "number" ? ["selected", "disabled"] : []).onclick = () => {
+			currentRule.activeRuleType = "number";
+			refreshEditor();
+		};
+		App.UI.DOM.appendNewElement("button", selectorDiv, "String", currentRule.activeRuleType === "string" ? ["selected", "disabled"] : []).onclick = () => {
+			currentRule.activeRuleType = "string";
+			refreshEditor();
+		};
+
+		// add bool
+		if (currentRule.activeRuleType === "boolean") {
+			const boolDiv = document.createElement("div");
+			boolDiv.classList.add("button-group");
+			outerDiv.append(boolDiv);
+			/**
+			 * @type {selectOption[]}
+			 */
+			const options = [];
+			App.RA.Activation.getterManager.booleanGetters.forEach((getter, key) => {
+				if (!getter.visible || getter.visible()) {
+					options.push({
+						key: key, name: getter.name, enabled: !getter.enabled || getter.enabled()
+					});
+				}
+			});
+			boolDiv.append(App.UI.DOM.makeSelect(options, currentRule.boolGetter, key => {
+				currentRule.boolGetter = key;
+				refreshEditor();
+			}));
+			boolDiv.append(" should be ");
+			App.UI.DOM.appendNewElement("button", boolDiv, "True", currentRule.negateBool ? [] : ["selected", "disabled"]).onclick = () => {
+				currentRule.negateBool = false;
+				refreshEditor();
+			};
+			App.UI.DOM.appendNewElement("button", boolDiv, "False", currentRule.negateBool ? ["selected", "disabled"] : []).onclick = () => {
+				currentRule.negateBool = true;
+				refreshEditor();
+			};
+		} else if (currentRule.activeRuleType === "number") {
+			const numberDiv = document.createElement("div");
+			outerDiv.append(numberDiv);
+			/**
+			 * @type {selectOption[]}
+			 */
+			const options = [];
+			App.RA.Activation.getterManager.numberGetters.forEach((getter, key) => {
+				if (!getter.visible || getter.visible()) {
+					options.push({
+						key: key, name: getter.name, enabled: !getter.enabled || getter.enabled()
+					});
+				}
+			});
+			numberDiv.append(App.UI.DOM.makeSelect(options, currentRule.numberGetter, key => {
+				currentRule.numberGetter = key;
+				refreshEditor();
+			}));
+			numberDiv.append(" should be ");
+			numberDiv.append(App.UI.DOM.makeSelect(
+				[{key: "gt", name: "greater than"}, {key: "gte", name: "greater than or equal to"},
+					{key: "", name: "ignored"}],
+				currentRule.numberLowerComparator, key => {
+					currentRule.numberLowerComparator = key;
+					refreshEditor();
+				}));
+			if (currentRule.numberLowerComparator !== "") {
+				numberDiv.append(" ", App.UI.DOM.makeTextBox(currentRule.numberLowerValue, val => {
+					currentRule.numberLowerValue = val;
+				}, true));
+			}
+			numberDiv.append(" and ");
+			numberDiv.append(App.UI.DOM.makeSelect(
+				[{key: "lt", name: "less than"}, {key: "lte", name: "less than or equal to"},
+					{key: "", name: "ignored"}],
+				currentRule.numberUpperComparator, key => {
+					currentRule.numberUpperComparator = key;
+					refreshEditor();
+				}));
+			if (currentRule.numberUpperComparator !== "") {
+				numberDiv.append(" ", App.UI.DOM.makeTextBox(currentRule.numberUpperValue, val => {
+					currentRule.numberUpperValue = val;
+				}, true));
+			}
+		} else if (currentRule.activeRuleType === "string") {
+			const stringDiv = document.createElement("div");
+			outerDiv.append(stringDiv);
+			/**
+			 * @type {selectOption[]}
+			 */
+			const options = [];
+			App.RA.Activation.getterManager.stringGetters.forEach((getter, key) => {
+				if (!getter.visible || getter.visible()) {
+					options.push({
+						key: key, name: getter.name, enabled: !getter.enabled || getter.enabled()
+					});
+				}
+			});
+			stringDiv.append(App.UI.DOM.makeSelect(options, currentRule.stringGetter, key => {
+				currentRule.stringGetter = key;
+				refreshEditor();
+			}));
+			stringDiv.append(" should ");
+			stringDiv.append(App.UI.DOM.makeSelect(
+				[{key: "eqstr", name: "equal"}, {key: "substr", name: "contain"}, {key: "match", name: "match"}],
+				currentRule.stringComparator, key => {
+					currentRule.stringComparator = key;
+					refreshEditor();
+				}));
+			stringDiv.append(" ", App.UI.DOM.makeTextBox(currentRule.stringValue, val => {
+				currentRule.stringValue = val;
+			}));
+		}
+
+		return outerDiv;
+	}
+
+	/**
+	 * @param {FC.RA.PostFixRule} rule
+	 * @returns {RuleState}
+	 */
+	function deserializeRule(rule) {
+		// About the TS errors in this function: we can assume a lot about the rule composition because we know it's in
+		// the simple format. The rule itself is still a normal FC.RA.PostFixRule which would allow a lot more.
+		// Therefore, TS is not happy even though we now everything's fine.
+		/**
+		 * @type {RuleState}
+		 */
+		const ruleState = {
+			activeRuleType: "always",
+			boolGetter: "isfertile",
+			negateBool: false,
+			stringGetter: "label",
+			stringValue: "",
+			stringComparator: "eqstr",
+			numberGetter: "devotion",
+			numberUpperValue: 100,
+			numberUpperComparator: "",
+			numberLowerValue: -100,
+			numberLowerComparator: ""
+		};
+		// we know there is only one rule.
+		let i = 0;
+		const rulePart = rule[i];
+		if (rulePart === true) {
+			ruleState.activeRuleType = "always";
+		} else if (rulePart === false) {
+			ruleState.activeRuleType = "never";
+		} else if (App.RA.Activation.getterManager.isBoolean(rulePart)) {
+			ruleState.activeRuleType = "boolean";
+			ruleState.boolGetter = rulePart;
+			if (rule[i + 1] === "not") {
+				ruleState.negateBool = true;
+				i++;
+			}
+		} else if (App.RA.Activation.getterManager.isNumber(rulePart)) {
+			ruleState.activeRuleType = "number";
+			ruleState.numberGetter = rulePart;
+			// check if there is a lower rule:
+			if (rule[i + 2].startsWith("g")) {
+				ruleState.numberLowerValue = rule[i + 1];
+				ruleState.numberLowerComparator = rule[i + 2];
+				// check if there is also an upper value:
+				if (rule[i + 3] === ruleState.numberGetter) {
+					ruleState.numberUpperValue = rule[i + 4];
+					ruleState.numberUpperComparator = rule[i + 5];
+					i += 3;
+				}
+			} else {
+				ruleState.numberUpperValue = rule[i + 1];
+				ruleState.numberUpperComparator = rule[i + 2];
+			}
+			i += 2;
+		} else if (App.RA.Activation.getterManager.isString(rulePart)) {
+			ruleState.activeRuleType = "string";
+			ruleState.stringGetter = rulePart;
+			ruleState.stringValue = rule[i + 1].slice(1);
+			ruleState.stringComparator = rule[i + 2];
+			i += 2;
+		} else {
+			throw new Error("Rule is not in simple mode format!");
+		}
+		i++;
+
+		return ruleState;
+	}
+
+	/**
+	 * Expects a valid RulePart structure
+	 *
+	 * @param {RuleState} ruleState
+	 * @returns {FC.RA.PostFixRule}
+	 */
+	function serializeRule(ruleState) {
+		/**
+		 * @type {FC.RA.PostFixRule}
+		 */
+		const rule = [];
+		let counter = 0;
+
+		switch (ruleState.activeRuleType) {
+			case "always":
+				rule.push(true);
+				counter++;
+				break;
+			case "never":
+				rule.push(false);
+				counter++;
+				break;
+			case "boolean":
+				rule.push(ruleState.boolGetter);
+				if (ruleState.negateBool) {
+					rule.push("not");
+				}
+				counter++;
+				break;
+			case "number":
+				// eslint-disable-next-line no-case-declarations
+				let any = false;
+				if (ruleState.numberLowerComparator !== "") {
+					any = true;
+					rule.push(ruleState.numberGetter);
+					rule.push(ruleState.numberLowerValue);
+					rule.push(ruleState.numberLowerComparator);
+					counter++;
+				}
+				if (ruleState.numberUpperComparator !== "") {
+					any = true;
+					rule.push(ruleState.numberGetter);
+					rule.push(ruleState.numberUpperValue);
+					rule.push(ruleState.numberUpperComparator);
+					counter++;
+				}
+				if (!any) {
+					rule.push(true);
+					counter++;
+				}
+				break;
+			case "string":
+				rule.push(ruleState.stringGetter);
+				rule.push("!"+ruleState.stringValue);
+				rule.push(ruleState.stringComparator);
+				counter++;
+				break;
+		}
+		rule.push(counter, "and");
+		return rule;
+	}
+
+	return {
+		build: editor,
+		save: saveEditor,
+		reset: resetEditor,
+	};
+})();
diff --git a/js/rulesAssistant/conditionEditorTree.js b/js/rulesAssistant/conditionEditorTree.js
index 49cbb8e46fa..87b8831b4c8 100644
--- a/js/rulesAssistant/conditionEditorTree.js
+++ b/js/rulesAssistant/conditionEditorTree.js
@@ -33,18 +33,19 @@ App.RA.Activation.TreeEditor = (function() {
 	}
 
 	/**
-	 * Save the rule, if it is valid.
+	 * If the rule is valid, returns the serialized rule, otherwise null.
 	 *
-	 * @param {(rule:FC.RA.PostFixRule)=>void} callback
+	 * @returns {FC.RA.PostFixRule}
 	 */
-	function saveEditor(callback) {
+	function saveEditor() {
 		if (currentRule == null) {
-			return;
+			return null;
 		}
 		const error = currentRule.validate([]) === "error";
 		if (!error) {
-			callback(serializeRule(currentRule));
+			return serializeRule(currentRule);
 		}
+		return null;
 	}
 
 	function resetEditor() {
diff --git a/js/rulesAssistant/z1-conditionEditorController.js b/js/rulesAssistant/z1-conditionEditorController.js
index abf3b05fd9d..a45aa13f462 100644
--- a/js/rulesAssistant/z1-conditionEditorController.js
+++ b/js/rulesAssistant/z1-conditionEditorController.js
@@ -1,41 +1,91 @@
 App.RA.Activation.Editor = (function() {
-	let advanced = true;
 	/**
+	 * Should the advanced mode (tree editor) be used?
+	 * @type {boolean}
+	 */
+	let advanced = false;
+	/**
+	 * Keep a reference to the outermost node, so we can refresh it when needed.
 	 * @type {HTMLDivElement}
 	 */
-	let editorNode = null;
+	let outerNode = null;
 
 	/**
-	 * @param {FC.RA.PostFixRule} rule
+	 * @param {FC.RA.RuleConditionEditorArguments} args
 	 * @returns {HTMLDivElement}
 	 */
-	function editor(rule) {
-		editorNode = document.createElement("div");
+	function editor(args) {
+		outerNode = document.createElement("div");
+		fillOuterNode(args);
+		return outerNode;
+	}
+
+	/**
+	 * @param {FC.RA.RuleConditionEditorArguments} args
+	 */
+	function fillOuterNode(args) {
+		advanced = args.advancedMode;
+		let editorNode = document.createElement("div");
 		if (advanced) {
-			App.RA.Activation.TreeEditor.build(rule, editorNode);
+			outerNode.append(App.UI.DOM.link("Reset to simple mode", () => {
+				if (SugarCube.Dialog.isOpen()) {
+					SugarCube.Dialog.close();
+				}
+				SugarCube.Dialog.setup("Reset RA to simple mode");
+				$(SugarCube.Dialog.body()).empty().append(
+					"<p>Resetting will delete your current conditions. Do you want to continue?</p>",
+					App.UI.DOM.link("Yes, delete conditions.", () => {
+						args.advancedMode = false;
+						args.activation = App.RA.newRule.conditions().activation;
+						SugarCube.Dialog.close();
+						$(outerNode).empty();
+						fillOuterNode(args);
+					}), " ",
+					App.UI.DOM.makeElement("p", App.UI.DOM.link("Abort.", () => {
+						SugarCube.Dialog.close();
+					})));
+				SugarCube.Dialog.open();
+			}));
+			App.RA.Activation.TreeEditor.build(args.activation, editorNode);
 		} else {
-			// TODO basic
+			outerNode.append(App.UI.DOM.link("Switch to advanced mode", () => {
+				args.advancedMode = true;
+				$(outerNode).empty();
+				fillOuterNode(args);
+			}));
+			App.RA.Activation.SimpleEditor.build(args.activation, editorNode);
 		}
-		return editorNode;
+		outerNode.append(editorNode);
 	}
 
 	/**
 	 * Save the rule, if it is valid.
 	 *
-	 * @param {(rule:FC.RA.PostFixRule)=>void} callback
+	 * @param {FC.RA.RuleConditionEditorArguments} args
 	 */
-	function saveEditor(callback) {
+	function saveEditor(args) {
 		if (advanced) {
-			App.RA.Activation.TreeEditor.save(callback);
+			let rule = App.RA.Activation.TreeEditor.save();
+			if (rule == null) {
+				return;
+			}
+			args.advancedMode = advanced;
+			args.activation = rule;
 		} else {
-			// TODO basic
+			let rule = App.RA.Activation.SimpleEditor.save();
+			if (rule == null) {
+				return;
+			}
+			args.advancedMode = advanced;
+			args.activation = rule;
 		}
 	}
 
 	function resetEditor() {
 		App.RA.Activation.TreeEditor.reset();
-		// TODO basic
-		editorNode = null;
+		App.RA.Activation.SimpleEditor.reset();
+		outerNode = null;
+		advanced = false;
 	}
 
 	return {
diff --git a/src/data/backwardsCompatibility/datatypeCleanup.js b/src/data/backwardsCompatibility/datatypeCleanup.js
index af14361979d..6c747999a1e 100644
--- a/src/data/backwardsCompatibility/datatypeCleanup.js
+++ b/src/data/backwardsCompatibility/datatypeCleanup.js
@@ -2305,6 +2305,10 @@ App.Entity.Utils.RARuleDatatypeCleanup = function() {
 			};
 			return jobs[assignment];
 		}
+
+		if (!cond.hasOwnProperty("advancedMode")) {
+			cond.advancedMode = true;
+		}
 	}
 
 	/** @param {object} o */
diff --git a/src/js/rulesAssistant.js b/src/js/rulesAssistant.js
index 66be45512f3..c247619be5f 100644
--- a/src/js/rulesAssistant.js
+++ b/src/js/rulesAssistant.js
@@ -178,6 +178,7 @@ App.RA.newRule = function() {
 	function emptyConditions() {
 		return {
 			activation: ["devotion", 20, "gt", 1, "and"],
+			advancedMode: false,
 			selectedSlaves: [],
 			excludedSlaves: [],
 			applyRuleOnce: false,
diff --git a/src/js/rulesAssistantOptions.js b/src/js/rulesAssistantOptions.js
index d47e17530bd..ebc47642f37 100644
--- a/src/js/rulesAssistantOptions.js
+++ b/src/js/rulesAssistantOptions.js
@@ -105,7 +105,7 @@ App.RA.options = (function() {
 	 * Save the settings for this rule.
 	 */
 	function saveSettings() {
-		App.RA.Activation.Editor.save(cond => current_rule.condition.activation = cond);
+		App.RA.Activation.Editor.save(current_rule.condition);
 	}
 
 	const parse = {
@@ -1294,7 +1294,7 @@ App.RA.options = (function() {
 
 	class ConditionBuilder extends Element {
 		render() {
-			return App.RA.Activation.Editor.build(current_rule.condition.activation);
+			return App.RA.Activation.Editor.build(current_rule.condition);
 		}
 	}
 
-- 
GitLab