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.
- package/README.md +164 -2
- package/dist/cli.js +28 -0
- package/dist/components/Board.js +32 -0
- package/dist/components/Feedback.js +7 -0
- package/dist/components/GameInfoPanel.js +18 -0
- package/dist/components/GameShell.js +19 -0
- package/dist/components/GomokuApp.js +100 -0
- package/dist/components/HelpView.js +22 -0
- package/dist/components/TooSmallNotice.js +22 -0
- package/dist/components/Wordmark.js +12 -0
- package/dist/components/gameSession.js +135 -0
- package/dist/domain/board.js +28 -0
- package/dist/domain/engine.js +98 -0
- package/dist/domain/types.js +3 -0
- package/dist/domain/win.js +78 -0
- package/dist/opponent/candidates.js +47 -0
- package/dist/opponent/evaluation.js +140 -0
- package/dist/opponent/opponent.js +18 -0
- package/dist/opponent/profiles.js +28 -0
- package/dist/opponent/search.js +170 -0
- package/dist/terminal/fullscreen.js +95 -0
- package/dist/terminal/mouse.js +158 -0
- package/dist/ui/branding.js +17 -0
- package/dist/ui/glyphs.js +38 -0
- package/dist/ui/hitTest.js +40 -0
- package/dist/ui/input.js +37 -0
- package/dist/ui/labels.js +18 -0
- package/dist/ui/layout.js +86 -0
- package/dist/ui/status.js +36 -0
- package/dist/ui/theme.js +41 -0
- package/dist/ui/winning.js +20 -0
- package/package.json +46 -4
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream';
|
|
2
|
+
/**
|
|
3
|
+
* Keeps keyboard bytes intact while extracting SGR mouse events. CSI sequences
|
|
4
|
+
* beginning ESC[< belong exclusively to the mouse parser, not Ink's useInput.
|
|
5
|
+
*/
|
|
6
|
+
export class SgrMouseDecoder {
|
|
7
|
+
keyboard;
|
|
8
|
+
mouse;
|
|
9
|
+
pending = Buffer.alloc(0);
|
|
10
|
+
discardingMouse = false;
|
|
11
|
+
prefix = Buffer.from('\x1b[<');
|
|
12
|
+
constructor(keyboard, mouse) {
|
|
13
|
+
this.keyboard = keyboard;
|
|
14
|
+
this.mouse = mouse;
|
|
15
|
+
}
|
|
16
|
+
push(bytes) {
|
|
17
|
+
if (this.discardingMouse) {
|
|
18
|
+
const end = bytes.findIndex((byte) => byte === 0x4d || byte === 0x6d);
|
|
19
|
+
if (end === -1)
|
|
20
|
+
return;
|
|
21
|
+
this.discardingMouse = false;
|
|
22
|
+
bytes = bytes.subarray(end + 1);
|
|
23
|
+
}
|
|
24
|
+
this.pending = Buffer.concat([this.pending, bytes]);
|
|
25
|
+
while (this.pending.length > 0) {
|
|
26
|
+
const marker = this.pending.indexOf(this.prefix);
|
|
27
|
+
if (marker >= 0) {
|
|
28
|
+
if (marker > 0)
|
|
29
|
+
this.keyboard(this.pending.subarray(0, marker));
|
|
30
|
+
this.pending = this.pending.subarray(marker);
|
|
31
|
+
// Only M/m complete SGR mouse reports. Keep malformed reports away
|
|
32
|
+
// from Ink rather than leaking their tail as keyboard shortcuts.
|
|
33
|
+
let end = -1;
|
|
34
|
+
for (let i = this.prefix.length; i < this.pending.length; i += 1) {
|
|
35
|
+
if (this.pending[i] === 0x4d || this.pending[i] === 0x6d) {
|
|
36
|
+
end = i;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (end === -1) {
|
|
41
|
+
// Continue discarding long malformed reports until their terminator.
|
|
42
|
+
if (this.pending.length > 64) {
|
|
43
|
+
this.pending = Buffer.alloc(0);
|
|
44
|
+
this.discardingMouse = true;
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
const event = this.pending.subarray(this.prefix.length, end + 1);
|
|
49
|
+
this.consume(event.toString('ascii'));
|
|
50
|
+
this.pending = this.pending.subarray(end + 1);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// Preserve a partial mouse prefix across raw-data chunk boundaries.
|
|
54
|
+
let keep = 0;
|
|
55
|
+
for (let n = Math.min(this.prefix.length - 1, this.pending.length); n > 0; n -= 1) {
|
|
56
|
+
if (this.pending
|
|
57
|
+
.subarray(this.pending.length - n)
|
|
58
|
+
.equals(this.prefix.subarray(0, n))) {
|
|
59
|
+
keep = n;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const ready = this.pending.length - keep;
|
|
64
|
+
if (ready > 0)
|
|
65
|
+
this.keyboard(this.pending.subarray(0, ready));
|
|
66
|
+
this.pending = this.pending.subarray(ready);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** No pending prefix should leak into Ink during shutdown. */
|
|
71
|
+
reset() {
|
|
72
|
+
this.pending = Buffer.alloc(0);
|
|
73
|
+
this.discardingMouse = false;
|
|
74
|
+
}
|
|
75
|
+
consume(sequence) {
|
|
76
|
+
const match = /^(\d+);(\d+);(\d+)([Mm])$/.exec(sequence);
|
|
77
|
+
if (!match || match[4] !== 'M' || Number(match[1]) !== 0)
|
|
78
|
+
return;
|
|
79
|
+
const column = Number(match[2]);
|
|
80
|
+
const row = Number(match[3]);
|
|
81
|
+
if (!Number.isSafeInteger(column) ||
|
|
82
|
+
!Number.isSafeInteger(row) ||
|
|
83
|
+
column < 1 ||
|
|
84
|
+
row < 1)
|
|
85
|
+
return;
|
|
86
|
+
this.mouse({ column, row });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class MouseInputAdapter {
|
|
90
|
+
stdin;
|
|
91
|
+
keyboard;
|
|
92
|
+
active = false;
|
|
93
|
+
initialRaw = false;
|
|
94
|
+
listeners = new Set();
|
|
95
|
+
decoder;
|
|
96
|
+
onData = (bytes) => {
|
|
97
|
+
this.decoder.push(Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes));
|
|
98
|
+
};
|
|
99
|
+
constructor(stdin) {
|
|
100
|
+
this.stdin = stdin;
|
|
101
|
+
const keyboard = new PassThrough();
|
|
102
|
+
Object.defineProperty(keyboard, 'isTTY', { value: true });
|
|
103
|
+
Object.defineProperty(keyboard, 'fd', { value: stdin.fd ?? 0 });
|
|
104
|
+
Object.defineProperty(keyboard, 'isRaw', {
|
|
105
|
+
get: () => stdin.isRaw === true,
|
|
106
|
+
});
|
|
107
|
+
keyboard.setRawMode = (mode) => {
|
|
108
|
+
stdin.setRawMode?.(mode);
|
|
109
|
+
return keyboard;
|
|
110
|
+
};
|
|
111
|
+
keyboard.ref = () => {
|
|
112
|
+
stdin.ref?.();
|
|
113
|
+
return keyboard;
|
|
114
|
+
};
|
|
115
|
+
keyboard.unref = () => {
|
|
116
|
+
stdin.unref?.();
|
|
117
|
+
return keyboard;
|
|
118
|
+
};
|
|
119
|
+
this.keyboard = keyboard;
|
|
120
|
+
this.decoder = new SgrMouseDecoder((bytes) => this.keyboard.write(bytes), (event) => {
|
|
121
|
+
for (const listener of this.listeners)
|
|
122
|
+
listener(event);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Ink types stdin as a concrete TTY ReadStream, while this adapter must
|
|
127
|
+
* provide a filtered PassThrough so Ink never receives mouse sequences.
|
|
128
|
+
* Its input contract (data events, raw mode, TTY flags and ref/unref) is
|
|
129
|
+
* implemented above; keep the structural type adaptation at this boundary.
|
|
130
|
+
*/
|
|
131
|
+
get inkStdin() {
|
|
132
|
+
return this.keyboard;
|
|
133
|
+
}
|
|
134
|
+
subscribe(listener) {
|
|
135
|
+
this.listeners.add(listener);
|
|
136
|
+
return () => this.listeners.delete(listener);
|
|
137
|
+
}
|
|
138
|
+
start() {
|
|
139
|
+
if (this.active)
|
|
140
|
+
return;
|
|
141
|
+
this.initialRaw = this.stdin.isRaw === true;
|
|
142
|
+
this.active = true;
|
|
143
|
+
this.stdin.on('data', this.onData);
|
|
144
|
+
}
|
|
145
|
+
stop() {
|
|
146
|
+
if (!this.active)
|
|
147
|
+
return;
|
|
148
|
+
this.active = false;
|
|
149
|
+
this.stdin.off('data', this.onData);
|
|
150
|
+
// Ink normally restores raw mode itself, but a failed mount may not.
|
|
151
|
+
if (this.stdin.isRaw !== undefined &&
|
|
152
|
+
this.stdin.isRaw !== this.initialRaw) {
|
|
153
|
+
this.stdin.setRawMode?.(this.initialRaw);
|
|
154
|
+
}
|
|
155
|
+
this.decoder.reset();
|
|
156
|
+
this.listeners.clear();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const FULL_WORDMARK = [
|
|
2
|
+
'╔═╗ ╔═╗ ╔╦╗ ╔═╗ ╦╔═ ╦ ╦',
|
|
3
|
+
'║ ╦ ║ ║ ║║║ ║ ║ ╠╩╗ ║ ║',
|
|
4
|
+
'╚═╝ ╚═╝ ╩ ╩ ╚═╝ ╩ ╩ ╚═╝',
|
|
5
|
+
];
|
|
6
|
+
export const EMBLEM_ROWS = ['┌───┬───┐', '│ X │ ● │', '└───┴───┘'];
|
|
7
|
+
export const WORDMARK_TAGLINE = 'FIVE IN A ROW · 15 × 15';
|
|
8
|
+
export const COMPACT_WORDMARK = 'GOMOKU';
|
|
9
|
+
export const FULL_WORDMARK_WIDTH = Math.max(...FULL_WORDMARK.map((line) => line.length), WORDMARK_TAGLINE.length);
|
|
10
|
+
// Center the approved three-row emblem over the entire wordmark.
|
|
11
|
+
const EMBLEM_INSET = Math.floor((FULL_WORDMARK_WIDTH - EMBLEM_ROWS[0].length) / 2);
|
|
12
|
+
export const FULL_BANNER_ROWS = [
|
|
13
|
+
...EMBLEM_ROWS.map((line) => ' '.repeat(EMBLEM_INSET) + line),
|
|
14
|
+
...FULL_WORDMARK,
|
|
15
|
+
WORDMARK_TAGLINE,
|
|
16
|
+
];
|
|
17
|
+
export const FULL_BANNER_WIDTH = Math.max(...FULL_BANNER_ROWS.map((line) => line.length));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { BOARD_SIZE } from '../domain/types.js';
|
|
2
|
+
export const CELL_WIDTH = 3;
|
|
3
|
+
/** Five symmetric reference intersections on a 15×15 board. */
|
|
4
|
+
const STAR_POINTS = new Set(['3,3', '3,11', '7,7', '11,3', '11,11']);
|
|
5
|
+
export function isStarPoint(row, col) {
|
|
6
|
+
return (Number.isInteger(row) &&
|
|
7
|
+
Number.isInteger(col) &&
|
|
8
|
+
row >= 0 &&
|
|
9
|
+
row < BOARD_SIZE &&
|
|
10
|
+
col >= 0 &&
|
|
11
|
+
col < BOARD_SIZE &&
|
|
12
|
+
STAR_POINTS.has(`${row},${col}`));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Every cell occupies exactly three terminal columns. Markers communicate
|
|
16
|
+
* winning > selected-and-latest > selected > latest > ordinary, without color.
|
|
17
|
+
* Star points are displayed only on empty, unselected intersections.
|
|
18
|
+
*/
|
|
19
|
+
export function renderCell({ cell, selected, lastMove, winning = false, starPoint = false, ascii = false, }) {
|
|
20
|
+
const glyph = cell ?? (ascii ? '.' : '·');
|
|
21
|
+
let text;
|
|
22
|
+
if (winning && cell !== null) {
|
|
23
|
+
text = `{${glyph}}`;
|
|
24
|
+
}
|
|
25
|
+
else if (selected && lastMove && cell !== null) {
|
|
26
|
+
text = `<${glyph}>`;
|
|
27
|
+
}
|
|
28
|
+
else if (selected) {
|
|
29
|
+
text = `[${glyph}]`;
|
|
30
|
+
}
|
|
31
|
+
else if (lastMove) {
|
|
32
|
+
text = `(${glyph})`;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
text = ` ${cell === null && starPoint ? '*' : glyph} `;
|
|
36
|
+
}
|
|
37
|
+
return { text, selected, lastMove, winning: winning && cell !== null };
|
|
38
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BOARD_SIZE } from '../domain/types.js';
|
|
2
|
+
import { CONTROLS_TEXT } from './labels.js';
|
|
3
|
+
/** The whole three-character cell is clickable, including its side padding. */
|
|
4
|
+
export function hitTestMouse(geometry, press) {
|
|
5
|
+
if (!geometry ||
|
|
6
|
+
!Number.isSafeInteger(press.column) ||
|
|
7
|
+
!Number.isSafeInteger(press.row)) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const x = press.column - 1;
|
|
11
|
+
const y = press.row - 1;
|
|
12
|
+
const { firstCell, cellWidth } = geometry;
|
|
13
|
+
const relativeX = x - firstCell.left;
|
|
14
|
+
const relativeY = y - firstCell.top;
|
|
15
|
+
if (relativeX >= 0 &&
|
|
16
|
+
relativeX < BOARD_SIZE * cellWidth &&
|
|
17
|
+
relativeY >= 0 &&
|
|
18
|
+
relativeY < BOARD_SIZE) {
|
|
19
|
+
return {
|
|
20
|
+
type: 'cell',
|
|
21
|
+
row: relativeY,
|
|
22
|
+
col: Math.floor(relativeX / cellWidth),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
// Only the visible wide footer advertises clickable command targets.
|
|
26
|
+
if (geometry.mode === 'wide' && y === geometry.controls.top) {
|
|
27
|
+
for (const [text, command] of [
|
|
28
|
+
['U Undo', 'undo'],
|
|
29
|
+
['M Mode', 'mode'],
|
|
30
|
+
['N New', 'new'],
|
|
31
|
+
['? Help', 'help'],
|
|
32
|
+
]) {
|
|
33
|
+
const start = geometry.controls.left + CONTROLS_TEXT.indexOf(text);
|
|
34
|
+
if (x >= start && x < start + text.length) {
|
|
35
|
+
return { type: 'command', command };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
package/dist/ui/input.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { BOARD_SIZE } from '../domain/types.js';
|
|
2
|
+
/** Arrow keys and WASD resolve to the same movement intent. */
|
|
3
|
+
export function resolveMovement(input, key) {
|
|
4
|
+
if (key.upArrow)
|
|
5
|
+
return 'up';
|
|
6
|
+
if (key.downArrow)
|
|
7
|
+
return 'down';
|
|
8
|
+
if (key.leftArrow)
|
|
9
|
+
return 'left';
|
|
10
|
+
if (key.rightArrow)
|
|
11
|
+
return 'right';
|
|
12
|
+
switch (input.toLowerCase()) {
|
|
13
|
+
case 'w':
|
|
14
|
+
return 'up';
|
|
15
|
+
case 's':
|
|
16
|
+
return 'down';
|
|
17
|
+
case 'a':
|
|
18
|
+
return 'left';
|
|
19
|
+
case 'd':
|
|
20
|
+
return 'right';
|
|
21
|
+
default:
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Clamps cursor movement to the 15×15 board without mutating its position. */
|
|
26
|
+
export function moveSelection(current, direction) {
|
|
27
|
+
switch (direction) {
|
|
28
|
+
case 'up':
|
|
29
|
+
return { ...current, row: Math.max(0, current.row - 1) };
|
|
30
|
+
case 'down':
|
|
31
|
+
return { ...current, row: Math.min(BOARD_SIZE - 1, current.row + 1) };
|
|
32
|
+
case 'left':
|
|
33
|
+
return { ...current, col: Math.max(0, current.col - 1) };
|
|
34
|
+
case 'right':
|
|
35
|
+
return { ...current, col: Math.min(BOARD_SIZE - 1, current.col + 1) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const CONTROLS_TEXT = 'Arrows/WASD · Enter/Space Place · U Undo · N New · M Mode · ? Help · Q Quit';
|
|
2
|
+
// Explicit compact copy, without dropping a command or causing a second row.
|
|
3
|
+
export const COMPACT_CONTROLS_TEXT = 'Arrows/WASD Enter/Space U Undo N New M Mode ? Help Q Quit';
|
|
4
|
+
/** Zero-pads a zero-based coordinate to its 1-based display form (`01`..`15`). */
|
|
5
|
+
export function formatCoord(n) {
|
|
6
|
+
return String(n + 1).padStart(2, '0');
|
|
7
|
+
}
|
|
8
|
+
export function formatPosition({ row, col, }) {
|
|
9
|
+
return `R${formatCoord(row)} C${formatCoord(col)}`;
|
|
10
|
+
}
|
|
11
|
+
export function captionText(state) {
|
|
12
|
+
const move = state.moves.at(-1);
|
|
13
|
+
return move ? `LAST ${move.player} ${formatPosition(move)}` : 'LAST: NONE YET';
|
|
14
|
+
}
|
|
15
|
+
/** Chronological order, including only actually committed moves. */
|
|
16
|
+
export function recentMoves(state) {
|
|
17
|
+
return state.moves.slice(-4);
|
|
18
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { BOARD_SIZE } from '../domain/types.js';
|
|
2
|
+
import { COMPACT_WORDMARK, FULL_BANNER_ROWS, FULL_BANNER_WIDTH, } from './branding.js';
|
|
3
|
+
import { CELL_WIDTH } from './glyphs.js';
|
|
4
|
+
import { COMPACT_CONTROLS_TEXT } from './labels.js';
|
|
5
|
+
// The second digit of each heading aligns with the cell's center glyph.
|
|
6
|
+
// Keep one right-side spacer so the established board/panel geometry is stable.
|
|
7
|
+
export const ROW_LABEL_WIDTH = 3;
|
|
8
|
+
export const COLUMN_HEADER_INSET = ROW_LABEL_WIDTH;
|
|
9
|
+
export const BOARD_WIDTH = COLUMN_HEADER_INSET + BOARD_SIZE * CELL_WIDTH + 1;
|
|
10
|
+
export const BOARD_HEIGHT = 1 + BOARD_SIZE;
|
|
11
|
+
export const PANEL_GAP = 2;
|
|
12
|
+
export const PANEL_WIDTH = 27;
|
|
13
|
+
export const DASHBOARD_WIDTH = BOARD_WIDTH + PANEL_GAP + PANEL_WIDTH;
|
|
14
|
+
const WIDE_BANNER_ROWS = FULL_BANNER_ROWS.length; // emblem + wordmark + subtitle
|
|
15
|
+
const WIDE_BANNER_GAP = 1;
|
|
16
|
+
const WIDE_FOOTER_ROWS = 3; // reserved feedback, divider, controls
|
|
17
|
+
const COMPACT_HEADER_ROWS = 5; // logo, status, two metadata rows, gap
|
|
18
|
+
const COMPACT_FOOTER_ROWS = 3; // board caption, feedback, controls
|
|
19
|
+
// No redundant board caption in wide mode. This is the measured physical height.
|
|
20
|
+
export const WIDE_MIN_ROWS = WIDE_BANNER_ROWS + WIDE_BANNER_GAP + BOARD_HEIGHT + WIDE_FOOTER_ROWS;
|
|
21
|
+
export const COMPACT_MIN_ROWS = COMPACT_HEADER_ROWS + BOARD_HEIGHT + COMPACT_FOOTER_ROWS;
|
|
22
|
+
export const COMPACT_MIN_COLUMNS = Math.max(57, BOARD_WIDTH, COMPACT_CONTROLS_TEXT.length);
|
|
23
|
+
export const MIN_COLUMNS = COMPACT_MIN_COLUMNS;
|
|
24
|
+
export const MIN_ROWS = COMPACT_MIN_ROWS;
|
|
25
|
+
export function resolveLayout({ columns, rows }) {
|
|
26
|
+
if (!Number.isFinite(columns) || !Number.isFinite(rows)) {
|
|
27
|
+
return 'too-small';
|
|
28
|
+
}
|
|
29
|
+
if (columns >= DASHBOARD_WIDTH && rows >= WIDE_MIN_ROWS) {
|
|
30
|
+
return 'wide';
|
|
31
|
+
}
|
|
32
|
+
if (columns >= COMPACT_MIN_COLUMNS && rows >= COMPACT_MIN_ROWS) {
|
|
33
|
+
return 'compact';
|
|
34
|
+
}
|
|
35
|
+
return 'too-small';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Single source for rendered coordinates, including future mouse hit-testing.
|
|
39
|
+
* The slight upward bias leaves more breathing room below than above on tall
|
|
40
|
+
* screens. At the minimum height, the top offset is exactly zero.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveGeometry(size) {
|
|
43
|
+
const mode = resolveLayout(size);
|
|
44
|
+
if (mode === 'too-small')
|
|
45
|
+
return null;
|
|
46
|
+
const width = mode === 'wide' ? DASHBOARD_WIDTH : COMPACT_MIN_COLUMNS;
|
|
47
|
+
const height = mode === 'wide' ? WIDE_MIN_ROWS : COMPACT_MIN_ROWS;
|
|
48
|
+
const left = Math.floor((Math.floor(size.columns) - width) / 2);
|
|
49
|
+
const spareRows = Math.floor(size.rows) - height;
|
|
50
|
+
const top = Math.max(0, Math.floor(spareRows / 2) - (spareRows > 0 ? 1 : 0));
|
|
51
|
+
const bannerWidth = mode === 'wide' ? FULL_BANNER_WIDTH : COMPACT_WORDMARK.length;
|
|
52
|
+
const bannerHeight = mode === 'wide' ? WIDE_BANNER_ROWS : 1;
|
|
53
|
+
const headerRows = mode === 'wide' ? WIDE_BANNER_ROWS + WIDE_BANNER_GAP : COMPACT_HEADER_ROWS;
|
|
54
|
+
const footerRows = mode === 'wide' ? WIDE_FOOTER_ROWS : COMPACT_FOOTER_ROWS;
|
|
55
|
+
const boardTop = top + headerRows;
|
|
56
|
+
return {
|
|
57
|
+
mode,
|
|
58
|
+
dashboard: { left, top, width, height },
|
|
59
|
+
banner: {
|
|
60
|
+
left: left + Math.floor((width - bannerWidth) / 2),
|
|
61
|
+
top,
|
|
62
|
+
width: bannerWidth,
|
|
63
|
+
height: bannerHeight,
|
|
64
|
+
},
|
|
65
|
+
board: { left, top: boardTop, width: BOARD_WIDTH, height: BOARD_HEIGHT },
|
|
66
|
+
panel: mode === 'wide'
|
|
67
|
+
? {
|
|
68
|
+
left: left + BOARD_WIDTH + PANEL_GAP,
|
|
69
|
+
top: boardTop,
|
|
70
|
+
width: PANEL_WIDTH,
|
|
71
|
+
height: BOARD_HEIGHT,
|
|
72
|
+
}
|
|
73
|
+
: null,
|
|
74
|
+
headerRows,
|
|
75
|
+
footerRows,
|
|
76
|
+
feedback: {
|
|
77
|
+
left,
|
|
78
|
+
top: boardTop + BOARD_HEIGHT + (mode === 'compact' ? 1 : 0),
|
|
79
|
+
},
|
|
80
|
+
firstCell: { left: left + ROW_LABEL_WIDTH, top: boardTop + 1 },
|
|
81
|
+
columnLabels: { left: left + COLUMN_HEADER_INSET, top: boardTop },
|
|
82
|
+
controls: { left, top: top + height - 1 },
|
|
83
|
+
rowLabelWidth: ROW_LABEL_WIDTH,
|
|
84
|
+
cellWidth: CELL_WIDTH,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function statusText(state, machineThinking) {
|
|
2
|
+
if (state.status === 'won') {
|
|
3
|
+
return state.winner === 'X'
|
|
4
|
+
? { heading: 'You win', supporting: 'Five in a row' }
|
|
5
|
+
: { heading: 'You lose', supporting: 'Machine made five' };
|
|
6
|
+
}
|
|
7
|
+
if (state.status === 'draw') {
|
|
8
|
+
return { heading: 'Draw', supporting: 'No more legal moves' };
|
|
9
|
+
}
|
|
10
|
+
if (machineThinking) {
|
|
11
|
+
return { heading: 'Machine thinking', supporting: 'Waiting for O' };
|
|
12
|
+
}
|
|
13
|
+
return { heading: 'Your turn', supporting: 'Choose an intersection' };
|
|
14
|
+
}
|
|
15
|
+
export function feedbackText(reason) {
|
|
16
|
+
switch (reason) {
|
|
17
|
+
case 'game-over':
|
|
18
|
+
return 'Game over. Press N for a new game.';
|
|
19
|
+
case 'out-of-bounds':
|
|
20
|
+
return 'Outside the board.';
|
|
21
|
+
case 'occupied':
|
|
22
|
+
return 'That cell is taken.';
|
|
23
|
+
case 'not-your-turn':
|
|
24
|
+
return 'Wait for the machine to move.';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function completionFeedback(state) {
|
|
28
|
+
if (state.status === 'draw')
|
|
29
|
+
return 'Draw. N New · U Undo · Q Quit';
|
|
30
|
+
if (state.status === 'won') {
|
|
31
|
+
return state.winner === 'X'
|
|
32
|
+
? 'Five in a row. N New · U Undo · Q Quit'
|
|
33
|
+
: 'Machine wins. N New · U Undo · Q Quit';
|
|
34
|
+
}
|
|
35
|
+
return '';
|
|
36
|
+
}
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const theme = {
|
|
2
|
+
text: {},
|
|
3
|
+
muted: { dimColor: true },
|
|
4
|
+
divider: { dimColor: true },
|
|
5
|
+
playerX: { color: 'red', bold: true },
|
|
6
|
+
playerO: { bold: true },
|
|
7
|
+
// No dimColor: it makes dots and coordinates almost invisible on some themes.
|
|
8
|
+
emptyCell: { color: 'gray' },
|
|
9
|
+
starPoint: { color: 'gray', bold: true },
|
|
10
|
+
coordinate: {},
|
|
11
|
+
selectedCoordinate: { bold: true, underline: true },
|
|
12
|
+
selectedCell: { inverse: true },
|
|
13
|
+
latestMove: { underline: true },
|
|
14
|
+
selectedLatestMove: { inverse: true, underline: true },
|
|
15
|
+
winningRun: { color: 'green', bold: true },
|
|
16
|
+
feedback: { color: 'yellow' },
|
|
17
|
+
gameResult: { bold: true },
|
|
18
|
+
};
|
|
19
|
+
/** Overlays never replace the foreground color identifying the stone owner. */
|
|
20
|
+
export function cellStyle(cell, selected, lastMove, winning = false, starPoint = false) {
|
|
21
|
+
const ownerStyle = cell === 'X'
|
|
22
|
+
? theme.playerX
|
|
23
|
+
: cell === 'O'
|
|
24
|
+
? theme.playerO
|
|
25
|
+
: starPoint && !selected
|
|
26
|
+
? theme.starPoint
|
|
27
|
+
: theme.emptyCell;
|
|
28
|
+
if (winning && cell !== null) {
|
|
29
|
+
return { ...ownerStyle, bold: true, underline: true };
|
|
30
|
+
}
|
|
31
|
+
if (selected && lastMove && cell !== null) {
|
|
32
|
+
return { ...ownerStyle, ...theme.selectedLatestMove };
|
|
33
|
+
}
|
|
34
|
+
if (selected) {
|
|
35
|
+
return { ...ownerStyle, ...theme.selectedCell };
|
|
36
|
+
}
|
|
37
|
+
if (lastMove) {
|
|
38
|
+
return { ...ownerStyle, ...theme.latestMove };
|
|
39
|
+
}
|
|
40
|
+
return ownerStyle;
|
|
41
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { findWinningLine } from '../domain/win.js';
|
|
2
|
+
/**
|
|
3
|
+
* Coordinates of the engine-confirmed winning run, including runs over five.
|
|
4
|
+
* The last move is the move that ended a well-formed finished game.
|
|
5
|
+
*/
|
|
6
|
+
export function winningCoordinates(state) {
|
|
7
|
+
if (state.status !== 'won')
|
|
8
|
+
return [];
|
|
9
|
+
const last = state.moves.at(-1);
|
|
10
|
+
if (!last || last.player !== state.winner)
|
|
11
|
+
return [];
|
|
12
|
+
const line = findWinningLine(state.board, last.row, last.col);
|
|
13
|
+
if (!line || line.player !== state.winner)
|
|
14
|
+
return [];
|
|
15
|
+
const [dr, dc] = line.direction;
|
|
16
|
+
return Array.from({ length: line.count }, (_, index) => ({
|
|
17
|
+
row: line.start.row + index * dr,
|
|
18
|
+
col: line.start.col + index * dc,
|
|
19
|
+
}));
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gomoku-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local 15x15 terminal Gomoku against a deterministic machine opponent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"play": "tsx src/cli.tsx",
|
|
8
|
+
"dev": "tsx watch src/cli.tsx",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"typecheck": "tsc --noEmit",
|
|
11
|
+
"lint": "eslint .",
|
|
12
|
+
"format": "prettier --write .",
|
|
13
|
+
"format:check": "prettier --check .",
|
|
14
|
+
"benchmark:bot": "tsx scripts/benchmark-opponent.ts",
|
|
15
|
+
"build": "tsc -p tsconfig.build.json",
|
|
16
|
+
"prepack": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [],
|
|
19
|
+
"author": "",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"packageManager": "pnpm@10.33.0",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"ink": "^8.0.0",
|
|
24
|
+
"react": "^19.3.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@eslint/js": "^10.0.1",
|
|
28
|
+
"@types/node": "^26.6.4",
|
|
29
|
+
"@types/react": "^19.3.0",
|
|
30
|
+
"eslint": "^10.12.0",
|
|
31
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
32
|
+
"ink-testing-library": "^4.0.0",
|
|
33
|
+
"prettier": "^3.9.9",
|
|
34
|
+
"tsx": "^4.23.15",
|
|
35
|
+
"typescript": "^6.0.3",
|
|
36
|
+
"typescript-eslint": "^8.71.1",
|
|
37
|
+
"vitest": "^5.0.3"
|
|
38
|
+
},
|
|
39
|
+
"bin": {
|
|
40
|
+
"gomoku": "./dist/cli.js"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist/"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22"
|
|
47
|
+
}
|
|
48
|
+
}
|