@jjlmoya/utils-tabletop 1.3.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 (121) hide show
  1. package/package.json +65 -0
  2. package/scripts/postinstall.mjs +27 -0
  3. package/src/category/TabletopCategorySEO.astro +14 -0
  4. package/src/category/i18n/de.ts +27 -0
  5. package/src/category/i18n/en.ts +27 -0
  6. package/src/category/i18n/es.ts +27 -0
  7. package/src/category/i18n/fr.ts +27 -0
  8. package/src/category/i18n/id.ts +27 -0
  9. package/src/category/i18n/it.ts +27 -0
  10. package/src/category/i18n/ja.ts +27 -0
  11. package/src/category/i18n/ko.ts +27 -0
  12. package/src/category/i18n/nl.ts +27 -0
  13. package/src/category/i18n/pl.ts +27 -0
  14. package/src/category/i18n/pt.ts +27 -0
  15. package/src/category/i18n/ru.ts +27 -0
  16. package/src/category/i18n/sv.ts +27 -0
  17. package/src/category/i18n/tr.ts +27 -0
  18. package/src/category/i18n/zh.ts +27 -0
  19. package/src/category/index.ts +27 -0
  20. package/src/components/PreviewNavSidebar.astro +116 -0
  21. package/src/components/PreviewToolbar.astro +143 -0
  22. package/src/data.ts +10 -0
  23. package/src/entries.ts +10 -0
  24. package/src/env.d.ts +5 -0
  25. package/src/index.ts +21 -0
  26. package/src/layouts/PreviewLayout.astro +117 -0
  27. package/src/pages/[locale]/[slug].astro +161 -0
  28. package/src/pages/[locale].astro +251 -0
  29. package/src/pages/index.astro +4 -0
  30. package/src/tests/faq_count.test.ts +18 -0
  31. package/src/tests/i18n_coverage.test.ts +36 -0
  32. package/src/tests/locale_completeness.test.ts +29 -0
  33. package/src/tests/mocks/astro_mock.js +2 -0
  34. package/src/tests/no_en_dash.test.ts +41 -0
  35. package/src/tests/no_h1_in_components.test.ts +48 -0
  36. package/src/tests/schemas_fulfillment.test.ts +23 -0
  37. package/src/tests/seo_length.test.ts +22 -0
  38. package/src/tests/shared-test-helpers.ts +56 -0
  39. package/src/tests/slug_language_code_format.test.ts +23 -0
  40. package/src/tests/slug_uniqueness.test.ts +81 -0
  41. package/src/tests/title_quality.test.ts +55 -0
  42. package/src/tests/tool_exports.test.ts +34 -0
  43. package/src/tests/tool_validation.test.ts +16 -0
  44. package/src/tool/board-game-timer/bibliography.astro +16 -0
  45. package/src/tool/board-game-timer/bibliography.ts +12 -0
  46. package/src/tool/board-game-timer/board-game-timer.css +1615 -0
  47. package/src/tool/board-game-timer/client.ts +17 -0
  48. package/src/tool/board-game-timer/component.astro +22 -0
  49. package/src/tool/board-game-timer/components/DuelTimer.astro +75 -0
  50. package/src/tool/board-game-timer/components/MultiplayerTimer.astro +58 -0
  51. package/src/tool/board-game-timer/components/SetupPanel.astro +155 -0
  52. package/src/tool/board-game-timer/components/StatsModal.astro +52 -0
  53. package/src/tool/board-game-timer/entry.ts +76 -0
  54. package/src/tool/board-game-timer/i18n/de.ts +303 -0
  55. package/src/tool/board-game-timer/i18n/en.ts +304 -0
  56. package/src/tool/board-game-timer/i18n/es.ts +303 -0
  57. package/src/tool/board-game-timer/i18n/fr.ts +303 -0
  58. package/src/tool/board-game-timer/i18n/id.ts +303 -0
  59. package/src/tool/board-game-timer/i18n/it.ts +303 -0
  60. package/src/tool/board-game-timer/i18n/ja.ts +306 -0
  61. package/src/tool/board-game-timer/i18n/ko.ts +303 -0
  62. package/src/tool/board-game-timer/i18n/nl.ts +303 -0
  63. package/src/tool/board-game-timer/i18n/pl.ts +303 -0
  64. package/src/tool/board-game-timer/i18n/pt.ts +303 -0
  65. package/src/tool/board-game-timer/i18n/ru.ts +303 -0
  66. package/src/tool/board-game-timer/i18n/sv.ts +303 -0
  67. package/src/tool/board-game-timer/i18n/tr.ts +303 -0
  68. package/src/tool/board-game-timer/i18n/zh.ts +303 -0
  69. package/src/tool/board-game-timer/index.ts +9 -0
  70. package/src/tool/board-game-timer/logic.test.ts +282 -0
  71. package/src/tool/board-game-timer/logic.ts +6 -0
  72. package/src/tool/board-game-timer/modules/ColorHelper.ts +26 -0
  73. package/src/tool/board-game-timer/modules/DuelViewManager.ts +120 -0
  74. package/src/tool/board-game-timer/modules/FormatHelper.ts +12 -0
  75. package/src/tool/board-game-timer/modules/GameRunner.ts +136 -0
  76. package/src/tool/board-game-timer/modules/GameState.ts +48 -0
  77. package/src/tool/board-game-timer/modules/MultiplayerViewManager.ts +163 -0
  78. package/src/tool/board-game-timer/modules/SetupPanelManager.ts +209 -0
  79. package/src/tool/board-game-timer/modules/SoundManager.ts +109 -0
  80. package/src/tool/board-game-timer/modules/SpeechManager.ts +1 -0
  81. package/src/tool/board-game-timer/modules/StatsManager.ts +87 -0
  82. package/src/tool/board-game-timer/modules/StatsModalManager.ts +74 -0
  83. package/src/tool/board-game-timer/modules/TimerEngine.ts +175 -0
  84. package/src/tool/board-game-timer/modules/TimerEngineBase.ts +49 -0
  85. package/src/tool/board-game-timer/modules/TimerEngineClock.ts +127 -0
  86. package/src/tool/board-game-timer/modules/TimerStateHelper.ts +55 -0
  87. package/src/tool/board-game-timer/modules/particles.ts +108 -0
  88. package/src/tool/board-game-timer/seo.astro +16 -0
  89. package/src/tool/board-game-timer/types.ts +97 -0
  90. package/src/tool/dice-roller-simulator/bibliography.astro +16 -0
  91. package/src/tool/dice-roller-simulator/bibliography.ts +12 -0
  92. package/src/tool/dice-roller-simulator/client.ts +273 -0
  93. package/src/tool/dice-roller-simulator/component.astro +75 -0
  94. package/src/tool/dice-roller-simulator/components/DiceGrid.astro +55 -0
  95. package/src/tool/dice-roller-simulator/components/RollHistorySection.astro +26 -0
  96. package/src/tool/dice-roller-simulator/components/RollResultBanner.astro +10 -0
  97. package/src/tool/dice-roller-simulator/components/StatsPanel.astro +36 -0
  98. package/src/tool/dice-roller-simulator/dice-roller-simulator.css +1319 -0
  99. package/src/tool/dice-roller-simulator/entry.ts +39 -0
  100. package/src/tool/dice-roller-simulator/i18n/de.ts +305 -0
  101. package/src/tool/dice-roller-simulator/i18n/en.ts +305 -0
  102. package/src/tool/dice-roller-simulator/i18n/es.ts +305 -0
  103. package/src/tool/dice-roller-simulator/i18n/fr.ts +305 -0
  104. package/src/tool/dice-roller-simulator/i18n/id.ts +305 -0
  105. package/src/tool/dice-roller-simulator/i18n/it.ts +305 -0
  106. package/src/tool/dice-roller-simulator/i18n/ja.ts +305 -0
  107. package/src/tool/dice-roller-simulator/i18n/ko.ts +305 -0
  108. package/src/tool/dice-roller-simulator/i18n/nl.ts +305 -0
  109. package/src/tool/dice-roller-simulator/i18n/pl.ts +305 -0
  110. package/src/tool/dice-roller-simulator/i18n/pt.ts +305 -0
  111. package/src/tool/dice-roller-simulator/i18n/ru.ts +305 -0
  112. package/src/tool/dice-roller-simulator/i18n/sv.ts +305 -0
  113. package/src/tool/dice-roller-simulator/i18n/tr.ts +305 -0
  114. package/src/tool/dice-roller-simulator/i18n/zh.ts +305 -0
  115. package/src/tool/dice-roller-simulator/index.ts +9 -0
  116. package/src/tool/dice-roller-simulator/modules/chart.ts +32 -0
  117. package/src/tool/dice-roller-simulator/modules/history.ts +87 -0
  118. package/src/tool/dice-roller-simulator/modules/particles.ts +22 -0
  119. package/src/tool/dice-roller-simulator/seo.astro +16 -0
  120. package/src/tools.ts +9 -0
  121. package/src/types.ts +70 -0
@@ -0,0 +1,209 @@
1
+ import type { GameState } from './GameState';
2
+ import type { TimeControlMode, PlayerColor, PlayerConfig } from '../types';
3
+
4
+ export class SetupPanelManager {
5
+ private state: GameState;
6
+ private onStart: () => void;
7
+
8
+ private minsInput: HTMLInputElement;
9
+ private secsInput: HTMLInputElement;
10
+ private modeSelect: HTMLSelectElement;
11
+ private incrementInput: HTMLInputElement;
12
+ private warningInput: HTMLInputElement;
13
+ private soundToggle: HTMLInputElement;
14
+ private nameInput: HTMLInputElement;
15
+ private listContainer: HTMLElement;
16
+ private hintEl: HTMLElement;
17
+ private countBadge: HTMLElement;
18
+ private startBtn: HTMLButtonElement;
19
+
20
+ constructor(state: GameState, onStart: () => void) {
21
+ this.state = state;
22
+ this.onStart = onStart;
23
+
24
+ this.minsInput = document.getElementById('setting-mins') as HTMLInputElement;
25
+ this.secsInput = document.getElementById('setting-secs') as HTMLInputElement;
26
+ this.modeSelect = document.getElementById('setting-mode') as HTMLSelectElement;
27
+ this.incrementInput = document.getElementById('setting-increment') as HTMLInputElement;
28
+ this.warningInput = document.getElementById('setting-warning') as HTMLInputElement;
29
+ this.soundToggle = document.getElementById('toggle-sound') as HTMLInputElement;
30
+ this.nameInput = document.getElementById('new-player-name') as HTMLInputElement;
31
+ this.listContainer = document.getElementById('player-list-container') as HTMLElement;
32
+ this.hintEl = document.getElementById('empty-players-hint') as HTMLElement;
33
+ this.countBadge = document.getElementById('player-count') as HTMLElement;
34
+ this.startBtn = document.getElementById('start-game-btn') as HTMLButtonElement;
35
+
36
+ this.initEvents();
37
+ this.render();
38
+ }
39
+
40
+ private initEvents() {
41
+ this.modeSelect.addEventListener('change', () => this.updateIncrementVisibility());
42
+ this.updateIncrementVisibility();
43
+ this.initPresets();
44
+ this.initAdjusters();
45
+ this.initColorPicker();
46
+ this.initPlayerActions();
47
+ }
48
+
49
+ private updateIncrementVisibility() {
50
+ const val = this.modeSelect.value;
51
+ const targets = document.querySelectorAll('.active-fischer, .active-bronstein');
52
+ targets.forEach(t => {
53
+ (t as HTMLElement).style.display = (val === 'fischer' || val === 'bronstein') ? 'flex' : 'none';
54
+ });
55
+ }
56
+
57
+ private initPresets() {
58
+ const presets = document.querySelectorAll('.preset-card');
59
+ presets.forEach(preset => {
60
+ preset.addEventListener('click', () => {
61
+ presets.forEach(p => p.classList.remove('active'));
62
+ preset.classList.add('active');
63
+ this.applyPreset(preset);
64
+ });
65
+ });
66
+ }
67
+
68
+ private applyPreset(preset: Element) {
69
+ const presetId = preset.getAttribute('data-preset');
70
+ if (presetId === 'custom') return;
71
+ const mode = preset.getAttribute('data-mode') || 'normal';
72
+ const time = parseInt(preset.getAttribute('data-time') || '120');
73
+ const inc = parseInt(preset.getAttribute('data-increment') || '0');
74
+
75
+ this.modeSelect.value = mode;
76
+ this.minsInput.value = Math.floor(time / 60).toString();
77
+ this.secsInput.value = (time % 60).toString();
78
+ this.incrementInput.value = inc.toString();
79
+ this.updateIncrementVisibility();
80
+ }
81
+
82
+ private initAdjusters() {
83
+ document.querySelector('.dec-min')?.addEventListener('click', () => this.adjustMins(-1));
84
+ document.querySelector('.inc-min')?.addEventListener('click', () => this.adjustMins(1));
85
+ document.querySelector('.dec-sec')?.addEventListener('click', () => this.adjustSecs(-5));
86
+ document.querySelector('.inc-sec')?.addEventListener('click', () => this.adjustSecs(5));
87
+ }
88
+
89
+ private adjustMins(diff: number) {
90
+ const val = Math.max(0, Math.min(99, (parseInt(this.minsInput.value) || 0) + diff));
91
+ this.minsInput.value = val.toString();
92
+ }
93
+
94
+ private adjustSecs(diff: number) {
95
+ const val = Math.max(0, Math.min(59, (parseInt(this.secsInput.value) || 0) + diff));
96
+ this.secsInput.value = val.toString();
97
+ }
98
+
99
+ private initColorPicker() {
100
+ const dots = document.querySelectorAll('.color-dot');
101
+ dots.forEach(dot => {
102
+ if (dot.getAttribute('data-color') === this.state.selectedColor) {
103
+ dot.classList.add('active');
104
+ }
105
+ dot.addEventListener('click', () => {
106
+ dots.forEach(d => d.classList.remove('active'));
107
+ dot.classList.add('active');
108
+ this.state.selectedColor = dot.getAttribute('data-color') as PlayerColor;
109
+ });
110
+ });
111
+ }
112
+
113
+ private initPlayerActions() {
114
+ const addBtn = document.getElementById('add-player-btn');
115
+ addBtn?.addEventListener('click', () => this.addPlayer());
116
+ this.nameInput.addEventListener('keydown', (e) => {
117
+ if (e.key === 'Enter') this.addPlayer();
118
+ });
119
+ this.startBtn.addEventListener('click', () => {
120
+ this.state.sound.setEnabled(this.soundToggle.checked);
121
+ this.state.recreateEngine({
122
+ mode: this.modeSelect.value as TimeControlMode,
123
+ increment: parseInt(this.incrementInput.value) || 0,
124
+ delay: parseInt(this.incrementInput.value) || 0,
125
+ warningTime: parseInt(this.warningInput.value) || 30
126
+ });
127
+ this.onStart();
128
+ });
129
+ }
130
+
131
+ public getBaseDuration(): number {
132
+ const mins = parseInt(this.minsInput.value) || 0;
133
+ const secs = parseInt(this.secsInput.value) || 0;
134
+ return Math.max(1, mins * 60 + secs);
135
+ }
136
+
137
+ private addPlayer() {
138
+ const name = this.nameInput.value.trim();
139
+ if (!name) return;
140
+ this.state.players.push({
141
+ id: `p-${Date.now()}-${Math.random().toString(36).substr(2, 4)}`,
142
+ name,
143
+ color: this.state.selectedColor,
144
+ time: this.getBaseDuration()
145
+ });
146
+ this.nameInput.value = '';
147
+ this.state.savePlayers();
148
+ this.render();
149
+ }
150
+
151
+ public render() {
152
+ this.listContainer.innerHTML = '';
153
+ const hasPlayers = this.state.players.length > 0;
154
+ this.hintEl.style.display = hasPlayers ? 'none' : 'flex';
155
+ this.countBadge.textContent = this.state.players.length.toString();
156
+ this.startBtn.disabled = this.state.players.length < 2;
157
+
158
+ this.state.players.forEach((p, idx) => {
159
+ const row = this.createPlayerRow(p, idx);
160
+ this.listContainer.appendChild(row);
161
+ });
162
+ }
163
+
164
+ private createPlayerRow(p: PlayerConfig, idx: number): HTMLElement {
165
+ const row = document.createElement('div');
166
+ row.className = 'player-row';
167
+ row.innerHTML = `
168
+ <div class="player-info-meta">
169
+ <div class="player-badge-color" style="background-color: var(--color-${p.color}, ${p.color})"></div>
170
+ <span class="player-row-name">${p.name}</span>
171
+ </div>
172
+ <div class="player-row-actions">
173
+ <button type="button" class="btn-action order-up" data-index="${idx}">
174
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 15l-6-6-6 6"/></svg>
175
+ </button>
176
+ <button type="button" class="btn-action order-down" data-index="${idx}">
177
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
178
+ </button>
179
+ <button type="button" class="btn-action danger delete-player" data-index="${idx}">
180
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
181
+ </button>
182
+ </div>
183
+ `;
184
+ this.bindRowEvents(row, idx);
185
+ return row;
186
+ }
187
+
188
+ private bindRowEvents(row: HTMLElement, idx: number) {
189
+ row.querySelector('.delete-player')?.addEventListener('click', () => {
190
+ this.state.players.splice(idx, 1);
191
+ this.state.savePlayers();
192
+ this.render();
193
+ });
194
+ row.querySelector('.order-up')?.addEventListener('click', () => {
195
+ if (idx > 0) this.swapPlayers(idx, idx - 1);
196
+ });
197
+ row.querySelector('.order-down')?.addEventListener('click', () => {
198
+ if (idx < this.state.players.length - 1) this.swapPlayers(idx, idx + 1);
199
+ });
200
+ }
201
+
202
+ private swapPlayers(i: number, j: number) {
203
+ const temp = this.state.players[i];
204
+ this.state.players[i] = this.state.players[j];
205
+ this.state.players[j] = temp;
206
+ this.state.savePlayers();
207
+ this.render();
208
+ }
209
+ }
@@ -0,0 +1,109 @@
1
+ export class SoundManager {
2
+ private ctx: AudioContext | null = null;
3
+ private isEnabled: boolean = true;
4
+
5
+ constructor() {
6
+ this.ctx = null;
7
+ }
8
+
9
+ private initCtx() {
10
+ if (!this.ctx) {
11
+ this.ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
12
+ }
13
+ if (this.ctx.state === 'suspended') {
14
+ this.ctx.resume();
15
+ }
16
+ }
17
+
18
+ public setEnabled(enabled: boolean) {
19
+ this.isEnabled = enabled;
20
+ }
21
+
22
+ public playTick() {
23
+ if (!this.isEnabled) return;
24
+ this.initCtx();
25
+ if (!this.ctx) return;
26
+
27
+ const osc = this.ctx.createOscillator();
28
+ const gain = this.ctx.createGain();
29
+
30
+ osc.connect(gain);
31
+ gain.connect(this.ctx.destination);
32
+
33
+ osc.frequency.setValueAtTime(800, this.ctx.currentTime);
34
+ gain.gain.setValueAtTime(0.05, this.ctx.currentTime);
35
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.05);
36
+
37
+ osc.start();
38
+ osc.stop(this.ctx.currentTime + 0.05);
39
+ }
40
+
41
+ public playWarning() {
42
+ if (!this.isEnabled) return;
43
+ this.initCtx();
44
+ if (!this.ctx) return;
45
+
46
+ const osc = this.ctx.createOscillator();
47
+ const gain = this.ctx.createGain();
48
+
49
+ osc.connect(gain);
50
+ gain.connect(this.ctx.destination);
51
+
52
+ osc.frequency.setValueAtTime(1200, this.ctx.currentTime);
53
+ gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
54
+ gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
55
+
56
+ osc.start();
57
+ osc.stop(this.ctx.currentTime + 0.15);
58
+ }
59
+
60
+ public playTurn() {
61
+ if (!this.isEnabled) return;
62
+ this.initCtx();
63
+ if (!this.ctx) return;
64
+
65
+ const now = this.ctx.currentTime;
66
+ const osc1 = this.ctx.createOscillator();
67
+ const osc2 = this.ctx.createOscillator();
68
+ const gain = this.ctx.createGain();
69
+
70
+ osc1.connect(gain);
71
+ osc2.connect(gain);
72
+ gain.connect(this.ctx.destination);
73
+
74
+ osc1.frequency.setValueAtTime(600, now);
75
+ osc2.frequency.setValueAtTime(900, now + 0.06);
76
+
77
+ gain.gain.setValueAtTime(0.1, now);
78
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
79
+
80
+ osc1.start(now);
81
+ osc1.stop(now + 0.06);
82
+
83
+ osc2.start(now + 0.06);
84
+ osc2.stop(now + 0.2);
85
+ }
86
+
87
+ public playTimeUp() {
88
+ if (!this.isEnabled) return;
89
+ this.initCtx();
90
+ if (!this.ctx) return;
91
+
92
+ const now = this.ctx.currentTime;
93
+ const osc = this.ctx.createOscillator();
94
+ const gain = this.ctx.createGain();
95
+
96
+ osc.connect(gain);
97
+ gain.connect(this.ctx.destination);
98
+
99
+ osc.type = 'sawtooth';
100
+ osc.frequency.setValueAtTime(300, now);
101
+ osc.frequency.linearRampToValueAtTime(150, now + 0.8);
102
+
103
+ gain.gain.setValueAtTime(0.15, now);
104
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 0.8);
105
+
106
+ osc.start();
107
+ osc.stop(now + 0.8);
108
+ }
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ export interface TurnRecord {
2
+ playerId: string;
3
+ round: number;
4
+ duration: number;
5
+ }
6
+
7
+ export class StatsManager {
8
+ private startTime: number = 0;
9
+ private endTime: number = 0;
10
+ private turns: TurnRecord[] = [];
11
+ private currentTurnStart: number = 0;
12
+
13
+ public start() {
14
+ this.startTime = Date.now();
15
+ this.turns = [];
16
+ this.currentTurnStart = 0;
17
+ }
18
+
19
+ public end() {
20
+ this.endTime = Date.now();
21
+ }
22
+
23
+ public startTurn() {
24
+ this.currentTurnStart = Date.now();
25
+ }
26
+
27
+ public endTurn(playerId: string, round: number) {
28
+ if (this.currentTurnStart === 0) return;
29
+ const duration = (Date.now() - this.currentTurnStart) / 1000;
30
+ this.turns.push({ playerId, round, duration: +duration.toFixed(2) });
31
+ this.currentTurnStart = 0;
32
+ }
33
+
34
+ public pauseTurn() {
35
+ if (this.currentTurnStart === 0) return;
36
+ const elapsed = (Date.now() - this.currentTurnStart) / 1000;
37
+ this.currentTurnStart = Date.now();
38
+ return elapsed;
39
+ }
40
+
41
+ public resumeTurn() {
42
+ this.currentTurnStart = Date.now();
43
+ }
44
+
45
+ public getTotalElapsedTime(): number {
46
+ const end = this.endTime || Date.now();
47
+ return (end - this.startTime) / 1000;
48
+ }
49
+
50
+ public getPlayerStats(playerId: string) {
51
+ const playerTurns = this.turns.filter(t => t.playerId === playerId);
52
+ if (playerTurns.length === 0) {
53
+ return { total: 0, average: 0, max: 0, count: 0 };
54
+ }
55
+ const total = playerTurns.reduce((acc, t) => acc + t.duration, 0);
56
+ const max = Math.max(...playerTurns.map(t => t.duration));
57
+ const average = total / playerTurns.length;
58
+ return {
59
+ total: +total.toFixed(2),
60
+ average: +average.toFixed(2),
61
+ max: +max.toFixed(2),
62
+ count: playerTurns.length
63
+ };
64
+ }
65
+
66
+ public getSlowestPlayerId(playerIds: string[]): string | null {
67
+ let slowestId: string | null = null;
68
+ let maxAvg = -1;
69
+ for (const id of playerIds) {
70
+ const stats = this.getPlayerStats(id);
71
+ if (stats.count > 0 && stats.average > maxAvg) {
72
+ maxAvg = stats.average;
73
+ slowestId = id;
74
+ }
75
+ }
76
+ return slowestId;
77
+ }
78
+
79
+ public getRanking(playerIds: string[]) {
80
+ return playerIds
81
+ .map(id => ({
82
+ id,
83
+ ...this.getPlayerStats(id)
84
+ }))
85
+ .sort((a, b) => b.average - a.average);
86
+ }
87
+ }
@@ -0,0 +1,74 @@
1
+ import type { GameState } from './GameState';
2
+ import type { PlayerConfig } from '../types';
3
+
4
+ import { formatTime, formatFullTime } from './FormatHelper';
5
+ import { spawnConfetti } from './particles';
6
+
7
+ export class StatsModalManager {
8
+ private state: GameState;
9
+ private statsModal: HTMLElement;
10
+ private onRestart: () => void;
11
+
12
+ constructor(state: GameState, onRestart: () => void) {
13
+ this.state = state;
14
+ this.onRestart = onRestart;
15
+ this.statsModal = document.getElementById('stats-modal') as HTMLElement;
16
+ this.initEvents();
17
+ }
18
+
19
+ private initEvents() {
20
+ document.getElementById('stats-close-btn')?.addEventListener('click', () => {
21
+ this.statsModal.style.display = 'none';
22
+ });
23
+ document.getElementById('stats-restart-btn')?.addEventListener('click', () => {
24
+ this.statsModal.style.display = 'none';
25
+ this.onRestart();
26
+ });
27
+ }
28
+
29
+ public show(roundNumber: number) {
30
+ this.state.engine.pauseAll();
31
+ this.state.stats.end();
32
+ this.statsModal.style.display = 'flex';
33
+
34
+ document.getElementById('stats-total-time')!.textContent = formatFullTime(this.state.stats.getTotalElapsedTime());
35
+ document.getElementById('stats-total-rounds')!.textContent = roundNumber.toString();
36
+
37
+ const slowestId = this.state.stats.getSlowestPlayerId(this.state.players.map(p => p.id));
38
+ const slowestPlayer = this.state.players.find(p => p.id === slowestId);
39
+ document.getElementById('stats-slowest-player')!.textContent = slowestPlayer ? slowestPlayer.name : '-';
40
+
41
+ this.renderRankingList();
42
+
43
+ const statsCard = this.statsModal.querySelector('.stats-modal-card') as HTMLElement;
44
+ if (statsCard) spawnConfetti(statsCard, 60);
45
+ }
46
+
47
+ private renderRankingList() {
48
+ const ranking = this.state.stats.getRanking(this.state.players.map(p => p.id));
49
+ const container = document.getElementById('stats-players-ranking') as HTMLElement;
50
+ container.innerHTML = '';
51
+
52
+ const maxAvg = Math.max(...ranking.map(r => r.average), 1);
53
+ ranking.forEach(r => {
54
+ const p = this.state.players.find(x => x.id === r.id);
55
+ if (p) this.appendRankingRow(container, p, r, maxAvg);
56
+ });
57
+ }
58
+
59
+ private appendRankingRow(container: HTMLElement, p: PlayerConfig, r: { id: string; average: number; max: number }, maxAvg: number) {
60
+ const pct = (r.average / maxAvg) * 100;
61
+ const row = document.createElement('div');
62
+ row.className = 'ranking-row';
63
+ row.innerHTML = `
64
+ <div class="ranking-info">
65
+ <span style="color: var(--color-text-${p.color})">${p.name}</span>
66
+ <span>Avg: ${formatTime(r.average)} (Max: ${formatTime(r.max)})</span>
67
+ </div>
68
+ <div class="ranking-bar-outer">
69
+ <div class="ranking-bar-inner" style="width: ${pct}%; background-color: var(--color-${p.color})"></div>
70
+ </div>
71
+ `;
72
+ container.appendChild(row);
73
+ }
74
+ }
@@ -0,0 +1,175 @@
1
+ import type { TimerState, TimerEngine, TimerConfig } from '../types';
2
+ import { createTimerState, addTimerTime, removeTimerTime } from './TimerStateHelper';
3
+ import { TimerEngineClock } from './TimerEngineClock';
4
+
5
+ export class TimerEngineImpl extends TimerEngineClock implements TimerEngine {
6
+ constructor(config?: TimerConfig) {
7
+ super(config);
8
+ }
9
+
10
+ public create(id: string, label: string, duration: number) {
11
+ this.timers.set(id, createTimerState(id, label, duration));
12
+ }
13
+
14
+ private pauseActiveTimer(active: TimerState) {
15
+ if (active.status !== 'running' && active.status !== 'overtime') {
16
+ return;
17
+ }
18
+ const now = this.getNow();
19
+ const elapsed = (now - (active._lastTickAt ?? now)) / 1000;
20
+ if (active.remaining <= 0) {
21
+ active.overtime = +(active.overtime + elapsed).toFixed(3);
22
+ } else {
23
+ active.remaining = Math.max(0, +(active.remaining - elapsed).toFixed(3));
24
+ }
25
+ active._lastTickAt = 0;
26
+ active.status = 'paused';
27
+ if (this.currentMode === 'fischer') {
28
+ active.remaining = +(active.remaining + this.currentIncrement).toFixed(3);
29
+ }
30
+ }
31
+
32
+ public start(id: string) {
33
+ const t = this.timers.get(id);
34
+ if (!t || !this.canStart(t)) return;
35
+ this.switchActiveTimer(id);
36
+ this.setupTurnRemaining(t);
37
+ t._lastTickAt = this.getNow();
38
+ t.status = 'running';
39
+ this.activeId = id;
40
+ this.delayRemaining = this.currentDelay;
41
+ this.startClock();
42
+ }
43
+
44
+ private canStart(t: TimerState): boolean {
45
+ return t.status === 'idle' || t.status === 'paused';
46
+ }
47
+
48
+ private switchActiveTimer(id: string) {
49
+ if (this.activeId && this.activeId !== id) {
50
+ const active = this.timers.get(this.activeId);
51
+ if (active) this.pauseActiveTimer(active);
52
+ }
53
+ }
54
+
55
+ private setupTurnRemaining(t: TimerState) {
56
+ if (this.currentMode === 'turn' && t.status === 'idle') {
57
+ t.remaining = t.initialDuration;
58
+ }
59
+ }
60
+
61
+ public pause(id: string) {
62
+ const t = this.timers.get(id);
63
+ if (!t || (t.status !== 'running' && t.status !== 'overtime')) {
64
+ return;
65
+ }
66
+ if (this.activeId === id) {
67
+ this.pauseActiveTimer(t);
68
+ this.activeId = undefined;
69
+ this.stopClock();
70
+ } else {
71
+ t.status = 'paused';
72
+ }
73
+ }
74
+
75
+ public resume(id: string) {
76
+ const t = this.timers.get(id);
77
+ if (t && t.status === 'paused') {
78
+ this.start(id);
79
+ }
80
+ }
81
+
82
+ public reset(id: string) {
83
+ const t = this.timers.get(id);
84
+ if (!t) return;
85
+ const wasActive = (t.status === 'running' || t.status === 'overtime') && this.activeId === id;
86
+ t.remaining = t.initialDuration;
87
+ t.overtime = 0;
88
+ t.roundsPlayed = 0;
89
+ t.status = 'idle';
90
+ t._lastTickAt = 0;
91
+ if (wasActive) {
92
+ this.activeId = undefined;
93
+ this.stopClock();
94
+ }
95
+ }
96
+
97
+ public addTime(id: string, seconds: number) {
98
+ const t = this.timers.get(id);
99
+ if (t) {
100
+ addTimerTime(t, seconds);
101
+ }
102
+ }
103
+
104
+ public removeTime(id: string, seconds: number) {
105
+ const t = this.timers.get(id);
106
+ if (t) {
107
+ removeTimerTime(t, seconds, () => this.notifyExpired(id));
108
+ }
109
+ }
110
+
111
+ public switchTurn(nextId: string) {
112
+ if (this.activeId) {
113
+ const current = this.timers.get(this.activeId);
114
+ if (current) this.pauseActiveTimer(current);
115
+ }
116
+ const next = this.timers.get(nextId);
117
+ if (!next || next.status === 'expired') {
118
+ return;
119
+ }
120
+ if (next.status === 'idle' || next.status === 'paused') {
121
+ this.startNextPlayer(next);
122
+ }
123
+ }
124
+
125
+ private startNextPlayer(next: TimerState) {
126
+ if (this.currentMode === 'turn') {
127
+ next.remaining = next.initialDuration;
128
+ }
129
+ next._lastTickAt = this.getNow();
130
+ next.status = 'running';
131
+ next.roundsPlayed += 1;
132
+ this.activeId = next.id;
133
+ this.delayRemaining = this.currentDelay;
134
+ this.startClock();
135
+ }
136
+
137
+ public switchToNextPlayer(playerIds: string[], currentIndex: number) {
138
+ const nextIndex = (currentIndex + 1) % playerIds.length;
139
+ const nextId = playerIds[nextIndex];
140
+ return { nextId, nextIndex };
141
+ }
142
+
143
+ public pauseAll() {
144
+ if (this.activeId) {
145
+ const active = this.timers.get(this.activeId);
146
+ if (active) this.pauseActiveTimer(active);
147
+ }
148
+ this.stopClock();
149
+ this.activeId = undefined;
150
+ }
151
+
152
+ public resumeAll(playerIds: string[], activeIndex: number) {
153
+ const id = playerIds[activeIndex];
154
+ if (id) this.resume(id);
155
+ }
156
+
157
+ public destroyTimer(id: string) {
158
+ const t = this.timers.get(id);
159
+ if (!t) return;
160
+ if (this.activeId === id) {
161
+ this.activeId = undefined;
162
+ this.stopClock();
163
+ }
164
+ this.timers.delete(id);
165
+ }
166
+
167
+ public destroy() {
168
+ this.stopClock();
169
+ this.timers.clear();
170
+ this.expiredCallbacks = [];
171
+ this.tickCallbacks = [];
172
+ this.warningCallbacks = [];
173
+ this.activeId = undefined;
174
+ }
175
+ }