IntCyoaEnhancer

QoL improvements for CYOAs made in IntCyoaCreator

Fra og med 07.03.2022. Se den nyeste version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         IntCyoaEnhancer
// @namespace    https://agregen.gitlab.io/
// @version      0.5.2
// @description  QoL improvements for CYOAs made in IntCyoaCreator
// @author       agreg
// @license      MIT
// @match        https://*.neocities.org/*
// @icon         https://intcyoacreator.onrender.com/favicon.ico?
// @run-at       document-start
// @require      https://unpkg.com/mreframe/dist/mreframe.js
// @require      https://unpkg.com/lz-string/libs/lz-string.js
// @require      https://greatest.deepsurf.us/scripts/441035-json-edit/code/Json%20edit.js?version=1025094
// @require      https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/prism.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/components/prism-json.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/prism/1.27.0/plugins/match-braces/prism-match-braces.min.js
// @grant        unsafeWindow
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// ==/UserScript==

(function() {
  'use strict';

  // overriding AJAX sender (before the page starts loading) to detect project.json download done at init time
  let init, enhance, _XHR = unsafeWindow.XMLHttpRequest;
  unsafeWindow.XMLHttpRequest = class XHR extends _XHR {
    constructor () {
      super();
      let _open = this.open;
      this.open = (...args) => {
        if ((`${args[0]}`.toUpperCase() === "GET") && (`${args[1]}`.match(/^project.json$|^js\/app\.\w*\.js$/))) {
          init(() => this.addEventListener('loadend', () => setTimeout(enhance)));
          // displaying loading indicator if not present already (as a mod)
          if (!document.getElementById('indicator')) {
            let _indicator = document.createElement('div'),  NBSP = '\xA0';
            _indicator.style = `position: fixed;  top: 0;  left: 0;  z-index: 1000`;
            _indicator.title = args[1];
            document.body.prepend(_indicator);
            this.addEventListener('progress', e => {
              _indicator.innerText = NBSP + "Loading data: " + (!e.total ? `${(e.loaded/1024**2).toFixed(1)} MB` :
                                                                `${(100 * e.loaded / e.total).toFixed(2)}%`);
            });
            this.addEventListener('loadend', () => {_indicator.innerText = ""});
          }
        }
        return _open.apply(this, args);
      };
    }
  };

  init = (thunk=enhance) => {!init.done && (console.log("IntCyoaEnhancer!"),  init.done = true,  thunk())};
  document.addEventListener('readystatechange', () =>
    (document.readyState == 'complete') && ['activated', 'rows', 'pointTypes'].every(k => k in app.__vue__.$store.state.app) && init());

  enhance = () => {
    let {isArray} = Array,   isJson = x => (typeof x === 'string') && x.trim().match(/^{.*}$/); // minimal check
    let range = n => Array.from({length: n}, (_, i) => i);
    let times = (n, f) => range(n).forEach(f);
    let _lazy = thunk => {let result, cached = false;  return () => (cached ? result : cached = true, result = thunk())};
    let _try = (thunk, fallback, quiet=false) => {try {return thunk()} catch (e) {quiet||console.error(e);  return fallback}};
    let _prompt = (message, value) => {let s = prompt(message, (typeof value == 'string' ? value : JSON.stringify(value)));
                                       return new Promise(resolve => (s != null) && resolve(s))};
    let _node = (tag, attr, ...children) => {
      let node = Object.assign(document.createElement(tag), attr);
      children.forEach(child => {node.append(isArray(child) ? _node(...child) : document.createTextNode(`${child||""}`))});
      return node;
    };
    let _debounce = (thunk, msec) => function $debounce () {
      clearTimeout($debounce.delay);
      $debounce.delay = setTimeout(() => {
        $debounce.delay = null;
        thunk();
      }, msec);
    };
    let $editor = $jsonEdit.createEditorModal('PROMPT');
    document.body.append($editor);
    GM_addStyle(`#PROMPT button {width:auto; background:darkgray; padding:0 1ex; border-radius:1ex}
                 #PROMPT .editor pre {padding:0 !important}`);
    let validator = s => !s || _decode(s);
    let _edit = (message, value, {json=false}={}) => $editor[json ? 'editAsJson' : 'editText'](value, {title: message, validator});

    // title & savestate are stored in URL hash
    let _hash = _try(() => `["${decodeURIComponent( location.hash.slice(1) )}"]`);  // it's a JSON array of 2 strings, without '["' & '"]' parts
    let $save = [],   [$title="", $saved="", $snapshot=""] = _try(() => JSON.parse(_hash), []);
    let _encode = o => LZString.compressToBase64(isJson(o) ? o : JSON.stringify(o)),
        _decode = s => (isJson(s) ? JSON.parse(s) : JSON.parse(LZString.decompressFromBase64(s) || (_ => {throw Error("Invalid input")})()));
    let $updateUrl = ({title=$title, save=$save, snapshot=$snapshot}={}) =>
      {location.hash = JSON.stringify([title, ...(!snapshot ? [$saved=save.join(",")] : ["", snapshot])]).slice(2, -2)};
    // app state accessors
    let $store = () => app.__vue__.$store,   $state = () => $store().state.app;
    let $pointTypes = () => $state().pointTypes,   $rows = () => $state().rows;
    let $items = _lazy(() => [].concat( ...$rows().map(row => row.objects) ));
    let $hiddenActive = _lazy(() => $items().filter(item => item.isSelectableMultiple || item.isImageUpload));
    let $itemsMap = _lazy((m = new Map()) => ($items().forEach(item => m.set(item.id, item)), m)),   $getItem = id => $itemsMap().get(id);
    let _fatKeys = x => ['backgroundImage', 'rowBackgroundImage'].concat(x.isImageUpload ? [] : ['image']);
    let _slim = x => x && (typeof x !== 'object' ? x : isArray(x) ? x.map(_slim) :
                           Object.assign({}, x, ..._fatKeys(x).map(k => ({[k]: void 0})),
                                         ...Object.keys(x).filter(k => typeof x[k] === 'object').map(k => x[k] && ({[k]: _slim(x[k])}))));
    let $slimStateCopy = (state=$state()) => $clone( _slim(state) );
    try {$store()} catch (e) {throw Error("[IntCyoaEnhancer] Can't access app state!", {cause: e})}

    // logic taken from IntCyoaCreator as it appears to be hardwired into a UI component
    let _selectedMulti = (item, num) => {  // selecting a multi-value
      let counter = 0, sign = Math.sign(num);
      let _timesCmp = n => (sign < 0 ? item.numMultipleTimesMinus < n : item.numMultipleTimesPluss > n);
      let _useMulti = () => _timesCmp(counter) && (item.multipleUseVariable = counter += sign,  true);
      let _addPoints = () => $pointTypes().filter(points => points.id == item.multipleScoreId).every(points =>
        _timesCmp(points.startingSum) && (item.multipleUseVariable = points.startingSum += sign,  true));
      times(Math.abs(num), _ => {
        if ((item.isMultipleUseVariable ? _useMulti() : _addPoints()))
          item.scores.forEach(score => $pointTypes().filter(points => points.id == score.id)
                                                    .forEach(points => {points.startingSum -= sign * parseInt(score.value)}));
      });
    };
    let _loadSave = save => {  // applying a savestate
      let _isHidden = s => s.includes("/ON#") || s.includes("/IMG#");
      let tokens = save.split(','),  activated = tokens.filter(s => s && !_isHidden(s)),  hidden = tokens.filter(_isHidden);
      let _split = (sep, item, token, fn, [id, arg]=token.split(sep, 2)) => {(id == item.id) && fn(arg)};
      $store().commit({type: 'cleanActivated'});  // hopefully not broken…
      $items().forEach(item => {
        if (item.isSelectableMultiple)
          hidden.forEach(token => _split("/ON#", item, token, num => _selectedMulti(item, parseInt(num))));
        else if (item.isImageUpload)
          hidden.forEach(token => _split("/IMG#", item, token, img => {item.image = img.replaceAll("/CHAR#", ",")}));
      });
      //$store().commit({type: 'addNewActivatedArray', newActivated: activated});  // not all versions have this :-(
      let _activated = new Set(activated),  _isActivated = id => _activated.has(id);
      $state().activated = activated;
      $rows().forEach(row => {  // yes, four-level nested loop is how the app does everything
        row.isEditModeOn = false;
        delete row.allowedChoicesChange;  // bugfix: cleanActivated is supposed to do this… but it doesn't
        row.objects.filter(item => _isActivated(item.id)).forEach(item => {
          item.isActive = true;
          row.currentChoices += 1;
          item.scores.forEach(score => $pointTypes().filter(points => points.id == score.id).forEach(points => {
            if (!score.requireds || (score.requireds.length <= 0) || $store().getters.checkRequireds(score)) {
              score.isActive = true;
              points.startingSum -= parseInt(score.value);
            }
          }));
        });
      });
    };
    // these are used for generating savestate
    let _isActive = item => item && (item.isActive || (item.isImageUpload && item.image) || (item.isSelectableMultiple && (item.multipleUseVariable !== 0)));
    let _activeId = item => (!_isActive(item) ? null : item.id + (item.isImageUpload        ? `/IMG#${item.image.replaceAll(",", "/CHAR#")}` :
                                                                  item.isSelectableMultiple ? `/ON#${item.multipleUseVariable}`              : ""));
    //let _activated = () => $items().map(_activeId).filter(Boolean);  // this is how the app calculates it (selection order seems to be ignored)

    let $hiddenActivated = () => $hiddenActive().filter(_isActive).map(item => item.id);  // images and multi-vals are excluded from state
    $store().watch(state => state.app.activated.filter(Boolean).concat( $hiddenActivated() ),  // activated is formed incorrectly and may contain ""
                   ids => {$save = ids.map($getItem).filter(Boolean).map(_activeId),  $updateUrl()});  // compared to the app """optimization""" this is blazing fast

    let diff = initial => (current=$slimStateCopy(), cheat=$cheat.data) => {
      let _cheat = (function $slim (o) {
        if (!o || isArray(o) || (typeof o != 'object')) return o;
        let kvs = Object.entries(o).filter(([k, v]) => $slim(v));
        return (kvs.length == 0 ? void 0 : Object.fromEntries(kvs));
      })(cheat);
      return (function $diff (a, b/*, ...path*/) {
        if ((typeof a !== typeof b) || (isArray(a) !== isArray(b)) || (isArray(a) && (a.length !== b.length)))
          return b;
        else if (a && b && (typeof a === 'object')) {
          let res = Object.entries(b).map(([k, v]) => [k, $diff(a[k], v/*, ...path, k*/)]).filter(([k, v]) => v !== void 0);
          if (res.length > 0) return Object.fromEntries(res);
        } else if (a === a ? a !== b : b === b)
          return b;
      })(initial, {_cheat, ...current}) || {};
    };
    let restoreSnapshot = initial => (snapshot=$snapshot) => _try(() => {
      let {reFrame: rf, util: {getIn, assoc, isArray, isDict, keys}} = require('mreframe');
      let {_cheat, ..._state} = (typeof snapshot !== 'string' ? snapshot : _decode(snapshot||"{}"));
      let newState = (function $deepMerge (a, b) {
        return (!isDict(b) ? a : keys(b).reduce((o, k) => ((o[k] = (!isDict(b[k]) ? b[k] : $deepMerge(a[k], b[k]))), o), a));
      })($clone(initial), _state);
      (function $updState (a, x/*, ...path*/) {
        a && (typeof a == 'object') && keys(a).forEach(k => {
          isArray(a[k]) && (x[k] = (!isArray(x[k]) ? a[k] : x[k].slice(0, a[k].length).concat( a[k].slice(x[k].length) )));
          (!(k in x) || (typeof a[k] != 'object') ? x[k] = a[k] : $updState(a[k], x[k]/*, ...path, k*/));
        });
        isDict(x) && keys(x).filter(k => !(k in a) && !_fatKeys(x).includes(k)).forEach(k => {delete x[k]});
      })(newState, $state());
      (_cheat || $cheat.toggle) && ($cheat.toggle || $cheat(), rf.disp(['init-db', $cheat.data = _cheat]));
      $snapshot = _encode({_cheat, ..._state});
      $updateUrl();
      return true;
    }) || alert("State load failed. (Possible reason: invalid state snapshot.)");

    // debug functions for console
    let $activated = () => $state().activated,   $clone = x => JSON.parse(JSON.stringify(x));
    let $rowsActive = () => $rows().map(row => [row, row.objects.filter(_isActive)]).filter(([_, items]) => items.length > 0);
    let $dbg = {$store, $state, $pointTypes, $rows, $items, $getItem, $activated, $hiddenActivated, $rowsActive, $clone, $slimStateCopy};
    Object.assign(unsafeWindow, {$dbg}, $dbg);

    let _bugfix = () => {
      $rows().forEach(row => {delete row.allowedChoicesChange});  // This is a runtime variable, why is it exported?! It breaks reset!
    };

    // init && menu
    _bugfix();
    let _title = document.title,  _initial = $slimStateCopy(),  _restore = restoreSnapshot(_initial),  _diff = diff(_initial);
    Object.assign(unsafeWindow, {$initial: JSON.stringify(_initial).length, $diff: _diff, $encode: _encode, $decode: _decode});
    $title && (document.title = $title);
    ($saved||$snapshot) && confirm("Load state from URL?") && setTimeout(() => !$snapshot ? _loadSave($saved) : _restore($snapshot));
    let _syncSnapshot = _debounce(() => {$snapshot = _encode(_diff()), $updateUrl()}, 1000);
    let $watch = (snapshot=($snapshot ? "" : _encode( _diff() ))) => {
      document.body.classList[snapshot ? 'add' : 'remove']('-FULL-SCAN');
      $snapshot = snapshot;
      $watch.stop = $watch.stop && ($watch.stop(), null);
      snapshot && ($watch.stop = $store().watch(x => x, _syncSnapshot, {deep: true}));
      $updateUrl();
    };
    $snapshot && $watch($snapshot);
    GM_registerMenuCommand("Change webpage title", () =>
      _prompt("Change webpage title (empty to default)", $title||document.title).then(s => {document.title = ($title = s) || _title;  $updateUrl()}));
    GM_registerMenuCommand("Edit state", () => _edit("Edit state (empty to reset)", (!$snapshot ? $saved : _decode($snapshot)), {json: $snapshot})
                                                 .then(!$snapshot ? _loadSave : _restore));
    GM_registerMenuCommand("Toggle full scan mode", () => $watch());
    GM_registerMenuCommand("Download project data", () => Object.assign(document.createElement('a'), {
      download: "project.json", href: `data:application/json,${encodeURIComponent(JSON.stringify($state()) + "\n")}`,
    }).click());
    ($state().backpack.length == 0) && GM_registerMenuCommand("Enable backpack", function $addBackpack(prefix) {
      _prompt([prefix, "How many choices should be displayed in a row? (1-4)"].filter(Boolean).join("\n"), "3").then(num =>
        (!["1", "2", "3", "4"].includes(num) ? setTimeout(() => $addBackpack(`Sorry, ${JSON.stringify(num)} is not a valid column number.`)) :
         ($state().backpack = [{title: "Selected choices", titleText: "", template: "1", isInfoRow: true, isResultRow: true,
                                objectWidth: `col-md-${{1: 12, 2: 6, 3: 4, 4: 3}[num]}`}])));
    });

    let $overview = () => {
      if ($overview.toggle)
        $overview.toggle();
      else {
        const _ID = 'LIST', ID = '#'+_ID, _scroll = (s, bg='#2B2F35', thumb='grey', wk='::-webkit-scrollbar') =>
          `${s} {scrollbar-width:thin; scrollbar-color:${thumb} ${bg}}  ${s}${wk} {width:6px; height:6px; background:${bg}}  ${s}${wk}-thumb {background:${thumb}}`;
        GM_addStyle(`${ID} {position:fixed; top:0; left:0; height:100%; width:100%; background:#0008; z-index:1001}
                     ${ID} img {position:fixed; top:0; max-height:40%; object-fit:contain; background:#000B}
                     ${ID} .-nav .-row-name {cursor:pointer; padding:2px 1ex}  ${ID} .-nav .-row-name:hover {background:var(--gray)}
                     ${ID} .-item-name {font-weight:bold}  ${ID} .-dialog :is(.-row-name, .-item):hover {cursor:help; text-shadow:0 0 10px}
                     ${ID} .-roll :is(input, button) {width:2.5em; color:black; background:var(--light)}
                     ${ID} .-roll button {border-radius:2ex}  ${ID} input[type=number] {text-align:right}  ${ID} input:invalid {background:var(--red)}` +
                     [[" .-roll", "0", "20%", "#0008"], [" .-dialog", "20%", "60%", "var(--dark)"], [" .-nav", "80%", "20%", "#0008"]].map(([k, left, width, bg]) =>
                       `${ID}${k} {position:fixed; top:40%; left:${left}; height:calc(60% - 56px); width:${width}; color:var(--light); background:${bg};
                                   padding:1em; overflow-y:auto}  ${_scroll(ID+k)}`).join("\n"));
        document.body.append($overview.overlay = _node('div', {id: _ID, onclick: $overview}));
        $overview.overlay.append($overview.image = _node('img'));
        $overview.overlay.append($overview.activated = _node('div', {className: '-dialog', title: "Activated items", onclick: e => e.stopPropagation()}));
        $overview.overlay.append($overview.nav = _node('div', {className: '-nav', title: "Navigation (visible rows)", onclick: e => e.stopPropagation()}));
        $overview.overlay.append($overview.roll = _node('div', {className: '-roll', title: "Dice roll", onclick: e => e.stopPropagation()}));
        document.addEventListener('keydown', e => (e.key == 'Escape') && $overview.toggle(true));
        let _points = Object.fromEntries( $pointTypes().map(points => [points.id, `[${points.id}] `+ (points.beforeText || `(${points.name})`)]) );
        let _ptReqOp = {1: ">", 2: "≥", 3: "=", 4: "≤", 5: "<"},  _ptReqCmpOp = {1: ">", 2: "=", 3: "≥"};
        let _req = score => x => (x.required ? "" : "NOT!") + ({id: x.reqId||"?", points: `${x.reqId||"?"} ${_ptReqOp[x.operator]} ${x.reqPoints}`,
                                                                   pointCompare: `${x.reqId||"?"} ${_ptReqCmpOp[x.operator]} ${x.reqId1||"?"}`})[x.type] || "???";
        let _cost = score => "  " + (_points[score.id] || `"${score.beforeText}"`) + (score.value > 0 ? " " : " +") + (-parseInt(score.value||0)) +
          ((score.requireds||[]).length == 0 ? "" : "\t{" + score.requireds.map(_req(score)).join("  &  ") + "}");
        let _showImg = ({image}) => () => ($overview.image.src = image) && ($overview.image.style.display = '');
        let _hideImg = () => {[$overview.image.src, $overview.image.style.display] = ["", 'none']};
        let _rowAttrs = row => ({className: '-row-name', title: `[${row.id}]\n\n${row.titleText}`.trim(), onmouseenter: _showImg(row), onmouseleave: _hideImg});
        let _nav = e => () => {$overview.toggle(true);  e.scrollIntoView({block: 'start'})};
        let _dice = [1, 6, 0],  _roll = (n, m, k) => (_dice = [n, m, k, range(n).reduce(res => res + Math.floor(1 + m*Math.random()), k)], _dice[3]);
        let _setDice = idx => function () {this.value = parseInt(this.value)||_dice[idx];  _dice.splice(idx, 1, this.valueAsNumber)};
        $overview.toggle = (visible = !$overview.overlay.style.display) => {
          if (!visible) {
            $overview.roll.innerHTML = "<h3>Roll</h3>";
            $overview.roll.append( _node('div', {},
              ['p', {}, ['input', {type: 'number', title: "N", min: 1, value: _dice[0], onchange: _setDice(0)}], " d ",
                        ['input', {type: 'number', title: "M", min: 2, value: _dice[1], onchange: _setDice(1)}], " + ",
                        ['input', {type: 'number', title: "K",         value: _dice[2], onchange: _setDice(2)}], " = ",
                        ['button', {title: "ROLL", onclick () {this.innerText = _roll(..._dice)}}, `${_dice.length < 4 ? "(roll)" : _dice[3]}`]],
              "(NdM+K means rolling an M-sided die N times and adding K to the total)") );
            $overview.nav.innerHTML = "<h3>Navigation</h3>";
            [...document.querySelectorAll(["* > .row", "*"].map(s => `.v-application--wrap > ${s} > :not(.v-bottom-navigation) > :not(.col)`).join(", "))]
              .filter(e => !e.style.display).map(e => [e, e.__vue__._props.row])
              .forEach(([e, row]) => {$overview.nav.append( _node('div', {..._rowAttrs(row), onclick: _nav(e)}, row.title.trim() || ['i', {}, row.id]) )});
            $overview.activated.innerHTML = "<h3>Activated</h3>";
            $rowsActive().forEach(([row, items]) => {
              $overview.activated.append( _node('p', {className: '-row'},
                ['span', _rowAttrs(row), row.title.trim() || ['i', {}, row.id]],
                ": ",
                ...[].concat(...items.map(item => [
                  ", ",
                  ['span', {className: '-item', title: [`[${item.id}]`, item.text, item.scores.map(_cost).join("\n")].filter(Boolean).join("\n\n").trim(),
                            onmouseenter: _showImg(item), onmouseleave: _hideImg},
                    ['span', {className: '-item-name'}, item.title.trim() || ['i', {}, item.id]],
                    !item.isActive && (item.isSelectableMultiple ? ` {×${item.multipleUseVariable}}` : " {Image}")],
                ])).slice(1)));
            });
          }
          $overview.overlay.style.display = (visible ? 'none' : '');
        }
        $overview.toggle(false);
      }
    };
    GM_registerMenuCommand("Overview", $overview);
    GM_addStyle(`#LIST-TOGGLE {position:fixed; right:3px; bottom:3px; z-index:1001; color:var(--light); background:var(--gray);
                               padding:1ex; width:auto; border-radius:1em}
                 .-FULL-SCAN #LIST-TOGGLE {color:var(--gray); background:var(--light)}`);
    document.body.append( _node('button', {id: 'LIST-TOGGLE', className: "v-icon mdi mdi-table-of-contents", title: "Overview/dice roll", onclick: $overview}) );

    function $cheat() {
      if (!$cheat.toggle) {
        const {reFrame: rf, reagent: r, util: {getIn, update, assocIn, merge, entries}} = require('mreframe');
        let updateIn = (o, path, f, ...args) => assocIn(o, path, f(getIn(o, path), ...args));
        const _ID = 'CHEAT', ID = '#'+_ID, _scroll = (s, bg='#2B2F35', thumb='grey', wk='::-webkit-scrollbar') =>
          `${s} {scrollbar-width:thin; scrollbar-color:${thumb} ${bg}}  ${s}${wk} {width:6px; height:6px; background:${bg}}  ${s}${wk}-thumb {background:${thumb}}`;
        GM_addStyle(`${ID} {position:fixed; bottom:0; left:0; z-index:1000; color:var(--light); background:var(--gray-dark); opacity:.75}  ${ID}:hover {opacity:1}
                     ${ID} .-frame {max-height:100vh; display:flex; flex-direction:column}  ${ID} .-scrollbox {overflow-y:auto}  ${_scroll(ID+" .-scrollbox")}
                     ${ID} h3 {text-align:center}  ${ID} table.-points td, ${ID} .-cheats {padding:.5ex}  ${ID} .-row {display:flex; flex-direction:row}
                     ${ID} button {background-color:var(--secondary); border-style:outset; border-radius:1em}
                     ${ID} td.-minus button, ${ID} tr.-minus :is(.-point-name, .-point-value) {background-color:var(--danger)}
                     ${ID} td.-plus  button, ${ID} tr.-plus  :is(.-point-name, .-point-value) {background-color:var(--purple)}
                     ${ID} button.-cheats {background: var(--cyan)}`);
        document.body.append($cheat.UI = _node('div', {id: _ID}));
        $cheat.toggle = () => rf.disp(['toggle-ui']);

        let _points = pointTypes => pointTypes.map(points => [points.id, points.name, points.beforeText, points.startingSum]);
        $store().watch(state => _points(state.app.pointTypes), points => rf.disp(['cache-points', points]));
        let _upd = rf.after(({show, cache, ...data}) => {$cheat.data = data});

        rf.regEventDb('init-db', [_upd], (db, [_, {points={}}={}]) => ({
          show: false,
          points,
          cache: db.cache || {points: []},
        }));
        rf.regEventDb('toggle-ui', [_upd], db => update(db, 'show', x => !x));
        rf.regEventFx('point-add!', [_upd], ({db}, [_, id, n]) => ({db:     updateIn(db, ['points', id], x => (x||0)+n),
                                                                    points: [{id, add: n}]}));
        rf.regEventFx('reset-cheats!', [_upd], ({db}) => ({db:     merge(db, {points: {}}),
                                                           points: entries(db.points).map(([id, n]) => ({id, add: -n}))}));
        rf.regEventDb('cache-points', [_upd], (db, [_, points]) => assocIn(db, ['cache', 'points'], points));

        rf.regFx('points', changes => changes.forEach(({id, add}) => {$pointTypes().find(x => x.id == id).startingSum += add}));

        rf.regSub('show',       getIn);
        rf.regSub('points',     getIn);
        rf.regSub('cache',      getIn);
        rf.regSub('cheating?',  db => true);
        rf.regSub('points*',    ([_, id]) => rf.subscribe(['points', id]), n => n||0);
        let _change = n => (!n ? "" : `${n < 0 ? n : '+'+n}`);
        rf.regSub('point-show', ([_, id]) => rf.subscribe(['points', id]), _change);
        rf.regSub('point-changes', '<-', ['cache', 'points'], '<-', ['points'], ([points, o]) =>
          points.filter(([id]) => o[id]).map(([id, name, show]) => [`[${id}] ` + (show||`(${name})`), o[id]]));
        rf.regSub('tooltip', '<-', ['point-changes'], changes =>
          changes.map(([points, change]) => `${points} ${_change(change)}`).join("\n"));
        rf.regSub('cheating?', '<-', ['point-changes'], changes => changes.length > 0);

        let PointAdd = id => n => ['button', {onclick: () => rf.disp(['point-add!', id, n])}, (n > 0) && '+', n];
        let Points = () => ['table.-points', ...rf.dsub(['cache', 'points']).map(([id, name, show, amount]) =>
          ['tr', {class: [{1: '-plus', '-1': '-minus'}[Math.sign(rf.dsub(['points*', id]))]],
                  title: rf.dsub(['point-show', id])},
            ['td.-minus', ...[-100, -10, -1].map( PointAdd(id) )],
            ['td.-point-name', "[", ['tt', id], "]", ['br'], show||['em', "<untitled>"], ['br'], `(${name})`],
            ['td.-point-value', amount],
            ['td.-plus', ...[+100, +10, +1].map( PointAdd(id) )]])];
        let Frame = (...body) => ['.-frame',
          ['h3', {title: rf.dsub(['tooltip'])}, "Points"],
          ['.-scrollbox', ...body],
          ['div.-row', {title: rf.dsub(['tooltip'])},
             ['button', {onclick: $cheat}, (rf.dsub(['cheating?']) ? "< HIDE" : "× CLOSE")],
             rf.dsub(['cheating?']) && ['button', {onclick: () => rf.disp(['reset-cheats!'])}, "RESET"]]];
        let UI = () => (rf.dsub(['show']) ? [Frame, [Points]] :
                        rf.dsub(['cheating?']) && ['button.-cheats', {onclick: $cheat, title: rf.dsub(['tooltip'])}, " Cheats: on "]);

        rf.dispatchSync(['init-db']);
        rf.disp(['cache-points', _points( $pointTypes() )]);
        r.render([UI], $cheat.UI);
      }
      $cheat.toggle();
    }
    GM_registerMenuCommand("Cheat engine", $cheat);
  };
})();