diff --git a/css/rulesAssistant/activationConditions.css b/css/rulesAssistant/activationConditions.css new file mode 100644 index 0000000000000000000000000000000000000000..62dc010f9d226c7135a41d2db48eda4be41b9ef0 --- /dev/null +++ b/css/rulesAssistant/activationConditions.css @@ -0,0 +1,10 @@ +.rule-group { + margin: 5px; + border: #00cb7a 5px solid; + cursor: grab; +} +.rule-condition { + margin: 5px; + border: red 5px solid; + cursor: grab; +} diff --git a/devTools/types/FC/RA.d.ts b/devTools/types/FC/RA.d.ts index a771bac04b26912b7ce2018e7672581060b64bab..1816bfdbefc2b23de03c7a762061f5b3d250424a 100644 --- a/devTools/types/FC/RA.d.ts +++ b/devTools/types/FC/RA.d.ts @@ -175,7 +175,7 @@ declare namespace FC { scarDesign: string; hornColor: string; labelTagsClear: boolean; - choosesOwnClothes: 0|1; + choosesOwnClothes: 0 | 1; pronoun: number; } @@ -183,7 +183,36 @@ declare namespace FC { ID: string; name: string; condition: RuleConditions; + activation: RulePart; set: RuleSetters; } + + type RulePart = RuleGroup | RuleNegation | RuleCondition; + + interface RuleGroup { + type: "and" | "or" + parts: RulePart[] + } + + interface RuleNegation { + type: "negate" + } + + type RuleCondition = RuleConstant | RuleCustom | RuleBool; + + interface RuleConstant { + type: "constant" + value: boolean + } + + interface RuleCustom { + type: "custom" + value: string + } + + interface RuleBool { + type: "bool" + key: string + } } } diff --git a/src/js/rulesAssistantActivationCondition.js b/src/js/rulesAssistantActivationCondition.js new file mode 100644 index 0000000000000000000000000000000000000000..f60f2fd999a80a8e64a043539af0b2837d177638 --- /dev/null +++ b/src/js/rulesAssistantActivationCondition.js @@ -0,0 +1,336 @@ +App.RA.Activation = {}; + +App.RA.Activation = (function() { + /** + * @type {HTMLDivElement} + */ + let editorNode = null; + /** + * @type {Map<string, RulePart>} + */ + let rulePartMap = new Map(); + + /** + * @param {FC.RA.Rule} rule + * @returns {HTMLDivElement} + */ + function editor(rule) { + // TODO do this once on story ready + boolPopulate(); + // TODO: remove editorNode when leaving the passage + editorNode = document.createElement("div"); + editorNode.append(buildEditor()); + return editorNode; + } + + /** + * TODO: remove + * @type {FC.RA.RulePart} + */ + const testRule = { + type: "and", parts: [ + {type: "constant", value: true}, + { + type: "or", parts: [ + {type: "constant", value: false}, + {type: "constant", value: true} + ] + }, + { + type: "and", parts: [ + {type: "bool", key: "isamputee"}, + ] + }, + {type: "bool", key: "isfertile"}, + {type: "constant", value: true} + ] + }; + + /** + * @returns {DocumentFragment} + */ + function buildEditor() { + const f = new DocumentFragment(); + + rulePartMap = new Map(); + f.append(_getRulePart(null, testRule).render()); + + return f; + } + + function refreshEditor() { + if (editorNode !== null) { + $(editorNode).empty().append(buildEditor()); + } + } + + let id = 0; + + class RulePart { + /** + * @param {RuleGroup} parent + * @param {FC.RA.RulePart} rulePart + */ + constructor(parent, rulePart) { + this.id = id.toString(); + id++; + rulePartMap.set(this.id, this); + + this.parent = parent; + this.rulePart = rulePart; + } + + /** + * @returns {Node} + */ + render() { + return App.UI.DOM.makeElement("div", "Empty RulePart"); + } + + /** + * Validate the rule + * @returns {boolean} + */ + validate() { + return true; + } + + /** + * Check if this rule applies to the given slave + * + * @param {App.Entity.SlaveState} slave + * @returns {boolean} + */ + applies(slave) { + return false; + } + } + + class RuleGroup extends RulePart { + /** + * @param {RuleGroup} parent + * @param {FC.RA.RuleGroup} ruleGroup + */ + constructor(parent, ruleGroup) { + super(parent, ruleGroup); + this.ruleGroup = ruleGroup; + } + + render() { + const div = document.createElement("div"); + div.classList.add("rule-group"); + div.append(this.ruleGroup.type); + for (const rulePart of this.ruleGroup.parts) { + div.append(_getRulePart(this, rulePart).render()); + } + div.ondragover = ev => { + if (canDrop(ev, this)) { + // show that it can be dropped + ev.preventDefault(); + } + // stop groups further out from capturing the event. + ev.stopPropagation(); + }; + div.ondrop = ev => { + ev.preventDefault(); + // stop groups further out from capturing the event. + ev.stopPropagation(); + const rulePartID = ev.dataTransfer.getData("text/plain"); + const rulePart = rulePartMap.get(rulePartID); + rulePart.parent.ruleGroup.parts.delete(rulePart.rulePart); + this.ruleGroup.parts.push(rulePart.rulePart); + refreshEditor(); + }; + if (this.parent !== null) { + // if null, it's the outermost and that can't be draggable + makeDraggable(div, this); + } + return div; + } + + + validate() { + if (this.ruleGroup.parts.length === 0) { + return false; + } + return this.ruleGroup.parts.reduce((c, rulePart) => c && _getRulePart(this, rulePart).validate(), true); + } + + applies(slave) { + /** + * @type {function(boolean, boolean):boolean} + */ + const cmp = this.ruleGroup.type === "and" + ? (a, b) => a && b + : (a, b) => a || b; + + let sumValue = true; + for (const rulePart of this.ruleGroup.parts) { + sumValue = cmp(sumValue, _getRulePart(this, rulePart).applies(slave)); + } + return sumValue; + } + } + + /** + * @param {DragEvent} event + * @param {RulePart} targetPart + * @returns {boolean} + * @private + */ + function canDrop(event, targetPart) { + const movedPartID = event.dataTransfer.getData("text/plain"); + const movedPart = rulePartMap.get(movedPartID); + // don't allow dragging onto itself + if (movedPart === targetPart) { + return false; + } + // don't allow dragging onto children + const v = isParent(movedPart.rulePart, targetPart.rulePart); + return !v; + } + + /** + * @param {FC.RA.RulePart} maybeParent + * @param {FC.RA.RulePart} maybeChild + */ + function isParent(maybeParent, maybeChild) { + switch (maybeParent.type) { + case "and": + case "or": + for (const child of maybeParent.parts) { + if (child === maybeChild) { + return true; + } else if (isParent(child, maybeChild)) { + return true; + } + } + } + return false; + } + + + class RuleConstant extends RulePart { + /** + * @param {RuleGroup} parent + * @param {FC.RA.RuleConstant} ruleConstant + */ + constructor(parent, ruleConstant) { + super(parent, ruleConstant); + this.ruleConstant = ruleConstant; + } + + render() { + const b = App.UI.DOM.makeElement("button", this.ruleConstant.value ? "Always" : "Never", ["rule-condition"]); + b.onclick = () => { + this.ruleConstant.value = !this.ruleConstant.value; + refreshEditor(); + }; + makeDraggable(b, this); + return b; + } + + applies(slave) { + return this.ruleConstant.value; + } + } + + /** + * @param {HTMLElement} node + * @param {RulePart} rulePart + */ + function makeDraggable(node, rulePart) { + node.draggable = true; + node.ondragstart = ev => { + ev.stopPropagation(); + ev.dataTransfer.setData("text/plain", rulePart.id); + }; + } + + class RuleBoolCheck extends RulePart { + /** + * @param {RuleGroup} parent + * @param {FC.RA.RuleBool} ruleBool + */ + constructor(parent, ruleBool) { + super(parent, ruleBool); + this.ruleBool = ruleBool; + } + + render() { + // make container + const span = document.createElement("span"); + span.classList.add("rule-condition"); + makeDraggable(span, this); + // fill container + span.append("Slave"); + let matchFound = false; + let select = document.createElement("select"); + + for (const [key, value] of boolCheckMap.entries()) { + let el = document.createElement("option"); + el.value = key; + el.textContent = value.name; + if (this.ruleBool.key === key) { + el.selected = true; + matchFound = true; + } + select.append(el); + } + if (!matchFound) { + select.selectedIndex = -1; + } + select.onchange = () => { + /** @type {HTMLSelectElement} */ + // @ts-ignore + const option = select.children.item(select.selectedIndex); + this.ruleBool.key = option.value; + refreshEditor(); + }; + span.append(select); + return span; + } + + validate() { + return boolCheckMap.has(this.ruleBool.key); + } + + applies(slave) { + return boolCheckMap.get(this.ruleBool.key).cond(slave); + } + } + + /** + * @param {RuleGroup} parent + * @param {FC.RA.RulePart} rulePart + * @returns {RulePart} + */ + function _getRulePart(parent, rulePart) { + switch (rulePart.type) { + case "and": + case "or": + return new RuleGroup(parent, rulePart); + case "constant": + return new RuleConstant(parent, rulePart); + case "bool": + return new RuleBoolCheck(parent, rulePart); + } + } + + /** + * TODO: encapsulate for outside access + * TODO: add availability check + * + * @type {Map<string, {name: string, cond: (function(FC.SlaveState): boolean)}>} + */ + const boolCheckMap = new Map(); + + function boolPopulate() { + boolCheckMap.set("isfertile", {name: "Is Fertile?", cond: isFertile}); + boolCheckMap.set("isamputee", {name: "Is Amputee?", cond: isAmputee}); + } + + return { + editor: editor + }; +})(); diff --git a/src/js/rulesAssistantOptions.js b/src/js/rulesAssistantOptions.js index be981014f9eecc1976b3d181b3fb01e5bc378309..51b06b2c675e7769142ab1f69df557be508604b4 100644 --- a/src/js/rulesAssistantOptions.js +++ b/src/js/rulesAssistantOptions.js @@ -1267,14 +1267,12 @@ App.RA.options = (function() { } // parent section for condition editing - class ConditionEditor extends Section { - constructor() { - super("Activation Condition"); - this.appendChild(new ConditionFunction()); - this.appendChild(new AssignmentInclusion()); - this.appendChild(new FacilityHeadAssignmentInclusion()); - this.appendChild(new SpecificInclusionExclusion()); - this.appendChild(new ApplyRuleOnce()); + class ConditionEditor extends Element { + render() { + const f = document.createElement("p"); + App.UI.DOM.appendNewElement("h1", f, "Activation condition"); + f.appendChild(App.RA.Activation.editor(current_rule)); + return f; } }