11import { Chess , PieceSymbol } from 'chess.ts'
22
33type 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
676const A = 1
777const 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
1083const winRate = ( p : number ) => 1 / ( 1 + Math . exp ( - ( p - A ) / B ) )
1184const wdl = ( p : number ) => {
@@ -14,28 +87,30 @@ const wdl = (p: number) => {
1487 return { w, d : 1 - w - l }
1588}
1689
90+ /* ================================================================ */
91+
1792export 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