BC Seed Tracker Util

Helps you find the optimal use of your tickets and cat food in BC Seed Tracker.

"use strict";
// ==UserScript==
// @name         BC Seed Tracker Util
// @namespace    https://好きな.みんな
// @version      1.0.0
// @description  Helps you find the optimal use of your tickets and cat food in BC Seed Tracker.
// @author       theusaf
// @match        https://bc.godfat.org/
// @icon         https://bc.godfat.org/asset/image/treasure.png
// @license      MIT
// @grant        none
// @noframes
// ==/UserScript==
(async () => {
    class TrackGraph {
        nodes = new Map();
        addNode(node, name) {
            this.nodes.set(name, node);
        }
        getNode(name) {
            return this.nodes.get(name);
        }
        deleteNode(name) {
            this.nodes.delete(name);
        }
    }
    class TrackGraphNode {
        neighbors = new Map();
        nextNormalPullNode = null;
        element;
        name;
        constructor(element, name) {
            this.element = element;
            this.name = name;
        }
        get catName() {
            return this.element.querySelector("a").textContent;
        }
        get leadsToName() {
            try {
                const outputNodeRight = this.element.childNodes[3], outputNodeLeft = this.element.childNodes[0];
                if (outputNodeRight?.textContent.trim()) {
                    return outputNodeRight.textContent.trim().match(/(\d+[ABR]+)/)[1];
                }
                else if (outputNodeLeft?.textContent.trim()) {
                    return outputNodeLeft.textContent.trim().match(/(\d+[ABR]+)/)[1];
                }
                else {
                    return null;
                }
            }
            catch (e) {
                // probably a <?>
                return null;
            }
        }
    }
    // initial processing (convert DOM into arrays)
    function parseTable() {
        const header = document.querySelector("table tr:first-child"), itemsWanted = ["no.", "result", "score, slot", "guaranteed"], [mainNumber, mainResult, mainGuaranteed, altResult, altGuaranteed, altNumber,] = [...header.children]
            .map((child, i) => [i, child.textContent])
            .filter((data) => itemsWanted.find((item) => data[1].toLowerCase().includes(item.toLowerCase())))
            .map(([i]) => i);
        const rows = document.querySelectorAll("table tr:not(:first-child)");
        let leftTrackNumber = 0, rightTrackNumber = 0;
        const leftTrack = [], rightTrack = [];
        let leftTrackStartIndex = 0, leftTrackEndLength = 0, rightTrackStartIndex = 0, rightTrackEndLength = 0, columnSkip = 0;
        const rowsizes = [].fill(0, 0, altNumber + 1);
        for (let r = 0; r < rows.length; r++) {
            if (leftTrackStartIndex - r + leftTrackEndLength < 0 ||
                leftTrackNumber === 0) {
                leftTrackStartIndex = r;
                leftTrackEndLength = 0;
                leftTrackNumber++;
            }
            if (rightTrackStartIndex - r + rightTrackEndLength < 0 ||
                rightTrackNumber === 0) {
                rightTrackStartIndex = r;
                rightTrackEndLength = 0;
                rightTrackNumber++;
            }
            const row = rows[r];
            let i = 0; // actual element index
            for (let c = 0; c <= altNumber; c++) {
                if (columnSkip > 0) {
                    columnSkip--;
                    if (c === mainNumber)
                        leftTrackNumber--;
                    if (c === altNumber)
                        rightTrackNumber--;
                    continue;
                }
                if (rowsizes[c] > 0) {
                    rowsizes[c]--;
                    continue;
                }
                const cell = row.children[i];
                i++;
                switch (c) {
                    case mainNumber: {
                        leftTrackStartIndex = r;
                        leftTrackEndLength = +cell.getAttribute("rowspan") - 1;
                        break;
                    }
                    case altNumber: {
                        rightTrackStartIndex = r;
                        rightTrackEndLength = +cell.getAttribute("rowspan") - 1;
                        break;
                    }
                    case mainResult: {
                        if (cell.classList.contains("cat") &&
                            cell.classList.contains("pick")) {
                            if (leftTrack[leftTrackNumber - 1]) {
                                leftTrack[leftTrackNumber - 1][0].push(cell);
                            }
                            else {
                                leftTrack[leftTrackNumber - 1] = [[cell], []];
                            }
                        }
                        break;
                    }
                    case mainGuaranteed: {
                        if (cell.classList.contains("cat") &&
                            cell.classList.contains("pick")) {
                            leftTrack[leftTrackNumber - 1][1].push(cell);
                        }
                        break;
                    }
                    case altResult: {
                        if (cell.classList.contains("cat") &&
                            cell.classList.contains("pick")) {
                            if (rightTrack[rightTrackNumber - 1]) {
                                rightTrack[rightTrackNumber - 1][0].push(cell);
                            }
                            else {
                                rightTrack[rightTrackNumber - 1] = [[cell], []];
                            }
                        }
                        break;
                    }
                    case altGuaranteed: {
                        if (cell.classList.contains("cat") &&
                            cell.classList.contains("pick")) {
                            rightTrack[rightTrackNumber - 1][1].push(cell);
                        }
                        break;
                    }
                }
                const rowspan = +(cell.getAttribute("rowspan") ?? 1), colspan = +(cell.getAttribute("colspan") ?? 1);
                if (rowspan > 1) {
                    rowsizes[c] = rowspan - 1;
                }
                if (colspan > 1) {
                    columnSkip = colspan - 1;
                }
            }
        }
        return { leftTrack, rightTrack };
    }
    function generateGraph(leftTrack, rightTrack) {
        const graph = new TrackGraph();
        function nodify(track, letter) {
            for (let i = 0; i < track.length; i++) {
                const [normal, guaranteed] = track[i];
                for (let j = 0; j < normal.length; j++) {
                    const name = `${i + 1}${letter}${j === 0 ? "" : "R"}`, node = new TrackGraphNode(normal[j], name);
                    graph.addNode(node, name);
                }
                for (let j = 0; j < guaranteed.length; j++) {
                    const name = `${i + 1}${letter}${j === 0 ? "" : "R"}G`, node = new TrackGraphNode(guaranteed[j], name);
                    graph.addNode(node, name);
                }
            }
        }
        // nodify all items
        nodify(leftTrack, "A");
        nodify(rightTrack, "B");
        for (const node of graph.nodes.values()) {
            const nodeName = node.name, nodeCatName = node.catName, [, nodeNumber, nodeFlags] = nodeName.match(/(\d+)(\w+)/);
            // normal pull
            let normalPullName = node.leadsToName ?? `${+nodeNumber + 1}${nodeFlags}`, normalPull = graph.getNode(normalPullName);
            if (normalPull) {
                const normalPullNextCatName = normalPull.catName;
                if (normalPullNextCatName === nodeCatName) {
                    normalPullName = `${normalPullName}R`;
                    normalPull = graph.getNode(normalPullName);
                }
                if (normalPull) {
                    node.neighbors.set(normalPull, "normal");
                    node.nextNormalPullNode = normalPull;
                }
            }
            // guaranteed pull
            if (normalPull) {
                const guaranteedPullName = `${normalPull.name}G`, guaranteedPull = graph.getNode(guaranteedPullName);
                if (guaranteedPull) {
                    const guaranteedPullLinkName = guaranteedPull.leadsToName, guaranteedLink = graph.getNode(guaranteedPullLinkName), nameNumber = +normalPull.name.match(/(\d+)/)[1], linkNameNumber = +guaranteedPullLinkName.match(/(\d+)/)[1];
                    guaranteedPull.nextNormalPullNode = guaranteedLink ?? null;
                    node.neighbors.set(guaranteedPull, linkNameNumber - +nameNumber <= 14 ? "guaranteed11" : "guaranteed15");
                }
            }
        }
        const zeroNode = new TrackGraphNode(document.createElement("div"), "0A");
        graph.addNode(zeroNode, "0A");
        zeroNode.neighbors.set(graph.getNode("1A"), "normal");
        const guaranteedNode = graph.getNode("1AG");
        if (guaranteedNode) {
            // get guaranteed node type
            const guaranteedNodeName = guaranteedNode.leadsToName, guaranteedNodeNumber = +guaranteedNodeName.match(/(\d+)/)[1], guaranteedNodeType = guaranteedNodeNumber <= 14 ? "guaranteed11" : "guaranteed15";
            zeroNode.neighbors.set(guaranteedNode, guaranteedNodeType);
        }
        return graph;
    }
    class Distance {
        ticketsLeft;
        catFoodLeft;
        virtualFoodUsed;
        catsFound = new Set();
        constructor(ticketsLeft, catFoodLeft, initialValue = 0) {
            this.ticketsLeft = ticketsLeft;
            this.catFoodLeft = catFoodLeft;
            this.virtualFoodUsed = initialValue;
        }
        getValue() {
            return this.virtualFoodUsed;
        }
        addCat(catName) {
            this.catsFound.add(catName);
        }
        hasCat(catName) {
            return this.catsFound.has(catName);
        }
        getCats() {
            return [...this.catsFound];
        }
    }
    const SINGLE_PULL_COST = 150;
    const SINGLE_PULL_COST_DISCOUNT = 30;
    const ELEVEN_PULL_COST = 1500;
    const ELEVEN_PULL_COST_DISCOUNT = 750;
    const FIFTEEN_PULL_COST = 2100;
    function getCost(type) {
        switch (type) {
            case "normal":
                return SINGLE_PULL_COST;
            case "guaranteed11":
                return ELEVEN_PULL_COST;
            case "guaranteed15":
                return FIFTEEN_PULL_COST;
        }
    }
    function getSmallestVertex(distances, queue) {
        let smallest = null;
        let smallestDistance = Infinity;
        for (const node of queue) {
            const distance = distances.get(node);
            if (distance.getValue() < smallestDistance) {
                smallest = node;
                smallestDistance = distance.getValue();
            }
        }
        return smallest;
    }
    function getPath(previous, start, end) {
        const path = [];
        let current = end;
        while (current !== start) {
            path.push(current);
            current = previous.get(current);
        }
        path.reverse();
        return path;
    }
    // a modified version of Dijkstra's algorithm
    // somewhat like a star search
    function graphSearch(graph, start, { cats, tickets, catFood, hasDiscount, foundCatValue = ELEVEN_PULL_COST, ticketValue = SINGLE_PULL_COST, }) {
        if (cats.length === 0) {
            throw new Error("There must be at least one cat to search for.");
        }
        if (hasDiscount) {
            catFood += ELEVEN_PULL_COST - ELEVEN_PULL_COST_DISCOUNT;
            catFood += SINGLE_PULL_COST - SINGLE_PULL_COST_DISCOUNT;
        }
        const distances = new Map();
        const previous = new Map();
        const queue = new Set();
        for (const node of graph.nodes.values()) {
            distances.set(node, new Distance(tickets, catFood, Infinity));
            previous.set(node, null);
            queue.add(node);
        }
        distances.set(start, new Distance(tickets, catFood, 0));
        const catSetsFound = new Map();
        while (queue.size > 0) {
            const vertex = getSmallestVertex(distances, queue);
            // probably out of "resources"
            if (!vertex)
                break;
            queue.delete(vertex);
            // check cats found!
            const catsFound = distances
                .get(vertex)
                .getCats()
                .filter((cat) => cats.includes(cat));
            if (catsFound.length > 0) {
                if (!catSetsFound.has(catsFound.length)) {
                    catSetsFound.set(catsFound.length, []);
                }
                const catLengthSets = catSetsFound.get(catsFound.length);
                let found = false;
                for (const catSet of catLengthSets) {
                    if (catsFound.every((cat) => catSet.cats.has(cat))) {
                        found = true;
                        break;
                    }
                }
                if (!found) {
                    // adjust distance output
                    const distance = distances.get(vertex), path = getPath(previous, start, vertex), duplicateDistance = new Distance(distance.ticketsLeft, distance.catFoodLeft, distance.getValue());
                    if (hasDiscount) {
                        if (duplicateDistance.ticketsLeft > 0) {
                            duplicateDistance.catFoodLeft -=
                                SINGLE_PULL_COST - SINGLE_PULL_COST_DISCOUNT;
                        }
                        // scan for any guaranteed pulls
                        let found = false;
                        for (let i = 0; i < path.length; i++) {
                            const node = path[i];
                            if (node.name.includes("G")) {
                                found = true;
                                break;
                            }
                        }
                        if (!found) {
                            console.log("no guaranteed pulls found", path);
                            duplicateDistance.catFoodLeft -=
                                ELEVEN_PULL_COST - ELEVEN_PULL_COST_DISCOUNT;
                        }
                    }
                    catLengthSets.push({
                        cats: new Set(catsFound),
                        path,
                        finalDistance: duplicateDistance,
                    });
                }
                if (catSetsFound.size === cats.length) {
                    // we found all cats!
                    console.log("found all cats!");
                    break;
                }
            }
            for (const [neighbor, distanceType] of vertex.neighbors.entries()) {
                if (!queue.has(neighbor))
                    continue;
                let cost = getCost(distanceType);
                const vertexDistance = distances.get(vertex);
                let tickets = vertexDistance.ticketsLeft, catFood = vertexDistance.catFoodLeft;
                // handle initial pull (including start)
                if (distanceType === "normal" && tickets > 0) {
                    tickets--;
                    cost = ticketValue;
                }
                else {
                    catFood -= cost;
                }
                let alt = vertexDistance.getValue() + cost;
                if (catFood < 0)
                    alt = Infinity;
                const distance = new Distance(tickets, catFood, alt), currentCats = vertexDistance.getCats();
                for (const cat of currentCats) {
                    distance.addCat(cat);
                }
                // heuristic for finding wanted cats
                const handleNewWanted = (catName) => {
                    if (cats.includes(catName) && !vertexDistance.hasCat(catName)) {
                        alt -= foundCatValue;
                        distance.virtualFoodUsed = alt;
                    }
                };
                const handleGuaranteed = (numToSummon) => {
                    distance.addCat(neighbor.catName);
                    let start = [...vertex.neighbors.entries()].find(([, type]) => type === "normal")?.[0] ?? null;
                    for (let i = 0; i < numToSummon; i++) {
                        if (!start)
                            break;
                        distance.addCat(start.catName);
                        handleNewWanted(start.catName);
                        start = start.nextNormalPullNode;
                    }
                };
                // simulate pull
                switch (distanceType) {
                    case "normal": {
                        distance.addCat(neighbor.catName);
                        handleNewWanted(neighbor.catName);
                        break;
                    }
                    case "guaranteed11": {
                        handleGuaranteed(10);
                        break;
                    }
                    case "guaranteed15": {
                        handleGuaranteed(14);
                        break;
                    }
                }
                if (alt < distances.get(neighbor).getValue()) {
                    // update distances
                    distances.set(neighbor, distance);
                    previous.set(neighbor, vertex);
                }
            }
        }
        return catSetsFound;
    }
    function* subsets(array, offset = 0) {
        while (offset < array.length) {
            const first = array[offset++];
            for (const subset of subsets(array, offset)) {
                subset.push(first);
                yield subset;
            }
        }
        yield [];
    }
    function multiSearch(graph, start, { cats, tickets, catFood, hasDiscount, ticketValue }) {
        const results = new Map(), foundCatValues = [0, ELEVEN_PULL_COST, FIFTEEN_PULL_COST];
        for (const subset of subsets(cats)) {
            if (subset.length === 0)
                continue;
            for (const foundCatValue of foundCatValues) {
                const result = graphSearch(graph, start, {
                    cats: subset,
                    tickets,
                    catFood,
                    hasDiscount,
                    foundCatValue,
                    ticketValue,
                }), lengthResult = result.get(subset.length);
                if (lengthResult) {
                    results.set(subset, lengthResult[0] ?? null);
                    break;
                }
                else {
                    results.set(subset, null);
                }
            }
        }
        return results;
    }
    function htmlEntities(str) {
        return String(str)
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;");
    }
    function wait(ms = 0) {
        return new Promise((resolve) => setTimeout(resolve, ms));
    }
    const ui = document.createElement("template");
    ui.innerHTML = `
    <style>
      #bstu-main {
        position: fixed;
        top: 0;
        right: 0;
        background: grey;
        padding: 0.5rem;
        max-width: 25rem;
        max-height: 100vh;
        overflow: auto;
      }
      .bstu-choice {
        background: #eee;
        border-radius: 0.5rem;
        padding: 0.25rem;
        margin: 0.25rem;
      }
      #bstu-choices {
        display: flex;
        flex-wrap: wrap;
      }
      .bstu-result {
        background: darkgray;
        border-radius: 0.5rem;
        margin: 0.25rem;
        padding: 0.25rem;
      }
      .bstu-result-cats {
        font-weight: bold;
      }
      .bstu-result-distance-food {
        border-radius: 0.5rem;
        background: red;
        color: white;
        padding: 0.1rem;
      }
      .bstu-result-distance-tickets {
        border-radius: 0.5rem;
        background: gold;
        padding: 0.1rem;
      }

      .bstu-rarity-rare {
        color: white;
      }
      .bstu-rarity-super {
        color: blue;
      }
      .bstu-rarity-uber {
        color: purple;
      }
      .bstu-rarity-legend {
        color: red;
      }
    </style>
    <div id="bstu-main">
      <details>
        <summary>BC Seed Tracker Util</summary>
        <div>
          <div>
            <div>
              <label for="bstu-discount">Has Discount</label>
              <input type="checkbox" id="bstu-discount" />
            </div>
            <div>
              <label for="bstu-tickets">Tickets</label>
              <input type="number" id="bstu-tickets" />
            </div>
            <div>
              <label for="bstu-cat-food">Cat Food</label>
              <input type="number" id="bstu-cat-food" />
            </div>
            <div>
              <label for="bstu-ticket-value" title="The cat food value given to a ticket. Influences how much the algorithm will prefer using tickets.">Ticket Value</label>
              <input type="number" id="bstu-ticket-value" />
            </div>
          </div>
          <select id="bstu-selector"></select>
          <button id="bstu-start-button">Calculate Paths</button>
        </div>
        <div id="bstu-choices">
        </div>
        <hr />
        <div id="bstu-results">
        </div>
      </details>
    </div>
  `;
    document.body.appendChild(ui.content.cloneNode(true));
    await wait();
    // copy available cats
    const selector = document.querySelector("#bstu-selector"), choicesArea = document.querySelector("#bstu-choices"), resultsArea = document.querySelector("#bstu-results"), startButton = document.querySelector("#bstu-start-button"), discountCheckbox = document.querySelector("#bstu-discount"), ticketsInput = document.querySelector("#bstu-tickets"), catFoodInput = document.querySelector("#bstu-cat-food"), ticketValueInput = document.querySelector("#bstu-ticket-value"), providedCatSelector = document.querySelector("#find_select"), options = providedCatSelector.cloneNode(true)
        .children;
    selector.append(...options);
    selector.value = "";
    const choices = new Set();
    function addChoice(cat) {
        if (choices.has(cat))
            return;
        choices.add(cat);
        const choice = document.createElement("span");
        choice.innerHTML = `
      <span class="bstu-choice-name">${cat}</span>
      <span class="bstu-choice-remove">x</span>
    `;
        choice.className = "bstu-choice";
        const node = choice.cloneNode(true);
        choicesArea.append(node);
        node.querySelector(".bstu-choice-remove").addEventListener("click", () => {
            choices.delete(cat);
            node.remove();
        });
    }
    function getRarity(cat) {
        const option = [
            ...selector.querySelectorAll("option"),
        ].find((option) => option.textContent === cat), optGroup = option.parentElement;
        return optGroup.label.match(/\w+/)[0].toLowerCase();
    }
    function saveToLocalStore() {
        localStorage.setItem("bstu-data", JSON.stringify({
            cats: [...choices],
            tickets: ticketsInput.value,
            catFood: catFoodInput.value,
            hasDiscount: discountCheckbox.checked,
            ticketValue: ticketValueInput.value,
        }));
    }
    function loadFromLocalStore() {
        const data = JSON.parse(localStorage.getItem("bstu-data") ?? "{}");
        choices.clear();
        for (const cat of data.cats ?? []) {
            if (![...selector.options].find((option) => option.textContent === cat)) {
                continue;
            }
            addChoice(cat);
        }
        ticketsInput.value = data.tickets ?? "";
        catFoodInput.value = data.catFood ?? "";
        discountCheckbox.checked = data.hasDiscount ?? false;
        ticketValueInput.value = data.ticketValue ?? "150";
    }
    loadFromLocalStore();
    selector.addEventListener("change", () => {
        if (!selector.value)
            return;
        addChoice(selector.options[selector.selectedIndex].textContent);
        setTimeout(() => (selector.value = ""));
    });
    startButton.addEventListener("click", async () => {
        resultsArea.innerHTML = "";
        await wait(500);
        const { leftTrack, rightTrack } = parseTable(), graph = generateGraph(leftTrack, rightTrack), results = multiSearch(graph, graph.getNode("0A"), {
            cats: [...choices],
            tickets: ticketsInput.valueAsNumber,
            catFood: catFoodInput.valueAsNumber || Infinity,
            hasDiscount: discountCheckbox.checked,
            ticketValue: ticketValueInput.valueAsNumber,
        });
        saveToLocalStore();
        console.log(results);
        // generate output
        for (const [catList, result] of [...results.entries()].sort(([a], [b]) => b.length - a.length)) {
            const resultDiv = document.createElement("div");
            resultDiv.className = "bstu-result";
            if (result) {
                const { path, finalDistance } = result, simplifiedPath = [];
                let currentPullType = null, currentPullCount = 0;
                for (let i = 1; i < path.length; i++) {
                    const prev = path[i - 1], current = path[i], type = prev.neighbors.get(current);
                    if (type === currentPullType) {
                        currentPullCount++;
                    }
                    else {
                        if (currentPullType) {
                            simplifiedPath.push({
                                type: currentPullType === "normal" ? "normal" : "guaranteed",
                                count: currentPullCount,
                            });
                        }
                        currentPullType = type;
                        currentPullCount = 1;
                    }
                }
                if (currentPullType) {
                    simplifiedPath.push({
                        type: currentPullType === "normal" ? "normal" : "guaranteed",
                        count: currentPullCount,
                    });
                }
                const isInitialGuaranteed = path[0].name.includes("G"), currentFirstPath = simplifiedPath[0];
                const tempPath = isInitialGuaranteed
                    ? { type: "guaranteed", count: 1 }
                    : { type: "normal", count: 1 };
                if (currentFirstPath?.type === tempPath.type) {
                    currentFirstPath.count += tempPath.count;
                }
                else {
                    simplifiedPath.unshift(tempPath);
                }
                resultDiv.innerHTML = `
          <div>
            <span class="bstu-result-cats">${catList
                    .map((cat) => `<span class="bstu-rarity-${getRarity(cat)}">${htmlEntities(cat)}</span>`)
                    .join(", ")}</span>
            <span class="bstu-result-distance">
              <span class="bstu-result-distance-food" title="remaining cat food">${finalDistance.catFoodLeft}</span>
              <span class="bstu-result-distance-tickets" title="remaining tickets">${finalDistance.ticketsLeft}</span>
            </span>
          </div>
          <div>
            <span class="bstu-result-path" data-path="${htmlEntities(path.map((node) => `${node.catName} (${node.name})`).join(" > "))}">
            ${simplifiedPath
                    .map((pull) => `${pull.count} ${pull.type === "normal" ? "pull" : "guaranteed pull"}${pull.count > 1 ? "s" : ""}`)
                    .join(", ")}
            </span>
          </div>
        `;
                await wait();
                resultDiv
                    .querySelector(".bstu-result-path")
                    .addEventListener("click", (e) => {
                    // copy path to clipboard
                    const path = e.target.getAttribute("data-path");
                    navigator.clipboard.writeText(path);
                    alert("Copied path to clipboard!");
                });
            }
            else {
                resultDiv.innerHTML = `
          <div>
            <span class="bstu-result-cats">${catList
                    .map((cat) => `<span class="bstu-rarity-${getRarity(cat)}">${htmlEntities(cat)}</span>`)
                    .join(", ")}</span>
          </div>
          <div>
            <span class="bstu-result-path">No path found</span>
          </div>
        `;
            }
            resultsArea.append(resultDiv);
        }
    });
})();