Scratchpads By Sam

Extra grids that let you try out logic permutations.

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

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

Tendrás que 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.

Tendrás que instalar una extensión como Tampermonkey antes de poder 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)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

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

// ==UserScript==
// @name        Scratchpads By Sam
// @namespace   Userscripts by cethemaco
// @author      cethemaco
// @description Extra grids that let you try out logic permutations.
// @supportURL  https://github.com/cethemaco/scratchpads-by-sam
// @match       https://cluesbysam.com/
// @match       https://cluesbysam.com/s/share/*
// @match       https://cluesbysam.com/s/archive/*
// @match       https://cluesbysam.com/archive/*
// @match       https://cluesbysam.com/s/play/*
// @icon        data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAADlElEQVQ4T0WTfUzUdRzHX7/jOLin33EcF8hDdjwkXlC6nibEEFnTCArpSVummzNrJcOR08rSZSxXmWaFyvnAzaUtpYZUjBmYlLiEBSaYCgfTQ8XT4zi6h3i6X7+7tvr+892++35fn8/7/Xl/hXRdgoS8BEFgcnKS6OhoHs3PQ6vVEvD7iVIquXihlxvDw2jkM0mKXP9vCWFA+PH01BTmxEQ+3vc5oimOQCCARiND5N1sSuDLj3bS8NXRCCQUCv0PyNCbJWW0ktFRN7X2Q5jTzNTbahlx3WZC7kiv0zIn+z4qqzbyUkk5g/0DxKrVzMzMRCCRDu747jDPOh9703F2frINnUZNv2NIrh4kM8OC03mNVWveYHzEw6trVxMfGxeRGpYj5CbNlsqXP8+6DdU0/dCI/cBe0jMycQ47Cc2EMMXH4fP5iFHrOWg/QmfHWeq/2MeZ0+2oVCqElWXPSu/sqMF+qI6fWk8TDE4TE6vGPeoiKOsPQ8K+CVEKSh4vpnrTRvo6e6hc9Qp6US93kDhbeqtmK9aH5tNYv4PLvedQaY3c9k7IOkGvlhA1ArH4WVm9m8lpLe9WVuHod0RkCBatSfL5vTS0/UKOpplfW/bTOWCkolBA1P5N5yURiRnysz0Yi5pZWvIaPb93YDSYIkYK98YlSZ4xNx/WHuDlvCsMOtrpHbHwzCK5vGKQjgvzmA66eSBxCN/du1hWUcXwUD9KuXp4nEKWIVEa846yYu06tm5aSuh8AQZrEXjH5CGpQB0FQScj7gI8yZupWFgsj9wTCZpCofgX4JUBb76/nao1D9Jau5AlZXPp6g5y6fI4y59LwfFnH7eCT5GzzMbVwWt0neng023bZcOD4RyYZI8VfNPSSNTQLpobT5CVYcYyy8lfgRhilJOc6zbiVsyi9HUbYlwyWoOObw/aqNm8BSFTvEvyy5mvO2pnyeIF7P3sPYY9USy2jKP0d3NT9wJdfeeZjjbzZPlqbl4fkS0VyEwz8uITTyOkKHXSHOtcDssp/KPnLD+f+pGCwiLyCstwXR/AnJKJfc8WHC6BbOtj3Lp6g9yHc0iIlVhRWoFgQinttu3Hen8KTQ02LOkWRsf8PJJfSnJqFu2tx5gKuvBPSAQU95CaZGFR8QI+WL+BY0e+RkhVidLhE8fxefs4ebKN3JwspuSfqdElIMancOXib6QlmxkbD9DS1kNd3R6+/+4Ub1euRxQN/AM+GXfOzHiBSgAAAABJRU5ErkJggg==
// @grant       GM_getValues
// @grant       GM_setValue
// @version     1.0
// @license     MIT
// @compatible  firefox
// @compatible  edge
// ==/UserScript==
(() => {
  const WRAP_ID = "devtools-and-grid-wrap";
  const STYLE_ID = "devtools-and-grid-style";
  const SYNC_OBSERVER_CONFIG = { subtree: true, attributeFilter: ["class"], attributeOldValue: true };
  let puzzleGrid = document.querySelector("#grid");

  // preferences to remember
  const preferences = GM_getValues({
    synchronized: true,
    pinned: true,
    hidden: false,
  });

  // Remove prior runs
  const oldWrap = document.getElementById(WRAP_ID);
  if (oldWrap) oldWrap.remove();
  const oldStyle = document.getElementById(STYLE_ID);
  if (oldStyle) oldStyle.remove();

  // Config
  const rows = 5;
  const cols = 4;

  // Tile size (original 25x37.5 then 30% smaller)
  const tileW = 25 * 0.7;      // 17.5
  const tileH = 37.5 * 0.7;    // 26.25

  const gap = 4;
  const pad = 8;
  const outerGap = 10;

  const states = [
    { name: "clear", color: "transparent" },
    { name: "red",   color: "rgba(255,0,0,0.55)" },
    { name: "green", color: "rgba(0,200,0,0.55)" },
    { name: "yellow", color: "rgba(255,255,0,0.55)", ignore: true},
  ];

  // Styles
  const style = document.createElement("style");
  style.id = STYLE_ID;
  style.textContent = `
    #${WRAP_ID} {
      position: ${preferences.pinned ? "absolute" : "fixed"};
      left: 8px;
      top: 8px;
      z-index: 2147483647;
      display: flex;
      flex-direction: column;
      gap: ${outerGap}px;
      user-select: none;
      font: 12px/1.2 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
    }

    #${WRAP_ID} .topbar {
      display: flex;
      align-items: center;
      gap: 8px;
    }

    #${WRAP_ID} .row {
      display: flex;
      gap: ${outerGap}px;
      align-items: flex-start;
    }

    #${WRAP_ID} .row.center {
      justify-content: center;
    }

    #${WRAP_ID} .panel {
      position: relative;
      background: rgba(255,255,255,0.92);
      padding: ${pad}px;
      border: 1px solid rgba(0,0,0,0.25);
      border-radius: 8px;
    }

    #${WRAP_ID} .panel.result {
      background: rgba(173, 216, 230, 0.55);
      border-color: rgba(0, 120, 200, 0.35);
    }

    #${WRAP_ID} .title {
      font-weight: 800;
      letter-spacing: 0.4px;
      margin: 0 0 6px 0;
      color: rgba(0,0,0,0.75);
      text-align: center;
    }

    #${WRAP_ID} .grid {
      display: grid;
      grid-template-columns: repeat(${cols}, ${tileW}px);
      grid-template-rows: repeat(${rows}, ${tileH}px);
      gap: ${gap}px;
    }

    #${WRAP_ID} .tile {
      width: ${tileW}px;
      height: ${tileH}px;
      box-sizing: border-box;
      border: 2px solid rgba(0,0,0,0.65);
      background: transparent;
      cursor: pointer;
    }

    #${WRAP_ID} .tile:hover {
      outline: 2px solid rgba(0,0,0,0.25);
      outline-offset: 1px;
    }

    #${WRAP_ID} .panel.result .tile {
      cursor: default;
      pointer-events: none;
    }

    #${WRAP_ID} .tile.synced {
      cursor: default;
      pointer-events: none;
      border: none;
    }

    #${WRAP_ID} .controls {
      margin-top: 8px;
      display: flex;
      gap: 8px;
      justify-content: center;
    }

    #${WRAP_ID} button {
      all: unset;
      cursor: pointer;
      padding: 6px 10px;
      border: 1px solid rgba(0,0,0,0.35);
      border-radius: 6px;
      background: rgba(255,255,255,0.85);
      color: rgba(0,0,0,0.85);
      font-weight: 700;
      line-height: 1;
      text-align: center;
      min-width: 18px;
    }

    #${WRAP_ID} button:hover { background: rgba(240,240,240,0.95); }
    #${WRAP_ID} button:active { transform: translateY(1px); }

    #${WRAP_ID} button[disabled] {
      opacity: 0.5;
      cursor: not-allowed;
    }

    /* Close button positioned outside panel corner */
    #${WRAP_ID} .close-btn {
      position: absolute;
      top: -10px;
      right: -10px;
      width: 20px;
      height: 20px;
      padding: 0;
      min-width: 0;
      border-radius: 999px;
      display: grid;
      place-items: center;
      font-weight: 900;
      line-height: 1;
      background: white;
      border: 1px solid rgba(0,0,0,0.5);
    }
  `;
  document.head.appendChild(style);

  function applyState(tile, idx) {
    tile.dataset.stateIndex = idx;
    tile.style.background = states[idx].color;
  }

  function getState(tile) {
    return Number(tile.dataset.stateIndex || 0);
  }

  function tiles(grid, synced) {
    return synced ? Array.from(grid.querySelectorAll(".tile.synced")) : Array.from(grid.querySelectorAll(".tile"));
  }

  function clearGrid(grid) {
    tiles(grid).forEach(t => { if (!t.classList.contains("synced")) applyState(t, 0) });
  }

  function updateResult(inputs, resultGrid) {
    const rTiles = tiles(resultGrid);
    const inputTiles = inputs.map(g => tiles(g));

    for (let i = 0; i < rTiles.length; i++) {
      const first = getState(inputTiles[0][i]);
      const same = inputTiles.every(arr => getState(arr[i]) === first);
      const canBeIgnored = inputTiles.some(arr => states[getState(arr[i])].ignore);
      const synced = inputTiles.some(arr => arr[i].classList.contains("synced"))
      applyState(rTiles[i], (same && !canBeIgnored && !synced) ? first : 0);
    }
  }

  function createGrid({ clickable, onChange }) {
    const grid = document.createElement("div");
    grid.className = "grid";

    for (let i = 0; i < rows * cols; i++) {
      const tile = document.createElement("div");
      tile.className = "tile";
      applyState(tile, 0);

      if (clickable) {
        tile.onChange = onChange
        tile.addEventListener("click", e => {
          e.stopPropagation();
          const next = (getState(tile) + 1) % states.length;
          applyState(tile, next);
          tile.onChange();
        });
      }

      grid.appendChild(tile);
    }

    return grid;
  }

  function createInputPanel(grid, onClear, withClose, onClose) {
    const panel = document.createElement("div");
    panel.className = "panel";
    panel.appendChild(grid);

    if (withClose) {
      const close = document.createElement("button");
      close.className = "close-btn";
      close.textContent = "×";
      close.addEventListener("click", e => {
        e.stopPropagation();
        onClose();
      });
      panel.appendChild(close);
    }

    const controls = document.createElement("div");
    controls.className = "controls";

    const clear = document.createElement("button");
    clear.textContent = "Clear";
    clear.addEventListener("click", () => {
      clearGrid(grid);
      onClear();
    });

    controls.appendChild(clear);
    panel.appendChild(controls);

    return panel;
  }

  function createResultPanel(grid) {
    const panel = document.createElement("div");
    panel.className = "panel result";

    const title = document.createElement("div");
    title.className = "title";
    title.textContent = "COMMON";

    panel.appendChild(title);
    panel.appendChild(grid);
    return panel;
  }

  const wrap = document.createElement("div");
  wrap.id = WRAP_ID;

  const topbar = document.createElement("div");
  topbar.className = "topbar";

  const addBtn = document.createElement("button");
  addBtn.textContent = "+";

  const pinBtn = document.createElement("button");
  pinBtn.textContent = preferences.pinned ? "Un-📌" : "📌";

  const hideBtn = document.createElement("button");
  hideBtn.textContent = preferences.hidden ? "Show" : "Hide";
  hideBtn.style.visibility = "visible";
  wrap.style.visibility = preferences.hidden ? "hidden" : "visible";

  const syncBtn = document.createElement("button");
  syncBtn.textContent = preferences.synchronized ? "Desync" : "Sync";

  topbar.appendChild(hideBtn);
  topbar.appendChild(addBtn);
  topbar.appendChild(pinBtn);
  topbar.appendChild(syncBtn);
  wrap.appendChild(topbar);

  const topRow = document.createElement("div");
  topRow.className = "row";

  const bottomRow = document.createElement("div");
  bottomRow.className = "row center";

  wrap.appendChild(topRow);
  wrap.appendChild(bottomRow);

  const resultGrid = createGrid({ clickable: false });
  const resultPanel = createResultPanel(resultGrid);
  bottomRow.appendChild(resultPanel);

  const inputs = [];
  const panels = [];
  let thirdIndex = -1;

  function recompute() {
    if (inputs.length) updateResult(inputs, resultGrid);
  }

  function addInput(closable=false) {
    const grid = createGrid({ clickable: true, onChange: recompute });

    const panel = createInputPanel(
      grid,
      recompute,
      closable,
      () => removeThird()
    );

    inputs.push(grid);
    panels.push(panel);
    topRow.appendChild(panel);

    recompute();
  }

  function removeThird() {
    if (thirdIndex === -1) return;

    panels[thirdIndex].remove();
    panels.splice(thirdIndex,1);
    inputs.splice(thirdIndex,1);

    thirdIndex = -1;
    addBtn.disabled = false;

    recompute();
  }

  addInput(false);
  addInput(false);

  function calculateGridPosition(coord) {
    let digits = coord.split("");
    return (parseInt(digits[1]) - 1) * 4 + parseInt(digits[0], 16) - 10;
  }

  function extractTileInfo(suspectTile) {
    let tileIndex = calculateGridPosition(suspectTile.querySelector(".coord").textContent);

    let colorIndex;
    if (suspectTile.classList.contains("criminal")) {
      colorIndex = 1;
    } else if (suspectTile.classList.contains("innocent")) {
      colorIndex = 2;
    } else { colorIndex = 0 }
  
    return [tileIndex, colorIndex]
  }

  function updateInputGrids(inputTiles, suspectTileIndex, color) {
    // Updates a single tile on the input grids.
    for (let i = 0; i < inputTiles.length; i++) {
      let tileToBeSynced = inputTiles[i][suspectTileIndex];
      applyState(tileToBeSynced, color);
      tileToBeSynced.classList.add("synced");
      tileToBeSynced.removeEventListener("click", tileToBeSynced.onChange)
    }
  }

  function syncInputGrids(mainGrid, inputGrids) {
    // Scans the entire main board and syncs the input grids.
    const mainTiles = mainGrid.querySelectorAll(".card");
    const inputTiles = inputGrids.map(g => tiles(g))
    for (const mainTile of mainTiles) {
      let [tileIndex, color] = extractTileInfo(mainTile);
      if (color != 0) updateInputGrids(inputTiles, tileIndex, color);
    }
  }

  function DesyncInputGrids(inputGrids) {
    const inputTiles = inputGrids.map(g => tiles(g, true)).flat()
    for (const inputTile of inputTiles) {
      applyState(inputTile, 0);
      inputTile.classList.remove("synced");
      inputTile.addEventListener("click", inputTile.onChange)
    }
  }

  const syncObserver = new MutationObserver((mutationList) => {
    for (const mutation of mutationList) {
      let currentClassList = Array.from(mutation.target.classList); // Actually unecessary; want to avoid includes-contains confusion

      // The Clues By Sam website writes down class names with whitespaces and newlines for some reason
      let previousClassList = mutation.oldValue.split("\n").map(string => string.trim()).filter(string => (string != ""));

      // Ignoring every change which doesn't indicate that a suspect was revealed
      if (!currentClassList.includes("card") || previousClassList.includes("innocent") || previousClassList.includes("criminal")) {
        continue;
      }

      let [tileIndex, color] = extractTileInfo(mutation.target)
      if (color == 0) { continue; } // Somehow, a suspects verdict wasn't revealed

      updateInputGrids(inputs.map(g => tiles(g)), tileIndex, color);
    }
  })

  addBtn.onclick = () => {
    if (inputs.length >= 3) return;
    addInput(true);
    thirdIndex = inputs.length - 1;
    if (preferences.synchronized) syncInputGrids(puzzleGrid, inputs.slice(-1))
    addBtn.disabled = true;
  };

  hideBtn.onclick = () => {
    preferences.hidden = !preferences.hidden;
    wrap.style.visibility = preferences.hidden ? "hidden" : "visible";
    hideBtn.textContent = preferences.hidden ? "Show" : "Hide";
    GM_setValue("hidden", preferences.hidden);
  };

  pinBtn.onclick = () => {
    preferences.pinned = !preferences.pinned
    wrap.style.position = preferences.pinned ? "absolute" : "fixed";
    pinBtn.textContent = preferences.pinned ? "Un-📌" : "📌"
    GM_setValue("pinned", preferences.pinned);
  }

  syncBtn.onclick = () => {
    preferences.synchronized = !preferences.synchronized
    if (!preferences.synchronized) {
      DesyncInputGrids(inputs);
      syncObserver.disconnect()
    } else {
      syncInputGrids(puzzleGrid, inputs);
      syncObserver.observe(puzzleGrid, SYNC_OBSERVER_CONFIG)
    }

    syncBtn.textContent = preferences.synchronized ? "Desync" : "Sync"
    GM_setValue("synchronized", preferences.synchronized)
  }

  document.body.appendChild(wrap);

  // The main puzzle grid might not be available when connecting to or refreshing the page
  const gridObserver = new MutationObserver((mutationList, go) => {
    console.log("Puzzle grid was added")
    puzzleGrid = document.getElementById("grid")
    syncInputGrids(puzzleGrid, inputs)
    syncObserver.observe(puzzleGrid, SYNC_OBSERVER_CONFIG)
    go.disconnect()
  })

  if (preferences.synchronized) {
    if (puzzleGrid != null) {
      syncInputGrids(puzzleGrid, inputs)
      syncObserver.observe(puzzleGrid, SYNC_OBSERVER_CONFIG)
    } else {
      gridObserver.observe(document.getElementById("root"), { childList: true })
    }
  }

  recompute();
})();