pi-tic-tac-toe 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 ADDED
@@ -0,0 +1,93 @@
1
+ # pi-tic-tac-toe
2
+
3
+ Play a quick game of tic tac toe against the LLM inside [Pi](https://github.com/earendil-works/pi).
4
+
5
+ `pi-tic-tac-toe` adds a persistent TUI overlay opened with `/tic-tac-toe`. You play `X`, the LLM plays `O`, and the game stays in your Pi session so you can close the overlay and resume later.
6
+
7
+ ## Features
8
+
9
+ - Centered terminal overlay, built with Pi TUI
10
+ - Human vs LLM gameplay
11
+ - Arrow-key and number-key input
12
+ - Session persistence across Pi reloads/resumes
13
+ - Validated saved state and legal move checking
14
+ - TypeScript source, no build step required
15
+ - Manual npm publish workflow included
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pi install npm:pi-tic-tac-toe
21
+ ```
22
+
23
+ Or add it to `~/.pi/agent/settings.json`:
24
+
25
+ ```json
26
+ {
27
+ "packages": ["npm:pi-tic-tac-toe"]
28
+ }
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ Start or resume a game:
34
+
35
+ ```text
36
+ /tic-tac-toe
37
+ ```
38
+
39
+ Start a fresh game:
40
+
41
+ ```text
42
+ /tic-tac-toe new
43
+ ```
44
+
45
+ ## Controls
46
+
47
+ | Key | Action |
48
+ | --- | --- |
49
+ | Arrow keys | Move cursor |
50
+ | `1`-`9` | Jump to a square |
51
+ | Enter | Place `X` |
52
+ | `n` | New game |
53
+ | `q` | Close overlay |
54
+
55
+ The overlay is TUI-only. It will not open in Pi print, JSON, or RPC modes.
56
+
57
+ ## How It Works
58
+
59
+ After you place `X`, the extension asks the LLM to play `O` by calling:
60
+
61
+ ```text
62
+ make_tic_tac_toe_move
63
+ ```
64
+
65
+ The extension validates every move, so the LLM cannot move out of turn, overwrite a square, or continue after the game is over.
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ npm install
71
+ npm test
72
+ npm run check
73
+ ```
74
+
75
+ Try the extension locally:
76
+
77
+ ```bash
78
+ pi -e ./extensions/index.ts
79
+ ```
80
+
81
+ Then run:
82
+
83
+ ```text
84
+ /tic-tac-toe
85
+ ```
86
+
87
+ ## Package Notes
88
+
89
+ Pi loads TypeScript extensions directly, so this package publishes the source `.ts` files. Pi core packages are declared as peer dependencies to avoid bundling duplicate Pi runtime packages.
90
+
91
+ ## License
92
+
93
+ MIT
@@ -0,0 +1,128 @@
1
+ export type Mark = "X" | "O";
2
+ export type Winner = Mark | "draw" | null;
3
+
4
+ export type Move = {
5
+ cell: number;
6
+ mark: Mark;
7
+ };
8
+
9
+ export type GameState = {
10
+ board: Array<Mark | null>;
11
+ turn: Mark;
12
+ history: Move[];
13
+ };
14
+
15
+ export class TicTacToeGame {
16
+ board: Array<Mark | null>;
17
+ turn: Mark;
18
+ history: Move[];
19
+
20
+ constructor(state?: GameState) {
21
+ const parsed = state ? parseGameState(state) : initialState();
22
+ this.board = parsed.board;
23
+ this.turn = parsed.turn;
24
+ this.history = parsed.history;
25
+ }
26
+
27
+ play(cell: number, mark: Mark): boolean {
28
+ if (!Number.isInteger(cell) || cell < 0 || cell > 8 || mark !== this.turn || this.board[cell] || this.winner()) return false;
29
+ this.board[cell] = mark;
30
+ this.history.push({ cell, mark });
31
+ this.turn = mark === "X" ? "O" : "X";
32
+ return true;
33
+ }
34
+
35
+ legalMoves(): number[] {
36
+ if (this.winner()) return [];
37
+ return this.board.flatMap((mark, cell) => mark ? [] : [cell]);
38
+ }
39
+
40
+ winner(): Winner {
41
+ const winners = winnersFor(this.board);
42
+ if (winners.length) return winners[0];
43
+ return this.board.every(Boolean) ? "draw" : null;
44
+ }
45
+
46
+ status(): string {
47
+ const winner = this.winner();
48
+ if (winner === "draw") return "Draw";
49
+ if (winner) return `${winner} wins`;
50
+ return `${this.turn} to move`;
51
+ }
52
+
53
+ toState(): GameState {
54
+ return { board: this.board.slice(), turn: this.turn, history: this.history.slice() };
55
+ }
56
+
57
+ static fromState(state: unknown): TicTacToeGame {
58
+ return new TicTacToeGame(parseGameState(state));
59
+ }
60
+ }
61
+
62
+ export function boardPrompt(game: TicTacToeGame): string {
63
+ const cells = game.board.map((mark, i) => mark ?? String(i + 1));
64
+ return [
65
+ `${cells[0]} | ${cells[1]} | ${cells[2]}`,
66
+ `${cells[3]} | ${cells[4]} | ${cells[5]}`,
67
+ `${cells[6]} | ${cells[7]} | ${cells[8]}`,
68
+ ].join("\n");
69
+ }
70
+
71
+ export function parseGameState(value: unknown): GameState {
72
+ if (!isRecord(value)) throw new Error("Invalid state");
73
+ if (!Array.isArray(value.board) || value.board.length !== 9) throw new Error("Invalid board");
74
+ if (value.turn !== "X" && value.turn !== "O") throw new Error("Invalid turn");
75
+ if (!Array.isArray(value.history)) throw new Error("Invalid history");
76
+
77
+ const board = value.board.map(parseCell);
78
+ const turn: Mark = value.turn;
79
+ const history = value.history.map(parseMove);
80
+ const state = { board, turn, history };
81
+ validateState(state);
82
+ return state;
83
+ }
84
+
85
+ function initialState(): GameState {
86
+ return { board: Array<Mark | null>(9).fill(null), turn: "X", history: [] };
87
+ }
88
+
89
+ function parseCell(value: unknown): Mark | null {
90
+ if (value === null || value === "X" || value === "O") return value;
91
+ throw new Error("Invalid board");
92
+ }
93
+
94
+ function parseMove(value: unknown): Move {
95
+ if (!isRecord(value) || (value.mark !== "X" && value.mark !== "O")) throw new Error("Invalid history");
96
+ const cell = value.cell;
97
+ if (typeof cell !== "number" || !Number.isInteger(cell) || cell < 0 || cell > 8) {
98
+ throw new Error("Invalid history");
99
+ }
100
+ return { cell, mark: value.mark };
101
+ }
102
+
103
+ function validateState(state: GameState): void {
104
+ const xs = state.board.filter((mark) => mark === "X").length;
105
+ const os = state.board.filter((mark) => mark === "O").length;
106
+ if (os > xs || xs > os + 1) throw new Error("Invalid mark counts");
107
+
108
+ const winners = winnersFor(state.board);
109
+ const winnerSet = new Set(winners);
110
+ if (winnerSet.size > 1) throw new Error("Invalid board: mixed winners");
111
+ if (state.turn !== (xs === os ? "X" : "O")) throw new Error("Invalid turn");
112
+ if (winners[0] === "X" && xs !== os + 1) throw new Error("Invalid winner");
113
+ if (winners[0] === "O" && xs !== os) throw new Error("Invalid winner");
114
+ }
115
+
116
+ function winnersFor(board: Array<Mark | null>): Mark[] {
117
+ return LINES.flatMap(([a, b, c]) => board[a] && board[a] === board[b] && board[a] === board[c] ? [board[a]] : []);
118
+ }
119
+
120
+ function isRecord(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === "object" && value !== null;
122
+ }
123
+
124
+ export const LINES = [
125
+ [0, 1, 2], [3, 4, 5], [6, 7, 8],
126
+ [0, 3, 6], [1, 4, 7], [2, 5, 8],
127
+ [0, 4, 8], [2, 4, 6],
128
+ ] as const;
@@ -0,0 +1,245 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ExtensionAPI,
4
+ ExtensionCommandContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ type Component,
8
+ Key,
9
+ matchesKey,
10
+ type TUI,
11
+ truncateToWidth,
12
+ visibleWidth,
13
+ } from "@earendil-works/pi-tui";
14
+ import { Type } from "typebox";
15
+ import { boardPrompt, type GameState, TicTacToeGame } from "./game.ts";
16
+
17
+ const SAVE_TYPE = "tic-tac-toe-save";
18
+ const moveSchema = Type.Object({
19
+ square: Type.Integer({
20
+ minimum: 1,
21
+ maximum: 9,
22
+ description: "Board square from 1 to 9.",
23
+ }),
24
+ });
25
+
26
+ type MoveDetails = { square: number; state: GameState; status: string };
27
+
28
+ class TicTacToeComponent implements Component {
29
+ cursor = 4;
30
+ error = "";
31
+ private tui: TUI;
32
+ private game: TicTacToeGame;
33
+ private onClose: () => void;
34
+ private onMove: (cell: number) => void;
35
+ private onNew: () => void;
36
+
37
+ constructor(
38
+ tui: TUI,
39
+ game: TicTacToeGame,
40
+ onClose: () => void,
41
+ onMove: (cell: number) => void,
42
+ onNew: () => void,
43
+ ) {
44
+ this.tui = tui;
45
+ this.game = game;
46
+ this.onClose = onClose;
47
+ this.onMove = onMove;
48
+ this.onNew = onNew;
49
+ }
50
+
51
+ handleInput(data: string): void {
52
+ if (matchesKey(data, "q")) return this.onClose();
53
+ if (matchesKey(data, "n")) return this.onNew();
54
+ if (this.game.winner() || this.game.turn !== "X") return;
55
+
56
+ const digit = Number(data);
57
+ if (Number.isInteger(digit) && digit >= 1 && digit <= 9)
58
+ return this.pick(digit - 1);
59
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.return))
60
+ return this.pick(this.cursor);
61
+ if (matchesKey(data, Key.up)) this.cursor = Math.max(0, this.cursor - 3);
62
+ if (matchesKey(data, Key.down)) this.cursor = Math.min(8, this.cursor + 3);
63
+ if (matchesKey(data, Key.left) && this.cursor % 3) this.cursor--;
64
+ if (matchesKey(data, Key.right) && this.cursor % 3 < 2) this.cursor++;
65
+ this.tui.requestRender();
66
+ }
67
+
68
+ pick(cell: number): void {
69
+ this.cursor = cell;
70
+ this.onMove(cell);
71
+ }
72
+
73
+ render(width: number): string[] {
74
+ return [
75
+ this.center("TIC TAC TOE - You(X) vs LLM(O)", width),
76
+ "",
77
+ ...this.boardLines(width),
78
+ "",
79
+ this.center(`Status: ${this.game.status()}`, width),
80
+ this.error ? this.center(`Error: ${this.error}`, width) : "",
81
+ this.center("Arrows/1-9=move Enter=place n=new q=quit", width),
82
+ ].map((line) => truncateToWidth(line, width));
83
+ }
84
+
85
+ boardLines(width: number): string[] {
86
+ const rows = [0, 3, 6].map((start) =>
87
+ [0, 1, 2].map((i) => this.cell(start + i)).join("|"),
88
+ );
89
+ return rows.flatMap((row, i) =>
90
+ i < 2
91
+ ? [this.center(row, width), this.center("---+---+---", width)]
92
+ : [this.center(row, width)],
93
+ );
94
+ }
95
+
96
+ cell(i: number): string {
97
+ const mark = this.game.board[i] ?? String(i + 1);
98
+ return i === this.cursor && this.game.turn === "X" && !this.game.winner()
99
+ ? `[${mark}]`
100
+ : ` ${mark} `;
101
+ }
102
+
103
+ center(text: string, width: number): string {
104
+ return (
105
+ " ".repeat(Math.max(0, Math.floor((width - visibleWidth(text)) / 2))) +
106
+ text
107
+ );
108
+ }
109
+
110
+ setGame(game: TicTacToeGame): void {
111
+ this.game = game;
112
+ this.cursor = game.legalMoves()[0] ?? 4;
113
+ this.error = "";
114
+ this.tui.requestRender();
115
+ }
116
+
117
+ setError(error: string): void {
118
+ this.error = error;
119
+ this.tui.requestRender();
120
+ }
121
+
122
+ invalidate(): void {}
123
+ }
124
+
125
+ export default function ticTacToe(pi: ExtensionAPI): void {
126
+ let game: TicTacToeGame | null = null;
127
+ let active: TicTacToeComponent | null = null;
128
+
129
+ pi.on("session_start", (_event, ctx) => {
130
+ game = loadGame(ctx.sessionManager.getBranch());
131
+ });
132
+
133
+ pi.registerCommand("tic-tac-toe", {
134
+ description: "Play tic tac toe against the LLM",
135
+ handler: async (args, ctx) => {
136
+ if (ctx.mode !== "tui") {
137
+ ctx.ui.notify("Tic tac toe overlay only works in TUI mode", "error");
138
+ return;
139
+ }
140
+ if (!game || game.winner() || args.trim() === "new") {
141
+ game = new TicTacToeGame();
142
+ save();
143
+ }
144
+ await show(ctx);
145
+ },
146
+ });
147
+
148
+ pi.registerTool<typeof moveSchema, MoveDetails>({
149
+ name: "make_tic_tac_toe_move",
150
+ label: "Tic Tac Toe Move",
151
+ description: "Place O on the tic tac toe board for the LLM.",
152
+ parameters: moveSchema,
153
+ async execute(_id, params) {
154
+ if (!game || game.turn !== "O") throw new Error("Not the LLM turn.");
155
+ const cell = params.square - 1;
156
+ if (!game.play(cell, "O"))
157
+ throw new Error(`Invalid square. Legal: ${legal()}`);
158
+ save();
159
+ active?.setGame(game);
160
+ return result(
161
+ `Moved to ${params.square}. ${game.winner() ? game.status() : "X to move."}`,
162
+ params.square,
163
+ );
164
+ },
165
+ });
166
+
167
+ function handleHumanMove(cell: number): void {
168
+ if (!game?.play(cell, "X"))
169
+ return active?.setError(`Invalid square: ${cell + 1}`);
170
+ save();
171
+ active?.setGame(game);
172
+ if (game.winner()) return;
173
+ pi.sendUserMessage(
174
+ `You are O in tic tac toe. Pick the best legal square and call make_tic_tac_toe_move.\n\nBoard:\n${boardPrompt(game)}\n\nLegal squares: ${legal()}`,
175
+ { deliverAs: "steer" },
176
+ );
177
+ }
178
+
179
+ async function show(ctx: ExtensionCommandContext): Promise<void> {
180
+ await ctx.ui.custom(
181
+ (tui, _theme, _keybindings, done) => {
182
+ active = new TicTacToeComponent(
183
+ tui,
184
+ game!,
185
+ () => {
186
+ active = null;
187
+ done(undefined);
188
+ },
189
+ handleHumanMove,
190
+ () => {
191
+ game = new TicTacToeGame();
192
+ save();
193
+ active?.setGame(game);
194
+ },
195
+ );
196
+ return active;
197
+ },
198
+ {
199
+ overlay: true,
200
+ overlayOptions: {
201
+ width: "44%",
202
+ maxHeight: "70%",
203
+ anchor: "center",
204
+ margin: { top: 1 },
205
+ },
206
+ },
207
+ );
208
+ }
209
+
210
+ function save(): void {
211
+ if (game) pi.appendEntry(SAVE_TYPE, game.toState());
212
+ }
213
+
214
+ function legal(): string {
215
+ return (
216
+ game
217
+ ?.legalMoves()
218
+ .map((cell) => cell + 1)
219
+ .join(", ") ?? ""
220
+ );
221
+ }
222
+
223
+ function result(text: string, square: number): AgentToolResult<MoveDetails> {
224
+ return {
225
+ content: [{ type: "text", text }],
226
+ details: { square, state: game!.toState(), status: game!.status() },
227
+ };
228
+ }
229
+ }
230
+
231
+ function loadGame(
232
+ entries: Array<{ type: string; customType?: string; data?: unknown }>,
233
+ ): TicTacToeGame | null {
234
+ for (let i = entries.length - 1; i >= 0; i--) {
235
+ const entry = entries[i];
236
+ if (entry.type === "custom" && entry.customType === SAVE_TYPE) {
237
+ try {
238
+ return TicTacToeGame.fromState(entry.data);
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+ }
244
+ return null;
245
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "pi-tic-tac-toe",
3
+ "version": "0.1.0",
4
+ "description": "Tic tac toe extension for Pi",
5
+ "type": "module",
6
+ "main": "./extensions/index.ts",
7
+ "scripts": {
8
+ "test": "node --test tests/*.test.ts",
9
+ "check": "tsc --noEmit"
10
+ },
11
+ "keywords": ["pi-package", "pi", "extension", "tic-tac-toe"],
12
+ "files": ["extensions", "README.md"],
13
+ "pi": {
14
+ "extensions": ["./extensions/index.ts"]
15
+ },
16
+ "peerDependencies": {
17
+ "@earendil-works/pi-coding-agent": "*",
18
+ "@earendil-works/pi-tui": "*",
19
+ "typebox": "*"
20
+ },
21
+ "devDependencies": {
22
+ "@earendil-works/pi-coding-agent": "latest",
23
+ "@earendil-works/pi-tui": "latest",
24
+ "@types/node": "latest",
25
+ "typebox": "latest",
26
+ "typescript": "latest"
27
+ },
28
+ "license": "MIT"
29
+ }