Skip to content

Commit

Permalink
Fix XP macro reward and rating values, localize
Browse files Browse the repository at this point in the history
  • Loading branch information
blmaier authored and stwlam committed Nov 27, 2021
1 parent 45b6fb4 commit ceeccc4
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 38 deletions.
2 changes: 1 addition & 1 deletion packs/data/pf2e-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/**\n * @param actors {Array<Hazard>}\n * @param type {string}\n * @returns {Array<HazardLevel>}\n */\nfunction getHazardLevels(actors) {\n return actors.filter((a) => a.data.type === \"hazard\");\n}\n\n/**\n * @typedef {{data: {data: {details: {level: {value: number|string|undefined|null}}}, type: string}}} Actor\n */\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.data.type === type).map((a) => parseInt(a.data.data.details.level.value ?? \"1\", 10));\n}\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>Party</th>\n <td>PCs: ${xp.partySize} (Lv ${xp.partyLevel})</td>\n </tr>\n <tr>\n <th>Rating</th>\n <td>${xp.rating} (${xp.xpPerPlayer} XP)</td>\n </tr>\n <tr>\n <th>Reward</th>\n <td>${xp.encounterBudgets.moderate} XP</td>\n </tr>\n</table>\n<h2>Budgets</h2>\n<table>\n <tr>\n <th>Trivial</th>\n <td>${xp.encounterBudgets.trivial} XP</td>\n </tr>\n <tr>\n <th>Low</th>\n <td>${xp.encounterBudgets.low} XP</td>\n </tr>\n <tr>\n <th>Moderate</th>\n <td>${xp.encounterBudgets.moderate} XP</td>\n </tr>\n <tr>\n <th>Severe</th>\n <td>${xp.encounterBudgets.severe} XP</td>\n </tr>\n <tr>\n <th>Extreme</th>\n <td>${xp.encounterBudgets.extreme} XP</td>\n </tr>\n</table>`;\n}\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>Party Size</label>\n <input id=\"party-size\" name=\"party-size\" type=\"number\" value=\"${partySize}\">\n </div>\n <div class=\"form-group\">\n <label>Party level</label>\n <input id=\"party-level\" name=\"party-level\" type=\"number\" value=\"${partyLevel}\">\n </div>\n </form>\n `;\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/**\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}\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\n if (pcLevels.length === 0) {\n askPartyLevelAndSize(npcLevels, hazardLevels);\n } else {\n showXP(pcLevels[0], pcLevels.length, npcLevels, hazardLevels);\n }\n}\n\nmain();",
"command": "/**\n * @typedef {{data: {data: {details: {level: number|string|undefined|null, isComplex: boolean}}, type: string}}} Hazard\n */\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.data.type === \"hazard\");\n}\n\n/**\n * @typedef {{data: {data: {details: {level: {value: number|string|undefined|null}}}, type: string}}} Actor\n */\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.data.type === type).map((a) => parseInt(a.data.data.details.level.value ?? \"1\", 10));\n}\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.ratingXP} XP</td>\n </tr>\n</table>\n<h2>${game.i18n.localize(\"PF2E.Encounter.Budget.EncounterBudget\")}</h2>\n<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.Reward\")}</th>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.trivial\")}</td>\n <td>${xp.encounterBudgets.trivial} XP</td>\n <td>40 XP</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.low\")}</td>\n <td>${xp.encounterBudgets.low} XP</td>\n <td>60 XP</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.moderate\")}</td>\n <td>${xp.encounterBudgets.moderate} XP</td>\n <td>80 XP</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.severe\")}</td>\n <td>${xp.encounterBudgets.severe} XP</td>\n <td>120 XP</td>\n </tr>\n <tr>\n <td>${game.i18n.localize(\"PF2E.Encounter.Budget.Threats.extreme\")}</td>\n <td>${xp.encounterBudgets.extreme} XP</td>\n <td>160 XP</td>\n </tr>\n</table>`;\n}\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/**\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/**\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}\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\n if (pcLevels.length === 0) {\n askPartyLevelAndSize(npcLevels, hazardLevels);\n } else {\n showXP(pcLevels[0], pcLevels.length, npcLevels, hazardLevels);\n }\n}\n\nmain();",
"flags": {},
"img": "systems/pf2e/icons/spells/athletic-rush.webp",
"name": "XP",
Expand Down
49 changes: 28 additions & 21 deletions src/scripts/macros/xp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,6 @@ function getHazardXp(partyLevel: number, hazard: HazardLevel, dcOptions: DCOptio
}
}

export type EncounterBudget = "Trivial" | "Low" | "Moderate" | "Severe" | "Extreme";

export interface EncounterBudgets {
trivial: number;
low: number;
Expand All @@ -85,23 +83,37 @@ export interface EncounterBudgets {
extreme: number;
}

function calculateEncounterRating(challenge: number, budgets: EncounterBudgets): EncounterBudget {
if (challenge < budgets.low) {
return "Trivial";
} else if (challenge < budgets.moderate) {
return "Low";
} else if (challenge < budgets.severe) {
return "Moderate";
} else if (challenge < budgets.extreme) {
return "Severe";
function generateEncounterBudgets(partySize: number): EncounterBudgets {
const budget = partySize * 20;
return {
trivial: Math.floor(budget * 0.5),
low: Math.floor(budget * 0.75),
moderate: budget,
severe: Math.floor(budget * 1.5),
extreme: Math.floor(budget * 2),
};
}

const rewardEncounterBudgets = generateEncounterBudgets(4);

function calculateEncounterRating(challenge: number, budgets: EncounterBudgets): keyof EncounterBudgets {
if (challenge <= budgets.trivial) {
return "trivial";
} else if (challenge <= budgets.low) {
return "low";
} else if (challenge <= budgets.moderate) {
return "moderate";
} else if (challenge <= budgets.severe) {
return "severe";
} else {
return "Extreme";
return "extreme";
}
}

interface XP {
encounterBudgets: EncounterBudgets;
rating: EncounterBudget;
rating: keyof EncounterBudgets;
ratingXP: number;
xpPerPlayer: number;
totalXP: number;
partySize: number;
Expand All @@ -115,28 +127,23 @@ export function calculateXP(
hazards: HazardLevel[],
dcOptions: DCOptions
): XP {
const budget = partySize * 20;
const creatureChallenge = npcLevels
.map((level) => calculateCreatureXP(partyLevel, level, dcOptions))
.reduce((a, b) => a + b, 0);
const hazardChallenge = hazards
.map((hazard) => getHazardXp(partyLevel, hazard, dcOptions))
.reduce((a, b) => a + b, 0);
const totalXP = creatureChallenge + hazardChallenge;
const encounterBudgets = {
trivial: Math.floor(budget * 0.5),
low: Math.floor(budget * 0.75),
moderate: budget,
severe: Math.floor(budget * 1.5),
extreme: Math.floor(budget * 2),
};
const encounterBudgets = generateEncounterBudgets(partySize);
const rating = calculateEncounterRating(totalXP, encounterBudgets);
const ratingXP = rewardEncounterBudgets[rating];
return {
partyLevel,
partySize,
totalXP,
encounterBudgets,
rating,
ratingXP,
xpPerPlayer: Math.floor((totalXP / partySize) * 4),
};
}
15 changes: 15 additions & 0 deletions static/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,21 @@
"P1W": "week"
},
"Encounter": {
"Budget": {
"EncounterBudget": "Encounter Budget",
"PartySize": "Party Size",
"PartyLevel": "Party Level",
"Threat": "Threat",
"Reward": "Reward",
"XPBudget": "XP Budget",
"Threats": {
"trivial": "Trivial",
"low": "Low",
"moderate": "Moderate",
"severe": "Severe",
"extreme": "Extreme"
}
},
"ExcludingFromInitiative": "Excluding {type} {actor} from initiative.",
"HasNoInitiativeScore": "{actor} has no initiative score.",
"NoActiveEncounter": "There is no active encounter.",
Expand Down
42 changes: 28 additions & 14 deletions static/macros/xp.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,39 +33,53 @@ function dialogTemplate(xp) {
<h2>XP</h2>
<table>
<tr>
<th>Party</th>
<td>PCs: ${xp.partySize} (Lv ${xp.partyLevel})</td>
<th>${game.i18n.localize("PF2E.Encounter.Budget.PartySize")}</th>
<td>${xp.partySize}</td>
</tr>
<tr>
<th>Rating</th>
<td>${xp.rating} (${xp.xpPerPlayer} XP)</td>
<th>${game.i18n.localize("PF2E.Encounter.Budget.PartyLevel")}</th>
<td>${xp.partyLevel}</td>
</tr>
<tr>
<th>Reward</th>
<td>${xp.encounterBudgets.moderate} XP</td>
<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.ratingXP} XP</td>
</tr>
</table>
<h2>Budgets</h2>
<h2>${game.i18n.localize("PF2E.Encounter.Budget.EncounterBudget")}</h2>
<table>
<tr>
<th>Trivial</th>
<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.Reward")}</th>
</tr>
<tr>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.trivial")}</td>
<td>${xp.encounterBudgets.trivial} XP</td>
<td>40 XP</td>
</tr>
<tr>
<th>Low</th>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.low")}</td>
<td>${xp.encounterBudgets.low} XP</td>
<td>60 XP</td>
</tr>
<tr>
<th>Moderate</th>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.moderate")}</td>
<td>${xp.encounterBudgets.moderate} XP</td>
<td>80 XP</td>
</tr>
<tr>
<th>Severe</th>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.severe")}</td>
<td>${xp.encounterBudgets.severe} XP</td>
<td>120 XP</td>
</tr>
<tr>
<th>Extreme</th>
<td>${game.i18n.localize("PF2E.Encounter.Budget.Threats.extreme")}</td>
<td>${xp.encounterBudgets.extreme} XP</td>
<td>160 XP</td>
</tr>
</table>`;
}
Expand All @@ -76,11 +90,11 @@ const askLevelPopupTemplate = () => {
return `
<form>
<div class="form-group">
<label>Party Size</label>
<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>Party level</label>
<label>${game.i18n.localize("PF2E.Encounter.Budget.PartyLevel")}</label>
<input id="party-level" name="party-level" type="number" value="${partyLevel}">
</div>
</form>
Expand Down
6 changes: 4 additions & 2 deletions tests/module/xp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ describe("should calculate xp", () => {
severe: 120,
extreme: 160,
},
rating: "Moderate",
rating: "severe",
ratingXP: 120,
xpPerPlayer: 88,
totalXP: 88,
partySize: 4,
Expand Down Expand Up @@ -53,7 +54,8 @@ describe("should calculate xp", () => {
severe: 150,
extreme: 200,
},
rating: "Low",
rating: "moderate",
ratingXP: 80,
xpPerPlayer: 70,
totalXP: 88,
partySize: 5,
Expand Down

0 comments on commit ceeccc4

Please sign in to comment.