@jjlmoya/utils-tabletop 1.26.0 → 1.28.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 +1 -1
- package/src/category/index.ts +4 -0
- package/src/entries.ts +4 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/mus-scoreboard/bibliography.astro +16 -0
- package/src/tool/mus-scoreboard/bibliography.ts +12 -0
- package/src/tool/mus-scoreboard/component.astro +169 -0
- package/src/tool/mus-scoreboard/controller.ts +178 -0
- package/src/tool/mus-scoreboard/dom-views.ts +87 -0
- package/src/tool/mus-scoreboard/entry.ts +27 -0
- package/src/tool/mus-scoreboard/evaluator.ts +27 -0
- package/src/tool/mus-scoreboard/i18n/de.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/en.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/es.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/fr.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/id.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/it.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/ja.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/ko.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/nl.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/pl.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/pt.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/ru.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/sv.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/tr.ts +187 -0
- package/src/tool/mus-scoreboard/i18n/zh.ts +187 -0
- package/src/tool/mus-scoreboard/index.ts +11 -0
- package/src/tool/mus-scoreboard/logic.test.ts +82 -0
- package/src/tool/mus-scoreboard/logic.ts +122 -0
- package/src/tool/mus-scoreboard/online-mus-scoreboard.css +740 -0
- package/src/tool/mus-scoreboard/seo.astro +16 -0
- package/src/tool/mus-scoreboard/storage.ts +29 -0
- package/src/tool/mus-scoreboard/ui.ts +63 -0
- package/src/tool/token-stamp-studio/bibliography.astro +16 -0
- package/src/tool/token-stamp-studio/bibliography.ts +12 -0
- package/src/tool/token-stamp-studio/component.astro +91 -0
- package/src/tool/token-stamp-studio/controller.ts +179 -0
- package/src/tool/token-stamp-studio/dom-views.ts +266 -0
- package/src/tool/token-stamp-studio/entry.ts +27 -0
- package/src/tool/token-stamp-studio/evaluator.ts +6 -0
- package/src/tool/token-stamp-studio/i18n/de.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/en.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/es.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/fr.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/id.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/it.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ja.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ko.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/nl.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/pl.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/pt.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/ru.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/sv.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/tr.ts +132 -0
- package/src/tool/token-stamp-studio/i18n/zh.ts +132 -0
- package/src/tool/token-stamp-studio/index.ts +11 -0
- package/src/tool/token-stamp-studio/logic.test.ts +36 -0
- package/src/tool/token-stamp-studio/logic.ts +127 -0
- package/src/tool/token-stamp-studio/seo.astro +16 -0
- package/src/tool/token-stamp-studio/storage.ts +69 -0
- package/src/tool/token-stamp-studio/store.ts +139 -0
- package/src/tool/token-stamp-studio/token-stamp-studio.css +646 -0
- package/src/tool/token-stamp-studio/types.ts +65 -0
- package/src/tool/token-stamp-studio/ui.ts +50 -0
- package/src/tool/token-stamp-studio/workspace-media.ts +36 -0
- package/src/tool/token-stamp-studio/workspace.ts +83 -0
- package/src/tools.ts +4 -0
package/package.json
CHANGED
package/src/category/index.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { diceRollerSimulator } from '../tool/dice-roller-simulator/entry';
|
|
|
2
2
|
import { boardGameTimer } from '../tool/board-game-timer/entry';
|
|
3
3
|
import { dungeonMapGenerator } from '../tool/dungeon-map-generator/entry';
|
|
4
4
|
import { encounterDifficultyCalculator } from '../tool/encounter-difficulty-calculator/entry';
|
|
5
|
+
import { tokenStampStudio } from '../tool/token-stamp-studio/entry';
|
|
6
|
+
import { musScoreboard } from '../tool/mus-scoreboard/entry';
|
|
5
7
|
import type { CategoryLocaleContent, KnownLocale } from '../types';
|
|
6
8
|
|
|
7
9
|
export const tabletopCategory = {
|
|
@@ -11,6 +13,8 @@ export const tabletopCategory = {
|
|
|
11
13
|
boardGameTimer,
|
|
12
14
|
dungeonMapGenerator,
|
|
13
15
|
encounterDifficultyCalculator,
|
|
16
|
+
tokenStampStudio,
|
|
17
|
+
musScoreboard,
|
|
14
18
|
],
|
|
15
19
|
i18n: {
|
|
16
20
|
de: () => import('./i18n/de').then((m) => m.content),
|
package/src/entries.ts
CHANGED
|
@@ -35,6 +35,8 @@ import { hiddenRoleDealer } from './tool/hidden-role-dealer/entry';
|
|
|
35
35
|
import { scatterDirectionSelector } from './tool/scatter-direction-selector/entry';
|
|
36
36
|
import { dungeonMapGenerator } from './tool/dungeon-map-generator/entry';
|
|
37
37
|
import { encounterDifficultyCalculator } from './tool/encounter-difficulty-calculator/entry';
|
|
38
|
+
import { tokenStampStudio } from './tool/token-stamp-studio/entry';
|
|
39
|
+
import { musScoreboard } from './tool/mus-scoreboard/entry';
|
|
38
40
|
|
|
39
41
|
export const ALL_ENTRIES = [
|
|
40
42
|
diceRollerSimulator,
|
|
@@ -50,5 +52,7 @@ export const ALL_ENTRIES = [
|
|
|
50
52
|
scatterDirectionSelector,
|
|
51
53
|
dungeonMapGenerator,
|
|
52
54
|
encounterDifficultyCalculator,
|
|
55
|
+
tokenStampStudio,
|
|
56
|
+
musScoreboard,
|
|
53
57
|
];
|
|
54
58
|
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { fantasyRunesTranslator, FANTASY_RUNES_TRANSLATOR_TOOL } from './tool/fa
|
|
|
7
7
|
export { investigationBoard, INVESTIGATION_BOARD_TOOL } from './tool/investigation-board';
|
|
8
8
|
export { lunarTideTracker, LUNAR_TIDE_TRACKER_TOOL } from './tool/lunar-tide-tracker';
|
|
9
9
|
export { dungeonMapGenerator, DUNGEON_MAP_GENERATOR_TOOL } from './tool/dungeon-map-generator';
|
|
10
|
+
export { tokenStampStudio, TOKEN_STAMP_STUDIO_TOOL } from './tool/token-stamp-studio';
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
export type {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { musScoreboard } 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 = musScoreboard.i18n[locale] || musScoreboard.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'La Federación Española de Mus, Reglamento de Juego',
|
|
6
|
+
url: 'https://lafederaciondemus.es/wp-content/uploads/2026/02/Reglamento-de-Juego-Federacion.pdf',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'La Federación Española de Mus, Reglamento oficial',
|
|
10
|
+
url: 'https://lafederaciondemus.es/reglamento/',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './online-mus-scoreboard.css';
|
|
3
|
+
import { musScoreboard } 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 = musScoreboard.i18n[locale] || musScoreboard.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
const { ui } = content;
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
<div class="mus-scoreboard" data-mus-scoreboard>
|
|
18
|
+
<div class="mus-board-card">
|
|
19
|
+
<section class="mus-onboarding" aria-labelledby="mus-how-to-use">
|
|
20
|
+
<div class="mus-onboarding-mark" aria-hidden="true">
|
|
21
|
+
<span></span>
|
|
22
|
+
<span></span>
|
|
23
|
+
<span></span>
|
|
24
|
+
<span></span>
|
|
25
|
+
</div>
|
|
26
|
+
<div>
|
|
27
|
+
<p class="mus-kicker" id="mus-how-to-use">LIVE TABLE</p>
|
|
28
|
+
<p class="mus-onboarding-copy">{ui.onboarding}</p>
|
|
29
|
+
</div>
|
|
30
|
+
</section>
|
|
31
|
+
|
|
32
|
+
<form class="mus-config" data-config-form>
|
|
33
|
+
<div class="mus-config-heading">
|
|
34
|
+
<div>
|
|
35
|
+
<p class="mus-kicker">SET THE TABLE</p>
|
|
36
|
+
<h2>{ui.matchSettings}</h2>
|
|
37
|
+
</div>
|
|
38
|
+
<span class="mus-rule-seal" aria-hidden="true">MUS</span>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="mus-name-grid">
|
|
41
|
+
<label class="mus-field">
|
|
42
|
+
<span>{ui.pairOneLabel}</span>
|
|
43
|
+
<input type="text" maxlength="24" data-pair-name="0" value={ui.pairOneDefault} required />
|
|
44
|
+
</label>
|
|
45
|
+
<label class="mus-field">
|
|
46
|
+
<span>{ui.pairTwoLabel}</span>
|
|
47
|
+
<input type="text" maxlength="24" data-pair-name="1" value={ui.pairTwoDefault} required />
|
|
48
|
+
</label>
|
|
49
|
+
</div>
|
|
50
|
+
<div class="mus-rules-grid">
|
|
51
|
+
<fieldset class="mus-choice-group">
|
|
52
|
+
<legend>{ui.targetPointsLabel}</legend>
|
|
53
|
+
<p>{ui.targetPointsHint}</p>
|
|
54
|
+
<div class="mus-choice-row">
|
|
55
|
+
<label><input type="radio" name="target-points" data-target-input="30" value="30" /><span>{ui.pointsOption30}</span></label>
|
|
56
|
+
<label><input type="radio" name="target-points" data-target-input="40" value="40" checked /><span>{ui.pointsOption40}</span></label>
|
|
57
|
+
<label><input type="radio" name="target-points" data-target-input="50" value="50" /><span>{ui.pointsOption50}</span></label>
|
|
58
|
+
</div>
|
|
59
|
+
</fieldset>
|
|
60
|
+
<fieldset class="mus-choice-group">
|
|
61
|
+
<legend>{ui.gamesToWinLabel}</legend>
|
|
62
|
+
<p>{ui.gamesToWinHint}</p>
|
|
63
|
+
<div class="mus-choice-row">
|
|
64
|
+
<label><input type="radio" name="games-to-win" data-games-input="1" value="1" /><span>{ui.gamesOption1}</span></label>
|
|
65
|
+
<label><input type="radio" name="games-to-win" data-games-input="2" value="2" checked /><span>{ui.gamesOption2}</span></label>
|
|
66
|
+
<label><input type="radio" name="games-to-win" data-games-input="3" value="3" /><span>{ui.gamesOption3}</span></label>
|
|
67
|
+
</div>
|
|
68
|
+
</fieldset>
|
|
69
|
+
</div>
|
|
70
|
+
<fieldset class="mus-deck-group">
|
|
71
|
+
<legend>{ui.deckLabel}</legend>
|
|
72
|
+
<p>{ui.deckHint}</p>
|
|
73
|
+
<div class="mus-choice-row">
|
|
74
|
+
<label><input type="radio" name="mus-deck" data-deck-input="spanish" value="spanish" checked /><span>{ui.spanishDeck}</span></label>
|
|
75
|
+
<label><input type="radio" name="mus-deck" data-deck-input="french" value="french" /><span>{ui.frenchDeck}</span></label>
|
|
76
|
+
</div>
|
|
77
|
+
<p class="mus-deck-notice" data-french-deck-notice hidden role="status"><span aria-hidden="true">i</span>{ui.frenchDeckNotice}</p>
|
|
78
|
+
</fieldset>
|
|
79
|
+
<p class="mus-form-message" data-config-message hidden role="alert"></p>
|
|
80
|
+
<button class="mus-primary-button" type="submit">{ui.startMatch}</button>
|
|
81
|
+
</form>
|
|
82
|
+
|
|
83
|
+
<section class="mus-match-panel" data-match-panel hidden aria-labelledby="mus-live-match-heading">
|
|
84
|
+
<div class="mus-match-header">
|
|
85
|
+
<div>
|
|
86
|
+
<p class="mus-kicker">{ui.liveMatch}</p>
|
|
87
|
+
<h2 id="mus-live-match-heading"><span data-target-points>40</span> {ui.targetLabel}</h2>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="mus-match-meta">
|
|
90
|
+
<span><strong data-games-to-win>2</strong> {ui.vacaLabel}</span>
|
|
91
|
+
<button class="mus-quiet-button" type="button" data-action="reset">{ui.resetMatch}</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="mus-felt" data-mus-felt data-hand-team="0" aria-label={ui.visualScoreLabel}>
|
|
96
|
+
<div class="mus-felt-caption mus-felt-caption-left" data-pair-label="0"></div>
|
|
97
|
+
<div class="mus-felt-caption mus-felt-caption-right" data-pair-label="1"></div>
|
|
98
|
+
<div class="mus-hand-choices" role="group" aria-label={ui.handLabel}>
|
|
99
|
+
<button class="mus-hand-choice mus-hand-choice-left" type="button" data-action="set-hand" data-team="0" data-hand-choice="0" aria-pressed="true"><span data-hand-choice-label="0">A</span></button>
|
|
100
|
+
<button class="mus-hand-choice mus-hand-choice-right" type="button" data-action="set-hand" data-team="1" data-hand-choice="1" aria-pressed="false"><span data-hand-choice-label="1">B</span></button>
|
|
101
|
+
</div>
|
|
102
|
+
<div class="mus-score-arc mus-score-arc-left" data-score-track="0" role="progressbar" aria-valuemin="0" aria-valuemax="40" aria-valuenow="0"><span class="mus-score-marker" data-score-marker="0" aria-hidden="true"></span></div>
|
|
103
|
+
<div class="mus-score-arc mus-score-arc-right" data-score-track="1" role="progressbar" aria-valuemin="0" aria-valuemax="40" aria-valuenow="0"><span class="mus-score-marker" data-score-marker="1" aria-hidden="true"></span></div>
|
|
104
|
+
<span class="mus-center-suit" data-hand-indicator aria-label={ui.handLabel}>M</span>
|
|
105
|
+
<div class="mus-felt-center">
|
|
106
|
+
<span class="mus-center-label">{ui.scoreLabel}</span>
|
|
107
|
+
<strong><span data-score="0">0</span><i>:</i><span data-score="1">0</span></strong>
|
|
108
|
+
<span class="mus-center-status" data-status-message>{ui.matchSaved}</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="mus-pair-score-row">
|
|
113
|
+
<article class="mus-pair-card mus-pair-card-left">
|
|
114
|
+
<header><span class="mus-pair-index">A</span><h3 data-pair-label="0"></h3><span class="mus-game-count"><span data-games="0">0</span> {ui.gameLabel}</span></header>
|
|
115
|
+
<p class="mus-score-breakdown" data-breakdown="0">0 {ui.amarracoPlural} + 0 {ui.stonePlural}</p>
|
|
116
|
+
<p class="mus-remaining" data-remaining="0">40 {ui.pointsToGo}</p>
|
|
117
|
+
<div class="mus-tally" data-tally="0" aria-label={ui.stonesLabel}></div>
|
|
118
|
+
<div class="mus-score-actions">
|
|
119
|
+
<button type="button" data-action="add-one" data-team="0">+1 <span>{ui.addStone}</span></button>
|
|
120
|
+
<button type="button" data-action="add-five" data-team="0">+5 <span>{ui.addAmarraco}</span></button>
|
|
121
|
+
<button class="mus-close-button" type="button" data-action="close-game" data-team="0" disabled>{ui.closeGame}</button>
|
|
122
|
+
</div>
|
|
123
|
+
</article>
|
|
124
|
+
<article class="mus-pair-card mus-pair-card-right">
|
|
125
|
+
<header><span class="mus-pair-index">B</span><h3 data-pair-label="1"></h3><span class="mus-game-count"><span data-games="1">0</span> {ui.gameLabel}</span></header>
|
|
126
|
+
<p class="mus-score-breakdown" data-breakdown="1">0 {ui.amarracoPlural} + 0 {ui.stonePlural}</p>
|
|
127
|
+
<p class="mus-remaining" data-remaining="1">40 {ui.pointsToGo}</p>
|
|
128
|
+
<div class="mus-tally" data-tally="1" aria-label={ui.stonesLabel}></div>
|
|
129
|
+
<div class="mus-score-actions">
|
|
130
|
+
<button type="button" data-action="add-one" data-team="1">+1 <span>{ui.addStone}</span></button>
|
|
131
|
+
<button type="button" data-action="add-five" data-team="1">+5 <span>{ui.addAmarraco}</span></button>
|
|
132
|
+
<button class="mus-close-button" type="button" data-action="close-game" data-team="1" disabled>{ui.closeGame}</button>
|
|
133
|
+
</div>
|
|
134
|
+
</article>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div class="mus-status-strip">
|
|
138
|
+
<div><span class="mus-status-dot"></span><span data-status-message>{ui.matchSaved}</span></div>
|
|
139
|
+
<div class="mus-secondary-actions"><button type="button" data-action="undo">{ui.undo}</button><span class="mus-history-label">{ui.lastAction}</span></div>
|
|
140
|
+
</div>
|
|
141
|
+
<details class="mus-history-details">
|
|
142
|
+
<summary>{ui.historyTitle}</summary>
|
|
143
|
+
<ol data-history-list><li class="mus-history-empty">{ui.noHistory}</li></ol>
|
|
144
|
+
</details>
|
|
145
|
+
</section>
|
|
146
|
+
|
|
147
|
+
<div class="mus-confirmation" data-confirmation hidden>
|
|
148
|
+
<div class="mus-confirmation-backdrop" data-action="cancel-confirmation" aria-hidden="true"></div>
|
|
149
|
+
<section class="mus-confirmation-card" role="dialog" aria-modal="true" aria-labelledby="mus-confirmation-title" aria-describedby="mus-confirmation-message">
|
|
150
|
+
<span class="mus-confirmation-mark" aria-hidden="true">M</span>
|
|
151
|
+
<p class="mus-kicker">{ui.confirmationTitle}</p>
|
|
152
|
+
<h2 id="mus-confirmation-title" data-confirmation-title>{ui.confirmationTitle}</h2>
|
|
153
|
+
<p id="mus-confirmation-message" data-confirmation-message></p>
|
|
154
|
+
<div class="mus-confirmation-actions">
|
|
155
|
+
<button class="mus-secondary-button" type="button" data-action="cancel-confirmation">{ui.cancelAction}</button>
|
|
156
|
+
<button class="mus-primary-button" type="button" data-action="accept-confirmation">{ui.confirmAction}</button>
|
|
157
|
+
</div>
|
|
158
|
+
</section>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<script type="application/json" data-mus-ui set:html={JSON.stringify(ui)}></script>
|
|
164
|
+
<script>
|
|
165
|
+
import { mountMusScoreboard } from './controller';
|
|
166
|
+
const root = document.querySelector<HTMLElement>('[data-mus-scoreboard]');
|
|
167
|
+
const uiNode = document.querySelector<HTMLScriptElement>('[data-mus-ui]');
|
|
168
|
+
if (root && uiNode) mountMusScoreboard(root, JSON.parse(uiNode.textContent || '{}'));
|
|
169
|
+
</script>
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { addPoints, closeGame, createMusState, setHand, undoLastAction, updateConfig, type MusConfig, type MusState, type Team } from './logic';
|
|
2
|
+
import { evaluateMusState } from './evaluator';
|
|
3
|
+
import { loadMusState, saveMusState } from './storage';
|
|
4
|
+
import { renderMusState } from './dom-views';
|
|
5
|
+
import type { MusScoreboardUI } from './ui';
|
|
6
|
+
|
|
7
|
+
interface ControllerContext {
|
|
8
|
+
root: HTMLElement;
|
|
9
|
+
ui: MusScoreboardUI;
|
|
10
|
+
state: MusState;
|
|
11
|
+
pendingConfirmation: (() => void) | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const getInput = (root: HTMLElement, selector: string): HTMLInputElement => {
|
|
15
|
+
const input = root.querySelector<HTMLInputElement>(selector);
|
|
16
|
+
if (!input) throw new Error(`Missing scoreboard input ${selector}`);
|
|
17
|
+
return input;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const readConfig = (root: HTMLElement): MusConfig => ({
|
|
21
|
+
pairNames: [
|
|
22
|
+
getInput(root, '[data-pair-name="0"]').value.trim(),
|
|
23
|
+
getInput(root, '[data-pair-name="1"]').value.trim(),
|
|
24
|
+
],
|
|
25
|
+
targetPoints: Number(getInput(root, '[data-target-input]:checked').value),
|
|
26
|
+
gamesToWin: Number(getInput(root, '[data-games-input]:checked').value),
|
|
27
|
+
deck: getInput(root, '[data-deck-input]:checked').value as MusConfig['deck'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const getMessages = (ui: MusScoreboardUI) => ({ waiting: ui.waitingForScore, ready: ui.closeGameHint, won: ui.vacaWon });
|
|
31
|
+
|
|
32
|
+
const refresh = (context: ControllerContext, message = context.ui.matchSaved, showPanel = true): void => {
|
|
33
|
+
const evaluation = evaluateMusState(context.state, getMessages(context.ui));
|
|
34
|
+
renderMusState(context.root, context.state, context.ui, message || evaluation.message);
|
|
35
|
+
if (showPanel) context.root.querySelector<HTMLElement>('[data-match-panel]')?.removeAttribute('hidden');
|
|
36
|
+
if (showPanel) saveMusState(context.state);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const setConfigMessage = (root: HTMLElement, message = ''): void => {
|
|
40
|
+
const element = root.querySelector<HTMLElement>('[data-config-message]');
|
|
41
|
+
if (!element) return;
|
|
42
|
+
element.textContent = message;
|
|
43
|
+
element.hidden = !message;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const setFrenchDeckNotice = (root: HTMLElement, deck: MusConfig['deck']): void => {
|
|
47
|
+
const notice = root.querySelector<HTMLElement>('[data-french-deck-notice]');
|
|
48
|
+
if (!notice) return;
|
|
49
|
+
notice.hidden = deck !== 'french';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const closeConfirmation = (context: ControllerContext): void => {
|
|
53
|
+
const dialog = context.root.querySelector<HTMLElement>('[data-confirmation]');
|
|
54
|
+
if (!dialog) return;
|
|
55
|
+
dialog.hidden = true;
|
|
56
|
+
context.pendingConfirmation = null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const showConfirmation = (context: ControllerContext, message: string, action: () => void): void => {
|
|
60
|
+
const dialog = context.root.querySelector<HTMLElement>('[data-confirmation]');
|
|
61
|
+
const messageElement = context.root.querySelector<HTMLElement>('[data-confirmation-message]');
|
|
62
|
+
const acceptButton = context.root.querySelector<HTMLButtonElement>('[data-action="accept-confirmation"]');
|
|
63
|
+
if (!dialog || !messageElement || !acceptButton) return;
|
|
64
|
+
messageElement.textContent = message;
|
|
65
|
+
context.pendingConfirmation = action;
|
|
66
|
+
dialog.hidden = false;
|
|
67
|
+
requestAnimationFrame(() => acceptButton.focus());
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const setPairLabels = (root: HTMLElement, state: MusState): void => {
|
|
71
|
+
state.config.pairNames.forEach((name, team) => {
|
|
72
|
+
root.querySelector<HTMLElement>(`[data-pair-label="${team}"]`)!.textContent = name;
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const changeScore = (context: ControllerContext, team: Team, amount: 1 | 5): void => {
|
|
77
|
+
if (context.state.games.some((games) => games >= context.state.config.gamesToWin)) return;
|
|
78
|
+
context.state = addPoints(context.state, team, amount);
|
|
79
|
+
refresh(context, `${context.state.config.pairNames[team]} ${context.ui.pointsAdded} +${amount}`);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const confirmClose = (context: ControllerContext, team: Team): void => {
|
|
83
|
+
if (context.state.points[team] < context.state.config.targetPoints) return;
|
|
84
|
+
showConfirmation(context, `${context.state.config.pairNames[team]}: ${context.ui.closeGamePrompt}`, () => {
|
|
85
|
+
context.state = closeGame(context.state, team);
|
|
86
|
+
const winner = context.state.games[team] >= context.state.config.gamesToWin;
|
|
87
|
+
refresh(context, winner ? context.ui.vacaWon : `${context.state.config.pairNames[team]} ${context.ui.gameClosed}`);
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const applySettings = (context: ControllerContext): void => {
|
|
92
|
+
const config = readConfig(context.root);
|
|
93
|
+
if (config.pairNames.some((name) => !name)) {
|
|
94
|
+
setConfigMessage(context.root, context.ui.pairNameRequired);
|
|
95
|
+
getInput(context.root, '[data-pair-name="0"]').focus();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setConfigMessage(context.root);
|
|
99
|
+
context.state = updateConfig(context.state, config);
|
|
100
|
+
setPairLabels(context.root, context.state);
|
|
101
|
+
refresh(context, context.ui.settingsApplied);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleUndo = (context: ControllerContext): void => {
|
|
105
|
+
if (!context.state.history.length) return;
|
|
106
|
+
context.state = undoLastAction(context.state);
|
|
107
|
+
refresh(context, context.ui.undo);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const changeHand = (context: ControllerContext, team: Team): void => {
|
|
111
|
+
if (context.state.hand === team) return;
|
|
112
|
+
context.state = setHand(context.state, team);
|
|
113
|
+
refresh(context, `${context.state.config.pairNames[team]} ${context.ui.handSelected}`);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleReset = (context: ControllerContext): void => {
|
|
117
|
+
showConfirmation(context, context.ui.resetMatchPrompt, () => {
|
|
118
|
+
context.state = createMusState(context.state.config);
|
|
119
|
+
refresh(context, context.ui.resetMatch);
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const acceptConfirmation = (context: ControllerContext): void => {
|
|
124
|
+
const action = context.pendingConfirmation;
|
|
125
|
+
closeConfirmation(context);
|
|
126
|
+
action?.();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type ActionHandler = (context: ControllerContext, element: HTMLElement) => void;
|
|
130
|
+
|
|
131
|
+
const actionHandlers: Record<string, ActionHandler> = {
|
|
132
|
+
'add-one': (context, element) => changeScore(context, Number(element.dataset.team) as Team, 1),
|
|
133
|
+
'add-five': (context, element) => changeScore(context, Number(element.dataset.team) as Team, 5),
|
|
134
|
+
'close-game': (context, element) => confirmClose(context, Number(element.dataset.team) as Team),
|
|
135
|
+
'set-hand': (context, element) => changeHand(context, Number(element.dataset.team) as Team),
|
|
136
|
+
undo: (context) => handleUndo(context),
|
|
137
|
+
'cancel-confirmation': (context) => closeConfirmation(context),
|
|
138
|
+
'accept-confirmation': (context) => acceptConfirmation(context),
|
|
139
|
+
reset: (context) => handleReset(context),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleAction = (context: ControllerContext, element: HTMLElement): void => {
|
|
143
|
+
const action = element.dataset.action;
|
|
144
|
+
if (action) actionHandlers[action]?.(context, element);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const bindClickActions = (context: ControllerContext): void => {
|
|
148
|
+
context.root.addEventListener('click', (event) => {
|
|
149
|
+
const target = event.target as HTMLElement;
|
|
150
|
+
const actionElement = target.closest<HTMLElement>('[data-action]');
|
|
151
|
+
if (actionElement) handleAction(context, actionElement);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const mountMusScoreboard = (root: HTMLElement, ui: MusScoreboardUI): void => {
|
|
156
|
+
const savedState = loadMusState();
|
|
157
|
+
const context: ControllerContext = { root, ui, state: savedState || createMusState(), pendingConfirmation: null };
|
|
158
|
+
getInput(root, '[data-pair-name="0"]').value = context.state.config.pairNames[0];
|
|
159
|
+
getInput(root, '[data-pair-name="1"]').value = context.state.config.pairNames[1];
|
|
160
|
+
getInput(root, `[data-target-input="${context.state.config.targetPoints}"]`).checked = true;
|
|
161
|
+
getInput(root, `[data-games-input="${context.state.config.gamesToWin}"]`).checked = true;
|
|
162
|
+
getInput(root, `[data-deck-input="${context.state.config.deck}"]`).checked = true;
|
|
163
|
+
setPairLabels(root, context.state);
|
|
164
|
+
setFrenchDeckNotice(root, context.state.config.deck);
|
|
165
|
+
bindClickActions(context);
|
|
166
|
+
root.addEventListener('change', (event) => {
|
|
167
|
+
const target = event.target as HTMLInputElement;
|
|
168
|
+
if (target.matches('[data-deck-input]')) setFrenchDeckNotice(root, target.value as MusConfig['deck']);
|
|
169
|
+
});
|
|
170
|
+
root.addEventListener('keydown', (event) => {
|
|
171
|
+
if (event.key === 'Escape' && context.pendingConfirmation) closeConfirmation(context);
|
|
172
|
+
});
|
|
173
|
+
root.querySelector<HTMLFormElement>('[data-config-form]')?.addEventListener('submit', (event) => {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
applySettings(context);
|
|
176
|
+
});
|
|
177
|
+
refresh(context, context.ui.matchSaved, savedState !== null);
|
|
178
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { canCloseGame, decomposeScore, getMatchWinner, remainingPoints, type MusState, type Team } from './logic';
|
|
2
|
+
import type { MusScoreboardUI } from './ui';
|
|
3
|
+
|
|
4
|
+
const query = <T extends Element>(root: ParentNode, selector: string): T => {
|
|
5
|
+
const element = root.querySelector<T>(selector);
|
|
6
|
+
if (!element) throw new Error(`Missing scoreboard element ${selector}`);
|
|
7
|
+
return element;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const setText = (root: ParentNode, selector: string, value: string): void => {
|
|
11
|
+
query<HTMLElement>(root, selector).textContent = value;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const setTeamProgress = (root: ParentNode, team: Team, points: number, target: number): void => {
|
|
15
|
+
const progress = Math.min(100, points / target * 100);
|
|
16
|
+
const track = query<HTMLElement>(root, `[data-score-track="${team}"]`);
|
|
17
|
+
track.style.setProperty('--score-progress', String(progress / 100));
|
|
18
|
+
track.setAttribute('aria-valuenow', String(points));
|
|
19
|
+
const marker = query<HTMLElement>(root, `[data-score-marker="${team}"]`);
|
|
20
|
+
marker.style.setProperty('--score-progress', String(progress / 100));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const renderTally = (root: ParentNode, team: Team, score: number, ui: MusScoreboardUI): void => {
|
|
24
|
+
const target = query<HTMLElement>(root, `[data-tally="${team}"]`);
|
|
25
|
+
target.replaceChildren();
|
|
26
|
+
const parts = decomposeScore(score);
|
|
27
|
+
for (let index = 0; index < parts.amarracos; index += 1) {
|
|
28
|
+
const amarraco = document.createElement('span');
|
|
29
|
+
amarraco.className = 'mus-tally-amarraco';
|
|
30
|
+
amarraco.setAttribute('aria-label', ui.amarracoSingular);
|
|
31
|
+
target.append(amarraco);
|
|
32
|
+
}
|
|
33
|
+
for (let index = 0; index < parts.stones; index += 1) {
|
|
34
|
+
const stone = document.createElement('span');
|
|
35
|
+
stone.className = 'mus-tally-stone';
|
|
36
|
+
stone.setAttribute('aria-label', ui.stoneSingular);
|
|
37
|
+
target.append(stone);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const renderHistory = (root: ParentNode, state: MusState, ui: MusScoreboardUI): void => {
|
|
42
|
+
const list = query<HTMLOListElement>(root, '[data-history-list]');
|
|
43
|
+
list.replaceChildren();
|
|
44
|
+
if (!state.history.length) {
|
|
45
|
+
const empty = document.createElement('li');
|
|
46
|
+
empty.className = 'mus-history-empty';
|
|
47
|
+
empty.textContent = ui.noHistory;
|
|
48
|
+
list.append(empty);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
state.history.slice(-6).reverse().forEach((entry) => {
|
|
52
|
+
const item = document.createElement('li');
|
|
53
|
+
item.textContent = entry.team === null ? entry.action : `${state.config.pairNames[entry.team]} ${entry.action}`;
|
|
54
|
+
list.append(item);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const renderMusState = (root: ParentNode, state: MusState, ui: MusScoreboardUI, message: string): void => {
|
|
59
|
+
const felt = query<HTMLElement>(root, '[data-mus-felt]');
|
|
60
|
+
felt.dataset.handTeam = String(state.hand);
|
|
61
|
+
state.config.pairNames.forEach((name, team) => {
|
|
62
|
+
setText(root, `[data-hand-choice-label="${team}"]`, name.slice(0, 1).toUpperCase());
|
|
63
|
+
const handChoice = query<HTMLButtonElement>(root, `[data-hand-choice="${team}"]`);
|
|
64
|
+
handChoice.setAttribute('aria-pressed', String(state.hand === team));
|
|
65
|
+
handChoice.setAttribute('aria-label', `${ui.handLabel}: ${name}`);
|
|
66
|
+
});
|
|
67
|
+
state.points.forEach((score, team) => {
|
|
68
|
+
const index = team as Team;
|
|
69
|
+
const parts = decomposeScore(score);
|
|
70
|
+
setText(root, `[data-score="${index}"]`, String(score));
|
|
71
|
+
setText(root, `[data-breakdown="${index}"]`, `${parts.amarracos} ${parts.amarracos === 1 ? ui.amarracoSingular : ui.amarracoPlural} + ${parts.stones} ${parts.stones === 1 ? ui.stoneSingular : ui.stonePlural}`);
|
|
72
|
+
setText(root, `[data-remaining="${index}"]`, `${remainingPoints(state, index)} ${ui.pointsToGo}`);
|
|
73
|
+
setTeamProgress(root, index, score, state.config.targetPoints);
|
|
74
|
+
renderTally(root, index, score, ui);
|
|
75
|
+
});
|
|
76
|
+
state.games.forEach((games, team) => setText(root, `[data-games="${team}"]`, String(games)));
|
|
77
|
+
const matchWinner = getMatchWinner(state);
|
|
78
|
+
state.points.forEach((_, team) => {
|
|
79
|
+
const closeButton = query<HTMLButtonElement>(root, `[data-action="close-game"][data-team="${team}"]`);
|
|
80
|
+
closeButton.disabled = !canCloseGame(state, team as Team) || matchWinner !== null;
|
|
81
|
+
});
|
|
82
|
+
query<HTMLButtonElement>(root, '[data-action="undo"]').disabled = state.history.length === 0;
|
|
83
|
+
setText(root, '[data-target-points]', String(state.config.targetPoints));
|
|
84
|
+
setText(root, '[data-games-to-win]', String(state.config.gamesToWin));
|
|
85
|
+
setText(root, '[data-status-message]', message);
|
|
86
|
+
renderHistory(root, state, ui);
|
|
87
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { TabletopToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { MusScoreboardUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { MusScoreboardUI } from './ui';
|
|
5
|
+
export type MusScoreboardLocaleContent = ToolLocaleContent<MusScoreboardUI>;
|
|
6
|
+
|
|
7
|
+
export const musScoreboard: TabletopToolEntry<MusScoreboardUI> = {
|
|
8
|
+
id: 'mus-scoreboard',
|
|
9
|
+
icons: { bg: 'mdi:cards-playing-outline', fg: 'mdi:counter' },
|
|
10
|
+
i18n: {
|
|
11
|
+
de: () => import('./i18n/de').then((module) => module.content),
|
|
12
|
+
en: () => import('./i18n/en').then((module) => module.content),
|
|
13
|
+
es: () => import('./i18n/es').then((module) => module.content),
|
|
14
|
+
fr: () => import('./i18n/fr').then((module) => module.content),
|
|
15
|
+
'id': () => import('./i18n/id').then((module) => module.content),
|
|
16
|
+
it: () => import('./i18n/it').then((module) => module.content),
|
|
17
|
+
ja: () => import('./i18n/ja').then((module) => module.content),
|
|
18
|
+
ko: () => import('./i18n/ko').then((module) => module.content),
|
|
19
|
+
nl: () => import('./i18n/nl').then((module) => module.content),
|
|
20
|
+
pl: () => import('./i18n/pl').then((module) => module.content),
|
|
21
|
+
pt: () => import('./i18n/pt').then((module) => module.content),
|
|
22
|
+
ru: () => import('./i18n/ru').then((module) => module.content),
|
|
23
|
+
sv: () => import('./i18n/sv').then((module) => module.content),
|
|
24
|
+
tr: () => import('./i18n/tr').then((module) => module.content),
|
|
25
|
+
zh: () => import('./i18n/zh').then((module) => module.content),
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { decomposeScore, getMatchWinner, isGameReadyToClose, remainingPoints, type MusState, type Team } from './logic';
|
|
2
|
+
|
|
3
|
+
export interface MusEvaluation {
|
|
4
|
+
winner: Team | null;
|
|
5
|
+
readyTeam: Team | null;
|
|
6
|
+
badges: [string, string];
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const findLeader = (points: [number, number]): Team | null => {
|
|
11
|
+
if (points[0] === points[1]) return null;
|
|
12
|
+
return points[0] > points[1] ? 0 : 1;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const evaluateMusState = (state: MusState, messages: { waiting: string; ready: string; won: string }): MusEvaluation => {
|
|
16
|
+
const winner = getMatchWinner(state);
|
|
17
|
+
const readyTeam = state.points.findIndex((points) => points >= state.config.targetPoints);
|
|
18
|
+
const badges = state.points.map((points) => {
|
|
19
|
+
const parts = decomposeScore(points);
|
|
20
|
+
return `${parts.amarracos} / ${parts.stones}`;
|
|
21
|
+
}) as [string, string];
|
|
22
|
+
if (winner !== null) return { winner, readyTeam: null, badges, message: messages.won };
|
|
23
|
+
if (isGameReadyToClose(state)) return { winner: null, readyTeam: readyTeam as Team, badges, message: messages.ready };
|
|
24
|
+
const lead = findLeader(state.points);
|
|
25
|
+
const message = lead === null ? messages.waiting : `${messages.waiting} ${remainingPoints(state, lead as Team)}`;
|
|
26
|
+
return { winner: null, readyTeam: null, badges, message };
|
|
27
|
+
};
|