Skip to content

Commit 2f988f2

Browse files
authored
Update useDescriptionGenerator.ts
1 parent 15155d0 commit 2f988f2

1 file changed

Lines changed: 201 additions & 60 deletions

File tree

Lines changed: 201 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,84 @@
11
import { Chess, PieceSymbol } from 'chess.ts'
22

33
type StockfishEvals = Record<string, number>
4-
type MaiaEvals = Record<string, number[]>
4+
type MaiaEvals = Record<string, number[]>
5+
6+
/* ---------------- phrase banks ---------------- */
7+
8+
const pick = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]
9+
10+
const OUTCOME = {
11+
overwhelming: [
12+
'for an overwhelming winning advantage',
13+
'for a crushing advantage',
14+
'for a decisive winning advantage',
15+
],
16+
win: [
17+
'to win',
18+
'for a winning advantage',
19+
'to maintain a winning advantage',
20+
],
21+
advantage: [
22+
'for an advantage',
23+
'to gain an edge',
24+
'to press for advantage',
25+
],
26+
balance: [
27+
'to keep the balance',
28+
'to maintain the balance',
29+
'to hold equality',
30+
],
31+
hold: [
32+
'to hold the position',
33+
'to defend the position',
34+
'to hold on',
35+
],
36+
stay: [
37+
'to stay in the game',
38+
'to stay afloat',
39+
'to keep fighting',
40+
],
41+
}
42+
43+
const FINDABILITY = {
44+
hard: [
45+
'hard for human players to find',
46+
'very tough for humans to spot',
47+
'challenging for most players to see',
48+
],
49+
skilled: [
50+
'findable for skilled players',
51+
'within reach for experienced players',
52+
'doable for strong players',
53+
],
54+
straight: [
55+
'straightforward for players across skill levels to find',
56+
'easy for players of all strengths to spot',
57+
'obvious to most players',
58+
],
59+
}
60+
61+
const CAREFUL = [
62+
'Tread carefully',
63+
'Be alert',
64+
'Stay sharp',
65+
'Watch out',
66+
]
67+
68+
const TEMPTING_INTRO = [
69+
'There',
70+
'Be careful, as there',
71+
'In this position there',
72+
]
73+
74+
/* ---------------- constants ---------------- */
575

676
const A = 1
777
const B = 0.8
8-
const EPS = 0.08
78+
const EPS = 0.08 // relative ε
79+
const ABS_EPS = 1.0 // absolute-pawn guard
80+
81+
/* ---------------- helpers ---------------- */
982

1083
const winRate = (p: number) => 1 / (1 + Math.exp(-(p - A) / B))
1184
const wdl = (p: number) => {
@@ -14,28 +87,30 @@ const wdl = (p: number) => {
1487
return { w, d: 1 - w - l }
1588
}
1689

90+
/* ================================================================ */
91+
1792
export function describePosition(
1893
fen: string,
1994
sf: StockfishEvals,
2095
maia: MaiaEvals,
2196
whiteToMove: boolean,
22-
eps = EPS,
97+
eps = EPS
2398
): string {
99+
/* ---------- board & legal moves ---------- */
24100
const chess = new Chess(fen)
25-
26101
const legal = new Set<string>()
27-
chess
28-
.moves({ verbose: true })
29-
.forEach((m) => legal.add(m.from + m.to + (m.promotion ?? '')))
102+
chess.moves({ verbose: true }).forEach((m) =>
103+
legal.add(m.from + m.to + (m.promotion ?? ''))
104+
)
30105

31106
const moves = Object.keys(sf).filter((m) => legal.has(m))
32107
if (!moves.length) return 'No legal moves available.'
33108

109+
/* ---------- Stockfish evals ---------- */
34110
const seval: Record<string, number> = {}
35-
moves.forEach((m) => {
36-
seval[m] = (whiteToMove ? 1 : 1) * sf[m]
37-
})
111+
moves.forEach((m) => (seval[m] = sf[m]))
38112

113+
/* ---------- good-move window ---------- */
39114
const opt = moves.reduce((a, b) => (seval[a] > seval[b] ? a : b))
40115
const { w: wOpt, d: dOpt } = wdl(seval[opt])
41116

@@ -46,38 +121,57 @@ export function describePosition(
46121

47122
const nGood = good.length
48123
const abundance =
49-
nGood === 1 ? 'only one move' : nGood === 2 ? 'two moves' : 'several moves'
124+
nGood === 1 ? 'only one move'
125+
: nGood === 2 ? 'two moves'
126+
: pick(['several moves', 'multiple moves', 'a few moves'])
50127

128+
/* ---------- helpers ---------- */
51129
const uciToSan = (uci: string): string => {
52-
const from = uci.slice(0, 2)
53-
const to = uci.slice(2, 4)
54-
const promotion = uci.length > 4 ? uci[4] : undefined
55-
const mv = chess.move({ from, to, promotion: promotion as PieceSymbol })
130+
const mv = chess.move({
131+
from: uci.slice(0, 2),
132+
to: uci.slice(2, 4),
133+
promotion: uci.length > 4 ? (uci[4] as PieceSymbol) : undefined,
134+
})
56135
const san = mv?.san ?? uci
57136
chess.undo()
58137
return san
59138
}
60139

61-
const bestGoodMoves = [...good]
62-
.sort((a, b) => seval[b] - seval[a])
63-
.slice(0, 3)
64-
const moveList = bestGoodMoves.map(uciToSan).join(', ')
140+
const sortedGood = [...good].sort((a, b) => seval[b] - seval[a])
65141
const bestMoveSan = uciToSan(opt)
66142

67-
const avgGood = good.reduce((s, m) => s + seval[m], 0) / nGood
143+
/* ε/2 closeness check */
144+
let optCloseSecond = false
145+
if (sortedGood.length >= 2) {
146+
const second = sortedGood[1]
147+
const { w: w2, d: d2 } = wdl(seval[second])
148+
optCloseSecond =
149+
Math.abs(wOpt - w2) <= eps / 2 && Math.abs(dOpt - d2) <= eps / 2
150+
}
151+
152+
const listWithOpt = sortedGood.slice(0, 3).map(uciToSan).join(', ')
153+
const listWithoutOpt = sortedGood
154+
.filter((m) => m !== opt)
155+
.slice(0, 3)
156+
.map(uciToSan)
157+
.join(', ')
68158

159+
/* ---------- outcome wording ---------- */
160+
const avgGood = sortedGood.reduce((s, m) => s + seval[m], 0) / nGood
69161
let outcome: string
70-
if (avgGood > 2.5) outcome = 'to cleanly win'
71-
else if (avgGood > 1.0) outcome = 'to win'
72-
else if (avgGood > 0.35) outcome = 'for an advantage'
73-
else if (avgGood >= -0.35) outcome = 'to keep the balance'
74-
else if (avgGood >= -1.0) outcome = 'to hold the position'
75-
else outcome = 'to stay in the game'
162+
if (avgGood > 3) outcome = pick(OUTCOME.overwhelming)
163+
else if (avgGood > 1.5) outcome = pick(OUTCOME.win)
164+
else if (avgGood > 0.35) outcome = pick(OUTCOME.advantage)
165+
else if (avgGood >= -0.35) outcome = pick(OUTCOME.balance)
166+
else if (avgGood >= -1.5) outcome = pick(OUTCOME.hold)
167+
else outcome = pick(OUTCOME.stay)
76168

169+
/* ---------- Maia stats & tempting counts ---------- */
77170
let setLevels = 0
78171
let optLevels = 0
79172
let temptLevels = 0
80173
const temptCount: Record<string, number> = {}
174+
const aggProb: Record<string, number> = {}
81175

82176
for (let lvl = 0; lvl < 9; lvl++) {
83177
const probs = moves
@@ -88,70 +182,117 @@ export function describePosition(
88182
const [p2, m2] = probs[1] ?? [0, '']
89183
const [p3, m3] = probs[2] ?? [0, '']
90184

91-
const inGood = good.includes(m1)
92-
if (inGood) setLevels++
185+
if (good.includes(m1)) setLevels++
93186
if (m1 === opt) optLevels++
94187

188+
for (const [p, m] of probs.slice(0, 4))
189+
aggProb[m] = (aggProb[m] ?? 0) + p
190+
95191
const nearTop = (prob: number) => p1 - prob <= eps
96192
const addTempt = (uci: string) => {
97193
temptCount[uci] = (temptCount[uci] ?? 0) + 1
98194
return true
99195
}
100196

101-
const tempting =
102-
inGood &&
103-
((m2 && !good.includes(m2) && nearTop(p2) && addTempt(m2)) ||
104-
(m3 && !good.includes(m3) && nearTop(p3) && addTempt(m3)))
197+
const nearBad =
198+
(m2 && !good.includes(m2) && nearTop(p2) && addTempt(m2)) ||
199+
(m3 && !good.includes(m3) && nearTop(p3) && addTempt(m3))
105200

106-
if (tempting) temptLevels++
201+
if (nearBad) temptLevels++
107202
}
108203

204+
/* ---------- tiers ---------- */
109205
const tier = (k: number) => (k <= 2 ? 0 : k <= 6 ? 1 : 2)
110206
const setTier = tier(setLevels)
111207
const optTier = tier(optLevels)
208+
const bestHarder = optTier < setTier && !optCloseSecond
112209

113210
const phrSet =
114211
setTier === 0
115-
? 'hard for human players to find'
212+
? pick(FINDABILITY.hard)
116213
: setTier === 1
117-
? 'findable for skilled players'
118-
: 'straightforward for players across skill levels to find'
214+
? pick(FINDABILITY.skilled)
215+
: pick(FINDABILITY.straight)
119216

120217
let phrBest =
121218
optTier === 0
122-
? 'hard for human players to find'
219+
? pick(FINDABILITY.hard)
123220
: optTier === 1
124-
? 'findable for skilled players'
125-
: 'straightforward for players across skill levels to find'
221+
? pick(FINDABILITY.skilled)
222+
: pick(FINDABILITY.straight)
223+
224+
if (optTier === 1 && bestHarder) phrBest = 'only ' + phrBest
225+
226+
/* ---------- blunder detection ---------- */
227+
const isBlunder = (m: string) => {
228+
const { w, d } = wdl(seval[m])
229+
return (
230+
(wOpt - w > 2.25 * eps || dOpt - d > 2.25 * eps) &&
231+
seval[opt] - seval[m] > ABS_EPS
232+
)
233+
}
234+
235+
const topNonGood = Object.entries(aggProb)
236+
.filter(([m]) => !good.includes(m))
237+
.sort((a, b) => b[1] - a[1])[0]?.[0]
126238

127-
if (optTier === 1 && optTier < setTier) phrBest = 'only ' + phrBest
239+
const top4ByProb = Object.entries(aggProb)
240+
.sort((a, b) => b[1] - a[1])
241+
.slice(0, 4)
242+
243+
let blunderMove: string | null = null
244+
if (topNonGood && isBlunder(topNonGood)) {
245+
blunderMove = topNonGood
246+
} else {
247+
for (const [m, p] of top4ByProb) {
248+
if (p > 0.15 && isBlunder(m)) {
249+
blunderMove = m
250+
break
251+
}
252+
}
253+
}
128254

255+
/* ---------- tail text ---------- */
129256
const verb = nGood === 1 ? 'is' : 'are'
130257
const pron = nGood === 1 ? 'it is' : 'they are'
258+
const prefix = setTier === 2 && !bestHarder ? ', however' : ''
259+
260+
let tailText = ''
261+
262+
if (blunderMove) {
263+
tailText = ` ${pick(CAREFUL)}${prefix}! There is a tempting blunder in this position: ${uciToSan(blunderMove)}.`
264+
} else {
265+
const showTempt =
266+
setTier < 2 || (setTier === 2 && temptLevels > 4)
267+
268+
if (showTempt) {
269+
/* always name a move */
270+
let temptUci =
271+
Object.entries(temptCount).sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''
131272

132-
let temptText = ''
133-
const hasTempting = setLevels > 0 && temptLevels > setLevels / 2
134-
if (hasTempting) {
135-
const topTemptUci = Object.entries(temptCount).sort(
136-
(a, b) => b[1] - a[1],
137-
)[0]?.[0]
138-
const temptSan = topTemptUci ? uciToSan(topTemptUci) : ''
139-
temptText =
140-
temptSan !== ''
141-
? ` There are also tempting alternatives, such as ${temptSan}.`
142-
: ' There are also tempting alternatives.'
143-
if (!(optTier < setTier) && setTier == 2) {
144-
temptText = ` However, there are tempting alternatives, such as ${temptSan}.`
273+
if (!temptUci) {
274+
temptUci = Object.entries(aggProb)
275+
.filter(([m]) => !good.includes(m))
276+
.sort((a, b) => b[1] - a[1])[0]?.[0] ?? ''
277+
}
278+
279+
const temptSan = temptUci ? uciToSan(temptUci) : ''
280+
const intro = pick(TEMPTING_INTRO)
281+
tailText =
282+
temptSan
283+
? ` ${intro}${prefix} are also tempting alternatives, such as ${temptSan}.`
284+
: ` ${intro}${prefix} are also tempting alternatives.`
145285
}
146286
}
147287

148-
if (nGood === 1) {
149-
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${temptText}`
150-
}
288+
/* ---------- assemble ---------- */
289+
const moveList = bestHarder ? listWithoutOpt : listWithOpt
151290

152-
if (optTier < setTier) {
153-
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}, but the best move (${bestMoveSan}) is ${phrBest}.${temptText}`
154-
}
291+
if (nGood === 1)
292+
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${tailText}`
293+
294+
if (bestHarder)
295+
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}, but the best move (${bestMoveSan}) is ${phrBest}.${tailText}`
155296

156-
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${temptText}`
297+
return `There ${verb} ${abundance} (${moveList}) ${outcome}, and ${pron} ${phrSet}.${tailText}`
157298
}

0 commit comments

Comments
 (0)