@jjlmoya/utils-sports 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/src/entries.ts +5 -1
  3. package/src/tests/locale_completeness.test.ts +1 -1
  4. package/src/tests/tool_validation.test.ts +2 -2
  5. package/src/tool/snookerScoreKeeper/audio.ts +84 -0
  6. package/src/tool/snookerScoreKeeper/bibliography.astro +6 -0
  7. package/src/tool/snookerScoreKeeper/bibliography.ts +10 -0
  8. package/src/tool/snookerScoreKeeper/component.astro +153 -0
  9. package/src/tool/snookerScoreKeeper/entry.ts +30 -0
  10. package/src/tool/snookerScoreKeeper/game-logic.ts +181 -0
  11. package/src/tool/snookerScoreKeeper/i18n/de.ts +206 -0
  12. package/src/tool/snookerScoreKeeper/i18n/en.ts +206 -0
  13. package/src/tool/snookerScoreKeeper/i18n/es.ts +206 -0
  14. package/src/tool/snookerScoreKeeper/i18n/fr.ts +206 -0
  15. package/src/tool/snookerScoreKeeper/i18n/id.ts +206 -0
  16. package/src/tool/snookerScoreKeeper/i18n/it.ts +206 -0
  17. package/src/tool/snookerScoreKeeper/i18n/ja.ts +206 -0
  18. package/src/tool/snookerScoreKeeper/i18n/ko.ts +206 -0
  19. package/src/tool/snookerScoreKeeper/i18n/nl.ts +206 -0
  20. package/src/tool/snookerScoreKeeper/i18n/pl.ts +206 -0
  21. package/src/tool/snookerScoreKeeper/i18n/pt.ts +206 -0
  22. package/src/tool/snookerScoreKeeper/i18n/ru.ts +206 -0
  23. package/src/tool/snookerScoreKeeper/i18n/sv.ts +206 -0
  24. package/src/tool/snookerScoreKeeper/i18n/tr.ts +206 -0
  25. package/src/tool/snookerScoreKeeper/i18n/zh.ts +206 -0
  26. package/src/tool/snookerScoreKeeper/index.ts +9 -0
  27. package/src/tool/snookerScoreKeeper/logic.ts +210 -0
  28. package/src/tool/snookerScoreKeeper/particles.ts +27 -0
  29. package/src/tool/snookerScoreKeeper/render.ts +136 -0
  30. package/src/tool/snookerScoreKeeper/seo.astro +15 -0
  31. package/src/tool/snookerScoreKeeper/snooker-frame-tracker-break-calculator.css +766 -0
  32. package/src/tool/snookerScoreKeeper/ui.ts +34 -0
  33. package/src/tools.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
package/src/entries.ts CHANGED
@@ -22,6 +22,8 @@ export { streetballScoreKeeper } from './tool/streetballScoreKeeper/entry';
22
22
  export type { StreetballLocaleContent } from './tool/streetballScoreKeeper/entry';
23
23
  export { beachVolleyballScoreKeeper } from './tool/beachVolleyballScoreKeeper/entry';
24
24
  export type { BeachVolleyballLocaleContent } from './tool/beachVolleyballScoreKeeper/entry';
25
+ export { snookerScoreKeeper } from './tool/snookerScoreKeeper/entry';
26
+ export type { SnookerScoreKeeperLocaleContent } from './tool/snookerScoreKeeper/entry';
25
27
  export { sportsCategory } from './category';
26
28
  import { basketScoreKeeper } from './tool/basketScoreKeeper/entry';
27
29
  import { footballScoreKeeper } from './tool/footballScoreKeeper/entry';
@@ -35,4 +37,6 @@ import { dartsScoreKeeper } from './tool/dartsScoreKeeper/entry';
35
37
  import { padelScoreKeeper } from './tool/padelScoreKeeper/entry';
36
38
  import { streetballScoreKeeper } from './tool/streetballScoreKeeper/entry';
37
39
  import { beachVolleyballScoreKeeper } from './tool/beachVolleyballScoreKeeper/entry';
38
- export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper, beachVolleyballScoreKeeper];
40
+ import { snookerScoreKeeper } from './tool/snookerScoreKeeper/entry';
41
+ export const ALL_ENTRIES = [basketScoreKeeper, footballScoreKeeper, gymTracker, pingPongScoreKeeper, reactionTester, scoreKeeper, tournamentBracket, tennisScoreKeeper, dartsScoreKeeper, padelScoreKeeper, streetballScoreKeeper, beachVolleyballScoreKeeper, snookerScoreKeeper];
42
+
@@ -3,6 +3,6 @@ import { ALL_TOOLS } from '../tools';
3
3
 
4
4
  describe('Locale Completeness Validation', () => {
5
5
  it('all tools registered', () => {
6
- expect(ALL_TOOLS.length).toBe(12);
6
+ expect(ALL_TOOLS.length).toBe(13);
7
7
  });
8
8
  });
@@ -4,8 +4,8 @@ import { sportsCategory } from '../data';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 12 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(12);
7
+ it('should have 13 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(13);
9
9
  });
10
10
 
11
11
  it('sportsCategory should be defined', () => {
@@ -0,0 +1,84 @@
1
+ import { el } from './render';
2
+
3
+ let isMuted = false;
4
+
5
+ try {
6
+ isMuted = localStorage.getItem('sn_muted') === 'true';
7
+ } catch {}
8
+
9
+ function updateSoundIcon(): void {
10
+ const onIcon = el('sn-sound-icon-on');
11
+ const offIcon = el('sn-sound-icon-off');
12
+ if (onIcon && offIcon) {
13
+ onIcon.style.display = isMuted ? 'none' : 'block';
14
+ offIcon.style.display = isMuted ? 'block' : 'none';
15
+ }
16
+ }
17
+
18
+ export function setMuted(muted: boolean): void {
19
+ isMuted = muted;
20
+ try {
21
+ localStorage.setItem('sn_muted', String(muted));
22
+ } catch {}
23
+ updateSoundIcon();
24
+ }
25
+
26
+ export function getMuted(): boolean {
27
+ return isMuted;
28
+ }
29
+
30
+ export function playPocket(): void {
31
+ if (isMuted) return;
32
+ try {
33
+ const webkitCtx = (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
34
+ const ctx = new (window.AudioContext || webkitCtx)();
35
+ const osc = ctx.createOscillator();
36
+ const gain = ctx.createGain();
37
+ osc.type = 'sine';
38
+ osc.frequency.setValueAtTime(160, ctx.currentTime);
39
+ osc.frequency.exponentialRampToValueAtTime(50, ctx.currentTime + 0.2);
40
+ osc.connect(gain);
41
+ gain.connect(ctx.destination);
42
+ gain.gain.setValueAtTime(0.4, ctx.currentTime);
43
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2);
44
+ osc.start();
45
+ osc.stop(ctx.currentTime + 0.2);
46
+ } catch {}
47
+ }
48
+
49
+ export function playBuzzer(): void {
50
+ if (isMuted) return;
51
+ try {
52
+ const webkitCtx = (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
53
+ const ctx = new (window.AudioContext || webkitCtx)();
54
+ const osc = ctx.createOscillator();
55
+ const gain = ctx.createGain();
56
+ osc.type = 'sawtooth';
57
+ osc.frequency.setValueAtTime(100, ctx.currentTime);
58
+ osc.connect(gain);
59
+ gain.connect(ctx.destination);
60
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
61
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.8);
62
+ osc.start();
63
+ osc.stop(ctx.currentTime + 0.8);
64
+ } catch {}
65
+ }
66
+
67
+ export function playChime(): void {
68
+ if (isMuted) return;
69
+ try {
70
+ const webkitCtx = (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
71
+ const ctx = new (window.AudioContext || webkitCtx)();
72
+ const osc = ctx.createOscillator();
73
+ const gain = ctx.createGain();
74
+ osc.type = 'sine';
75
+ osc.frequency.setValueAtTime(523.25, ctx.currentTime);
76
+ osc.frequency.setValueAtTime(659.25, ctx.currentTime + 0.1);
77
+ osc.connect(gain);
78
+ gain.connect(ctx.destination);
79
+ gain.gain.setValueAtTime(0.25, ctx.currentTime);
80
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
81
+ osc.start();
82
+ osc.stop(ctx.currentTime + 0.4);
83
+ } catch {}
84
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliography } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliography} />
@@ -0,0 +1,10 @@
1
+ export const bibliography = [
2
+ {
3
+ name: 'WPBSA Official Snooker Rules',
4
+ url: 'https://wpbsa.com/rules/',
5
+ },
6
+ {
7
+ name: 'World Snooker Tour Rules and Regulations',
8
+ url: 'https://wst.tv/rules/',
9
+ },
10
+ ];
@@ -0,0 +1,153 @@
1
+ ---
2
+ import type { KnownLocale } from '../../types';
3
+ import type { SnookerScoreKeeperUI } from './ui';
4
+
5
+ interface Props {
6
+ locale?: KnownLocale;
7
+ ui?: Record<string, unknown>;
8
+ }
9
+
10
+ const { ui } = Astro.props;
11
+ const t = (ui ?? {}) as SnookerScoreKeeperUI;
12
+ ---
13
+
14
+ <div class="sn-console" id="sn-card" data-sn-ui={JSON.stringify(t)}>
15
+ <div class="sn-header">
16
+ <div class="sn-header-actions">
17
+ <button class="sn-action-btn" id="sn-sound-btn" title={t.toggleSound}>
18
+ <svg id="sn-sound-icon-on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
19
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
20
+ <path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/>
21
+ </svg>
22
+ <svg id="sn-sound-icon-off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display: none;">
23
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
24
+ <line x1="23" y1="9" x2="17" y2="15"/>
25
+ <line x1="17" y1="9" x2="23" y2="15"/>
26
+ </svg>
27
+ </button>
28
+ <button class="sn-action-btn" id="sn-reset-btn" title={t.reset}>
29
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
30
+ <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
31
+ <path d="M16 3h5v5M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
32
+ <path d="M8 21H3v-5"/>
33
+ </svg>
34
+ </button>
35
+ <button class="sn-action-btn" data-sn-fs title={t.fullscreen}>
36
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
37
+ <path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>
38
+ </svg>
39
+ </button>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="sn-dashboard-grid">
44
+ <div class="sn-player-panel" id="sn-panel-a">
45
+ <input type="text" class="sn-player-name" id="sn-name-a" value={t.player1} />
46
+ <span class="sn-indicator-active"></span>
47
+ <div class="sn-player-score" id="sn-score-a">0</div>
48
+ <div class="sn-break-value" id="sn-break-a"></div>
49
+ </div>
50
+
51
+ <div class="sn-player-panel" id="sn-panel-b">
52
+ <input type="text" class="sn-player-name" id="sn-name-b" value={t.player2} />
53
+ <span class="sn-indicator-active"></span>
54
+ <div class="sn-player-score" id="sn-score-b">0</div>
55
+ <div class="sn-break-value" id="sn-break-b"></div>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="sn-felt-container">
60
+ <div class="sn-rack">
61
+ <button class="sn-ball-btn sn-ball-red" id="sn-btn-red" title="Red">
62
+ <span class="sn-ball-value">1</span>
63
+ </button>
64
+ <button class="sn-ball-btn sn-ball-yellow" id="sn-btn-yellow" title="Yellow">
65
+ <span class="sn-ball-value">2</span>
66
+ </button>
67
+ <button class="sn-ball-btn sn-ball-green" id="sn-btn-green" title="Green">
68
+ <span class="sn-ball-value">3</span>
69
+ </button>
70
+ <button class="sn-ball-btn sn-ball-brown" id="sn-btn-brown" title="Brown">
71
+ <span class="sn-ball-value">4</span>
72
+ </button>
73
+ <button class="sn-ball-btn sn-ball-blue" id="sn-btn-blue" title="Blue">
74
+ <span class="sn-ball-value">5</span>
75
+ </button>
76
+ <button class="sn-ball-btn sn-ball-pink" id="sn-btn-pink" title="Pink">
77
+ <span class="sn-ball-value">6</span>
78
+ </button>
79
+ <button class="sn-ball-btn sn-ball-black" id="sn-btn-black" title="Black">
80
+ <span class="sn-ball-value">7</span>
81
+ </button>
82
+ </div>
83
+
84
+ <div class="sn-interactive-hud">
85
+ <div class="sn-potted-strip" id="sn-potted-strip"></div>
86
+ <div class="sn-hud-metrics">
87
+ <div class="sn-metric-box">
88
+ <div class="sn-metric-lbl">{t.redsRemaining}</div>
89
+ <div class="sn-metric-val" id="sn-reds-remaining">15</div>
90
+ </div>
91
+ <div class="sn-metric-box">
92
+ <div class="sn-metric-lbl">{t.remainingPoints}</div>
93
+ <div class="sn-metric-val sn-gold" id="sn-points-remaining">147</div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <div class="sn-gauge-container">
100
+ <div class="sn-status-badge sn-status-normal" id="sn-status-badge">{t.statusNormal}</div>
101
+ <div class="sn-gauge-track">
102
+ <div class="sn-gauge-bar" id="sn-gauge-bar"></div>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="sn-control-toolbar">
107
+ <button class="sn-btn-action" id="sn-undo-btn">
108
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
109
+ <path d="M3 7v6h6M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>
110
+ </svg>
111
+ Undo
112
+ </button>
113
+ <button class="sn-btn-action" id="sn-btn-foul">
114
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
115
+ <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
116
+ </svg>
117
+ {t.foul}
118
+ </button>
119
+ <button class="sn-btn-action sn-btn-end-turn" id="sn-btn-end-turn">
120
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
121
+ <path d="M5 12h14M12 5l7 7-7 7"/>
122
+ </svg>
123
+ {t.endTurn}
124
+ </button>
125
+ </div>
126
+
127
+ <div class="sn-foul-panel" id="sn-foul-panel">
128
+ <div class="sn-foul-title">{t.foulTitle}</div>
129
+ <div class="sn-foul-grid">
130
+ <button class="sn-btn-foul-select" id="sn-btn-foul-4">{t.foulOnRed} (4)</button>
131
+ <button class="sn-btn-foul-select" id="sn-btn-foul-5">{t.foulOnBlue} (5)</button>
132
+ <button class="sn-btn-foul-select" id="sn-btn-foul-6">{t.foulOnPink} (6)</button>
133
+ <button class="sn-btn-foul-select" id="sn-btn-foul-7">{t.foulOnBlack} (7)</button>
134
+ </div>
135
+ </div>
136
+
137
+ <div id="sn-particle-container" class="sn-particle-container"></div>
138
+
139
+ <div class="sn-reset-modal" id="sn-modal">
140
+ <div class="sn-modal-content">
141
+ <div class="sn-modal-text">{t.resetConfirm}</div>
142
+ <div class="sn-modal-btns">
143
+ <button class="sn-btn-modal sn-btn-modal-cancel" id="sn-modal-cancel">{t.cancel}</button>
144
+ <button class="sn-btn-modal sn-btn-modal-confirm" id="sn-modal-confirm">{t.confirm}</button>
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ <script>
151
+ import { initSnookerScoreKeeper } from './logic';
152
+ initSnookerScoreKeeper();
153
+ </script>
@@ -0,0 +1,30 @@
1
+ import type { SportsToolEntry, ToolLocaleContent } from '../../types';
2
+ import type { SnookerScoreKeeperUI } from './ui';
3
+
4
+ export type { SnookerScoreKeeperUI };
5
+ export type SnookerScoreKeeperLocaleContent = ToolLocaleContent<SnookerScoreKeeperUI>;
6
+
7
+ export const snookerScoreKeeper: SportsToolEntry<SnookerScoreKeeperUI> = {
8
+ id: 'snooker-frame-tracker-break-calculator',
9
+ icons: {
10
+ bg: 'mdi:billiards',
11
+ fg: 'mdi:scoreboard-outline',
12
+ },
13
+ i18n: {
14
+ en: () => import('./i18n/en').then((m) => m.content),
15
+ es: () => import('./i18n/es').then((m) => m.content),
16
+ fr: () => import('./i18n/fr').then((m) => m.content),
17
+ de: () => import('./i18n/de').then((m) => m.content),
18
+ id: () => import('./i18n/id').then((m) => m.content),
19
+ it: () => import('./i18n/it').then((m) => m.content),
20
+ ja: () => import('./i18n/ja').then((m) => m.content),
21
+ ko: () => import('./i18n/ko').then((m) => m.content),
22
+ nl: () => import('./i18n/nl').then((m) => m.content),
23
+ pl: () => import('./i18n/pl').then((m) => m.content),
24
+ pt: () => import('./i18n/pt').then((m) => m.content),
25
+ ru: () => import('./i18n/ru').then((m) => m.content),
26
+ sv: () => import('./i18n/sv').then((m) => m.content),
27
+ tr: () => import('./i18n/tr').then((m) => m.content),
28
+ zh: () => import('./i18n/zh').then((m) => m.content),
29
+ },
30
+ };
@@ -0,0 +1,181 @@
1
+ export interface SnookerMatchState {
2
+ scoreA: number;
3
+ scoreB: number;
4
+ activePlayer: 'a' | 'b';
5
+ currentBreak: number;
6
+ breakBalls: string[];
7
+ redsOnTable: number;
8
+ expecting: 'red' | 'color' | 'yellow' | 'green' | 'brown' | 'blue' | 'pink' | 'black' | 'ended';
9
+ remainingPoints: number;
10
+ status: 'normal' | 'safe' | 'need-snookers' | 'deciding-black';
11
+ leader: 'a' | 'b' | null;
12
+ }
13
+
14
+ export function createInitialMatch(): SnookerMatchState {
15
+ return {
16
+ scoreA: 0,
17
+ scoreB: 0,
18
+ activePlayer: 'a',
19
+ currentBreak: 0,
20
+ breakBalls: [],
21
+ redsOnTable: 15,
22
+ expecting: 'red',
23
+ remainingPoints: 147,
24
+ status: 'normal',
25
+ leader: null,
26
+ };
27
+ }
28
+
29
+ const COLOR_VALUES: Record<string, number> = {
30
+ red: 1,
31
+ yellow: 2,
32
+ green: 3,
33
+ brown: 4,
34
+ blue: 5,
35
+ pink: 6,
36
+ black: 7,
37
+ };
38
+
39
+ const SEQUENCE_ORDER = ['yellow', 'green', 'brown', 'blue', 'pink', 'black'];
40
+
41
+ function calculateRemainingPoints(
42
+ reds: number,
43
+ expecting: SnookerMatchState['expecting']
44
+ ): number {
45
+ if (reds > 0) {
46
+ if (expecting === 'color') {
47
+ return (reds * 8) + 34;
48
+ }
49
+ return (reds * 8) + 27;
50
+ }
51
+ const idx = SEQUENCE_ORDER.indexOf(expecting as string);
52
+ if (idx === -1) {
53
+ if (expecting === 'black') return 7;
54
+ return 0;
55
+ }
56
+ let sum = 0;
57
+ for (let i = idx; i < SEQUENCE_ORDER.length; i++) {
58
+ sum += COLOR_VALUES[SEQUENCE_ORDER[i]];
59
+ }
60
+ return sum;
61
+ }
62
+
63
+ function getLeader(scoreA: number, scoreB: number): 'a' | 'b' | null {
64
+ if (scoreA > scoreB) return 'a';
65
+ if (scoreB > scoreA) return 'b';
66
+ return null;
67
+ }
68
+
69
+ function determineStatus(
70
+ scoreA: number,
71
+ scoreB: number,
72
+ rem: number,
73
+ activePlayer: 'a' | 'b'
74
+ ): SnookerMatchState['status'] {
75
+ if (rem === 0) {
76
+ return scoreA === scoreB ? 'deciding-black' : 'safe';
77
+ }
78
+ const diff = Math.abs(scoreA - scoreB);
79
+ if (diff > rem) return 'safe';
80
+ if (diff === rem) return 'normal';
81
+
82
+ const activeScore = activePlayer === 'a' ? scoreA : scoreB;
83
+ const opponentScore = activePlayer === 'a' ? scoreB : scoreA;
84
+ if (opponentScore > activeScore + rem) return 'need-snookers';
85
+ return 'normal';
86
+ }
87
+
88
+ function updateStatus(state: SnookerMatchState): SnookerMatchState {
89
+ const rem = state.remainingPoints;
90
+ return {
91
+ ...state,
92
+ leader: getLeader(state.scoreA, state.scoreB),
93
+ status: determineStatus(state.scoreA, state.scoreB, rem, state.activePlayer),
94
+ };
95
+ }
96
+
97
+ function getNextExpecting(
98
+ color: string,
99
+ redsOnTable: number,
100
+ currentExpecting: SnookerMatchState['expecting']
101
+ ): SnookerMatchState['expecting'] {
102
+ if (color === 'red') return 'color';
103
+ if (redsOnTable > 0) return 'red';
104
+
105
+ const currentIdx = SEQUENCE_ORDER.indexOf(currentExpecting as string);
106
+ if (currentIdx !== -1 && currentIdx < SEQUENCE_ORDER.length - 1) {
107
+ return SEQUENCE_ORDER[currentIdx + 1] as SnookerMatchState['expecting'];
108
+ }
109
+ return 'ended';
110
+ }
111
+
112
+ export function potBall(state: SnookerMatchState, color: string): SnookerMatchState {
113
+ const val = COLOR_VALUES[color] || 0;
114
+ const isPlayerA = state.activePlayer === 'a';
115
+ const nextScoreA = isPlayerA ? state.scoreA + val : state.scoreA;
116
+ const nextScoreB = isPlayerA ? state.scoreB : state.scoreB + val;
117
+
118
+ const nextBreak = state.currentBreak + val;
119
+ const nextBreakBalls = [...state.breakBalls, color];
120
+
121
+ const nextReds = color === 'red' ? Math.max(0, state.redsOnTable - 1) : state.redsOnTable;
122
+ const nextExpecting = getNextExpecting(color, nextReds, state.expecting);
123
+ const nextRem = calculateRemainingPoints(nextReds, nextExpecting);
124
+
125
+ return updateStatus({
126
+ ...state,
127
+ scoreA: nextScoreA,
128
+ scoreB: nextScoreB,
129
+ currentBreak: nextBreak,
130
+ breakBalls: nextBreakBalls,
131
+ redsOnTable: nextReds,
132
+ expecting: nextExpecting,
133
+ remainingPoints: nextRem,
134
+ });
135
+ }
136
+
137
+ export function endTurn(state: SnookerMatchState): SnookerMatchState {
138
+ const nextPlayer = state.activePlayer === 'a' ? 'b' : 'a';
139
+ let nextExpecting = state.expecting;
140
+ if (state.redsOnTable > 0) {
141
+ nextExpecting = 'red';
142
+ }
143
+ const nextRem = calculateRemainingPoints(state.redsOnTable, nextExpecting);
144
+
145
+ return updateStatus({
146
+ ...state,
147
+ activePlayer: nextPlayer,
148
+ currentBreak: 0,
149
+ breakBalls: [],
150
+ expecting: nextExpecting,
151
+ remainingPoints: nextRem,
152
+ });
153
+ }
154
+
155
+ export function commitFoul(state: SnookerMatchState, penaltyPoints: number): SnookerMatchState {
156
+ let nextScoreA = state.scoreA;
157
+ let nextScoreB = state.scoreB;
158
+ if (state.activePlayer === 'a') {
159
+ nextScoreB += penaltyPoints;
160
+ } else {
161
+ nextScoreA += penaltyPoints;
162
+ }
163
+
164
+ const nextPlayer = state.activePlayer === 'a' ? 'b' : 'a';
165
+ let nextExpecting = state.expecting;
166
+ if (state.redsOnTable > 0) {
167
+ nextExpecting = 'red';
168
+ }
169
+ const nextRem = calculateRemainingPoints(state.redsOnTable, nextExpecting);
170
+
171
+ return updateStatus({
172
+ ...state,
173
+ scoreA: nextScoreA,
174
+ scoreB: nextScoreB,
175
+ activePlayer: nextPlayer,
176
+ currentBreak: 0,
177
+ breakBalls: [],
178
+ expecting: nextExpecting,
179
+ remainingPoints: nextRem,
180
+ });
181
+ }