@jjlmoya/utils-tabletop 1.3.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 (171) hide show
  1. package/package.json +1 -1
  2. package/src/category/TabletopCategorySEO.astro +2 -7
  3. package/src/category/i18n/en.ts +4 -4
  4. package/src/category/i18n/ru.ts +2 -2
  5. package/src/category/i18n/zh.ts +8 -8
  6. package/src/category/index.ts +2 -1
  7. package/src/entries.ts +14 -1
  8. package/src/index.ts +1 -0
  9. package/src/pages/[locale]/[slug].astro +7 -6
  10. package/src/tests/locale_completeness.test.ts +1 -1
  11. package/src/tests/no_em_dash.test.ts +47 -0
  12. package/src/tests/seo_length.test.ts +1 -0
  13. package/src/tests/title_quality.test.ts +1 -1
  14. package/src/tests/tool_validation.test.ts +1 -1
  15. package/src/tool/board-game-timer/modules/ColorHelper.ts +2 -2
  16. package/src/tool/board-game-timer/modules/DuelViewManager.ts +4 -4
  17. package/src/tool/board-game-timer/modules/GameRunner.ts +1 -1
  18. package/src/tool/board-game-timer/modules/MultiplayerViewManager.ts +2 -2
  19. package/src/tool/board-game-timer/modules/SetupPanelManager.ts +1 -1
  20. package/src/tool/board-game-timer/modules/TimerEngine.ts +1 -1
  21. package/src/tool/board-game-timer/types.ts +1 -1
  22. package/src/tool/decision-wheel/audio.ts +78 -0
  23. package/src/tool/decision-wheel/bibliography.astro +16 -0
  24. package/src/tool/decision-wheel/bibliography.ts +8 -0
  25. package/src/tool/decision-wheel/client.ts +226 -0
  26. package/src/tool/decision-wheel/component.astro +48 -0
  27. package/src/tool/decision-wheel/components/SegmentEditor.astro +22 -0
  28. package/src/tool/decision-wheel/components/SpinHistory.astro +17 -0
  29. package/src/tool/decision-wheel/components/SpinResult.astro +13 -0
  30. package/src/tool/decision-wheel/components/WheelCanvas.astro +23 -0
  31. package/src/tool/decision-wheel/decision-wheel.css +632 -0
  32. package/src/tool/decision-wheel/drawing.ts +144 -0
  33. package/src/tool/decision-wheel/entry.ts +48 -0
  34. package/src/tool/decision-wheel/i18n/de.ts +97 -0
  35. package/src/tool/decision-wheel/i18n/en.ts +251 -0
  36. package/src/tool/decision-wheel/i18n/es.ts +93 -0
  37. package/src/tool/decision-wheel/i18n/fr.ts +93 -0
  38. package/src/tool/decision-wheel/i18n/id.ts +93 -0
  39. package/src/tool/decision-wheel/i18n/it.ts +93 -0
  40. package/src/tool/decision-wheel/i18n/ja.ts +93 -0
  41. package/src/tool/decision-wheel/i18n/ko.ts +93 -0
  42. package/src/tool/decision-wheel/i18n/nl.ts +93 -0
  43. package/src/tool/decision-wheel/i18n/pl.ts +93 -0
  44. package/src/tool/decision-wheel/i18n/pt.ts +93 -0
  45. package/src/tool/decision-wheel/i18n/ru.ts +75 -0
  46. package/src/tool/decision-wheel/i18n/sv.ts +93 -0
  47. package/src/tool/decision-wheel/i18n/tr.ts +93 -0
  48. package/src/tool/decision-wheel/i18n/zh.ts +93 -0
  49. package/src/tool/decision-wheel/index.ts +9 -0
  50. package/src/tool/decision-wheel/logic.test.ts +163 -0
  51. package/src/tool/decision-wheel/logic.ts +145 -0
  52. package/src/tool/decision-wheel/seo.astro +16 -0
  53. package/src/tool/decision-wheel/types.ts +14 -0
  54. package/src/tool/decision-wheel/ui.ts +121 -0
  55. package/src/tool/dice-roller-simulator/client.ts +2 -2
  56. package/src/tool/dice-roller-simulator/i18n/ru.ts +8 -8
  57. package/src/tool/dice-roller-simulator/modules/history.ts +1 -1
  58. package/src/tool/fantasy-runes-translator/bibliography.astro +16 -0
  59. package/src/tool/fantasy-runes-translator/bibliography.ts +16 -0
  60. package/src/tool/fantasy-runes-translator/client.ts +205 -0
  61. package/src/tool/fantasy-runes-translator/component.astro +95 -0
  62. package/src/tool/fantasy-runes-translator/entry.ts +56 -0
  63. package/src/tool/fantasy-runes-translator/fantasy-runes-translator.css +712 -0
  64. package/src/tool/fantasy-runes-translator/i18n/de.ts +139 -0
  65. package/src/tool/fantasy-runes-translator/i18n/en.ts +139 -0
  66. package/src/tool/fantasy-runes-translator/i18n/es.ts +139 -0
  67. package/src/tool/fantasy-runes-translator/i18n/fr.ts +139 -0
  68. package/src/tool/fantasy-runes-translator/i18n/id.ts +139 -0
  69. package/src/tool/fantasy-runes-translator/i18n/it.ts +139 -0
  70. package/src/tool/fantasy-runes-translator/i18n/ja.ts +139 -0
  71. package/src/tool/fantasy-runes-translator/i18n/ko.ts +139 -0
  72. package/src/tool/fantasy-runes-translator/i18n/nl.ts +139 -0
  73. package/src/tool/fantasy-runes-translator/i18n/pl.ts +139 -0
  74. package/src/tool/fantasy-runes-translator/i18n/pt.ts +139 -0
  75. package/src/tool/fantasy-runes-translator/i18n/ru.ts +139 -0
  76. package/src/tool/fantasy-runes-translator/i18n/sv.ts +139 -0
  77. package/src/tool/fantasy-runes-translator/i18n/tr.ts +139 -0
  78. package/src/tool/fantasy-runes-translator/i18n/zh.ts +139 -0
  79. package/src/tool/fantasy-runes-translator/index.ts +9 -0
  80. package/src/tool/fantasy-runes-translator/logic.ts +174 -0
  81. package/src/tool/fantasy-runes-translator/seo.astro +16 -0
  82. package/src/tool/fantasy-runes-translator/types.ts +26 -0
  83. package/src/tool/first-player-selector/bibliography.astro +16 -0
  84. package/src/tool/first-player-selector/bibliography.ts +12 -0
  85. package/src/tool/first-player-selector/client.ts +197 -0
  86. package/src/tool/first-player-selector/component.astro +77 -0
  87. package/src/tool/first-player-selector/entry.ts +42 -0
  88. package/src/tool/first-player-selector/first-player-selector.css +303 -0
  89. package/src/tool/first-player-selector/i18n/de.ts +226 -0
  90. package/src/tool/first-player-selector/i18n/en.ts +226 -0
  91. package/src/tool/first-player-selector/i18n/es.ts +226 -0
  92. package/src/tool/first-player-selector/i18n/fr.ts +227 -0
  93. package/src/tool/first-player-selector/i18n/id.ts +226 -0
  94. package/src/tool/first-player-selector/i18n/it.ts +227 -0
  95. package/src/tool/first-player-selector/i18n/ja.ts +227 -0
  96. package/src/tool/first-player-selector/i18n/ko.ts +227 -0
  97. package/src/tool/first-player-selector/i18n/nl.ts +226 -0
  98. package/src/tool/first-player-selector/i18n/pl.ts +227 -0
  99. package/src/tool/first-player-selector/i18n/pt.ts +226 -0
  100. package/src/tool/first-player-selector/i18n/ru.ts +227 -0
  101. package/src/tool/first-player-selector/i18n/sv.ts +226 -0
  102. package/src/tool/first-player-selector/i18n/tr.ts +226 -0
  103. package/src/tool/first-player-selector/i18n/zh.ts +227 -0
  104. package/src/tool/first-player-selector/index.ts +9 -0
  105. package/src/tool/first-player-selector/modules/AnimationEffects.ts +256 -0
  106. package/src/tool/first-player-selector/modules/SelectionEngine.ts +249 -0
  107. package/src/tool/first-player-selector/modules/SoundEffects.ts +87 -0
  108. package/src/tool/first-player-selector/modules/TouchTracker.ts +148 -0
  109. package/src/tool/first-player-selector/seo.astro +16 -0
  110. package/src/tool/first-player-selector/types.ts +21 -0
  111. package/src/tool/rpg-initiative-tracker/bibliography.astro +16 -0
  112. package/src/tool/rpg-initiative-tracker/bibliography.ts +12 -0
  113. package/src/tool/rpg-initiative-tracker/client.ts +201 -0
  114. package/src/tool/rpg-initiative-tracker/component.astro +23 -0
  115. package/src/tool/rpg-initiative-tracker/components/AddForm.astro +49 -0
  116. package/src/tool/rpg-initiative-tracker/components/CombatantList.astro +16 -0
  117. package/src/tool/rpg-initiative-tracker/components/TurnControls.astro +28 -0
  118. package/src/tool/rpg-initiative-tracker/entry.ts +50 -0
  119. package/src/tool/rpg-initiative-tracker/i18n/de.ts +266 -0
  120. package/src/tool/rpg-initiative-tracker/i18n/en.ts +263 -0
  121. package/src/tool/rpg-initiative-tracker/i18n/es.ts +264 -0
  122. package/src/tool/rpg-initiative-tracker/i18n/fr.ts +263 -0
  123. package/src/tool/rpg-initiative-tracker/i18n/id.ts +263 -0
  124. package/src/tool/rpg-initiative-tracker/i18n/it.ts +263 -0
  125. package/src/tool/rpg-initiative-tracker/i18n/ja.ts +263 -0
  126. package/src/tool/rpg-initiative-tracker/i18n/ko.ts +263 -0
  127. package/src/tool/rpg-initiative-tracker/i18n/nl.ts +263 -0
  128. package/src/tool/rpg-initiative-tracker/i18n/pl.ts +264 -0
  129. package/src/tool/rpg-initiative-tracker/i18n/pt.ts +263 -0
  130. package/src/tool/rpg-initiative-tracker/i18n/ru.ts +264 -0
  131. package/src/tool/rpg-initiative-tracker/i18n/sv.ts +263 -0
  132. package/src/tool/rpg-initiative-tracker/i18n/tr.ts +263 -0
  133. package/src/tool/rpg-initiative-tracker/i18n/zh.ts +263 -0
  134. package/src/tool/rpg-initiative-tracker/index.ts +9 -0
  135. package/src/tool/rpg-initiative-tracker/logic.test.ts +121 -0
  136. package/src/tool/rpg-initiative-tracker/logic.ts +122 -0
  137. package/src/tool/rpg-initiative-tracker/rpg-initiative-tracker.css +617 -0
  138. package/src/tool/rpg-initiative-tracker/seo.astro +16 -0
  139. package/src/tool/rpg-initiative-tracker/types.ts +15 -0
  140. package/src/tool/score-tracker/bibliography.astro +16 -0
  141. package/src/tool/score-tracker/bibliography.ts +12 -0
  142. package/src/tool/score-tracker/client.ts +248 -0
  143. package/src/tool/score-tracker/component.astro +34 -0
  144. package/src/tool/score-tracker/components/Leaderboard.astro +19 -0
  145. package/src/tool/score-tracker/components/PlayerSetup.astro +32 -0
  146. package/src/tool/score-tracker/components/RoundScoring.astro +22 -0
  147. package/src/tool/score-tracker/components/ScoreHistory.astro +25 -0
  148. package/src/tool/score-tracker/entry.ts +57 -0
  149. package/src/tool/score-tracker/i18n/de.ts +278 -0
  150. package/src/tool/score-tracker/i18n/en.ts +278 -0
  151. package/src/tool/score-tracker/i18n/es.ts +251 -0
  152. package/src/tool/score-tracker/i18n/fr.ts +278 -0
  153. package/src/tool/score-tracker/i18n/id.ts +278 -0
  154. package/src/tool/score-tracker/i18n/it.ts +278 -0
  155. package/src/tool/score-tracker/i18n/ja.ts +278 -0
  156. package/src/tool/score-tracker/i18n/ko.ts +278 -0
  157. package/src/tool/score-tracker/i18n/nl.ts +278 -0
  158. package/src/tool/score-tracker/i18n/pl.ts +278 -0
  159. package/src/tool/score-tracker/i18n/pt.ts +278 -0
  160. package/src/tool/score-tracker/i18n/ru.ts +278 -0
  161. package/src/tool/score-tracker/i18n/sv.ts +278 -0
  162. package/src/tool/score-tracker/i18n/tr.ts +278 -0
  163. package/src/tool/score-tracker/i18n/zh.ts +278 -0
  164. package/src/tool/score-tracker/index.ts +9 -0
  165. package/src/tool/score-tracker/logic.test.ts +83 -0
  166. package/src/tool/score-tracker/logic.ts +122 -0
  167. package/src/tool/score-tracker/score-tracker.css +1029 -0
  168. package/src/tool/score-tracker/seo.astro +16 -0
  169. package/src/tool/score-tracker/types.ts +17 -0
  170. package/src/tools.ts +10 -0
  171. package/src/types.ts +1 -2
@@ -0,0 +1,145 @@
1
+ import type { WheelSegment, SpinResultEntry, PresetKey } from './types';
2
+
3
+ export const SEGMENT_COLORS = [
4
+ '#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
5
+ '#8b5cf6', '#ec4899', '#14b8a6', '#f97316',
6
+ '#6366f1', '#84cc16', '#06b6d4', '#e11d48',
7
+ ];
8
+
9
+ const PRESETS: Record<PresetKey, WheelSegment[]> = {
10
+ yesno: [
11
+ { id: 'seg-0', label: 'Yes', color: SEGMENT_COLORS[2], weight: 1 },
12
+ { id: 'seg-1', label: 'No', color: SEGMENT_COLORS[1], weight: 1 },
13
+ ],
14
+ numbers: Array.from({ length: 6 }, (_, i) => ({
15
+ id: `seg-${i}`,
16
+ label: String(i + 1),
17
+ color: SEGMENT_COLORS[i],
18
+ weight: 1,
19
+ })),
20
+ actions: [
21
+ { id: 'seg-0', label: 'Attack', color: SEGMENT_COLORS[1], weight: 1 },
22
+ { id: 'seg-1', label: 'Defend', color: SEGMENT_COLORS[0], weight: 1 },
23
+ { id: 'seg-2', label: 'Explore', color: SEGMENT_COLORS[2], weight: 1 },
24
+ { id: 'seg-3', label: 'Negotiate', color: SEGMENT_COLORS[3], weight: 1 },
25
+ { id: 'seg-4', label: 'Rest', color: SEGMENT_COLORS[4], weight: 1 },
26
+ ],
27
+ d20: Array.from({ length: 20 }, (_, i) => ({
28
+ id: `seg-${i}`,
29
+ label: String(i + 1),
30
+ color: SEGMENT_COLORS[i % SEGMENT_COLORS.length],
31
+ weight: 1,
32
+ })),
33
+ alignment: [
34
+ { id: 'seg-0', label: 'Lawful Good', color: SEGMENT_COLORS[2], weight: 1 },
35
+ { id: 'seg-1', label: 'Neutral Good', color: SEGMENT_COLORS[0], weight: 1 },
36
+ { id: 'seg-2', label: 'Chaotic Good', color: SEGMENT_COLORS[6], weight: 1 },
37
+ { id: 'seg-3', label: 'Lawful Neutral', color: SEGMENT_COLORS[3], weight: 1 },
38
+ { id: 'seg-4', label: 'True Neutral', color: SEGMENT_COLORS[4], weight: 1 },
39
+ { id: 'seg-5', label: 'Chaotic Neutral', color: SEGMENT_COLORS[7], weight: 1 },
40
+ { id: 'seg-6', label: 'Lawful Evil', color: SEGMENT_COLORS[1], weight: 1 },
41
+ { id: 'seg-7', label: 'Neutral Evil', color: SEGMENT_COLORS[11], weight: 1 },
42
+ { id: 'seg-8', label: 'Chaotic Evil', color: SEGMENT_COLORS[5], weight: 1 },
43
+ ],
44
+ loot: [
45
+ { id: 'seg-0', label: 'Common', color: '#9ca3af', weight: 5 },
46
+ { id: 'seg-1', label: 'Uncommon', color: '#22c55e', weight: 4 },
47
+ { id: 'seg-2', label: 'Rare', color: '#3b82f6', weight: 3 },
48
+ { id: 'seg-3', label: 'Very Rare', color: '#a855f7', weight: 2 },
49
+ { id: 'seg-4', label: 'Legendary', color: '#eab308', weight: 1 },
50
+ ],
51
+ custom: [
52
+ { id: 'seg-0', label: 'Option A', color: SEGMENT_COLORS[0], weight: 1 },
53
+ { id: 'seg-1', label: 'Option B', color: SEGMENT_COLORS[1], weight: 1 },
54
+ { id: 'seg-2', label: 'Option C', color: SEGMENT_COLORS[2], weight: 1 },
55
+ ],
56
+ };
57
+
58
+ export function getPresetSegments(preset: PresetKey): WheelSegment[] {
59
+ return (PRESETS[preset] || PRESETS.custom).map((s) => ({ ...s }));
60
+ }
61
+
62
+ export function addSegment(segments: WheelSegment[]): WheelSegment[] {
63
+ const idx = segments.length;
64
+ return [
65
+ ...segments,
66
+ {
67
+ id: `seg-${Date.now()}`,
68
+ label: `Option ${String.fromCharCode(65 + Math.min(idx, 25))}`,
69
+ color: SEGMENT_COLORS[idx % SEGMENT_COLORS.length],
70
+ weight: 1,
71
+ },
72
+ ];
73
+ }
74
+
75
+ export function removeSegment(segments: WheelSegment[], id: string): WheelSegment[] {
76
+ if (segments.length <= 1) return segments;
77
+ return segments.filter((s) => s.id !== id);
78
+ }
79
+
80
+ export function updateSegment(
81
+ segments: WheelSegment[],
82
+ id: string,
83
+ field: 'label' | 'color' | 'weight',
84
+ value: string,
85
+ ): WheelSegment[] {
86
+ return segments.map((s) => {
87
+ if (s.id === id) {
88
+ if (field === 'weight') {
89
+ return { ...s, weight: Math.max(1, parseInt(value, 10) || 1) };
90
+ }
91
+ return { ...s, [field]: value };
92
+ }
93
+ return s;
94
+ });
95
+ }
96
+
97
+ export function selectRandomSegment(segments: WheelSegment[]): number {
98
+ const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
99
+ const r = Math.random() * totalWeight;
100
+ let acc = 0;
101
+ for (let i = 0; i < segments.length; i++) {
102
+ acc += segments[i].weight || 1;
103
+ if (r < acc) {
104
+ return i;
105
+ }
106
+ }
107
+ return segments.length - 1;
108
+ }
109
+
110
+ export function getSelectedIndex(
111
+ segments: WheelSegment[],
112
+ currentAngle: number,
113
+ ): number {
114
+ const totalWeight = segments.reduce((sum, s) => sum + (s.weight || 1), 0);
115
+ const segmentAngle = (360 - (currentAngle % 360)) % 360;
116
+ let acc = 0;
117
+ for (let i = 0; i < segments.length; i++) {
118
+ const nextAcc = acc + (segments[i].weight || 1);
119
+ const startAngle = (acc / totalWeight) * 360;
120
+ const endAngle = (nextAcc / totalWeight) * 360;
121
+ if (segmentAngle >= startAngle && segmentAngle < endAngle) {
122
+ return i;
123
+ }
124
+ acc = nextAcc;
125
+ }
126
+ return 0;
127
+ }
128
+
129
+ export function getArcAngle(segmentCount: number): number {
130
+ return 360 / segmentCount;
131
+ }
132
+
133
+ export function addHistoryItem(
134
+ history: SpinResultEntry[],
135
+ segment: WheelSegment,
136
+ index: number,
137
+ ): SpinResultEntry[] {
138
+ const entry: SpinResultEntry = {
139
+ segment,
140
+ index,
141
+ timestamp: Date.now(),
142
+ };
143
+ const updated = [entry, ...history];
144
+ return updated.slice(0, 10);
145
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { decisionWheel } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props;
11
+ const loader = decisionWheel.i18n[locale] || decisionWheel.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,14 @@
1
+ export interface WheelSegment {
2
+ id: string;
3
+ label: string;
4
+ color: string;
5
+ weight?: number;
6
+ }
7
+
8
+ export interface SpinResultEntry {
9
+ segment: WheelSegment;
10
+ index: number;
11
+ timestamp: number;
12
+ }
13
+
14
+ export type PresetKey = 'yesno' | 'numbers' | 'actions' | 'custom' | 'd20' | 'alignment' | 'loot';
@@ -0,0 +1,121 @@
1
+ import type { WheelSegment, SpinResultEntry } from './types';
2
+
3
+ function selected(value: number | undefined, target: number): string {
4
+ return value === target ? 'selected' : '';
5
+ }
6
+
7
+ function createSegmentRow(seg: WheelSegment, t: (key: string) => string): HTMLDivElement {
8
+ const row = document.createElement('div');
9
+ row.className = 'segment-row';
10
+ const labelPlaceholder = t('segmentLabelPlaceholder') || 'Label';
11
+ const weightLabel = t('weightLabel') || 'Weight';
12
+ const removeLabel = t('removeSegmentLabel') || 'Remove';
13
+ const sw = seg.weight;
14
+ row.innerHTML = `
15
+ <input type="color" class="segment-color" data-id="${seg.id}" value="${seg.color}" />
16
+ <input type="text" class="segment-input" data-id="${seg.id}" value="${seg.label}" placeholder="${labelPlaceholder}" />
17
+ <select class="segment-weight" data-id="${seg.id}" title="${weightLabel}">
18
+ <option value="1" ${selected(sw, 1)}>w1</option>
19
+ <option value="2" ${selected(sw, 2)}>w2</option>
20
+ <option value="3" ${selected(sw, 3)}>w3</option>
21
+ <option value="4" ${selected(sw, 4)}>w4</option>
22
+ <option value="5" ${selected(sw, 5)}>w5</option>
23
+ </select>
24
+ <button type="button" class="btn-remove-segment" data-id="${seg.id}">${removeLabel}</button>
25
+ `;
26
+ return row;
27
+ }
28
+
29
+ export function bindSegmentEvents(
30
+ segmentList: HTMLElement,
31
+ onUpdateSegment: (id: string, field: string, value: string) => void,
32
+ onRemoveSegment: (id: string) => void,
33
+ ): void {
34
+ segmentList.querySelectorAll('.segment-color').forEach((el) => {
35
+ el.addEventListener('input', (e) => {
36
+ const input = e.target as HTMLInputElement;
37
+ onUpdateSegment(input.dataset.id || '', 'color', input.value);
38
+ });
39
+ });
40
+ segmentList.querySelectorAll('.segment-input').forEach((el) => {
41
+ el.addEventListener('input', (e) => {
42
+ const input = e.target as HTMLInputElement;
43
+ onUpdateSegment(input.dataset.id || '', 'label', input.value);
44
+ });
45
+ });
46
+ segmentList.querySelectorAll('.segment-weight').forEach((el) => {
47
+ el.addEventListener('change', (e) => {
48
+ const select = e.target as HTMLSelectElement;
49
+ onUpdateSegment(select.dataset.id || '', 'weight', select.value);
50
+ });
51
+ });
52
+ segmentList.querySelectorAll('.btn-remove-segment').forEach((el) => {
53
+ el.addEventListener('click', (e) => {
54
+ const btn = e.currentTarget as HTMLButtonElement;
55
+ onRemoveSegment(btn.dataset.id || '');
56
+ });
57
+ });
58
+ }
59
+
60
+ export function renderSegmentEditor(
61
+ segmentList: HTMLElement,
62
+ segments: WheelSegment[],
63
+ t: (key: string) => string,
64
+ onBind: () => void,
65
+ ): void {
66
+ segmentList.innerHTML = '';
67
+ if (segments.length === 0) {
68
+ segmentList.innerHTML = `<div class="no-segments-msg">${t('noSegments') || 'Add segments to the wheel before spinning.'}</div>`;
69
+ return;
70
+ }
71
+ segments.forEach((seg) => {
72
+ segmentList.appendChild(createSegmentRow(seg, t));
73
+ });
74
+ onBind();
75
+ }
76
+
77
+ export function renderResult(
78
+ resultDisplay: HTMLElement,
79
+ result: SpinResultEntry | null,
80
+ spinning: boolean,
81
+ t: (key: string) => string,
82
+ ): void {
83
+ if (!result) {
84
+ resultDisplay.innerHTML = `<div class="result-placeholder">${spinning ? 'Spinning...' : (t('noHistory') || 'Spin to see result')}</div>`;
85
+ return;
86
+ }
87
+ resultDisplay.innerHTML = `<div class="result-card" id="result-card">
88
+ <div class="result-header">${t('resultHeading') || 'Result'}</div>
89
+ <span class="result-value"><span class="result-color-dot" style="background:${result.segment.color}"></span>${result.segment.label}</span>
90
+ </div>`;
91
+ }
92
+
93
+ export function triggerEmphasis(): void {
94
+ const card = document.getElementById('result-card');
95
+ if (!card) return;
96
+ card.classList.remove('result-emphasis');
97
+ void card.offsetWidth;
98
+ card.classList.add('result-emphasis');
99
+ }
100
+
101
+ export function renderHistory(
102
+ historyList: HTMLElement,
103
+ historyEmpty: HTMLElement,
104
+ history: SpinResultEntry[],
105
+ ): void {
106
+ if (history.length === 0) {
107
+ historyEmpty.style.display = 'block';
108
+ historyList.querySelectorAll('.history-item').forEach((el) => el.remove());
109
+ return;
110
+ }
111
+ historyEmpty.style.display = 'none';
112
+ historyList.querySelectorAll('.history-item').forEach((el) => el.remove());
113
+ history.forEach((h) => {
114
+ const item = document.createElement('div');
115
+ item.className = 'history-item';
116
+ const time = new Date(h.timestamp);
117
+ const timeStr = time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
118
+ item.innerHTML = `<span class="history-item-label"><span class="history-item-dot" style="background:${h.segment.color}"></span>${h.segment.label}</span><span class="history-item-time">${timeStr}</span>`;
119
+ historyList.appendChild(item);
120
+ });
121
+ }
@@ -185,14 +185,14 @@ function rollDiceTumble(selectedDice: { type: string; max: number }[], visualDic
185
185
  return new Promise((resolve) => {
186
186
  const intervalId = setInterval(() => {
187
187
  visualDiceEls.forEach((el, index) => {
188
- const d = selectedDice[index];
188
+ const d = selectedDice[index]!;
189
189
  el.textContent = (Math.floor(Math.random() * d.max) + 1).toString();
190
190
  });
191
191
  }, 60);
192
192
  setTimeout(() => {
193
193
  clearInterval(intervalId);
194
194
  const results = visualDiceEls.map((el, index) => {
195
- const d = selectedDice[index];
195
+ const d = selectedDice[index]!;
196
196
  el.classList.remove('rolling-pulse');
197
197
  const val = Math.floor(Math.random() * d.max) + 1;
198
198
  if (val === d.max) {
@@ -1,4 +1,4 @@
1
- import { bibliography } from '../bibliography';
1
+ import { bibliography } from '../bibliography';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
 
4
4
  export const content: ToolLocaleContent = {
@@ -20,7 +20,7 @@ export const content: ToolLocaleContent = {
20
20
  },
21
21
  seo: [
22
22
  { type: 'title', text: 'Управление вероятностями в настольных играх: Как математика кубиков влияет на игровой процесс', level: 2 },
23
- { type: 'paragraph', html: 'Многогранные кости это сердце настольных ролевых игр и современных настолок. Стремитесь ли вы выбросить высокий спасбросок в Dungeons & Dragons или бросаете кости для получения ресурсов, результат подчиняется математической вероятности. Понимание того, как сочетание различных типов кубиков влияет на ваши шансы, имеет решающее значение для принятия тактических решений во время игры.' },
23
+ { type: 'paragraph', html: 'Многогранные кости - это сердце настольных ролевых игр и современных настолок. Стремитесь ли вы выбросить высокий спасбросок в Dungeons & Dragons или бросаете кости для получения ресурсов, результат подчиняется математической вероятности. Понимание того, как сочетание различных типов кубиков влияет на ваши шансы, имеет решающее значение для принятия тактических решений во время игры.' },
24
24
  {
25
25
  type: 'stats',
26
26
  items: [
@@ -33,7 +33,7 @@ export const content: ToolLocaleContent = {
33
33
  { type: 'title', text: 'Математика нескольких костей: Почему 3d6 надежнее, чем 1d20', level: 2 },
34
34
  { type: 'paragraph', html: 'Когда вы бросаете одну кость D20, каждый результат от 1 до 20 имеет равный шанс в 5%, что дает равномерное распределение вероятностей. Однако при броске нескольких костей, например 3d6, результаты группируются вокруг среднего значения 10.5. Эта куполообразная кривая (распределение Гаусса) означает, что вы гораздо чаще будете выбрасывать средние значения, чем крайние, что обеспечивает стабильность и сглаживает непредсказуемость одиночного кубика.' },
35
35
  { type: 'title', text: 'Разбор вероятностей D20: Критические попадания, критические провалы (pifia) и роль модификаторов', level: 2 },
36
- { type: 'paragraph', html: 'Простой модификатор «плюс два» может кардинально изменить вероятность успеха, превращая сложные задачи в легко выполнимые. Во многих игровых системах чистая 20 означает автоматический критический успех (шанс 5%), тогда как чистая 1 критический провал (pifia). Визуализация этих исходов помогает демистифицировать случайность и позволяет планировать тактические ходы на основе статистики.' },
36
+ { type: 'paragraph', html: 'Простой модификатор «плюс два» может кардинально изменить вероятность успеха, превращая сложные задачи в легко выполнимые. Во многих игровых системах чистая 20 означает автоматический критический успех (шанс 5%), тогда как чистая 1 - критический провал (pifia). Визуализация этих исходов помогает демистифицировать случайность и позволяет планировать тактические ходы на основе статистики.' },
37
37
  {
38
38
  type: 'title',
39
39
  text: 'Таблица характеристик стандартных многогранных костей',
@@ -108,7 +108,7 @@ export const content: ToolLocaleContent = {
108
108
  },
109
109
  {
110
110
  title: 'Процентная система (Бросок под значение)',
111
- description: 'Использует две 10-гранные кости для получения числа от 1 до 100. Цель выбросить значение меньше показателя персонажа, что делает шансы наглядными.',
111
+ description: 'Использует две 10-гранные кости для получения числа от 1 до 100. Цель - выбросить значение меньше показателя персонажа, что делает шансы наглядными.',
112
112
  icon: 'mdi:percent-box-outline',
113
113
  highlight: false,
114
114
  points: [
@@ -126,7 +126,7 @@ export const content: ToolLocaleContent = {
126
126
  title: 'Ошибка игрока: Почему у кубиков нет памяти',
127
127
  icon: 'mdi:information-outline',
128
128
  badge: 'PROBABILITY TIP',
129
- html: 'Распространенное заблуждение в настольных играх вера в то, что предыдущие броски влияют на будущие. Если вы выбросили три «1» подряд на D20, вам может показаться, что высокий результат уже «на подходе». На самом деле каждый бросок независимое событие. Вероятность выбросить 20 при следующем броске по-прежнему составляет ровно 5%. Знание этого помогает игрокам делать тактический выбор объективно.',
129
+ html: 'Распространенное заблуждение в настольных играх - вера в то, что предыдущие броски влияют на будущие. Если вы выбросили три «1» подряд на D20, вам может показаться, что высокий результат уже «на подходе». На самом деле каждый бросок - независимое событие. Вероятность выбросить 20 при следующем броске по-прежнему составляет ровно 5%. �-нание этого помогает игрокам делать тактический выбор объективно.',
130
130
  },
131
131
  {
132
132
  type: 'title',
@@ -154,7 +154,7 @@ export const content: ToolLocaleContent = {
154
154
  },
155
155
  {
156
156
  term: 'Математическое ожидание',
157
- definition: 'Долгосрочный средний результат ваших бросков. Для одного D6 математическое ожидание составляет 3.5; для D20 10.5.',
157
+ definition: 'Долгосрочный средний результат ваших бросков. Для одного D6 математическое ожидание составляет 3.5; для D20 - 10.5.',
158
158
  },
159
159
  {
160
160
  term: 'Процентные кости',
@@ -174,7 +174,7 @@ export const content: ToolLocaleContent = {
174
174
  },
175
175
  {
176
176
  question: 'Что такое модификаторы и как они работают?',
177
- answer: 'Модификаторы это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.',
177
+ answer: 'Модификаторы - это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.',
178
178
  },
179
179
  {
180
180
  question: 'Что означает бросок с Преимуществом или Помехой?',
@@ -242,7 +242,7 @@ export const content: ToolLocaleContent = {
242
242
  'name': 'Что такое модификаторы и как они работают?',
243
243
  'acceptedAnswer': {
244
244
  '@type': 'Answer',
245
- 'text': 'Модификаторы это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.'
245
+ 'text': 'Модификаторы - это фиксированные значения (+1, -2 и т. д.), которые прибавляются к сумме броска или вычитаются из нее для отражения навыков персонажа, бонусов или штрафов.'
246
246
  }
247
247
  },
248
248
  {
@@ -81,7 +81,7 @@ export function renderHistory(refs: HistoryUIRefs, onDrawChart: (totals: number[
81
81
  setText(refs.totalVal, history.length.toString());
82
82
  setText(refs.badge, history.length.toString());
83
83
  setText(refs.averageVal, (sum / history.length).toFixed(1));
84
- setText(refs.lastVal, history[history.length - 1].total.toString());
84
+ setText(refs.lastVal, history[history.length - 1]!.total.toString());
85
85
 
86
86
  onDrawChart(totals);
87
87
  }
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { fantasyRunesTranslator } 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 = fantasyRunesTranslator.i18n[locale] || fantasyRunesTranslator.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,16 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Elder Futhark (Wikipedia)',
6
+ url: 'https://en.wikipedia.org/wiki/Elder_Futhark',
7
+ },
8
+ {
9
+ name: 'Anglo-Saxon Runes (Wikipedia)',
10
+ url: 'https://en.wikipedia.org/wiki/Anglo-Saxon_runes',
11
+ },
12
+ {
13
+ name: 'Runic Unicode Block',
14
+ url: 'https://en.wikipedia.org/wiki/Runic_(Unicode_block)',
15
+ },
16
+ ];
@@ -0,0 +1,205 @@
1
+ import type { RuneAlphabetKey, RuneCharacterResult, RuneCharacter } from './types';
2
+ import { translateText } from './logic';
3
+
4
+ const wrap = document.querySelector('.runes-wrap') as HTMLElement;
5
+ const input = document.getElementById('runes-input') as HTMLInputElement;
6
+ const seedIn = document.getElementById('runes-seed') as HTMLInputElement;
7
+ const seedTag = document.getElementById('seed-tag') as HTMLElement;
8
+ const seedShuf = document.getElementById('seed-shuffle-btn') as HTMLElement;
9
+ const seedToggle = document.getElementById('seed-toggle-btn') as HTMLElement;
10
+ const seedDrawer = document.getElementById('seed-drawer') as HTMLElement;
11
+ const output = document.getElementById('runes-output') as HTMLElement;
12
+ const detailCard = document.getElementById('detail-card') as HTMLElement;
13
+ const dName = document.getElementById('detail-name') as HTMLElement;
14
+ const dMeaning = document.getElementById('detail-meaning') as HTMLElement;
15
+ const dPhonetic = document.getElementById('detail-phonetic') as HTMLElement;
16
+ const copyBtn = document.getElementById('runes-copy-btn') as HTMLElement;
17
+ const clearBtn = document.getElementById('runes-clear-btn') as HTMLElement;
18
+ const pills = document.getElementById('alphabet-pills') as HTMLElement;
19
+ const gmBtn = document.getElementById('runes-gm-btn') as HTMLElement;
20
+ const gmOverlay = document.getElementById('gm-overlay') as HTMLElement;
21
+ const gmDisplay = document.getElementById('gm-runes') as HTMLElement;
22
+ const gmParts = document.getElementById('gm-particles') as HTMLElement;
23
+ const gmExit = document.getElementById('gm-exit-btn') as HTMLElement;
24
+ const refFoundBtn = document.getElementById('runes-ref-found-btn') as HTMLElement;
25
+ const refAllBtn = document.getElementById('runes-ref-all-btn') as HTMLElement;
26
+ const refOverlay = document.getElementById('ref-overlay') as HTMLElement;
27
+ const refGrid = document.getElementById('ref-grid') as HTMLElement;
28
+ const refTitle = document.getElementById('ref-title') as HTMLElement;
29
+ const refClose = document.getElementById('ref-close-btn') as HTMLElement;
30
+ const ALPHA_KEY = 'runes-alphabet';
31
+ let alphabet: RuneAlphabetKey = (localStorage.getItem(ALPHA_KEY) as RuneAlphabetKey) || 'elder-futhark';
32
+ let result: RuneCharacterResult[] = [];
33
+ let seed: number | null = null;
34
+ const discovered = new Set<string>();
35
+
36
+ function ui(key: string): string {
37
+ try { return JSON.parse(wrap.getAttribute('data-ui') || '{}')[key] || ''; } catch { return ''; }
38
+ }
39
+
40
+ function spawnPart(sym: string, x: number, y: number) {
41
+ const el = document.createElement('div');
42
+ el.className = 'particle'; el.textContent = sym;
43
+ el.style.cssText = `left:${x}px;top:${y}px;position:fixed`;
44
+ document.body.appendChild(el);
45
+ setTimeout(() => el.remove(), 1000);
46
+ }
47
+
48
+ function spawnGmParts() {
49
+ gmParts.innerHTML = '';
50
+ const rs = ['ᚠ','ᛟ','ᚱ','ᚲ','ᚨ','ᚢ','ᛉ','ᚷ','ᚹ','ᛞ','ᛁ','ᛃ','ᛚ','ᛗ','ᚾ','ᛟ','ᛈ','ᛏ','ᛒ','ᛇ','ᛉ','ᚱ','ᛋ','ᚠ','ᚢ','ᚦ','ᚬ'];
51
+ for (let i = 0; i < 10; i++) {
52
+ const p = document.createElement('span');
53
+ p.className = 'gm-float'; p.textContent = rs[Math.floor(Math.random() * rs.length)];
54
+ p.style.left = Math.random() * 100 + '%';
55
+ p.style.fontSize = (0.7 + Math.random() * 1.3) + 'rem';
56
+ p.style.animationDuration = (3 + Math.random() * 3) + 's';
57
+ p.style.animationDelay = Math.random() * 4 + 's';
58
+ gmParts.appendChild(p);
59
+ }
60
+ }
61
+
62
+ function selPill(key: RuneAlphabetKey) {
63
+ pills.querySelectorAll('.pill').forEach(b => b.classList.toggle('active', b.getAttribute('data-alphabet') === key));
64
+ }
65
+
66
+ function emptyOut() {
67
+ output.innerHTML = '<div class="runes-output-empty"><span class="empty-rune">ᛟ</span><span class="empty-label">' + ui('noInput') + '</span></div>';
68
+ output.classList.remove('has-chars');
69
+ }
70
+
71
+ function showDetail(c: RuneCharacter | null) {
72
+ if (!c) { detailCard.classList.remove('vis'); return; }
73
+ dName.textContent = c.name; dMeaning.textContent = c.meaning; dPhonetic.textContent = c.phonetic;
74
+ detailCard.classList.add('vis');
75
+ }
76
+
77
+ function clickRune(cr: RuneCharacterResult, card: HTMLElement) {
78
+ output.querySelectorAll('.rune-card').forEach(c => c.classList.remove('active'));
79
+ card.classList.add('active');
80
+ if (cr.char) {
81
+ showDetail(cr.char);
82
+ const r = card.getBoundingClientRect();
83
+ spawnPart(cr.char.symbol, r.left + r.width / 2, r.top);
84
+ }
85
+ }
86
+
87
+ function render(rs: RuneCharacterResult[]) {
88
+ output.classList.add('has-chars');
89
+ result = rs;
90
+ const shim = document.createElement('div');
91
+ shim.className = 'shimmer';
92
+ output.innerHTML = '';
93
+ output.appendChild(shim);
94
+ setTimeout(() => {
95
+ const g = document.createElement('div');
96
+ g.className = 'runes-grid';
97
+ rs.forEach((cr, i) => {
98
+ const c = document.createElement('div');
99
+ c.className = 'rune-card'; c.style.animationDelay = i * 25 + 'ms';
100
+ const sy = document.createElement('span');
101
+ sy.className = 'sym'; sy.textContent = cr.char ? cr.char.symbol : '?';
102
+ const le = document.createElement('span');
103
+ le.className = 'let'; le.textContent = cr.input.toUpperCase();
104
+ c.appendChild(sy); c.appendChild(le);
105
+ c.addEventListener('click', () => clickRune(cr, c));
106
+ g.appendChild(c);
107
+ });
108
+ output.innerHTML = '';
109
+ output.appendChild(g);
110
+ }, 300);
111
+ }
112
+
113
+ function translate() {
114
+ const t = input.value.trim();
115
+ if (!t) { emptyOut(); detailCard.classList.remove('vis'); result = []; return; }
116
+ for (const ch of t.toLowerCase()) { if (ch >= 'a' && ch <= 'z') discovered.add(ch); }
117
+ render(translateText(t, alphabet, seed).characters);
118
+ detailCard.classList.remove('vis');
119
+ }
120
+
121
+ function copyText() {
122
+ if (result.length === 0) return;
123
+ const t = result.map(c => (c.char ? c.char.symbol : c.input)).join('');
124
+ navigator.clipboard.writeText(t).catch(() => {});
125
+ }
126
+
127
+ function clearAll() {
128
+ input.value = ''; seedIn.value = ''; seed = null; seedTag.style.display = 'none';
129
+ emptyOut(); detailCard.classList.remove('vis'); result = [];
130
+ }
131
+
132
+ function applySeed() {
133
+ const v = parseInt(seedIn.value);
134
+ seed = isNaN(v) || v < 1 ? null : v;
135
+ seedTag.style.display = seed ? '' : 'none';
136
+ if (input.value.trim()) translate();
137
+ }
138
+
139
+ function randSeed() {
140
+ seedIn.value = String(Math.floor(Math.random() * 99999) + 1);
141
+ applySeed();
142
+ }
143
+
144
+ function openGm() {
145
+ if (result.length === 0) return;
146
+ gmDisplay.textContent = result.map(c => (c.char ? c.char.symbol : c.input)).join('');
147
+ gmOverlay.classList.add('active');
148
+ spawnGmParts();
149
+ }
150
+
151
+ function closeGm() { gmOverlay.classList.remove('active'); }
152
+
153
+ function openRef(mode: 'found' | 'all') {
154
+ const mapping = translateText('abcdefghijklmnopqrstuvwxyz', alphabet, seed).characters;
155
+ refTitle.textContent = mode === 'found' ? 'Discovered Runes' : 'Full Rune Dictionary';
156
+ refGrid.innerHTML = mapping.map(c => {
157
+ if (mode === 'found' && !discovered.has(c.input)) return '';
158
+ return `<div class="ref-cell lit"><span class="ref-sym">${c.char ? c.char.symbol : '?'}</span><span class="ref-let">${c.input}</span></div>`;
159
+ }).join('');
160
+ refOverlay.classList.add('active');
161
+ spawnRefParticles();
162
+ }
163
+
164
+ function spawnRefParticles() {
165
+ const container = refOverlay;
166
+ const existing = container.querySelectorAll('.gm-float');
167
+ existing.forEach(e => e.remove());
168
+ const rs = ['ᚠ','ᛟ','ᚱ','ᚲ','ᚨ','ᚢ','ᛉ','ᚷ','ᚹ','ᛞ','ᛁ','ᛃ','ᛚ','ᛗ','ᚾ','ᛟ','ᛈ','ᛏ','ᛒ','ᛇ','ᛉ','ᚱ','ᛋ','ᚠ','ᚢ','ᚦ','ᚬ'];
169
+ for (let i = 0; i < 8; i++) {
170
+ const p = document.createElement('span');
171
+ p.className = 'gm-float'; p.textContent = rs[Math.floor(Math.random() * rs.length)];
172
+ p.style.left = Math.random() * 100 + '%';
173
+ p.style.fontSize = (0.7 + Math.random() * 1.3) + 'rem';
174
+ p.style.animationDuration = (4 + Math.random() * 3) + 's';
175
+ p.style.animationDelay = Math.random() * 4 + 's';
176
+ container.appendChild(p);
177
+ }
178
+ }
179
+ function closeRef() { refOverlay.classList.remove('active'); }
180
+
181
+ input.addEventListener('input', () => { if (input.value.trim()) translate(); });
182
+ seedIn.addEventListener('input', applySeed);
183
+ seedShuf.addEventListener('click', randSeed);
184
+ seedToggle.addEventListener('click', () => seedDrawer.classList.toggle('open'));
185
+ copyBtn.addEventListener('click', copyText);
186
+ clearBtn.addEventListener('click', clearAll);
187
+ gmBtn.addEventListener('click', openGm);
188
+ gmExit.addEventListener('click', closeGm);
189
+ refFoundBtn.addEventListener('click', () => openRef('found'));
190
+ refAllBtn.addEventListener('click', () => openRef('all'));
191
+ refClose.addEventListener('click', closeRef);
192
+ refOverlay.addEventListener('click', (e) => { if (e.target === refOverlay) closeRef(); });
193
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { closeGm(); closeRef(); } });
194
+
195
+ pills.addEventListener('click', (e) => {
196
+ const b = (e.target as HTMLElement).closest('.pill') as HTMLButtonElement;
197
+ if (!b) return;
198
+ alphabet = b.getAttribute('data-alphabet') as RuneAlphabetKey;
199
+ localStorage.setItem(ALPHA_KEY, alphabet);
200
+ selPill(alphabet);
201
+ if (input.value.trim()) translate();
202
+ });
203
+
204
+ selPill(alphabet);
205
+ emptyOut();