@jjlmoya/utils-tabletop 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +2 -0
  4. package/src/tests/locale_completeness.test.ts +1 -1
  5. package/src/tests/tool_validation.test.ts +1 -1
  6. package/src/tool/encounter-difficulty-calculator/bibliography.astro +6 -0
  7. package/src/tool/encounter-difficulty-calculator/bibliography.ts +12 -0
  8. package/src/tool/encounter-difficulty-calculator/component.astro +53 -0
  9. package/src/tool/encounter-difficulty-calculator/controller.ts +133 -0
  10. package/src/tool/encounter-difficulty-calculator/dnd-5e-encounter-difficulty-calculator.css +389 -0
  11. package/src/tool/encounter-difficulty-calculator/dom-views.ts +54 -0
  12. package/src/tool/encounter-difficulty-calculator/entry.ts +27 -0
  13. package/src/tool/encounter-difficulty-calculator/evaluator.ts +12 -0
  14. package/src/tool/encounter-difficulty-calculator/i18n/de.ts +155 -0
  15. package/src/tool/encounter-difficulty-calculator/i18n/en.ts +155 -0
  16. package/src/tool/encounter-difficulty-calculator/i18n/es.ts +155 -0
  17. package/src/tool/encounter-difficulty-calculator/i18n/fr.ts +155 -0
  18. package/src/tool/encounter-difficulty-calculator/i18n/id.ts +155 -0
  19. package/src/tool/encounter-difficulty-calculator/i18n/it.ts +155 -0
  20. package/src/tool/encounter-difficulty-calculator/i18n/ja.ts +155 -0
  21. package/src/tool/encounter-difficulty-calculator/i18n/ko.ts +155 -0
  22. package/src/tool/encounter-difficulty-calculator/i18n/nl.ts +155 -0
  23. package/src/tool/encounter-difficulty-calculator/i18n/pl.ts +155 -0
  24. package/src/tool/encounter-difficulty-calculator/i18n/pt.ts +155 -0
  25. package/src/tool/encounter-difficulty-calculator/i18n/ru.ts +155 -0
  26. package/src/tool/encounter-difficulty-calculator/i18n/sv.ts +155 -0
  27. package/src/tool/encounter-difficulty-calculator/i18n/tr.ts +155 -0
  28. package/src/tool/encounter-difficulty-calculator/i18n/zh.ts +155 -0
  29. package/src/tool/encounter-difficulty-calculator/index.ts +11 -0
  30. package/src/tool/encounter-difficulty-calculator/logic.test.ts +37 -0
  31. package/src/tool/encounter-difficulty-calculator/logic.ts +174 -0
  32. package/src/tool/encounter-difficulty-calculator/seo.astro +16 -0
  33. package/src/tool/encounter-difficulty-calculator/storage.ts +26 -0
  34. package/src/tool/encounter-difficulty-calculator/ui.ts +45 -0
  35. package/src/tools.ts +2 -0
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateEncounter } from './logic';
3
+
4
+ describe('calculateEncounter', () => {
5
+ it('calculates a medium encounter for a level five party', () => {
6
+ const result = calculateEncounter({ partyLevel: 5, partySize: 4, monsterCr: 3, monsterCount: 2 });
7
+ expect(result.monsterXp).toBe(700);
8
+ expect(result.adjustedXp).toBe(2100);
9
+ expect(result.difficulty).toBe('medium');
10
+ });
11
+
12
+ it('applies the multiple monster multiplier', () => {
13
+ const result = calculateEncounter({ partyLevel: 3, partySize: 4, monsterCr: 1, monsterCount: 4 });
14
+ expect(result.baseXp).toBe(800);
15
+ expect(result.multiplier).toBe(2);
16
+ expect(result.adjustedXp).toBe(1600);
17
+ expect(result.difficulty).toBe('deadly');
18
+ });
19
+
20
+ it('raises the multiplier for a small party', () => {
21
+ const result = calculateEncounter({ partyLevel: 5, partySize: 2, monsterCr: 2, monsterCount: 2 });
22
+ expect(result.multiplier).toBe(2);
23
+ expect(result.warnings).toContain('partyAdjustment');
24
+ });
25
+
26
+ it('lowers the multiplier for a large party', () => {
27
+ const result = calculateEncounter({ partyLevel: 5, partySize: 6, monsterCr: 2, monsterCount: 6 });
28
+ expect(result.multiplier).toBe(1.5);
29
+ expect(result.warnings).toContain('partyAdjustment');
30
+ });
31
+
32
+ it('clamps unsafe inputs to the supported range', () => {
33
+ const result = calculateEncounter({ partyLevel: 99, partySize: 99, monsterCr: 99, monsterCount: 99 });
34
+ expect(result.settings).toEqual({ partyLevel: 20, partySize: 8, monsterCr: 30, monsterCount: 15 });
35
+ expect(result.difficulty).toBe('deadly');
36
+ });
37
+ });
@@ -0,0 +1,174 @@
1
+ export type EncounterDifficulty = 'belowEasy' | 'easy' | 'medium' | 'hard' | 'deadly';
2
+
3
+ export interface EncounterSettings {
4
+ partyLevel: number;
5
+ partySize: number;
6
+ monsterCr: number;
7
+ monsterCount: number;
8
+ }
9
+
10
+ export interface EncounterThresholds {
11
+ easy: number;
12
+ medium: number;
13
+ hard: number;
14
+ deadly: number;
15
+ }
16
+
17
+ export interface EncounterResult {
18
+ settings: EncounterSettings;
19
+ monsterXp: number;
20
+ baseXp: number;
21
+ multiplier: number;
22
+ adjustedXp: number;
23
+ thresholds: EncounterThresholds;
24
+ difficulty: EncounterDifficulty;
25
+ crLabel: string;
26
+ warnings: string[];
27
+ }
28
+
29
+ interface CrEntry {
30
+ value: number;
31
+ label: string;
32
+ xp: number;
33
+ }
34
+
35
+ const CR_ENTRIES: CrEntry[] = [
36
+ { value: 0, label: '0', xp: 10 },
37
+ { value: 0.125, label: '1/8', xp: 25 },
38
+ { value: 0.25, label: '1/4', xp: 50 },
39
+ { value: 0.5, label: '1/2', xp: 100 },
40
+ { value: 1, label: '1', xp: 200 },
41
+ { value: 2, label: '2', xp: 450 },
42
+ { value: 3, label: '3', xp: 700 },
43
+ { value: 4, label: '4', xp: 1100 },
44
+ { value: 5, label: '5', xp: 1800 },
45
+ { value: 6, label: '6', xp: 2300 },
46
+ { value: 7, label: '7', xp: 2900 },
47
+ { value: 8, label: '8', xp: 3900 },
48
+ { value: 9, label: '9', xp: 5000 },
49
+ { value: 10, label: '10', xp: 5900 },
50
+ { value: 11, label: '11', xp: 7200 },
51
+ { value: 12, label: '12', xp: 8400 },
52
+ { value: 13, label: '13', xp: 10000 },
53
+ { value: 14, label: '14', xp: 11500 },
54
+ { value: 15, label: '15', xp: 13000 },
55
+ { value: 16, label: '16', xp: 15000 },
56
+ { value: 17, label: '17', xp: 18000 },
57
+ { value: 18, label: '18', xp: 20000 },
58
+ { value: 19, label: '19', xp: 22000 },
59
+ { value: 20, label: '20', xp: 25000 },
60
+ { value: 21, label: '21', xp: 33000 },
61
+ { value: 22, label: '22', xp: 41000 },
62
+ { value: 23, label: '23', xp: 50000 },
63
+ { value: 24, label: '24', xp: 62000 },
64
+ { value: 25, label: '25', xp: 75000 },
65
+ { value: 26, label: '26', xp: 90000 },
66
+ { value: 27, label: '27', xp: 105000 },
67
+ { value: 28, label: '28', xp: 120000 },
68
+ { value: 29, label: '29', xp: 135000 },
69
+ { value: 30, label: '30', xp: 155000 },
70
+ ];
71
+
72
+ const THRESHOLDS = [
73
+ [25, 50, 75, 100], [50, 100, 150, 200], [75, 150, 225, 400],
74
+ [125, 250, 375, 500], [250, 500, 750, 1100], [300, 600, 900, 1400],
75
+ [350, 750, 1100, 1700], [450, 900, 1400, 2100], [550, 1100, 1600, 2400],
76
+ [600, 1200, 1900, 2800], [800, 1600, 2400, 3600], [1000, 2000, 3000, 4500],
77
+ [1100, 2200, 3400, 5100], [1250, 2500, 3800, 5700], [1400, 2800, 4300, 6400],
78
+ [1600, 3200, 4800, 7200], [2000, 3900, 5900, 8800], [2100, 4200, 6300, 9500],
79
+ [2400, 4900, 7300, 10900], [2800, 5700, 8500, 12700],
80
+ ] as const;
81
+
82
+ const MULTIPLIERS = [1, 1.5, 2, 2.5, 3, 4];
83
+
84
+ export const DEFAULT_SETTINGS: EncounterSettings = {
85
+ partyLevel: 5,
86
+ partySize: 4,
87
+ monsterCr: 2,
88
+ monsterCount: 2,
89
+ };
90
+
91
+ export const CR_OPTIONS = CR_ENTRIES;
92
+ export const QUICK_CR_VALUES = [0, 0.125, 0.25, 0.5, 1, 2, 3, 4, 5, 8, 10, 15];
93
+
94
+ function clamp(value: number, min: number, max: number): number {
95
+ return Math.min(Math.max(value, min), max);
96
+ }
97
+
98
+ function findCrEntry(cr: number): CrEntry {
99
+ return CR_ENTRIES.reduce((closest, entry) => (
100
+ Math.abs(entry.value - cr) < Math.abs(closest.value - cr) ? entry : closest
101
+ ));
102
+ }
103
+
104
+ function getBaseMultiplier(monsterCount: number): number {
105
+ if (monsterCount === 1) return 1;
106
+ if (monsterCount === 2) return 1.5;
107
+ if (monsterCount <= 6) return 2;
108
+ if (monsterCount <= 10) return 2.5;
109
+ if (monsterCount <= 14) return 3;
110
+ return 4;
111
+ }
112
+
113
+ function getPartyAdjustment(partySize: number): number {
114
+ if (partySize < 3) return 1;
115
+ if (partySize > 5) return -1;
116
+ return 0;
117
+ }
118
+
119
+ function getMultiplier(monsterCount: number, partySize: number): number {
120
+ const baseIndex = MULTIPLIERS.indexOf(getBaseMultiplier(monsterCount));
121
+ const partyAdjustment = getPartyAdjustment(partySize);
122
+ return MULTIPLIERS[clamp(baseIndex + partyAdjustment, 0, MULTIPLIERS.length - 1)];
123
+ }
124
+
125
+ function getThresholds(partyLevel: number, partySize: number): EncounterThresholds {
126
+ const row = THRESHOLDS[clamp(partyLevel, 1, 20) - 1];
127
+ return {
128
+ easy: row[0] * partySize,
129
+ medium: row[1] * partySize,
130
+ hard: row[2] * partySize,
131
+ deadly: row[3] * partySize,
132
+ };
133
+ }
134
+
135
+ function getDifficulty(adjustedXp: number, thresholds: EncounterThresholds): EncounterDifficulty {
136
+ if (adjustedXp < thresholds.easy) return 'belowEasy';
137
+ if (adjustedXp < thresholds.medium) return 'easy';
138
+ if (adjustedXp < thresholds.hard) return 'medium';
139
+ if (adjustedXp < thresholds.deadly) return 'hard';
140
+ return 'deadly';
141
+ }
142
+
143
+ function getWarnings(settings: EncounterSettings): string[] {
144
+ const warnings: string[] = [];
145
+ if (settings.partySize < 3 || settings.partySize > 5) warnings.push('partyAdjustment');
146
+ if (settings.monsterCr > settings.partyLevel) warnings.push('highCr');
147
+ if (settings.monsterCount >= 11) warnings.push('manyMonsters');
148
+ return warnings;
149
+ }
150
+
151
+ export function calculateEncounter(rawSettings: EncounterSettings): EncounterResult {
152
+ const settings: EncounterSettings = {
153
+ partyLevel: clamp(Math.round(rawSettings.partyLevel), 1, 20),
154
+ partySize: clamp(Math.round(rawSettings.partySize), 1, 8),
155
+ monsterCr: findCrEntry(rawSettings.monsterCr).value,
156
+ monsterCount: clamp(Math.round(rawSettings.monsterCount), 1, 15),
157
+ };
158
+ const crEntry = findCrEntry(settings.monsterCr);
159
+ const thresholds = getThresholds(settings.partyLevel, settings.partySize);
160
+ const baseXp = crEntry.xp * settings.monsterCount;
161
+ const multiplier = getMultiplier(settings.monsterCount, settings.partySize);
162
+ const adjustedXp = Math.round(baseXp * multiplier);
163
+ return {
164
+ settings,
165
+ monsterXp: crEntry.xp,
166
+ baseXp,
167
+ multiplier,
168
+ adjustedXp,
169
+ thresholds,
170
+ difficulty: getDifficulty(adjustedXp, thresholds),
171
+ crLabel: crEntry.label,
172
+ warnings: getWarnings(settings),
173
+ };
174
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { encounterDifficultyCalculator } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const loader = encounterDifficultyCalculator.i18n[locale] || encounterDifficultyCalculator.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,26 @@
1
+ import type { EncounterSettings } from './logic';
2
+
3
+ const STORAGE_KEY = 'jjlmoya-encounter-difficulty-settings-v1';
4
+
5
+ function isSettings(value: unknown): value is EncounterSettings {
6
+ if (!value || typeof value !== 'object') return false;
7
+ const candidate = value as Record<string, unknown>;
8
+ return ['partyLevel', 'partySize', 'monsterCr', 'monsterCount'].every((key) => typeof candidate[key] === 'number');
9
+ }
10
+
11
+ export function readEncounterSettings(): EncounterSettings | null {
12
+ try {
13
+ const stored = localStorage.getItem(STORAGE_KEY);
14
+ if (!stored) return null;
15
+ const value: unknown = JSON.parse(stored);
16
+ return isSettings(value) ? value : null;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export function writeEncounterSettings(settings: EncounterSettings): void {
23
+ try {
24
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
25
+ } catch {}
26
+ }
@@ -0,0 +1,45 @@
1
+ export interface EncounterDifficultyUI {
2
+ intro: string;
3
+ partySection: string;
4
+ partyLevel: string;
5
+ partyLevelHint: string;
6
+ partySize: string;
7
+ partySizeHint: string;
8
+ threatSection: string;
9
+ monsterCr: string;
10
+ monsterCrHint: string;
11
+ moreCr: string;
12
+ lessCr: string;
13
+ monsterCount: string;
14
+ monsterCountHint: string;
15
+ presets: string;
16
+ presetClassic: string;
17
+ presetBoss: string;
18
+ presetSwarm: string;
19
+ resultSection: string;
20
+ belowEasyHint: string;
21
+ easyHint: string;
22
+ mediumHint: string;
23
+ hardHint: string;
24
+ deadlyHint: string;
25
+ adjustedXp: string;
26
+ baseXp: string;
27
+ multiplier: string;
28
+ partyThreshold: string;
29
+ belowEasy: string;
30
+ easy: string;
31
+ medium: string;
32
+ hard: string;
33
+ deadly: string;
34
+ warning: string;
35
+ partyAdjustment: string;
36
+ highCr: string;
37
+ manyMonsters: string;
38
+ rulesNote: string;
39
+ rulesLinkLabel: string;
40
+ reset: string;
41
+ xpUnit: string;
42
+ sceneLabel: string;
43
+ partyMarker: string;
44
+ threatMarker: string;
45
+ }
package/src/tools.ts CHANGED
@@ -12,6 +12,7 @@ import { LUNAR_TIDE_TRACKER_TOOL } from './tool/lunar-tide-tracker';
12
12
  import { HIDDEN_ROLE_DEALER_TOOL } from './tool/hidden-role-dealer';
13
13
  import { SCATTER_DIRECTION_SELECTOR_TOOL } from './tool/scatter-direction-selector';
14
14
  import { DUNGEON_MAP_GENERATOR_TOOL } from './tool/dungeon-map-generator';
15
+ import { ENCOUNTER_DIFFICULTY_CALCULATOR_TOOL } from './tool/encounter-difficulty-calculator';
15
16
 
16
17
  export const ALL_TOOLS: ToolDefinition[] = [
17
18
  DICE_ROLLER_SIMULATOR_TOOL,
@@ -26,5 +27,6 @@ export const ALL_TOOLS: ToolDefinition[] = [
26
27
  HIDDEN_ROLE_DEALER_TOOL,
27
28
  SCATTER_DIRECTION_SELECTOR_TOOL,
28
29
  DUNGEON_MAP_GENERATOR_TOOL,
30
+ ENCOUNTER_DIFFICULTY_CALCULATOR_TOOL,
29
31
  ];
30
32