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 +0 -0
- polyclash/cli.py +270 -0
- polyclash/data/__init__.py +0 -0
- polyclash/data/data.py +1535 -0
- polyclash/game/__init__.py +0 -0
- polyclash/game/board.py +418 -0
- polyclash/game/record.py +100 -0
- polyclash/py.typed +1 -0
- polyclash/server.py +757 -0
- polyclash/util/__init__.py +0 -0
- polyclash/util/api.py +211 -0
- polyclash/util/auth.py +216 -0
- polyclash/util/logging.py +44 -0
- polyclash/util/storage.py +498 -0
- polyclash/web/css/lobby.css +251 -0
- polyclash/web/css/style.css +259 -0
- polyclash/web/data/board.json +1 -0
- polyclash/web/index.html +88 -0
- polyclash/web/js/board-renderer.js +414 -0
- polyclash/web/js/game-client.js +660 -0
- polyclash/web/js/i18n.js +294 -0
- polyclash/web/js/light-rules.js +216 -0
- polyclash/web/js/lobby.js +357 -0
- polyclash/web/js/main.js +122 -0
- polyclash/web/lobby.html +74 -0
- polyclash/web/vendor/LICENSES.md +15 -0
- polyclash/web/vendor/OrbitControls.js +1045 -0
- polyclash/web/vendor/socket.io.min.js +7 -0
- polyclash/web/vendor/three.min.js +6 -0
- polyclash-0.1.0.dist-info/METADATA +186 -0
- polyclash-0.1.0.dist-info/RECORD +35 -0
- polyclash-0.1.0.dist-info/WHEEL +5 -0
- polyclash-0.1.0.dist-info/entry_points.txt +2 -0
- polyclash-0.1.0.dist-info/licenses/LICENSE +21 -0
- polyclash-0.1.0.dist-info/top_level.txt +1 -0
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
|