From 2c39c7b29e84d7ec7193887474783ace3c206c91 Mon Sep 17 00:00:00 2001 From: Kelly Stewart Date: Thu, 29 Jun 2023 09:43:12 +1000 Subject: [PATCH] Add ability to use Lazy GM's encounter difficulty system (#192) * feat: Add setting for XP system This doesn't currently do anything, but will be used for choosing different ways to calculate XP for encounters. Put it under "Encounters" in settings, but not in the builder part of the data, as this isn't specific to the encounter builder. * refactor: Generalize encounter difficulty utility functions This is to prepare for more customizable encounter XP systems. - Move all XP-calculation related things to encounter-difficulty.ts - Remove duplicated logic between encounter builder and other encounters - Use DifficultyReport for XP-related calculations. In the future we can swap out the contents of this and how it's generated based on the current XP system. Incidentally, this fixes some a discrepency between the encounter builder and other encounters: - other encounters will now show 'Trivial' for encounters below the Easy XP threshold, same as the encounter builder * feat: Add XP system for DnD 5e Lazy GM This alters the encounter builder, encounters, and encounter table to all use the Lazy GM's (https://slyflourish.com/the_lazy_encounter_benchmark.html) system for benchmarking encounters. - The encounter and encounter table now show CR for each creature rather than XP - The encounter builder will show total XP as well as the CR threshold for deadly, and the total CR BREAKING CHANGE: this changes the tracker difficulty result to not always populate `adjustedXp`. Now, non-DnD5e systems will use `totalXp` for the total, while DnD 5e will use `adjustedXp`. * feat: Add XP system for DnD 5e Lazy GM This alters the encounter builder, encounters, and encounter table to all use the Lazy GM's (https://slyflourish.com/the_lazy_encounter_benchmark.html) system for benchmarking encounters. - The encounter and encounter table now show CR for each creature rather than XP - The encounter builder will show total XP as well as the CR threshold for deadly, and the total CR BREAKING CHANGE: this changes the tracker difficulty result to not always populate `adjustedXp`. Now, non-DnD5e systems will use `totalXp` for the total, while DnD 5e will use `adjustedXp`. * feat: Add XP system for DnD 5e Lazy GM This alters the encounter builder, encounters, and encounter table to all use the Lazy GM's (https://slyflourish.com/the_lazy_encounter_benchmark.html) system for benchmarking encounters. - The encounter and encounter table now show CR for each creature rather than XP - The encounter builder will show total XP as well as the CR threshold for deadly, and the total CR BREAKING CHANGE: this changes the tracker difficulty result to not always populate `adjustedXp`. Now, non-DnD5e systems will use `totalXp` for the total, while DnD 5e will use `adjustedXp`. * refactor: Add a base RpgSystem and a dnd5e impl Also rename the xpSystem setting to rpgSystem to allow for future extensions for other purposes eg initiative. Still keep it in labelled as "XP Tracker" in the UI though. Delete encounter-difficulty.ts and put difficulty-related functions under rpg-system instead. Also for now remove the lazy GM setting, to be re-added in a later commit. * refactor: Add back in Lazy GM's system Also add calls to formatDifficultyValue() in several places that I initially forgot, and add an optional arg for whether the formatted value should include units or not. * fix: Fix a division by zero * fix: make formatDifficultyValue respect withUnits * refactor: Add a getFromCreatureOrBeastiary function * feat: Add method for what to show in creature row in builder - Add a getAdditionalCreatureDifficultyStats for what should be shown when a creature is added to the builder. This allows systems to show the stats that are relevant to their difficulty calculations. - Fix some null issues that I missed when refactoring to use getFromCreatureOrBestiary. And also a spelling mistake in that method name. - Move the method to convert CR num to string into utils so it can be used by both RPG systems - Minor formatting fixes * refactor: minor formatting fix * fix: fixes issue where an empty player list causes error --------- Co-authored-by: Kelly Stewart Co-authored-by: Jeremy Valentine <38669521+valentine195@users.noreply.github.com> --- index.ts | 10 +- src/builder/constants.ts | 78 ---------- src/builder/stores/players.ts | 26 +--- src/builder/view.ts | 1 - src/builder/view/encounter/Creature.svelte | 39 ++--- src/builder/view/party/Experience.svelte | 100 ++++-------- src/encounter/ui/Creature.svelte | 7 +- src/encounter/ui/Encounter.svelte | 61 +++----- src/encounter/ui/EncounterRow.svelte | 38 ++--- src/settings/settings.ts | 13 ++ src/tracker/stores/tracker.ts | 40 +++-- src/tracker/ui/Difficulty.svelte | 15 +- src/tracker/ui/Metadata.svelte | 6 +- src/utils/constants.ts | 50 ++---- src/utils/creature.ts | 20 +-- src/utils/encounter-difficulty.ts | 126 --------------- src/utils/index.ts | 35 ++++- src/utils/rpg-system/dnd5e-lazygm.ts | 89 +++++++++++ src/utils/rpg-system/dnd5e.ts | 169 +++++++++++++++++++++ src/utils/rpg-system/index.ts | 68 +++++++++ src/utils/rpg-system/rpgSystem.ts | 62 ++++++++ 21 files changed, 559 insertions(+), 494 deletions(-) delete mode 100644 src/builder/constants.ts delete mode 100644 src/utils/encounter-difficulty.ts create mode 100644 src/utils/rpg-system/dnd5e-lazygm.ts create mode 100644 src/utils/rpg-system/dnd5e.ts create mode 100644 src/utils/rpg-system/index.ts create mode 100644 src/utils/rpg-system/rpgSystem.ts diff --git a/index.ts b/index.ts index b729d5c0..e09c05bb 100644 --- a/index.ts +++ b/index.ts @@ -102,6 +102,7 @@ export interface InitiativeTrackerData { parties: Party[]; defaultParty: string; + rpgSystem: string; canUseDiceRoll: boolean; initiative: string; modifier: string; @@ -245,14 +246,6 @@ export interface BuilderGenericPlayer { export type BuilderPlayer = BuilderPartyPlayer | BuilderGenericPlayer; -export interface ExperienceThreshold { - Easy: number; - Medium: number; - Hard: number; - Deadly: number; - Daily: number; -} - import type InitiativeTracker from "src/main"; export declare function getId(): string; export declare class Creature { @@ -280,7 +273,6 @@ export declare class Creature { display: string; friendly: boolean; "statblock-link": string; - getXP(plugin: InitiativeTracker): number; constructor(creature: HomebrewCreature, initiative?: number); get hpDisplay(): string; get initiative(): number; diff --git a/src/builder/constants.ts b/src/builder/constants.ts deleted file mode 100644 index 33c156e4..00000000 --- a/src/builder/constants.ts +++ /dev/null @@ -1,78 +0,0 @@ -export const EXPERIENCE_PER_LEVEL: { - [key: number]: { - daily: number; - easy: number; - medium: number; - hard: number; - deadly: number; - }; -} = { - 1: { daily: 300, easy: 25, medium: 50, hard: 75, deadly: 100 }, - 2: { daily: 600, easy: 50, medium: 100, hard: 150, deadly: 200 }, - 3: { daily: 1200, easy: 75, medium: 150, hard: 225, deadly: 400 }, - 4: { daily: 1700, easy: 125, medium: 250, hard: 375, deadly: 500 }, - 5: { daily: 3500, easy: 250, medium: 500, hard: 750, deadly: 1100 }, - 6: { daily: 4000, easy: 300, medium: 600, hard: 900, deadly: 1400 }, - 7: { daily: 5000, easy: 350, medium: 750, hard: 1100, deadly: 1700 }, - 8: { daily: 6000, easy: 450, medium: 900, hard: 1400, deadly: 2100 }, - 9: { daily: 7500, easy: 550, medium: 1100, hard: 1600, deadly: 2400 }, - 10: { daily: 9000, easy: 600, medium: 1200, hard: 1900, deadly: 2800 }, - 11: { daily: 10500, easy: 800, medium: 1600, hard: 2400, deadly: 3600 }, - 12: { daily: 11500, easy: 1000, medium: 2000, hard: 3000, deadly: 4500 }, - 13: { daily: 13500, easy: 1100, medium: 2200, hard: 3400, deadly: 5100 }, - 14: { daily: 15000, easy: 1250, medium: 2500, hard: 3800, deadly: 5700 }, - 15: { daily: 18000, easy: 1400, medium: 2800, hard: 4300, deadly: 6400 }, - 16: { daily: 20000, easy: 1600, medium: 3200, hard: 4800, deadly: 7200 }, - 17: { daily: 25000, easy: 2000, medium: 3900, hard: 5900, deadly: 8800 }, - 18: { daily: 27000, easy: 2100, medium: 4200, hard: 6300, deadly: 9500 }, - 19: { daily: 30000, easy: 2400, medium: 4900, hard: 7300, deadly: 10900 }, - 20: { daily: 40000, easy: 2800, medium: 5700, hard: 8500, deadly: 12700 } -}; - -export const CR_EXPERIENCE_VALUES = { - 0: 10, - "1/8": 25, - "1/4": 50, - "1/2": 100, - 1: 200, - 2: 450, - 3: 700, - 4: 1100, - 5: 1800, - 6: 2300, - 7: 2900, - 8: 3900, - 9: 5000, - 10: 5900, - 11: 7200, - 12: 8400, - 13: 10000, - 14: 11500, - 15: 13000, - 16: 15000, - 17: 18000, - 18: 20000, - 19: 22000, - 20: 25000, - 21: 33000, - 22: 41000, - 23: 50000, - 24: 62000, - 25: 75000, - 26: 90000, - 27: 105000, - 28: 120000, - 29: 135000, - 30: 155000 -}; - -export const MODIFIERS_BY_COUNT = [0.5, 1, 1.5, 2, 2.5, 3, 4, 5] as const; -export const MODIFIER_THRESHOLDS = [Infinity, 1, 2, 3, 7, 11, 15]; - -export const EXPERIENCE_THRESHOLDS = [ - "Easy", - "Medium", - "Hard", - "Deadly" -] as const; - diff --git a/src/builder/stores/players.ts b/src/builder/stores/players.ts index 7e7c5bb4..9b2f15d9 100644 --- a/src/builder/stores/players.ts +++ b/src/builder/stores/players.ts @@ -1,6 +1,5 @@ import { derived, get, writable } from "svelte/store"; import type { CreatureState } from "../../../index"; -import { EXPERIENCE_PER_LEVEL } from "../constants"; export const playerCount = writable(0); @@ -17,7 +16,7 @@ interface GenericPlayer extends Partial { enabled: boolean; count: number; } -type CombinedPlayer = Player | GenericPlayer; +export type CombinedPlayer = Player | GenericPlayer; function createPlayers() { const store = writable([]); @@ -39,29 +38,6 @@ function createPlayers() { party, generics, count, - thresholds: derived(store, ($players) => { - const threshold = { - Easy: 0, - Medium: 0, - Hard: 0, - Deadly: 0, - Daily: 0 - }; - for (const player of $players) { - if (!player.level) continue; - if (!player.enabled) continue; - const level = player.level > 20 ? 20 : player.level; - const thresholds = EXPERIENCE_PER_LEVEL[level]; - if (!thresholds) continue; - - threshold.Easy += thresholds.easy * player.count; - threshold.Medium += thresholds.medium * player.count; - threshold.Hard += thresholds.hard * player.count; - threshold.Deadly += thresholds.deadly * player.count; - threshold.Daily += thresholds.daily * player.count; - } - return threshold; - }), modifier: derived(count, ($count) => $count < 3 ? 1 : $count > 5 ? -1 : 0 ), diff --git a/src/builder/view.ts b/src/builder/view.ts index 3220c941..d3bdbd30 100644 --- a/src/builder/view.ts +++ b/src/builder/view.ts @@ -11,7 +11,6 @@ import type { SRDMonster } from "obsidian-overload"; interface BuilderContext { plugin: InitiativeTracker; playerCount: number; - thresholds: ExperienceThreshold; } declare module "svelte" { function setContext( diff --git a/src/builder/view/encounter/Creature.svelte b/src/builder/view/encounter/Creature.svelte index ed6ebbf1..f2498a6b 100644 --- a/src/builder/view/encounter/Creature.svelte +++ b/src/builder/view/encounter/Creature.svelte @@ -3,10 +3,9 @@ import { ExtraButtonComponent, setIcon } from "obsidian"; import { convertFraction, - DEFAULT_UNDEFINED, FRIENDLY, - HIDDEN, - XP_PER_CR + getRpgSystem, + HIDDEN } from "src/utils"; import { encounter } from "../../stores/encounter"; import Nullable from "../Nullable.svelte"; @@ -17,6 +16,7 @@ const { average } = players; const plugin = getContext("plugin"); + const rpgSystem = getRpgSystem(plugin); const remove = (node: HTMLElement) => { new ExtraButtonComponent(node).setIcon("minus-circle"); }; @@ -41,19 +41,6 @@ export let count: number; export let creature: SRDMonster; - const convertedCR = (cr: string | number) => { - if (cr == undefined) return DEFAULT_UNDEFINED; - if (cr == "1/8") { - return "⅛"; - } - if (cr == "1/4") { - return "¼"; - } - if (cr == "1/2") { - return "½"; - } - return cr; - }; $: insignificant = "cr" in creature && creature.cr && @@ -63,6 +50,8 @@ creature.cr && convertFraction(creature.cr) > $average + 3; + $: playerLevels = $players.filter(p => p.enabled).map(p => p.level); + const baby = (node: HTMLElement) => setIcon(node, "baby"); const skull = (node: HTMLElement) => setIcon(node, "skull"); @@ -121,20 +110,14 @@ /> {/if} + {#each rpgSystem.getAdditionalCreatureDifficultyStats(creature, playerLevels) as stat} +
+ {stat} +
+ {/each}
- - -
-
- - +
diff --git a/src/builder/view/party/Experience.svelte b/src/builder/view/party/Experience.svelte index b64257f1..b0efd030 100644 --- a/src/builder/view/party/Experience.svelte +++ b/src/builder/view/party/Experience.svelte @@ -1,54 +1,17 @@
@@ -57,39 +20,38 @@ on:toggle={() => (plugin.data.builder.showXP = !plugin.data.builder.showXP)} > -
Experience
+
+ Experience + {#if plugin.data.rpgSystem != RpgSystemSetting.Dnd5e} + ({rpgSystem.displayName}) + {/if} +
Difficulty - - {difficulty} - + {difficulty.displayName}
+ {#each difficulty.intermediateValues as intermediate} +
+ {intermediate.label} + {intermediate.value.toLocaleString()} +
+ {/each}
- XP - - {xp ? xp.toLocaleString() : DEFAULT_UNDEFINED} - -
-
- Adjusted - - {adjXP ? adjXP.toLocaleString() : DEFAULT_UNDEFINED} - + {difficulty.title} + {rpgSystem.formatDifficultyValue(difficulty.value)}
- {#each EXPERIENCE_THRESHOLDS as level} -
- {level} + {#each rpgSystem.getDifficultyThresholds(playerLevels) as budget} +
+ + {budget.displayName} + - {$thresholds[level].toLocaleString()} XP + {rpgSystem.formatDifficultyValue(budget.minValue, true)}
{/each} @@ -97,10 +59,12 @@
-
Daily budget
- - {$thresholds.Daily.toLocaleString()} XP - + {#each rpgSystem.getAdditionalDifficultyBudgets(playerLevels) as budget} +
{budget.displayName}
+ + {rpgSystem.formatDifficultyValue(budget.minValue, true)} + + {/each}
diff --git a/src/encounter/ui/Creature.svelte b/src/encounter/ui/Creature.svelte index 09a7930f..e27320ef 100644 --- a/src/encounter/ui/Creature.svelte +++ b/src/encounter/ui/Creature.svelte @@ -1,11 +1,12 @@ -
+
Easy @@ -15,9 +17,9 @@ {#if $name && $name.length}

{$name}

{/if} - {#if $dif?.totalXp > 0} + {#if $dif?.difficulty?.value > 0} {$dif?.totalXp} XP{rpgSystem.formatDifficultyValue($dif?.difficulty?.value, true)}
{/if}
diff --git a/src/utils/constants.ts b/src/utils/constants.ts index fa8adbbd..6a19f876 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -51,6 +51,7 @@ export const DEFAULT_SETTINGS: InitiativeTrackerData = { }, hpOverflow: "ignore", additiveTemp: false, + rpgSystem: "dnd5e", logging: false, logFolder: "/", useLegacy: false, @@ -63,48 +64,19 @@ export const DEFAULT_SETTINGS: InitiativeTrackerData = { } }; -export const XP_PER_CR: Record = { - "0": 0, - "0.125": 25, - "1/8": 25, - "0.25": 50, - "1/4": 50, - "0.5": 100, - "1/2": 100, - "1": 200, - "2": 450, - "3": 700, - "4": 1100, - "5": 1800, - "6": 2300, - "7": 2900, - "8": 3900, - "9": 5000, - "10": 5900, - "11": 7200, - "12": 8400, - "13": 10000, - "14": 11500, - "15": 13000, - "16": 15000, - "17": 18000, - "18": 20000, - "19": 22000, - "20": 25000, - "21": 33000, - "22": 41000, - "23": 50000, - "24": 62000, - "25": 75000, - "26": 90000, - "27": 105000, - "28": 120000, - "29": 135000, - "30": 155000 -}; export const OVERFLOW_TYPE: { [key: string]: string } = { ignore: "ignore", current: "current", temp: "temp" }; + +export const DECIMAL_TO_VULGAR_FRACTION: Record = { + 0.125: "⅛", + 0.25: "¼", + 0.375: "⅜", + 0.5: "½", + 0.625: "⅝", + 0.75: "¾", + 0.875: "⅞", +} as const; \ No newline at end of file diff --git a/src/utils/creature.ts b/src/utils/creature.ts index c182f2dc..bd5c8b80 100644 --- a/src/utils/creature.ts +++ b/src/utils/creature.ts @@ -4,7 +4,7 @@ import type { HomebrewCreature, SRDMonster } from "index"; -import { Conditions, XP_PER_CR } from "."; +import { Conditions } from "."; import { DEFAULT_UNDEFINED } from "./constants"; import type InitiativeTracker from "src/main"; @@ -48,17 +48,6 @@ export class Creature { cr: string | number; path: string; - getXP(plugin: InitiativeTracker) { - if (this.xp) return this.xp; - if (this.creature.cr) { - return XP_PER_CR[this.creature.cr] ?? 0; - } - const base = plugin.getBaseCreatureFromBestiary(this.name); - if (base && base.cr) { - return XP_PER_CR[base.cr] ?? 0; - } - } - constructor(public creature: HomebrewCreature, initiative: number = 0) { this.name = creature.name; this.display = creature.display; @@ -91,11 +80,8 @@ export class Creature { this.note = creature.note; this.path = creature.path; - if ("xp" in creature) { - this.xp = creature.xp; - } else if ("cr" in creature) { - this.xp = XP_PER_CR[`${creature.cr}`]; - } + this.xp = creature.xp; + this.cr = creature.cr; this.id = creature.id ?? getId(); if ("statblock-link" in creature) { diff --git a/src/utils/encounter-difficulty.ts b/src/utils/encounter-difficulty.ts deleted file mode 100644 index 2a1b3c6e..00000000 --- a/src/utils/encounter-difficulty.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { XP_PER_CR } from "./constants"; -import type InitiativeTracker from "../main"; -import type { Creature } from "./creature"; - -type XpBudget = { easy: number; medium: number; hard: number; deadly: number }; -export type DifficultyReport = { - difficulty: string; - totalXp: number; - adjustedXp: number; - multiplier: number; - budget: XpBudget; -}; - -interface BudgetDict { - [index: number]: XpBudget; -} - -export const getCreatureXP = ( - plugin: InitiativeTracker, - creature: Creature -) => { - if (creature.xp) return creature.xp; - let existing = plugin.bestiary.find((c) => c.name == creature.name); - if (existing && existing.cr && existing.cr in XP_PER_CR) { - return XP_PER_CR[existing.cr]; - } - return 0; -}; - -const tresholds: BudgetDict = { - 1: { easy: 25, medium: 50, hard: 75, deadly: 100 }, - 2: { easy: 50, medium: 100, hard: 150, deadly: 200 }, - 3: { easy: 75, medium: 150, hard: 225, deadly: 400 }, - 4: { easy: 125, medium: 250, hard: 375, deadly: 500 }, - 5: { easy: 250, medium: 500, hard: 750, deadly: 1100 }, - 6: { easy: 300, medium: 600, hard: 900, deadly: 1400 }, - 7: { easy: 350, medium: 750, hard: 1100, deadly: 1700 }, - 8: { easy: 450, medium: 900, hard: 1400, deadly: 2100 }, - 9: { easy: 550, medium: 1100, hard: 1600, deadly: 2400 }, - 10: { easy: 600, medium: 1200, hard: 1900, deadly: 2800 }, - 11: { easy: 800, medium: 1600, hard: 2400, deadly: 3600 }, - 12: { easy: 1000, medium: 2000, hard: 3000, deadly: 4500 }, - 13: { easy: 1100, medium: 2200, hard: 3400, deadly: 5100 }, - 14: { easy: 1250, medium: 2500, hard: 3800, deadly: 5700 }, - 15: { easy: 1400, medium: 2800, hard: 4300, deadly: 6400 }, - 16: { easy: 1600, medium: 3200, hard: 4800, deadly: 7200 }, - 17: { easy: 2000, medium: 3900, hard: 5900, deadly: 8800 }, - 18: { easy: 2100, medium: 4200, hard: 6300, deadly: 9500 }, - 19: { easy: 2400, medium: 4900, hard: 7300, deadly: 10900 }, - 20: { easy: 2800, medium: 5700, hard: 8500, deadly: 12700 } -}; - -function xpBudget(characterLevels: number[]): XpBudget { - const easy = characterLevels.reduce( - (acc, lvl) => acc + (tresholds[lvl]?.easy ?? 0), - 0 - ); - const medium = characterLevels.reduce( - (acc, lvl) => acc + (tresholds[lvl]?.medium ?? 0), - 0 - ); - const hard = characterLevels.reduce( - (acc, lvl) => acc + (tresholds[lvl]?.hard ?? 0), - 0 - ); - const deadly = characterLevels.reduce( - (acc, lvl) => acc + (tresholds[lvl]?.deadly ?? 0), - 0 - ); - return { easy: easy, medium: medium, hard: hard, deadly: deadly }; -} - -export function formatDifficultyReport(report: DifficultyReport): string { - return `${[ - `Encounter is ${report.difficulty}`, - `Total XP: ${report.totalXp}`, - `Adjusted XP: ${report.adjustedXp} (x${report.multiplier})`, - ` `, - `Threshold`, - `Easy: ${report.budget.easy}`, - `Medium: ${report.budget.medium}`, - `Hard: ${report.budget.hard}`, - `Deadly: ${report.budget.deadly}` - ].join("\n")}`; -} - -export function encounterDifficulty( - characterLevels: number[], - xp: number, - numberOfMonsters: number -): DifficultyReport | null { - if (!characterLevels?.length || xp == 0 || numberOfMonsters == 0) - return null; - let numberMultiplier: number; - if (numberOfMonsters === 1) { - numberMultiplier = 1; - } else if (numberOfMonsters === 2) { - numberMultiplier = 1.5; - } else if (numberOfMonsters < 7) { - numberMultiplier = 2.0; - } else if (numberOfMonsters < 11) { - numberMultiplier = 2.5; - } else if (numberOfMonsters < 15) { - numberMultiplier = 3.0; - } else { - numberMultiplier = 4.0; - } - const adjustedXp = numberMultiplier * xp; - const budget = xpBudget(characterLevels); - let difficulty = "Easy"; - if (adjustedXp >= budget.deadly) { - difficulty = "Deadly"; - } else if (adjustedXp >= budget.hard) { - difficulty = "Hard"; - } else if (adjustedXp >= budget.medium) { - difficulty = "Medium"; - } - let result = { - difficulty: difficulty, - totalXp: xp, - adjustedXp: adjustedXp, - multiplier: numberMultiplier, - budget: budget - }; - return result; -} diff --git a/src/utils/index.ts b/src/utils/index.ts index c2956343..9bfca115 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,8 +1,14 @@ +import type InitiativeTracker from "src/main"; +import type { SRDMonster } from "index"; +import type { Creature } from "./creature"; +import { DECIMAL_TO_VULGAR_FRACTION } from "./constants"; + export * from "./constants"; export * from "./icons"; export * from "./conditions"; +export { getRpgSystem, RpgSystemSetting } from "./rpg-system"; -export const convertFraction = (s: string | number): number => { +export function convertFraction(s: string | number): number { if (typeof s == "number") return s; if (typeof s != "string") return null; if (!s || s == "undefined" || !s.length) return 0; @@ -15,4 +21,29 @@ export const convertFraction = (s: string | number): number => { return Number(s); } return Number(split[0]) / Number(split[1]); -}; +} + +export function crToString(cr: string | number): string { + if (typeof cr == "string") cr = convertFraction(cr); + if (cr == 0) return "0"; + const decimalPart = cr % 1; + const wholePart = Math.floor(cr); + if (decimalPart == 0) return wholePart.toString(); + let str = (wholePart == 0) ? "" : wholePart.toString(); + if (decimalPart in DECIMAL_TO_VULGAR_FRACTION) { + str += DECIMAL_TO_VULGAR_FRACTION[decimalPart]; + } else { + str += decimalPart.toString().slice(1); + } + return str; +} + +export function getFromCreatureOrBestiary( + plugin: InitiativeTracker, + creature: Creature | SRDMonster, + getter: (creature: Creature | SRDMonster | null) => T +): T { + const fromBase = getter(creature); + if (fromBase) return fromBase; + return getter(plugin.bestiary.find(c => c.name == creature.name)); +} \ No newline at end of file diff --git a/src/utils/rpg-system/dnd5e-lazygm.ts b/src/utils/rpg-system/dnd5e-lazygm.ts new file mode 100644 index 00000000..23a5d89d --- /dev/null +++ b/src/utils/rpg-system/dnd5e-lazygm.ts @@ -0,0 +1,89 @@ +import type InitiativeTracker from "src/main"; +import type { DifficultyLevel, GenericCreature, DifficultyThreshold } from "."; +import { + DEFAULT_UNDEFINED, + convertFraction, + crToString, + getFromCreatureOrBestiary +} from "src/utils"; +import { RpgSystem } from "./rpgSystem"; +import { Dnd5eRpgSystem } from "./dnd5e"; + +export class Dnd5eLazyGmRpgSystem extends RpgSystem { + plugin: InitiativeTracker; + dnd5eRpgSystem: Dnd5eRpgSystem; + + constructor(plugin: InitiativeTracker) { + super(); + this.plugin = plugin; + this.valueUnit = "CR"; + this.displayName = "DnD 5e Lazy GM"; + this.dnd5eRpgSystem = new Dnd5eRpgSystem(plugin); + } + + getCreatureDifficulty(creature: GenericCreature, _?: number[]): number { + return convertFraction( + getFromCreatureOrBestiary(this.plugin, creature, (c) => c?.cr ?? 0) + ); + } + + getAdditionalCreatureDifficultyStats( + creature: GenericCreature, + _?: number[] + ): string[] { + const xp = this.dnd5eRpgSystem.getCreatureDifficulty(creature); + return [this.dnd5eRpgSystem.formatDifficultyValue(xp, true)]; + } + + getDifficultyThresholds(playerLevels: number[]): DifficultyThreshold[] { + const totalLevels = playerLevels.reduce((acc, lv) => acc + lv, 0); + const avgLevel = + playerLevels.length > 0 ? totalLevels / playerLevels.length : 0; + return [ + { + displayName: "Deadly", + minValue: totalLevels / (avgLevel > 4 ? 2 : 4) + } + ]; + } + + getEncounterDifficulty( + creatures: Map, + playerLevels: number[] + ): DifficultyLevel { + const crSum = [...creatures].reduce( + (acc, [creature, count]) => + acc + this.getCreatureDifficulty(creature) * count, + 0 + ); + const deadlyThreshold = + this.getDifficultyThresholds(playerLevels).first()?.minValue ?? 0; + const displayName = crSum > deadlyThreshold ? "Deadly" : "Not Deadly"; + const xp = [...creatures].reduce( + (acc, [creature, count]) => + acc + + this.dnd5eRpgSystem.getCreatureDifficulty(creature) * count, + 0 + ); + + const summary = `Encounter is ${displayName} +Total XP: ${xp} +Total CR: ${crSum} +Total levels: ${playerLevels.reduce((acc, lv) => acc + lv, 0)} +Deadly Threshold: ${deadlyThreshold}`; + + return { + displayName, + summary, + cssClass: displayName == "Deadly" ? "deadly" : "easy", + value: crSum, + title: "Total CR", + intermediateValues: [{ label: "Total XP", value: xp }] + }; + } + + formatDifficultyValue(value: number, withUnits?: boolean): string { + if (!value) return DEFAULT_UNDEFINED; + return crToString(value) + (withUnits ? " CR" : ""); + } +} diff --git a/src/utils/rpg-system/dnd5e.ts b/src/utils/rpg-system/dnd5e.ts new file mode 100644 index 00000000..08a210a4 --- /dev/null +++ b/src/utils/rpg-system/dnd5e.ts @@ -0,0 +1,169 @@ +import { RpgSystem } from "./rpgSystem"; +import { crToString, getFromCreatureOrBestiary } from ".."; +import type InitiativeTracker from "src/main"; +import type { DifficultyLevel, GenericCreature, DifficultyThreshold } from "."; + +const XP_THRESHOLDS_PER_LEVEL: { [level: number]: { [threshold: string]: number} } = { + 1: { daily: 300, easy: 25, medium: 50, hard: 75, deadly: 100 }, + 2: { daily: 600, easy: 50, medium: 100, hard: 150, deadly: 200 }, + 3: { daily: 1200, easy: 75, medium: 150, hard: 225, deadly: 400 }, + 4: { daily: 1700, easy: 125, medium: 250, hard: 375, deadly: 500 }, + 5: { daily: 3500, easy: 250, medium: 500, hard: 750, deadly: 1100 }, + 6: { daily: 4000, easy: 300, medium: 600, hard: 900, deadly: 1400 }, + 7: { daily: 5000, easy: 350, medium: 750, hard: 1100, deadly: 1700 }, + 8: { daily: 6000, easy: 450, medium: 900, hard: 1400, deadly: 2100 }, + 9: { daily: 7500, easy: 550, medium: 1100, hard: 1600, deadly: 2400 }, + 10: { daily: 9000, easy: 600, medium: 1200, hard: 1900, deadly: 2800 }, + 11: { daily: 10500, easy: 800, medium: 1600, hard: 2400, deadly: 3600 }, + 12: { daily: 11500, easy: 1000, medium: 2000, hard: 3000, deadly: 4500 }, + 13: { daily: 13500, easy: 1100, medium: 2200, hard: 3400, deadly: 5100 }, + 14: { daily: 15000, easy: 1250, medium: 2500, hard: 3800, deadly: 5700 }, + 15: { daily: 18000, easy: 1400, medium: 2800, hard: 4300, deadly: 6400 }, + 16: { daily: 20000, easy: 1600, medium: 3200, hard: 4800, deadly: 7200 }, + 17: { daily: 25000, easy: 2000, medium: 3900, hard: 5900, deadly: 8800 }, + 18: { daily: 27000, easy: 2100, medium: 4200, hard: 6300, deadly: 9500 }, + 19: { daily: 30000, easy: 2400, medium: 4900, hard: 7300, deadly: 10900 }, + 20: { daily: 40000, easy: 2800, medium: 5700, hard: 8500, deadly: 12700 } +}; + +const XP_PER_CR: Record = { + "0": 0, + "0.125": 25, + "1/8": 25, + "0.25": 50, + "1/4": 50, + "0.5": 100, + "1/2": 100, + "1": 200, + "2": 450, + "3": 700, + "4": 1100, + "5": 1800, + "6": 2300, + "7": 2900, + "8": 3900, + "9": 5000, + "10": 5900, + "11": 7200, + "12": 8400, + "13": 10000, + "14": 11500, + "15": 13000, + "16": 15000, + "17": 18000, + "18": 20000, + "19": 22000, + "20": 25000, + "21": 33000, + "22": 41000, + "23": 50000, + "24": 62000, + "25": 75000, + "26": 90000, + "27": 105000, + "28": 120000, + "29": 135000, + "30": 155000 +}; + +export class Dnd5eRpgSystem extends RpgSystem { + plugin: InitiativeTracker; + + constructor(plugin: InitiativeTracker) { + super(); + this.plugin = plugin; + this.displayName = "DnD 5e"; + } + + getCreatureDifficulty(creature: GenericCreature, _?: number[]): number { + const xp = getFromCreatureOrBestiary(this.plugin, creature, c => c?.xp ?? 0); + if (xp) return xp; + const cr = getFromCreatureOrBestiary(this.plugin, creature, c => c?.cr ?? "0"); + return XP_PER_CR[cr] ?? 0; + } + + getAdditionalCreatureDifficultyStats( + creature: GenericCreature, + _?: number[] + ): string[] { + const cr = getFromCreatureOrBestiary( + this.plugin, creature, c => c?.cr ?? 0); + return [`${crToString(cr)} CR`]; + } + + getEncounterDifficulty( + creatures: Map, + playerLevels: number[] + ) : DifficultyLevel { + const creatureXp = [...creatures].reduce( + (acc, [creature, count]) => acc + this.getCreatureDifficulty(creature) * count, 0); + const creatureCount = [...creatures.values()].reduce((acc, cur) => acc + cur, 0); + const mult = this.#getXpMult(creatureCount); + const adjustedXp = (playerLevels.length == 0 || creatureCount == 0) + ? 0 : creatureXp * mult; + + const thresholds = this.getDifficultyThresholds(playerLevels); + const displayName = thresholds + .reverse() // Should now be in descending order + .find(threshold => adjustedXp >= threshold.minValue)?.displayName + ?? "Trivial"; + + const thresholdSummary = thresholds + .map(threshold => `${threshold.displayName}: ${threshold.minValue}`) + .join("\n"); + + const summary = `Encounter is ${displayName} +Total XP: ${creatureXp} +Adjusted XP: ${adjustedXp} (x${mult}) + +Threshold +${thresholdSummary}`; + + return { + displayName, + summary, + cssClass: displayName.toLowerCase(), + value: adjustedXp, + title: "Adjusted XP", + intermediateValues: [{label: "Total XP", value: creatureXp}], + }; + } + + getDifficultyThresholds(playerLevels: number[]): DifficultyThreshold[] { + const budget: Record = { + easy: 0, + medium: 0, + hard: 0, + deadly: 0, + }; + const clampedLevels = playerLevels.map(lv => Math.max(1, Math.min(lv, 20))); + Object.keys(budget).forEach(key => { + budget[key] += clampedLevels.reduce( + (acc, lv) => acc + XP_THRESHOLDS_PER_LEVEL[lv][key], 0); + }); + return Object.entries(budget) + .map(([name, value]) => ({ + displayName: (name.charAt(0).toUpperCase() + name.slice(1)), + minValue: value + })) + .sort((a, b) => a.minValue - b.minValue); + } + + getAdditionalDifficultyBudgets(playerLevels: number[]): DifficultyThreshold[] { + return [{ + displayName: "Daily Budget", + minValue: playerLevels.reduce( + (acc, lv) => acc + XP_THRESHOLDS_PER_LEVEL[Math.max(1, Math.min(lv, 20))].daily, + 0) + }]; + } + + #getXpMult(creatureCount: number) { + if (creatureCount >= 15) return 4; + if (creatureCount >= 11) return 3; + if (creatureCount >= 7) return 2.5; + if (creatureCount >= 3) return 2; + if (creatureCount >= 2) return 1.5; + return 1; + } +} diff --git a/src/utils/rpg-system/index.ts b/src/utils/rpg-system/index.ts new file mode 100644 index 00000000..09c5d827 --- /dev/null +++ b/src/utils/rpg-system/index.ts @@ -0,0 +1,68 @@ +import type { Creature } from "../creature"; +import type { SRDMonster } from "../../../index"; +import type InitiativeTracker from "../../main"; +import { Dnd5eRpgSystem } from "./dnd5e"; +import { Dnd5eLazyGmRpgSystem } from "./dnd5e-lazygm"; +import { RpgSystem } from "./rpgSystem"; + +export type GenericCreature = Creature | SRDMonster; + +export type DifficultyLevel = { + /** Name of the difficulty level, eg "trivial". Used to display the difficulty level in encounters. */ + displayName: string, + /** The CSS class to apply when formatting the display name. */ + cssClass: string, + /** Associated value for the difficulty level. This should be the value used to calculate the difficulty level. */ + value: number, + /** Title to display for the difficulty value, eg "Total XP". Used to label the difficulty value. */ + title: string, + /** + * A summary of the difficulty of an enounter, including the thresholds involved + * and any intermediate calculation values. + * + * @example + * ``` + * Encounter is Deadly + * Total XP: 400 + * Adjusted XP: 600 (x1.5) + * + * Threshold + * Easy: 100 + * Medium: 200 + * Hard: 300 + * Deadly: 400 + * ``` + */ + summary: string, + /** Any intermediate values involved in the calculation that should be displayed */ + intermediateValues: {label: string, value: number}[] +}; + +export type DifficultyThreshold = { + /** The display name for the budget item, eg "Trivial" */ + displayName: string, + /** The minimum value required to meet this difficulty threshold. */ + minValue: number +} + +export type IntermediateValues = { label: string, value: number }[] + +export enum RpgSystemSetting { + Dnd5e = "dnd5e", + Dnd5eLazyGm = "dnd5e-lazygm" +} + + +class UndefinedRpgSystem extends RpgSystem {} + +/** + * Returns the RpgSystem associated with the settings value. If not provided, + * use the value in the plugin settings. + */ +export function getRpgSystem(plugin: InitiativeTracker, settingId?: string): RpgSystem { + switch (settingId ? settingId : plugin.data.rpgSystem) { + case RpgSystemSetting.Dnd5e: return new Dnd5eRpgSystem(plugin); + case RpgSystemSetting.Dnd5eLazyGm: return new Dnd5eLazyGmRpgSystem(plugin); + } + return new UndefinedRpgSystem(); +} \ No newline at end of file diff --git a/src/utils/rpg-system/rpgSystem.ts b/src/utils/rpg-system/rpgSystem.ts new file mode 100644 index 00000000..bc7f4e3c --- /dev/null +++ b/src/utils/rpg-system/rpgSystem.ts @@ -0,0 +1,62 @@ +import { DEFAULT_UNDEFINED } from "../constants"; +import type { GenericCreature, DifficultyLevel, DifficultyThreshold } from "./index"; + +export abstract class RpgSystem { + /** The display name of the RPG system, used in the UI. */ + displayName: string = DEFAULT_UNDEFINED; + + /** The unit that difficulty values are expressed in, eg "XP". Used for formatting values. */ + valueUnit: string = "XP"; + + /** Returns information related to the difficulty of a creature relative to the given players. */ + getCreatureDifficulty( + creature: GenericCreature, + playerLevels: number[] + ): number { + return 0; + } + + /** Returns additional information related to the difficulty of a creature relative to the given players. */ + getAdditionalCreatureDifficultyStats( + creature: GenericCreature, + playerLevels: number[] + ): string[] { + return []; + } + + /** Returns information related to the difficulty of the encounter relative to the given players. */ + getEncounterDifficulty( + creatures: Map, + playerLevels: number[] + ) : DifficultyLevel { + return { + displayName: DEFAULT_UNDEFINED, + cssClass: "", + value: 0, + title: "Total XP", + summary: DEFAULT_UNDEFINED, + intermediateValues: [] + }; + } + + /** Returns an array of difficulty thresholds in ascending order. */ + getDifficultyThresholds(playerLevels: number[]): DifficultyThreshold[] { + return []; + } + + /** Returns the given difficulty value formatted with system-appropriate units, eg "800 XP". */ + formatDifficultyValue(value: number, withUnits?: boolean): string { + if (isNaN(value)) return DEFAULT_UNDEFINED; + return withUnits + ? `${value.toLocaleString()} ${this.valueUnit}` + : value.toLocaleString(); + } + + /** + * Returns an array of any additional budgets which should be displayed, + * but not taken into account when calculating the difficulty tier. + */ + getAdditionalDifficultyBudgets(playerLevels: number[]): DifficultyThreshold[] { + return [] + } +} \ No newline at end of file