Repository navigation
Expand file tree
/
Copy pathexplain.ts
More file actions
82 lines (75 loc) · 3.37 KB
/
Copy pathexplain.ts
File metadata and controls
82 lines (75 loc) · 3.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// Human-readable shot explanations for the UI. Turns a PlannedShot into a
// one-sentence description of the pocket, route, and position play.
import { dist } from './geometry';
import { ShotType, Sidespin } from './shots';
import { SkillProfile, distanceSigma } from './skill';
import type { PlannedShot } from './solver';
const pct = (v: number) => `${Math.min(99, Math.round(v * 100))}%`;
function typePhrase(type: ShotType): string {
switch (type) {
case 'stop': return 'Stop shot';
case 'follow': return 'A natural rolling follow';
case 'stun': return 'A stun off the tangent line';
case 'lowTouch': return 'A touch of low (slight draw)';
case 'draw': return 'A draw';
}
}
function railsPhrase(rails: number): string {
if (rails === 0) return '';
if (rails === 1) return ' off one rail';
return ` off ${rails === 2 ? 'two' : 'three'} rails`;
}
function spinPhrase(sidespin: Sidespin): string {
if (sidespin > 0) return ' with right spin';
if (sidespin < 0) return ' with left spin';
return '';
}
function entryPhrase(entryDeg: number | null, margin: number): string {
if (entryDeg === null) return '';
if (entryDeg <= 35) return ', coming into the line of the shot';
if (margin >= 4) return ', with enough room that the entry angle hardly matters';
if (entryDeg <= 60) return ', entering at an angle';
return ', crossing the line with the best available margin';
}
export function explainShot(
shot: PlannedShot,
next: PlannedShot | null,
first: boolean,
skill: SkillProfile,
): string {
const intro = first
? `Ball in hand: place the cue ball for a ${Math.round(shot.cutDeg)}° cut on the ${shot.ball.num}, into the ${shot.pocket.label}.`
: `${shot.ball.num} ball into the ${shot.pocket.label} (${Math.round(shot.cutDeg)}° cut).`;
if (!next) {
// Final ball: a Route chosen for safety (no next window to play for), so the
// cue ball is left clear of a scratch rather than positioned.
const safe = shot.type
? ` ${typePhrase(shot.type)}${spinPhrase(shot.sidespin)}${railsPhrase(shot.rails)} keeps the cue ball clear of a scratch.`
: '';
return `${intro}${safe} Pot ${pct(shot.potProb)}. Finish the rack.`;
}
if (!shot.type) {
// Narrows shot.type to non-null for the route phrasing below. Only the
// final ball can carry a null type, and that is handled by !next above, so
// this is effectively unreachable — but the guard keeps the types honest.
return `${intro} Pot ${pct(shot.potProb)}. Finish the rack.`;
}
let route: string;
if (shot.type === 'stop') {
route = `Stop shot. The cue ball stays put for the ${next.ball.num}.`;
} else {
let zone = '';
if (shot.zoneLen !== null) {
const margin =
shot.zoneLen /
distanceSigma(shot.type, shot.travel, shot.rails, skill, dist(shot.cuePos, shot.ghost));
const size =
shot.zoneLen > 40
? `a wide-open Position Window (${Math.round(shot.zoneLen)}″ of the path lies inside it`
: `the Position Window (${Math.round(shot.zoneLen)}″ of the path lies inside it`;
zone = ` It targets ${size}${entryPhrase(shot.entryDeg, margin)}).`;
}
route = `${typePhrase(shot.type)}${spinPhrase(shot.sidespin)}${railsPhrase(shot.rails)} sends the cue ball into position for the ${next.ball.num}.${zone}`;
}
return `${intro} ${route} Pot ${pct(shot.potProb)}, position ${pct(shot.eNext ?? 0)}.`;
}