IntCyoaEnhancer

QoL improvements for CYOAs made in IntCyoaCreator

Versión del día 30/01/2022. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         IntCyoaEnhancer
// @namespace    https://agregen.gitlab.io/
// @version      0.4.3
// @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 => 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

    // 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", () => 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", 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 _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();
    });
  };
})();