@jjlmoya/utils-tabletop 1.24.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.
- package/package.json +1 -1
- package/src/category/index.ts +2 -0
- package/src/entries.ts +2 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/dungeon-map-generator/component.astro +1 -0
- package/src/tool/dungeon-map-generator/controller.ts +1 -0
- package/src/tool/dungeon-map-generator/dom-views.ts +19 -14
- package/src/tool/dungeon-map-generator/i18n/en.ts +3 -2
- package/src/tool/dungeon-map-generator/i18n/es.ts +1 -0
- package/src/tool/dungeon-map-generator/logic.ts +42 -16
- package/src/tool/dungeon-map-generator/ui.ts +1 -0
- package/src/tool/encounter-difficulty-calculator/bibliography.astro +6 -0
- package/src/tool/encounter-difficulty-calculator/bibliography.ts +12 -0
- package/src/tool/encounter-difficulty-calculator/component.astro +53 -0
- package/src/tool/encounter-difficulty-calculator/controller.ts +133 -0
- package/src/tool/encounter-difficulty-calculator/dnd-5e-encounter-difficulty-calculator.css +389 -0
- package/src/tool/encounter-difficulty-calculator/dom-views.ts +54 -0
- package/src/tool/encounter-difficulty-calculator/entry.ts +27 -0
- package/src/tool/encounter-difficulty-calculator/evaluator.ts +12 -0
- package/src/tool/encounter-difficulty-calculator/i18n/de.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/en.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/es.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/fr.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/id.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/it.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/ja.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/ko.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/nl.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/pl.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/pt.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/ru.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/sv.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/tr.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/i18n/zh.ts +155 -0
- package/src/tool/encounter-difficulty-calculator/index.ts +11 -0
- package/src/tool/encounter-difficulty-calculator/logic.test.ts +37 -0
- package/src/tool/encounter-difficulty-calculator/logic.ts +174 -0
- package/src/tool/encounter-difficulty-calculator/seo.astro +16 -0
- package/src/tool/encounter-difficulty-calculator/storage.ts +26 -0
- package/src/tool/encounter-difficulty-calculator/ui.ts +45 -0
- package/src/tools.ts +2 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { EncounterDifficultyLocaleContent, EncounterDifficultyUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: EncounterDifficultyUI = {
|
|
6
|
+
intro: 'Grubu ve tehdidi ayarlayın. Hesaplayıcı karşılaşmayı D&D 5e 2014 eşikleriyle karşılaştırır.',
|
|
7
|
+
partySection: 'Grup',
|
|
8
|
+
partyLevel: 'Karakter seviyesi',
|
|
9
|
+
partyLevelHint: 'Benzer seviyedeki bir grup için ortalama seviye kullanın.',
|
|
10
|
+
partySize: 'Karakterler',
|
|
11
|
+
partySizeHint: 'Kurallar çok küçük veya büyük gruplar için canavar çarpanını ayarlar.',
|
|
12
|
+
threatSection: 'Tehdit',
|
|
13
|
+
monsterCr: 'Canavar meydan okuma derecesi (CR)',
|
|
14
|
+
monsterCrHint: 'Aynı tür canavarlar için bir CR seçin. Karışık gruplar ayrı hesaplama gerektirir.',
|
|
15
|
+
moreCr: 'Daha yüksek CR göster',
|
|
16
|
+
lessCr: 'Daha az CR göster',
|
|
17
|
+
monsterCount: 'Canavar sayısı',
|
|
18
|
+
monsterCountHint: 'Daha fazla yaratık, temel XP mütevazı olsa bile eylem baskısını artırır.',
|
|
19
|
+
presets: 'Bir sahne ile başlayın',
|
|
20
|
+
presetClassic: 'Klasik devriye',
|
|
21
|
+
presetBoss: 'Yalnız patron',
|
|
22
|
+
presetSwarm: 'Minyon sürüsü',
|
|
23
|
+
resultSection: 'Karşılaşma baskısı',
|
|
24
|
+
belowEasyHint: 'Gelecek zorluklar için kaynakları koruyan hafif bir sahne.',
|
|
25
|
+
easyHint: 'Grup üzerinde az baskı oluşturan yönetilebilir bir sahne.',
|
|
26
|
+
mediumHint: 'Can puanı veya kaynak maliyeti çıkarabilecek anlamlı bir test.',
|
|
27
|
+
hardHint: 'Taktik ve kaynak seçimlerinin önem taşıdığı tehlikeli bir sahne.',
|
|
28
|
+
deadlyHint: 'Ölümcül baskı sinyali. Kaçış yollarını ve araziyi kontrol edin.',
|
|
29
|
+
adjustedXp: 'Ayarlanmış XP',
|
|
30
|
+
baseXp: 'Temel XP',
|
|
31
|
+
multiplier: 'Grup çarpanı',
|
|
32
|
+
partyThreshold: 'Orta eşik',
|
|
33
|
+
belowEasy: 'Kolay altı',
|
|
34
|
+
easy: 'Kolay',
|
|
35
|
+
medium: 'Orta',
|
|
36
|
+
hard: 'Zor',
|
|
37
|
+
deadly: 'Ölümcül',
|
|
38
|
+
warning: 'Dikkat',
|
|
39
|
+
partyAdjustment: 'Grup üçten az veya beşten fazla karakterden oluştuğu için çarpan ayarlandı.',
|
|
40
|
+
highCr: 'Grup seviyesinden yüksek CR değerine sahip bir canavar bir karakteri hızlıca düşürebilir.',
|
|
41
|
+
manyMonsters: 'On bir veya daha fazla canavar savaşı yönetmeyi zorlaştırır.',
|
|
42
|
+
rulesNote: 'D&D 5e 2014 kurallarına göre tahmindir. Arazi veya taktikleri hesaba katmaz.',
|
|
43
|
+
rulesLinkLabel: 'Kaynak kuralları oku',
|
|
44
|
+
reset: 'Örneğe sıfırla',
|
|
45
|
+
xpUnit: 'XP',
|
|
46
|
+
sceneLabel: 'Savaş baskısı görsel göstergesi',
|
|
47
|
+
partyMarker: 'Grup',
|
|
48
|
+
threatMarker: 'Tehdit',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const faq = [
|
|
52
|
+
{
|
|
53
|
+
question: 'Bu karşılaşma zorluğu hesaplayıcısı hangi kuralları kullanır?',
|
|
54
|
+
answer: 'Temel Kurallar daki resmi D&D 5e 2014 yöntemini kullanır. Kolay, orta, zor ve ölümcül eşikleri ayarlanmış XP ile karşılaştırır.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
question: 'Ayarlanmış XP neden canavarın ödül XP sinden farklıdır?',
|
|
58
|
+
answer: 'Kurallar, aynı turda hareket eden birden fazla yaratığın tehlikesini yansıtmak için toplam XP yi bir çarpanla çarpar.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: 'Bunu karışık canavar grupları için kullanabilir miyim?',
|
|
62
|
+
answer: 'Aynı tür canavarlar için hızlı bir tahmin olarak kullanın. Karışık gruplarda her yaratığın XP sini toplayıp çarpanı uygulayın.',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: 'Ölümcül sonuç grubun öleceği anlamına mı gelir?',
|
|
66
|
+
answer: 'Hayır. Ölümcül, ayarlanmış XP nin ölümcül eşiğe ulaştığını gösterir. Arazi, taktikler ve büyüler sonucu değiştirebilir.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: 'Grup büyüklüğü çarpanı neden değiştirir?',
|
|
70
|
+
answer: 'Temel Kurallar, üçten az karaktere sahip gruplar için çarpanı artırmayı, altı veya daha fazla karakter için azaltmayı önerir.',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const howTo = [
|
|
75
|
+
{
|
|
76
|
+
name: 'Grup seviyesini ayarlayın',
|
|
77
|
+
text: 'Gruptaki karakterlerin ortalama seviyesini seçin.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: 'Grup büyüklüğünü belirleyin',
|
|
81
|
+
text: 'Savaşa katılacak karakter sayısını girin.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'Tehdidi tanımlayın',
|
|
85
|
+
text: 'Meydan okuma derecesini (CR) ve canavar sayısını seçin.',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'Baskıyı inceleyin',
|
|
89
|
+
text: 'Ayarlanmış XP yi resmi zorluk eşikleriyle karşılaştırın.',
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
94
|
+
'@context': 'https://schema.org',
|
|
95
|
+
'@type': 'FAQPage',
|
|
96
|
+
mainEntity: faq.map((item) => ({
|
|
97
|
+
'@type': 'Question',
|
|
98
|
+
name: item.question,
|
|
99
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
104
|
+
'@context': 'https://schema.org',
|
|
105
|
+
'@type': 'SoftwareApplication',
|
|
106
|
+
name: 'D&D 5e Karşılaşma Zorluğu Hesaplayıcı',
|
|
107
|
+
operatingSystem: 'All',
|
|
108
|
+
applicationCategory: 'GameApplication',
|
|
109
|
+
description: 'Seviye, grup büyüklüğü, canavar CR, sayı ve resmi eşiklere göre D&D 5e 2014 karşılaşma zorluğunu hesaplayın.',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const howToSchema: WithContext<HowTo> = {
|
|
113
|
+
'@context': 'https://schema.org',
|
|
114
|
+
'@type': 'HowTo',
|
|
115
|
+
name: 'D&D 5e karşılaşma zorluğu nasıl hesaplanır',
|
|
116
|
+
step: howTo.map((item) => ({
|
|
117
|
+
'@type': 'HowToStep',
|
|
118
|
+
name: item.name,
|
|
119
|
+
text: item.text,
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const content: EncounterDifficultyLocaleContent = {
|
|
124
|
+
slug: 'dnd-5e-karsilasma-zorlugu-hesaplayici',
|
|
125
|
+
title: 'D&D 5e Karşılaşma Zorluğu Hesaplayıcı',
|
|
126
|
+
description: 'Grup seviyesi, büyüklük, canavar CR, ayarlanmış XP ve resmi eşikler ile D&D 5e 2014 karşılaşma baskısını tahmin edin.',
|
|
127
|
+
ui,
|
|
128
|
+
seo: [
|
|
129
|
+
{ type: 'title', text: 'İnisiyatif zarı atılmadan önce savaş zorluğunu değerlendirin', level: 2 },
|
|
130
|
+
{ type: 'paragraph', html: 'Bir D&D karşılaşması bir canavarın yanında yazan sayıdan ibaret değildir. Bu hesaplayıcı grup büyüklüğü, seviye, CR ve canavar sayısını D&D 5e 2014 kurallarındaki ayarlanmış XP değerine dönüştürür. Görsel sonuç, karşılaşmanın kolay, orta, zor ve ölümcül eşiklerine göre nerede durduğunu net bir şekilde gösterir.' },
|
|
131
|
+
{ type: 'title', text: 'D&D 5e karşılaşma formülü nasıl çalışır', level: 2 },
|
|
132
|
+
{ type: 'paragraph', html: 'Yöntem her zorluk seviyesi için her karakterin XP eşiğini toplar ve ardından toplam canavar XP sine çarpan uygular. Üçten az karaktere sahip gruplar bir üst çarpanı kullanırken, altı veya daha fazla karaktere sahip gruplar bir alt çarpanı kullanır.' },
|
|
133
|
+
{
|
|
134
|
+
type: 'table',
|
|
135
|
+
headers: ['Sinyal', 'Masada ne kontrol edilmeli'],
|
|
136
|
+
rows: [
|
|
137
|
+
['Kolay altı', 'Karşılaşma bir ısınma veya hafif kaynak harcama sahnesidir.'],
|
|
138
|
+
['Kolay', 'Grup genellikle fazla kaynak harcamadan kazanır.'],
|
|
139
|
+
['Orta', 'Belirli bir baskı ve kaynak kullanımı konusunda anlamlı seçimler bekleyin.'],
|
|
140
|
+
['Zor', 'Can puanı ve büyü yuvası kaybı planlayın.'],
|
|
141
|
+
['Ölümcül', 'Taktikleri, araziyi ve kaçış yollarını kontrol edin.'],
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{ type: 'title', text: 'Canavar sayısı neden önemlidir', level: 2 },
|
|
145
|
+
{ type: 'paragraph', html: 'Birden fazla canavar aynı turda daha fazla saldırı ve reaksiyon yapabildiği için temel XP lerinin gösterdiğinden daha büyük tehlike yaratır. Bu nedenle bir çift yaratık, aynı birleşik XP ye sahip tek bir yaratıktan daha yüksek bir çarpan kullanır.' },
|
|
146
|
+
{ type: 'tip', title: 'Yüksek CR değerini özel bir uyarı olarak görün', html: 'Grup seviyesinin üzerinde CR ye sahip bir canavar bir karakteri tek bir güçlü eylemle saf dışı bırakabilir. Yalnızca zorluk etiketine güvenmek yerine hasarı ve kontrol etkilerini doğrudan inceleyin.' },
|
|
147
|
+
{ type: 'title', text: 'Sonucu bir hazırlık aracı olarak kullanın', level: 2 },
|
|
148
|
+
{ type: 'paragraph', html: 'Zor veya ölümcül bir karşılaşma hazırlamadan önce ortamı, sürprizi ve grubun genel durumunu değerlendirin. Dar odalar, siperler, tuzaklar ve konsantrasyon büyüleri masadaki gerçek tehlikeyi önemli ölçüde değiştirebilir.' },
|
|
149
|
+
{ type: 'tip', title: 'Karışık canavar grupları', html: 'Her yaratığın temel XP sini toplayın ve toplam canavar sayısına karşılık gelen çarpanı uygulayın.' },
|
|
150
|
+
],
|
|
151
|
+
faq,
|
|
152
|
+
bibliography,
|
|
153
|
+
howTo,
|
|
154
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
155
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication, WithContext } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { EncounterDifficultyLocaleContent, EncounterDifficultyUI } from '../entry';
|
|
4
|
+
|
|
5
|
+
const ui: EncounterDifficultyUI = {
|
|
6
|
+
intro: '设置队伍与威胁。计算器将根据 D&D 5e 2014 的官方阈值评估遭遇战的压力。',
|
|
7
|
+
partySection: '队伍设置',
|
|
8
|
+
partyLevel: '角色等级',
|
|
9
|
+
partyLevelHint: '对于等级相近的队伍,请使用平均等级。',
|
|
10
|
+
partySize: '角色人数',
|
|
11
|
+
partySizeHint: '规则会对过小或过大的队伍调整怪物乘数。',
|
|
12
|
+
threatSection: '威胁设置',
|
|
13
|
+
monsterCr: '怪物挑战等级 (CR)',
|
|
14
|
+
monsterCrHint: '选择同种怪物的 CR。混合怪物组合需要单独计算。',
|
|
15
|
+
moreCr: '显示更高 CR',
|
|
16
|
+
lessCr: '显示更少 CR',
|
|
17
|
+
monsterCount: '怪物数量',
|
|
18
|
+
monsterCountHint: '即便基础 XP 较低,较多的怪物也会显著增加行动经济压力。',
|
|
19
|
+
presets: '从预设场景开始',
|
|
20
|
+
presetClassic: '经典巡逻队',
|
|
21
|
+
presetBoss: '单体 Boss',
|
|
22
|
+
presetSwarm: '杂兵蜂拥',
|
|
23
|
+
resultSection: '遭遇战压力评估',
|
|
24
|
+
belowEasyHint: '轻松场景,可保留资源用于后续挑战。',
|
|
25
|
+
easyHint: '可控场景,对队伍压力较小。',
|
|
26
|
+
mediumHint: '有意义的考验,可能会消耗生命值或关键资源。',
|
|
27
|
+
hardHint: '危险场景,战术与资源选择至关重要。',
|
|
28
|
+
deadlyHint: '致命压力信号!请检查撤退路线、地形及失误成本。',
|
|
29
|
+
adjustedXp: '调整后 XP',
|
|
30
|
+
baseXp: '基础 XP',
|
|
31
|
+
multiplier: '队伍乘数',
|
|
32
|
+
partyThreshold: '中等阈值',
|
|
33
|
+
belowEasy: '低于简单',
|
|
34
|
+
easy: '简单',
|
|
35
|
+
medium: '中等',
|
|
36
|
+
hard: '困难',
|
|
37
|
+
deadly: '致命',
|
|
38
|
+
warning: '注意事项',
|
|
39
|
+
partyAdjustment: '由于队伍人数少于3人或多于5人,怪物乘数已作相应调整。',
|
|
40
|
+
highCr: 'CR 高于队伍等级的怪物可能在单次强力行动中击倒角色。',
|
|
41
|
+
manyMonsters: '11 个或更多怪物会让战斗主持变得复杂。',
|
|
42
|
+
rulesNote: '基于 D&D 5e 2014 规则的估算,未包含地形或战术因素。',
|
|
43
|
+
rulesLinkLabel: '阅读源规则',
|
|
44
|
+
reset: '重置为示例',
|
|
45
|
+
xpUnit: 'XP',
|
|
46
|
+
sceneLabel: '战斗压力可视化',
|
|
47
|
+
partyMarker: '队伍',
|
|
48
|
+
threatMarker: '威胁',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const faq = [
|
|
52
|
+
{
|
|
53
|
+
question: '本遭遇难度计算器使用什么规则?',
|
|
54
|
+
answer: '它使用基础规则中 D&D 5e 2014 的官方计算公式,将队伍阈值与怪物的调整后 XP 进行对比。',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
question: '为什么调整后 XP 与怪物奖励 XP 不同?',
|
|
58
|
+
answer: '规则对怪物总 XP 进行乘数放大,以反映多个生物在同一轮行动带来的额外危险。',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
question: '能否用于计算混合怪物组合?',
|
|
62
|
+
answer: '适合作为同种怪物的快速估算。对于混合组合,请累加每个生物的基础 XP 后再乘以对应乘数。',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
question: '致命结果是否意味着队伍必然团灭?',
|
|
66
|
+
answer: '并非如此。致命代表调整后 XP 达到了致命阈值。地形、战术和法术使用都会改变实际结果。',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
question: '为什么队伍人数会改变乘数?',
|
|
70
|
+
answer: '官方规则建议对少于 3 人的队伍提高乘数,对 6 人及以上的队伍降低乘数。',
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const howTo = [
|
|
75
|
+
{
|
|
76
|
+
name: '设置队伍等级',
|
|
77
|
+
text: '选择队伍中角色的平均等级。',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
name: '设置队伍人数',
|
|
81
|
+
text: '输入参战角色的数量。',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: '描述威胁',
|
|
85
|
+
text: '选择怪物的挑战等级 (CR) 和数量。',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: '评估压力',
|
|
89
|
+
text: '对比调整后 XP 与官方难度阈值区间。',
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const faqSchema: WithContext<FAQPage> = {
|
|
94
|
+
'@context': 'https://schema.org',
|
|
95
|
+
'@type': 'FAQPage',
|
|
96
|
+
mainEntity: faq.map((item) => ({
|
|
97
|
+
'@type': 'Question',
|
|
98
|
+
name: item.question,
|
|
99
|
+
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
100
|
+
})),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const appSchema: WithContext<SoftwareApplication> = {
|
|
104
|
+
'@context': 'https://schema.org',
|
|
105
|
+
'@type': 'SoftwareApplication',
|
|
106
|
+
name: 'D&D 5e 遭遇难度计算器',
|
|
107
|
+
operatingSystem: 'All',
|
|
108
|
+
applicationCategory: 'GameApplication',
|
|
109
|
+
description: '根据等级、队伍人数、怪物 CR、数量及官方阈值计算 D&D 5e 2014 的遭遇战难度。',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const howToSchema: WithContext<HowTo> = {
|
|
113
|
+
'@context': 'https://schema.org',
|
|
114
|
+
'@type': 'HowTo',
|
|
115
|
+
name: '如何计算 D&D 5e 遭遇战难度',
|
|
116
|
+
step: howTo.map((item) => ({
|
|
117
|
+
'@type': 'HowToStep',
|
|
118
|
+
name: item.name,
|
|
119
|
+
text: item.text,
|
|
120
|
+
})),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const content: EncounterDifficultyLocaleContent = {
|
|
124
|
+
slug: 'dnd-5e-encounter-difficulty-calculator',
|
|
125
|
+
title: 'D&D 5e 遭遇难度计算器',
|
|
126
|
+
description: '借助队伍等级、人数、怪物 CR、调整后 XP 与官方阈值估算 D&D 5e 2014 的战斗压力。',
|
|
127
|
+
ui,
|
|
128
|
+
seo: [
|
|
129
|
+
{ type: 'title', text: '在投掷先攻前评估战斗难度', level: 2 },
|
|
130
|
+
{ type: 'paragraph', html: 'D&D 遭遇战远不止怪物卡上的数值。本计算器将队伍人数、等级、CR 和怪物数量转化为 D&D 5e 2014 规则的调整后 XP。可视化展示可以清晰展现遭遇战相对于简单、中等、困难和致命阈值所处的位置与危险程度。' },
|
|
131
|
+
{ type: 'title', text: 'D&D 5e 遭遇计算公式原理', level: 2 },
|
|
132
|
+
{ type: 'paragraph', html: '该方法首先汇总各难度等级下的角色 XP 阈值,然后对怪物基础 XP 总和乘以相应的数量乘数。少于 3 人的队伍使用高一阶的乘数,而 6 人及以上的队伍则使用低一阶的乘数进行平衡调整。' },
|
|
133
|
+
{
|
|
134
|
+
type: 'table',
|
|
135
|
+
headers: ['信号', '案头检查要点'],
|
|
136
|
+
rows: [
|
|
137
|
+
['低于简单', '遭遇战可作为热身或轻微资源消耗。'],
|
|
138
|
+
['简单', '队伍通常无需消耗过多资源即可获胜。'],
|
|
139
|
+
['中等', '预计会有适度压力以及至少一次关键资源决策。'],
|
|
140
|
+
['困难', '请预估生命值与法术位的损失。'],
|
|
141
|
+
['致命', '检查战术、地形及撤退路线。'],
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
{ type: 'title', text: '为什么怪物数量至关重要', level: 2 },
|
|
145
|
+
{ type: 'paragraph', html: '多个怪物在同轮中拥有更多攻击与反应机会,因此带来的危险远超其基础 XP 所示。这就是为什么一对怪物使用的乘数要高于具有相同合并 XP 的单只怪物的原因所在。' },
|
|
146
|
+
{ type: 'tip', title: '将高 CR 视作特定预警', html: 'CR 高于队伍等级的怪物可能凭借单次强力行动直接击倒角色。请仔细检查其伤害能力与控制效果,而不要仅仅依赖最终的难度标签或文字说明。' },
|
|
147
|
+
{ type: 'title', text: '将计算结果作为备战辅助工具', level: 2 },
|
|
148
|
+
{ type: 'paragraph', html: '在安排困难或致命遭遇前,请评估环境、突袭及队伍的整体状态。狭窄的空间、遮蔽物、环境伤害和专注法术都可能极大地改变桌面上的实际危险程度与战斗走向。' },
|
|
149
|
+
{ type: 'tip', title: '混合怪物组合', html: '累加每个生物的基础 XP,并对怪物总数施加对应的乘数进行评估。' },
|
|
150
|
+
],
|
|
151
|
+
faq,
|
|
152
|
+
bibliography,
|
|
153
|
+
howTo,
|
|
154
|
+
schemas: [faqSchema, appSchema, howToSchema] as unknown as Record<string, unknown>[],
|
|
155
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { encounterDifficultyCalculator } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
|
|
6
|
+
export const ENCOUNTER_DIFFICULTY_CALCULATOR_TOOL: ToolDefinition = {
|
|
7
|
+
entry: encounterDifficultyCalculator,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -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
|
|