@@ -4,6 +4,10 @@ import { Chess, PieceSymbol } from 'chess.ts'
44type StockfishEvals = Record < string , number >
55type MaiaEvals = Record < string , number [ ] >
66
7+ type DescriptionSegment =
8+ | { type : 'text' ; content : string }
9+ | { type : 'move' ; san : string ; uci : string }
10+
711/* ---------------- phrase banks ---------------- */
812
913const pick = < T > ( a : T [ ] ) => a [ Math . floor ( Math . random ( ) * a . length ) ]
@@ -53,7 +57,7 @@ const TEMPT_WORD = ['tempting', 'enticing', 'natural-looking']
5357
5458/* ---------------- constants & helpers ---------------- */
5559
56- const EPS = 0.08 // “ good move” window
60+ const EPS = 0.08 // " good move" window
5761const BLUNDER_GAP = 0.1 // win-rate drop that defines a blunder
5862
5963const winRate = ( p : number ) => 1 / ( 1 + Math . exp ( - ( p - 1 ) / 0.8 ) )
@@ -70,7 +74,7 @@ export function describePosition(
7074 sf : StockfishEvals ,
7175 maia : MaiaEvals ,
7276 whiteToMove : boolean ,
73- ) : string {
77+ ) : { segments : DescriptionSegment [ ] } {
7478 /* ---------- board ---------- */
7579 const chess = new Chess ( fen )
7680 const legal = new Set < string > ( )
@@ -79,7 +83,10 @@ export function describePosition(
7983 . forEach ( ( m ) => legal . add ( m . from + m . to + ( m . promotion ?? '' ) ) )
8084
8185 const moves = Object . keys ( sf ) . filter ( ( m ) => legal . has ( m ) )
82- if ( ! moves . length ) return 'No legal moves available.'
86+ if ( ! moves . length )
87+ return {
88+ segments : [ { type : 'text' , content : 'No legal moves available.' } ] ,
89+ }
8390
8491 if ( ! whiteToMove ) {
8592 sf = Object . fromEntries ( Object . entries ( sf ) . map ( ( [ m , v ] ) => [ m , - v ] ) )
@@ -130,12 +137,13 @@ export function describePosition(
130137 Math . abs ( wOpt - w2 ) <= EPS / 2 && Math . abs ( dOpt - d2 ) <= EPS / 2
131138 }
132139
133- const listWithOpt = sortedGood . slice ( 0 , 3 ) . map ( uciToSan ) . join ( ', ' )
134- const listWithoutOpt = sortedGood
140+ const goodMovesList = sortedGood
141+ . slice ( 0 , 3 )
142+ . map ( ( uci ) => ( { san : uciToSan ( uci ) , uci } ) )
143+ const goodMovesWithoutOpt = sortedGood
135144 . filter ( ( m ) => m !== opt )
136145 . slice ( 0 , 3 )
137- . map ( uciToSan )
138- . join ( ', ' )
146+ . map ( ( uci ) => ( { san : uciToSan ( uci ) , uci } ) )
139147
140148 /* ---------- outcome wording ---------- */
141149 const avgGood = sortedGood . reduce ( ( s , m ) => s + seval [ m ] , 0 ) / nGood
@@ -242,15 +250,25 @@ export function describePosition(
242250 const prefix = setT === 2 && ! bestHarder ? ', however' : ''
243251 const temptW = pick ( TEMPT_WORD )
244252
245- let tail = ''
253+ let tailSegments : DescriptionSegment [ ] = [ ]
246254 if ( blunderMove && treach ) {
247- tail = ` ${ pick ( CAREFUL ) } ${ prefix } , this position is highly treacherous! It is easy to go astray with ${ temptW } blunders like ${ uciToSan (
248- blunderMove ,
249- ) } .`
255+ tailSegments = [
256+ {
257+ type : 'text' ,
258+ content : ` ${ pick ( CAREFUL ) } ${ prefix } , this position is highly treacherous! It is easy to go astray with ${ temptW } blunders like ` ,
259+ } ,
260+ { type : 'move' , san : uciToSan ( blunderMove ) , uci : blunderMove } ,
261+ { type : 'text' , content : '.' } ,
262+ ]
250263 } else if ( blunderMove ) {
251- tail = ` ${ pick ( CAREFUL ) } ${ prefix } ! There is a ${ temptW } blunder in this position: ${ uciToSan (
252- blunderMove ,
253- ) } .`
264+ tailSegments = [
265+ {
266+ type : 'text' ,
267+ content : ` ${ pick ( CAREFUL ) } ${ prefix } ! There is a ${ temptW } blunder in this position: ` ,
268+ } ,
269+ { type : 'move' , san : uciToSan ( blunderMove ) , uci : blunderMove } ,
270+ { type : 'text' , content : '.' } ,
271+ ]
254272 } else {
255273 const showTempt = setT < 2 || ( setT === 2 && temptLv > 4 )
256274 if ( showTempt ) {
@@ -261,22 +279,73 @@ export function describePosition(
261279 Object . entries ( aggProb )
262280 . filter ( ( [ m ] ) => ! good . includes ( m ) )
263281 . sort ( ( a , b ) => b [ 1 ] - a [ 1 ] ) [ 0 ] ?. [ 0 ] ?? ''
264- const temptSan = temptUci ? uciToSan ( temptUci ) : ''
282+
265283 const intro = pick ( TEMPTING_INTRO )
266- tail = temptSan
267- ? ` ${ intro } ${ prefix } are also ${ temptW } alternatives, such as ${ temptSan } .`
268- : ` ${ intro } ${ prefix } are also ${ temptW } alternatives.`
284+ if ( temptUci ) {
285+ tailSegments = [
286+ {
287+ type : 'text' ,
288+ content : ` ${ intro } ${ prefix } are also ${ temptW } alternatives, such as ` ,
289+ } ,
290+ { type : 'move' , san : uciToSan ( temptUci ) , uci : temptUci } ,
291+ { type : 'text' , content : '.' } ,
292+ ]
293+ } else {
294+ tailSegments = [
295+ {
296+ type : 'text' ,
297+ content : ` ${ intro } ${ prefix } are also ${ temptW } alternatives.` ,
298+ } ,
299+ ]
300+ }
269301 }
270302 }
271303
272304 /* ---------- assemble ---------- */
273- const moveList = bestHarder ? listWithoutOpt : listWithOpt
274- const result =
275- nGood === 1
276- ? `There ${ verb } ${ abundance } (${ moveList } ) ${ outcome } , and ${ pron } ${ phrSet } .${ tail } `
277- : bestHarder
278- ? `There ${ verb } ${ abundance } (${ moveList } ) ${ outcome } , and ${ pron } ${ phrSet } , but the best move (${ bestMoveSan } ) is ${ phrBest } .${ tail } `
279- : `There ${ verb } ${ abundance } (${ moveList } ) ${ outcome } , and ${ pron } ${ phrSet } .${ tail } `
305+ const segments : DescriptionSegment [ ] = [ ]
306+
307+ // Helper function to create move list segments
308+ const createMoveListSegments = ( moveList : { san : string ; uci : string } [ ] ) => {
309+ const moveSegments : DescriptionSegment [ ] = [ ]
310+ moveList . forEach ( ( move , index ) => {
311+ moveSegments . push ( { type : 'move' , san : move . san , uci : move . uci } )
312+ if ( index < moveList . length - 1 ) {
313+ moveSegments . push ( { type : 'text' , content : ', ' } )
314+ }
315+ } )
316+ return moveSegments
317+ }
318+
319+ // Main description
320+ const moveList = bestHarder ? goodMovesWithoutOpt : goodMovesList
321+
322+ if ( nGood === 1 ) {
323+ segments . push (
324+ { type : 'text' , content : `There ${ verb } ${ abundance } (` } ,
325+ ...createMoveListSegments ( moveList ) ,
326+ { type : 'text' , content : `) ${ outcome } , and ${ pron } ${ phrSet } .` } ,
327+ )
328+ } else if ( bestHarder ) {
329+ segments . push (
330+ { type : 'text' , content : `There ${ verb } ${ abundance } (` } ,
331+ ...createMoveListSegments ( moveList ) ,
332+ {
333+ type : 'text' ,
334+ content : `) ${ outcome } , and ${ pron } ${ phrSet } , but the best move (` ,
335+ } ,
336+ { type : 'move' , san : bestMoveSan , uci : opt } ,
337+ { type : 'text' , content : `) is ${ phrBest } .` } ,
338+ )
339+ } else {
340+ segments . push (
341+ { type : 'text' , content : `There ${ verb } ${ abundance } (` } ,
342+ ...createMoveListSegments ( moveList ) ,
343+ { type : 'text' , content : `) ${ outcome } , and ${ pron } ${ phrSet } .` } ,
344+ )
345+ }
346+
347+ // Add tail segments
348+ segments . push ( ...tailSegments )
280349
281- return result
350+ return { segments }
282351}
0 commit comments