1616
1717import { JGOFAIReview , JGOFNumericPlayerColor } from "../formats/JGOF" ;
1818import { GobanEngine } from "../GobanEngine" ;
19+ import { MoveTree } from "../MoveTree" ;
1920
2021export const DEFAULT_SCORE_DIFF_THRESHOLDS : ScoreDiffThresholds = {
2122 Excellent : 0.2 ,
@@ -44,7 +45,7 @@ export type ScoreDiffThresholds = {
4445
4546// Joseki detection constants
4647const STRONG_MOVE_SCORE_LOSS_THRESHOLD = 1.2 ;
47- const SINGLE_MOVE_LOSS_THRESHOLD = STRONG_MOVE_SCORE_LOSS_THRESHOLD * 2 ;
48+ const SINGLE_MOVE_LOSS_THRESHOLD = 1. 2;
4849
4950export interface AiReviewCategorization {
5051 uuid : string ;
@@ -117,38 +118,205 @@ function getMoveCutoff(size: number): number {
117118 return 20 ;
118119}
119120
120- function getZonesQuadrant ( x : number , y : number , width : number , height : number ) : number [ ] {
121- // 4 quadrants with overlap on center lines
121+ /**
122+ * Get the half-width of the center band based on board size.
123+ * Returns -1 for 9x9 (no middle zones), 0 for 13x13 (single line), 1 for 19x19 (3 lines).
124+ */
125+ function getCenterHalfWidth ( size : number ) : number {
126+ if ( size <= 9 ) {
127+ return - 1 ; // No middle zones for small boards
128+ } else if ( size <= 13 ) {
129+ return 0 ; // Single center line
130+ }
131+ return 1 ; // 3 center lines
132+ }
133+
134+ /**
135+ * Zone adjacency map for propagation.
136+ * When a zone exits joseki, these adjacent zones also exit.
137+ *
138+ * Zone layout for 13x13+:
139+ * 0 | 4 | 1
140+ * ----+-----+----
141+ * 7 | * | 5
142+ * ----+-----+----
143+ * 2 | 6 | 3
144+ */
145+ const ZONE_ADJACENCY : { [ key : number ] : number [ ] } = {
146+ 0 : [ 4 , 7 ] , // top-left corner → top middle, left middle
147+ 1 : [ 4 , 5 ] , // top-right corner → top middle, right middle
148+ 2 : [ 6 , 7 ] , // bottom-left corner → bottom middle, left middle
149+ 3 : [ 5 , 6 ] , // bottom-right corner → bottom middle, right middle
150+ 4 : [ 0 , 1 ] , // top middle → top-left corner, top-right corner
151+ 5 : [ 1 , 3 ] , // right middle → top-right corner, bottom-right corner
152+ 6 : [ 2 , 3 ] , // bottom middle → bottom-left corner, bottom-right corner
153+ 7 : [ 0 , 2 ] , // left middle → top-left corner, bottom-left corner
154+ } ;
155+
156+ /**
157+ * Propagate joseki exit from a zone to its adjacent zones.
158+ */
159+ function propagateJosekiExit ( zone : number , stillJoseki : boolean [ ] ) : void {
160+ for ( const adjacentZone of ZONE_ADJACENCY [ zone ] ?? [ ] ) {
161+ stillJoseki [ adjacentZone ] = false ;
162+ }
163+ }
164+
165+ // Distance from zone boundary to be considered "on the edge"
166+ const EDGE_DISTANCE = 2 ;
167+
168+ // Zone regions relative to center band: -1 = left/top, 0 = in center, 1 = right/bottom
169+ const ZONE_X_REGION : readonly number [ ] = [ - 1 , 1 , - 1 , 1 , 0 , 1 , 0 , - 1 ] ;
170+ const ZONE_Y_REGION : readonly number [ ] = [ - 1 , - 1 , 1 , 1 , - 1 , 0 , 1 , 0 ] ;
171+
172+ /**
173+ * Get adjacent zones that this position is near (within EDGE_DISTANCE of the boundary).
174+ *
175+ * Computes the shared boundary between adjacent zones geometrically based on
176+ * their relative positions, rather than enumerating cases per zone.
177+ */
178+ function getNearbyAdjacentZones (
179+ x : number ,
180+ y : number ,
181+ width : number ,
182+ height : number ,
183+ zone : number ,
184+ ) : number [ ] {
185+ const maxSize = Math . max ( width , height ) ;
186+ const halfWidth = getCenterHalfWidth ( maxSize ) ;
187+
188+ if ( halfWidth < 0 ) {
189+ return [ ] ; // No edge detection for 9x9
190+ }
191+
122192 const centerX = Math . floor ( ( width - 1 ) / 2 ) ;
123193 const centerY = Math . floor ( ( height - 1 ) / 2 ) ;
124- const zones : number [ ] = [ ] ;
125194
126- if ( x <= centerX ) {
127- if ( y <= centerY ) {
128- zones . push ( 0 ) ;
195+ const left = centerX - halfWidth ;
196+ const right = centerX + halfWidth ;
197+ const top = centerY - halfWidth ;
198+ const bottom = centerY + halfWidth ;
199+
200+ const nearby : number [ ] = [ ] ;
201+ const zx = ZONE_X_REGION [ zone ] ;
202+ const zy = ZONE_Y_REGION [ zone ] ;
203+
204+ for ( const adj of ZONE_ADJACENCY [ zone ] ?? [ ] ) {
205+ const ax = ZONE_X_REGION [ adj ] ;
206+ const ay = ZONE_Y_REGION [ adj ] ;
207+
208+ let near : boolean ;
209+ if ( zx !== ax ) {
210+ // Zones differ in X - check distance to vertical boundary
211+ const boundary = zx < 0 || ax < 0 ? left : right ;
212+ const approachFromLow = zx < 0 || ( zx === 0 && ax > 0 ) ;
213+ near = approachFromLow ? x >= boundary - EDGE_DISTANCE : x <= boundary + EDGE_DISTANCE ;
214+ } else {
215+ // Zones differ in Y - check distance to horizontal boundary
216+ const boundary = zy < 0 || ay < 0 ? top : bottom ;
217+ const approachFromLow = zy < 0 || ( zy === 0 && ay > 0 ) ;
218+ near = approachFromLow ? y >= boundary - EDGE_DISTANCE : y <= boundary + EDGE_DISTANCE ;
129219 }
130- if ( y >= centerY ) {
131- zones . push ( 2 ) ;
220+
221+ if ( near ) {
222+ nearby . push ( adj ) ;
132223 }
133224 }
134- if ( x >= centerX ) {
135- if ( y <= centerY ) {
136- zones . push ( 1 ) ;
225+
226+ return nearby ;
227+ }
228+
229+ /**
230+ * Get the zone indices that contain a given position.
231+ *
232+ * The board is divided into 8 zones: 4 corner zones (0-3) and 4 middle zones (4-7).
233+ * Center handling varies by size:
234+ * - 9x9: No middle zones, center included in corner zones with overlap
235+ * - 13x13+: Middle zones are the center bands, center intersection is ignored
236+ *
237+ * Zone layout for 13x13+:
238+ * 0 | 4 | 1 (corners 0-3, middles 4-7)
239+ * ----+-----+----
240+ * 7 | * | 5 (* = center intersection, ignored)
241+ * ----+-----+----
242+ * 2 | 6 | 3
243+ *
244+ * For 9x9, only corner zones (0-3) are used with overlap at center.
245+ */
246+ function getZones ( x : number , y : number , width : number , height : number ) : number [ ] {
247+ const maxSize = Math . max ( width , height ) ;
248+ const halfWidth = getCenterHalfWidth ( maxSize ) ;
249+ const centerX = Math . floor ( ( width - 1 ) / 2 ) ;
250+ const centerY = Math . floor ( ( height - 1 ) / 2 ) ;
251+
252+ // For 9x9, only corner zones with overlap at center
253+ if ( halfWidth < 0 ) {
254+ const zones : number [ ] = [ ] ;
255+ if ( x <= centerX ) {
256+ if ( y <= centerY ) {
257+ zones . push ( 0 ) ; // top-left
258+ }
259+ if ( y >= centerY ) {
260+ zones . push ( 2 ) ; // bottom-left
261+ }
137262 }
138- if ( y >= centerY ) {
139- zones . push ( 3 ) ;
263+ if ( x >= centerX ) {
264+ if ( y <= centerY ) {
265+ zones . push ( 1 ) ; // top-right
266+ }
267+ if ( y >= centerY ) {
268+ zones . push ( 3 ) ; // bottom-right
269+ }
140270 }
271+ return zones ;
141272 }
142273
143- return zones ;
144- }
274+ // For larger boards, check center bands for middle zones
275+ const inVerticalBand = Math . abs ( x - centerX ) <= halfWidth ;
276+ const inHorizontalBand = Math . abs ( y - centerY ) <= halfWidth ;
145277
146- function getZones ( x : number , y : number , width : number , height : number ) : number [ ] {
147- return getZonesQuadrant ( x , y , width , height ) ;
278+ // Center intersection is ignored (no zones)
279+ if ( inVerticalBand && inHorizontalBand ) {
280+ return [ ] ;
281+ }
282+
283+ // Vertical band (top middle or bottom middle)
284+ if ( inVerticalBand ) {
285+ if ( y < centerY - halfWidth ) {
286+ return [ 4 ] ; // top middle
287+ } else {
288+ return [ 6 ] ; // bottom middle
289+ }
290+ }
291+
292+ // Horizontal band (left middle or right middle)
293+ if ( inHorizontalBand ) {
294+ if ( x < centerX - halfWidth ) {
295+ return [ 7 ] ; // left middle
296+ } else {
297+ return [ 5 ] ; // right middle
298+ }
299+ }
300+
301+ // Corner zones (outside center bands)
302+ if ( x < centerX - halfWidth ) {
303+ if ( y < centerY - halfWidth ) {
304+ return [ 0 ] ; // top-left corner
305+ } else {
306+ return [ 2 ] ; // bottom-left corner
307+ }
308+ } else {
309+ if ( y < centerY - halfWidth ) {
310+ return [ 1 ] ; // top-right corner
311+ } else {
312+ return [ 3 ] ; // bottom-right corner
313+ }
314+ }
148315}
149316
150317function getNumZones ( size : number ) : number {
151- return size === 19 ? 8 : 4 ;
318+ const halfWidth = getCenterHalfWidth ( size ) ;
319+ return halfWidth < 0 ? 4 : 8 ; // 4 zones for 9x9, 8 zones for larger boards
152320}
153321
154322interface MoveCoordinate {
@@ -180,6 +348,22 @@ interface JosekiMoves {
180348 white : Set < number > ;
181349}
182350
351+ /**
352+ * Detect joseki moves using zone-based heuristics.
353+ *
354+ * This algorithm tracks 8 zones (4 corners + 4 middles for larger boards)
355+ * and determines which moves are part of joseki (opening patterns).
356+ * A zone remains "joseki" until:
357+ * - Accumulated score loss in the zone exceeds threshold
358+ * - A single move has very high score loss (> 2.4)
359+ * - Too many moves have been played in the zone
360+ *
361+ * When a zone exits joseki, it propagates to adjacent zones:
362+ * - Corner zones propagate to their two adjacent middle zones
363+ * - Middle zones propagate to their two adjacent corner zones
364+ *
365+ * For 9x9, only corner zones (0-3) are used with overlap at center.
366+ */
183367function detectJosekiMoves ( engine : GobanEngine , score_loss_list : ScoreLossList ) : JosekiMoves {
184368 const width = engine . width ;
185369 const height = engine . height ;
@@ -223,15 +407,42 @@ function detectJosekiMoves(engine: GobanEngine, score_loss_list: ScoreLossList):
223407 continue ;
224408 }
225409
410+ // Check if move is on the edge near an adjacent zone that's not in joseki
411+ if ( num_zones === 8 ) {
412+ const nearbyAdjacent = getNearbyAdjacentZones ( x , y , width , height , zone ) ;
413+ const adjacentNotJoseki = nearbyAdjacent . some (
414+ ( adj ) => ! zoneState . still_joseki [ adj ] ,
415+ ) ;
416+ if ( adjacentNotJoseki ) {
417+ // Bust this zone out of joseki and propagate
418+ zoneState . still_joseki [ zone ] = false ;
419+ propagateJosekiExit ( zone , zoneState . still_joseki ) ;
420+ continue ;
421+ }
422+ }
423+
226424 zoneState . moves_in_zone [ zone ] += 1 ;
227425 zoneState . zone_loss [ zone ] += move_loss ;
228426
427+ // First move in a zone gets 2x threshold tolerance
428+ const effectiveSingleMoveThreshold =
429+ zoneState . moves_in_zone [ zone ] === 1
430+ ? SINGLE_MOVE_LOSS_THRESHOLD * 2
431+ : SINGLE_MOVE_LOSS_THRESHOLD ;
432+
433+ // Middle zones (4-7) only allow 2 joseki moves
434+ const zoneLimit = zone >= 4 ? 2 : move_cutoff ;
435+
229436 if (
230437 zoneState . zone_loss [ zone ] > accumulated_loss_threshold ||
231- move_loss > SINGLE_MOVE_LOSS_THRESHOLD ||
232- zoneState . moves_in_zone [ zone ] > move_cutoff
438+ move_loss > effectiveSingleMoveThreshold ||
439+ zoneState . moves_in_zone [ zone ] > zoneLimit
233440 ) {
234441 zoneState . still_joseki [ zone ] = false ;
442+ // Propagate to adjacent zones (only for 8-zone boards)
443+ if ( num_zones === 8 ) {
444+ propagateJosekiExit ( zone , zoneState . still_joseki ) ;
445+ }
235446 } else {
236447 is_joseki = true ;
237448 }
@@ -413,10 +624,23 @@ function categorizeMoves(
413624 return { move_counters, categorized_moves } ;
414625}
415626
627+ /**
628+ * Gets the number of moves in the main line (trunk) of the move tree.
629+ * This excludes variations/branches but includes pass moves.
630+ */
631+ function getTrunkLength ( moveTree : MoveTree ) : number {
632+ let count = 0 ;
633+ let current : MoveTree | undefined = moveTree . trunk_next ; // Start from first move, not root
634+ while ( current ) {
635+ count ++ ;
636+ current = current . trunk_next ;
637+ }
638+ return count ;
639+ }
640+
416641function validateReviewData (
417642 ai_review : JGOFAIReview ,
418643 engine : GobanEngine ,
419- b_player : number ,
420644) : { isValid : boolean ; shouldShowTable : boolean } {
421645 const is_uploaded = engine . config . original_sgf !== undefined ;
422646 const scores = ai_review . scores ;
@@ -425,10 +649,13 @@ function validateReviewData(
425649 return { isValid : false , shouldShowTable : true } ;
426650 }
427651
652+ // For uploaded SGFs, use the trunk length (main line only, excluding variations)
653+ // For regular games, use the moves array length
654+ // Both should satisfy: moves_count === scores.length - 1
655+ // (scores includes the initial position, so there's one more score than moves)
656+ const trunk_length = is_uploaded ? getTrunkLength ( engine . move_tree ) : 0 ;
428657 const check1 = ! is_uploaded && engine . config . moves ?. length !== scores . length - 1 ;
429- const check2 =
430- is_uploaded &&
431- ( engine . config as any ) [ "all_moves" ] ?. split ( "!" ) . length - b_player !== scores . length ;
658+ const check2 = is_uploaded && trunk_length !== scores . length - 1 ;
432659
433660 if ( check1 || check2 ) {
434661 return { isValid : false , shouldShowTable : true } ;
@@ -467,13 +694,11 @@ export function AIReviewData_categorize(
467694 return null ;
468695 }
469696
470- const handicap = engine . handicap ;
471697 let handicap_offset = handicapOffset ( engine ) ;
472698 handicap_offset = handicap_offset === 1 ? 0 : handicap_offset ;
473- const b_player = handicap_offset > 0 || handicap > 1 ? 1 : 0 ;
474699 const move_player_list = getPlayerColorsMoveList ( engine ) ;
475700
476- const { isValid } = validateReviewData ( ai_review , engine , b_player ) ;
701+ const { isValid } = validateReviewData ( ai_review , engine ) ;
477702 if ( ! isValid ) {
478703 return null ;
479704 }
0 commit comments