@jjlmoya/utils-tabletop 1.4.0 → 1.5.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 (123) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +11 -1
  3. package/src/index.ts +1 -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/decision-wheel/audio.ts +78 -0
  7. package/src/tool/decision-wheel/bibliography.astro +16 -0
  8. package/src/tool/decision-wheel/bibliography.ts +8 -0
  9. package/src/tool/decision-wheel/client.ts +226 -0
  10. package/src/tool/decision-wheel/component.astro +48 -0
  11. package/src/tool/decision-wheel/components/SegmentEditor.astro +22 -0
  12. package/src/tool/decision-wheel/components/SpinHistory.astro +17 -0
  13. package/src/tool/decision-wheel/components/SpinResult.astro +13 -0
  14. package/src/tool/decision-wheel/components/WheelCanvas.astro +23 -0
  15. package/src/tool/decision-wheel/decision-wheel.css +632 -0
  16. package/src/tool/decision-wheel/drawing.ts +144 -0
  17. package/src/tool/decision-wheel/entry.ts +48 -0
  18. package/src/tool/decision-wheel/i18n/de.ts +97 -0
  19. package/src/tool/decision-wheel/i18n/en.ts +251 -0
  20. package/src/tool/decision-wheel/i18n/es.ts +93 -0
  21. package/src/tool/decision-wheel/i18n/fr.ts +93 -0
  22. package/src/tool/decision-wheel/i18n/id.ts +93 -0
  23. package/src/tool/decision-wheel/i18n/it.ts +93 -0
  24. package/src/tool/decision-wheel/i18n/ja.ts +93 -0
  25. package/src/tool/decision-wheel/i18n/ko.ts +93 -0
  26. package/src/tool/decision-wheel/i18n/nl.ts +93 -0
  27. package/src/tool/decision-wheel/i18n/pl.ts +93 -0
  28. package/src/tool/decision-wheel/i18n/pt.ts +93 -0
  29. package/src/tool/decision-wheel/i18n/ru.ts +75 -0
  30. package/src/tool/decision-wheel/i18n/sv.ts +93 -0
  31. package/src/tool/decision-wheel/i18n/tr.ts +93 -0
  32. package/src/tool/decision-wheel/i18n/zh.ts +93 -0
  33. package/src/tool/decision-wheel/index.ts +9 -0
  34. package/src/tool/decision-wheel/logic.test.ts +163 -0
  35. package/src/tool/decision-wheel/logic.ts +145 -0
  36. package/src/tool/decision-wheel/seo.astro +16 -0
  37. package/src/tool/decision-wheel/types.ts +14 -0
  38. package/src/tool/decision-wheel/ui.ts +121 -0
  39. package/src/tool/fantasy-runes-translator/bibliography.astro +16 -0
  40. package/src/tool/fantasy-runes-translator/bibliography.ts +16 -0
  41. package/src/tool/fantasy-runes-translator/client.ts +205 -0
  42. package/src/tool/fantasy-runes-translator/component.astro +95 -0
  43. package/src/tool/fantasy-runes-translator/entry.ts +56 -0
  44. package/src/tool/fantasy-runes-translator/fantasy-runes-translator.css +712 -0
  45. package/src/tool/fantasy-runes-translator/i18n/de.ts +139 -0
  46. package/src/tool/fantasy-runes-translator/i18n/en.ts +139 -0
  47. package/src/tool/fantasy-runes-translator/i18n/es.ts +139 -0
  48. package/src/tool/fantasy-runes-translator/i18n/fr.ts +139 -0
  49. package/src/tool/fantasy-runes-translator/i18n/id.ts +139 -0
  50. package/src/tool/fantasy-runes-translator/i18n/it.ts +139 -0
  51. package/src/tool/fantasy-runes-translator/i18n/ja.ts +139 -0
  52. package/src/tool/fantasy-runes-translator/i18n/ko.ts +139 -0
  53. package/src/tool/fantasy-runes-translator/i18n/nl.ts +139 -0
  54. package/src/tool/fantasy-runes-translator/i18n/pl.ts +139 -0
  55. package/src/tool/fantasy-runes-translator/i18n/pt.ts +139 -0
  56. package/src/tool/fantasy-runes-translator/i18n/ru.ts +139 -0
  57. package/src/tool/fantasy-runes-translator/i18n/sv.ts +139 -0
  58. package/src/tool/fantasy-runes-translator/i18n/tr.ts +139 -0
  59. package/src/tool/fantasy-runes-translator/i18n/zh.ts +139 -0
  60. package/src/tool/fantasy-runes-translator/index.ts +9 -0
  61. package/src/tool/fantasy-runes-translator/logic.ts +174 -0
  62. package/src/tool/fantasy-runes-translator/seo.astro +16 -0
  63. package/src/tool/fantasy-runes-translator/types.ts +26 -0
  64. package/src/tool/rpg-initiative-tracker/bibliography.astro +16 -0
  65. package/src/tool/rpg-initiative-tracker/bibliography.ts +12 -0
  66. package/src/tool/rpg-initiative-tracker/client.ts +201 -0
  67. package/src/tool/rpg-initiative-tracker/component.astro +23 -0
  68. package/src/tool/rpg-initiative-tracker/components/AddForm.astro +49 -0
  69. package/src/tool/rpg-initiative-tracker/components/CombatantList.astro +16 -0
  70. package/src/tool/rpg-initiative-tracker/components/TurnControls.astro +28 -0
  71. package/src/tool/rpg-initiative-tracker/entry.ts +50 -0
  72. package/src/tool/rpg-initiative-tracker/i18n/de.ts +266 -0
  73. package/src/tool/rpg-initiative-tracker/i18n/en.ts +263 -0
  74. package/src/tool/rpg-initiative-tracker/i18n/es.ts +264 -0
  75. package/src/tool/rpg-initiative-tracker/i18n/fr.ts +263 -0
  76. package/src/tool/rpg-initiative-tracker/i18n/id.ts +263 -0
  77. package/src/tool/rpg-initiative-tracker/i18n/it.ts +263 -0
  78. package/src/tool/rpg-initiative-tracker/i18n/ja.ts +263 -0
  79. package/src/tool/rpg-initiative-tracker/i18n/ko.ts +263 -0
  80. package/src/tool/rpg-initiative-tracker/i18n/nl.ts +263 -0
  81. package/src/tool/rpg-initiative-tracker/i18n/pl.ts +264 -0
  82. package/src/tool/rpg-initiative-tracker/i18n/pt.ts +263 -0
  83. package/src/tool/rpg-initiative-tracker/i18n/ru.ts +264 -0
  84. package/src/tool/rpg-initiative-tracker/i18n/sv.ts +263 -0
  85. package/src/tool/rpg-initiative-tracker/i18n/tr.ts +263 -0
  86. package/src/tool/rpg-initiative-tracker/i18n/zh.ts +263 -0
  87. package/src/tool/rpg-initiative-tracker/index.ts +9 -0
  88. package/src/tool/rpg-initiative-tracker/logic.test.ts +121 -0
  89. package/src/tool/rpg-initiative-tracker/logic.ts +122 -0
  90. package/src/tool/rpg-initiative-tracker/rpg-initiative-tracker.css +617 -0
  91. package/src/tool/rpg-initiative-tracker/seo.astro +16 -0
  92. package/src/tool/rpg-initiative-tracker/types.ts +15 -0
  93. package/src/tool/score-tracker/bibliography.astro +16 -0
  94. package/src/tool/score-tracker/bibliography.ts +12 -0
  95. package/src/tool/score-tracker/client.ts +248 -0
  96. package/src/tool/score-tracker/component.astro +34 -0
  97. package/src/tool/score-tracker/components/Leaderboard.astro +19 -0
  98. package/src/tool/score-tracker/components/PlayerSetup.astro +32 -0
  99. package/src/tool/score-tracker/components/RoundScoring.astro +22 -0
  100. package/src/tool/score-tracker/components/ScoreHistory.astro +25 -0
  101. package/src/tool/score-tracker/entry.ts +57 -0
  102. package/src/tool/score-tracker/i18n/de.ts +278 -0
  103. package/src/tool/score-tracker/i18n/en.ts +278 -0
  104. package/src/tool/score-tracker/i18n/es.ts +251 -0
  105. package/src/tool/score-tracker/i18n/fr.ts +278 -0
  106. package/src/tool/score-tracker/i18n/id.ts +278 -0
  107. package/src/tool/score-tracker/i18n/it.ts +278 -0
  108. package/src/tool/score-tracker/i18n/ja.ts +278 -0
  109. package/src/tool/score-tracker/i18n/ko.ts +278 -0
  110. package/src/tool/score-tracker/i18n/nl.ts +278 -0
  111. package/src/tool/score-tracker/i18n/pl.ts +278 -0
  112. package/src/tool/score-tracker/i18n/pt.ts +278 -0
  113. package/src/tool/score-tracker/i18n/ru.ts +278 -0
  114. package/src/tool/score-tracker/i18n/sv.ts +278 -0
  115. package/src/tool/score-tracker/i18n/tr.ts +278 -0
  116. package/src/tool/score-tracker/i18n/zh.ts +278 -0
  117. package/src/tool/score-tracker/index.ts +9 -0
  118. package/src/tool/score-tracker/logic.test.ts +83 -0
  119. package/src/tool/score-tracker/logic.ts +122 -0
  120. package/src/tool/score-tracker/score-tracker.css +1029 -0
  121. package/src/tool/score-tracker/seo.astro +16 -0
  122. package/src/tool/score-tracker/types.ts +17 -0
  123. package/src/tools.ts +8 -0
@@ -0,0 +1,263 @@
1
+ import { bibliography } from '../bibliography';
2
+ import type { ToolLocaleContent } from '../../../types';
3
+ import type { InitiativeTrackerUI } from '../entry';
4
+
5
+ const ui: InitiativeTrackerUI = {
6
+ title: '先攻追踪器',
7
+ addCombatant: '添加战斗者',
8
+ removeCombatant: '移除',
9
+ playerLabel: '玩家',
10
+ npcLabel: 'NPC',
11
+ initiativeLabel: '先攻',
12
+ modifierLabel: '调整值',
13
+ namePlaceholder: '角色名称',
14
+ initiativePlaceholder: '先攻',
15
+ modifierPlaceholder: '调整值',
16
+ addButton: '添加',
17
+ startCombat: '开始战斗',
18
+ endCombat: '结束战斗',
19
+ nextTurn: '下一回合',
20
+ prevTurn: '上一回合',
21
+ roundLabel: '轮',
22
+ turnLabel: '回合',
23
+ noCombatants: '暂无战斗者。请添加角色以开始。',
24
+ statusInput: '状态',
25
+ combatStarted: '战斗开始!',
26
+ combatEnded: '战斗结束',
27
+ yourTurn: '回合',
28
+ };
29
+
30
+ export const content: ToolLocaleContent<InitiativeTrackerUI> = {
31
+ slug: 'rpg-initiative-tracker',
32
+ title: 'RPG先攻追踪器: 战斗回合顺序与先攻管理工具',
33
+ description: '追踪任何桌面RPG的先攻顺序。添加战斗者、掷先攻、管理状态效果,以及在战斗中导航回合。',
34
+ ui,
35
+ seo: [
36
+ { type: 'title', text: '先攻在D&D及其他桌面RPG中的运作方式', level: 2 },
37
+ { type: 'paragraph', html: '先攻决定战斗中行动的顺序。每位参与者在战斗开始时进行一次敏捷检定,回合顺序从高到低进行。数字先攻追踪器自动排序、高亮当前行动者、追踪持续中的状态效果,并让全桌都知道下一个是谁的回合。这对于需要管理多种敌人类型和玩家角色的地下城主来说尤其有用。' },
38
+ {
39
+ type: 'stats',
40
+ items: [
41
+ { value: '无限制', label: '支持战斗者数量' },
42
+ { value: '自动排序', label: '按先攻投掷' },
43
+ { value: '实时', label: '回合追踪' },
44
+ ],
45
+ columns: 3,
46
+ },
47
+ { type: 'title', text: 'D&D 5e的标准先攻规则', level: 2 },
48
+ { type: 'paragraph', html: '在D&D第五版中,每位参与者在战斗开始时进行一次敏捷检定。玩家单独投掷。地下城主为每组相同类型的怪物投掷一次。回合顺序从高到低进行,然后回到开头进行下一轮。平局由敏捷值决定,或由DM裁定。数字追踪器自动处理所有这些,一目了然地显示完整的回合顺序。' },
49
+ {
50
+ type: 'table',
51
+ headers: ['功能', '手动追踪', '数字追踪器'],
52
+ rows: [
53
+ ['排序', '先攻变化时重写列表', '即时自动重新排序'],
54
+ ['当前回合', '必须口头或用标记物追踪', '绿色高亮显示'],
55
+ ['状态效果', '在模型旁放置单独便签或骰子', '点击切换每个战斗者的状态'],
56
+ ['轮次计数', '在脑中或草稿纸上计数', '自动显示'],
57
+ ['战斗中添加', '擦除并重写顺序', '插入到正确位置'],
58
+ ],
59
+ },
60
+ {
61
+ type: 'tip',
62
+ title: '在桌面上管理先攻的提示',
63
+ html: '在游戏开始前为所有怪物投掷先攻,以节省战斗中的时间。像震慑或中毒这样的常见状态效果一旦施加就立即添加到战斗者身上。这可以防止在受影响角色回合到来时忘记该状态。如果需要检查本轮早些时候的裁定,请使用上一回合按钮回顾已发生的情况。',
64
+ },
65
+ {
66
+ type: 'title',
67
+ text: '战斗中常用的状态效果',
68
+ level: 3,
69
+ },
70
+ { type: 'paragraph', html: '战斗中追踪状态是最容易被忽视的任务之一。状态效果可以完全改变角色的效能,忘记移除它可能会改变遭遇战的平衡。先攻追踪器让您只需单击即可应用和移除状态,使其保持在战斗者名称旁边可见。' },
71
+ {
72
+ type: 'proscons',
73
+ title: '数字化管理 vs 手动管理状态效果',
74
+ items: [
75
+ {
76
+ pro: '状态直接显示在角色名称旁边,因此没有人会忘记活跃的效果。',
77
+ con: '需要记住在施加和移除状态时切换开关。',
78
+ },
79
+ {
80
+ pro: '同一战斗者可以同时拥有多个活跃状态,并且全部一起显示。',
81
+ con: '追踪器不会自动强制执行规则效果,DM仍需手动应用它们。',
82
+ },
83
+ {
84
+ pro: '可以在战斗中途添加状态,无需停下来在角色卡上书写。',
85
+ con: '默认情况下仅提供预定义的状态标签。',
86
+ },
87
+ ],
88
+ },
89
+ {
90
+ type: 'title',
91
+ text: '替代先攻系统',
92
+ level: 3,
93
+ },
94
+ {
95
+ type: 'comparative',
96
+ items: [
97
+ {
98
+ title: '标准先攻',
99
+ description: '每位战斗者在开始时投掷一次。每轮重复相同的顺序。简单且可预测,但第一次投掷后顺序即固定。适用于大多数团队和遭遇战规模。',
100
+ icon: 'mdi:swap-vertical',
101
+ highlight: true,
102
+ points: ['易于学习和教授', '每轮固定回合顺序', '适用于任何团队规模'],
103
+ },
104
+ {
105
+ title: '分组先攻',
106
+ description: '所有玩家角色一起行动,然后所有敌人一起行动。在旧版和OSR游戏中常见。通过减少单独跟踪来加快战斗速度,但降低了战术细腻度。',
107
+ icon: 'mdi:account-group',
108
+ highlight: false,
109
+ points: ['最快的战斗解决方式', '鼓励团队协作', '个人回合多样性较少'],
110
+ },
111
+ {
112
+ title: '速度因素变体',
113
+ description: '每轮参与者先宣布行动,然后投掷经所选行动调整的先攻。用于可选规则集。产生不可预测的回合顺序,但增加了决策深度。',
114
+ icon: 'mdi:run-fast',
115
+ highlight: false,
116
+ points: ['每轮顺序不同', '行动选择影响时机', '每回合更多战术决策'],
117
+ },
118
+ ],
119
+ columns: 3,
120
+ },
121
+ {
122
+ type: 'title',
123
+ text: '战斗追踪器词汇表',
124
+ level: 3,
125
+ },
126
+ {
127
+ type: 'glossary',
128
+ items: [
129
+ {
130
+ term: '先攻',
131
+ definition: '战斗开始时进行的敏捷检定,用于决定回合顺序。每位参与者投掷并按降序行动。',
132
+ },
133
+ {
134
+ term: '轮',
135
+ definition: '每位战斗者执行一个回合的完整循环。最后一位战斗者行动后,本轮结束,新的一轮从顺序顶部开始。',
136
+ },
137
+ {
138
+ term: '状态效果',
139
+ definition: '临时改变角色能力的条件。例如震慑(无法行动)、中毒(投掷劣势)和目盲(攻击劣势)。',
140
+ },
141
+ {
142
+ term: 'NPC',
143
+ definition: '由地下城主控制的非玩家角色。在先攻追踪中,NPC通常是遭遇战中的敌人或中立生物。',
144
+ },
145
+ {
146
+ term: 'PC',
147
+ definition: '由桌边某位玩家控制的玩家角色。追踪器中PC与NPC区分开,帮助DM识别友方目标。',
148
+ },
149
+ ],
150
+ },
151
+ {
152
+ type: 'diagnostic',
153
+ variant: 'info',
154
+ title: '何时每轮重新投掷先攻',
155
+ icon: 'mdi:information-outline',
156
+ badge: 'DM提示',
157
+ html: '有些DM喜欢每轮重新投掷先攻以增加不可预测性,而大多数DM为了简单而保持相同的顺序。如果新的战斗者在战斗中途加入,投掷其先攻并将其插入当前位置。追踪器会自动处理。对于头目战,考虑给反派一个固定先攻值,使战斗感觉更有条理。',
158
+ },
159
+ ],
160
+ faq: [
161
+ {
162
+ question: '如何将战斗者添加到先攻顺序中?',
163
+ answer: '输入角色名称、先攻投掷值和先攻调整值,然后单击添加。在玩家和NPC类型之间切换,以在视觉上区分列表中的队伍成员和敌人。',
164
+ },
165
+ {
166
+ question: '单击开始战斗会发生什么?',
167
+ answer: '所有战斗者按先攻从高到低排序。回合顺序从顶部开始,轮次计数器从1开始,活跃战斗者以绿色高亮显示。',
168
+ },
169
+ {
170
+ question: '如何在战斗中追踪状态效果?',
171
+ answer: '单击状态标签(震慑、中毒、目盲等)以切换选定战斗者的开启或关闭。活跃状态显示在角色名称旁边。这会在该战斗者回合到来时提醒所有人持续中的效果。',
172
+ },
173
+ {
174
+ question: '如果我刷新页面,战斗数据会保留吗?',
175
+ answer: '会。先攻追踪器将战斗状态保存到浏览器的本地存储中。您的战斗者、回合顺序和状态效果在页面重新加载后仍然保留。单击结束战斗时会清除数据。',
176
+ },
177
+ {
178
+ question: '我可以回到上一回合吗?',
179
+ answer: '可以。上一回合按钮允许您向后导航回合顺序。这对于回顾本轮早些时候发生的情况或纠正错误很有用。',
180
+ },
181
+ {
182
+ question: '我可以在战斗中添加或移除战斗者吗?',
183
+ answer: '可以。新战斗者可以随时添加,并根据其先攻投掷自动插入到正确位置。现有战斗者如果离开遭遇战可以移除。',
184
+ },
185
+ {
186
+ question: '这个工具适用于任何RPG系统吗?',
187
+ answer: '适用。该追踪器适用于任何使用基于先攻的回合顺序的桌面RPG。输入您系统使用的任何先攻值,无论是D&D的d20加敏捷投掷,还是其他游戏的不同机制。',
188
+ },
189
+ ],
190
+ bibliography,
191
+ howTo: [
192
+ {
193
+ name: '在战斗前添加战斗者',
194
+ text: '输入每个角色的名称、先攻投掷值和调整值。将其标记为玩家或NPC。根据遭遇战需要添加尽可能多的战斗者。',
195
+ },
196
+ {
197
+ name: '开始战斗以锁定顺序',
198
+ text: '单击开始战斗,按先攻对所有战斗者进行排序并开始追踪回合。顺序中的第一个战斗者被高亮显示。',
199
+ },
200
+ {
201
+ name: '导航回合并追踪状态',
202
+ text: '使用下一回合和上一回合在顺序中移动。通过单击状态标签应用状态效果,以追踪战斗中的条件。',
203
+ },
204
+ {
205
+ name: '完成后结束战斗',
206
+ text: '单击结束战斗以停止回合追踪。这将清除活跃状态并重置轮次计数器,同时保留战斗者列表以供下次遭遇战使用。',
207
+ },
208
+ ],
209
+ schemas: [
210
+ {
211
+ '@context': 'https://schema.org',
212
+ '@type': 'SoftwareApplication',
213
+ 'name': 'RPG先攻追踪器',
214
+ 'operatingSystem': 'All',
215
+ 'applicationCategory': 'UtilitiesApplication',
216
+ 'browserRequirements': '需要HTML5。需要JavaScript。',
217
+ },
218
+ {
219
+ '@context': 'https://schema.org',
220
+ '@type': 'FAQPage',
221
+ 'mainEntity': [
222
+ {
223
+ '@type': 'Question',
224
+ 'name': '如何添加战斗者?',
225
+ 'acceptedAnswer': {
226
+ '@type': 'Answer',
227
+ 'text': '输入角色名称、先攻投掷值和先攻调整值,然后点击添加。玩家和NPC可以切换以帮助在列表中区分它们。',
228
+ },
229
+ },
230
+ {
231
+ '@type': 'Question',
232
+ 'name': '单击开始战斗会发生什么?',
233
+ 'acceptedAnswer': {
234
+ '@type': 'Answer',
235
+ 'text': '所有战斗者按先攻排序(最高优先)。回合顺序从第一位战斗者开始,轮次计数器从1开始。',
236
+ },
237
+ },
238
+ ],
239
+ },
240
+ {
241
+ '@context': 'https://schema.org',
242
+ '@type': 'HowTo',
243
+ 'name': '如何使用先攻追踪器',
244
+ 'step': [
245
+ {
246
+ '@type': 'HowToStep',
247
+ 'name': '添加战斗者',
248
+ 'text': '输入角色详细信息,包括名称、先攻投掷值和调整值。在添加前在玩家和NPC类型之间切换。',
249
+ },
250
+ {
251
+ '@type': 'HowToStep',
252
+ 'name': '开始战斗',
253
+ 'text': '单击开始战斗,按先攻对所有战斗者进行排序并开始回合顺序追踪。',
254
+ },
255
+ {
256
+ '@type': 'HowToStep',
257
+ 'name': '管理回合',
258
+ 'text': '使用下一回合和上一回合导航回合顺序。活跃战斗者以绿色高亮显示。',
259
+ },
260
+ ],
261
+ },
262
+ ],
263
+ };
@@ -0,0 +1,9 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { initiativeTracker } from './entry';
3
+ export * from './entry';
4
+ export const INITIATIVE_TRACKER_TOOL: ToolDefinition = {
5
+ entry: initiativeTracker,
6
+ Component: () => import('./component.astro'),
7
+ SEOComponent: () => import('./seo.astro'),
8
+ BibliographyComponent: () => import('./bibliography.astro'),
9
+ };
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ createBlankState,
4
+ addCombatant,
5
+ removeCombatant,
6
+ updateInitiative,
7
+ toggleStatus,
8
+ startCombat,
9
+ nextTurn,
10
+ prevTurn,
11
+ endCombat,
12
+ } from './logic';
13
+
14
+ function makeState() {
15
+ let state = createBlankState();
16
+ state = addCombatant(state, 'Hero', 18, { modifier: 4, isPlayer: true });
17
+ state = addCombatant(state, 'Goblin', 12, { modifier: 2, isPlayer: false });
18
+ state = addCombatant(state, 'Dragon', 22, { modifier: 7, isPlayer: false });
19
+ return state;
20
+ }
21
+
22
+ describe('initiative tracker logic', () => {
23
+ it('creates blank state', () => {
24
+ const state = createBlankState();
25
+ expect(state.combatants).toEqual([]);
26
+ expect(state.currentIndex).toBe(0);
27
+ expect(state.round).toBe(0);
28
+ expect(state.isCombatActive).toBe(false);
29
+ });
30
+
31
+ it('adds combatant and sorts by initiative', () => {
32
+ const state = makeState();
33
+ expect(state.combatants).toHaveLength(3);
34
+ expect(state.combatants[0].name).toBe('Dragon');
35
+ expect(state.combatants[1].name).toBe('Hero');
36
+ expect(state.combatants[2].name).toBe('Goblin');
37
+ });
38
+
39
+ it('sorts by modifier when initiatives tie', () => {
40
+ let state = createBlankState();
41
+ state = addCombatant(state, 'A', 10, { modifier: 2, isPlayer: true });
42
+ state = addCombatant(state, 'B', 10, { modifier: 5, isPlayer: true });
43
+ expect(state.combatants[0].name).toBe('B');
44
+ expect(state.combatants[1].name).toBe('A');
45
+ });
46
+
47
+ it('removes combatant', () => {
48
+ let state = makeState();
49
+ const id = state.combatants[0].id;
50
+ state = removeCombatant(state, id);
51
+ expect(state.combatants).toHaveLength(2);
52
+ expect(state.combatants.find(c => c.id === id)).toBeUndefined();
53
+ });
54
+
55
+ it('updates initiative', () => {
56
+ let state = makeState();
57
+ const id = state.combatants[2].id;
58
+ state = updateInitiative(state, id, 25);
59
+ expect(state.combatants[0].id).toBe(id);
60
+ });
61
+
62
+ it('toggles status on combatant', () => {
63
+ let state = makeState();
64
+ const id = state.combatants[0].id;
65
+ state = toggleStatus(state, id, 'Stunned');
66
+ expect(state.combatants[0].statuses).toContain('Stunned');
67
+ state = toggleStatus(state, id, 'Stunned');
68
+ expect(state.combatants[0].statuses).not.toContain('Stunned');
69
+ });
70
+
71
+ it('starts combat', () => {
72
+ const state = startCombat(makeState());
73
+ expect(state.isCombatActive).toBe(true);
74
+ expect(state.round).toBe(1);
75
+ expect(state.currentIndex).toBe(0);
76
+ });
77
+
78
+ it('does not start combat with no combatants', () => {
79
+ const state = startCombat(createBlankState());
80
+ expect(state.isCombatActive).toBe(false);
81
+ });
82
+
83
+ it('advances to next turn', () => {
84
+ let state = startCombat(makeState());
85
+ state = nextTurn(state);
86
+ expect(state.currentIndex).toBe(1);
87
+ expect(state.round).toBe(1);
88
+ });
89
+
90
+ it('wraps to next round', () => {
91
+ let state = startCombat(makeState());
92
+ state = nextTurn(state);
93
+ state = nextTurn(state);
94
+ state = nextTurn(state);
95
+ expect(state.currentIndex).toBe(0);
96
+ expect(state.round).toBe(2);
97
+ });
98
+
99
+ it('goes to previous turn', () => {
100
+ let state = startCombat(makeState());
101
+ state = nextTurn(state);
102
+ state = nextTurn(state);
103
+ state = prevTurn(state);
104
+ expect(state.currentIndex).toBe(1);
105
+ expect(state.round).toBe(1);
106
+ });
107
+
108
+ it('wraps previous turn to last index', () => {
109
+ let state = startCombat(makeState());
110
+ state = prevTurn(state);
111
+ expect(state.currentIndex).toBe(2);
112
+ expect(state.round).toBe(1);
113
+ });
114
+
115
+ it('ends combat', () => {
116
+ let state = startCombat(makeState());
117
+ state = endCombat(state);
118
+ expect(state.isCombatActive).toBe(false);
119
+ expect(state.round).toBe(0);
120
+ });
121
+ });
@@ -0,0 +1,122 @@
1
+ import type { Combatant, InitiativeState } from './types';
2
+
3
+ export function createBlankState(): InitiativeState {
4
+ return {
5
+ combatants: [],
6
+ currentIndex: 0,
7
+ round: 0,
8
+ isCombatActive: false,
9
+ };
10
+ }
11
+
12
+ function sortByInitiative(combatants: Combatant[]): Combatant[] {
13
+ return [...combatants].sort((a, b) => {
14
+ if (b.initiative !== a.initiative) return b.initiative - a.initiative;
15
+ return b.modifier - a.modifier;
16
+ });
17
+ }
18
+
19
+ function generateId(): string {
20
+ return 'cbt_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6);
21
+ }
22
+
23
+ export function addCombatant(
24
+ state: InitiativeState,
25
+ name: string,
26
+ initiative: number,
27
+ opts: { modifier: number; isPlayer: boolean }
28
+ ): InitiativeState {
29
+ const combatant: Combatant = {
30
+ id: generateId(),
31
+ name,
32
+ initiative,
33
+ modifier: opts.modifier,
34
+ isPlayer: opts.isPlayer,
35
+ statuses: [],
36
+ };
37
+ const combatants = sortByInitiative([...state.combatants, combatant]);
38
+ return { ...state, combatants };
39
+ }
40
+
41
+ export function removeCombatant(
42
+ state: InitiativeState,
43
+ combatantId: string
44
+ ): InitiativeState {
45
+ const combatants = state.combatants.filter(c => c.id !== combatantId);
46
+ let currentIndex = state.currentIndex;
47
+ if (state.isCombatActive && currentIndex >= combatants.length) {
48
+ currentIndex = Math.max(0, combatants.length - 1);
49
+ }
50
+ return { ...state, combatants, currentIndex };
51
+ }
52
+
53
+ export function updateInitiative(
54
+ state: InitiativeState,
55
+ combatantId: string,
56
+ initiative: number
57
+ ): InitiativeState {
58
+ const combatants = state.combatants.map(c =>
59
+ c.id === combatantId ? { ...c, initiative } : c
60
+ );
61
+ return { ...state, combatants: sortByInitiative(combatants) };
62
+ }
63
+
64
+ export function toggleStatus(
65
+ state: InitiativeState,
66
+ combatantId: string,
67
+ status: string
68
+ ): InitiativeState {
69
+ const combatants = state.combatants.map(c => {
70
+ if (c.id !== combatantId) return c;
71
+ const has = c.statuses.includes(status);
72
+ return {
73
+ ...c,
74
+ statuses: has
75
+ ? c.statuses.filter(s => s !== status)
76
+ : [...c.statuses, status],
77
+ };
78
+ });
79
+ return { ...state, combatants };
80
+ }
81
+
82
+ export function startCombat(state: InitiativeState): InitiativeState {
83
+ const combatants = sortByInitiative(state.combatants);
84
+ return {
85
+ ...state,
86
+ combatants,
87
+ currentIndex: 0,
88
+ round: 1,
89
+ isCombatActive: combatants.length > 0,
90
+ };
91
+ }
92
+
93
+ export function nextTurn(state: InitiativeState): InitiativeState {
94
+ if (!state.isCombatActive || state.combatants.length === 0) return state;
95
+ let nextIndex = state.currentIndex + 1;
96
+ let round = state.round;
97
+ if (nextIndex >= state.combatants.length) {
98
+ nextIndex = 0;
99
+ round += 1;
100
+ }
101
+ return { ...state, currentIndex: nextIndex, round };
102
+ }
103
+
104
+ export function prevTurn(state: InitiativeState): InitiativeState {
105
+ if (!state.isCombatActive || state.combatants.length === 0) return state;
106
+ let prevIndex = state.currentIndex - 1;
107
+ let round = state.round;
108
+ if (prevIndex < 0) {
109
+ prevIndex = state.combatants.length - 1;
110
+ round = Math.max(1, round - 1);
111
+ }
112
+ return { ...state, currentIndex: prevIndex, round };
113
+ }
114
+
115
+ export function endCombat(state: InitiativeState): InitiativeState {
116
+ return {
117
+ ...state,
118
+ currentIndex: 0,
119
+ round: 0,
120
+ isCombatActive: false,
121
+ };
122
+ }