island-battles-python 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.
- island_battles_client/__init__.py +89 -0
- island_battles_client/_entities.py +107 -0
- island_battles_client/_generated.py +870 -0
- island_battles_client/bot.py +189 -0
- island_battles_client/cli.py +539 -0
- island_battles_client/client.py +763 -0
- island_battles_client/errors.py +92 -0
- island_battles_client/fixture.py +88 -0
- island_battles_client/game_log.py +346 -0
- island_battles_client/models.py +33 -0
- island_battles_python-0.1.0.dist-info/METADATA +8 -0
- island_battles_python-0.1.0.dist-info/RECORD +14 -0
- island_battles_python-0.1.0.dist-info/WHEEL +4 -0
- island_battles_python-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""``island-battles-bot``: serve, check and run a bot function.
|
|
2
|
+
|
|
3
|
+
``serve`` is the bot's local HTTP server for a game client in the browser,
|
|
4
|
+
``check`` calls the bot once on a fixture turn, and ``run`` plays a seat
|
|
5
|
+
without a browser.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import importlib
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import sysconfig
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
import traceback
|
|
23
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from pydantic import ValidationError
|
|
29
|
+
|
|
30
|
+
from ._generated import BotTurn, GameOutcome, Generation
|
|
31
|
+
from .bot import MEMORY_LIMIT_BYTES, Play, call_play, run_turn
|
|
32
|
+
from .client import IslandBattlesClient
|
|
33
|
+
from .errors import BotMemoryTooLargeError, GameError, InvalidBotResultError
|
|
34
|
+
from .fixture import fixture_turn, fixture_turn_data
|
|
35
|
+
from .game_log import ClientLogging
|
|
36
|
+
|
|
37
|
+
DEFAULT_PORT = 41414
|
|
38
|
+
HOST = "127.0.0.1"
|
|
39
|
+
# The game clients that may relay turns by default: the local backend's lobby
|
|
40
|
+
# page and the web client's dev server, the deployed game, and the desktop app
|
|
41
|
+
# (#105), whose web view sends its own origin: `tauri://localhost` on macOS and
|
|
42
|
+
# Linux, `http://tauri.localhost` on Windows. Add more with --allow-origin.
|
|
43
|
+
DEFAULT_ORIGINS = (
|
|
44
|
+
"http://localhost:8888",
|
|
45
|
+
"http://localhost:3000",
|
|
46
|
+
"https://island-battles.elidan.org",
|
|
47
|
+
"tauri://localhost",
|
|
48
|
+
"http://tauri.localhost",
|
|
49
|
+
)
|
|
50
|
+
CHECK_SECONDS = 2.0
|
|
51
|
+
# The lobby's shortest turn timeout is 10 s; answer before the game moves on.
|
|
52
|
+
DEFAULT_TURN_TIMEOUT = 8.0
|
|
53
|
+
# A BotTurn of a busy 6-team generation with 64 KiB of memory is a few hundred
|
|
54
|
+
# KiB; 2 MiB leaves headroom.
|
|
55
|
+
MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
56
|
+
# A refused request's body is read (up to this much) so closing the socket
|
|
57
|
+
# doesn't reset the connection before the client reads the refusal.
|
|
58
|
+
DRAIN_BYTES = 64 * 1024
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_with_timeout[T](
|
|
62
|
+
make: Callable[[], Coroutine[Any, Any, T]], seconds: float
|
|
63
|
+
) -> T:
|
|
64
|
+
"""Run a coroutine to the end in a daemon thread, or raise ``TimeoutError``.
|
|
65
|
+
|
|
66
|
+
A bot that loops forever can't be stopped, but as the thread is a daemon
|
|
67
|
+
it never blocks the process from exiting.
|
|
68
|
+
"""
|
|
69
|
+
outcome: list[tuple[bool, Any]] = []
|
|
70
|
+
|
|
71
|
+
def target() -> None:
|
|
72
|
+
try:
|
|
73
|
+
outcome.append((True, asyncio.run(make())))
|
|
74
|
+
except BaseException as error: # noqa: BLE001 - re-raised in the caller
|
|
75
|
+
outcome.append((False, error))
|
|
76
|
+
|
|
77
|
+
worker = threading.Thread(target=target, daemon=True, name="bot-turn")
|
|
78
|
+
worker.start()
|
|
79
|
+
worker.join(seconds)
|
|
80
|
+
if not outcome:
|
|
81
|
+
raise TimeoutError
|
|
82
|
+
succeeded, value = outcome[0]
|
|
83
|
+
if not succeeded:
|
|
84
|
+
raise value
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_play(spec: str) -> Play:
|
|
89
|
+
"""Import ``module:function`` and return the function."""
|
|
90
|
+
module_name, separator, function_name = spec.partition(":")
|
|
91
|
+
if not separator or not module_name or not function_name:
|
|
92
|
+
raise ValueError(f"expected module:function, not {spec!r}")
|
|
93
|
+
if os.getcwd() not in sys.path:
|
|
94
|
+
sys.path.insert(0, os.getcwd())
|
|
95
|
+
module = importlib.import_module(module_name)
|
|
96
|
+
play = getattr(module, function_name, None)
|
|
97
|
+
if not callable(play):
|
|
98
|
+
raise TypeError(f"module {module_name} has no function {function_name}")
|
|
99
|
+
return play
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_installed(file: Path) -> bool:
|
|
103
|
+
"""Whether a module file is in the standard library or site-packages."""
|
|
104
|
+
resolved = file.resolve()
|
|
105
|
+
if {"site-packages", "dist-packages"} & set(resolved.parts):
|
|
106
|
+
return True
|
|
107
|
+
paths = sysconfig.get_paths()
|
|
108
|
+
return any(
|
|
109
|
+
resolved.is_relative_to(Path(paths[key]).resolve())
|
|
110
|
+
for key in ("stdlib", "platstdlib")
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ReloadingPlay:
|
|
115
|
+
"""A bot that is imported again when a ``.py`` file in its directory changes.
|
|
116
|
+
|
|
117
|
+
The check runs on each turn, so the next turn after a save uses the new
|
|
118
|
+
code and nothing runs while the server is idle.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, spec: str) -> None:
|
|
122
|
+
self.spec = spec
|
|
123
|
+
self._module_name = spec.partition(":")[0]
|
|
124
|
+
self._signature: tuple | None = None
|
|
125
|
+
self._play: Play | None = None
|
|
126
|
+
self._error: str | None = None
|
|
127
|
+
self._lock = threading.Lock()
|
|
128
|
+
|
|
129
|
+
def _directory(self) -> Path | None:
|
|
130
|
+
module = sys.modules.get(self._module_name)
|
|
131
|
+
file = getattr(module, "__file__", None)
|
|
132
|
+
if file is None:
|
|
133
|
+
try:
|
|
134
|
+
found = importlib.util.find_spec(self._module_name)
|
|
135
|
+
except ImportError, ValueError:
|
|
136
|
+
return None
|
|
137
|
+
file = found.origin if found else None
|
|
138
|
+
return Path(file).resolve().parent if file else None
|
|
139
|
+
|
|
140
|
+
def _current_signature(self) -> tuple:
|
|
141
|
+
directory = self._directory()
|
|
142
|
+
if directory is None:
|
|
143
|
+
return ()
|
|
144
|
+
signature = []
|
|
145
|
+
for path in sorted(directory.glob("*.py")):
|
|
146
|
+
stat = path.stat()
|
|
147
|
+
signature.append((path.name, stat.st_mtime_ns, stat.st_size))
|
|
148
|
+
return tuple(signature)
|
|
149
|
+
|
|
150
|
+
def _forget_modules(self) -> None:
|
|
151
|
+
"""Drop the bot module and the modules that sit beside it.
|
|
152
|
+
|
|
153
|
+
Only modules whose file is directly in the bot's directory are
|
|
154
|
+
dropped (never the standard library or site-packages, unless the bot
|
|
155
|
+
itself lives there), so the next import reads the changed files. If
|
|
156
|
+
the directory is a shared one, every module in it is imported again.
|
|
157
|
+
"""
|
|
158
|
+
directory = self._directory()
|
|
159
|
+
for name, module in list(sys.modules.items()):
|
|
160
|
+
file = getattr(module, "__file__", None)
|
|
161
|
+
if name == self._module_name or (
|
|
162
|
+
directory is not None
|
|
163
|
+
and file is not None
|
|
164
|
+
and not _is_installed(Path(file))
|
|
165
|
+
and Path(file).resolve().parent == directory
|
|
166
|
+
):
|
|
167
|
+
del sys.modules[name]
|
|
168
|
+
|
|
169
|
+
def get(self) -> Play:
|
|
170
|
+
"""The bot as of the files now; raises what importing it raised."""
|
|
171
|
+
with self._lock:
|
|
172
|
+
return self._get()
|
|
173
|
+
|
|
174
|
+
def _get(self) -> Play:
|
|
175
|
+
signature = self._current_signature()
|
|
176
|
+
first = self._signature is None
|
|
177
|
+
if first or signature != self._signature:
|
|
178
|
+
importlib.invalidate_caches()
|
|
179
|
+
if not first:
|
|
180
|
+
self._forget_modules()
|
|
181
|
+
self._signature = signature
|
|
182
|
+
try:
|
|
183
|
+
self._play = load_play(self.spec)
|
|
184
|
+
self._error = None
|
|
185
|
+
if not first:
|
|
186
|
+
print(f"reloaded {self.spec}", flush=True)
|
|
187
|
+
except Exception: # noqa: BLE001 - reported to the caller of /turn
|
|
188
|
+
self._play = None
|
|
189
|
+
self._error = traceback.format_exc()
|
|
190
|
+
print(f"could not load {self.spec}\n{self._error}", flush=True)
|
|
191
|
+
if self._play is None:
|
|
192
|
+
raise ImportError(f"could not load {self.spec}\n{self._error}")
|
|
193
|
+
return self._play
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class BotServer(ThreadingHTTPServer):
|
|
197
|
+
"""The bot's HTTP server, with its bot and the origins it answers."""
|
|
198
|
+
|
|
199
|
+
daemon_threads = True
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
address: tuple[str, int],
|
|
204
|
+
bot: ReloadingPlay,
|
|
205
|
+
origins: Sequence[str],
|
|
206
|
+
turn_timeout: float = DEFAULT_TURN_TIMEOUT,
|
|
207
|
+
) -> None:
|
|
208
|
+
super().__init__(address, BotRequestHandler)
|
|
209
|
+
self.bot = bot
|
|
210
|
+
self.turn_timeout = turn_timeout
|
|
211
|
+
self.origins = frozenset(origin.rstrip("/") for origin in origins)
|
|
212
|
+
self.client_logging = ClientLogging(self, logging.INFO, True, None)
|
|
213
|
+
self.client_logging.attach()
|
|
214
|
+
|
|
215
|
+
def server_close(self) -> None:
|
|
216
|
+
super().server_close()
|
|
217
|
+
self.client_logging.detach()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class BotRequestHandler(BaseHTTPRequestHandler):
|
|
221
|
+
"""``POST /turn``, ``GET /health`` and the CORS preflights for them.
|
|
222
|
+
|
|
223
|
+
One request per connection: HTTP/1.0 closes after each response, and every
|
|
224
|
+
refusal also sends ``Connection: close``. Never keep a connection after a
|
|
225
|
+
refusal, or the unread body of a cross-origin POST could be parsed as the
|
|
226
|
+
next request, without an Origin header.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
server: BotServer
|
|
230
|
+
protocol_version = "HTTP/1.0"
|
|
231
|
+
timeout = 10
|
|
232
|
+
_body_read = False
|
|
233
|
+
|
|
234
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
235
|
+
print(f"{self.address_string()} {format % args}", flush=True)
|
|
236
|
+
|
|
237
|
+
def _origin_allowed(self) -> bool | None:
|
|
238
|
+
"""None without an Origin header (curl), else whether the origin may relay."""
|
|
239
|
+
origin = self.headers.get("Origin")
|
|
240
|
+
if origin is None:
|
|
241
|
+
return None
|
|
242
|
+
return origin.rstrip("/") in self.server.origins
|
|
243
|
+
|
|
244
|
+
def _host_allowed(self) -> bool:
|
|
245
|
+
"""Only the bot's own address as the Host, against DNS rebinding."""
|
|
246
|
+
port = self.server.server_address[1]
|
|
247
|
+
return self.headers.get("Host") in (f"127.0.0.1:{port}", f"localhost:{port}")
|
|
248
|
+
|
|
249
|
+
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
|
250
|
+
if status >= 400:
|
|
251
|
+
self.close_connection = True
|
|
252
|
+
self.send_response(status)
|
|
253
|
+
self.send_header("Content-Type", content_type)
|
|
254
|
+
self.send_header("Content-Length", str(len(body)))
|
|
255
|
+
self.send_header("Connection", "close")
|
|
256
|
+
self.send_header("Vary", "Origin")
|
|
257
|
+
if self._origin_allowed() is True:
|
|
258
|
+
self.send_header("Access-Control-Allow-Origin", self.headers["Origin"])
|
|
259
|
+
self.end_headers()
|
|
260
|
+
self.wfile.write(body)
|
|
261
|
+
self.close_connection = True
|
|
262
|
+
|
|
263
|
+
def _text(self, status: int, text: str) -> None:
|
|
264
|
+
if status >= 400:
|
|
265
|
+
self._drain()
|
|
266
|
+
self._send(status, text.encode("utf-8", "replace"), "text/plain; charset=utf-8")
|
|
267
|
+
|
|
268
|
+
def _drain(self) -> None:
|
|
269
|
+
"""Discard a small request body, so closing doesn't reset the connection."""
|
|
270
|
+
self.close_connection = True
|
|
271
|
+
if self._body_read:
|
|
272
|
+
return
|
|
273
|
+
try:
|
|
274
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
275
|
+
if 0 < length <= DRAIN_BYTES:
|
|
276
|
+
self.rfile.read(length)
|
|
277
|
+
except ValueError, OSError:
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
def _refuse(self) -> bool:
|
|
281
|
+
"""Refuse a request for another Host or from an origin that may not relay."""
|
|
282
|
+
if not self._host_allowed():
|
|
283
|
+
self._text(
|
|
284
|
+
403,
|
|
285
|
+
f"Host {self.headers.get('Host')} is not allowed; "
|
|
286
|
+
f"use 127.0.0.1:{self.server.server_address[1]} or "
|
|
287
|
+
f"localhost:{self.server.server_address[1]}\n",
|
|
288
|
+
)
|
|
289
|
+
return True
|
|
290
|
+
if self._origin_allowed() is False:
|
|
291
|
+
self._text(
|
|
292
|
+
403,
|
|
293
|
+
f"origin {self.headers['Origin']} is not allowed; "
|
|
294
|
+
"start the server with --allow-origin to add it\n",
|
|
295
|
+
)
|
|
296
|
+
return True
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
def do_OPTIONS(self) -> None:
|
|
300
|
+
"""Answer a preflight for an allowed origin, with Private Network Access."""
|
|
301
|
+
if not self._host_allowed() or self._origin_allowed() is not True:
|
|
302
|
+
self._text(403, "origin or host not allowed\n")
|
|
303
|
+
return
|
|
304
|
+
self.close_connection = True
|
|
305
|
+
self.send_response(204)
|
|
306
|
+
self.send_header("Access-Control-Allow-Origin", self.headers["Origin"])
|
|
307
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
308
|
+
self.send_header(
|
|
309
|
+
"Access-Control-Allow-Headers",
|
|
310
|
+
self.headers.get("Access-Control-Request-Headers", "content-type"),
|
|
311
|
+
)
|
|
312
|
+
self.send_header("Access-Control-Allow-Private-Network", "true")
|
|
313
|
+
self.send_header("Access-Control-Max-Age", "600")
|
|
314
|
+
self.send_header("Vary", "Origin")
|
|
315
|
+
self.send_header("Connection", "close")
|
|
316
|
+
self.send_header("Content-Length", "0")
|
|
317
|
+
self.end_headers()
|
|
318
|
+
|
|
319
|
+
def do_GET(self) -> None:
|
|
320
|
+
"""``/health`` says the bot is there."""
|
|
321
|
+
if self._refuse():
|
|
322
|
+
return
|
|
323
|
+
if self.path != "/health":
|
|
324
|
+
self._text(404, "not found\n")
|
|
325
|
+
return
|
|
326
|
+
body = json.dumps({"name": self.server.bot.spec, "ok": True}).encode()
|
|
327
|
+
self._send(200, body, "application/json")
|
|
328
|
+
|
|
329
|
+
def do_POST(self) -> None:
|
|
330
|
+
"""``/turn`` plays a ``BotTurn`` and answers its ``BotTurnResult``."""
|
|
331
|
+
if self._refuse():
|
|
332
|
+
return
|
|
333
|
+
if self.path != "/turn":
|
|
334
|
+
self._text(404, "not found\n")
|
|
335
|
+
return
|
|
336
|
+
media_type = self.headers.get("Content-Type", "").partition(";")[0]
|
|
337
|
+
if media_type.strip().lower() != "application/json":
|
|
338
|
+
self._text(415, "Content-Type must be application/json\n")
|
|
339
|
+
return
|
|
340
|
+
try:
|
|
341
|
+
length = int(self.headers.get("Content-Length", ""))
|
|
342
|
+
except ValueError:
|
|
343
|
+
self._text(411, "Content-Length is required\n")
|
|
344
|
+
return
|
|
345
|
+
if not 0 <= length <= MAX_BODY_BYTES:
|
|
346
|
+
self._text(413, "request body is too large\n")
|
|
347
|
+
return
|
|
348
|
+
raw = self.rfile.read(length)
|
|
349
|
+
self._body_read = True
|
|
350
|
+
try:
|
|
351
|
+
turn = BotTurn.model_validate_json(raw)
|
|
352
|
+
except ValidationError as error:
|
|
353
|
+
self._text(422, f"the body is not a BotTurn:\n{error}\n")
|
|
354
|
+
return
|
|
355
|
+
try:
|
|
356
|
+
play = self.server.bot.get()
|
|
357
|
+
result = run_with_timeout(
|
|
358
|
+
lambda: run_turn(play, turn, self.server.client_logging),
|
|
359
|
+
self.server.turn_timeout,
|
|
360
|
+
)
|
|
361
|
+
except TimeoutError:
|
|
362
|
+
self._text(
|
|
363
|
+
504,
|
|
364
|
+
f"the bot took longer than {self.server.turn_timeout:g} s on this "
|
|
365
|
+
"turn; it may be in an endless loop. Fix it and save; the "
|
|
366
|
+
"server keeps serving.\n",
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
except Exception: # noqa: BLE001 - the relaying client shows the traceback
|
|
370
|
+
self._text(500, traceback.format_exc())
|
|
371
|
+
return
|
|
372
|
+
body = result.model_dump_json(by_alias=True).encode()
|
|
373
|
+
self._send(200, body, "application/json")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def serve(args: argparse.Namespace) -> int:
|
|
377
|
+
"""Run the local bot server until interrupted."""
|
|
378
|
+
bot = ReloadingPlay(args.bot)
|
|
379
|
+
# A broken bot is reported on load and on each turn; fix it while serving.
|
|
380
|
+
with contextlib.suppress(Exception):
|
|
381
|
+
bot.get()
|
|
382
|
+
server = BotServer(
|
|
383
|
+
(HOST, args.port),
|
|
384
|
+
bot,
|
|
385
|
+
[*DEFAULT_ORIGINS, *args.allow_origin],
|
|
386
|
+
args.turn_timeout,
|
|
387
|
+
)
|
|
388
|
+
print(
|
|
389
|
+
f"serving {args.bot} on http://{HOST}:{args.port} "
|
|
390
|
+
f"for {', '.join(sorted(server.origins))}",
|
|
391
|
+
flush=True,
|
|
392
|
+
)
|
|
393
|
+
try:
|
|
394
|
+
server.serve_forever()
|
|
395
|
+
except KeyboardInterrupt:
|
|
396
|
+
pass
|
|
397
|
+
finally:
|
|
398
|
+
server.server_close()
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def fail(message: str) -> int:
|
|
403
|
+
"""Print a failure and return the exit status."""
|
|
404
|
+
print(f"FAIL: {message}", file=sys.stderr)
|
|
405
|
+
return 1
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def check(args: argparse.Namespace) -> int:
|
|
409
|
+
"""Call the bot once on a fixture turn and report what is wrong with it."""
|
|
410
|
+
turn = (
|
|
411
|
+
BotTurn.model_validate_json(Path(args.turn).read_text())
|
|
412
|
+
if args.turn
|
|
413
|
+
else fixture_turn()
|
|
414
|
+
)
|
|
415
|
+
try:
|
|
416
|
+
play = load_play(args.bot)
|
|
417
|
+
except Exception: # noqa: BLE001
|
|
418
|
+
return fail(f"could not load {args.bot}:\n{traceback.format_exc()}")
|
|
419
|
+
started = time.monotonic()
|
|
420
|
+
try:
|
|
421
|
+
result = run_with_timeout(lambda: call_play(play, turn), CHECK_SECONDS)
|
|
422
|
+
except TimeoutError:
|
|
423
|
+
# The bot's thread can't be stopped, and a daemon thread would still
|
|
424
|
+
# be joined by a normal exit path in some cases; leave at once.
|
|
425
|
+
sys.stderr.write(
|
|
426
|
+
f"FAIL: the bot took longer than {CHECK_SECONDS:g} s on one turn\n"
|
|
427
|
+
)
|
|
428
|
+
sys.stderr.flush()
|
|
429
|
+
os._exit(1)
|
|
430
|
+
except BotMemoryTooLargeError as error:
|
|
431
|
+
return fail(f"memory is too large: {error.detail}")
|
|
432
|
+
except InvalidBotResultError as error:
|
|
433
|
+
return fail(f"the bot returned an invalid result: {error.detail}")
|
|
434
|
+
except Exception: # noqa: BLE001
|
|
435
|
+
return fail(f"the bot raised an exception:\n{traceback.format_exc()}")
|
|
436
|
+
elapsed = time.monotonic() - started
|
|
437
|
+
memory_size = len(
|
|
438
|
+
json.dumps(result.memory, separators=(",", ":"), ensure_ascii=False).encode(
|
|
439
|
+
"utf-8", "surrogatepass"
|
|
440
|
+
)
|
|
441
|
+
)
|
|
442
|
+
print(
|
|
443
|
+
f"ok: {len(result.commands)} commands, {len(result.logs)} log lines, "
|
|
444
|
+
f"memory {memory_size} of {MEMORY_LIMIT_BYTES} bytes, {elapsed:.2f} s"
|
|
445
|
+
)
|
|
446
|
+
return 0
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def run(args: argparse.Namespace) -> int:
|
|
450
|
+
"""Play one seat of a lobby's game to its end, without a browser."""
|
|
451
|
+
play = load_play(args.bot)
|
|
452
|
+
|
|
453
|
+
def ended(outcome: GameOutcome, generation: Generation) -> None:
|
|
454
|
+
print(f"game ended at generation {generation.id}: {outcome.summary}")
|
|
455
|
+
|
|
456
|
+
async def main() -> None:
|
|
457
|
+
client = IslandBattlesClient(
|
|
458
|
+
args.base_url,
|
|
459
|
+
args.token,
|
|
460
|
+
args.lobby_id,
|
|
461
|
+
play=play,
|
|
462
|
+
on_game_ended=ended,
|
|
463
|
+
log_level=getattr(logging, args.log_level.upper()),
|
|
464
|
+
log_file=args.log_file,
|
|
465
|
+
)
|
|
466
|
+
await client.run()
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
asyncio.run(main())
|
|
470
|
+
except GameError as error:
|
|
471
|
+
return fail(str(error))
|
|
472
|
+
return 0
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def print_fixture(_: argparse.Namespace) -> int:
|
|
476
|
+
"""Print the fixture turn that ``check`` uses, for ``curl -d @file``."""
|
|
477
|
+
print(json.dumps(fixture_turn_data(), indent=2))
|
|
478
|
+
return 0
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
482
|
+
"""The command line of ``island-battles-bot``."""
|
|
483
|
+
parser = argparse.ArgumentParser(prog="island-battles-bot", description=__doc__)
|
|
484
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
485
|
+
|
|
486
|
+
serving = commands.add_parser(
|
|
487
|
+
"serve", help=f"serve the bot on {HOST}:{DEFAULT_PORT} for a game client"
|
|
488
|
+
)
|
|
489
|
+
serving.add_argument("bot", help="module:function, for example mybot:play")
|
|
490
|
+
serving.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
491
|
+
serving.add_argument(
|
|
492
|
+
"--allow-origin",
|
|
493
|
+
action="append",
|
|
494
|
+
default=[],
|
|
495
|
+
metavar="ORIGIN",
|
|
496
|
+
help="also answer this browser origin, such as https://game.example.org "
|
|
497
|
+
f"(repeatable; always allowed: {', '.join(DEFAULT_ORIGINS)})",
|
|
498
|
+
)
|
|
499
|
+
serving.add_argument(
|
|
500
|
+
"--turn-timeout",
|
|
501
|
+
type=float,
|
|
502
|
+
default=DEFAULT_TURN_TIMEOUT,
|
|
503
|
+
metavar="SECONDS",
|
|
504
|
+
help="answer 504 when a turn takes longer than this (default "
|
|
505
|
+
f"{DEFAULT_TURN_TIMEOUT:g}, under the lobby's shortest turn timeout of 10)",
|
|
506
|
+
)
|
|
507
|
+
serving.set_defaults(handler=serve)
|
|
508
|
+
|
|
509
|
+
checking = commands.add_parser("check", help="call the bot once on a fixture turn")
|
|
510
|
+
checking.add_argument("bot", help="module:function")
|
|
511
|
+
checking.add_argument("--turn", help="a BotTurn JSON file instead of the fixture")
|
|
512
|
+
checking.set_defaults(handler=check)
|
|
513
|
+
|
|
514
|
+
fixture = commands.add_parser(
|
|
515
|
+
"fixture", help="print the fixture BotTurn as JSON, for curl"
|
|
516
|
+
)
|
|
517
|
+
fixture.set_defaults(handler=print_fixture)
|
|
518
|
+
|
|
519
|
+
running = commands.add_parser("run", help="play a seat without a browser")
|
|
520
|
+
running.add_argument("bot", help="module:function")
|
|
521
|
+
running.add_argument("--base-url", required=True)
|
|
522
|
+
running.add_argument("--token", required=True, help="the guest bearer credential")
|
|
523
|
+
running.add_argument("--lobby-id", required=True)
|
|
524
|
+
running.add_argument("--log-file")
|
|
525
|
+
running.add_argument(
|
|
526
|
+
"--log-level", default="info", choices=("debug", "info", "warning", "error")
|
|
527
|
+
)
|
|
528
|
+
running.set_defaults(handler=run)
|
|
529
|
+
return parser
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
533
|
+
"""Entry point of the ``island-battles-bot`` script."""
|
|
534
|
+
args = build_parser().parse_args(argv)
|
|
535
|
+
return args.handler(args)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
if __name__ == "__main__":
|
|
539
|
+
sys.exit(main())
|