BotC homebrew and large script renderer

Extensions for Homebrew Scripts and formatting for large Custom Scripts with official script tool of Blood on the Clocktower

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         BotC homebrew and large script renderer
// @namespace    http://tampermonkey.net/
// @version      2026-03-20
// @description  Extensions for Homebrew Scripts and formatting for large Custom Scripts with official script tool of Blood on the Clocktower
// @author       You
// @match        https://script.bloodontheclocktower.com/
// @icon         https://www.google.com/s2/favicons?sz=64&domain=bloodontheclocktower.com
// @grant        none
// @license      MIT
// ==/UserScript==

(async function() {
    'use strict';

    console.log("start prettyprint BotC script");

    let form = document.createElement("form");
    form.innerHTML = `
    <label for="characters-json-url">Bloodstar-JSON-URLs (newline-separated): </label><button id="update-almanac-jsons-button" type="button">Update</button>
    <textarea id="characters-json-url-input" name="characters-json-url" placeholder="&lt;URLs&gt;" cols="100" autocorrect="off" oninput="event.target.style.height = '5px'; event.target.style.height = event.target.scrollHeight + 'px'"></textarea>
    <label for="homebrew-jinxes">Homebrew Jinxes: (Format: <code>‹character›/‹character›…: ‹description›</code>)</label><button id="update-homebrew-jinxes-button" type="button">Update</button><button id="clear-hombrew-jinxes-button" type="button">Clear</button>
    <textarea id="homebrew-jinxes-input" name="homebrew-jinxes" cols="100" oninput="event.target.style.height = '5px'; event.target.style.height = event.target.scrollHeight + 'px'"></textarea>
    `;

    let metadataForm = document.getElementById("metadata");
    metadataForm.prepend(form);

    var jsonUrlInput = document.getElementById("characters-json-url-input");
    jsonUrlInput.value =
`https://www.bloodstar.xyz/p/Elmar/trust-me-bro/script.json?2e68501c
https://www.bloodstar.xyz/p/Elmar/homebrew/script.json?37ca6d00
https://www.bloodstar.xyz/p/Elmar/imppreposterous/script.json?37fb06aa`;

    // value entries of the charactersJsons dictionary
    class Almanac
    {
        // the object cannot contain functions as they are not de-/sierialized from/to localStorage and therefore missing
        static create(jsonUrl, json)
        {
            return {
                jsonUrl: jsonUrl,
                name: Almanac.getAlmanacName(jsonUrl),
                json: json,
            };
        }

        static getAlmanacLink(almanac)
        {
            return almanac.json[0].almanac;
        }

        static toHtmlUrl(jsonUrl)
        {
            return jsonUrl.replace(/\/[^\/]+$/, "/almanac.html");
        }

        static getAlmanacName(url)
        {
            let almanacName = /[^\/]+(?=\/[^\/]+$)/.exec(url)[0];

            almanacName = almanacName.replaceAll(/[^\d\w_]/g, "");
            return almanacName;
        }

        // adjust the base URL before the `#`
        static replaceAlmanacBaseUrl(href, almanacUrl)
        {
            let [_, entryName] = href.split("#");
            let [name, almanacName] = entryName.split("_");

            if (typeof(almanacUrl) !== "string")
                almanacUrl = almanacUrl[almanacName].json[0].almanac;

            return `${almanacUrl}#${name}_${almanacName}`;
        }
    }

    function getCharacterEntries(almanacNames, ...characterIds)
    {
        let generalSelectors = (almanacNames.length > 0)? `[id$=_${almanacNames.join("],[id$=_")}]` : "";
        let specificSelectors = (characterIds.length > 0)? `#${characterIds.join(",#")}` : "";
        return document.querySelectorAll(`.item:is(${generalSelectors}${generalSelectors && specificSelectors && ","}${specificSelectors})`);
    }

    class CustomScript
    {
        static adjustCharacterImages(almanacNames, transform, ...characterIds)
        {
            transform ??= "translateY(0.5lh)";

            for (let characterEntry of getCharacterEntries(almanacNames, ...characterIds))
            {
                let image = characterEntry.querySelector(`.icon-container img`);
                image.style.transform = transform;
            }
        }

        static compressCharacterRoster(gridItemMargin, lineHeight)
        {
            for (let header of document.querySelectorAll(`#script h3[data-for], print-box h3[data-for]`)) {
                header.style.marginTop = "0.5em";
                header.style.marginBottom = "0";
            }

            for (let gridItem of document.querySelectorAll(`#script .script .item`)) {
                if (gridItemMargin) gridItem.style.margin = `${gridItemMargin}`;

                if (lineHeight !== undefined) gridItem.style.lineHeight = lineHeight;
            }
        }

        /** at least 1 section name, Lower Case */
        static compressCharacterSections(gridItemMargin, ...sectionNames)
        {
            if (sectionNames.length === 0) return;

            for (let section of document.querySelectorAll(`:is(.${sectionNames.join(", .")})`))
            {
                section.style.gap = "0 1em";
                if (!gridItemMargin) continue;

                for (var item of section.querySelectorAll(`.item`))
                {
                    item.style.margin = gridItemMargin;
                }
                if (section.classList.contains("fabled-and-loric"))  // a negative bottom margin before the bootlegger, moves the Bootlegger to the next page, magically
                    item.style.marginBottom = 0;
            }
        }

        static removeVerticalGap(...sectionNames)
        {
            for (let section of document.querySelectorAll(`:is(.${sectionNames.join(", .")})`))
            {
                section.style.rowGap = 0;
            }
        }

        static transformCharacterEntries(transform, ...characterIds)
        {
            for (let gridItem of document.querySelectorAll(`.script .item:is(#${characterIds.join(", #")})`))
            {
                gridItem.style.transform = transform;
            }
        }

        static compressBootlegger()
        {
            let bootleggerContainer = document.querySelector(`.bootlegger-rules-container`);
            bootleggerContainer.style.rowGap = "0";
            let bootleggerEntry = document.getElementById("bootlegger");
            bootleggerEntry.style.margin = "-0.5lh 0";
            let bootleggerRuleList = document.querySelector(`.bootlegger-rules`);
            bootleggerRuleList.style.rowGap = "0.2lh";
            for (let ruleEntry of bootleggerRuleList.querySelectorAll(`.item`))
            {
                ruleEntry.style.lineHeight = "1";
            }
        }

        static startTravellersOnNewPage()
        {
            let travellersSection = document.querySelector("div.recommended-travellers-container");
            travellersSection.style.breakBefore = "always";
        }

        static enableSeamlessNightSheets()
        {
            let page3 = (document.querySelector("page-three"));
            if (page3) page3.style.breakBefore = "avoid";
            if (page3) page3.style.breakAfter = "avoid";
        }

        static transformBootleggerBulletPoints(transform)
        {
            for (const node of document.querySelectorAll(".script-container .bootlegger-rules .rule"))
            {
                node.style.maxWidth = "none";
                let bulletPoint = node.parentElement.querySelector(".fa-circle")
                bulletPoint.style.scale = "1.0";
                bulletPoint.style.transform = transform;
            }
        }

        static transformFabledLoricJinxIcons(transform)
        {
            for (let characterEntry of document.querySelectorAll(`:is([data-type="fabled" s], [data-type="loric" s]) .character-name .character-jinxes`))
            {
                characterEntry.style.transform = transform;
            }
        }

        static format(scriptName)
        {
            let alphanumName = scriptName.replace(/[^\w\d]/g, '');
            return CustomScript[alphanumName]?.();
        }

        static CasualontheHomebrewer()
        {
            CustomScript.adjustCharacterImages(["imppreposterous", "trustmebro", "homebrew"]);

            if (!homebrewJinxesInput.value.trim())
                homebrewJinxesInput.value =
`Rookie / Monstro: If the Rookie executes the player with Monstro, they are not assassinated and don't die.
Rookie / Lleedh: If the Rookie moves the Lleedh to another player, the host remains the same.`;

            CustomScript.compressCharacterSections("-0.1lh 0", "first-night", "other-night");

            return () => {};
        }

        static Beginners101()
        {
            CustomScript.adjustCharacterImages(["imppreposterous", "trustmebro", "homebrew"]);

            return false;
        }

        static WhatsYourAlignment()
        {
            CustomScript.adjustCharacterImages(["imppreposterous", "trustmebro", "homebrew"]);

            if (!homebrewJinxesInput.value.trim())
                homebrewJinxesInput.value =
`Ponerologist / Lover: Each change of the alignment shared with the Lover only counts as 1 change.
Identity Thief / Bounty Hunter: If the Identity Thief has the bounty and their victim is executed, the Bounty Hunter learns a new player.
Gnostic / Cult Leader: The Gnostic's winning condition is not used, if the Cult Leader ends the game.
Epidemon / Bodyguard: Epidemon only gets to know the VIP if they are the VIP.
Mafia Boss / Aristocrat: Both Characters cannot be in play together.
Hydra / Murderer: The Murderer *character* may register as Slayer character to the Hydra.
Nimp / Snake Charmer: If the Snake Charmer swaps with the Nimp, they learn the player chosen by the Nimp on night 1.
Repairman / Aristocrat / Lover: If the Aristocrat or Lover is in play, the Repairman allows for 1 more evil player who can also be neutral.`;

            CustomScript.compressCharacterSections("-0.15lh 0", "townsfolk", "outsider", "minion", "demon");
            CustomScript.compressCharacterSections("-0.1lh 0", "fabled-and-loric");
            CustomScript.compressCharacterSections("-0.1lh 0", "recommended-travellers");
            //CustomScript.compressCharacterSections("-0.1lh 0", "first-night", "other-night");

            CustomScript.removeVerticalGap("jinxes-container .jinxes");

            CustomScript.enableSeamlessNightSheets();

            CustomScript.transformBootleggerBulletPoints(`scale(0.5)`);

            //let firstNightContainer = document.querySelector(`.night-sheet .first-night-container`);
            //firstNightContainer.style.marginTop = "-9lh";

            return () => {};
        }

        static WickedWizardry()
        {
            CustomScript.adjustCharacterImages(["trustmebro", "homebrew"]);

            if (!homebrewJinxesInput.value.trim())
                homebrewJinxesInput.value =
`Alchemist / Tyrant: If the Alchemist has the Tyrant ability, the Alchemist is an Outsider, not a Townsfolk.
Alchemist / President: If the Alchemist has the President ability, the Alchemist is an Outsider, not a Townsfolk.
Alchemist / Executioner: If the Alchemist has the Executioner ability, the evil Executioner is also in play.
Murderer / Punk: If the Murderer satisfies the condition by targeting the Punk, the Punk is assassinated.
Faustian / Twin Demon: The Faustian acts on both Twin Demons independently.
Faustian / Maniac: The Faustian replaces 1 choice of the Maniac if it does *not* kill the Faustian target.
Tyrant / Bipartisan: If the Bipartisan is used by the Tyrant, it is declared and each Townsfolk player gets an independent anti or normal state.
Hydra / Murderer: The Murderer *character* may register as Slayer character to the Hydra.
Hydra / Faustian: The Faustian *character* may register as Slayer character to the Hydra.
Steelfzkin / Witcher: Steelfzkin related jinxes are hidden from abilities and cannot interact (including this jinx).
Steelfzkin / Maniac: If the Steelfzkin is in play, the Maniac dies if the Maniac is guessed as a Demon (no matter their Demon ability).`;

            CustomScript.compressCharacterRoster("");
            CustomScript.compressCharacterSections("-0.4lh 0", "townsfolk", "outsider", "minion", "demon");
            CustomScript.compressCharacterSections("-0.1lh 0", "recommended-travellers");
            CustomScript.compressCharacterSections("-0.2lh 0", "fabled-and-loric");
            CustomScript.compressCharacterSections("-0.15lh 0", "first-night", "other-night");

            /* no effect, all grid items are forced to be of same size
            for (let entry of document.querySelectorAll(":is(#genie_homebrew, #survivor_homebrew)"))
                entry.style.margin = "-1.3lh 0";
            for (let entry of document.querySelectorAll(":is(#villagegargoyle_trustmebro)"))
                entry.style.margin = "-0.3lh 0";
            for (let entry of document.querySelectorAll(":is(#punk_trustmebro, #tyrant_trustmebro, #steelfzkin_trustmebro)"))
                entry.style.marginTop = "-0.3lh";
            */

            CustomScript.transformCharacterEntries("translateY(0.2lh)", "punk_trustmebro");
            CustomScript.transformCharacterEntries("translateY(-0.2lh)", "executioner_homebrew");
            CustomScript.transformCharacterEntries("translateY(-0.4lh)", "conspirator_homebrew");

            CustomScript.removeVerticalGap("jinxes-container .jinxes");

            // enhance page break inside the bootlegger rules
            let bootleggerEntry = document.getElementById("bootlegger");
            bootleggerEntry.style.marginBottom = "-0.75lh";
            let bootleggerRules = bootleggerEntry.nextSibling;
            bootleggerRules.style.marginBottom = "0";
            bootleggerRules.style.rowGap = "0";
            let bootleggerFirstRule = bootleggerRules.querySelector(`[data-idx="0"] .rule`);
            bootleggerFirstRule.style.marginBottom = "0.5lh";

            let firstNightHeading = document.querySelector(`page-three .first-night-container h3`);
            firstNightHeading.style.margin = "-1lh 0 -0.5lh";
            let otherNightHeading = document.querySelector(`page-four .other-night-container h3`);
            otherNightHeading.style.margin = "-0.5lh 0 -0.5lh";

            let fabledAndLoricHeading = document.querySelector(`.fabled-and-loric-heading`);
            fabledAndLoricHeading.style.margin = "-1lh 0 -0.5lh";

            CustomScript.enableSeamlessNightSheets();

            CustomScript.transformBootleggerBulletPoints(`scale(0.5)`);

            let firstNightList = document.querySelector(`page-three .first-night`);
            firstNightList.style.lineHeight = "1";
            let otherNightList = document.querySelector(`page-four .other-night`);
            otherNightList.style.lineHeight = "1";

            return () => {};
        }

        static DearDictator()
        {
            CustomScript.adjustCharacterImages(["trustmebro", "homebrew"]);

            if (!homebrewJinxesInput.value.trim())
                homebrewJinxesInput.value =
`Pope / Shredder: The Pope-protected player cannot be chosen for assassination (except if a Demon assassinates the self-protected Pope).
Pope / Punk: The Punk cannot get the Pope token.
Sectarian / Romantic: The Sectarian *might* learn that the Romantic is in play.
Anarchist / Samurai: A Samurai player can only kill an Anarchist when the Samurai has another character that only counts as Outsider this game.
Centralist / Terminator: If the Terminator nominates and executes the Demon player when dead, the Terminator's winning condition triggers nevertheless.
Warmonger / Anarchist: If the Warmonger chooses the Anarchist player, the Anarchist registers as good (otherwise evil).
Dictator / Anarchist: The Anarchist *might* register as good to Dictator.
Repairman / Toy Maker: Evil may see each other if 3 ≥ good Townsfolk are in play.`;


            CustomScript.compressCharacterRoster("0 0 -0.3lh");
            CustomScript.compressCharacterSections("-0.1lh 0", "townsfolk", "minion");
            CustomScript.compressCharacterSections("-0.5lh 0 0", "fabled-and-loric");

            // squeeze Travellers, Fabled, Loric onto 1 page
            CustomScript.removeVerticalGap("recommended-travellers", "jinxes-container .jinxes", "fabled-and-loric");

            let fabledAndLoricHeading = document.querySelector(`.fabled-and-loric-heading`);
            fabledAndLoricHeading.style.margin = "-1lh 0 -0.5lh";

            CustomScript.compressBootlegger();

            let firstNightContainer = document.querySelector(`.night-sheet .first-night-container`);
            firstNightContainer.style.marginTop = "-1.5lh";

            CustomScript.enableSeamlessNightSheets();

            // make it fit on 5 pages
            let nightSheet = document.querySelector(`#script .night-sheet`);
            nightSheet.style.paddingBottom = "0";

            CustomScript.transformBootleggerBulletPoints(`scale(0.5)`);

            let firstNightList = document.querySelector(`page-three .first-night`);
            firstNightList.style.lineHeight = "1";
            let otherNightList = document.querySelector(`page-four .other-night`);
            otherNightList.style.lineHeight = "1";

            return () => {};
        }

        static TrustMeBro()
        {
            CustomScript.adjustCharacterImages(["trustmebro"]);
            CustomScript.adjustCharacterImages([], "translateY(-0.5lh)", "puzzledrunk_trustmebro");

            CustomScript.compressCharacterRoster();
            CustomScript.compressCharacterSections("-0.15lh 0 -0.55lh", "outsider");
            CustomScript.compressCharacterSections("0 0 -0.45lh", "minion");
            CustomScript.compressCharacterSections("-0.75lh 0", "townsfolk");
            CustomScript.compressCharacterSections("-0.5lh 0", "demon");
            CustomScript.compressCharacterSections("-0.1lh 0", "first-night", "other-night");

            CustomScript.transformCharacterEntries("translateY(1lh)", "puzzledrunk_trustmebro", "sage", "pandemon_trustmebro");

            CustomScript.removeVerticalGap("jinxes-container .jinxes");

            // moving the fabled-and-loric-container squeezes the last bullet point of the bootlegger
            let fabledAndLoricHeading = document.querySelector(`.fabled-and-loric-heading`);
            fabledAndLoricHeading.style.margin = "-1lh 0 -0.5lh";

            let fabledLoricPageBreakEntry = document.getElementById("ferryman");
            fabledLoricPageBreakEntry.style.marginTop = "0.6lh";

            CustomScript.enableSeamlessNightSheets();

            CustomScript.transformBootleggerBulletPoints(`scale(0.5)`);

            let firstNightList = document.querySelector(`page-three .first-night`);
            firstNightList.style.lineHeight = "1";
            let otherNightList = document.querySelector(`page-four .other-night`);
            otherNightList.style.lineHeight = "1";

            if (!homebrewJinxesInput.value.trim())
                homebrewJinxesInput.value =
`- Leviathan / Sage
Sage / Leviathan / Diabolus: Each night*, the Demon chooses an alive good player (different to previous nights): a chosen Sage uses their ability but does not die.
Alsaahir: The Alsaahir's guess applies to the actual character type, else (if incorrect) to the misregistered one.
Alsaahir / Legionatic: With Legionatic in play, the Alsaahir may guess the Townsfolk & Outsider players instead.
Puzzledrunk / Legionatic: The Puzzledrunk is not a Legion(atic). When the sober+healthy Puzzledrunk guesses right, they learn a 3rd non-Legion player.
Puzzledrunk / Muezzin: If the Puzzledrunk is in play, the Muezzin allows for ≤ 2 drunk abilities in their team.
Master Hora / Leviathan: Master Hora shifts a night only when a good player is executed. Master Hora dies if chosen by Leviathan at night.
Spy / Poppy Snitch: If the Poppy Snitch has their ability, the Spy does not see the Grimoire.
Spy / Wicked: Both cannot be in play together.
Legionatic / Spy: If a Legionatic has a drunk spy ability, they see no Legionatic characters in the grimoire but the Legionatics' drunk abilities plus 1 demon.
Legionatic / Politician: The Politician might register as evil to the Legionatic.
Polymath / Lunatic: If the Polymath player is a Lunatic, the demon and Polymath don't know who is demon/Polymath.
Plotter / Lunatic: Outsiders/Minions who don't know their character type cannot be in play with the Plotter.
Plotter / Wicked: The Wicked affects the Plotter's winning condition even if the Story Teller is executed.
Trustmaker / Soulmate: A soulmate vessel nevertheless triggers the Trustmaker reward on death.`;

            let playerCountTableContainer = document.getElementById(`player-count-table`);
            playerCountTableContainer.style.width = "100%";
            playerCountTableContainer.innerHTML = `
  <table rules="cols">
    <tbody><tr>
      <th data-tl-key="keyword.player" data-tl-plural="">Players</th>
      <td>5</td><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td><td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td><td>18</td><td>19</td><td>20+</td>
    </tr>
    <tr>
      <th data-tl-key="keyword.townsfolk" data-tl-plural="">Townsfolk</th>
      <td>2</td><td>3</td><td>4</td><td>5</td><td>5</td><td>5</td><td>6</td><td>7</td><td>7</td><td>8</td><td>9</td><td>9</td><td>10</td><td>11</td><td>12</td><td>13</td>
    </tr>
    <tr>
      <th data-tl-key="keyword.outsider" data-tl-plural="">Outsiders</th>
      <td>1</td><td>1</td><td>1</td><td>1</td><td>2</td><td>2</td><td>2</td><td>2</td><td>2</td><td>3</td><td>3</td><td>3</td><td>3</td><td>4</td><td>4</td><td>4</td>
    </tr>
    <tr>
      <th data-tl-key="keyword.minion" data-tl-plural="">Minions</th>
      <td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>2</td><td>2</td><td>2</td><td>3</td><td>3</td><td>3</td><td>4</td><td>4</td><td>2</td><td>2</td><td>3</td>
    </tr>
    <tr>
      <th data-tl-key="keyword.demon" data-tl-plural="">Demons</th>
      <td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>1</td><td>2</td><td>2</td><td>2</td>
    </tr>
  </tbody></table>
`;
            return () => {
                CustomScript.transformFabledLoricJinxIcons(`translateY(-1lh)`);

                let page2 = document.querySelector(`page-two`);
                if (page2) page2.style.breakBefore = "avoid";
            }
        }
    }

    var homebrewJinxesInput = document.getElementById(`homebrew-jinxes-input`);

    function clearJinxes()
    {
        homebrewJinxesInput.value = "";
    }

    function repairAlmanacLinks(almanacNames, charactersJsons)
    {
        for (let characterEntry of getCharacterEntries(almanacNames))
        {
            let characterName = characterEntry.querySelector(`.character-name`);

            let link = characterName.getElementsByTagName("a")[0];
            if (link != undefined)
            {
                link.href = Almanac.replaceAlmanacBaseUrl(link.href, charactersJsons);
                return;
            }

            replaceCharacterNameSpanWithLink(characterName, characterEntry.getAttribute("id"))
        }
    }

    function loadHomebrewNamesIconsInCharacterPalette(charactersJsons)
    {
        for (let paletteEntry of document.querySelectorAll(`#all-characters .item[data-id*="_"]`))
        {
            let characterName = paletteEntry.querySelector(`.character-name`);
            if (characterName == null || characterName.textContent !== "_")
                continue;

            let characterId = paletteEntry.getAttribute("data-id");
            let characterObject = charactersJsons.characterMap.get(characterId);
            if (!characterObject) console.error(`character with id "${characterId}" does not exist. Maybe a typo? Or maybe you need to add a digit 1?`);

            characterName.textContent = characterObject.name;

            let icon = paletteEntry.querySelector(`img.icon`);
            icon.src = characterObject.image;
        }
    }

    function isDefaultImageUrl(srcUrl)
    {
        return Boolean(srcUrl.match(/\/(Generic_|generic\/)[a-z]+\.\w+$/i));
    }

    // stolen from official code
    function parseNightReminderText(text)
    {
        text += '\n';
        text = text.replaceAll(/\*(.*?)\*/g, (e, text) => `<b>${ text }</b>`);
        text = text.replaceAll(/\n(\t[\s\S]*?)\n([^\t]|$)/g, (e, t, a) => `<ul>${ t }
</ul>
        ${ a }`);
        text = text.replaceAll(/\t(.*?)\n/g, (e, t) => `<li>${ t }</li>`);
        text = text.replaceAll(':reminder:', '<i class="fa-solid fa-circle" style="font-size: 0.76em; color: #666;"></i>');
        return text;
    }

    function getFallbackImageUrl(teamName)
    {
        return `/src/assets/icons/generic/${teamName}.webp`;  //formerly Generic_${teamname}.webp
    }

    function createNightSheetEntry(characterObject, reminderKey, characterUrl)
    {
        let nightSheetEntry = document.createElement("div");
        nightSheetEntry.innerHTML =
`<img class=" handle" src="${characterObject.image ?? getFallbackImageUrl(characterObject.team)}" draggable="false">
<div>
    <div class="night-sheet-char-name" data-type="${characterObject.team}">
        <a title="Read the almanac/wiki entry" href="${characterUrl}" target="_blank" draggable="false">${characterObject.name}</a>
    </div>
    <div class="night-sheet-reminder handle">
    ${parseNightReminderText(characterObject[reminderKey])}
    </div>
</div>`;
        nightSheetEntry.classList.add("item");
        nightSheetEntry.setAttribute("draggable", "false");
        return nightSheetEntry;
    }

    function getNightSheetEntry(nightSheetContainer, characterId)
    {
        return nightSheetContainer.querySelector(`.item:has(.night-sheet-char-name a[href$="${characterId}"], img[src$="/${characterId}.webp"])`);
    }

    /** Note: at least 2 divs match the selector. */
    function* getNightSheetEntries(selector, characterId)
    {
        if (characterId.startsWith("dusk_")) characterId = "dusk";
        else if (characterId.startsWith("dawn_")) characterId = "dawn";
        
        for (let nightSheetEntry of document.querySelectorAll(`${selector} .item:has(.night-sheet-char-name a[href$="${characterId}"], img[src$="/${characterId}.webp"])`))
        {
            yield nightSheetEntry;
        }
    }

    function getPrecedentNightSheetEntry(nightSheetContainer, entriesList, nightIndex)
    {
        let precedentEntry = null;
        for (let i = nightIndex; --i >= 0;)
        {
            let precedentCharacter = entriesList[i];
            let characterId = precedentCharacter.id.replace(/(?<=^(dusk|dawn|minioninfo|demoninfo))_.*$/, "");

            precedentEntry = getNightSheetEntry(nightSheetContainer, characterId);
            if (precedentEntry !== null)
            {
                break;
            }
        }

        return precedentEntry;
    }

    function addHomebrewTravellerInNightSheets(nightSheetContainer, characterObject, reminderKey, charactersJsons)
    {
        if (characterObject.team !== "traveller")
            return;

        let almanacName = characterObject.id.replace(/^.*_/, "");

        let almanac = charactersJsons[almanacName];
        let [entriesList, nightIndex, firstIndex] = (reminderKey === "firstNightReminder"
                                         ? [almanac.firstNightList, characterObject.firstNight, almanac.firstNightList[0].firstNight]
                                         : [almanac.otherNightList, characterObject.otherNight, almanac.otherNightList[0].otherNight]
                                        );

        if (nightIndex === undefined)
            return;

        let precedentEntry = getPrecedentNightSheetEntry(nightSheetContainer, entriesList, nightIndex - firstIndex);
        let characterUrl = `${Almanac.getAlmanacLink(almanac)}#${characterObject.id}`;
        let nightSheetEntry = createNightSheetEntry(characterObject, reminderKey, characterUrl);

        if (precedentEntry !== null)
            precedentEntry.after(nightSheetEntry);
        else
            nightSheetContainer.insertBefore(nightSheetEntry, nightSheetContainer.children[0]);
    }

    function replaceCharacterNameSpanWithLink(characterNameContainer, characterId)
    {
        let characterNameContent = characterNameContainer.getElementsByTagName("span")[0];
        let link = document.createElement("a");
        link.title = "Read the almanac/wiki entry";
        link.target = "_blank";
        link.textContent = characterNameContent.textContent;
        link.href = Almanac.replaceAlmanacBaseUrl(`#${characterId}`, charactersJsons)
        characterNameContainer.replaceChild(link, characterNameContent);
    }

    function fillInNightSheets(characterObject, charactersJsons)
    {
        let sectionSelector = [["firstNightReminder", ".first-night"], ["otherNightReminder", ".other-night"]];

        for (let [key, selector] of sectionSelector)
        {
            for (let nightEntry of getNightSheetEntries(selector, characterObject.id)) {

                if (!(key in characterObject))
                {
                    nightEntry.parentElement.removeChild(nightEntry);  // remove entries without reminder
                    continue;
                }

                let nightImage = nightEntry.querySelector(`img`);
                if (isDefaultImageUrl(nightImage.src))
                    nightImage.src = characterObject.image;

                let nightName = nightEntry.querySelector(`.night-sheet-char-name a`);
                if (nightName.textContent.trim() === '_')
                    nightName.textContent = characterObject.name;

                let nightReminder = nightEntry.querySelector(`.night-sheet-reminder`);
                if (nightReminder.textContent.trim() === '_')
                {
                    nightReminder.innerHTML = parseNightReminderText(characterObject[key]);
                }
            }
        }
    }

    function fillTravellersInNightSheets(characterObject, charactersJsons)
    {
        let sectionSelector = [["firstNightReminder", ".first-night"], ["otherNightReminder", ".other-night"]];

        for (let [key, selector] of sectionSelector)
        {
            for (let nightSheetContainer of document.querySelectorAll(selector))
            {
                addHomebrewTravellerInNightSheets(nightSheetContainer, characterObject, key, charactersJsons);
            }
        }
    }

    function* getCharacterObjectsFromNightSheet(almanacNames, charactersJsons)
    {
        for (let characterEntry of getCharacterEntries(almanacNames))
        {
            let characterId = characterEntry.getAttribute("id");
            let characterObject = charactersJsons.characterMap.get(characterId);

            if (!characterObject)
            {
                console.error(`character id "${characterId}" not found in json.characterMap.`);
                continue;
            }

            yield [characterObject, characterEntry];
        }
    }

    function showWarningIfNightSheetCannotBeFilled()
    {
        let unfilledNightSheetEntries = Array.from(document.querySelectorAll(`.night-sheet-char-name span`)).filter(entry => entry.textContent === "_");
        if (unfilledNightSheetEntries.length <= 0)
        {
            let warningMessage = document.getElementById("auto-resolve-failure-warning");
            if (warningMessage != null)
                warningMessage.parentElement.removeChild(warningMessage);
            return;
        }

        let firstNightSheet = document.querySelector(`page-three .first-night-container`);
        let warningMessage = document.createElement("div");
        warningMessage.classList.add("noprint");
        warningMessage.setAttribute("id", "auto-resolve-failure-warning");
        warningMessage.style.color = "#800000";
        warningMessage.textContent = `Unfortunately, Homebrew character reminders cannot be automatically resolved without Almanac link. Please upload a JSON with "almanac" property (an URL) in the meta data.`

        firstNightSheet.insertBefore(warningMessage, firstNightSheet.children[0]);
    }

    function fillInAbilityAndNightSheet(almanacNames, charactersJsons)
    {
        for (let [characterObject, characterEntry] of getCharacterObjectsFromNightSheet(almanacNames, charactersJsons))
        {
            // image
            let image = characterEntry.querySelector(`.icon-container img`);
            if (isDefaultImageUrl(image.src))
                image.src = characterObject.image;

            let name = characterEntry.querySelector(`.name-and-summary a`);
            if (name.textContent.trim() === '_')
                name.textContent = characterObject.name;

            let ability = characterEntry.querySelector(`.name-and-summary .character-summary`);
            if (ability.textContent.trim() === '_')
            {
                ability.textContent = characterObject.ability;
                ability.innerHTML = ability.innerHTML.replace(/(\[[^\]]+\])\s*$/, `<span class="setup-text">$1</span>`);
            }

            fillInNightSheets(characterObject, charactersJsons);
        }

        for (let [characterObject, ] of getCharacterObjectsFromNightSheet(almanacNames, charactersJsons))
        {
            fillTravellersInNightSheets(characterObject, charactersJsons);
        }

        showWarningIfNightSheetCannotBeFilled();
    }

    function expandVariablesInBootlegger(charactersJsons)
    {
        for (let ruleText of document.querySelectorAll(`.bootlegger-rules .item .rule`)) {

            let textFragments = [];
            let text = ruleText.textContent;
            let lastMatchEnd = 0;

            for (let match of text.matchAll(/\$([\w\d]+)\b/g))
            {
                let characterId = match[1];
                let characterObject = charactersJsons.characterMap.get(characterId);
                if (!characterObject) continue;

                textFragments.push(text.slice(lastMatchEnd, match.index));
                lastMatchEnd = match.index + match[0].length;
                textFragments.push(characterObject.ability);
            }

            if (textFragments.length > 0)
                ruleText.textContent = textFragments.join("");
        }
    }

    var jsonUrls = new Set();

    function addFallbackImageUrls(charactersJsons)
    {
        for (let [name, almanac] of Object.entries(charactersJsons))
        {
            for (let characterObject of almanac.json)
            {
                if (characterObject.id.startsWith("_")) continue;

                characterObject.image ??= getFallbackImageUrl(characterObject.team);
            }
        }
    }

    function addSortedCharacterLists(charactersJsons)
    {
        for (let almanacName of Object.keys(charactersJsons))
        {
            let almanac = charactersJsons[almanacName];

            almanac.firstNightList = almanac.json.filter(entry => entry.firstNight > 0);
            almanac.firstNightList.sort((c1, c2) => c1.firstNight - c2.firstNight);
            almanac.otherNightList = almanac.json.filter(entry => entry.otherNight > 0);
            almanac.otherNightList.sort((c1, c2) => c1.otherNight - c2.otherNight);
        }
    }

    function addNameIndexCharacterMap(charactersJsons)
    {
        let characterMap = new Map();

        for (let almanacName of Object.keys(charactersJsons))
        {
            let characterList = charactersJsons[almanacName].json;

            for (let characterObject of characterList)
            {
                if (characterObject.id !== undefined)
                    characterMap.set(characterObject.id, characterObject);
            }
        }

        charactersJsons.characterMap = characterMap;
    }

    async function updateCharactersJSONs()
    {
        let charactersJsons = localStorage.getItem("charactersJsons");

        try {
            charactersJsons = JSON.parse(charactersJsons) ?? {};
        } catch (error) {
            charactersJsons = {};
        }

        let jsonPromises = [];

        let oldJsonUrls = new Set(Object.values(charactersJsons).map(almanac => almanac.jsonUrl));

        jsonUrls = new Set(jsonUrlInput.value.split("\n").map(line => line.trim()).filter(line => Boolean(line)));
        let loadedJsonUrls = new Set(jsonUrls);
        for (let urlText of loadedJsonUrls) {

            if (!urlText.startsWith("http"))
                urlText = "https://" + urlText;

            if (oldJsonUrls.has(urlText))  // if equal hash, no changes
            {
                console.log(`data found, skip loading ${urlText}`);
                oldJsonUrls.delete(urlText);
                loadedJsonUrls.delete(urlText);
                continue;
            }

            try {
                console.log(`fetch ${urlText}`);
                jsonPromises.push((await fetch(urlText)).json());
            } catch(error)
            {
                console.error(`could not load ${urlText}`, error);
                loadedJsonUrls.delete(urlText);
            }
        }

        oldJsonUrls.forEach(oldKey => { delete charactersJsons[Almanac.getAlmanacName(oldKey)]; });

        let newJsons = (await Promise.all(jsonPromises));
        for (let [index, jsonUrl] of Array.from(loadedJsonUrls).entries()) {

            let almanacObject = Almanac.create(jsonUrl, newJsons[index]);
            console.log(almanacObject);

            charactersJsons[almanacObject.name] = almanacObject;
        }

        localStorage.setItem("charactersJsons", JSON.stringify(charactersJsons));

        addFallbackImageUrls(charactersJsons);
        addSortedCharacterLists(charactersJsons);
        addNameIndexCharacterMap(charactersJsons);

        return charactersJsons;
    }

    var [charactersJsons, ] = await Promise.all([updateCharactersJSONs(), new Promise((resolve, reject) => { setTimeout(() => resolve(true), 1500); })]);

    function applyJsonData(charactersJsons) {
        let almanacNames = Object.keys(charactersJsons)
        repairAlmanacLinks(almanacNames, charactersJsons);

        fillInAbilityAndNightSheet(almanacNames, charactersJsons);
        expandVariablesInBootlegger(charactersJsons);
    }
    applyJsonData(charactersJsons);
    loadHomebrewNamesIconsInCharacterPalette(charactersJsons);

    let updateAlmanacJsonsButton = document.getElementById("update-almanac-jsons-button");

    function rerenderJsonData() {
        updateCharactersJSONs().then(result => {
            charactersJsons = result;
            applyJsonData(charactersJsons);
        });
    }

    updateAlmanacJsonsButton.addEventListener("click", (event) => {
        event.preventDefault();
        rerenderJsonData();
    });

    let changeDetector = new MutationObserver((mutationList, observer) => {
        observer.takeRecords();
        clearJinxes();
        rerenderJsonData();
    });
    changeDetector.observe(document.getElementById("script"), { childList: true });

    let sidebarChangeDetector = new MutationObserver((mutationList, observer) => {
        observer.takeRecords();
        loadHomebrewNamesIconsInCharacterPalette(charactersJsons);
    });
    sidebarChangeDetector.observe(document.querySelector(`#all-characters`), { childList: true })

    function turnTitleIntoLink()
    {
        let scriptNameElement = document.querySelector("#title .script-name");
        let link = document.createElement("a");
        link.textContent = scriptNameElement.textContent;
        link.href = Almanac.toHtmlUrl(jsonUrlInput.value.split("\n", 1)[0]);
        scriptNameElement.replaceChildren(link);
    }

    function formatPageForPrint()
    {
        let scriptName = document.getElementById("script-name-input").value;
        let finalize = CustomScript.format(scriptName);

        turnTitleIntoLink();

        // widen the Jinxes and Bootlegger lists

        let jinxesContainer = document.querySelector(".jinxes-container");
        jinxesContainer.style.width = "100%";
        jinxesContainer.style.justifyContent = "start";
        addHomebrewJinxes(jinxesContainer);

        let playerCountTable = document.querySelector("page-two #player-count-table");
        if (!playerCountTable)
            return;

        Array.from(playerCountTable.getElementsByTagName("th")).forEach(th => { th.style.paddingRight = ".5em"; th.setAttribute("contenteditable", true); });
        Array.from(playerCountTable.getElementsByTagName("td")).forEach(td => { td.style.minWidth = "1.2em"; td.setAttribute("contenteditable", true); });

        if (!finalize)
            return;

        playerCountTable.getElementsByTagName("table")[0].setAttribute("rules", "cols");

        let travellersSection = document.querySelector("div.recommended-travellers-container");
        //let playerCountTable2 = document.querySelector("body > #player-count-table");
        let pageTwo = (document.querySelector("page-two .page-two-main-content"));

        pageTwo?.insertBefore(travellersSection, pageTwo.children[0]);
        pageTwo?.insertBefore(document.createElement("div"), travellersSection);

        let pageTwoContainer = document.querySelector(`.page-two-main-content`);
        pageTwoContainer.classList.remove("page-two-main-content");

        let fabledAndLoricHeading = document.querySelector(`.fabled-and-loric-heading`);
        fabledAndLoricHeading.classList.remove("fabled-and-loric-heading");

        let table = playerCountTable.querySelector("table");
        table.style.display = "block";
        table.style.width = "100%";
        table.style.lineHeight = "1";

        let bootleggerContainer = document.querySelector(".bootlegger-rules-container");
        bootleggerContainer.style.width = "100%";
        bootleggerContainer.style.justifyContent = "start";

        // page breaks
        let page2 = (document.querySelector("page-two"));
        //if (page2) page2.style.breakBefore = "avoid";
        if (page2) page2.style.marginBottom = "0";
        let page3 = (document.querySelector("page-three"));
        //if (page3) page3.style.breakBefore = "avoid";
        let page4 = (document.querySelector("page-four"));
        if (page4) page4.style.breakBefore = "avoid";
        if (page4) page4.style.breakAfter = "avoid";

        finalize?.();
    }

    let printButton = document.querySelector("#print-form button");
    printButton.addEventListener("click", formatPageForPrint);

    function addHomebrewJinxes(jinxesContainer)
    {
        let tableString = document.getElementById(`homebrew-jinxes-input`)?.value;
        let entries = tableString.split("\n").filter(s => Boolean(s.trim())).map(createJinxEntry).filter(entry => entry !== null);

        let jinxesEntryContainer = jinxesContainer.querySelector(`.jinxes`);

        entries.forEach(([characterNames, entry, id]) => {
            if (entry === null)
            {
                removeFromJinxContainer(jinxesEntryContainer, id);
                return;
            }

            let lastEntryOfCharacter = jinxesEntryContainer.querySelector(`:nth-last-child(n of .item:has(.icons img[id^="${characterNames[0]}"]:first-child))`);

            if (lastEntryOfCharacter !== null) lastEntryOfCharacter.after(entry);
            else jinxesEntryContainer.append(entry);

            jinxesEntryContainer.setAttribute("nitems", Number(jinxesEntryContainer.getAttribute("nitems")) + 1);
        });
    }

    function removeFromJinxContainer(jinxesEntryContainer, id)
    {
        let removedEntry = jinxesEntryContainer.querySelector(`#${id}.item`);

        try{

        if (removedEntry.classList.contains("homebrew-jinx"))
            removedEntry.parentElement.removeChild(removedEntry);
        else
            removedEntry.style.display = "none";
        jinxesEntryContainer.setAttribute("nitems", Number(jinxesEntryContainer.getAttribute("nitems")) - 1);

        } catch(e)
        {
            console.error(`character id "${id}" of jinx not found`);
        }
    }

    class JinxEntry
    {
        constructor (jinxRowString)
        {
            [this.charactersString, ...this.descriptionString] = jinxRowString.split(":");
            this.descriptionString = this.descriptionString.join(":");

            this.isRemovingJinxes = this.charactersString.startsWith("-");
            if (this.isRemovingJinxes)
                this.charactersString = this.charactersString.substring(1);

            this.characterNames = this.charactersString.split("/").map(n => n.trim().replace(/\s+/g, "").toLowerCase());
            this.imageUrls = this.characterNames.map((name) => [name, getImageUrlFromCharacter(name)]);

            this.id = `${this.characterNames.join("-")}-jinx`;
        }
    }

    function createJinxEntry(jinxRowString)
    {
        let {id, isRemovingJinxes, imageUrls, characterNames, descriptionString} = new JinxEntry(jinxRowString);

        if (isRemovingJinxes)
        {
            removeSmallIconsFromRoster(imageUrls);
            return [characterNames, null, id];
        }

        if (document.getElementById(id) !== null)
            return null;

        addSmallIconsToRoster(id, imageUrls, descriptionString);

        let jinxEntry = document.createElement("div");
        jinxEntry.setAttribute("id", id);
        jinxEntry.classList.add("item", "homebrew-jinx");
        jinxEntry.innerHTML = `
            <div class="icons">
            ${imageUrls.map(([name, url]) => `
                <img id="${name}-icon-jinxes" class="icon " src="${url}">
            `).join("")}
            </div>
            <div class="jinx-text" style="max-width: none">${descriptionString}</div>
        `;

        return [characterNames, jinxEntry, id];
    }

    function addSmallIconsToRoster(id, nameUrlPairs, descriptionString)
    {
        if (nameUrlPairs.length < 2) return;

        let sourceName = nameUrlPairs[0][0];
        let characterEntry = document.querySelector(`div[id^="${sourceName}"].item`);
        let characterNameElement = characterEntry.querySelector(`.character-name`);
        let characterJinxesTray = characterNameElement.querySelector(`.character-jinxes`);

        if (characterJinxesTray === null)
        {
            characterJinxesTray = document.createElement("div");
            characterJinxesTray.classList.add("character-jinxes");
            characterNameElement.append(characterJinxesTray);
        }

        for (let i=1; i<nameUrlPairs.length; i++)
        {
            let icon = document.createElement("img");
            icon.title = descriptionString;
            icon.classList.add("jinx-icon", "homebrew-jinx-icon");
            icon.onclick = () => { document.getElementById(`${id}`).scrollIntoView({ behavior: 'smooth', block: 'center' }) };
            icon.src = nameUrlPairs[i][1];

            characterJinxesTray.append(icon);
        }
    }

    function removeSmallIconsFromRoster(imageUrls)
    {
        if (imageUrls.length < 2) return;

        let characterEntryName = imageUrls[0][0];
        for (let [name, src] of imageUrls.slice(1))
        {
            src = src.match(/(?<=\/)src\/.+$/);
            for (let jinxIcon of document.querySelectorAll(`:is(#${characterEntryName},[id^="${characterEntryName}_"]).item .character-jinxes .jinx-icon:nth-child(1 of [src$="${src}"])`))
            {
                if (jinxIcon.classList.contains("homebrew-jinx-icon"))
                    jinxIcon.parentElement.removeChild(jinxIcon);
                else
                    jinxIcon.style.display = "none";
            }
        }
    }

    function removeHomebrewJinxes()
    {
        for (let homebrewJinxEntry of document.querySelectorAll(`.item.homebrew-jinx`)) {

            let container = homebrewJinxEntry.parentElement;
            container.removeChild(homebrewJinxEntry);
            container.setAttribute("nitems", Number(container.getAttribute("nitems")) - 1);
        }

        for (let officialJinxEntry of document.querySelectorAll(`.jinxes-container .jinxes > .item:not(.homebrew-jinx)`))
        {
            officialJinxEntry.style.display = "block";
        }
    }

    function removeAllSmallIconsFromRoster()
    {
        for (let homebrewJinxIcon of document.querySelectorAll(`.homebrew-jinx-icon`)) {
            let container = homebrewJinxIcon.parentElement;
            container.removeChild(homebrewJinxIcon);
        }

        for (let officialJinxIcon of document.querySelectorAll(`.jinx-icon:not(.homebrew-jinx-icon)`)) {
            officialJinxIcon.style.display = "block";
        }
    }

    let updateHomebrewJinxesButton = document.getElementById("update-homebrew-jinxes-button");
    updateHomebrewJinxesButton.addEventListener("click", (event) => {
        event.preventDefault();
        removeHomebrewJinxes();
        removeAllSmallIconsFromRoster();
        addHomebrewJinxes(document.querySelector(".jinxes-container"));
    });

    let clearHomebrewJinxesButton = document.getElementById("clear-hombrew-jinxes-button");
    clearHomebrewJinxesButton.addEventListener("click", (event) => {
        event.preventDefault();
        clearJinxes();
    });

    function getImageUrlFromCharacter(characterString)
    {
        try {
        return document.querySelector(`img[id^="${characterString}"][id$="-icon-script"]`).src;
        } catch(e)
        {
            console.error(e, characterString);
        }
    }
})();