Skip to content

Commit

Permalink
Move rest of XP macro to system codebase (foundryvtt#7160)
Browse files Browse the repository at this point in the history
  • Loading branch information
stwlam committed Apr 16, 2023
1 parent f33ed19 commit edb0322
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 19 deletions.
2 changes: 1 addition & 1 deletion packs/data/macros.db/xp.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"_id": "MAHxEeGf31wqv3jp",
"author": "oxKN2HrOvz2sSqlF",
"command": "/**\n * @typedef {{data: {data: {details: {level: number|string|undefined|null, isComplex: boolean}}, type: string}}} Hazard\n */\n/**\n * @param actors {Array<Hazard>}\n * @param type {string}\n * @returns {Array<HazardLevel>}\n */\nfunction getHazardLevels(actors) {\n return actors.filter((a) => a.type === \"hazard\");\n}\n/**\n * @typedef {{data: {data: {details: {level: {value: number|string|undefined|null}}}, type: string}}} Actor\n */\n/**\n * @param actors {Array<Actor>}\n * @param type {string}\n * @returns {Array<number>}\n */\nfunction getLevels(actors, type) {\n return actors.filter((a) => a.type === type).map((a) => a.level ?? 1);\n}\n/**\n * @param xp {XP}\n * @returns {string}\n */\nfunction dialogTemplate(xp) {\n return `\n<h2>XP</h2>\n<table>\n <tr>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.PartySize\")}</th>\n <td>${xp.partySize}</td>\n </tr>\n <tr>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.PartyLevel\")}</th>\n <td>${xp.partyLevel}</td>\n </tr>\n <tr>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.Threat\")}</th>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.\"+xp.rating)} (${xp.totalXP} XP)</td>\n </tr>\n <tr>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.Reward\")}</th>\n <td>${xp.xpPerPlayer} XP</td>\n </tr>\n</table>\n<h2>${game.i18n.localize(\"PF2E.Encounter.Budget.EncounterBudget\")}</h2>\n<table class=\"pf2-table\">\n <tr>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.Threat\")}</th>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.XPBudget\")}</th>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.XPNeeded\")}</th>\n <th>${game.i18n.localize(\"PF2E.Encounter.Budget.Reward\")}</th>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.trivial\")}</td>\n <td>${xp.encounterBudgets.trivial}</td>\n <td>${xp.encounterBudgets.trivial - xp.totalXP}</td>\n <td>40</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.low\")}</td>\n <td>${xp.encounterBudgets.low}</td>\n <td>${xp.encounterBudgets.low - xp.totalXP}</td>\n <td>60</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.moderate\")}</td>\n <td>${xp.encounterBudgets.moderate}</td>\n <td>${xp.encounterBudgets.moderate - xp.totalXP}</td>\n <td>80</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.severe\")}</td>\n <td>${xp.encounterBudgets.severe}</td>\n <td>${xp.encounterBudgets.severe - xp.totalXP}</td>\n <td>120</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.extreme\")}</td>\n <td>${xp.encounterBudgets.extreme}</td>\n <td>${xp.encounterBudgets.extreme - xp.totalXP}</td>\n <td>160</td>\n </tr>\n</table>\n<h2>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureXPAndRole\")}</h2>\n<table class=\"pf2-table\">\n <tr>\n <th>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevel\")}</th>\n <th>XP</th>\n <th>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.SuggestedRole\")}</th>\n </tr>\n <tr>\n <td>${xp.partyLevel - 4}</td>\n <td>10</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-4\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel - 3}</td>\n <td>15</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-3\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel - 2}</td>\n <td>20</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-2\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel - 1}</td>\n <td>30</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-1\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel}</td>\n <td>40</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.0\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel + 1}</td>\n <td>60</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.1\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel + 2}</td>\n <td>80</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.2\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel + 3}</td>\n <td>120</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.3\")}</td>\n </tr>\n <tr>\n <td>${xp.partyLevel + 4}</td>\n <td>160</td>\n <td>${game.i18n.localize(\"PF2E.Encounter.CreatureXPAndRole.CreatureLevels.4\")}</td>\n </tr>\n</table>`;\n}\nconst askLevelPopupTemplate = () => {\n const partySize = parseInt(localStorage.getItem(\"xpMacroPartySize\") ?? 4, 10);\n const partyLevel = parseInt(localStorage.getItem(\"xpMacroPartyLevel\") ?? 1, 10);\n return `\n <form>\n <div class=\"form-group\">\n <label>${game.i18n.localize(\"PF2E.Encounter.Budget.PartySize\")}</label>\n <input id=\"party-size\" name=\"party-size\" type=\"number\" value=\"${partySize}\">\n </div>\n <div class=\"form-group\">\n <label>${game.i18n.localize(\"PF2E.Encounter.Budget.PartyLevel\")}</label>\n <input id=\"party-level\" name=\"party-level\" type=\"number\" value=\"${partyLevel}\">\n </div>\n </form>\n `;\n};\n/**\n * @param partyLevel {number}\n * @param partySize {number}\n * @param npcLevels {Array<number>}\n * @param hazardLevels {Array<HazardLevel>}\n */\nfunction showXP(partyLevel, partySize, npcLevels, hazardLevels) {\n const xp = game.pf2e.gm.calculateXP(partyLevel, partySize, npcLevels, hazardLevels, {\n proficiencyWithoutLevel: game.settings.get(\"pf2e\", \"proficiencyVariant\") === \"ProficiencyWithoutLevel\",\n });\n new Dialog({\n title: \"XP\",\n content: dialogTemplate(xp),\n buttons: {},\n }).render(true);\n}\n/**\n * @param npcLevels {Array<number>}\n * @param hazardLevels {Array<HazardLevel>}\n */\nfunction askPartyLevelAndSize(npcLevels, hazardLevels) {\n new Dialog({\n title: \"Party Information\",\n content: askLevelPopupTemplate,\n buttons: {\n no: {\n icon: '<i class=\"fas fa-times\"></i>',\n label: \"Cancel\",\n },\n yes: {\n icon: '<i class=\"fas fa-calculator\"></i>',\n label: \"Calculate XP\",\n callback: ($html) => {\n const partySize = parseInt($html[0].querySelector('[name=\"party-size\"]').value, 10) ?? 1;\n const partyLevel = parseInt($html[0].querySelector('[name=\"party-level\"]').value, 10) ?? 1;\n // persist for future uses\n localStorage.setItem(\"xpMacroPartySize\", partySize);\n localStorage.setItem(\"xpMacroPartyLevel\", partyLevel);\n showXP(partyLevel, partySize, npcLevels, hazardLevels);\n },\n },\n },\n default: \"yes\",\n }).render(true);\n}\nfunction main() {\n const actors = canvas.tokens.controlled.map((a) => a.actor);\n const npcLevels = getLevels(actors, \"npc\");\n const pcLevels = getLevels(actors, \"character\");\n const hazardLevels = getHazardLevels(actors);\n if (npcLevels.length === 0 && hazardLevels.length === 0) {\n ui.notifications.error(`You must select at least one npc and/or hazard token and optionally all PC tokens`);\n return;\n }\n if (pcLevels.length === 0) {\n askPartyLevelAndSize(npcLevels, hazardLevels);\n } else {\n showXP(pcLevels[0], pcLevels.length, npcLevels, hazardLevels);\n }\n}\nmain();",
"command": "game.pf2e.gm.xpFromEncounter();",
"img": "systems/pf2e/icons/spells/athletic-rush.webp",
"name": "XP",
"ownership": {
Expand Down
4 changes: 3 additions & 1 deletion src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
rollActionMacro,
rollItemMacro,
stealthForSelected,
xpFromEncounter,
} from "@scripts/macros/index.ts";
import { remigrate } from "@scripts/system/remigrate.ts";
import { CheckPF2e } from "@system/check/index.ts";
Expand Down Expand Up @@ -74,10 +75,11 @@ declare global {
rollItemMacro: typeof rollItemMacro;
gm: {
calculateXP: typeof calculateXP;
editPersistent: typeof editPersistent;
launchTravelSheet: typeof launchTravelSheet;
perceptionForSelected: typeof perceptionForSelected;
stealthForSelected: typeof stealthForSelected;
editPersistent: typeof editPersistent;
xpFromEncounter: typeof xpFromEncounter;
};
system: {
moduleArt: ModuleArt;
Expand Down
2 changes: 1 addition & 1 deletion src/scripts/macros/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,5 @@ export { stealthForSelected } from "./stealth-for-selected-tokens.ts";
export { steelYourResolve } from "./steel-your-resolve.ts";
export { launchTravelSheet } from "./travel/index.ts";
export { treatWounds } from "./treat-wounds.ts";
export { calculateXP } from "./xp.ts";
export { calculateXP, xpFromEncounter } from "./xp/index.ts";
export { editPersistent } from "./edit-persistent.ts";
204 changes: 204 additions & 0 deletions src/scripts/macros/xp/dialog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { ActorPF2e, HazardPF2e } from "@actor";
import { htmlQuery } from "@util";
import type { XPCalculation } from "./index.ts";

function getHazards(actors: ActorPF2e[]): HazardPF2e[] {
return actors.filter((a): a is HazardPF2e => a.type === "hazard");
}

function getLevels(actors: ActorPF2e[], type: string): number[] {
return actors.filter((a) => a.type === type).map((a) => a.level);
}

function dialogTemplate(xp: XPCalculation): string {
return `
<h2>XP</h2>
<table>
<tr>
<th>${game.i18n.localize("PF2E.Encounter.Budget.PartySize")}</th>
<td>${xp.partySize}</td>
</tr>
<tr>
<th>${game.i18n.localize("PF2E.Encounter.Budget.PartyLevel")}</th>
<td>${xp.partyLevel}</td>
</tr>
<tr>
<th>${game.i18n.localize("PF2E.Encounter.Budget.Threat")}</th>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats." + xp.rating)} (${xp.totalXP} XP)</td>
</tr>
<tr>
<th>${game.i18n.localize("PF2E.Encounter.Budget.Reward")}</th>
<td>${xp.xpPerPlayer} XP</td>
</tr>
</table>
<h2>${game.i18n.localize("PF2E.Encounter.Budget.EncounterBudget")}</h2>
<table class="pf2-table">
<tr>
<th>${game.i18n.localize("PF2E.Encounter.Budget.Threat")}</th>
<th>${game.i18n.localize("PF2E.Encounter.Budget.XPBudget")}</th>
<th>${game.i18n.localize("PF2E.Encounter.Budget.XPNeeded")}</th>
<th>${game.i18n.localize("PF2E.Encounter.Budget.Reward")}</th>
</tr>
<tr>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.trivial")}</td>
<td>${xp.encounterBudgets.trivial}</td>
<td>${xp.encounterBudgets.trivial - xp.totalXP}</td>
<td>40</td>
</tr>
<tr>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.low")}</td>
<td>${xp.encounterBudgets.low}</td>
<td>${xp.encounterBudgets.low - xp.totalXP}</td>
<td>60</td>
</tr>
<tr>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.moderate")}</td>
<td>${xp.encounterBudgets.moderate}</td>
<td>${xp.encounterBudgets.moderate - xp.totalXP}</td>
<td>80</td>
</tr>
<tr>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.severe")}</td>
<td>${xp.encounterBudgets.severe}</td>
<td>${xp.encounterBudgets.severe - xp.totalXP}</td>
<td>120</td>
</tr>
<tr>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.extreme")}</td>
<td>${xp.encounterBudgets.extreme}</td>
<td>${xp.encounterBudgets.extreme - xp.totalXP}</td>
<td>160</td>
</tr>
</table>
<h2>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureXPAndRole")}</h2>
<table class="pf2-table">
<tr>
<th>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevel")}</th>
<th>XP</th>
<th>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.SuggestedRole")}</th>
</tr>
<tr>
<td>${xp.partyLevel - 4}</td>
<td>10</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-4")}</td>
</tr>
<tr>
<td>${xp.partyLevel - 3}</td>
<td>15</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-3")}</td>
</tr>
<tr>
<td>${xp.partyLevel - 2}</td>
<td>20</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-2")}</td>
</tr>
<tr>
<td>${xp.partyLevel - 1}</td>
<td>30</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.-1")}</td>
</tr>
<tr>
<td>${xp.partyLevel}</td>
<td>40</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.0")}</td>
</tr>
<tr>
<td>${xp.partyLevel + 1}</td>
<td>60</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.1")}</td>
</tr>
<tr>
<td>${xp.partyLevel + 2}</td>
<td>80</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.2")}</td>
</tr>
<tr>
<td>${xp.partyLevel + 3}</td>
<td>120</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.3")}</td>
</tr>
<tr>
<td>${xp.partyLevel + 4}</td>
<td>160</td>
<td>${game.i18n.localize("PF2E.Encounter.CreatureXPAndRole.CreatureLevels.4")}</td>
</tr>
</table>`;
}

const askLevelPopupTemplate = (): string => {
const partySize = Math.trunc(Number(localStorage.getItem("xpMacroPartySize") ?? 4));
const partyLevel = Math.trunc(Number(localStorage.getItem("xpMacroPartyLevel") ?? 1));
return `
<form>
<div class="form-group">
<label>${game.i18n.localize("PF2E.Encounter.Budget.PartySize")}</label>
<input id="party-size" name="party-size" type="number" value="${partySize}">
</div>
<div class="form-group">
<label>${game.i18n.localize("PF2E.Encounter.Budget.PartyLevel")}</label>
<input id="party-level" name="party-level" type="number" value="${partyLevel}">
</div>
</form>
`;
};

function showXP(partyLevel: number, partySize: number, npcLevels: number[], hazards: HazardPF2e[]): void {
const xp = game.pf2e.gm.calculateXP(partyLevel, partySize, npcLevels, hazards, {
proficiencyWithoutLevel: game.settings.get("pf2e", "proficiencyVariant") === "ProficiencyWithoutLevel",
});
new Dialog({
title: "XP",
content: dialogTemplate(xp),
buttons: {},
}).render(true);
}

function askPartyLevelAndSize(npcLevels: number[], hazards: HazardPF2e[]): void {
new Dialog({
title: "Party Information",
content: askLevelPopupTemplate,
buttons: {
no: {
icon: '<i class="fas fa-times"></i>',
label: "Cancel",
},
yes: {
icon: '<i class="fas fa-calculator"></i>',
label: "Calculate XP",
callback: ($html) => {
const html = $html[0];
const partySize = Math.abs(
Math.trunc(Number(htmlQuery<HTMLInputElement>(html, "[name=party-size]")?.value || 1))
);
const partyLevel = Math.abs(
Math.trunc(Number(htmlQuery<HTMLInputElement>(html, "[name=party-level]")?.value || 1))
);

// persist for future uses
localStorage.setItem("xpMacroPartySize", partySize.toString());
localStorage.setItem("xpMacroPartyLevel", partyLevel.toString());
showXP(partyLevel, partySize, npcLevels, hazards);
},
},
},
default: "yes",
}).render(true);
}

function xpFromEncounter(): void {
const actors = canvas.tokens.controlled.flatMap((a) => a.actor ?? []);
const npcLevels = getLevels(actors, "npc");
const pcLevels = getLevels(actors, "character");
const hazards = getHazards(actors);
if (npcLevels.length === 0 && hazards.length === 0) {
ui.notifications.error(`You must select at least one npc and/or hazard token and optionally all PC tokens`);
return;
}
if (pcLevels.length === 0) {
askPartyLevelAndSize(npcLevels, hazards);
} else {
showXP(pcLevels[0], pcLevels.length, npcLevels, hazards);
}
}

export { xpFromEncounter };
26 changes: 15 additions & 11 deletions src/scripts/macros/xp.ts → src/scripts/macros/xp/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { DCOptions } from "@module/dc.ts";

/**
* Rules are implemented as described in https://2e.aonprd.com/Rules.aspx?ID=575
* including the variant rules for proficiency without level https://2e.aonprd.com/Rules.aspx?ID=1371
*/
import { DCOptions } from "@module/dc.ts";

// level without proficiency variant
const xpVariantCreatureDifferences = new Map([
Expand Down Expand Up @@ -65,12 +66,7 @@ function calculateCreatureXP(partyLevel: number, npcLevel: number, dcOptions: DC
}
}

interface HazardLevel {
level: number;
isComplex: boolean;
}

function getHazardXp(partyLevel: number, hazard: HazardLevel, dcOptions: DCOptions): number {
function getHazardXp(partyLevel: number, hazard: HazardBrief, dcOptions: DCOptions): number {
if (hazard.isComplex) {
return calculateCreatureXP(partyLevel, hazard.level, dcOptions);
} else {
Expand Down Expand Up @@ -113,7 +109,7 @@ function calculateEncounterRating(challenge: number, budgets: EncounterBudgets):
}
}

interface XP {
interface XPCalculation {
encounterBudgets: EncounterBudgets;
rating: keyof EncounterBudgets;
ratingXP: number;
Expand All @@ -123,13 +119,18 @@ interface XP {
partyLevel: number;
}

export function calculateXP(
interface HazardBrief {
level: number;
isComplex: boolean;
}

function calculateXP(
partyLevel: number,
partySize: number,
npcLevels: number[],
hazards: HazardLevel[],
hazards: HazardBrief[],
dcOptions: DCOptions
): XP {
): XPCalculation {
const creatureChallenge = npcLevels
.map((level) => calculateCreatureXP(partyLevel, level, dcOptions))
.reduce((a, b) => a + b, 0);
Expand All @@ -150,3 +151,6 @@ export function calculateXP(
xpPerPlayer: Math.floor((totalXP / partySize) * 4),
};
}

export { xpFromEncounter } from "./dialog.ts";
export { XPCalculation, calculateXP };
Loading

0 comments on commit edb0322

Please sign in to comment.