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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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