@@ -4,11 +4,11 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResu
44import { APGamesInformation } from "../schemas/gameinfo" ;
55import { APRenderRep , RowCol } from "@abstractplay/renderer/src/schemas/schema" ;
66import { APMoveResult } from "../schemas/moveresults" ;
7- import { HexTriGraph , reviver , UserFacingError } from "../common" ;
8- import { allSimplePaths } from 'graphology-simple-path' ;
7+ import { HexTriGraph , reviver , UserFacingError , StackSet } from "../common" ;
98import { bfsFromNode , dfsFromNode } from 'graphology-traversal' ;
109import i18next from "i18next" ;
1110
11+
1212export type playerid = 1 | 2 ;
1313export type cellcontent = playerid | "neutral" ;
1414
@@ -292,18 +292,14 @@ export class HulaGame extends GameBase {
292292 return this ;
293293 }
294294
295- public getWinningLoop ( player : playerid , lastmove : string ) : string [ ] {
296- const blockers = new Set < string > ( ) ;
297295
298- let graph = this . getGraph ( false ) ;
299- const center = graph . coords2algebraic ( this . boardsize - 1 , this . boardsize - 1 ) ;
296+ private enclosesCenter ( group : Set < string > ) : boolean {
297+ const graph = this . getGraph ( false ) ; // The board, including center cell
300298
299+ const center = graph . coords2algebraic ( this . boardsize - 1 , this . boardsize - 1 ) ;
301300 let reachedOuter = false ;
302-
303301 bfsFromNode ( graph . graph , center , ( cell ) => {
304- const value = this . board . get ( cell ) ;
305- if ( value === player || value === "neutral" ) {
306- blockers . add ( cell ) ;
302+ if ( group . has ( cell ) ) {
307303 return true ;
308304 }
309305 else if ( this . outerRing . has ( cell ) ) {
@@ -312,32 +308,150 @@ export class HulaGame extends GameBase {
312308 return false ;
313309 } ) ;
314310
315- if ( reachedOuter ) { return [ ] ; }
311+ return ! reachedOuter ;
312+ }
316313
317- graph = this . getGraph ( ) ;
314+ private allShortestCycles ( group : Set < string > , source : string ) : string [ ] [ ] {
315+ /* Adapted from Graphology's allSimplePaths. Finds the shortest winning cycle,
316+ and any other cycles (if existing) of the same length, then returns them in
317+ an array. The reason for returning all of them is because we might need the one
318+ with the least amount of neutrals, which is not necessarily the first-found one.
319+ */
320+ const groupGraph = this . getGraph ( ) ;
318321 for ( const cell of this . graph . graph . nodes ( ) ) {
319- if ( ! blockers . has ( cell ) ) { graph . graph . dropNode ( cell ) ; }
322+ if ( ! group . has ( cell ) ) { groupGraph . graph . dropNode ( cell ) ; }
320323 }
324+ const graph = groupGraph . graph ;
325+
326+ let found = false ;
327+ /* Iterative deepening dfs */
328+ for ( let maxDepth = 6 ; maxDepth <= group . size ; maxDepth ++ ) {
329+ const stack = [ graph . outboundNeighbors ( source ) ] ;
330+ const visited = StackSet . of ( source , true ) ;
331+
332+ const paths : string [ ] [ ] = [ ] ;
333+ let p : string [ ] ;
334+ let children ;
335+ let child ;
336+
337+ while ( stack . length !== 0 ) {
338+ children = stack [ stack . length - 1 ] ;
339+ child = children . pop ( ) ;
340+
341+ if ( ! child ) {
342+ stack . pop ( ) ;
343+ visited . pop ( ) ;
344+ } else {
345+ if ( visited . has ( child ) ) continue ;
346+
347+ /* Check whether the last three nodes of the path form a triangle,
348+ if so we can skip the rest of this branch, because the shortest loop
349+ will never contain an acute angle. */
350+ p = visited . path ( child ) ;
351+ const tri = p . slice ( - 3 ) ;
352+ if ( graph . hasEdge ( tri [ 0 ] , tri [ 1 ] ) && graph . hasEdge ( tri [ 1 ] , tri [ 2 ] ) &&
353+ graph . hasEdge ( tri [ 2 ] , tri [ 0 ] ) ) {
354+ continue ;
355+ }
356+
357+ if ( child === source ) {
358+ if ( this . enclosesCenter ( new Set ( p ) ) ) {
359+ paths . push ( p ) ;
360+ found = true ;
361+ }
362+ }
363+
364+ visited . push ( child ) ;
365+
366+ if ( ! visited . has ( source ) && stack . length < maxDepth ) {
367+ stack . push ( graph . outboundNeighbors ( child ) ) ;
368+ } else {
369+ visited . pop ( ) ;
370+ }
371+ }
372+ }
373+ if ( found ) {
374+ return paths ;
375+ }
376+ }
377+ return [ ] ;
378+ }
321379
322- let cycles = allSimplePaths ( graph . graph , lastmove , lastmove ) ;
323- cycles = cycles . filter ( c => c . length > 6 ) ;
324- cycles . sort ( ( a , b ) => a . length - b . length ) ;
380+ public getWinningLoop ( player : playerid , lastmove : string ) : [ string [ ] , number ] {
381+ /*
382+ Do a BFS to find all possible paths emanating from the placed stone, for each check if it's a winning loop.
383+ Since it is a BFS the shortest one will be found first.
384+ Could be sped up by not taking sharp-angled steps in the BFS.
385+ */
386+ const graph = this . getGraph ( false ) ; // The board, including center cell
387+
388+ // Find the current group of player + neutral stones
389+ const currentGroup = new Set < string > ( ) ;
390+ bfsFromNode ( graph . graph , lastmove , ( cell ) => {
391+ const value = this . board . get ( cell ) ;
392+ if ( value === player || value === "neutral" ) {
393+ currentGroup . add ( cell ) ;
394+ return false ;
395+ } else {
396+ return true ;
397+ }
398+ } ) ;
399+
400+ // First check if there's a winning loop at all (i.e. path from center to edge is blocked by player + neutral stones)
401+ if ( ! this . enclosesCenter ( currentGroup ) ) { return [ [ ] , 0 ] ; } ;
402+
403+ const cycles = this . allShortestCycles ( currentGroup , lastmove ) ;
404+
405+ let fewestNeutrals = Infinity ;
406+ let bestCycle : string [ ] = [ ] ;
407+ for ( const cycle of cycles ) {
408+ let neutrals = 0 ;
409+ for ( const cell of cycle ) {
410+ if ( this . board . get ( cell ) === "neutral" ) {
411+ neutrals ++ ;
412+ }
413+ }
414+ if ( neutrals < fewestNeutrals ) {
415+ fewestNeutrals = neutrals ;
416+ bestCycle = cycle ;
417+ }
418+ }
325419
326- return cycles [ 0 ] ;
420+ return [ bestCycle , fewestNeutrals ] ;
327421 }
328422
329423 protected checkEOG ( ) : HulaGame {
330-
331- for ( const player of [ this . currplayer , this . otherPlayer ( ) ] ) {
332- const loop = this . getWinningLoop ( player , this . lastmove ! ) ;
333- if ( loop . length > 0 ) {
334- this . winner . push ( player ) ;
335- this . gameover = true ;
336- this . winningLoop = loop ;
337- break ;
424+ /* If both players get a loop simultaneously, the shortest loop wins.
425+ If they are equally long, the loop with fewer neutrals wins.
426+ If this is equal too, p2 wins. */
427+ if ( this . board . get ( this . lastmove ! ) === "neutral" ) {
428+ const [ p1loop , p1neutrals ] = this . getWinningLoop ( 1 , this . lastmove ! ) ;
429+ const [ p2loop , p2neutrals ] = this . getWinningLoop ( 2 , this . lastmove ! ) ;
430+ if ( p1loop . length && ! p2loop . length ) {
431+ this . winner . push ( 1 ) ;
432+ } else if ( p2loop . length && ! p1loop . length ) {
433+ this . winner . push ( 2 ) ;
434+ } else if ( p1loop . length && p2loop . length ) {
435+ if ( p1loop . length === p2loop . length ) {
436+ if ( p1neutrals === p2neutrals ) {
437+ this . winner . push ( 2 ) ;
438+ } else {
439+ this . winner . push ( ( p1neutrals < p2neutrals ) ? 1 : 2 ) ;
440+ }
441+ } else {
442+ this . winner . push ( ( p1loop . length < p2loop . length ) ? 1 : 2 ) ;
443+ }
444+ }
445+ this . winningLoop = ( this . winner [ 0 ] === 1 ) ? p1loop : p2loop ;
446+ } else {
447+ const currloop = this . getWinningLoop ( this . currplayer , this . lastmove ! ) [ 0 ] ;
448+ if ( currloop . length > 0 ) {
449+ this . winner . push ( this . currplayer ) ;
450+ this . winningLoop = currloop ;
338451 }
339452 }
340453
454+ this . gameover = this . winner . length > 0 ;
341455 if ( this . gameover ) {
342456 this . results . push (
343457 { type : "eog" } ,
0 commit comments