IntCyoaEnhancer

QoL improvements for CYOAs made in IntCyoaCreator

目前為 2022-01-23 提交的版本,檢視 最新版本

// ==UserScript==
// @name         IntCyoaEnhancer
// @namespace    https://agregen.gitlab.io/
// @version      0.4.1
// @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
// @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] === "project.json")) {
          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`;
            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 _lazy = thunk => {let result, cached = false;  return () => (cached ? result : cached = true, result = thunk())};
    let _try = (thunk, fallback) => {try {return thunk()} catch (e) {console.error(e);  return fallback}};
    let _prompt = (message, value, thunk) => {let s = prompt(message, value);  return (s != null) && thunk(s)};
    let _node = (tag, attr, ...children) => {
      let node = Object.assign(document.createElement(tag), attr);
      children.forEach(child => {node.append(Array.isArray(child) ? _node(...child) : document.createTextNode(`${child||""}`))});
      return node;
    };
    let range = n => Array.from({length: n}, (_, i) => i);
    let times = (n, f) => range(n).forEach(f);

    // 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=""] = _try(() => JSON.parse(_hash), []);
    let $updateUrl = ({title=$title, save=$save}={}) => {location.hash = JSON.stringify([title, $saved=save.join(",")]).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);
    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 => !_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 => activated.includes(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.length <= 0) || $store().getters.checkRequireds(score)) {
              score.isActive = true;
              points.startingSum -= parseInt(score.value);
            }
          }));
        });
      });
    };
    // these are used for generating savestate
    let _isActive = 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).map(_activeId),  $updateUrl()});  // compared to the app """optimization""" this is blazing fast

    // 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};
    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;
    $title && (document.title = $title);
    $saved && confirm("Load state from URL?") && _loadSave($saved);
    GM_registerMenuCommand("Change webpage title", () =>
      _prompt("Change webpage title (empty to default)", $title||document.title, s => {document.title = ($title = s) || _title;  $updateUrl()}));
    GM_registerMenuCommand("Edit state", () =>
      _prompt("Edit state (empty to reset)", $saved, _loadSave));
    GM_registerMenuCommand("Download project data", () =>
      saveAs("data:application/json," + encodeURIComponent( JSON.stringify($state()) ), "project.json"));

    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) {cursor:help}
                     ${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 _cost = score => _points[score.id] + (score.value > 0 ? " " : " +") + (-parseInt(score.value));
        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}`);
    document.body.append( _node('button', {id: 'LIST-TOGGLE', className: "v-icon mdi mdi-table-of-contents", title: "Overview/dice roll", onclick: $overview}) );

    GM_registerMenuCommand("Cheat engine", 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]));

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