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 CHANGED
@@ -1,3 +1,165 @@
1
- # Temporary Holding Version
1
+ # Gomoku
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ }