@tungstenstudio/outschart-generator 1.0.0-rc.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/LICENSE +15 -0
- package/README.md +184 -0
- package/dist/index.cjs +1194 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +107 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +1155 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
- package/src/dartboard.ts +122 -0
- package/src/data/dartboard-dimensions.json +77 -0
- package/src/data/dartboard-layout.json +66 -0
- package/src/data/double-out-checkouts.ts +168 -0
- package/src/data/single-out-checkouts.ts +177 -0
- package/src/filter.ts +35 -0
- package/src/generate.ts +91 -0
- package/src/index.ts +37 -0
- package/src/lookup.ts +47 -0
- package/src/miss-analysis.ts +90 -0
- package/src/notation.ts +69 -0
- package/src/recommend.ts +434 -0
- package/src/types.ts +141 -0
package/src/lookup.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { DOUBLE_OUT_CHECKOUTS, DOUBLE_OUT_BOGEY_NUMBERS } from './data/double-out-checkouts.js';
|
|
2
|
+
import { SINGLE_OUT_CHECKOUTS, SINGLE_OUT_BOGEY_NUMBERS } from './data/single-out-checkouts.js';
|
|
3
|
+
import type { OutMode, CheckoutEntry, CheckoutChart } from './types.js';
|
|
4
|
+
|
|
5
|
+
function getChartForMode(outMode: OutMode): CheckoutChart {
|
|
6
|
+
return outMode === 'double' ? DOUBLE_OUT_CHECKOUTS : SINGLE_OUT_CHECKOUTS;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getBogeyNumbersForMode(outMode: OutMode): number[] {
|
|
10
|
+
return outMode === 'double' ? DOUBLE_OUT_BOGEY_NUMBERS : SINGLE_OUT_BOGEY_NUMBERS;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getCheckout(score: number, outMode: OutMode = 'double'): CheckoutEntry | null {
|
|
14
|
+
const chart = getChartForMode(outMode);
|
|
15
|
+
return chart[score] ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getCheckoutChart(
|
|
19
|
+
outMode: OutMode = 'double',
|
|
20
|
+
range?: { min?: number; max?: number },
|
|
21
|
+
): CheckoutChart {
|
|
22
|
+
const chart = getChartForMode(outMode);
|
|
23
|
+
|
|
24
|
+
if (!range) return { ...chart };
|
|
25
|
+
|
|
26
|
+
const min = range.min ?? 0;
|
|
27
|
+
const max = range.max ?? Infinity;
|
|
28
|
+
const filtered: CheckoutChart = {};
|
|
29
|
+
|
|
30
|
+
for (const [scoreStr, entry] of Object.entries(chart)) {
|
|
31
|
+
const score = Number(scoreStr);
|
|
32
|
+
if (score >= min && score <= max) {
|
|
33
|
+
filtered[score] = entry;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return filtered;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getBogeyNumbers(outMode: OutMode = 'double'): number[] {
|
|
41
|
+
return [...getBogeyNumbersForMode(outMode)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isCheckable(score: number, outMode: OutMode = 'double'): boolean {
|
|
45
|
+
const chart = getChartForMode(outMode);
|
|
46
|
+
return score in chart;
|
|
47
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { OutMode, CheckoutChart, DartAnnotation, MissAnalysisResult } from './types.js';
|
|
2
|
+
import { getCheckoutChart } from './lookup.js';
|
|
3
|
+
|
|
4
|
+
function missResult(dart: string): { label: string; value: number } | null {
|
|
5
|
+
if (dart === 'Bull') return { label: '25', value: 25 };
|
|
6
|
+
if (dart === '25') return { label: '25', value: 25 };
|
|
7
|
+
const m = dart.match(/^([TD]?)(\d+)$/);
|
|
8
|
+
if (!m) return null;
|
|
9
|
+
const num = parseInt(m[2]);
|
|
10
|
+
const prefix = m[1];
|
|
11
|
+
if (prefix === 'T' || prefix === 'D') return { label: `${num}`, value: num };
|
|
12
|
+
return { label: dart, value: parseInt(dart) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function dartValue(d: string): number {
|
|
16
|
+
if (d === 'Bull') return 50;
|
|
17
|
+
if (d === '25') return 25;
|
|
18
|
+
const m = d.match(/^([TD]?)(\d+)$/);
|
|
19
|
+
if (!m) return NaN;
|
|
20
|
+
const num = parseInt(m[2]);
|
|
21
|
+
const prefix = m[1];
|
|
22
|
+
if (prefix === 'T') return num * 3;
|
|
23
|
+
if (prefix === 'D') return num * 2;
|
|
24
|
+
return num;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isScoreCheckable(score: number, checkoutsMap: CheckoutChart): boolean {
|
|
28
|
+
return score >= 1 && score in checkoutsMap;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function analyzeMisses(
|
|
32
|
+
score: number,
|
|
33
|
+
path: string[],
|
|
34
|
+
outMode: OutMode = 'double',
|
|
35
|
+
): MissAnalysisResult {
|
|
36
|
+
const checkoutsMap = getCheckoutChart(outMode);
|
|
37
|
+
const annotations: DartAnnotation[] = [];
|
|
38
|
+
let remaining = score;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < path.length; i++) {
|
|
41
|
+
const dart = path[i];
|
|
42
|
+
const value = dartValue(dart);
|
|
43
|
+
const miss = missResult(dart);
|
|
44
|
+
const isSingle = !dart.startsWith('T') && !dart.startsWith('D') && dart !== 'Bull';
|
|
45
|
+
|
|
46
|
+
const annotation: DartAnnotation = {
|
|
47
|
+
dart: i + 1,
|
|
48
|
+
target: dart,
|
|
49
|
+
missRelevant: false,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (!isSingle && miss) {
|
|
53
|
+
const missRemaining = remaining - miss.value;
|
|
54
|
+
annotation.missRelevant = true;
|
|
55
|
+
annotation.missResult = miss.label;
|
|
56
|
+
annotation.missRemaining = missRemaining;
|
|
57
|
+
|
|
58
|
+
if (i === path.length - 1) {
|
|
59
|
+
annotation.stillCheckable = isScoreCheckable(missRemaining, checkoutsMap);
|
|
60
|
+
annotation.remainderCheckout = checkoutsMap[missRemaining]?.darts ?? null;
|
|
61
|
+
} else {
|
|
62
|
+
annotation.stillCheckable = isScoreCheckable(missRemaining, checkoutsMap);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
annotations.push(annotation);
|
|
67
|
+
remaining -= value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const relevantMisses = annotations.filter((m) => m.missRelevant);
|
|
71
|
+
const checkableMisses = relevantMisses.filter((m) => m.stillCheckable);
|
|
72
|
+
const missResiliency =
|
|
73
|
+
relevantMisses.length > 0
|
|
74
|
+
? `${checkableMisses.length}/${relevantMisses.length}`
|
|
75
|
+
: 'N/A';
|
|
76
|
+
|
|
77
|
+
return { score, path, annotations, missResiliency };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function analyzeCheckoutChart(
|
|
81
|
+
chart: CheckoutChart,
|
|
82
|
+
outMode: OutMode = 'double',
|
|
83
|
+
): Record<number, MissAnalysisResult> {
|
|
84
|
+
const results: Record<number, MissAnalysisResult> = {};
|
|
85
|
+
for (const [scoreStr, entry] of Object.entries(chart)) {
|
|
86
|
+
const score = Number(scoreStr);
|
|
87
|
+
results[score] = analyzeMisses(score, entry.darts, outMode);
|
|
88
|
+
}
|
|
89
|
+
return results;
|
|
90
|
+
}
|
package/src/notation.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { parseTarget } from './dartboard.js';
|
|
2
|
+
import type { CheckoutChart, CheckoutEntry } from './types.js';
|
|
3
|
+
import { OutsChartError } from './types.js';
|
|
4
|
+
|
|
5
|
+
export interface NotationMap {
|
|
6
|
+
treble: string;
|
|
7
|
+
double: string;
|
|
8
|
+
single: string;
|
|
9
|
+
bull: string;
|
|
10
|
+
outerBull: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const NOTATIONS: Record<string, NotationMap> = {
|
|
14
|
+
default: { treble: 'T', double: 'D', single: '', bull: 'Bull', outerBull: '25' },
|
|
15
|
+
british: { treble: 'T', double: 'D', single: 'S', bull: 'DB', outerBull: 'SB' },
|
|
16
|
+
american: { treble: 'T', double: 'D', single: '', bull: 'DB', outerBull: 'SB' },
|
|
17
|
+
explicit: { treble: 'T', double: 'D', single: 'S', bull: 'Bull', outerBull: '25' },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function formatDart(dart: string, notation: NotationMap): string {
|
|
21
|
+
const parsed = parseTarget(dart);
|
|
22
|
+
if (!parsed) {
|
|
23
|
+
throw new OutsChartError('INVALID_TARGET', `Cannot format invalid dart notation '${dart}'`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (parsed.bed === 'Bull') return notation.bull;
|
|
27
|
+
if (parsed.bed === '25') return notation.outerBull;
|
|
28
|
+
if (parsed.bed === 'treble') return `${notation.treble}${parsed.number}`;
|
|
29
|
+
if (parsed.bed === 'double') return `${notation.double}${parsed.number}`;
|
|
30
|
+
return `${notation.single}${parsed.number}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatCheckoutChart(
|
|
34
|
+
chart: CheckoutChart,
|
|
35
|
+
notation: NotationMap | string,
|
|
36
|
+
): CheckoutChart {
|
|
37
|
+
const map = resolveNotation(notation);
|
|
38
|
+
const result: CheckoutChart = {};
|
|
39
|
+
|
|
40
|
+
for (const [scoreStr, entry] of Object.entries(chart)) {
|
|
41
|
+
const score = Number(scoreStr);
|
|
42
|
+
const formatted: CheckoutEntry = {
|
|
43
|
+
darts: entry.darts.map((d) => formatDart(d, map)),
|
|
44
|
+
numDarts: entry.numDarts,
|
|
45
|
+
};
|
|
46
|
+
if (entry.alternates) {
|
|
47
|
+
formatted.alternates = entry.alternates.map((alt) =>
|
|
48
|
+
alt.map((d) => formatDart(d, map)),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
result[score] = formatted;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveNotation(notation: NotationMap | string): NotationMap {
|
|
58
|
+
if (typeof notation === 'string') {
|
|
59
|
+
const preset = NOTATIONS[notation];
|
|
60
|
+
if (!preset) {
|
|
61
|
+
throw new OutsChartError(
|
|
62
|
+
'INVALID_TARGET',
|
|
63
|
+
`Unknown notation preset '${notation}'. Available: ${Object.keys(NOTATIONS).join(', ')}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return preset;
|
|
67
|
+
}
|
|
68
|
+
return notation;
|
|
69
|
+
}
|
package/src/recommend.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { parseTarget, evaluateThrow } from './dartboard.js';
|
|
2
|
+
import dimensions from './data/dartboard-dimensions.json' with { type: 'json' };
|
|
3
|
+
import type { OutMode, CheckoutChart, CheckoutResult } from './types.js';
|
|
4
|
+
import { OutsChartError } from './types.js';
|
|
5
|
+
|
|
6
|
+
interface Target {
|
|
7
|
+
label: string;
|
|
8
|
+
value: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function buildTargets(): Target[] {
|
|
12
|
+
const targets: Target[] = [];
|
|
13
|
+
for (let i = 1; i <= 20; i++) {
|
|
14
|
+
targets.push({ label: `${i}`, value: i });
|
|
15
|
+
targets.push({ label: `D${i}`, value: i * 2 });
|
|
16
|
+
targets.push({ label: `T${i}`, value: i * 3 });
|
|
17
|
+
}
|
|
18
|
+
targets.push({ label: '25', value: 25 });
|
|
19
|
+
targets.push({ label: 'Bull', value: 50 });
|
|
20
|
+
return targets;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildFinishingDoubles(): Target[] {
|
|
24
|
+
const doubles: Target[] = [];
|
|
25
|
+
for (let i = 1; i <= 20; i++) {
|
|
26
|
+
doubles.push({ label: `D${i}`, value: i * 2 });
|
|
27
|
+
}
|
|
28
|
+
doubles.push({ label: 'Bull', value: 50 });
|
|
29
|
+
return doubles;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const allTargets = buildTargets();
|
|
33
|
+
const finishingDoubles = buildFinishingDoubles();
|
|
34
|
+
|
|
35
|
+
const AREA = {
|
|
36
|
+
treble: dimensions.segmentAreas.trebleBed,
|
|
37
|
+
double: dimensions.segmentAreas.doubleBed,
|
|
38
|
+
largeSingle: dimensions.segmentAreas.largeSingle,
|
|
39
|
+
smallSingle: dimensions.segmentAreas.smallSingle,
|
|
40
|
+
bull: dimensions.segmentAreas.doubleBull,
|
|
41
|
+
outerBull: dimensions.segmentAreas.singleBull,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// All 1-dart checkable scores for single-out
|
|
45
|
+
const oneDartScores = new Set(allTargets.map((t) => t.value));
|
|
46
|
+
|
|
47
|
+
function missWeight(target: string, missType: string): number {
|
|
48
|
+
const parsed = parseTarget(target);
|
|
49
|
+
if (!parsed) return 0.5;
|
|
50
|
+
|
|
51
|
+
if (missType === 'radialIn') {
|
|
52
|
+
if (!parsed.bed || parsed.bed === null) return AREA.treble / AREA.largeSingle;
|
|
53
|
+
if (parsed.bed === 'double') return 1.0;
|
|
54
|
+
if (parsed.bed === 'treble') return 1.0;
|
|
55
|
+
return 0.5;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (missType === 'radialOut') {
|
|
59
|
+
if (!parsed.bed || parsed.bed === null) return AREA.double / AREA.largeSingle;
|
|
60
|
+
if (parsed.bed === 'double') return 0.5;
|
|
61
|
+
if (parsed.bed === 'treble') return 1.0;
|
|
62
|
+
return 0.5;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return 0.7;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function evaluatePath(
|
|
69
|
+
score: number,
|
|
70
|
+
darts: string[],
|
|
71
|
+
outMode: OutMode,
|
|
72
|
+
checkableScores?: Set<number>,
|
|
73
|
+
): number {
|
|
74
|
+
let penalty = 0;
|
|
75
|
+
let remaining = score;
|
|
76
|
+
const isDoubleOut = outMode === 'double';
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < darts.length; i++) {
|
|
79
|
+
const dart = darts[i];
|
|
80
|
+
const parsed = parseTarget(dart);
|
|
81
|
+
if (!parsed) return Infinity;
|
|
82
|
+
|
|
83
|
+
const evaluation = evaluateThrow(dart, remaining);
|
|
84
|
+
if (!evaluation) return Infinity;
|
|
85
|
+
|
|
86
|
+
for (const miss of evaluation.misses) {
|
|
87
|
+
const weight = missWeight(dart, miss.missType);
|
|
88
|
+
if (miss.busts) {
|
|
89
|
+
penalty += 6 * weight;
|
|
90
|
+
} else if (isDoubleOut) {
|
|
91
|
+
// In double-out, bust also if remaining = 1 (can't finish on a double)
|
|
92
|
+
const missRemaining = remaining - miss.value;
|
|
93
|
+
if (missRemaining === 1) {
|
|
94
|
+
penalty += 6 * weight;
|
|
95
|
+
} else if (missRemaining === 0) {
|
|
96
|
+
// Only counts as accidental checkout if last dart would be a valid finisher
|
|
97
|
+
// In double-out, miss to a single doesn't count as checkout
|
|
98
|
+
} else if (checkableScores && checkableScores.has(missRemaining)) {
|
|
99
|
+
penalty -= 0.5 * weight;
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
const missRemaining = remaining - miss.value;
|
|
103
|
+
if (missRemaining === 0) {
|
|
104
|
+
penalty -= 1 * weight;
|
|
105
|
+
} else if (missRemaining > 0 && oneDartScores.has(missRemaining)) {
|
|
106
|
+
penalty -= 0.5 * weight;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Prefer larger targets
|
|
112
|
+
let targetArea: number;
|
|
113
|
+
if (parsed.bed === 'treble') targetArea = AREA.treble;
|
|
114
|
+
else if (parsed.bed === 'double') targetArea = AREA.double;
|
|
115
|
+
else if (dart === 'Bull') targetArea = AREA.bull;
|
|
116
|
+
else if (dart === '25') targetArea = AREA.outerBull;
|
|
117
|
+
else targetArea = AREA.largeSingle;
|
|
118
|
+
penalty += (1 - targetArea / AREA.largeSingle) * 5.5;
|
|
119
|
+
|
|
120
|
+
// Hard penalty: never use treble/double when a fat single scores the same
|
|
121
|
+
if ((parsed.bed === 'treble' || parsed.bed === 'double') && parsed.value <= 20) {
|
|
122
|
+
penalty += 5;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Penalize harder darts following easier ones (throw hard darts first)
|
|
126
|
+
if (i > 0) {
|
|
127
|
+
const difficulty = (d: string) => {
|
|
128
|
+
if (/^T\d+$/.test(d)) return 3;
|
|
129
|
+
if (d === 'Bull') return 2;
|
|
130
|
+
if (/^D\d+$/.test(d)) return 1.5;
|
|
131
|
+
if (d === '25') return 1;
|
|
132
|
+
return 0;
|
|
133
|
+
};
|
|
134
|
+
const prev = difficulty(darts[i - 1]);
|
|
135
|
+
const curr = difficulty(dart);
|
|
136
|
+
if (curr > prev && (parsed.bed === 'treble' || dart === 'Bull')) {
|
|
137
|
+
penalty += 4.0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
remaining -= parsed.value;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Prefer finishing doubles with good halving chains
|
|
145
|
+
const lastDart = darts[darts.length - 1];
|
|
146
|
+
const lastMatch = lastDart.match(/^D(\d+)$/);
|
|
147
|
+
if (lastMatch) {
|
|
148
|
+
let val = parseInt(lastMatch[1]);
|
|
149
|
+
let chainLen = 0;
|
|
150
|
+
while (val % 2 === 0) { val /= 2; chainLen++; }
|
|
151
|
+
chainLen++;
|
|
152
|
+
penalty -= chainLen * 0.2;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return penalty;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sortTrebles(darts: string[]): string[] {
|
|
159
|
+
const sorted = [...darts];
|
|
160
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
161
|
+
const m1 = sorted[i].match(/^T(\d+)$/);
|
|
162
|
+
const m2 = sorted[i + 1].match(/^T(\d+)$/);
|
|
163
|
+
if (m1 && m2 && parseInt(m1[1]) < parseInt(m2[1])) {
|
|
164
|
+
[sorted[i], sorted[i + 1]] = [sorted[i + 1], sorted[i]];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return sorted;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pathKey(darts: string[]): string {
|
|
171
|
+
return [...darts].sort().join(',');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Single-out overrides
|
|
175
|
+
const SINGLE_OUT_OVERRIDES: Record<number, { darts: string[] }> = {
|
|
176
|
+
1: { darts: ['1'] }, 2: { darts: ['2'] }, 3: { darts: ['3'] }, 4: { darts: ['4'] },
|
|
177
|
+
5: { darts: ['5'] }, 6: { darts: ['6'] }, 7: { darts: ['7'] }, 8: { darts: ['8'] },
|
|
178
|
+
9: { darts: ['9'] }, 10: { darts: ['10'] }, 11: { darts: ['11'] }, 12: { darts: ['12'] },
|
|
179
|
+
13: { darts: ['13'] }, 14: { darts: ['14'] }, 15: { darts: ['15'] }, 16: { darts: ['16'] },
|
|
180
|
+
17: { darts: ['17'] }, 18: { darts: ['18'] }, 19: { darts: ['19'] }, 20: { darts: ['20'] },
|
|
181
|
+
21: { darts: ['7', '14'] }, 23: { darts: ['3', '20'] },
|
|
182
|
+
25: { darts: ['9', '16'] }, 27: { darts: ['9', '18'] },
|
|
183
|
+
29: { darts: ['9', '20'] }, 31: { darts: ['12', '19'] },
|
|
184
|
+
33: { darts: ['14', '19'] }, 35: { darts: ['15', '20'] },
|
|
185
|
+
37: { darts: ['18', '19'] }, 39: { darts: ['19', '20'] },
|
|
186
|
+
50: { darts: ['10', 'D20'] },
|
|
187
|
+
80: { darts: ['T20', '20'] },
|
|
188
|
+
82: { darts: ['T14', 'D20'] },
|
|
189
|
+
92: { darts: ['T20', 'D16'] },
|
|
190
|
+
100: { darts: ['T20', 'D20'] },
|
|
191
|
+
103: { darts: ['T19', '6', 'D20'] },
|
|
192
|
+
147: { darts: ['T20', 'T17', 'D18'] },
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function findBestCheckout(
|
|
196
|
+
score: number,
|
|
197
|
+
outMode: OutMode,
|
|
198
|
+
altThreshold: number,
|
|
199
|
+
includeAlternates: boolean,
|
|
200
|
+
overrides: Record<number, { darts: string[] }>,
|
|
201
|
+
): { darts: string[]; numDarts: number; penalty: number; alternates: string[][] } | null {
|
|
202
|
+
const isDoubleOut = outMode === 'double';
|
|
203
|
+
const finishing = isDoubleOut ? finishingDoubles : allTargets;
|
|
204
|
+
|
|
205
|
+
let best: string[] | null = null;
|
|
206
|
+
let bestPenalty = Infinity;
|
|
207
|
+
let bestNumDarts = Infinity;
|
|
208
|
+
|
|
209
|
+
// 1-dart finishes
|
|
210
|
+
for (const t of finishing) {
|
|
211
|
+
if (t.value !== score) continue;
|
|
212
|
+
const parsed = parseTarget(t.label);
|
|
213
|
+
// In single-out, trebles for scores <=40 compete as priority 2
|
|
214
|
+
const priority = (!isDoubleOut && parsed?.bed === 'treble' && score <= 40) ? 2 : 1;
|
|
215
|
+
const penalty = evaluatePath(score, [t.label], outMode);
|
|
216
|
+
if (priority < bestNumDarts || (priority === bestNumDarts && penalty < bestPenalty)) {
|
|
217
|
+
best = [t.label];
|
|
218
|
+
bestPenalty = penalty;
|
|
219
|
+
bestNumDarts = priority;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 2-dart finishes
|
|
224
|
+
for (const d1 of allTargets) {
|
|
225
|
+
if (d1.value >= score) continue;
|
|
226
|
+
const remainder = score - d1.value;
|
|
227
|
+
for (const d2 of finishing) {
|
|
228
|
+
if (d2.value !== remainder) continue;
|
|
229
|
+
const path = [d1.label, d2.label];
|
|
230
|
+
const penalty = evaluatePath(score, path, outMode);
|
|
231
|
+
if (2 < bestNumDarts || (2 === bestNumDarts && penalty < bestPenalty)) {
|
|
232
|
+
best = path;
|
|
233
|
+
bestPenalty = penalty;
|
|
234
|
+
bestNumDarts = 2;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 3-dart finishes
|
|
240
|
+
if (bestNumDarts > 2 || score > 110) {
|
|
241
|
+
for (const d1 of allTargets) {
|
|
242
|
+
if (d1.value >= score) continue;
|
|
243
|
+
for (const d2 of allTargets) {
|
|
244
|
+
if (d1.value + d2.value >= score) continue;
|
|
245
|
+
const remainder = score - d1.value - d2.value;
|
|
246
|
+
for (const d3 of finishing) {
|
|
247
|
+
if (d3.value !== remainder) continue;
|
|
248
|
+
const path = [d1.label, d2.label, d3.label];
|
|
249
|
+
const penalty = evaluatePath(score, path, outMode);
|
|
250
|
+
if (3 < bestNumDarts || (3 === bestNumDarts && penalty < bestPenalty)) {
|
|
251
|
+
best = path;
|
|
252
|
+
bestPenalty = penalty;
|
|
253
|
+
bestNumDarts = 3;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!best) return null;
|
|
261
|
+
|
|
262
|
+
best = sortTrebles(best);
|
|
263
|
+
|
|
264
|
+
if (!includeAlternates) {
|
|
265
|
+
return { darts: best, numDarts: best.length, penalty: bestPenalty, alternates: [] };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Collect alternates within threshold
|
|
269
|
+
const alternates: { darts: string[]; penalty: number }[] = [];
|
|
270
|
+
const seen = new Set<string>();
|
|
271
|
+
seen.add(pathKey(best));
|
|
272
|
+
|
|
273
|
+
const collectAlt = (path: string[], penalty: number) => {
|
|
274
|
+
if (penalty > bestPenalty + altThreshold) return;
|
|
275
|
+
const key = pathKey(path);
|
|
276
|
+
if (seen.has(key)) return;
|
|
277
|
+
seen.add(key);
|
|
278
|
+
alternates.push({ darts: sortTrebles(path), penalty });
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
if (bestNumDarts === 1) {
|
|
282
|
+
for (const t of finishing) {
|
|
283
|
+
if (t.value !== score) continue;
|
|
284
|
+
collectAlt([t.label], evaluatePath(score, [t.label], outMode));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (bestNumDarts <= 2) {
|
|
289
|
+
for (const d1 of allTargets) {
|
|
290
|
+
if (d1.value >= score) continue;
|
|
291
|
+
const remainder = score - d1.value;
|
|
292
|
+
for (const d2 of finishing) {
|
|
293
|
+
if (d2.value !== remainder) continue;
|
|
294
|
+
collectAlt([d1.label, d2.label], evaluatePath(score, [d1.label, d2.label], outMode));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (bestNumDarts === 3) {
|
|
300
|
+
for (const d1 of allTargets) {
|
|
301
|
+
if (d1.value >= score) continue;
|
|
302
|
+
for (const d2 of allTargets) {
|
|
303
|
+
if (d1.value + d2.value >= score) continue;
|
|
304
|
+
const remainder = score - d1.value - d2.value;
|
|
305
|
+
for (const d3 of finishing) {
|
|
306
|
+
if (d3.value !== remainder) continue;
|
|
307
|
+
collectAlt(
|
|
308
|
+
[d1.label, d2.label, d3.label],
|
|
309
|
+
evaluatePath(score, [d1.label, d2.label, d3.label], outMode),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
alternates.sort((a, b) => a.penalty - b.penalty);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
darts: best,
|
|
320
|
+
numDarts: best.length,
|
|
321
|
+
penalty: bestPenalty,
|
|
322
|
+
alternates: alternates.slice(0, 5).map((a) => a.darts),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function validateOverrides(
|
|
327
|
+
overrides: Record<number, { darts: string[] }>,
|
|
328
|
+
outMode: OutMode,
|
|
329
|
+
): void {
|
|
330
|
+
const isDoubleOut = outMode === 'double';
|
|
331
|
+
|
|
332
|
+
for (const [scoreStr, entry] of Object.entries(overrides)) {
|
|
333
|
+
const score = Number(scoreStr);
|
|
334
|
+
|
|
335
|
+
if (!Array.isArray(entry.darts) || entry.darts.length === 0 || entry.darts.length > 3) {
|
|
336
|
+
throw new OutsChartError(
|
|
337
|
+
'INVALID_OVERRIDE',
|
|
338
|
+
`Override for score ${score}: must have 1-3 darts`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let sum = 0;
|
|
343
|
+
for (const dart of entry.darts) {
|
|
344
|
+
const parsed = parseTarget(dart);
|
|
345
|
+
if (!parsed) {
|
|
346
|
+
throw new OutsChartError(
|
|
347
|
+
'INVALID_OVERRIDE',
|
|
348
|
+
`Override for score ${score}: invalid dart notation '${dart}'`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
sum += parsed.value;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (sum !== score) {
|
|
355
|
+
throw new OutsChartError(
|
|
356
|
+
'INVALID_OVERRIDE',
|
|
357
|
+
`Override for score ${score}: darts sum to ${sum}, expected ${score}`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const lastDart = entry.darts[entry.darts.length - 1];
|
|
362
|
+
const lastParsed = parseTarget(lastDart)!;
|
|
363
|
+
if (isDoubleOut && lastParsed.bed !== 'double' && lastParsed.bed !== 'Bull') {
|
|
364
|
+
throw new OutsChartError(
|
|
365
|
+
'INVALID_OVERRIDE',
|
|
366
|
+
`Override for score ${score}: last dart '${lastDart}' is not a valid double-out finish`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function generateRecommended(options: {
|
|
373
|
+
outMode: OutMode;
|
|
374
|
+
min?: number;
|
|
375
|
+
max?: number;
|
|
376
|
+
includeAlternates?: boolean;
|
|
377
|
+
altThreshold?: number;
|
|
378
|
+
overrides?: Record<number, { darts: string[] }>;
|
|
379
|
+
}): CheckoutResult {
|
|
380
|
+
const { outMode } = options;
|
|
381
|
+
const isDoubleOut = outMode === 'double';
|
|
382
|
+
const min = options.min ?? (isDoubleOut ? 2 : 1);
|
|
383
|
+
const max = options.max ?? (isDoubleOut ? 170 : 180);
|
|
384
|
+
const includeAlternates = options.includeAlternates ?? (outMode === 'single');
|
|
385
|
+
const altThreshold = options.altThreshold ?? 1.0;
|
|
386
|
+
|
|
387
|
+
// Build overrides: start with defaults for single-out, merge user overrides
|
|
388
|
+
const overrides: Record<number, { darts: string[] }> = {};
|
|
389
|
+
if (!isDoubleOut) {
|
|
390
|
+
Object.assign(overrides, SINGLE_OUT_OVERRIDES);
|
|
391
|
+
}
|
|
392
|
+
if (options.overrides) {
|
|
393
|
+
validateOverrides(options.overrides, outMode);
|
|
394
|
+
Object.assign(overrides, options.overrides);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const checkouts: CheckoutChart = {};
|
|
398
|
+
const bogeyNumbers: number[] = [];
|
|
399
|
+
|
|
400
|
+
for (let score = min; score <= max; score++) {
|
|
401
|
+
const result = findBestCheckout(score, outMode, altThreshold, includeAlternates, overrides);
|
|
402
|
+
|
|
403
|
+
if (overrides[score]) {
|
|
404
|
+
const ov = overrides[score];
|
|
405
|
+
checkouts[score] = {
|
|
406
|
+
darts: ov.darts,
|
|
407
|
+
numDarts: ov.darts.length,
|
|
408
|
+
};
|
|
409
|
+
if (includeAlternates && result && result.alternates.length > 0 && ov.darts.length > 1) {
|
|
410
|
+
const overrideKey = pathKey(ov.darts);
|
|
411
|
+
const alts = result.alternates.filter((a) => pathKey(a) !== overrideKey);
|
|
412
|
+
if (pathKey(result.darts) !== overrideKey) {
|
|
413
|
+
alts.unshift(result.darts);
|
|
414
|
+
}
|
|
415
|
+
if (alts.length > 0) checkouts[score].alternates = alts;
|
|
416
|
+
}
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (result) {
|
|
421
|
+
checkouts[score] = {
|
|
422
|
+
darts: result.darts,
|
|
423
|
+
numDarts: result.numDarts,
|
|
424
|
+
};
|
|
425
|
+
if (includeAlternates && result.alternates.length > 0) {
|
|
426
|
+
checkouts[score].alternates = result.alternates;
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
bogeyNumbers.push(score);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return { checkouts, bogeyNumbers };
|
|
434
|
+
}
|