jkh-c4 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,203 @@
1
+ # Getting Started
2
+
3
+ A terminal Connect Four game in Python. This guide gets you playing locally and
4
+ shows the **mechanics** of writing your own bot. (For playing over a network
5
+ with a server and clients, see [NETWORKED_PLAY.md](NETWORKED_PLAY.md).)
6
+
7
+ ---
8
+
9
+ ## Requirements
10
+
11
+ - **Python 3.13 or newer** — check with `python3 --version`.
12
+ - **No packages to install** — it uses only the standard library.
13
+
14
+ Run all commands from the project folder (the one with `local.py` and the
15
+ `bot/` directory). If `python3` isn't found, try `python`.
16
+
17
+ ---
18
+
19
+ ## Play a local game
20
+
21
+ Local play runs everything in one program. Start a human-vs-human game:
22
+
23
+ ```bash
24
+ python3 local.py
25
+ ```
26
+
27
+ You'll enter a name, then see the board:
28
+
29
+ ```
30
+  0 1 2 3 4 5 6
31
+ +-+-+-+-+-+-+-+
32
+ | | | | | | | |
33
+ | | | | | | | |
34
+ | | | | | | | |
35
+ | | | | | | | |
36
+ | | | | | | | |
37
+ | | | | | | | |
38
+ +-+-+-+-+-+-+-+
39
+ ```
40
+
41
+ The numbers on top are **column numbers, starting at 0**. On your turn, type a
42
+ column number and press Enter to drop your piece; it falls to the lowest open
43
+ slot. Get `win_length` pieces in a row (horizontal, vertical, or diagonal) to
44
+ win. **Ctrl-C** quits.
45
+
46
+ ### Choosing players
47
+
48
+ RED always moves first, then YELLOW. Use `--red` and `--yellow` to set each
49
+ color to either `human` or a bot name:
50
+
51
+ ```bash
52
+ python3 local.py # human vs human (default)
53
+ python3 local.py --yellow leftmost # you (RED) vs leftmost bot
54
+ python3 local.py --red leftmost --yellow rightmost # bot vs bot
55
+ ```
56
+
57
+ The bots included so far are deliberately simple — they're your competition to
58
+ beat:
59
+
60
+ | Name | Strategy |
61
+ | ----------- | ------------------------------------------------------------------- |
62
+ | `leftmost` | Plays the left-most column with room. |
63
+ | `rightmost` | Plays the right-most column with room. |
64
+ | `rand` | Plays a random column with room. |
65
+ | `invalid` | Always plays an out-of-bounds column (for testing error handling). |
66
+
67
+ ### Changing the board and win condition
68
+
69
+ | Option | Default | Meaning |
70
+ | -------------- | ------- | ----------------------------- |
71
+ | `--cols` | `7` | Board width |
72
+ | `--rows` | `6` | Board height |
73
+ | `--win-length` | `4` | Pieces in a row needed to win |
74
+
75
+ ```bash
76
+ # Same as the default:
77
+ python3 local.py --cols 7 --rows 6 --win-length 4
78
+
79
+ # A small, fast "three in a row" board — handy while testing a bot:
80
+ python3 local.py --cols 5 --rows 4 --win-length 3 --red leftmost --yellow rand
81
+ ```
82
+
83
+ `win_length` must be at least 1 and can't exceed *both* the width and height
84
+ (otherwise no win is possible); the game refuses impossible combinations.
85
+
86
+ ---
87
+
88
+ ## Write your own bot
89
+
90
+ A bot is just **one function** that, given the current board and which piece
91
+ you're playing, returns the **column number to play**. The rest of this section
92
+ is the plumbing; the *strategy* is up to you.
93
+
94
+ ### 1. Create a file in `bot/`
95
+
96
+ Add a file `bot/<name>.py`. Any file you drop in the `bot/` folder is picked up
97
+ automatically when the program starts (except files beginning with `_` or
98
+ `test_`). The name you `register` is the name you'll use on the command line.
99
+
100
+ Here is the entire `leftmost` bot (`bot/leftmost.py`) — the simplest complete
101
+ example to model yours on:
102
+
103
+ ```python
104
+ from game import Board, Piece
105
+ from . import register
106
+
107
+
108
+ @register("leftmost")
109
+ def leftmost(board: Board, piece: Piece) -> int:
110
+ for c in range(board.cols):
111
+ if board.is_column_playable(c):
112
+ return c
113
+ raise ValueError("Board appears full")
114
+ ```
115
+
116
+ The pieces that make it a bot:
117
+
118
+ - `from . import register` — pulls in the registration helper from the `bot`
119
+ package.
120
+ - `@register("leftmost")` — registers the function under a name. Use a unique
121
+ name for yours, e.g. `@register("mybot")`.
122
+ - The function signature **must** be `(board: Board, piece: Piece) -> int`.
123
+ - It **returns a column number** (an `int`). It does *not* place the piece
124
+ itself — the game does that.
125
+
126
+ ### 2. What your function gets, and what it returns
127
+
128
+ - **`board`** — the current position. Read it to decide your move (see the
129
+ toolbox below). Treat it as read-only.
130
+ - **`piece`** — `Piece.RED` or `Piece.YELLOW`, whichever you are playing.
131
+ - **Return** — the column number to drop into, `0` to `board.cols - 1`. Pick a
132
+ column that still has room. If you return a full or out-of-range column the
133
+ server counts it as an illegal move, so check first.
134
+
135
+ ### 3. The board toolbox
136
+
137
+ These are the methods and values you'll use to inspect the position:
138
+
139
+ | Tool | What it gives you |
140
+ | --------------------------------- | ---------------------------------------------------------- |
141
+ | `board.cols`, `board.rows` | Board dimensions. |
142
+ | `board.win_length` | Pieces in a row needed to win (may not be 4!). |
143
+ | `board.is_column_playable(c)` | `True` if column `c` has room. |
144
+ | `board.get_next_open_row(c)` | Row a new piece in column `c` would land on (`None` if full). |
145
+ | `board[c][r]` | The `Piece` at column `c`, row `r` (`Piece.EMPTY` if empty). Row 0 is the bottom. |
146
+ | `board.check_win(piece)` | `True` if `piece` already has a winning line. |
147
+ | `board.is_full()` | `True` if no moves remain. |
148
+ | `piece.opponent()` | The other player's piece. |
149
+ | `board.copy()` | A separate copy you can experiment on. |
150
+ | `board.move_piece(c, piece)` | Drops `piece` into column `c` **on that board**. |
151
+
152
+ The last two are how you can *try out* a move without disturbing the real game:
153
+ copy the board, call `move_piece` on the copy, and inspect the result (for
154
+ example with `check_win`). The real `board` the game hands you is unchanged.
155
+
156
+ ### 4. Run and test your bot
157
+
158
+ Once `bot/mybot.py` exists, use it like any other bot:
159
+
160
+ ```bash
161
+ python3 local.py --red mybot --yellow leftmost
162
+ python3 local.py --red mybot --yellow rand --cols 5 --rows 4 --win-length 3
163
+ ```
164
+
165
+ You can also call the function directly to test specific situations. Set up a
166
+ position with `move_piece`, then assert your bot picks the column you expect:
167
+
168
+ ```python
169
+ import unittest
170
+ from game import Board, Piece
171
+ import bot
172
+
173
+
174
+ class TestMyBot(unittest.TestCase):
175
+ def test_picks_a_playable_column(self):
176
+ mybot = bot.strict_lookup("mybot") # raises if "mybot" never registered
177
+ board = Board()
178
+ board.move_piece(3, Piece.RED) # set up any position you like
179
+ choice = mybot(board, Piece.YELLOW)
180
+ self.assertTrue(board.is_column_playable(choice))
181
+
182
+
183
+ if __name__ == "__main__":
184
+ unittest.main()
185
+ ```
186
+
187
+ Run your tests with:
188
+
189
+ ```bash
190
+ python3 -m unittest discover -p "test_*.py"
191
+ ```
192
+
193
+ ### 5. Now it's your turn
194
+
195
+ The example bots never look at where the pieces are — that's the bar to clear.
196
+ Some questions to get you thinking (no code provided on purpose):
197
+
198
+ - Can your bot notice when *it* has a move that wins right now, and take it?
199
+ - Can it notice when the *opponent* is about to win, and block?
200
+ - Which column is worth more when nothing urgent is happening?
201
+ - How would you handle a board whose `win_length` isn't 4?
202
+
203
+ Start small, play it against `leftmost` and `rand`, and grow it from there.
jkh_c4-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joseph Anttila Hall
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jkh_c4-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,221 @@
1
+ Metadata-Version: 2.4
2
+ Name: jkh-c4
3
+ Version: 0.0.1
4
+ Summary: Connect Four with local play and a networked server/clients.
5
+ Author-email: Joseph Anttila Hall <joseph.hall@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: connect-four,game,asyncio,teaching
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: Topic :: Games/Entertainment :: Board Games
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # Getting Started
20
+
21
+ A terminal Connect Four game in Python. This guide gets you playing locally and
22
+ shows the **mechanics** of writing your own bot. (For playing over a network
23
+ with a server and clients, see [NETWORKED_PLAY.md](NETWORKED_PLAY.md).)
24
+
25
+ ---
26
+
27
+ ## Requirements
28
+
29
+ - **Python 3.13 or newer** — check with `python3 --version`.
30
+ - **No packages to install** — it uses only the standard library.
31
+
32
+ Run all commands from the project folder (the one with `local.py` and the
33
+ `bot/` directory). If `python3` isn't found, try `python`.
34
+
35
+ ---
36
+
37
+ ## Play a local game
38
+
39
+ Local play runs everything in one program. Start a human-vs-human game:
40
+
41
+ ```bash
42
+ python3 local.py
43
+ ```
44
+
45
+ You'll enter a name, then see the board:
46
+
47
+ ```
48
+  0 1 2 3 4 5 6
49
+ +-+-+-+-+-+-+-+
50
+ | | | | | | | |
51
+ | | | | | | | |
52
+ | | | | | | | |
53
+ | | | | | | | |
54
+ | | | | | | | |
55
+ | | | | | | | |
56
+ +-+-+-+-+-+-+-+
57
+ ```
58
+
59
+ The numbers on top are **column numbers, starting at 0**. On your turn, type a
60
+ column number and press Enter to drop your piece; it falls to the lowest open
61
+ slot. Get `win_length` pieces in a row (horizontal, vertical, or diagonal) to
62
+ win. **Ctrl-C** quits.
63
+
64
+ ### Choosing players
65
+
66
+ RED always moves first, then YELLOW. Use `--red` and `--yellow` to set each
67
+ color to either `human` or a bot name:
68
+
69
+ ```bash
70
+ python3 local.py # human vs human (default)
71
+ python3 local.py --yellow leftmost # you (RED) vs leftmost bot
72
+ python3 local.py --red leftmost --yellow rightmost # bot vs bot
73
+ ```
74
+
75
+ The bots included so far are deliberately simple — they're your competition to
76
+ beat:
77
+
78
+ | Name | Strategy |
79
+ | ----------- | ------------------------------------------------------------------- |
80
+ | `leftmost` | Plays the left-most column with room. |
81
+ | `rightmost` | Plays the right-most column with room. |
82
+ | `rand` | Plays a random column with room. |
83
+ | `invalid` | Always plays an out-of-bounds column (for testing error handling). |
84
+
85
+ ### Changing the board and win condition
86
+
87
+ | Option | Default | Meaning |
88
+ | -------------- | ------- | ----------------------------- |
89
+ | `--cols` | `7` | Board width |
90
+ | `--rows` | `6` | Board height |
91
+ | `--win-length` | `4` | Pieces in a row needed to win |
92
+
93
+ ```bash
94
+ # Same as the default:
95
+ python3 local.py --cols 7 --rows 6 --win-length 4
96
+
97
+ # A small, fast "three in a row" board — handy while testing a bot:
98
+ python3 local.py --cols 5 --rows 4 --win-length 3 --red leftmost --yellow rand
99
+ ```
100
+
101
+ `win_length` must be at least 1 and can't exceed *both* the width and height
102
+ (otherwise no win is possible); the game refuses impossible combinations.
103
+
104
+ ---
105
+
106
+ ## Write your own bot
107
+
108
+ A bot is just **one function** that, given the current board and which piece
109
+ you're playing, returns the **column number to play**. The rest of this section
110
+ is the plumbing; the *strategy* is up to you.
111
+
112
+ ### 1. Create a file in `bot/`
113
+
114
+ Add a file `bot/<name>.py`. Any file you drop in the `bot/` folder is picked up
115
+ automatically when the program starts (except files beginning with `_` or
116
+ `test_`). The name you `register` is the name you'll use on the command line.
117
+
118
+ Here is the entire `leftmost` bot (`bot/leftmost.py`) — the simplest complete
119
+ example to model yours on:
120
+
121
+ ```python
122
+ from game import Board, Piece
123
+ from . import register
124
+
125
+
126
+ @register("leftmost")
127
+ def leftmost(board: Board, piece: Piece) -> int:
128
+ for c in range(board.cols):
129
+ if board.is_column_playable(c):
130
+ return c
131
+ raise ValueError("Board appears full")
132
+ ```
133
+
134
+ The pieces that make it a bot:
135
+
136
+ - `from . import register` — pulls in the registration helper from the `bot`
137
+ package.
138
+ - `@register("leftmost")` — registers the function under a name. Use a unique
139
+ name for yours, e.g. `@register("mybot")`.
140
+ - The function signature **must** be `(board: Board, piece: Piece) -> int`.
141
+ - It **returns a column number** (an `int`). It does *not* place the piece
142
+ itself — the game does that.
143
+
144
+ ### 2. What your function gets, and what it returns
145
+
146
+ - **`board`** — the current position. Read it to decide your move (see the
147
+ toolbox below). Treat it as read-only.
148
+ - **`piece`** — `Piece.RED` or `Piece.YELLOW`, whichever you are playing.
149
+ - **Return** — the column number to drop into, `0` to `board.cols - 1`. Pick a
150
+ column that still has room. If you return a full or out-of-range column the
151
+ server counts it as an illegal move, so check first.
152
+
153
+ ### 3. The board toolbox
154
+
155
+ These are the methods and values you'll use to inspect the position:
156
+
157
+ | Tool | What it gives you |
158
+ | --------------------------------- | ---------------------------------------------------------- |
159
+ | `board.cols`, `board.rows` | Board dimensions. |
160
+ | `board.win_length` | Pieces in a row needed to win (may not be 4!). |
161
+ | `board.is_column_playable(c)` | `True` if column `c` has room. |
162
+ | `board.get_next_open_row(c)` | Row a new piece in column `c` would land on (`None` if full). |
163
+ | `board[c][r]` | The `Piece` at column `c`, row `r` (`Piece.EMPTY` if empty). Row 0 is the bottom. |
164
+ | `board.check_win(piece)` | `True` if `piece` already has a winning line. |
165
+ | `board.is_full()` | `True` if no moves remain. |
166
+ | `piece.opponent()` | The other player's piece. |
167
+ | `board.copy()` | A separate copy you can experiment on. |
168
+ | `board.move_piece(c, piece)` | Drops `piece` into column `c` **on that board**. |
169
+
170
+ The last two are how you can *try out* a move without disturbing the real game:
171
+ copy the board, call `move_piece` on the copy, and inspect the result (for
172
+ example with `check_win`). The real `board` the game hands you is unchanged.
173
+
174
+ ### 4. Run and test your bot
175
+
176
+ Once `bot/mybot.py` exists, use it like any other bot:
177
+
178
+ ```bash
179
+ python3 local.py --red mybot --yellow leftmost
180
+ python3 local.py --red mybot --yellow rand --cols 5 --rows 4 --win-length 3
181
+ ```
182
+
183
+ You can also call the function directly to test specific situations. Set up a
184
+ position with `move_piece`, then assert your bot picks the column you expect:
185
+
186
+ ```python
187
+ import unittest
188
+ from game import Board, Piece
189
+ import bot
190
+
191
+
192
+ class TestMyBot(unittest.TestCase):
193
+ def test_picks_a_playable_column(self):
194
+ mybot = bot.strict_lookup("mybot") # raises if "mybot" never registered
195
+ board = Board()
196
+ board.move_piece(3, Piece.RED) # set up any position you like
197
+ choice = mybot(board, Piece.YELLOW)
198
+ self.assertTrue(board.is_column_playable(choice))
199
+
200
+
201
+ if __name__ == "__main__":
202
+ unittest.main()
203
+ ```
204
+
205
+ Run your tests with:
206
+
207
+ ```bash
208
+ python3 -m unittest discover -p "test_*.py"
209
+ ```
210
+
211
+ ### 5. Now it's your turn
212
+
213
+ The example bots never look at where the pieces are — that's the bar to clear.
214
+ Some questions to get you thinking (no code provided on purpose):
215
+
216
+ - Can your bot notice when *it* has a move that wins right now, and take it?
217
+ - Can it notice when the *opponent* is about to win, and block?
218
+ - Which column is worth more when nothing urgent is happening?
219
+ - How would you handle a board whose `win_length` isn't 4?
220
+
221
+ Start small, play it against `leftmost` and `rand`, and grow it from there.
@@ -0,0 +1,57 @@
1
+ import importlib
2
+ import pkgutil
3
+ import sys
4
+ from typing import Callable
5
+
6
+ from game import Board, Piece
7
+
8
+ _BOTS: dict[str, Callable[[Board, Piece], int]] = {}
9
+
10
+
11
+ def register(name: str) -> Callable:
12
+ def decorator(fn: Callable[[Board, Piece], int]) -> Callable[[Board, Piece], int]:
13
+ if name in _BOTS:
14
+ raise ValueError(
15
+ f"Bot name {name!r} is already registered by "
16
+ f"{_BOTS[name].__module__}; choose a different name.")
17
+ _BOTS[name] = fn
18
+ return fn
19
+ return decorator
20
+
21
+
22
+ def lookup(name: str) -> Callable[[Board, Piece], int] | None:
23
+ """Return the bot registered under name, or None if there is none.
24
+
25
+ The lenient counterpart to strict_lookup() (which raises KeyError). Use it
26
+ where the name might legitimately be unknown, e.g. validating user input.
27
+ """
28
+ return _BOTS.get(name)
29
+
30
+
31
+ def strict_lookup(name: str) -> Callable[[Board, Piece], int]:
32
+ """Return the bot registered under name, raising KeyError if there is none.
33
+
34
+ The strict counterpart to lookup() (which returns None). Use it where the
35
+ name is already known valid and its absence would be a bug, e.g. in tests.
36
+ """
37
+ return _BOTS[name]
38
+
39
+
40
+ def _load_bot_module(name: str) -> None:
41
+ """Import one bot module, skipping it (with a warning) if it can't load.
42
+
43
+ A syntax error or a duplicate name in one student's bot file must not
44
+ break the whole package — and with it local.py, client.py, and server.py.
45
+ """
46
+ try:
47
+ importlib.import_module(f".{name}", package=__name__)
48
+ except Exception as exc:
49
+ print(f"⚠️ Skipping bot module {name!r}: {exc}", file=sys.stderr)
50
+
51
+
52
+ for _mod_info in pkgutil.iter_modules(__path__):
53
+ if not _mod_info.name.startswith(("_", "test_")):
54
+ _load_bot_module(_mod_info.name)
55
+
56
+ # Don't expose bots as module attributes: one named after a framework symbol
57
+ # (e.g. "lookup") would shadow it.
@@ -0,0 +1,7 @@
1
+ from game import Board, Piece
2
+ from . import register
3
+
4
+
5
+ @register("invalid")
6
+ def invalid(board: Board, piece: Piece) -> int:
7
+ return board.cols
@@ -0,0 +1,10 @@
1
+ from game import Board, Piece
2
+ from . import register
3
+
4
+
5
+ @register("leftmost")
6
+ def leftmost(board: Board, piece: Piece) -> int:
7
+ for c in range(board.cols):
8
+ if board.is_column_playable(c):
9
+ return c
10
+ raise ValueError("Board appears full")
@@ -0,0 +1,12 @@
1
+ import random
2
+
3
+ from game import Board, Piece
4
+ from . import register
5
+
6
+
7
+ @register("rand")
8
+ def rand(board: Board, piece: Piece) -> int:
9
+ choices = [c for c in range(board.cols) if board.is_column_playable(c)]
10
+ if not choices:
11
+ raise ValueError("Board appears full")
12
+ return random.choice(choices)
@@ -0,0 +1,10 @@
1
+ from game import Board, Piece
2
+ from . import register
3
+
4
+
5
+ @register("rightmost")
6
+ def rightmost(board: Board, piece: Piece) -> int:
7
+ for c in reversed(range(board.cols)):
8
+ if board.is_column_playable(c):
9
+ return c
10
+ raise ValueError("Board appears full")