Scratchpads By Sam

Extra grids that let you try out logic permutations.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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