// Firebase Configuration // REPLACE WITH YOUR FIREBASE CONFIG const firebaseConfig = { apiKey: "AIzaSyAVNd1Tmmn6rl3OwAk56ZIfoi5UmqMIHVk", authDomain: "eberron-quest-board.firebaseapp.com", projectId: "eberron-quest-board", storageBucket: "eberron-quest-board.firebasestorage.app", messagingSenderId: "769889059206", appId: "1:769889059206:web:869a4b81867fb576902c07" }; // Initialize Firebase firebase.initializeApp(firebaseConfig); const db = firebase.firestore(); const auth = firebase.auth(); // Game Constants const FACTIONS = { "Ashbound": { xp: 1.5, gold: 0.5, "rep+": 1.5, "rep-": -1 }, "Deneith": { xp: 1.25, gold: 0.5, "rep+": 0.5, "rep-": -0.5 }, "Kundarak": { xp: 0.5, gold: 3.0, "rep+": 1, "rep-": -1 }, "Medani": { xp: 1.0, gold: 1.5, "rep+": 1.0, "rep-": -0.5 }, "Morgrave": { xp: 1.0, gold: 0.5, "rep+": 0.5, "rep-": 0 }, "Tarkanan": { xp: 1.0, gold: 2.25, "rep+": 1.0, "rep-": -2 }, "Tharashk": { xp: 1.0, gold: 1.0, "rep+": 1.0, "rep-": -0.5 }, "Thuranni": { xp: 1.0, gold: 1.25, "rep+": 0.5, "rep-": -2 } }; // Magic item attunement lookup (falls back to data file when market items omit it) const MAGIC_ITEM_ATTUNEMENT_MAP = typeof MAGIC_ITEMS_DATA !== 'undefined' ? new Map(MAGIC_ITEMS_DATA.map(item => [item.name, item.attunementRequired])) : new Map(); const DNDBEYOND_MAGIC_ITEM_BASE_URL = "https://dndbeyond.com/magic-items/"; const MAGIC_ITEM_PAGE_MAP = typeof MAGIC_ITEMS_DATA !== 'undefined' ? new Map(MAGIC_ITEMS_DATA.map(item => [item.name, item.dndbeyondPage]).filter(([, page]) => page)) : new Map(); const OPPOSITIONS = { "Kundarak": ["Deneith", "Tharashk"], "Medani": ["Tharashk", "Thuranni"], "Deneith": ["Thuranni", "Kundarak"], "Tharashk": ["Kundarak", "Medani"], "Thuranni": ["Medani", "Deneith"], "Tarkanan": ["Kundarak", "Medani", "Deneith", "Tharashk", "Thuranni"], "Ashbound": ["Kundarak", "Medani", "Deneith", "Tharashk", "Thuranni", "Tarkanan"], "Morgrave": [] }; // Ordered quest lists per faction (taken sequentially) // Each quest has: description, duration (weeks), and availability (weeks until expires) const FACTION_QUESTS = { "Ashbound": [ { description: "Make contact with a village that has gone silent.", duration: 1, availability: 4, location: "Eldeen Reaches" }, { description: "Sabotage a House Cannith creation forge discovered near tribal lands.", duration: 3, availability: 2, location: "Shadow Marches" }, { description: "Rescue captive druids from a warforged settlement.", duration: 2, availability: 2, location: "Mournland" }, { description: "Destroy arcane mining equipment threatening a sacred glade.", duration: 1, availability: 3, location: "Eldeen Reaches" }, { description: "Drive off settlers encroaching on wolf pack territory.", duration: 1, availability: 4, location: "Eldeen Reaches" }, { description: "Recover stolen seed vaults from Aundairian researchers.", duration: 2, availability: 3, location: "Aundair" }, { description: "Purge undead corruption from a forest shrine.", duration: 2, availability: 4, location: "Karrnath" }, { description: "Prevent House Vadalis from capturing rare primal beasts.", duration: 1, availability: 2, location: "Eldeen Reaches" }, { description: "Seal a manifest zone attracting aberrations.", duration: 3, availability: 3, location: "Shadow Marches" }, { description: "Eliminate a cabal of mages experimenting on captured elementals.", duration: 2, availability: 2, location: "Breland" } ], "Deneith": [ { description: "Extract a missing caravan guard from hostile territory.", duration: 1, availability: 2, location: "Icehorn Mountains" }, { description: "Provide security detail for a diplomatic envoy through dangerous lands.", duration: 3, availability: 3, location: "Darguun" }, { description: "Recover stolen Sentinel Marshal badge and apprehend the thief.", duration: 1, availability: 2, location: "Sharn" }, { description: "Investigate attacks on Deneith supply convoys.", duration: 2, availability: 3, location: "Talenta Plains" }, { description: "Train local militia to defend against gnoll raiders.", duration: 3, availability: 5, location: "Droaam Border" }, { description: "Rescue hostages taken by Valenar war party.", duration: 2, availability: 1, location: "Valenar" }, { description: "Test new recruits in simulated combat scenarios.", duration: 1, availability: 5, location: "Karrnath" }, { description: "Guard a high-value prisoner transfer between nations.", duration: 2, availability: 2, location: "Lightning Rail" }, { description: "Neutralize deserters selling Deneith military secrets.", duration: 2, availability: 2, location: "Lhazaar Principalities" }, { description: "Defend a frontier outpost expecting imminent siege.", duration: 3, availability: 2, location: "Droaam" } ], "Kundarak": [ { description: "Test new security measures at an urban vault.", duration: 1, availability: 4, location: "Sharn" }, { description: "Retrieve assets from a compromised safety deposit box.", duration: 1, availability: 3, location: "Korranberg" }, { description: "Investigate a break-in at a provincial branch.", duration: 2, availability: 3, location: "Wroat" }, { description: "Transport high-value cargo through bandit country.", duration: 3, availability: 2, location: "Breland" }, { description: "Recover stolen vault blueprints from industrial spies.", duration: 2, availability: 2, location: "Sharn" }, { description: "Assess security vulnerabilities at a client's estate.", duration: 1, availability: 4, location: "Aundair" }, { description: "Trace counterfeit Kundarak letters of credit to their source.", duration: 2, availability: 3, location: "Zilargo" }, { description: "Defend a gold shipment traveling to the Mror Holds.", duration: 3, availability: 3, location: "Mror Holds" }, { description: "Extract a witness to vault tampering before silencers arrive.", duration: 1, availability: 1, location: "Sharn" }, { description: "Consult on fortress defenses for a paranoid noble.", duration: 2, availability: 5, location: "Karrnath" } ], "Medani": [ { description: "Investigate reports of odd patron behavior at a House Ghallanda inn.", duration: 1, availability: 3, location: "Karrnath" }, { description: "Divine the location of a kidnapped merchant's daughter.", duration: 1, availability: 2, location: "Sharn" }, { description: "Analyze mysterious arcane residue at a crime scene.", duration: 1, availability: 3, location: "Wroat" }, { description: "Provide divination support for a noble's political negotiations.", duration: 2, availability: 4, location: "Fairhaven" }, { description: "Track a shapeshifter operating in the merchant district.", duration: 2, availability: 2, location: "Sharn" }, { description: "Investigate precognitive warnings about a coming disaster.", duration: 2, availability: 3, location: "Thrane" }, { description: "Expose a spy ring infiltrating House operations.", duration: 3, availability: 2, location: "Aundair" }, { description: "Verify the authenticity of a supposedly magical artifact.", duration: 1, availability: 4, location: "Korranberg" }, { description: "Determine if a business partner's sudden success is legitimate.", duration: 2, availability: 4, location: "Stormreach" }, { description: "Guard a seer receiving death threats over their predictions.", duration: 2, availability: 2, location: "Sharn" } ], "Morgrave": [ { description: "Explore a ruins to establish as a staging area for future expeditions.", duration: 1, availability: 5, location: "Xen'drik" }, { description: "Recover a specific tome from a rival collector's estate.", duration: 2, availability: 3, location: "Aundair" }, { description: "Document giant artifacts in an unexplored jungle valley.", duration: 3, availability: 4, location: "Xen'drik" }, { description: "Retrieve samples from the Mournland for research.", duration: 2, availability: 3, location: "Mournland" }, { description: "Investigate claims of Dhakaani ruins beneath a modern city.", duration: 2, availability: 4, location: "Sharn" }, { description: "Acquire a rare specimen from the Talenta Plains.", duration: 3, availability: 4, location: "Talenta Plains" }, { description: "Translate ancient tablets discovered in a Mror Hold mine.", duration: 1, availability: 5, location: "Mror Holds" }, { description: "Survey a newly discovered island for archaeological significance.", duration: 3, availability: 5, location: "Thunder Sea" }, { description: "Rescue a professor trapped in collapsed catacombs.", duration: 1, availability: 1, location: "Sharn" }, { description: "Outbid rivals for a dragonmarked family's private library access.", duration: 2, availability: 4, location: "Wroat" } ], "Tarkanan": [ { description: "Accompany a ship to clear and secure a new safehouse.", duration: 1, availability: 3, location: "Lhazaar Principalities" }, { description: "Eliminate a Sentinel Marshal closing in on house operations.", duration: 1, availability: 2, location: "Sharn" }, { description: "Steal prototype warforged components from a Cannith facility.", duration: 2, availability: 2, location: "Breland" }, { description: "Frame a rival criminal organization for Tarkanan crimes.", duration: 2, availability: 3, location: "Sharn" }, { description: "Extract an imprisoned aberrant heir before their execution.", duration: 1, availability: 1, location: "Thrane" }, { description: "Sabotage a dragonmarked house's business deal.", duration: 2, availability: 3, location: "Fairhaven" }, { description: "Acquire blackmail material on a corrupt city official.", duration: 1, availability: 4, location: "Sharn" }, { description: "Secure a smuggling route through Karrnathi undead patrols.", duration: 3, availability: 4, location: "Karrnath" }, { description: "Test a new member's loyalty through a dangerous assignment.", duration: 2, availability: 3, location: "Lower Sharn" }, { description: "Silence witnesses to a botched assassination.", duration: 1, availability: 2, location: "Wroat" } ], "Tharashk": [ { description: "Locate a missing Blood of Vol priest.", duration: 1, availability: 5, location: "Aundair" }, { description: "Track a fugitive noble fleeing gambling debts.", duration: 2, availability: 3, location: "Breland" }, { description: "Prospect a remote valley for Khyber dragonshard deposits.", duration: 3, availability: 4, location: "Shadow Marches" }, { description: "Apprehend a changeling con artist with multiple identities.", duration: 2, availability: 3, location: "Sharn" }, { description: "Find survivors of a caravan lost in the wastes.", duration: 3, availability: 2, location: "Demon Wastes" }, { description: "Investigate unauthorized mining on Tharashk claims.", duration: 2, availability: 3, location: "Droaam" }, { description: "Recover a stolen ancestral relic for a half-orc clan.", duration: 2, availability: 4, location: "Shadow Marches" }, { description: "Extract a prospecting team surrounded by territorial monsters.", duration: 2, availability: 2, location: "Q'barra" }, { description: "Track down a debtor hiding in the criminal underworld.", duration: 1, availability: 3, location: "Lower Sharn" }, { description: "Survey a new dragonshard field discovered by scouts.", duration: 3, availability: 5, location: "Mror Holds" } ], "Thuranni": [ { description: "Apprehend a rogue house member operating an unauthorized theater.", duration: 1, availability: 2, location: "Breland" }, { description: "Eliminate a target who has discovered Shadow Network operations.", duration: 1, availability: 2, location: "Karrnath" }, { description: "Establish a new entertainment venue as an espionage front.", duration: 3, availability: 4, location: "Aundair" }, { description: "Retrieve stolen correspondence from a Phiarlan agent.", duration: 2, availability: 2, location: "Sharn" }, { description: "Investigate rumors of a double agent within Thuranni ranks.", duration: 2, availability: 3, location: "Regalport" }, { description: "Stage a theatrical production while gathering political intelligence.", duration: 3, availability: 5, location: "Fairhaven" }, { description: "Silence an informant preparing to expose house secrets.", duration: 1, availability: 1, location: "Stormreach" }, { description: "Smuggle sensitive documents out of a fortified embassy.", duration: 2, availability: 2, location: "Flamekeep" }, { description: "Recruit a talented performer with useful connections.", duration: 1, availability: 4, location: "Sharn" }, { description: "Sabotage a rival house's counterintelligence operation.", duration: 2, availability: 3, location: "Trolanport" } ] }; // Faction reputation benefits at levels 3, 5, 7, and 9 const FACTION_BENEFITS = { "Ashbound": { 3: "You can hire Ashbound hirelings for half the usual cost.", 5: "You can request a Caretaker (Scout) to accompany you on a journey and help you navigate, forage, and search. Once you use this reward, you can't do so again until your Renown Score increases. Also, the first time your Renown Score reaches 5, a Caretaker teaches you Druidic.

The following items are always available for purchase, even if they aren't on the market: Armor of the White Rose (7000 gp); Gulthias Staff (2200 gp); Horn of Blasting (2500 gp); Rod of Hellish Flames (10250 gp); Staff of the Woodlands (4500 gp)", 7: "If you or your companions are at risk of suffering from dehydration or malnutrition while within 10 miles of an Ashbound site, Caretakers bring you enough food and water to meet your needs.", 9: "The first time your Renown Score reaches 9, Caretakers provide you with 1d4 Potions of Healing (superior). Also, your eyes turn bright green. Centaurs, treants, unicorns, and all Beasts are Friendly to you by default. This change lasts until your reputation with the Ashbound drops below 5." }, "Deneith": { 3: "You can request the assistance of 1d4 + 1 mercenaries for up to 1 day. The mercenaries are Medium Guards.

A dragonmarked member of the house casts for you a cantrip or level 1 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 3 or lower at half the usual cost.", 5: "When you take a Bastion turn and issue the Recruit order to a Barrack facility in your Bastion, you can recruit up to twelve Bastion defenders instead of four.

You and any allies of your choice can train in a House Deneith facility as if it were a Training Area facility within your Bastion (using the Empower order), with a trainer of your choice.

A dragonmarked member of the house casts for you a level 2 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 4 or lower at half the usual cost.

The following items are always available for purchase, even if they aren't on the market: Armor of Safeguarding (10000 gp); Magician's Judge (1125 gp); Mindguard Crown (11000 gp); Pariah's Shield (1500 gp); Ring of Temporal Salvation (2250 gp)", 7: "A dragonmarked member of the house casts for you a level 3 or 4 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 6 or lower at half the usual cost.

You gain a bodyguard for the duration of your next mission for the house. The bodyguard (a creature with a Challenge Rating no higher than half your level rounded up) is appropriate to your house.", 9: "A dragonmarked member of the house casts for you a level 5 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 8 or lower at half the usual cost." }, "Kundarak": { 3: "You can store something in a Kundarak vault for up to 1 month at no charge. The vault is an extradimensional space accessible (with the proper keys) from any Kundarak bank in Khorvaire and is generally considered impenetrable.

A dragonmarked member of the house casts for you a cantrip or level 1 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 3 or lower at half the usual cost.", 5: "When you take a Bastion turn, you can issue the Trade order to House Kundarak as if it were a Storehouse facility in your Bastion, using your level to determine the maximum value of items purchased and the profit you get from selling them.

A dragonmarked member of the house casts for you a level 2 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 4 or lower at half the usual cost.

The following items are always available for purchase, even if they aren't on the market: Cube of Force (5000 gp); Dagger of Denial (2750 gp); Living Armor (9500 gp); Rod of Alertness (11000 gp); Shield of the Cavalier (12500 gp); Tentacle Rod (2000 gp)", 7: "A dragonmarked member of the house casts for you a level 3 or 4 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 6 or lower at half the usual cost.

You gain a bodyguard for the duration of your next mission for the house. The bodyguard (a creature with a Challenge Rating no higher than half your level rounded up) is appropriate to your house.", 9: "A dragonmarked member of the house casts for you a level 5 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 8 or lower at half the usual cost." }, "Medani": { 3: "You can secure overnight lodging in a Medani safe house for yourself and your party. Secreted away under the house's protection, you become almost impossible to find by nonmagical means.

A dragonmarked member of the house casts for you a cantrip or level 1 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 3 or lower at half the usual cost.", 5: "When you take a Bastion turn, you can issue the Research order to House Medani as if it were a Library facility in your Bastion.

A dragonmarked member of the house casts for you a level 2 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 4 or lower at half the usual cost.

The following items are always available for purchase, even if they aren't on the market: Coat of the Crest (4000 gp); Gavel of the Venn Rune (2800 gp); Robe of Eyes (5000 gp); Watchful Helm (8500 gp); Weapon of Thrones Command (16000 gp)", 7: "When you take a Bastion turn, you can issue the Research order to the house as if it were a Pub facility.

A dragonmarked member of the house casts for you a level 3 or 4 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 6 or lower at half the usual cost.

You gain a bodyguard for the duration of your next mission for the house. The bodyguard (a creature with a Challenge Rating no higher than half your level rounded up) is appropriate to your house.", 9: "A dragonmarked member of the house casts for you a level 5 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 8 or lower at half the usual cost." }, "Morgrave": { 3: "You gain free access to Morgrave's extensive library. When you take a Bastion turn, you can issue the Research order to Morgrave as if it were a Library facility in your Bastion. Additionally, a Morgrave scholar will identify any one item, relic, or artifact for you at no cost.

Once per week, a Morgrave faculty member will use Legend Lore on your behalf at no cost (though results may take 1d4 days).", 5: "When you take a Bastion turn, you can issue the Research order to Morgrave as if it were both a Library and an Archive facility in your Bastion.

The first time your Renown Score reaches 5, you receive a Heward's Handy Haversack.

The following items are always available for purchase, even if they aren't on the market: Grasping Whip (750 gp); Rod of Absorption (48000 gp); Staff of Power (46000 gp); Wand of Wonder (2250 gp); Wayfarer's Boots (4750 gp)", 7: "You gain access to Morgrave's restricted collection, including forbidden texts, dangerous magical research, and artifacts deemed too risky for public display. When you take a Bastion turn, you can issue the Research order to Morgrave as if it were an Arcane Study facility.

You can commission Morgrave artificers to create custom magic items at 75% of the usual cost.", 9: "You gain access to a private research chamber in the university and can request any single Rare or lower magic item from the museum's collection for 'extended study' (permanent loan). Additionally, they will purchase any artifacts or relics you recover at 150% of market value." }, "Tarkanan": { 3: "You can stay in Tarkanan-controlled safe houses and inns, providing you a Poor lifestyle for free.", 5: "You can reliably sell stolen goods in settlements.

The first time your Renown Score reaches 5, you receive a random Uncommon magic item.

The following items are always available for purchase, even if they aren't on the market: Bloodshed Blade (8000 gp); Demon Skin (1300 gp); Dispelling Stone (7000 gp); Raven's Slumber (15000 gp); Rogue's Mantle (5000 gp)", 7: "In Tarkanan-controlled settlements, you have Advantage on Charisma (Intimidation) checks made to influence others.", 9: "You can assign a group of up to six Spies to complete a task that doesn't interfere with the Tarkanan's business. Once you use this reward, you can't do so again until your Renown Score increases. In addition, you receive the authority and funds necessary to establish your own Tarkanan outpost. In addition, you can call on a Tarkanan-allied Beholder for help on a single mission. Once you use this reward, you can't do so again." }, "Tharashk": { 3: "You can purchase a dragonshard, or one Common or Uncommon magic item that incorporates dragonshards, at half the usual cost. Alternatively, you can request the assistance of a mercenary from Droaam (a Harpy, an Ogrillon Ogre, a Spy, or a Thri-kreen Marauder) for up to 1 day.

A dragonmarked member of the house casts for you a cantrip or level 1 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 3 or lower at half the usual cost.", 5: "When you take a Bastion turn and issue the Recruit order to a Barrack facility in your Bastion, you can recruit up to twelve Bastion Defenders instead of four. In addition, you can issue the Research order to House Tharashk as if it were a Trophy Room facility within your Bastion.

A dragonmarked member of the house casts for you a level 2 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 4 or lower at half the usual cost.

The following items are always available for purchase, even if they aren't on the market: Iron Bands of Bilarro (2600 gp); Nimbus Coronet (10500 gp); Sanctum Amulet (6000 gp); Warrior's Passkey (5000 gp); Whispering Cloak (4500 gp)", 7: "A dragonmarked member of the house casts for you a level 3 or 4 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 6 or lower at half the usual cost.

You gain a bodyguard for the duration of your next mission for the house. The bodyguard (a creature with a Challenge Rating no higher than half your level rounded up) is appropriate to your house.", 9: "A dragonmarked member of the house casts for you a level 5 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 8 or lower at half the usual cost." }, "Thuranni": { 3: "You can purchase one dose of poison (any kind) or other illegal item (such as stolen goods) at half price.

A dragonmarked member of the house casts for you a cantrip or level 1 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 3 or lower at half the usual cost.", 5: "When you take a Bastion turn, you can issue the Craft order to House Thuranni as if it were a Workshop or Theater facility in your Bastion.

A dragonmarked member of the house casts for you a level 2 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 4 or lower at half the usual cost.

The following items are always available for purchase, even if they aren't on the market: Cape of the Mountebank (3900 gp); Crown of the Wrath Bringer (3750 gp); Fate Cutter Shears (9000 gp); Reveler's Concertina (4250 gp); Robe of Scintillating Colors (27000 gp)", 7: "When you take a Bastion turn, you can issue the Research order to the house as if it were a Pub facility.

A dragonmarked member of the house casts for you a level 3 or 4 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 6 or lower at half the usual cost.

You gain a bodyguard for the duration of your next mission for the house. The bodyguard (a creature with a Challenge Rating no higher than half your level rounded up) is appropriate to your house.", 9: "A dragonmarked member of the house casts for you a level 5 spell associated with the house's mark at no cost. A member of the house casts for you a different spell of level 8 or lower at half the usual cost." } }; // Game Logic Functions function calculatePartyLevel(partyXP) { const levels = [60000, 132000, 220000, 332000, 460000, 628000, 748000, 908000, 1068000, 1268000, 1508000, 1748000, 2068000, 2388000, 2788000]; let i = 0; while (i < levels.length && levels[i] < partyXP) { i++; } return 5 + i; } // Box-Muller transform to generate standard normal random variable function randomNormal() { let u = 0, v = 0; while (u === 0) u = Math.random(); // Converting [0,1) to (0,1) while (v === 0) v = Math.random(); return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v); } function calculateRewards(faction, reputation, partyLevel) { const factionData = FACTIONS[faction]; const reputationMap = { '-10': 0, '-9': 0, '-8': 0, '-7': 0, '-6': 0, '-5': 0, '-4': 0, '-3': 0.2, '-2': 0.4, '-1': 0.6, '0': 0.75, '1': 1, '2': 2, '3': 3, '4': 4, '5': 5, '6': 6, '7': 7, '8': 8, '9': 9, '10': 10 }; const xpLevels = [ 30000, 36000, 44000, 56000, 64000, 84000, 60000, 80000, 80000, 100000, 120000, 120000, 160000, 160000, 200000 ]; const xpBase = xpLevels[partyLevel - 5] / 3; const xpMean = Math.floor(xpBase * factionData.xp); const xpStdev = xpBase * factionData.xp / 10.0; const frep = reputationMap[Math.floor(reputation).toString()]; const baseGold = 600; const repGold = 300; const goldMean = Math.floor((baseGold + ((frep - 2) * repGold)) * partyLevel * Math.min(frep, 1) * factionData.gold); const goldStdev = (baseGold + ((frep - 2) * repGold)) / 8.0; // Use the means and stdevs to produce a normally distributed random variable for xp and gold const xp = Math.max(0, Math.floor(xpMean + xpStdev * randomNormal())); const gold = Math.max(0, Math.floor(goldMean + goldStdev * randomNormal())); return { xp, gold }; } // ===== MAGIC ITEM MARKET SYSTEM ===== // Stock level definitions const STOCK_LEVELS = { "unavailable": 0, "very limited": 1, "limited": 2, "modest": 3, "abundant": 4, "very abundant": 5 }; const STOCK_LEVEL_NAMES = ["unavailable", "very limited", "limited", "modest", "abundant", "very abundant"]; // Rarity stock limits const RARITY_STOCK_LIMITS = { "Very Rare": 1, // Max "very limited" "Rare": 2, // Max "limited" "Uncommon": 5 // Max "very abundant" }; // Stock transition probabilities by rarity // Rows: current stock, Columns: change amount (-3 to +3) // Uncommon items have balanced transitions (original probabilities) const STOCK_TRANSITIONS_UNCOMMON = { "very abundant": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0.2, 0.3, 0.4, 0.1, 0, 0, 0] }, "abundant": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0.1, 0.2, 0.4, 0.2, 0.1, 0, 0] }, "modest": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0.05, 0.1, 0.3, 0.4, 0.1, 0.05, 0] }, "limited": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0.1, 0.3, 0.3, 0.2, 0.1, 0] }, "very limited": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0.4, 0.3, 0.2, 0.1, 0] }, "unavailable": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0.6, 0.3, 0.1, 0] } }; // Rare items tend to stay at lower stock levels (max "limited") const STOCK_TRANSITIONS_RARE = { "very abundant": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "limited") "abundant": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "limited") "modest": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "limited") "limited": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0.2, 0.6, 0.2, 0, 0, 0] }, // More likely to drop than uncommon "very limited": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0.7, 0.1, 0.1, 0, 0] }, // More likely to stay low "unavailable": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0.75, 0.2, 0.05, 0] } // Slower to become available }; // Very Rare items are very scarce (max "very limited") const STOCK_TRANSITIONS_VERY_RARE = { "very abundant": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "very limited") "abundant": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "very limited") "modest": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "very limited") "limited": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0, 0, 0, 0] }, // N/A (capped at "very limited") "very limited": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0.8, 0.2, 0, 0, 0] }, // Very likely to become unavailable "unavailable": { changes: [-3, -2, -1, 0, 1, 2, 3], probs: [0, 0, 0, 0.85, 0.15, 0, 0] } // Very slow to become available }; // Helper function to get the appropriate transition matrix for an item's rarity function getStockTransitions(rarity) { switch(rarity) { case "Very Rare": return STOCK_TRANSITIONS_VERY_RARE; case "Rare": return STOCK_TRANSITIONS_RARE; case "Uncommon": default: return STOCK_TRANSITIONS_UNCOMMON; } } // Global market data and sort state let marketData = null; let currentSort = { column: 'name', direction: 'asc' }; // Calculate price multiplier based on reputation and rarity function getPriceMultiplier(reputation, rarity) { if (reputation <= -3) { return null; // Hostile - no sales } else if (reputation <= -2) { return rarity === "Uncommon" ? 3.0 : null; } else if (reputation < 1) { return rarity === "Uncommon" ? 2.0 : null; } else if (reputation < 3) { if (rarity === "Uncommon") return 1.0; if (rarity === "Rare") return 2.0; return null; // Very Rare unavailable } else if (reputation < 5) { if (rarity === "Uncommon") return 0.5; if (rarity === "Rare") return 1.0; if (rarity === "Very Rare") return 2.0; } else { if (rarity === "Uncommon") return 0.5; if (rarity === "Rare") return 0.75; if (rarity === "Very Rare") return 1.0; } return null; } // Apply probabilistic stock change to single item function updateItemStock(item) { const currentStock = item.stock; const currentLevel = STOCK_LEVELS[currentStock]; // Get rarity-specific transition matrix const transitions = getStockTransitions(item.rarity); const transition = transitions[currentStock]; // Weighted random selection const rand = Math.random(); let cumulative = 0; for (let i = 0; i < transition.probs.length; i++) { cumulative += transition.probs[i]; if (rand < cumulative) { const change = transition.changes[i]; let newLevel = currentLevel + change; // Clamp to valid range [0, 5] newLevel = Math.max(0, Math.min(5, newLevel)); // Enforce rarity constraints const maxLevel = RARITY_STOCK_LIMITS[item.rarity]; newLevel = Math.min(newLevel, maxLevel); return STOCK_LEVEL_NAMES[newLevel]; } } return currentStock; // Fallback (shouldn't reach) } // Check if item is available for purchase based on reputation function isItemAvailable(item, factionReputation) { return getPriceMultiplier(factionReputation, item.rarity) !== null; } // Calculate adjusted price function getAdjustedPrice(item, factionReputation) { const multiplier = getPriceMultiplier(factionReputation, item.rarity); if (multiplier === null) return null; return Math.round(item.baseCost * multiplier); } // Get visual indicator for stock level (dots) function getStockIndicator(stockLevel) { const level = STOCK_LEVELS[stockLevel]; const dots = '●'.repeat(level) + '○'.repeat(5 - level); return dots; } // Toggle section visibility (generic function for all collapsible sections) function toggleSection(contentId, iconId) { const content = document.getElementById(contentId); const icon = document.getElementById(iconId); if (content.style.display === 'none') { content.style.display = 'block'; icon.textContent = '▼'; } else { content.style.display = 'none'; icon.textContent = '▶'; } } // Toggle hamburger menu function toggleHamburgerMenu() { const nav = document.getElementById('hamburger-nav'); nav.classList.toggle('open'); } // Close hamburger menu when clicking outside document.addEventListener('click', (event) => { const menu = document.querySelector('.hamburger-menu'); const nav = document.getElementById('hamburger-nav'); if (menu && nav && !menu.contains(event.target)) { nav.classList.remove('open'); } }); // Render market section as sortable table // Only shows items that are actually available for purchase function renderMarket(marketData, reputationData) { const marketContainer = document.getElementById('market-display'); if (!marketContainer || !marketData || !marketData.items) return; // Filter to only available items (stock > unavailable AND reputation high enough) const availableItems = []; marketData.items.forEach(item => { const reputation = reputationData[item.faction] || 0; if (item.stock !== "unavailable" && isItemAvailable(item, reputation)) { const attunementRequired = item.attunementRequired || MAGIC_ITEM_ATTUNEMENT_MAP.get(item.name) || 'No'; const normalizedAttunement = attunementRequired.toString().trim().toLowerCase(); const attunementDisplay = normalizedAttunement === 'required' ? 'Required' : normalizedAttunement === 'special' ? 'Special' : 'No'; const dndbeyondPage = item.dndbeyondPage || MAGIC_ITEM_PAGE_MAP.get(item.name) || null; const dndbeyondUrl = dndbeyondPage ? `${DNDBEYOND_MAGIC_ITEM_BASE_URL}${dndbeyondPage}` : null; // Add adjusted price to item for sorting const adjustedPrice = getAdjustedPrice(item, reputation); availableItems.push({ ...item, adjustedPrice, reputation, attunementRequired: attunementDisplay, dndbeyondUrl }); } }); if (availableItems.length === 0) { marketContainer.innerHTML = '
No items currently available for purchase.
'; return; } // Sort items sortItems(availableItems, currentSort.column, currentSort.direction); // Build table HTML with scrollable wrapper let html = `
`; // Render each item as table row availableItems.forEach(item => { const stockClass = item.stock.replace(' ', '-'); const factionClass = item.faction.toLowerCase(); const rarityClass = item.rarity.toLowerCase().replace(' ', '-'); // Detect if this is admin page const isAdminPage = window.location.pathname.includes('admin.html'); const priceDisplay = item.adjustedPrice === item.baseCost ? `${item.adjustedPrice} gp` : isAdminPage ? `${item.adjustedPrice} gp (${item.baseCost} gp)` : `${item.adjustedPrice} gp`; const attunementRequired = (item.attunementRequired || 'No').toString().trim(); const attunementClass = attunementRequired === 'Required' ? 'attunement-required' : attunementRequired === 'Special' ? 'attunement-special' : 'attunement-no'; const nameDisplay = item.dndbeyondUrl ? `${item.name}` : item.name; html += ` `; }); html += `
Name ${getSortIndicator('name')} Faction ${getSortIndicator('faction')} Rarity ${getSortIndicator('rarity')} Stock ${getSortIndicator('stock')} Cost ${getSortIndicator('cost')} Attunement ${getSortIndicator('attunement')}
${nameDisplay} ${item.faction} ${item.rarity} ${getStockIndicator(item.stock)} ${item.stock} ${priceDisplay} ${attunementRequired}
`; marketContainer.innerHTML = html; } // Sort items by column function sortItems(items, column, direction) { const multiplier = direction === 'asc' ? 1 : -1; items.sort((a, b) => { let aVal, bVal; switch (column) { case 'name': aVal = a.name.toLowerCase(); bVal = b.name.toLowerCase(); return aVal.localeCompare(bVal) * multiplier; case 'faction': aVal = a.faction.toLowerCase(); bVal = b.faction.toLowerCase(); return aVal.localeCompare(bVal) * multiplier; case 'rarity': // Sort order: Uncommon < Rare < Very Rare const rarityOrder = { "Uncommon": 1, "Rare": 2, "Very Rare": 3 }; aVal = rarityOrder[a.rarity] || 0; bVal = rarityOrder[b.rarity] || 0; return (aVal - bVal) * multiplier; case 'stock': aVal = STOCK_LEVELS[a.stock]; bVal = STOCK_LEVELS[b.stock]; return (aVal - bVal) * multiplier; case 'cost': aVal = a.adjustedPrice; bVal = b.adjustedPrice; return (aVal - bVal) * multiplier; case 'attunement': const attuneOrder = { "No": 1, "Required": 2, "Special": 3 }; aVal = attuneOrder[a.attunementRequired] || 0; bVal = attuneOrder[b.attunementRequired] || 0; return (aVal - bVal) * multiplier; default: return 0; } }); } // Handle sort column click function sortMarket(column) { if (currentSort.column === column) { // Toggle direction currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc'; } else { // New column, default to ascending currentSort.column = column; currentSort.direction = 'asc'; } // Re-render with new sort if (marketData && campaignData) { renderMarket(marketData, campaignData.reputation); } } // Get sort indicator for column header function getSortIndicator(column) { if (currentSort.column !== column) return '⇅'; return currentSort.direction === 'asc' ? '↑' : '↓'; } // ===== END MAGIC ITEM MARKET SYSTEM ===== function getNextQuestForFaction(faction, currentIndex) { const questList = FACTION_QUESTS[faction]; if (!questList) return null; // Check if faction has exhausted all quests if (currentIndex >= questList.length) { return null; // Faction quests exhausted } // Return the full quest object (description, duration, availability) return questList[currentIndex]; } // UI Rendering Functions function renderReputationTable(reputation, elementId) { const table = document.getElementById(elementId); if (!table) return; // Sort factions alphabetically const factions = Object.keys(FACTIONS).sort(); let html = ''; // First row (4 factions) for (let i = 0; i < 4; i++) { const faction = factions[i]; const rep = reputation[faction] || 0; const colorClass = faction.toLowerCase(); const failurePenalty = FACTIONS[faction]["rep-"]; // Determine status badge and color let badge = ' '; let scoreClass = ''; if (rep <= -3) { badge = 'Hostile'; scoreClass = 'hostile'; } else if (rep >= 5) { badge = 'Friendly'; scoreClass = 'friendly'; } // Generate benefits tooltip const tooltipHtml = generateBenefitsTooltip(faction, rep); html += `
${tooltipHtml} ${faction}
${badge}
${rep >= 0 ? '+' : ''}${rep.toFixed(2)}
Failure: ${failurePenalty.toFixed(2)}
`; } html += ''; // Second row (4 factions) for (let i = 4; i < 8; i++) { const faction = factions[i]; const rep = reputation[faction] || 0; const colorClass = faction.toLowerCase(); const failurePenalty = FACTIONS[faction]["rep-"]; // Determine status badge and color let badge = ' '; let scoreClass = ''; if (rep <= -3) { badge = 'Hostile'; scoreClass = 'hostile'; } else if (rep >= 5) { badge = 'Friendly'; scoreClass = 'friendly'; } // Generate benefits tooltip const tooltipHtml = generateBenefitsTooltip(faction, rep); html += `
${tooltipHtml} ${faction}
${badge}
${rep >= 0 ? '+' : ''}${rep.toFixed(2)}
Failure: ${failurePenalty.toFixed(2)}
`; } html += ''; table.innerHTML = html; } function generateBenefitsTooltip(faction, currentRep) { const benefits = FACTION_BENEFITS[faction]; if (!benefits) return ''; const benefitLevels = [3, 5, 7, 9]; let tooltipContent = ''; benefitLevels.forEach(level => { const benefit = benefits[level]; if (benefit) { const isUnlocked = currentRep >= level; const statusClass = isUnlocked ? 'unlocked' : 'locked'; const lockIcon = isUnlocked ? '✓' : '🔒'; tooltipContent += `
Reputation ${level} ${lockIcon}
${benefit}
`; } }); return ` ?
Reputation Benefits
${tooltipContent}
`; } // Toggle benefits tooltip on click function toggleBenefitsTooltip(event) { event.stopPropagation(); const icon = event.target; const tooltip = icon.nextElementSibling; // Close all other tooltips first document.querySelectorAll('.benefits-tooltip.show').forEach(t => { if (t !== tooltip) { t.classList.remove('show'); } }); // Toggle this tooltip tooltip.classList.toggle('show'); } // Close tooltips when clicking outside document.addEventListener('click', (event) => { if (!event.target.classList.contains('benefits-info-icon')) { document.querySelectorAll('.benefits-tooltip.show').forEach(tooltip => { tooltip.classList.remove('show'); }); } }); function renderQuestBoard(quests, currentTime) { const board = document.getElementById('quest-board'); if (!board) return; if (!quests || quests.length === 0) { board.innerHTML = '
No active quests available
'; return; } // Sort quests by faction quests.sort((a, b) => a.faction.localeCompare(b.faction)); let html = ''; quests.forEach(quest => { const expiresIn = quest.expires_at - currentTime; const colorClass = quest.faction.toLowerCase(); // Format reputation changes let repChangesHtml = ''; if (quest.repChanges) { const sortedChanges = Object.entries(quest.repChanges).sort((a, b) => a[0].localeCompare(b[0])); sortedChanges.forEach(([faction, change]) => { const sign = change >= 0 ? '+' : ''; const changeClass = change >= 0 ? 'rep-positive' : 'rep-negative'; const factionColorClass = faction.toLowerCase(); repChangesHtml += `${faction} ${sign}${change.toFixed(2)}`; }); } html += `

${quest.faction}

${quest.description}
💰 ${quest.gold.toLocaleString()} gp ⭐ ${quest.xp.toLocaleString()} XP ⏱ Duration: ${quest.duration}w ⏰ Expires: ${expiresIn}w 📍 ${quest.location}
${repChangesHtml ? `
Reputation (on success): ${repChangesHtml}
` : ''}
`; }); board.innerHTML = html; } function renderQuestHistory() { const container = document.getElementById('quest-history'); if (!container) return; if (!questHistory || questHistory.length === 0) { container.innerHTML = '
No completed quests yet.
'; return; } // Sort by completion time (newest first) const sortedHistory = [...questHistory].sort((a, b) => b.completed_at - a.completed_at); let html = ''; sortedHistory.forEach(quest => { const colorClass = quest.faction.toLowerCase(); const statusClass = quest.success ? 'success' : 'failure'; const statusIcon = quest.success ? '✓' : '✗'; const statusText = quest.success ? 'Success' : 'Failed'; html += `

${quest.faction}

${statusIcon} ${statusText}
Week ${quest.completed_at}
${quest.description}
💰 ${quest.gold.toLocaleString()} gp ⭐ ${quest.xp.toLocaleString()} XP ⏱ ${quest.duration}w 📍 ${quest.location}
`; }); container.innerHTML = html; } // Real-time Database Listeners let campaignData = null; let questHistory = []; function setupRealtimeListeners() { // Listen to campaign state db.collection('campaign').doc('state').onSnapshot((doc) => { if (doc.exists) { campaignData = doc.data(); updatePublicUI(); } }); // Listen to quests db.collection('campaign').doc('quests').onSnapshot((doc) => { if (doc.exists && campaignData) { campaignData.active_quests = doc.data().quests || []; updatePublicUI(); } }); // Listen to quest history db.collection('campaign').doc('history').onSnapshot((doc) => { if (doc.exists) { questHistory = doc.data().completed_quests || []; renderQuestHistory(); } }); // Listen to magic item market db.collection('campaign').doc('market').onSnapshot((doc) => { if (doc.exists) { marketData = doc.data(); if (campaignData) { renderMarket(marketData, campaignData.reputation); } } }); } function updatePublicUI() { if (!campaignData) return; // Update week display const weekElement = document.getElementById('week-display'); if (weekElement) weekElement.textContent = `Week ${campaignData.current_time}`; // Update reputation table renderReputationTable(campaignData.reputation, 'rep-table'); // Update quest board renderQuestBoard(campaignData.active_quests, campaignData.current_time); // Update market (reputation changes affect prices and availability) if (marketData) { renderMarket(marketData, campaignData.reputation); } } // Initialize on page load document.addEventListener('DOMContentLoaded', () => { setupRealtimeListeners(); });