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
package/README.md
CHANGED
|
@@ -1,3 +1,165 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Gomoku
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A local 15×15 freestyle Gomoku (five-in-a-row) game that runs in the terminal. You play as `X` against a machine opponent (`O`).
|
|
4
|
+
|
|
5
|
+
## Play without cloning
|
|
6
|
+
|
|
7
|
+
Once `gomoku-cli` has been published to npm, launch it from an interactive
|
|
8
|
+
terminal with **Node.js 22 or newer**:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx gomoku-cli
|
|
12
|
+
# or
|
|
13
|
+
pnpm dlx gomoku-cli
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
These commands will not work until the package owner publishes it and the
|
|
17
|
+
chosen npm package name is available. `npx` downloads and caches an npm
|
|
18
|
+
package; it does not launch a standalone executable or remove the need for
|
|
19
|
+
Node.js. The game requires an interactive terminal with TTY input and output.
|
|
20
|
+
|
|
21
|
+
For an optional global install, after publication:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install -g gomoku-cli
|
|
25
|
+
gomoku
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Develop from source
|
|
29
|
+
|
|
30
|
+
Install Node.js 22 or newer and pnpm (this repository's package manager),
|
|
31
|
+
then run:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
corepack enable
|
|
35
|
+
pnpm install --frozen-lockfile
|
|
36
|
+
pnpm play
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
To prepare the distributable JavaScript, run `pnpm build`. This compiles the
|
|
40
|
+
CLI into `dist/cli.js` and its required modules without bundling React or Ink.
|
|
41
|
+
`npm pack` automatically builds before creating a tarball. The published
|
|
42
|
+
package must install its runtime dependencies (`ink` and `react`), but does
|
|
43
|
+
not require `tsx`, the TypeScript compiler, or repository sources to play.
|
|
44
|
+
|
|
45
|
+
The game opens in the terminal's alternate screen, hiding the previous shell
|
|
46
|
+
output while you play. Press `Q` to quit and restore the original shell screen.
|
|
47
|
+
Ctrl+C and normal termination signals also restore it where the terminal and
|
|
48
|
+
runtime permit. A forced kill or terminal crash may prevent restoration.
|
|
49
|
+
|
|
50
|
+
Both standard input and standard output must be interactive TTYs. When launched
|
|
51
|
+
with redirected input/output or in a non-interactive environment, the CLI
|
|
52
|
+
prints a short error to stderr and exits with code 1 without rendering the game
|
|
53
|
+
or emitting alternate-screen escape sequences.
|
|
54
|
+
|
|
55
|
+
The board and status panel form one centered, bounded dashboard on terminals at least
|
|
56
|
+
**78 columns × 27 rows** (including 80×30). The approved emblem is centered
|
|
57
|
+
above the GOMOKU wordmark and subtitle. Smaller terminals use a centered, stacked
|
|
58
|
+
layout with the complete board at a minimum of **57 columns × 24 rows**.
|
|
59
|
+
On taller terminals, the game is positioned near the vertical center, slightly
|
|
60
|
+
above the midpoint. Below the compact minimum, a resize notice replaces the game.
|
|
61
|
+
Resizing never restarts the game or moves your selection.
|
|
62
|
+
|
|
63
|
+
Interactive terminals with SGR mouse reporting support left-click placement.
|
|
64
|
+
Click any part of a board intersection (including its two side-padding columns)
|
|
65
|
+
to select and place an X in one action. The arrow/WASD and Enter/Space controls
|
|
66
|
+
continue to work alongside clicks. In wide mode, the visible `U Undo`, `M Mode`, `N New`, and `? Help`
|
|
67
|
+
footer labels are also clickable. Right/middle clicks, wheel events, drags, and
|
|
68
|
+
releases do nothing.
|
|
69
|
+
Terminals without supported mouse events remain fully keyboard-operable.
|
|
70
|
+
Mouse reporting is disabled before the previous terminal screen is restored.
|
|
71
|
+
|
|
72
|
+
## Controls
|
|
73
|
+
|
|
74
|
+
| Key | Action |
|
|
75
|
+
| --------------------------------------- | ------------------------------------------------------------------------ |
|
|
76
|
+
| Arrow keys / W, A, S, D | Move the selection cursor (up, left, down, right) |
|
|
77
|
+
| Enter / Space | Place your mark |
|
|
78
|
+
| Left click (supported terminals) | Select and place at a board intersection |
|
|
79
|
+
| Left click footer actions (wide layout) | Undo, Mode, New, or Help |
|
|
80
|
+
| U | Undo your last move and any machine reply |
|
|
81
|
+
| N | New game; an unfinished match requires Y confirmation (N/Escape cancels) |
|
|
82
|
+
| ? | Open or close Help (Escape also closes it) |
|
|
83
|
+
| M | Cycle difficulty: Easy → Normal → Hard → Easy (default: Normal) |
|
|
84
|
+
| Q | Quit |
|
|
85
|
+
|
|
86
|
+
Arrow keys and WASD are interchangeable. Letter commands are case-insensitive:
|
|
87
|
+
`D` moves right, while `M` changes difficulty.
|
|
88
|
+
|
|
89
|
+
Each press of `U` takes back your most recent `X` and the machine's `O`
|
|
90
|
+
reply, if one has been placed. The cursor returns to your undone square, and
|
|
91
|
+
play resumes on your turn without an automatic machine reply. You can undo
|
|
92
|
+
multiple earlier decisions by pressing `U` repeatedly, including after a win
|
|
93
|
+
or draw. Changing difficulty does not reset the board.
|
|
94
|
+
|
|
95
|
+
Press `?` to see the concise Help view, which also closes with Escape. In wide
|
|
96
|
+
mode, Help occupies the sidebar while the board remains visible. In compact
|
|
97
|
+
mode, it temporarily replaces the board without changing game state or screen
|
|
98
|
+
geometry.
|
|
99
|
+
|
|
100
|
+
`N` immediately starts a new game on an empty board or after a finished match.
|
|
101
|
+
During an unfinished match, it shows **Start a new game? Y/N**. Press `Y` to
|
|
102
|
+
confirm, or `N` / Escape to keep playing. Placement, navigation, and mouse board
|
|
103
|
+
clicks are blocked during confirmation. Pending machine replies cannot apply
|
|
104
|
+
after an interrupted or restarted session. `Q` always quits immediately.
|
|
105
|
+
|
|
106
|
+
## Reading the board
|
|
107
|
+
|
|
108
|
+
`X` is your red mark and `O` uses the terminal's default foreground. Every
|
|
109
|
+
intersection is three columns wide. `[·]` means selected empty; `[X]` means
|
|
110
|
+
selected X; `(O)` marks the latest O; `<O>` means the cursor is **also** on
|
|
111
|
+
the latest O. A winning run uses `{X}` or `{O}` for every winning stone,
|
|
112
|
+
including runs longer than five. Winning braces take precedence over all other
|
|
113
|
+
indicators, so state remains readable without color.
|
|
114
|
+
|
|
115
|
+
Five `*` landmarks mark empty reference intersections. They disappear when
|
|
116
|
+
occupied or selected. The selected row and column labels are underlined, while
|
|
117
|
+
ordinary coordinates remain readable. Coordinates are always **row then
|
|
118
|
+
column**, one-based: `R08 C11` means row 8, column 11.
|
|
119
|
+
|
|
120
|
+
In wide mode, the sidebar shows the latest move and up to four recent moves
|
|
121
|
+
in chronological order; `>` marks the newest entry. Completed games instead
|
|
122
|
+
show the result and immediate New, Undo, and Quit actions. Compact mode keeps
|
|
123
|
+
the status, difficulty, players, last move, and selected coordinates visible
|
|
124
|
+
without changing the board's position. Feedback is confined to a reserved
|
|
125
|
+
line: occupied cells, mode changes, undo, reset, and game completion do not
|
|
126
|
+
shift the board. Pressing a movement key clears an occupied-cell warning.
|
|
127
|
+
|
|
128
|
+
## Machine opponent
|
|
129
|
+
|
|
130
|
+
The local opponent offers three deterministic levels. Press `M` at any time to
|
|
131
|
+
cycle the level; the change applies to the next machine decision, even in the
|
|
132
|
+
middle of a game, without resetting the board or selection. `N` starts a new
|
|
133
|
+
game at the currently selected difficulty.
|
|
134
|
+
|
|
135
|
+
- **Easy:** basic tactical safety with a small two-ply search (32-node budget).
|
|
136
|
+
- **Normal (default):** positional evaluation and bounded reply search (120
|
|
137
|
+
nodes, up to four plies on forcing lines).
|
|
138
|
+
- **Hard:** more candidate replies and deeper forcing-line search (320 nodes,
|
|
139
|
+
up to six plies).
|
|
140
|
+
|
|
141
|
+
All levels use the same rules and engine-validated move simulation. Immediate
|
|
142
|
+
wins and blocks take priority. A forcing move extends the search through its
|
|
143
|
+
reply and counter-move; quiet continuations stop extending. Fixed node, depth,
|
|
144
|
+
and candidate limits bound the search. Stronger levels are not guaranteed to
|
|
145
|
+
win every position.
|
|
146
|
+
|
|
147
|
+
Run `pnpm benchmark:bot` to compare all three difficulties on fixed early-,
|
|
148
|
+
middle-, late-game, forcing, fork-defense, unsafe-attack, edge, and dense
|
|
149
|
+
positions. It reports chosen moves, node/depth limits, whether the search
|
|
150
|
+
finished within its budget, immediate winning replies for both players, and
|
|
151
|
+
elapsed time. Timings depend on hardware and are not fixed performance targets.
|
|
152
|
+
|
|
153
|
+
## Commands
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
pnpm build # compile the npm executable
|
|
157
|
+
pnpm test # run the test suite
|
|
158
|
+
pnpm typecheck # type-check with tsc
|
|
159
|
+
pnpm lint # lint with ESLint
|
|
160
|
+
pnpm format # format with Prettier
|
|
161
|
+
pnpm format:check # check formatting without writing
|
|
162
|
+
pnpm benchmark:bot # measure deterministic bot search positions
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
See [BOT.4 audit](docs/BOT4_AUDIT.md) for the reproduced search-budget issue and before/after measurements.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from 'ink';
|
|
4
|
+
import { GomokuApp } from './components/GomokuApp.js';
|
|
5
|
+
import { runFullscreenApp } from './terminal/fullscreen.js';
|
|
6
|
+
import { MouseInputAdapter } from './terminal/mouse.js';
|
|
7
|
+
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 };
|
|
8
|
+
async function main() {
|
|
9
|
+
const input = new MouseInputAdapter(process.stdin);
|
|
10
|
+
const result = await runFullscreenApp({
|
|
11
|
+
stdin: process.stdin,
|
|
12
|
+
stdout: process.stdout,
|
|
13
|
+
signals: process,
|
|
14
|
+
input,
|
|
15
|
+
mount: () => render(_jsx(GomokuApp, { mouseInput: input }), { stdin: input.inkStdin }),
|
|
16
|
+
});
|
|
17
|
+
if (result === 'not-interactive') {
|
|
18
|
+
process.stderr.write('Gomoku requires an interactive terminal (stdin and stdout must be TTYs).\n');
|
|
19
|
+
process.exitCode = 1;
|
|
20
|
+
}
|
|
21
|
+
else if (result !== 'completed') {
|
|
22
|
+
process.exitCode = SIGNAL_EXIT_CODES[result];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
void main().catch((error) => {
|
|
26
|
+
console.error(error);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { BOARD_SIZE } from '../domain/types.js';
|
|
4
|
+
import { CELL_WIDTH, isStarPoint, renderCell } from '../ui/glyphs.js';
|
|
5
|
+
import { formatCoord } from '../ui/labels.js';
|
|
6
|
+
import { cellStyle, theme } from '../ui/theme.js';
|
|
7
|
+
import { winningCoordinates } from '../ui/winning.js';
|
|
8
|
+
export function Board({ state, selection }) {
|
|
9
|
+
const latest = state.moves.at(-1) ?? null;
|
|
10
|
+
const winningCells = new Set(winningCoordinates(state).map(({ row, col }) => row * BOARD_SIZE + col));
|
|
11
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { children: ' ' }), state.board[0].map((_, col) => (_jsx(Text, { ...(selection.col === col
|
|
12
|
+
? theme.selectedCoordinate
|
|
13
|
+
: theme.coordinate), children: formatCoord(col).padEnd(CELL_WIDTH) }, col)))] }), state.board.map((rowCells, row) => (_jsxs(Box, { children: [_jsx(Text, { ...(selection.row === row
|
|
14
|
+
? theme.selectedCoordinate
|
|
15
|
+
: theme.coordinate), children: formatCoord(row) }), _jsx(Text, { children: " " }), rowCells.map((cell, col) => {
|
|
16
|
+
const selected = selection.row === row && selection.col === col;
|
|
17
|
+
const lastMove = latest?.row === row && latest.col === col;
|
|
18
|
+
const winning = winningCells.has(row * BOARD_SIZE + col);
|
|
19
|
+
const starPoint = cell === null && isStarPoint(row, col);
|
|
20
|
+
const rendered = renderCell({
|
|
21
|
+
cell,
|
|
22
|
+
selected,
|
|
23
|
+
lastMove,
|
|
24
|
+
winning,
|
|
25
|
+
starPoint,
|
|
26
|
+
});
|
|
27
|
+
if (winning && cell !== null) {
|
|
28
|
+
return (_jsxs(Text, { children: [_jsx(Text, { ...theme.winningRun, children: rendered.text[0] }), _jsx(Text, { ...cellStyle(cell, selected, lastMove, true), children: cell }), _jsx(Text, { ...theme.winningRun, children: rendered.text[2] })] }, col));
|
|
29
|
+
}
|
|
30
|
+
return (_jsx(Text, { ...cellStyle(cell, selected, lastMove, false, starPoint), children: rendered.text }, col));
|
|
31
|
+
})] }, row)))] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { theme } from '../ui/theme.js';
|
|
4
|
+
/** A single reserved line; an empty message still occupies it so nothing shifts. */
|
|
5
|
+
export function Feedback({ message }) {
|
|
6
|
+
return (_jsx(Box, { children: _jsx(Text, { ...theme.feedback, children: message.length > 0 ? message : ' ' }) }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { difficultyLabel } from '../opponent/profiles.js';
|
|
4
|
+
import { captionText, formatPosition, recentMoves } from '../ui/labels.js';
|
|
5
|
+
import { BOARD_HEIGHT } from '../ui/layout.js';
|
|
6
|
+
import { HelpView } from './HelpView.js';
|
|
7
|
+
import { theme } from '../ui/theme.js';
|
|
8
|
+
/** One 16-row panel beside the board. Never renders unbounded history. */
|
|
9
|
+
export function GameInfoPanel({ state, status, difficulty, view = 'game', }) {
|
|
10
|
+
if (view === 'help')
|
|
11
|
+
return _jsx(HelpView, {});
|
|
12
|
+
if (view === 'confirm-new') {
|
|
13
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOARD_HEIGHT, children: [_jsx(Text, { ...theme.muted, children: "NEW GAME" }), _jsx(Text, { ...theme.gameResult, children: "Start a new game?" }), _jsx(Text, { children: " " }), _jsx(Text, { children: "Y Confirm" }), _jsx(Text, { children: "N / Esc Cancel" }), _jsx(Text, { children: " " }), _jsx(Text, { ...theme.muted, children: "Current match stays" }), _jsx(Text, { ...theme.muted, children: "until you confirm." })] }));
|
|
14
|
+
}
|
|
15
|
+
const finished = state.status !== 'playing';
|
|
16
|
+
const moves = recentMoves(state);
|
|
17
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOARD_HEIGHT, children: [_jsx(Text, { ...theme.muted, children: "STATUS" }), _jsx(Text, { ...theme.gameResult, children: status.heading }), _jsx(Text, { ...theme.muted, children: status.supporting }), _jsx(Text, { children: " " }), finished ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: ["MODE: ", difficultyLabel(difficulty)] }), _jsx(Text, { children: captionText(state) }), _jsxs(Text, { children: ["MOVES: ", state.moves.length] }), _jsx(Text, { children: " " }), _jsx(Text, { ...theme.muted, children: "NEXT" }), _jsx(Text, { children: "N New U Undo Q Quit" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: ["MODE: ", difficultyLabel(difficulty)] }), _jsxs(Text, { children: [_jsx(Text, { ...theme.playerX, children: "X" }), " You", ' ', _jsx(Text, { ...theme.playerO, children: "O" }), " Machine"] }), _jsxs(Text, { children: ["MOVES: ", state.moves.length] }), _jsx(Text, { children: captionText(state) }), _jsx(Text, { children: " " }), _jsx(Text, { ...theme.muted, children: "RECENT" }), moves.map((move, index) => (_jsxs(Text, { ...(index === moves.length - 1 ? theme.gameResult : theme.text), children: [index === moves.length - 1 ? '> ' : ' ', _jsx(Text, { ...(move.player === 'X' ? theme.playerX : theme.playerO), children: move.player }), ' ', formatPosition(move)] }, `${state.moves.length - moves.length + index}`)))] }))] }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { difficultyLabel } from '../opponent/profiles.js';
|
|
4
|
+
import { COMPACT_CONTROLS_TEXT, CONTROLS_TEXT, captionText, formatPosition, } from '../ui/labels.js';
|
|
5
|
+
import { BOARD_WIDTH, PANEL_GAP, PANEL_WIDTH, } from '../ui/layout.js';
|
|
6
|
+
import { theme } from '../ui/theme.js';
|
|
7
|
+
import { Board } from './Board.js';
|
|
8
|
+
import { Feedback } from './Feedback.js';
|
|
9
|
+
import { GameInfoPanel } from './GameInfoPanel.js';
|
|
10
|
+
import { HelpView } from './HelpView.js';
|
|
11
|
+
import { Wordmark } from './Wordmark.js';
|
|
12
|
+
/** Every status row stays within the fixed geometry established by POST.6. */
|
|
13
|
+
export function GameShell({ geometry, state, selection, status, difficulty, feedback, view, }) {
|
|
14
|
+
const displayFeedback = view === 'confirm-new' ? 'Start a new game? Y/N' : feedback;
|
|
15
|
+
if (geometry.mode === 'compact') {
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", width: geometry.dashboard.width, children: [_jsx(Box, { marginLeft: geometry.banner.left - geometry.dashboard.left, children: _jsx(Wordmark, { compact: true }) }), _jsxs(Text, { children: [_jsx(Text, { ...theme.gameResult, children: status.heading }), _jsx(Text, { ...theme.muted, children: ` · ${difficultyLabel(difficulty)}` }), state.status !== 'playing' && (_jsx(Text, { children: " \u00B7 N New / U Undo / Q Quit" }))] }), _jsxs(Text, { children: [_jsx(Text, { ...theme.playerX, children: "X" }), " You", ' ', _jsx(Text, { ...theme.playerO, children: "O" }), " Machine", ' ', "MOVES:", ' ', state.moves.length] }), _jsx(Text, { ...theme.muted, children: captionText(state) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [view === 'help' ? (_jsx(HelpView, { compact: true })) : (_jsx(Board, { state: state, selection: selection })), _jsxs(Text, { ...theme.muted, children: ["SELECTED ", formatPosition(selection)] })] }), _jsx(Feedback, { message: displayFeedback }), _jsx(Text, { ...theme.muted, children: COMPACT_CONTROLS_TEXT })] }));
|
|
17
|
+
}
|
|
18
|
+
return (_jsxs(Box, { flexDirection: "column", width: geometry.dashboard.width, children: [_jsx(Box, { marginLeft: geometry.banner.left - geometry.dashboard.left, children: _jsx(Wordmark, {}) }), _jsxs(Box, { marginTop: 1, children: [_jsx(Box, { flexDirection: "column", width: BOARD_WIDTH, children: _jsx(Board, { state: state, selection: selection }) }), _jsx(Box, { width: PANEL_WIDTH, marginLeft: PANEL_GAP, children: _jsx(GameInfoPanel, { state: state, status: status, difficulty: difficulty, view: view }) })] }), _jsx(Feedback, { message: displayFeedback }), _jsx(Text, { ...theme.divider, children: '─'.repeat(geometry.dashboard.width) }), _jsx(Text, { ...theme.muted, children: CONTROLS_TEXT })] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, useApp, useInput, useWindowSize } from 'ink';
|
|
3
|
+
import { useEffect, useMemo, useReducer } from 'react';
|
|
4
|
+
import { chooseMove } from '../opponent/opponent.js';
|
|
5
|
+
import { hitTestMouse } from '../ui/hitTest.js';
|
|
6
|
+
import { resolveMovement } from '../ui/input.js';
|
|
7
|
+
import { resolveGeometry } from '../ui/layout.js';
|
|
8
|
+
import { statusText } from '../ui/status.js';
|
|
9
|
+
import { GameShell } from './GameShell.js';
|
|
10
|
+
import { gameSessionReducer, initialSession } from './gameSession.js';
|
|
11
|
+
import { TooSmallNotice } from './TooSmallNotice.js';
|
|
12
|
+
export function GomokuApp({ viewportSize, initialState, mouseInput, }) {
|
|
13
|
+
const { exit } = useApp();
|
|
14
|
+
const windowSize = useWindowSize();
|
|
15
|
+
const [session, dispatch] = useReducer(gameSessionReducer, initialState, initialSession);
|
|
16
|
+
const { game, selection, difficulty, feedback, revision, view } = session;
|
|
17
|
+
const viewport = viewportSize ?? windowSize;
|
|
18
|
+
const { columns, rows } = viewport;
|
|
19
|
+
const geometry = useMemo(() => resolveGeometry({ columns, rows }), [columns, rows]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!mouseInput)
|
|
22
|
+
return;
|
|
23
|
+
return mouseInput.subscribe((press) => {
|
|
24
|
+
const target = hitTestMouse(geometry, press);
|
|
25
|
+
if (!target)
|
|
26
|
+
return;
|
|
27
|
+
if (target.type === 'cell') {
|
|
28
|
+
dispatch({ type: 'place-at', row: target.row, col: target.col });
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
dispatch({ type: target.command });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}, [mouseInput, geometry]);
|
|
35
|
+
// Each timer captures a specific turn revision. A new game, undo, mode
|
|
36
|
+
// change, or fresh move invalidates outstanding results in the reducer,
|
|
37
|
+
// even if cancellation loses a race with a queued callback.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (view !== 'game' ||
|
|
40
|
+
game.status !== 'playing' ||
|
|
41
|
+
game.currentPlayer !== 'O') {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
const move = chooseMove(game, { difficulty });
|
|
46
|
+
dispatch({ type: 'machine-reply', move, revision });
|
|
47
|
+
}, 0);
|
|
48
|
+
return () => clearTimeout(timer);
|
|
49
|
+
}, [game, difficulty, revision, view]);
|
|
50
|
+
useInput((input, key) => {
|
|
51
|
+
const command = input.toLowerCase();
|
|
52
|
+
if (command === 'q') {
|
|
53
|
+
exit();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (view === 'confirm-new') {
|
|
57
|
+
if (command === 'y')
|
|
58
|
+
dispatch({ type: 'confirm-new' });
|
|
59
|
+
else if (command === 'n' || key.escape) {
|
|
60
|
+
dispatch({ type: 'dismiss' });
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (view === 'help') {
|
|
65
|
+
if (input === '?' || key.escape)
|
|
66
|
+
dispatch({ type: 'dismiss' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (input === '?') {
|
|
70
|
+
dispatch({ type: 'help' });
|
|
71
|
+
}
|
|
72
|
+
else if (command === 'm') {
|
|
73
|
+
dispatch({ type: 'mode' });
|
|
74
|
+
}
|
|
75
|
+
else if (command === 'n') {
|
|
76
|
+
dispatch({ type: 'new' });
|
|
77
|
+
}
|
|
78
|
+
else if (command === 'u') {
|
|
79
|
+
dispatch({ type: 'undo' });
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const direction = resolveMovement(command, key);
|
|
83
|
+
if (direction) {
|
|
84
|
+
dispatch({ type: 'move', direction });
|
|
85
|
+
}
|
|
86
|
+
else if (key.return || input === ' ') {
|
|
87
|
+
dispatch({ type: 'place' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
if (!geometry)
|
|
92
|
+
return _jsx(TooSmallNotice, { viewport: viewport });
|
|
93
|
+
const machineThinking = game.status === 'playing' && game.currentPlayer === 'O';
|
|
94
|
+
const status = view === 'confirm-new'
|
|
95
|
+
? { heading: 'Confirm restart', supporting: 'Y confirm · N cancel' }
|
|
96
|
+
: view === 'help'
|
|
97
|
+
? { heading: 'Help', supporting: '? or Escape to close' }
|
|
98
|
+
: statusText(game, machineThinking);
|
|
99
|
+
return (_jsx(Box, { flexDirection: "column", paddingLeft: geometry.dashboard.left, paddingTop: geometry.dashboard.top, children: _jsx(GameShell, { geometry: geometry, state: game, selection: selection, status: status, difficulty: difficulty, feedback: feedback, view: view }) }));
|
|
100
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { BOARD_HEIGHT, PANEL_WIDTH } from '../ui/layout.js';
|
|
4
|
+
import { theme } from '../ui/theme.js';
|
|
5
|
+
const HELP_LINES = [
|
|
6
|
+
'Arrows / WASD Move',
|
|
7
|
+
'Enter / Space Place X',
|
|
8
|
+
'Left click Place X',
|
|
9
|
+
'U Undo',
|
|
10
|
+
'M Mode',
|
|
11
|
+
'N New game',
|
|
12
|
+
'Q Quit',
|
|
13
|
+
'',
|
|
14
|
+
'Click Undo / Mode in',
|
|
15
|
+
'the wide footer.',
|
|
16
|
+
'',
|
|
17
|
+
'? / Esc Close',
|
|
18
|
+
];
|
|
19
|
+
/** Replaces only an existing fixed-height region; board geometry never shifts. */
|
|
20
|
+
export function HelpView({ compact = false }) {
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", height: BOARD_HEIGHT, width: compact ? undefined : PANEL_WIDTH, children: [_jsx(Text, { ...theme.gameResult, children: "HELP" }), HELP_LINES.map((line, index) => (_jsx(Text, { ...(line ? theme.text : theme.muted), children: line || ' ' }, index)))] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { MIN_COLUMNS, MIN_ROWS } from '../ui/layout.js';
|
|
4
|
+
import { theme } from '../ui/theme.js';
|
|
5
|
+
/** Never print a line wider than the reported terminal, even at extreme sizes. */
|
|
6
|
+
export function TooSmallNotice({ viewport }) {
|
|
7
|
+
const columns = Number.isFinite(viewport.columns)
|
|
8
|
+
? Math.max(0, Math.floor(viewport.columns))
|
|
9
|
+
: MIN_COLUMNS;
|
|
10
|
+
const rows = Number.isFinite(viewport.rows)
|
|
11
|
+
? Math.max(0, Math.floor(viewport.rows))
|
|
12
|
+
: 2;
|
|
13
|
+
if (columns === 0 || rows === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const title = columns >= 19 ? 'Terminal too small.' : 'Resize terminal';
|
|
16
|
+
const details = `Need ${MIN_COLUMNS} columns x ${MIN_ROWS} rows.`;
|
|
17
|
+
const centerLine = (message) => {
|
|
18
|
+
const text = message.slice(0, columns);
|
|
19
|
+
return `${' '.repeat(Math.floor((columns - text.length) / 2))}${text}`;
|
|
20
|
+
};
|
|
21
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { ...theme.gameResult, children: centerLine(title) }), rows > 1 && _jsx(Text, { ...theme.muted, children: centerLine(details) })] }));
|
|
22
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { COMPACT_WORDMARK, FULL_BANNER_ROWS } from '../ui/branding.js';
|
|
4
|
+
import { theme } from '../ui/theme.js';
|
|
5
|
+
export function Wordmark({ compact = false }) {
|
|
6
|
+
if (compact) {
|
|
7
|
+
return _jsx(Text, { bold: true, children: COMPACT_WORDMARK });
|
|
8
|
+
}
|
|
9
|
+
return (_jsx(Box, { flexDirection: "column", children: FULL_BANNER_ROWS.map((line, index) => (_jsx(Text, { ...(index === FULL_BANNER_ROWS.length - 1
|
|
10
|
+
? theme.muted
|
|
11
|
+
: { bold: true }), children: line }, line))) }));
|
|
12
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { applyMove, createInitialState, latestHumanMove, undoHumanTurn, } from '../domain/engine.js';
|
|
2
|
+
import { CENTER } from '../opponent/opponent.js';
|
|
3
|
+
import { DEFAULT_DIFFICULTY, difficultyLabel, nextDifficulty, } from '../opponent/profiles.js';
|
|
4
|
+
import { moveSelection } from '../ui/input.js';
|
|
5
|
+
import { completionFeedback, feedbackText } from '../ui/status.js';
|
|
6
|
+
export function initialSession(initialState) {
|
|
7
|
+
return {
|
|
8
|
+
game: initialState ?? createInitialState(),
|
|
9
|
+
selection: { row: CENTER, col: CENTER },
|
|
10
|
+
difficulty: DEFAULT_DIFFICULTY,
|
|
11
|
+
feedback: '',
|
|
12
|
+
view: 'game',
|
|
13
|
+
revision: 0,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function gameSessionReducer(session, action) {
|
|
17
|
+
const { game, view } = session;
|
|
18
|
+
// Confirmation and Help are exclusive input modes. A pending bot decision
|
|
19
|
+
// cannot apply while either is open, even if its timer already fired.
|
|
20
|
+
if (view === 'confirm-new') {
|
|
21
|
+
if (action.type === 'confirm-new')
|
|
22
|
+
return restart(session);
|
|
23
|
+
if (action.type === 'dismiss' || action.type === 'new') {
|
|
24
|
+
return resume(session);
|
|
25
|
+
}
|
|
26
|
+
return session;
|
|
27
|
+
}
|
|
28
|
+
if (view === 'help') {
|
|
29
|
+
if (action.type === 'help' || action.type === 'dismiss') {
|
|
30
|
+
return resume(session);
|
|
31
|
+
}
|
|
32
|
+
return session;
|
|
33
|
+
}
|
|
34
|
+
switch (action.type) {
|
|
35
|
+
case 'new':
|
|
36
|
+
if (game.status !== 'playing' || game.moves.length === 0) {
|
|
37
|
+
return restart(session);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
...session,
|
|
41
|
+
view: 'confirm-new',
|
|
42
|
+
revision: session.revision + 1,
|
|
43
|
+
};
|
|
44
|
+
case 'help':
|
|
45
|
+
return { ...session, view: 'help', revision: session.revision + 1 };
|
|
46
|
+
case 'confirm-new':
|
|
47
|
+
case 'dismiss':
|
|
48
|
+
return session;
|
|
49
|
+
case 'undo': {
|
|
50
|
+
const undone = latestHumanMove(game);
|
|
51
|
+
if (!undone)
|
|
52
|
+
return session;
|
|
53
|
+
return {
|
|
54
|
+
...session,
|
|
55
|
+
game: undoHumanTurn(game),
|
|
56
|
+
selection: { row: undone.row, col: undone.col },
|
|
57
|
+
feedback: 'Last turn undone.',
|
|
58
|
+
revision: session.revision + 1,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
case 'mode': {
|
|
62
|
+
const difficulty = nextDifficulty(session.difficulty);
|
|
63
|
+
return {
|
|
64
|
+
...session,
|
|
65
|
+
difficulty,
|
|
66
|
+
feedback: `Mode: ${difficultyLabel(difficulty)}.`,
|
|
67
|
+
revision: session.revision + 1,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
case 'move':
|
|
71
|
+
if (game.status !== 'playing' || game.currentPlayer !== 'X') {
|
|
72
|
+
return session;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
...session,
|
|
76
|
+
selection: moveSelection(session.selection, action.direction),
|
|
77
|
+
feedback: '',
|
|
78
|
+
};
|
|
79
|
+
case 'place':
|
|
80
|
+
case 'place-at': {
|
|
81
|
+
const selection = action.type === 'place-at'
|
|
82
|
+
? { row: action.row, col: action.col }
|
|
83
|
+
: session.selection;
|
|
84
|
+
if (game.status !== 'playing' || game.currentPlayer !== 'X') {
|
|
85
|
+
return {
|
|
86
|
+
...session,
|
|
87
|
+
feedback: feedbackText(game.status !== 'playing' ? 'game-over' : 'not-your-turn'),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// Mouse and keyboard share this authoritative engine transition.
|
|
91
|
+
const result = applyMove(game, selection.row, selection.col);
|
|
92
|
+
return result.ok
|
|
93
|
+
? {
|
|
94
|
+
...session,
|
|
95
|
+
selection,
|
|
96
|
+
game: result.state,
|
|
97
|
+
feedback: completionFeedback(result.state),
|
|
98
|
+
revision: session.revision + 1,
|
|
99
|
+
}
|
|
100
|
+
: { ...session, selection, feedback: feedbackText(result.reason) };
|
|
101
|
+
}
|
|
102
|
+
case 'machine-reply': {
|
|
103
|
+
if (action.revision !== session.revision ||
|
|
104
|
+
game.status !== 'playing' ||
|
|
105
|
+
game.currentPlayer !== 'O' ||
|
|
106
|
+
action.move.player !== 'O') {
|
|
107
|
+
return session;
|
|
108
|
+
}
|
|
109
|
+
const result = applyMove(game, action.move.row, action.move.col);
|
|
110
|
+
return result.ok
|
|
111
|
+
? {
|
|
112
|
+
...session,
|
|
113
|
+
game: result.state,
|
|
114
|
+
feedback: completionFeedback(result.state),
|
|
115
|
+
revision: session.revision + 1,
|
|
116
|
+
}
|
|
117
|
+
: session;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** A reset is deliberate, either from an empty/finished board or confirmed. */
|
|
122
|
+
function restart(session) {
|
|
123
|
+
return {
|
|
124
|
+
...session,
|
|
125
|
+
game: createInitialState(),
|
|
126
|
+
selection: { row: CENTER, col: CENTER },
|
|
127
|
+
view: 'game',
|
|
128
|
+
feedback: 'New game started.',
|
|
129
|
+
revision: session.revision + 1,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Resume a suspended machine turn with a fresh revision. */
|
|
133
|
+
function resume(session) {
|
|
134
|
+
return { ...session, view: 'game', revision: session.revision + 1 };
|
|
135
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { BOARD_SIZE } from './types.js';
|
|
2
|
+
export function createEmptyBoard() {
|
|
3
|
+
const board = [];
|
|
4
|
+
for (let row = 0; row < BOARD_SIZE; row += 1) {
|
|
5
|
+
board.push(new Array(BOARD_SIZE).fill(null));
|
|
6
|
+
}
|
|
7
|
+
return board;
|
|
8
|
+
}
|
|
9
|
+
export function cloneBoard(board) {
|
|
10
|
+
return board.map((row) => row.slice());
|
|
11
|
+
}
|
|
12
|
+
export function inBounds(row, col) {
|
|
13
|
+
return row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
|
14
|
+
}
|
|
15
|
+
export function isBoardFull(board) {
|
|
16
|
+
return board.every((row) => row.every((cell) => cell !== null));
|
|
17
|
+
}
|
|
18
|
+
export function emptyCells(board) {
|
|
19
|
+
const cells = [];
|
|
20
|
+
for (let row = 0; row < board.length; row += 1) {
|
|
21
|
+
for (let col = 0; col < board[row].length; col += 1) {
|
|
22
|
+
if (board[row][col] === null) {
|
|
23
|
+
cells.push([row, col]);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return cells;
|
|
28
|
+
}
|