Skip to content

Commit

Permalink
Add ability to use Lazy GM's encounter difficulty system (#192)
Browse files Browse the repository at this point in the history
* 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 <miscoined@google.com>
Co-authored-by: Jeremy Valentine <38669521+valentine195@users.noreply.github.com>
  • Loading branch information
3 people authored Jun 28, 2023
1 parent 8434eba commit 2c39c7b
Show file tree
Hide file tree
Showing 21 changed files with 559 additions and 494 deletions.
10 changes: 1 addition & 9 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface InitiativeTrackerData {
parties: Party[];
defaultParty: string;

rpgSystem: string;
canUseDiceRoll: boolean;
initiative: string;
modifier: string;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
78 changes: 0 additions & 78 deletions src/builder/constants.ts

This file was deleted.

26 changes: 1 addition & 25 deletions src/builder/stores/players.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand All @@ -17,7 +16,7 @@ interface GenericPlayer extends Partial<CreatureState> {
enabled: boolean;
count: number;
}
type CombinedPlayer = Player | GenericPlayer;
export type CombinedPlayer = Player | GenericPlayer;

function createPlayers() {
const store = writable<CombinedPlayer[]>([]);
Expand All @@ -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
),
Expand Down
1 change: 0 additions & 1 deletion src/builder/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type { SRDMonster } from "obsidian-overload";
interface BuilderContext {
plugin: InitiativeTracker;
playerCount: number;
thresholds: ExperienceThreshold;
}
declare module "svelte" {
function setContext<T extends keyof BuilderContext>(
Expand Down
39 changes: 11 additions & 28 deletions src/builder/view/encounter/Creature.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
};
Expand All @@ -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 &&
Expand All @@ -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");
Expand Down Expand Up @@ -121,20 +110,14 @@
/>
{/if}
</div>
{#each rpgSystem.getAdditionalCreatureDifficultyStats(creature, playerLevels) as stat}
<div class="encounter-creature-context">
<span>{stat}</span>
</div>
{/each}
<div class="encounter-creature-context">
<span>
<Nullable str={`${convertedCR(creature.cr)} CR`} />
</span>
</div>
<div class="encounter-creature-context">
<span>
<Nullable
str={`${
creature.xp ??
XP_PER_CR[creature.cr]?.toLocaleString() ??
DEFAULT_UNDEFINED
} XP`}
/>
<Nullable str={rpgSystem.formatDifficultyValue(rpgSystem.getCreatureDifficulty(creature, playerLevels), true)} />
</span>
</div>
<div class="encounter-creature-controls">
Expand Down
100 changes: 32 additions & 68 deletions src/builder/view/party/Experience.svelte
Original file line number Diff line number Diff line change
@@ -1,54 +1,17 @@
<script lang="ts">
import { EXPERIENCE_THRESHOLDS } from "src/builder/constants";
import { encounter } from "../../stores/encounter";
import { players } from "../../stores/players";
import { MODIFIERS_BY_COUNT, MODIFIER_THRESHOLDS } from "../../constants";
import { DEFAULT_UNDEFINED, XP_PER_CR } from "src/utils";
import { RpgSystemSetting, getRpgSystem } from "src/utils";
import { getContext } from "svelte";
import Collapsible from "./Collapsible.svelte";
const plugin = getContext("plugin");
const open = plugin.data.builder.showXP;
const { thresholds, modifier: playerModifier } = players;
$: count = ([...$encounter.values()] ?? []).reduce((a, b) => {
return a + b;
}, 0);
$: index =
MODIFIER_THRESHOLDS.lastIndexOf(
MODIFIER_THRESHOLDS.filter((t) => t <= count).pop()
) + $playerModifier;
$: modifier = MODIFIERS_BY_COUNT[index];
const open = plugin.data.builder.showXP;
const rpgSystem = getRpgSystem(plugin);
$: xp = ([...$encounter.entries()] ?? []).reduce((acc, cur) => {
const [monster, count] = cur;
if (monster.cr && monster.cr in XP_PER_CR) {
acc += XP_PER_CR[monster.cr] * count;
}
return acc;
}, 0);
$: adjXP = xp * modifier;
let difficulty: string;
$: {
if (!adjXP) difficulty = DEFAULT_UNDEFINED;
else {
difficulty = "Trivial";
if (adjXP > $thresholds.Easy) {
difficulty = "Easy";
}
if (adjXP > $thresholds.Medium) {
difficulty = "Medium";
}
if (adjXP > $thresholds.Hard) {
difficulty = "Hard";
}
if (adjXP > $thresholds.Deadly) {
difficulty = "Deadly";
}
}
}
$: playerLevels = $players.filter(p => p.enabled).map(p => p.level)
$: difficulty = rpgSystem.getEncounterDifficulty($encounter, playerLevels)
</script>

<div class="xp-container">
Expand All @@ -57,50 +20,51 @@
on:toggle={() =>
(plugin.data.builder.showXP = !plugin.data.builder.showXP)}
>
<h5 slot="title">Experience</h5>
<h5 slot="title">
Experience
{#if plugin.data.rpgSystem != RpgSystemSetting.Dnd5e}
({rpgSystem.displayName})
{/if}
</h5>
<div slot="content">
<div class="xp">
<div class="encounter-difficulty">
<div class="difficulty container">
<strong class="header">Difficulty</strong>
<span>
{difficulty}
</span>
<span>{difficulty.displayName}</span>
</div>
{#each difficulty.intermediateValues as intermediate}
<div class="adjusted container">
<strong class="header">{intermediate.label}</strong>
<span>{intermediate.value.toLocaleString()}</span>
</div>
{/each}
<div class="total container">
<strong class="header">XP</strong>
<span>
{xp ? xp.toLocaleString() : DEFAULT_UNDEFINED}
</span>
</div>
<div class="adjusted container">
<strong class="header">Adjusted</strong>
<span>
{adjXP ? adjXP.toLocaleString() : DEFAULT_UNDEFINED}
</span>
<strong class="header">{difficulty.title}</strong>
<span>{rpgSystem.formatDifficultyValue(difficulty.value)}</span>
</div>
</div>
<div class="thresholds">
{#each EXPERIENCE_THRESHOLDS as level}
<div
class="experience-threshold {level.toLowerCase()} container"
>
<strong class="experience-name header"
>{level}</strong
>
{#each rpgSystem.getDifficultyThresholds(playerLevels) as budget}
<div class="experience-threshold {budget.displayName.toLowerCase()} container">
<strong class="experience-name header">
{budget.displayName}
</strong>
<span class="experience-amount">
{$thresholds[level].toLocaleString()} XP
{rpgSystem.formatDifficultyValue(budget.minValue, true)}
</span>
</div>
{/each}
</div>
<br />
</div>
<div class="budget">
<h5 class="experience-name">Daily budget</h5>
<span class="experience-amount">
{$thresholds.Daily.toLocaleString()} XP
</span>
{#each rpgSystem.getAdditionalDifficultyBudgets(playerLevels) as budget}
<h5 class="experience-name">{budget.displayName}</h5>
<span class="experience-amount">
{rpgSystem.formatDifficultyValue(budget.minValue, true)}
</span>
{/each}
</div>
</div>
</Collapsible>
Expand Down
Loading

0 comments on commit 2c39c7b

Please sign in to comment.