Skip to content

Commit 9bea83f

Browse files
authored
Hula rules update (#233)
* Hula: correctly find shortest winning loops * Hula: new end-of-game rules * tests
1 parent e54103b commit 9bea83f

File tree

4 files changed

+361
-26
lines changed

4 files changed

+361
-26
lines changed

src/common/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RectGrid } from "./rectGrid";
2+
import { StackSet} from "./stackset";
23
import { reviver, replacer, sortingReplacer } from "./serialization";
34
import { shuffle } from "./shuffle";
45
import { UserFacingError } from "./errors";
@@ -9,7 +10,7 @@ import { hexhexAi2Ap, hexhexAp2Ai, triAi2Ap, triAp2Ai } from "./aiai";
910
import stringify from "json-stringify-deterministic";
1011
import fnv from "fnv-plus";
1112

12-
export { RectGrid, reviver, replacer, sortingReplacer, shuffle, UserFacingError, HexTriGraph, SnubSquareGraph, SquareOrthGraph, SquareDiagGraph, SquareGraph, Square3DGraph, SquareDirectedGraph, SquareFanoronaGraph, BaoGraph, SowingNoEndsGraph, wng, projectPoint, ptDistance, smallestDegreeDiff, normDeg, deg2rad, rad2deg, toggleFacing, calcBearing, matrixRectRot90, matrixRectRotN90, transposeRect, hexhexAi2Ap, hexhexAp2Ai, triAi2Ap, triAp2Ai, circle2poly, midpoint, distFromCircle };
13+
export { RectGrid, StackSet, reviver, replacer, sortingReplacer, shuffle, UserFacingError, HexTriGraph, SnubSquareGraph, SquareOrthGraph, SquareDiagGraph, SquareGraph, Square3DGraph, SquareDirectedGraph, SquareFanoronaGraph, BaoGraph, SowingNoEndsGraph, wng, projectPoint, ptDistance, smallestDegreeDiff, normDeg, deg2rad, rad2deg, toggleFacing, calcBearing, matrixRectRot90, matrixRectRotN90, transposeRect, hexhexAi2Ap, hexhexAp2Ai, triAi2Ap, triAp2Ai, circle2poly, midpoint, distFromCircle };
1314

1415
export type DirectionCardinal = "N" | "E" | "S" | "W";
1516
export type DirectionDiagonal = "NE" | "SE" | "SW" | "NW";

src/common/stackset.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* A StackSet helper class.
3+
* Converted from graphology source.
4+
*/
5+
export class StackSet {
6+
set: Set<string> = new Set<string>();
7+
stack: string[] = [];
8+
9+
has(value: string): boolean {
10+
return this.set.has(value);
11+
}
12+
13+
push(value: string): void {
14+
this.stack.push(value);
15+
this.set.add(value);
16+
}
17+
18+
pop(): void {
19+
this.set.delete(this.stack.pop() as string);
20+
}
21+
22+
path(value: string): string[] {
23+
return this.stack.concat(value);
24+
}
25+
26+
static of(value: string, cycle: boolean): StackSet{
27+
const set = new StackSet();
28+
29+
if (!cycle) {
30+
// Normally we add source both to set & stack
31+
set.push(value);
32+
} else {
33+
// But in case of cycle, we only add to stack so that we may reach the
34+
// source again (as it was not already visited)
35+
set.stack.push(value);
36+
}
37+
38+
return set;
39+
}
40+
}

src/games/hula.ts

Lines changed: 139 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { GameBase, IAPGameState, IClickResult, IIndividualState, IValidationResu
44
import { APGamesInformation } from "../schemas/gameinfo";
55
import { APRenderRep, RowCol } from "@abstractplay/renderer/src/schemas/schema";
66
import { 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";
98
import { bfsFromNode, dfsFromNode } from 'graphology-traversal';
109
import i18next from "i18next";
1110

11+
1212
export type playerid = 1|2;
1313
export 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

Comments
 (0)