polyclash 0.1.0__py3-none-any.whl

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.
polyclash/__init__.py ADDED
File without changes
polyclash/cli.py ADDED
@@ -0,0 +1,270 @@
1
+ """Unified CLI for PolyClash — solo, family, serve."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import secrets
8
+ import socket
9
+ import webbrowser
10
+ from threading import Timer
11
+ from typing import Optional
12
+
13
+
14
+ def _get_lan_ip() -> str:
15
+ """Return the LAN IP address of this machine."""
16
+ try:
17
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
18
+ s.connect(("8.8.8.8", 80))
19
+ ip: str = s.getsockname()[0]
20
+ s.close()
21
+ return ip
22
+ except Exception:
23
+ return "localhost"
24
+
25
+
26
+ def main() -> None:
27
+ parser = argparse.ArgumentParser(
28
+ prog="polyclash",
29
+ description="PolyClash: Go on a Spherical Universe",
30
+ )
31
+ sub = parser.add_subparsers(dest="command")
32
+
33
+ # --- polyclash solo ---
34
+ solo_parser = sub.add_parser("solo", help="Solo play: human vs AI")
35
+ solo_parser.add_argument(
36
+ "--side",
37
+ choices=["black", "white"],
38
+ default="black",
39
+ help="Your color (default: black)",
40
+ )
41
+ solo_parser.add_argument("--port", type=int, default=3302)
42
+
43
+ # --- polyclash family ---
44
+ family_parser = sub.add_parser("family", help="Family game on LAN")
45
+ family_parser.add_argument("--port", type=int, default=3302)
46
+ family_parser.add_argument(
47
+ "--black",
48
+ choices=["human", "ai"],
49
+ default="human",
50
+ help="Who controls black (default: human)",
51
+ )
52
+ family_parser.add_argument(
53
+ "--white",
54
+ choices=["human", "ai"],
55
+ default="human",
56
+ help="Who controls white (default: human)",
57
+ )
58
+
59
+ # --- polyclash serve (deployment) ---
60
+ serve_parser = sub.add_parser("serve", help="Start server for deployment")
61
+ serve_parser.add_argument("--host", default="0.0.0.0")
62
+ serve_parser.add_argument(
63
+ "--port", type=int, default=int(os.environ.get("PORT", 3302))
64
+ )
65
+ serve_parser.add_argument(
66
+ "--no-auth", action="store_true", help="Disable server token requirement"
67
+ )
68
+ serve_parser.add_argument(
69
+ "--token", default=None, help="Set server token (default: auto-generated)"
70
+ )
71
+
72
+ # --- polyclash team (self-hosted team server) ---
73
+ team_parser = sub.add_parser(
74
+ "team", help="Self-hosted team server with user accounts"
75
+ )
76
+ team_parser.add_argument("--host", default="0.0.0.0")
77
+ team_parser.add_argument(
78
+ "--port", type=int, default=int(os.environ.get("PORT", 3302))
79
+ )
80
+ team_parser.add_argument(
81
+ "--rooms",
82
+ type=int,
83
+ default=int(os.environ.get("POLYCLASH_MAX_ROOMS", "8")),
84
+ help="Max simultaneous games (default: 8, env: POLYCLASH_MAX_ROOMS)",
85
+ )
86
+ team_parser.add_argument(
87
+ "--admin-user",
88
+ default=os.environ.get("POLYCLASH_ADMIN_USER", "admin"),
89
+ help="Admin username (default: admin, env: POLYCLASH_ADMIN_USER)",
90
+ )
91
+ team_parser.add_argument(
92
+ "--admin-pass",
93
+ default=os.environ.get("POLYCLASH_ADMIN_PASS"),
94
+ help="Admin password (auto-generated if omitted, env: POLYCLASH_ADMIN_PASS)",
95
+ )
96
+ team_parser.add_argument(
97
+ "--invites",
98
+ type=int,
99
+ default=int(os.environ.get("POLYCLASH_INVITES", "5")),
100
+ help="Initial invite codes to generate (default: 5, env: POLYCLASH_INVITES)",
101
+ )
102
+ team_parser.add_argument(
103
+ "--db",
104
+ default=os.environ.get("POLYCLASH_AUTH_DB"),
105
+ help="Path to SQLite user database (env: POLYCLASH_AUTH_DB)",
106
+ )
107
+
108
+ args = parser.parse_args()
109
+
110
+ if args.command == "solo":
111
+ _run_solo(args.port, args.side)
112
+ elif args.command == "family":
113
+ _run_family(args.port, args.black, args.white)
114
+ elif args.command == "serve":
115
+ _run_serve(args.host, args.port, args.no_auth, args.token)
116
+ elif args.command == "team":
117
+ _run_team(
118
+ args.host,
119
+ args.port,
120
+ args.rooms,
121
+ args.admin_user,
122
+ args.admin_pass,
123
+ args.invites,
124
+ args.db,
125
+ )
126
+ else:
127
+ parser.print_help()
128
+
129
+
130
+ def _run_solo(port: int, side: str = "black") -> None:
131
+ """Start server in solo mode and open browser."""
132
+ token = secrets.token_hex(16)
133
+ os.environ["POLYCLASH_SERVER_TOKEN"] = token
134
+ os.environ["POLYCLASH_SOLO_MODE"] = "1"
135
+ os.environ["POLYCLASH_SIDE"] = side
136
+
137
+ from polyclash.util.logging import logger
138
+
139
+ logger.info(f"Solo mode on port {port}, playing as {side}")
140
+
141
+ url = f"http://localhost:{port}/?token={token}&side={side}"
142
+ Timer(1.5, lambda: webbrowser.open(url)).start()
143
+
144
+ from polyclash.server import app, socketio
145
+
146
+ socketio.run(
147
+ app, host="127.0.0.1", port=port, allow_unsafe_werkzeug=True, debug=False
148
+ )
149
+
150
+
151
+ def _run_family(port: int, black: str = "human", white: str = "human") -> None:
152
+ """Start server in family mode: create a game and print invite URLs."""
153
+ token = secrets.token_hex(16)
154
+ os.environ["POLYCLASH_SERVER_TOKEN"] = token
155
+
156
+ from polyclash.game.board import Board
157
+ from polyclash.server import app, boards, socketio, storage
158
+ from polyclash.util.logging import logger
159
+
160
+ # Pre-create a game room
161
+ data = storage.create_room()
162
+ game_id = data["game_id"]
163
+ board = Board()
164
+ board.disable_notification()
165
+ boards[game_id] = board
166
+
167
+ lan_ip = _get_lan_ip()
168
+ base = f"http://{lan_ip}:{port}"
169
+
170
+ black_ai = "&ai=1" if black == "ai" else ""
171
+ white_ai = "&ai=1" if white == "ai" else ""
172
+ black_url = f"{base}/?key={data['black_key']}{black_ai}"
173
+ white_url = f"{base}/?key={data['white_key']}{white_ai}"
174
+ viewer_url = f"{base}/?key={data['viewer_key']}"
175
+
176
+ black_label = "AI" if black == "ai" else "Human"
177
+ white_label = "AI" if white == "ai" else "Human"
178
+
179
+ logger.info("PolyClash 星逐 — Family Game")
180
+ logger.info(f" Black ({black_label}): {black_url}")
181
+ logger.info(f" White ({white_label}): {white_url}")
182
+ logger.info(f" Watch: {viewer_url}")
183
+
184
+ # Auto-open the first human side, or black if both are AI
185
+ if black == "human":
186
+ auto_url = black_url
187
+ elif white == "human":
188
+ auto_url = white_url
189
+ else:
190
+ auto_url = black_url
191
+ Timer(1.5, lambda: webbrowser.open(auto_url)).start()
192
+
193
+ socketio.run(
194
+ app, host="0.0.0.0", port=port, allow_unsafe_werkzeug=True, debug=False
195
+ )
196
+
197
+
198
+ def _run_team(
199
+ host: str,
200
+ port: int,
201
+ rooms: int,
202
+ admin_user: str,
203
+ admin_pass: Optional[str],
204
+ invites: int,
205
+ db: Optional[str],
206
+ ) -> None:
207
+ """Start server in team mode with user accounts and room limits."""
208
+ os.environ["POLYCLASH_TEAM_MODE"] = "1"
209
+ os.environ["POLYCLASH_MAX_ROOMS"] = str(rooms)
210
+ # Team mode uses lobby auth, not server token
211
+ token = secrets.token_hex(16)
212
+ os.environ["POLYCLASH_SERVER_TOKEN"] = token
213
+ if db:
214
+ os.environ["POLYCLASH_AUTH_DB"] = db
215
+
216
+ from polyclash.util.auth import UserStore
217
+ from polyclash.util.logging import logger
218
+
219
+ user_store = UserStore(db_path=db)
220
+
221
+ # Ensure admin account
222
+ if not admin_pass:
223
+ admin_pass = secrets.token_urlsafe(12)
224
+ user_store.ensure_admin(admin_user, admin_pass)
225
+
226
+ # Generate initial invite codes
227
+ codes = [user_store.create_invite(created_by=admin_user) for _ in range(invites)]
228
+
229
+ import polyclash.server as server_module
230
+ from polyclash.server import app, restore_boards, socketio
231
+
232
+ server_module._user_store = user_store
233
+ server_module.MAX_ROOMS = rooms
234
+ restore_boards()
235
+
236
+ lan_ip = _get_lan_ip()
237
+ logger.info("PolyClash 星逐 — Team Server")
238
+ logger.info(f" URL: http://{lan_ip}:{port}/")
239
+ logger.info(f" Max rooms: {rooms}")
240
+ logger.info(f" Admin: {admin_user} / {admin_pass}")
241
+ logger.info(f" Invite codes ({len(codes)}):")
242
+ for code in codes:
243
+ logger.info(f" {code}")
244
+
245
+ socketio.run(app, host=host, port=port, allow_unsafe_werkzeug=True, debug=False)
246
+
247
+
248
+ def _run_serve(host: str, port: int, no_auth: bool, token: Optional[str]) -> None:
249
+ """Start server for LAN/network play."""
250
+ if no_auth:
251
+ os.environ["POLYCLASH_NO_AUTH"] = "1"
252
+ if token:
253
+ os.environ["POLYCLASH_SERVER_TOKEN"] = token
254
+
255
+ from polyclash.util.logging import logger
256
+
257
+ logger.info(f"Serving on {host}:{port}")
258
+
259
+ from polyclash.server import app, restore_boards, server_token, socketio
260
+
261
+ restore_boards()
262
+
263
+ if not no_auth:
264
+ logger.info(f"Server token: {server_token}")
265
+
266
+ socketio.run(app, host=host, port=port, allow_unsafe_werkzeug=True, debug=False)
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
File without changes