@jjlmoya/utils-sports 1.62.1 → 1.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-sports",
3
- "version": "1.62.1",
3
+ "version": "1.64.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -13,6 +13,10 @@ const t = (ui ?? {}) as DartsScoreKeeperUI;
13
13
 
14
14
  <div class="tn-console" id="tn-card" data-tn-ui={JSON.stringify(t)}>
15
15
  <div class="tn-header-panel">
16
+ <div class="tn-mode-group" role="group" aria-label={t.modeLabel ?? 'Game mode'}>
17
+ <button type="button" class="tn-mode-btn" data-dt-mode="solo">{t.soloMode ?? 'Solo'}</button>
18
+ <button type="button" class="tn-mode-btn tn-mode-active" data-dt-mode="versus">{t.versusMode ?? 'Two players'}</button>
19
+ </div>
16
20
  <div class="tn-header-actions">
17
21
  <button class="tn-action-btn" id="tn-toggle-view-btn" title="Toggle View">
18
22
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -70,13 +70,39 @@
70
70
 
71
71
  .tn-header-panel {
72
72
  display: flex;
73
- justify-content: flex-end;
73
+ justify-content: space-between;
74
74
  align-items: center;
75
75
  padding: 0.5rem 1.25rem;
76
76
  background: var(--tn-bg-panel);
77
77
  border-bottom: 1px solid var(--tn-border-base);
78
78
  }
79
79
 
80
+ .tn-mode-group {
81
+ display: inline-flex;
82
+ gap: 0.25rem;
83
+ padding: 0.2rem;
84
+ border: 1px solid var(--tn-border-base);
85
+ border-radius: 999px;
86
+ background: var(--tn-bg-console);
87
+ }
88
+
89
+ .tn-mode-btn {
90
+ padding: 0.42rem 0.72rem;
91
+ border: 0;
92
+ border-radius: 999px;
93
+ color: var(--tn-text-muted);
94
+ background: transparent;
95
+ font: inherit;
96
+ font-size: 0.72rem;
97
+ font-weight: 800;
98
+ cursor: pointer;
99
+ }
100
+
101
+ .tn-mode-btn.tn-mode-active {
102
+ color: #0d1118;
103
+ background: var(--tn-color-active-border);
104
+ }
105
+
80
106
  .tn-header-actions {
81
107
  display: flex;
82
108
  gap: 0.5rem;
@@ -109,6 +135,24 @@
109
135
  background: var(--tn-bg-panel);
110
136
  }
111
137
 
138
+ .tn-mode-solo .tn-scoreboard {
139
+ grid-template-columns: minmax(0, 1fr);
140
+ }
141
+
142
+ .tn-mode-solo #tn-card-b {
143
+ display: none;
144
+ }
145
+
146
+ .tn-mode-solo .tn-history-grid {
147
+ max-width: 22rem;
148
+ margin-inline: auto;
149
+ }
150
+
151
+ .tn-history-solo .tn-history-header-row,
152
+ .tn-history-solo .tn-history-row {
153
+ grid-template-columns: minmax(0, 1fr) 3rem;
154
+ }
155
+
112
156
  .tn-player-card {
113
157
  background: var(--tn-bg-card);
114
158
  border: 2px solid var(--tn-border-base);
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createInitialScore, processThrow } from './game-logic';
3
+
4
+ function completeTurn(mode: 'solo' | 'versus') {
5
+ let score = createInitialScore('501', true, mode);
6
+ score = processThrow(score, 1, 20);
7
+ score = processThrow(score, 1, 20);
8
+ return processThrow(score, 1, 20);
9
+ }
10
+
11
+ describe('darts game modes', () => {
12
+ it('keeps player A active after a solo turn', () => {
13
+ const score = completeTurn('solo');
14
+ expect(score.activePlayer).toBe('a');
15
+ expect(score.playerA.remainingScore).toBe(441);
16
+ expect(score.playerB.remainingScore).toBe(501);
17
+ expect(score.history).toHaveLength(1);
18
+ });
19
+
20
+ it('alternates players in versus mode', () => {
21
+ const score = completeTurn('versus');
22
+ expect(score.activePlayer).toBe('b');
23
+ expect(score.playerA.remainingScore).toBe(441);
24
+ });
25
+
26
+ it('defaults existing callers to versus mode', () => {
27
+ expect(createInitialScore().mode).toBe('versus');
28
+ });
29
+ });
@@ -1,5 +1,6 @@
1
1
  export type PlayerKey = 'a' | 'b';
2
2
  export type DartsFormat = '501' | '301';
3
+ export type DartsMode = 'solo' | 'versus';
3
4
 
4
5
  export interface PlayerState {
5
6
  remainingScore: number;
@@ -31,6 +32,7 @@ export interface TurnRecord {
31
32
 
32
33
  export interface DartsMatchScore {
33
34
  format: DartsFormat;
35
+ mode: DartsMode;
34
36
  doubleOut: boolean;
35
37
  playerA: PlayerState;
36
38
  playerB: PlayerState;
@@ -50,10 +52,15 @@ export function createInitialPlayer(score: number): PlayerState {
50
52
  };
51
53
  }
52
54
 
53
- export function createInitialScore(format: DartsFormat = '501', doubleOut: boolean = true): DartsMatchScore {
55
+ export function createInitialScore(
56
+ format: DartsFormat = '501',
57
+ doubleOut: boolean = true,
58
+ mode: DartsMode = 'versus',
59
+ ): DartsMatchScore {
54
60
  const start = format === '501' ? 501 : 301;
55
61
  return {
56
62
  format,
63
+ mode,
57
64
  doubleOut,
58
65
  playerA: createInitialPlayer(start),
59
66
  playerB: createInitialPlayer(start),
@@ -184,7 +191,11 @@ function concludeTurn(score: DartsMatchScore): void {
184
191
  isBusted: score.turn.isBusted,
185
192
  };
186
193
  score.history.push(record);
187
- score.activePlayer = score.activePlayer === 'a' ? 'b' : 'a';
194
+ if (score.mode === 'solo') {
195
+ score.activePlayer = 'a';
196
+ } else {
197
+ score.activePlayer = score.activePlayer === 'a' ? 'b' : 'a';
198
+ }
188
199
  score.turn = {
189
200
  throws: [],
190
201
  isBusted: false,
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Darts Durchgang',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Spielmodus',
196
+ soloMode: 'Allein',
197
+ versusMode: 'Zwei Spieler',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Kein Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Darts Turn',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Game mode',
196
+ soloMode: 'Solo',
197
+ versusMode: 'Two players',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'No Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Turno de Dardos',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Modo de juego',
196
+ soloMode: 'Individual',
197
+ versusMode: 'Dos jugadores',
195
198
  doubleOut: 'Cierre con Doble',
196
199
  noCheckout: 'Sin Cierre',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Tour de Fléchettes',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Mode de jeu',
196
+ soloMode: 'Solo',
197
+ versusMode: 'Deux joueurs',
195
198
  doubleOut: 'Double Sortie',
196
199
  noCheckout: 'Pas de Fermeture',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Giliran Dart',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Mode permainan',
196
+ soloMode: 'Solo',
197
+ versusMode: 'Dua pemain',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Tidak Ada Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Turno Freccette',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Modalità di gioco',
196
+ soloMode: 'Singolo',
197
+ versusMode: 'Due giocatori',
195
198
  doubleOut: 'Doppia Uscita',
196
199
  noCheckout: 'Nessuna Chiusura',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'ダーツターン',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'ゲームモード',
196
+ soloMode: '一人',
197
+ versusMode: '二人対戦',
195
198
  doubleOut: 'ダブルアウト',
196
199
  noCheckout: 'チェックアウトなし',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: '다트 턴',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: '게임 모드',
196
+ soloMode: '혼자 하기',
197
+ versusMode: '두 명',
195
198
  doubleOut: '더블 아웃',
196
199
  noCheckout: '체크아웃 없음',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Pijlen Beurt',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Spelmodus',
196
+ soloMode: 'Solo',
197
+ versusMode: 'Twee spelers',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Geen Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Rzut Darta',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Tryb gry',
196
+ soloMode: 'Solo',
197
+ versusMode: 'Dwóch graczy',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Brak Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Vez de Dardos',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Modo de jogo',
196
+ soloMode: 'Individual',
197
+ versusMode: 'Dois jogadores',
195
198
  doubleOut: 'Fecho com Duplo',
196
199
  noCheckout: 'Sem Fecho',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Бросок дротика',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Режим игры',
196
+ soloMode: 'Один игрок',
197
+ versusMode: 'Два игрока',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Нет Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Pil Kast',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Spelläge',
196
+ soloMode: 'Solo',
197
+ versusMode: 'Två spelare',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Ingen Checkout',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: 'Dart Atışı',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: 'Oyun modu',
196
+ soloMode: 'Tek oyuncu',
197
+ versusMode: 'İki oyuncu',
195
198
  doubleOut: 'Double Out',
196
199
  noCheckout: 'Checkout Yok',
197
200
  },
@@ -192,6 +192,9 @@ export const content: ToolLocaleContent<DartsScoreKeeperUI> = {
192
192
  dart: '飞镖回合',
193
193
  score301: '301',
194
194
  score501: '501',
195
+ modeLabel: '游戏模式',
196
+ soloMode: '单人',
197
+ versusMode: '双人',
195
198
  doubleOut: '双倍结束',
196
199
  noCheckout: '无法结算',
197
200
  },
@@ -2,6 +2,7 @@ import type { DartsScoreKeeperUI } from './ui';
2
2
  import {
3
3
  type DartsMatchScore,
4
4
  type DartsFormat,
5
+ type DartsMode,
5
6
  createInitialScore,
6
7
  processThrow,
7
8
  } from './game-logic';
@@ -10,8 +11,9 @@ import {
10
11
  render,
11
12
  } from './render';
12
13
  import { buildDartboardSVG } from './dartboard';
14
+ import { setupMode, syncMode } from './mode';
15
+ import { clearStoredScore, loadInitialScore, persistScore } from './score-storage';
13
16
 
14
- const STORAGE_KEY = 'dt_match_state';
15
17
  const NAMES_KEY = 'dt_names';
16
18
 
17
19
  interface GameContext {
@@ -183,49 +185,42 @@ function setupViewToggles(card: HTMLElement): void {
183
185
  });
184
186
  }
185
187
 
186
- function loadInitialScore(): DartsMatchScore {
187
- try {
188
- const s = localStorage.getItem(STORAGE_KEY);
189
- if (s) return JSON.parse(s);
190
- } catch {
191
- return createInitialScore();
192
- }
193
- return createInitialScore();
194
- }
195
-
196
188
  function createSaveFn(stateRef: { score: DartsMatchScore; history: DartsMatchScore[] }, t: DartsScoreKeeperUI): (s: DartsMatchScore, pushHistory?: boolean) => void {
197
189
  return (s: DartsMatchScore, pushHistory = true) => {
198
190
  if (pushHistory) stateRef.history.push(JSON.parse(JSON.stringify(stateRef.score)));
199
191
  stateRef.score = s;
200
- localStorage.setItem(STORAGE_KEY, JSON.stringify(stateRef.score));
192
+ persistScore(stateRef.score);
201
193
  render(stateRef.score, t);
202
194
  };
203
195
  }
204
196
 
205
- function createApiObject(stateRef: { score: DartsMatchScore; history: DartsMatchScore[] }, save: (s: DartsMatchScore, pushHistory?: boolean) => void, t: DartsScoreKeeperUI) {
197
+ function createApiObject(stateRef: { score: DartsMatchScore; history: DartsMatchScore[] }, save: (s: DartsMatchScore, pushHistory?: boolean) => void, t: DartsScoreKeeperUI, card: HTMLElement) {
206
198
  return {
207
199
  resetAll() {
208
- localStorage.removeItem(STORAGE_KEY);
200
+ clearStoredScore();
209
201
  stateRef.history = [];
210
- save(createInitialScore(stateRef.score.format, stateRef.score.doubleOut), false);
202
+ save(createInitialScore(stateRef.score.format, stateRef.score.doubleOut, stateRef.score.mode), false);
211
203
  },
212
204
  setFormat(f: DartsFormat) {
213
205
  document.querySelectorAll('.tn-format-btn').forEach((b) => b.classList.remove('tn-format-active'));
214
206
  document.querySelector(`[data-dt-format="${f}"]`)?.classList.add('tn-format-active');
215
- save(createInitialScore(f, stateRef.score.doubleOut));
207
+ save(createInitialScore(f, stateRef.score.doubleOut, stateRef.score.mode));
216
208
  },
217
209
  toggleDoubleOut() {
218
210
  const nextD = !stateRef.score.doubleOut;
219
211
  document.querySelector('[data-dt-doubleout]')?.classList.toggle('tn-doubleout-active', nextD);
220
- save(createInitialScore(stateRef.score.format, nextD));
212
+ save(createInitialScore(stateRef.score.format, nextD, stateRef.score.mode));
213
+ },
214
+ setMode(mode: DartsMode) {
215
+ syncMode(card, mode);
216
+ save(createInitialScore(stateRef.score.format, stateRef.score.doubleOut, mode));
221
217
  },
222
218
  undo() {
223
219
  const prev = stateRef.history.pop();
224
- if (prev) {
225
- stateRef.score = prev;
226
- localStorage.setItem(STORAGE_KEY, JSON.stringify(stateRef.score));
227
- render(stateRef.score, t);
228
- }
220
+ if (!prev) return;
221
+ stateRef.score = prev;
222
+ persistScore(stateRef.score);
223
+ render(stateRef.score, t);
229
224
  }
230
225
  };
231
226
  }
@@ -248,11 +243,13 @@ function runInit(): void {
248
243
  document.querySelector(`[data-dt-mod="${m}"]`)?.classList.add('tn-mod-active');
249
244
  }
250
245
  };
251
- const api = createApiObject(stateRef, save, t);
246
+ syncMode(card, stateRef.score.mode);
247
+ const api = createApiObject(stateRef, save, t, card);
252
248
  bindClickDelegation({ stateRef, t, saveFn: save }, multRef);
253
249
  setupResetModal(card, api);
254
250
  setupUndoAndFormat(card, api);
255
251
  setupMultiplier(card, multRef);
252
+ setupMode(card, api.setMode);
256
253
  setupViewToggles(card);
257
254
  }
258
255
 
@@ -0,0 +1,14 @@
1
+ import type { DartsMode } from './game-logic';
2
+
3
+ export function syncMode(root: HTMLElement, mode: DartsMode): void {
4
+ root.classList.toggle('tn-mode-solo', mode === 'solo');
5
+ root.querySelectorAll<HTMLElement>('[data-dt-mode]').forEach((button) => {
6
+ button.classList.toggle('tn-mode-active', button.dataset.dtMode === mode);
7
+ });
8
+ }
9
+
10
+ export function setupMode(root: HTMLElement, setMode: (mode: DartsMode) => void): void {
11
+ root.querySelectorAll<HTMLElement>('[data-dt-mode]').forEach((button) => {
12
+ button.addEventListener('click', () => setMode(button.dataset.dtMode as DartsMode));
13
+ });
14
+ }
@@ -63,14 +63,17 @@ function renderTurnDots(score: DartsMatchScore): void {
63
63
  }
64
64
  }
65
65
 
66
- function renderHistoryHeader(t: DartsScoreKeeperUI, histGrid: HTMLElement): void {
66
+ function renderHistoryHeader(score: DartsMatchScore, t: DartsScoreKeeperUI, histGrid: HTMLElement): void {
67
67
  const nameA = el('tn-name-a') as HTMLInputElement;
68
68
  const nameB = el('tn-name-b') as HTMLInputElement;
69
69
  const labelA = nameA ? nameA.value : t.playerA;
70
70
  const labelB = nameB ? nameB.value : t.playerB;
71
71
  const headerRow = document.createElement('div');
72
72
  headerRow.className = 'tn-history-header-row';
73
- headerRow.innerHTML = `
73
+ headerRow.innerHTML = score.mode === 'solo' ? `
74
+ <span class="tn-hist-name tn-hist-a">${labelA}</span>
75
+ <span class="tn-hist-round-lbl">Rnd</span>
76
+ ` : `
74
77
  <span class="tn-hist-name tn-hist-a">${labelA}</span>
75
78
  <span class="tn-hist-round-lbl">Rnd</span>
76
79
  <span class="tn-hist-name tn-hist-b">${labelB}</span>
@@ -78,7 +81,7 @@ function renderHistoryHeader(t: DartsScoreKeeperUI, histGrid: HTMLElement): void
78
81
  histGrid.appendChild(headerRow);
79
82
  }
80
83
 
81
- function renderHistoryRow(round: { roundNum: number; a?: typeof score.history[0]; b?: typeof score.history[0] }, histGrid: HTMLElement): void {
84
+ function renderHistoryRow(round: { roundNum: number; a?: typeof score.history[0]; b?: typeof score.history[0] }, histGrid: HTMLElement, solo: boolean): void {
82
85
  const row = document.createElement('div');
83
86
  row.className = 'tn-history-row';
84
87
  const renderTurn = (turn?: typeof round.a) => {
@@ -88,7 +91,10 @@ function renderHistoryRow(round: { roundNum: number; a?: typeof score.history[0]
88
91
  }
89
92
  return `<span class="tn-hist-val">${turn.points} <span class="tn-hist-sub">(${turn.scoreAfter})</span></span>`;
90
93
  };
91
- row.innerHTML = `
94
+ row.innerHTML = solo ? `
95
+ <div class="tn-hist-cell tn-hist-cell-a">${renderTurn(round.a)}</div>
96
+ <div class="tn-hist-cell tn-hist-cell-round">${round.roundNum}</div>
97
+ ` : `
92
98
  <div class="tn-hist-cell tn-hist-cell-a">${renderTurn(round.a)}</div>
93
99
  <div class="tn-hist-cell tn-hist-cell-round">${round.roundNum}</div>
94
100
  <div class="tn-hist-cell tn-hist-cell-b">${renderTurn(round.b)}</div>
@@ -100,7 +106,8 @@ function renderHistoryList(score: DartsMatchScore, t: DartsScoreKeeperUI): void
100
106
  const histGrid = el('tn-history-list');
101
107
  if (!histGrid) return;
102
108
  histGrid.innerHTML = '';
103
- renderHistoryHeader(t, histGrid);
109
+ renderHistoryHeader(score, t, histGrid);
110
+ histGrid.classList.toggle('tn-history-solo', score.mode === 'solo');
104
111
  interface RoundRow {
105
112
  roundNum: number;
106
113
  a?: typeof score.history[0];
@@ -120,7 +127,7 @@ function renderHistoryList(score: DartsMatchScore, t: DartsScoreKeeperUI): void
120
127
  }
121
128
  });
122
129
  rounds.slice(-6).reverse().forEach((round) => {
123
- renderHistoryRow(round, histGrid);
130
+ renderHistoryRow(round, histGrid, score.mode === 'solo');
124
131
  });
125
132
  }
126
133
 
@@ -0,0 +1,24 @@
1
+ import { createInitialScore, type DartsMatchScore } from './game-logic';
2
+
3
+ const STORAGE_KEY = 'dt_match_state';
4
+
5
+ export function loadInitialScore(): DartsMatchScore {
6
+ try {
7
+ const stored = localStorage.getItem(STORAGE_KEY);
8
+ if (stored) {
9
+ const saved = JSON.parse(stored) as DartsMatchScore;
10
+ return { ...saved, mode: saved.mode ?? 'versus' };
11
+ }
12
+ } catch {
13
+ return createInitialScore();
14
+ }
15
+ return createInitialScore();
16
+ }
17
+
18
+ export function persistScore(score: DartsMatchScore): void {
19
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(score));
20
+ }
21
+
22
+ export function clearStoredScore(): void {
23
+ localStorage.removeItem(STORAGE_KEY);
24
+ }
@@ -15,6 +15,9 @@ export interface DartsScoreKeeperUI {
15
15
  dart: string;
16
16
  score301: string;
17
17
  score501: string;
18
+ modeLabel?: string;
19
+ soloMode?: string;
20
+ versusMode?: string;
18
21
  doubleOut: string;
19
22
  noCheckout: string;
20
23
  }