gomoku-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ import { cloneBoard, createEmptyBoard, inBounds, isBoardFull } from './board.js';
2
+ import { findWinningLine } from './win.js';
3
+ export function createInitialState() {
4
+ return {
5
+ board: createEmptyBoard(),
6
+ currentPlayer: 'X',
7
+ status: 'playing',
8
+ winner: null,
9
+ moves: [],
10
+ };
11
+ }
12
+ export function validateMove(state, row, col, player) {
13
+ if (state.status !== 'playing') {
14
+ return 'game-over';
15
+ }
16
+ if (!inBounds(row, col)) {
17
+ return 'out-of-bounds';
18
+ }
19
+ if (state.board[row][col] !== null) {
20
+ return 'occupied';
21
+ }
22
+ if (player !== state.currentPlayer) {
23
+ return 'not-your-turn';
24
+ }
25
+ return null;
26
+ }
27
+ /**
28
+ * Scans every occupied cell for a winning line. Only reached when the board is
29
+ * full, so the cost is bounded to a single pass per completed draw.
30
+ */
31
+ function boardHasAnyWin(board) {
32
+ for (let row = 0; row < board.length; row += 1) {
33
+ for (let col = 0; col < board[row].length; col += 1) {
34
+ if (board[row][col] !== null &&
35
+ findWinningLine(board, row, col) !== null) {
36
+ return true;
37
+ }
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+ function applyMoveCore(state, row, col, player) {
43
+ const error = validateMove(state, row, col, player);
44
+ if (error !== null) {
45
+ return { ok: false, reason: error };
46
+ }
47
+ const board = cloneBoard(state.board);
48
+ board[row][col] = player;
49
+ const move = { row, col, player };
50
+ const base = { ...state, board, moves: [...state.moves, move] };
51
+ if (findWinningLine(board, row, col) !== null) {
52
+ return { ok: true, state: { ...base, status: 'won', winner: player }, move };
53
+ }
54
+ if (isBoardFull(board) && !boardHasAnyWin(board)) {
55
+ return { ok: true, state: { ...base, status: 'draw', winner: null }, move };
56
+ }
57
+ return {
58
+ ok: true,
59
+ state: { ...base, currentPlayer: player === 'X' ? 'O' : 'X' },
60
+ move,
61
+ };
62
+ }
63
+ export function applyMove(state, row, col) {
64
+ return applyMoveCore(state, row, col, state.currentPlayer);
65
+ }
66
+ export function applyMoveAs(state, row, col, player) {
67
+ return applyMoveCore(state, row, col, player);
68
+ }
69
+ /** The latest human decision is the last X in a valid alternating history. */
70
+ export function latestHumanMove(state) {
71
+ return state.moves.findLast((move) => move.player === 'X') ?? null;
72
+ }
73
+ /**
74
+ * Undo the last human decision and any machine reply to it. Replay the
75
+ * surviving legal history through the normal engine so board, status and turn
76
+ * cannot disagree. Empty history is a no-op; the input is never mutated.
77
+ */
78
+ export function undoHumanTurn(state) {
79
+ const lastHumanIndex = state.moves.findLastIndex((move) => move.player === 'X');
80
+ if (lastHumanIndex === -1) {
81
+ return state;
82
+ }
83
+ let rebuilt = createInitialState();
84
+ for (const move of state.moves.slice(0, lastHumanIndex)) {
85
+ const result = applyMoveAs(rebuilt, move.row, move.col, move.player);
86
+ if (!result.ok) {
87
+ throw new Error(`undoHumanTurn: unreplayable move ${move.row},${move.col}`);
88
+ }
89
+ rebuilt = result.state;
90
+ }
91
+ return rebuilt;
92
+ }
93
+ export function latestMove(state) {
94
+ return state.moves.at(-1) ?? null;
95
+ }
96
+ export function moveCount(state) {
97
+ return state.moves.length;
98
+ }
@@ -0,0 +1,3 @@
1
+ export const BOARD_SIZE = 15;
2
+ export const HUMAN = 'X';
3
+ export const MACHINE = 'O';
@@ -0,0 +1,78 @@
1
+ import { inBounds } from './board.js';
2
+ export const WIN_LENGTH = 5;
3
+ export const DIRECTIONS = [
4
+ [0, 1],
5
+ [1, 0],
6
+ [1, 1],
7
+ [1, -1],
8
+ ];
9
+ /**
10
+ * Counts how many marks `player` would have consecutively through `(row, col)`
11
+ * in direction `(dr, dc)`, including `(row, col)` itself. Turn-agnostic.
12
+ */
13
+ function countHypothetical(board, row, col, player, dr, dc) {
14
+ let count = 1;
15
+ let startRow = row;
16
+ let startCol = col;
17
+ let endRow = row;
18
+ let endCol = col;
19
+ let r = row + dr;
20
+ let c = col + dc;
21
+ while (inBounds(r, c) && board[r][c] === player) {
22
+ count += 1;
23
+ endRow = r;
24
+ endCol = c;
25
+ r += dr;
26
+ c += dc;
27
+ }
28
+ r = row - dr;
29
+ c = col - dc;
30
+ while (inBounds(r, c) && board[r][c] === player) {
31
+ count += 1;
32
+ startRow = r;
33
+ startCol = c;
34
+ r -= dr;
35
+ c -= dc;
36
+ }
37
+ return {
38
+ count,
39
+ start: { row: startRow, col: startCol },
40
+ end: { row: endRow, col: endCol },
41
+ };
42
+ }
43
+ /**
44
+ * Reads the mark already at `(row, col)` and returns the first direction in
45
+ * which that mark forms a line of `WIN_LENGTH` or more; otherwise `null`.
46
+ */
47
+ export function findWinningLine(board, row, col) {
48
+ if (!inBounds(row, col)) {
49
+ return null;
50
+ }
51
+ const player = board[row][col];
52
+ if (player === null) {
53
+ return null;
54
+ }
55
+ for (const [dr, dc] of DIRECTIONS) {
56
+ const { count, start, end } = countHypothetical(board, row, col, player, dr, dc);
57
+ if (count >= WIN_LENGTH) {
58
+ return { player, direction: [dr, dc], count, start, end };
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+ /**
64
+ * Returns `true` when placing `player` at `(row, col)` would immediately create
65
+ * a winning line. Turn-agnostic: no check against `currentPlayer`.
66
+ */
67
+ export function isWinningMove(board, row, col, player) {
68
+ if (!inBounds(row, col) || board[row][col] !== null) {
69
+ return false;
70
+ }
71
+ for (const [dr, dc] of DIRECTIONS) {
72
+ const { count } = countHypothetical(board, row, col, player, dr, dc);
73
+ if (count >= WIN_LENGTH) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
@@ -0,0 +1,47 @@
1
+ import { emptyCells, inBounds } from '../domain/board.js';
2
+ import { BOARD_SIZE } from '../domain/types.js';
3
+ export const CENTER = (BOARD_SIZE - 1) / 2;
4
+ export function centerDistance(row, col) {
5
+ const dr = row - CENTER;
6
+ const dc = col - CENTER;
7
+ return dr * dr + dc * dc;
8
+ }
9
+ /** Stable tie order, independent of board traversal and insertion order. */
10
+ export function compareCells(a, b) {
11
+ return (centerDistance(a[0], a[1]) - centerDistance(b[0], b[1]) ||
12
+ a[0] - b[0] ||
13
+ a[1] - b[1]);
14
+ }
15
+ /**
16
+ * Every empty square within two steps of an existing stone. Any immediate win
17
+ * or block necessarily falls in this neighborhood. Dense positions simply
18
+ * include every available cell. Neither the input board nor its rows change.
19
+ */
20
+ export function candidateCells(board) {
21
+ const nearby = new Set();
22
+ let hasStones = false;
23
+ for (let row = 0; row < BOARD_SIZE; row += 1) {
24
+ for (let col = 0; col < BOARD_SIZE; col += 1) {
25
+ if (board[row][col] === null)
26
+ continue;
27
+ hasStones = true;
28
+ for (let dr = -2; dr <= 2; dr += 1) {
29
+ for (let dc = -2; dc <= 2; dc += 1) {
30
+ const r = row + dr;
31
+ const c = col + dc;
32
+ if (inBounds(r, c) && board[r][c] === null) {
33
+ nearby.add(r * BOARD_SIZE + c);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
39
+ if (!hasStones)
40
+ return [[CENTER, CENTER]];
41
+ // A nonterminal board with no nearby empties is possible in a contrived
42
+ // state. Fall back to all empty cells rather than failing to make a move.
43
+ const cells = nearby.size
44
+ ? [...nearby].map((key) => [Math.floor(key / BOARD_SIZE), key % BOARD_SIZE])
45
+ : emptyCells(board);
46
+ return cells.sort(compareCells);
47
+ }
@@ -0,0 +1,140 @@
1
+ import { inBounds } from '../domain/board.js';
2
+ import { DIRECTIONS, WIN_LENGTH } from '../domain/win.js';
3
+ /** Count a contiguous line including a hypothetical stone at an empty cell. */
4
+ export function countRun(board, row, col, player, dr, dc) {
5
+ let run = 1;
6
+ let r = row + dr;
7
+ let c = col + dc;
8
+ while (inBounds(r, c) && board[r][c] === player) {
9
+ run += 1;
10
+ r += dr;
11
+ c += dc;
12
+ }
13
+ const forwardOpen = inBounds(r, c) && board[r][c] === null;
14
+ r = row - dr;
15
+ c = col - dc;
16
+ while (inBounds(r, c) && board[r][c] === player) {
17
+ run += 1;
18
+ r -= dr;
19
+ c -= dc;
20
+ }
21
+ const backwardOpen = inBounds(r, c) && board[r][c] === null;
22
+ return { run, openEnds: Number(forwardOpen) + Number(backwardOpen) };
23
+ }
24
+ export function createsOpenThree(board, row, col, player, dr, dc) {
25
+ const { run, openEnds } = countRun(board, row, col, player, dr, dc);
26
+ return run === 3 && openEnds === 2;
27
+ }
28
+ /** Get the hypothetical stone without modifying the board. */
29
+ function markAt(board, row, col, player, placedRow, placedCol) {
30
+ if (!inBounds(row, col))
31
+ return undefined;
32
+ return row === placedRow && col === placedCol ? player : board[row][col];
33
+ }
34
+ /**
35
+ * Recognize one internal gap between two stones, without mistaking a board
36
+ * edge or an opponent-capped sequence for an open threat. The strongest shape
37
+ * in a direction wins, avoiding multiple counts of the same line.
38
+ */
39
+ function gappedShape(board, row, col, player, dr, dc) {
40
+ for (const [length, stones] of [
41
+ [WIN_LENGTH, WIN_LENGTH - 1],
42
+ [WIN_LENGTH - 1, WIN_LENGTH - 2],
43
+ ]) {
44
+ for (let offset = -(length - 1); offset <= 0; offset += 1) {
45
+ if (markAt(board, row + offset * dr, col + offset * dc, player, row, col) !== player ||
46
+ markAt(board, row + (offset + length - 1) * dr, col + (offset + length - 1) * dc, player, row, col) !== player)
47
+ continue;
48
+ let stonesFound = 0;
49
+ let emptyFound = 0;
50
+ for (let index = 0; index < length; index += 1) {
51
+ const cell = markAt(board, row + (offset + index) * dr, col + (offset + index) * dc, player, row, col);
52
+ if (cell === player)
53
+ stonesFound += 1;
54
+ else if (cell === null)
55
+ emptyFound += 1;
56
+ }
57
+ if (stonesFound !== stones || emptyFound !== 1)
58
+ continue;
59
+ if (length === WIN_LENGTH)
60
+ return 'broken-four';
61
+ const before = markAt(board, row + (offset - 1) * dr, col + (offset - 1) * dc, player, row, col);
62
+ const after = markAt(board, row + (offset + length) * dr, col + (offset + length) * dc, player, row, col);
63
+ if (before === null && after === null)
64
+ return 'broken-three';
65
+ }
66
+ }
67
+ return 'none';
68
+ }
69
+ const SHAPE_VALUE = {
70
+ win: 1_000_000,
71
+ 'open-four': 120_000,
72
+ 'broken-four': 42_000,
73
+ 'closed-four': 35_000,
74
+ 'open-three': 7_500,
75
+ 'broken-three': 4_500,
76
+ 'closed-three': 500,
77
+ 'open-two': 160,
78
+ 'closed-two': 35,
79
+ none: 0,
80
+ };
81
+ function bestShape(board, row, col, player, dr, dc) {
82
+ const { run, openEnds } = countRun(board, row, col, player, dr, dc);
83
+ if (run >= WIN_LENGTH)
84
+ return 'win';
85
+ const contiguous = run === 4 && openEnds === 2
86
+ ? 'open-four'
87
+ : run === 4 && openEnds === 1
88
+ ? 'closed-four'
89
+ : run === 3 && openEnds === 2
90
+ ? 'open-three'
91
+ : run === 3 && openEnds === 1
92
+ ? 'closed-three'
93
+ : run === 2 && openEnds === 2
94
+ ? 'open-two'
95
+ : run === 2 && openEnds === 1
96
+ ? 'closed-two'
97
+ : 'none';
98
+ const broken = gappedShape(board, row, col, player, dr, dc);
99
+ return SHAPE_VALUE[broken] > SHAPE_VALUE[contiguous] ? broken : contiguous;
100
+ }
101
+ /** Symmetric, turn-agnostic evaluation of placing either X or O at an empty cell. */
102
+ export function evaluatePlacement(board, row, col, player) {
103
+ if (!inBounds(row, col) || board[row][col] !== null) {
104
+ return { score: 0, openThrees: 0, openFours: 0, brokenFours: 0 };
105
+ }
106
+ let score = 0;
107
+ let openThrees = 0;
108
+ let openFours = 0;
109
+ let brokenFours = 0;
110
+ for (const [dr, dc] of DIRECTIONS) {
111
+ const shape = bestShape(board, row, col, player, dr, dc);
112
+ score += SHAPE_VALUE[shape];
113
+ if (shape === 'open-three')
114
+ openThrees += 1;
115
+ if (shape === 'open-four')
116
+ openFours += 1;
117
+ if (shape === 'broken-four')
118
+ brokenFours += 1;
119
+ }
120
+ // Two independent open threes force distinct replies. Prefer the fork to
121
+ // isolated open threes; this is positional assessment, not a reply search.
122
+ if (openThrees >= 2)
123
+ score += 25_000;
124
+ if (openFours + brokenFours >= 2)
125
+ score += 40_000;
126
+ // Nearby connections are only a tie-breaking positional signal. They must
127
+ // never override a live tactical shape. Edge/blocked lines earn no openness.
128
+ for (let dr = -2; dr <= 2; dr += 1) {
129
+ for (let dc = -2; dc <= 2; dc += 1) {
130
+ if (dr === 0 && dc === 0)
131
+ continue;
132
+ const r = row + dr;
133
+ const c = col + dc;
134
+ if (inBounds(r, c) && board[r][c] === player) {
135
+ score += Math.max(Math.abs(dr), Math.abs(dc)) === 1 ? 8 : 2;
136
+ }
137
+ }
138
+ }
139
+ return { score, openThrees, openFours, brokenFours };
140
+ }
@@ -0,0 +1,18 @@
1
+ import { validateMove } from '../domain/engine.js';
2
+ import { MACHINE } from '../domain/types.js';
3
+ import { DIRECTIONS } from '../domain/win.js';
4
+ import { CENTER, centerDistance } from './candidates.js';
5
+ import { createsOpenThree } from './evaluation.js';
6
+ import { DEFAULT_DIFFICULTY, SEARCH_PROFILES, } from './profiles.js';
7
+ import { searchMove } from './search.js';
8
+ export { CENTER, centerDistance };
9
+ /** Retain the original machine-turn and legality contract for forkCell. */
10
+ export function forkCell(state, row, col) {
11
+ return (validateMove(state, row, col, MACHINE) === null &&
12
+ DIRECTIONS.filter(([dr, dc]) => createsOpenThree(state.board, row, col, MACHINE, dr, dc)).length >= 2);
13
+ }
14
+ /** The default call retains the pre-difficulty Normal search policy. */
15
+ export function chooseMove(state, options = {}) {
16
+ const difficulty = options.difficulty ?? DEFAULT_DIFFICULTY;
17
+ return searchMove(state, SEARCH_PROFILES[difficulty]).move;
18
+ }
@@ -0,0 +1,28 @@
1
+ import { DEFAULT_SEARCH_LIMITS } from './search.js';
2
+ export const DIFFICULTIES = ['easy', 'normal', 'hard'];
3
+ export const DEFAULT_DIFFICULTY = 'normal';
4
+ /** Each difficulty uses the same evaluator and legal-move search. */
5
+ export const SEARCH_PROFILES = {
6
+ easy: {
7
+ maxNodes: 32,
8
+ maxDepth: 2,
9
+ rootWidth: 5,
10
+ replyWidth: 2,
11
+ forcingWidth: 1,
12
+ },
13
+ normal: DEFAULT_SEARCH_LIMITS,
14
+ hard: {
15
+ maxNodes: 320,
16
+ maxDepth: 6,
17
+ rootWidth: 12,
18
+ replyWidth: 6,
19
+ forcingWidth: 5,
20
+ },
21
+ };
22
+ export function nextDifficulty(current) {
23
+ const index = DIFFICULTIES.indexOf(current);
24
+ return DIFFICULTIES[(index + 1) % DIFFICULTIES.length];
25
+ }
26
+ export function difficultyLabel(difficulty) {
27
+ return difficulty[0].toUpperCase() + difficulty.slice(1);
28
+ }
@@ -0,0 +1,170 @@
1
+ import { applyMove } from '../domain/engine.js';
2
+ import { HUMAN, MACHINE, } from '../domain/types.js';
3
+ import { candidateCells, compareCells } from './candidates.js';
4
+ import { evaluatePlacement } from './evaluation.js';
5
+ export const DEFAULT_SEARCH_LIMITS = {
6
+ maxNodes: 120,
7
+ maxDepth: 4,
8
+ rootWidth: 10,
9
+ replyWidth: 5,
10
+ forcingWidth: 4,
11
+ };
12
+ const WIN_SCORE = 100_000_000;
13
+ // After a forcing placement, inspect the reply and the next counter-move.
14
+ // A quiet continuation must not inherit the forcing flag indefinitely.
15
+ const FORCING_REPLY_WINDOW = 2;
16
+ const OPPONENT = {
17
+ X: MACHINE,
18
+ O: HUMAN,
19
+ };
20
+ /** Rank both players with the same evaluator, keeping tactical moves first. */
21
+ function inspect(state) {
22
+ const actor = state.currentPlayer;
23
+ const ranked = [];
24
+ const ownWins = [];
25
+ const enemyWins = [];
26
+ for (const cell of candidateCells(state.board)) {
27
+ const [row, col] = cell;
28
+ const own = evaluatePlacement(state.board, row, col, actor);
29
+ const enemy = evaluatePlacement(state.board, row, col, OPPONENT[actor]);
30
+ // Includes capped fours (35k), open/broken fours, and multi-three forks.
31
+ const makesThreat = own.score >= 35_000;
32
+ const move = {
33
+ cell,
34
+ attack: own.score,
35
+ defense: enemy.score,
36
+ priority: own.score + enemy.score * 1.15,
37
+ forcing: makesThreat,
38
+ };
39
+ ranked.push(move);
40
+ if (own.score >= 1_000_000)
41
+ ownWins.push(move);
42
+ if (enemy.score >= 1_000_000)
43
+ enemyWins.push(move);
44
+ }
45
+ ranked.sort((a, b) => b.priority - a.priority || compareCells(a.cell, b.cell));
46
+ // Tactical moves are mandatory. With two distinct threats there may be no
47
+ // saving reply, but blocking one is preferable to ignoring both.
48
+ return ownWins.length ? ownWins : enemyWins.length ? enemyWins : ranked;
49
+ }
50
+ function terminalScore(state, ply) {
51
+ if (state.status === 'draw')
52
+ return 0;
53
+ if (state.status === 'won') {
54
+ return state.winner === MACHINE ? WIN_SCORE - ply : -WIN_SCORE + ply;
55
+ }
56
+ throw new Error('terminalScore requires a finished game');
57
+ }
58
+ /** A bounded heuristic for a move. Only the engine decides actual wins. */
59
+ function moveValue(move, player) {
60
+ const value = move.attack + move.defense * 0.03;
61
+ return player === MACHINE ? value : -value;
62
+ }
63
+ function nextForcingWindow(move, remaining) {
64
+ return move.forcing ? FORCING_REPLY_WINDOW : Math.max(0, remaining - 1);
65
+ }
66
+ /** Score from O's perspective: O maximizes, X minimizes. */
67
+ function search(state, ply, forcingRepliesLeft, alpha, beta, context, heuristic) {
68
+ if (context.stats.nodes >= context.limits.maxNodes) {
69
+ context.stats.budgetExhausted = true;
70
+ return { score: 0, complete: false };
71
+ }
72
+ context.stats.nodes += 1;
73
+ context.stats.maxDepth = Math.max(context.stats.maxDepth, ply);
74
+ if (state.status !== 'playing') {
75
+ return { score: terminalScore(state, ply), complete: true };
76
+ }
77
+ if (ply >= context.limits.maxDepth ||
78
+ (ply >= 2 && forcingRepliesLeft === 0)) {
79
+ return { score: heuristic, complete: true };
80
+ }
81
+ const ranked = inspect(state);
82
+ const maximize = state.currentPlayer === MACHINE;
83
+ const width = ply === 1 ? context.limits.replyWidth : context.limits.forcingWidth;
84
+ let best = maximize ? -Infinity : Infinity;
85
+ let evaluated = false;
86
+ for (const choice of ranked.slice(0, width)) {
87
+ if (context.stats.nodes >= context.limits.maxNodes) {
88
+ context.stats.budgetExhausted = true;
89
+ return { score: best, complete: false };
90
+ }
91
+ const applied = applyMove(state, ...choice.cell);
92
+ if (!applied.ok)
93
+ throw new Error('search generated an illegal move');
94
+ const outcome = search(applied.state, ply + 1, nextForcingWindow(choice, forcingRepliesLeft), alpha, beta, context, heuristic + moveValue(choice, state.currentPlayer));
95
+ if (!outcome.complete)
96
+ return { score: best, complete: false };
97
+ evaluated = true;
98
+ best = maximize
99
+ ? Math.max(best, outcome.score)
100
+ : Math.min(best, outcome.score);
101
+ if (maximize)
102
+ alpha = Math.max(alpha, best);
103
+ else
104
+ beta = Math.min(beta, best);
105
+ if (beta <= alpha)
106
+ break;
107
+ }
108
+ return { score: evaluated ? best : heuristic, complete: true };
109
+ }
110
+ /**
111
+ * Search from an active machine turn. Terminal/wrong-turn states throw, as
112
+ * chooseMove has always done. This function does not mutate the input state.
113
+ * When the work budget is exhausted, the best fully checked move is returned;
114
+ * if no candidate was fully searched, the deterministic top-ranked move wins.
115
+ */
116
+ export function searchMove(state, limits = DEFAULT_SEARCH_LIMITS) {
117
+ if (state.status !== 'playing' || state.currentPlayer !== MACHINE) {
118
+ throw new Error('no legal move available');
119
+ }
120
+ if (!Number.isInteger(limits.maxNodes) ||
121
+ limits.maxNodes < 1 ||
122
+ !Number.isInteger(limits.maxDepth) ||
123
+ limits.maxDepth < 2 ||
124
+ limits.maxDepth > 6 ||
125
+ !Number.isInteger(limits.rootWidth) ||
126
+ limits.rootWidth < 1 ||
127
+ !Number.isInteger(limits.replyWidth) ||
128
+ limits.replyWidth < 1 ||
129
+ !Number.isInteger(limits.forcingWidth) ||
130
+ limits.forcingWidth < 1) {
131
+ throw new Error('invalid search limits');
132
+ }
133
+ const ranked = inspect(state);
134
+ if (!ranked.length)
135
+ throw new Error('no legal move available');
136
+ const context = {
137
+ limits: { ...limits },
138
+ stats: { nodes: 0, maxDepth: 0, budgetExhausted: false },
139
+ };
140
+ let bestMove = ranked[0];
141
+ let bestScore = -Infinity;
142
+ let completed = false;
143
+ for (const choice of ranked.slice(0, limits.rootWidth)) {
144
+ if (context.stats.nodes >= limits.maxNodes) {
145
+ context.stats.budgetExhausted = true;
146
+ break;
147
+ }
148
+ const applied = applyMove(state, ...choice.cell);
149
+ if (!applied.ok)
150
+ throw new Error('search generated an illegal move');
151
+ // A win is absolute. Earlier wins outrank delayed ones and all heuristics.
152
+ const outcome = search(applied.state, 1, nextForcingWindow(choice, 0), bestScore, Infinity, context, moveValue(choice, MACHINE));
153
+ if (!outcome.complete)
154
+ break;
155
+ if (!completed ||
156
+ outcome.score > bestScore ||
157
+ (outcome.score === bestScore &&
158
+ compareCells(choice.cell, bestMove.cell) < 0)) {
159
+ bestMove = choice;
160
+ bestScore = outcome.score;
161
+ }
162
+ completed = true;
163
+ if (bestScore >= WIN_SCORE - 1)
164
+ break;
165
+ }
166
+ return {
167
+ move: { row: bestMove.cell[0], col: bestMove.cell[1], player: MACHINE },
168
+ stats: context.stats,
169
+ };
170
+ }
@@ -0,0 +1,95 @@
1
+ const ENTER_SCREEN = '\u001b[?1049h\u001b[H';
2
+ const LEAVE_SCREEN = '\u001b[?25h\u001b[?1049l';
3
+ const ENABLE_MOUSE = '\u001b[?1006h\u001b[?1000h';
4
+ const DISABLE_MOUSE = '\u001b[?1000l\u001b[?1006l';
5
+ const SHUTDOWN_SIGNALS = [
6
+ 'SIGINT',
7
+ 'SIGTERM',
8
+ 'SIGHUP',
9
+ ];
10
+ export class AlternateScreen {
11
+ streams;
12
+ active = false;
13
+ constructor(streams) {
14
+ this.streams = streams;
15
+ }
16
+ enter() {
17
+ if (this.active)
18
+ return true;
19
+ if (this.streams.stdin.isTTY !== true ||
20
+ this.streams.stdout.isTTY !== true) {
21
+ return false;
22
+ }
23
+ this.streams.stdout.write(ENTER_SCREEN + ENABLE_MOUSE);
24
+ this.active = true;
25
+ return true;
26
+ }
27
+ leave() {
28
+ if (!this.active)
29
+ return;
30
+ this.active = false;
31
+ this.streams.stdout.write(DISABLE_MOUSE + LEAVE_SCREEN);
32
+ }
33
+ }
34
+ /** Ink must stop rendering before the original terminal screen is restored. */
35
+ export async function runFullscreenApp({ stdin, stdout, signals, mount, input, }) {
36
+ const screen = new AlternateScreen({ stdin, stdout });
37
+ if (!screen.enter())
38
+ return 'not-interactive';
39
+ let app;
40
+ let shutdown;
41
+ let exited = false;
42
+ let unmountRequested = false;
43
+ const installed = [];
44
+ const requestUnmount = () => {
45
+ if (!app || unmountRequested)
46
+ return;
47
+ unmountRequested = true;
48
+ app.unmount();
49
+ };
50
+ const onSignal = (signal) => () => {
51
+ if (shutdown || exited)
52
+ return;
53
+ shutdown = signal;
54
+ requestUnmount();
55
+ };
56
+ const listeners = SHUTDOWN_SIGNALS.map((signal) => ({
57
+ signal,
58
+ listener: onSignal(signal),
59
+ }));
60
+ try {
61
+ input?.start();
62
+ for (const { signal, listener } of listeners) {
63
+ signals.on(signal, listener);
64
+ installed.push(signal);
65
+ }
66
+ app = mount();
67
+ if (shutdown)
68
+ requestUnmount();
69
+ await app.waitUntilExit();
70
+ exited = true;
71
+ return shutdown ?? 'completed';
72
+ }
73
+ finally {
74
+ try {
75
+ if (!exited)
76
+ requestUnmount();
77
+ }
78
+ finally {
79
+ try {
80
+ for (const { signal, listener } of listeners) {
81
+ if (installed.includes(signal))
82
+ signals.off(signal, listener);
83
+ }
84
+ }
85
+ finally {
86
+ try {
87
+ input?.stop();
88
+ }
89
+ finally {
90
+ screen.leave();
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }