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.
@@ -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())