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,92 @@
1
+ """Public failures with safe HTTP status and detail, without credentials or bodies."""
2
+
3
+
4
+ class GameError(Exception):
5
+ def __init__(self, detail: str, status: int | None = None):
6
+ self.detail = detail
7
+ self.status = status
8
+ super().__init__(f"HTTP {status}: {detail}" if status is not None else detail)
9
+
10
+
11
+ class GameAuthenticationError(GameError):
12
+ pass
13
+
14
+
15
+ class GameTransportError(GameError):
16
+ pass
17
+
18
+
19
+ class GameProtocolError(GameError):
20
+ pass
21
+
22
+
23
+ class GameClientClosedError(GameError):
24
+ def __init__(self):
25
+ super().__init__("client closed")
26
+
27
+
28
+ class GameCommandError(GameError):
29
+ """A command that the game rules reject, for a reason a strategy can act on.
30
+
31
+ The server rejects the whole result: its commands are not stored and the
32
+ turn stays open. ``status`` is the HTTP status.
33
+ """
34
+
35
+
36
+ class CastleNotFoundError(GameCommandError):
37
+ """The castle is not in the generation."""
38
+
39
+
40
+ class CastleNotOwnedError(GameCommandError):
41
+ """The castle belongs to another team or to none."""
42
+
43
+
44
+ class UpgradeMaxLevelError(GameCommandError):
45
+ """The upgrade type is at its maximum level, counting this turn's upgrades."""
46
+
47
+
48
+ class InsufficientSoldiersError(GameCommandError):
49
+ """The castle has too few soldiers left after this turn's reservations."""
50
+
51
+
52
+ class GameLogLimitError(GameError):
53
+ """The server refused a batch's game log lines, which would pass its limit.
54
+
55
+ The client handles it itself: it sends the batch's commands again without
56
+ the lines, so a strategy never sees it.
57
+ """
58
+
59
+
60
+ class BotMemoryTooLargeError(GameError):
61
+ """A bot's memory is over 64 KiB as compact JSON.
62
+
63
+ The client raises it before any request, with ``status`` None. The server
64
+ refuses such a batch with the same error and its HTTP status.
65
+ """
66
+
67
+
68
+ class InvalidBotResultError(GameError):
69
+ """A bot returned something other than a valid ``BotTurnResult``."""
70
+
71
+
72
+ class GameEndedError(GameError):
73
+ """The server refused a batch because the game has ended.
74
+
75
+ A turn that passes its deadline ends the game while a slow callback may
76
+ still be sending commands. The client handles it itself: it reads the
77
+ final state and calls ``on_game_ended``, so the client doesn't fail.
78
+ """
79
+
80
+
81
+ # The server's problem types that clients tell apart: the command rejections,
82
+ # shared by every command, the game's end, the game log limit and the bot
83
+ # memory limit.
84
+ PROBLEM_TYPE_ERRORS: dict[str, type[GameError]] = {
85
+ "urn:island-battles:problem:game-ended": GameEndedError,
86
+ "urn:island-battles:problem:castle-not-found": CastleNotFoundError,
87
+ "urn:island-battles:problem:castle-not-owned": CastleNotOwnedError,
88
+ "urn:island-battles:problem:upgrade-max-level": UpgradeMaxLevelError,
89
+ "urn:island-battles:problem:insufficient-soldiers": InsufficientSoldiersError,
90
+ "urn:island-battles:problem:game-log-limit": GameLogLimitError,
91
+ "urn:island-battles:problem:bot-memory-too-large": BotMemoryTooLargeError,
92
+ }
@@ -0,0 +1,88 @@
1
+ """A small board for ``island-battles-bot check`` and for trying ``serve`` with curl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+ from ._generated import BotTurn
9
+
10
+ _UPGRADES = {"speed": 1, "clash_stop_generations": 1}
11
+
12
+
13
+ # What GET /game/upgrades returns; every game has the same table.
14
+ _UPGRADE_TABLE = [
15
+ {
16
+ "type": "clash_stop_generations",
17
+ "description": "Generations a troop stays stopped after a clash",
18
+ "max_level": 3,
19
+ "levels": [
20
+ {"level": 1, "value": 3, "soldier_cost": 0},
21
+ {"level": 2, "value": 2, "soldier_cost": 10},
22
+ {"level": 3, "value": 1, "soldier_cost": 20},
23
+ ],
24
+ },
25
+ {
26
+ "type": "speed",
27
+ "description": "World units a troop moves per turn",
28
+ "max_level": 4,
29
+ "levels": [
30
+ {"level": 1, "value": 0.5, "soldier_cost": 0},
31
+ {"level": 2, "value": 1, "soldier_cost": 10},
32
+ {"level": 3, "value": 1.5, "soldier_cost": 20},
33
+ {"level": 4, "value": 2, "soldier_cost": 30},
34
+ ],
35
+ },
36
+ ]
37
+
38
+
39
+ def _castle(
40
+ castle_id: int, team_color: int, soldiers: int, x: float, y: float
41
+ ) -> dict[str, Any]:
42
+ return {
43
+ "id": castle_id,
44
+ "team_color": team_color,
45
+ "soldiers": soldiers,
46
+ "x": x,
47
+ "y": y,
48
+ "upgrades": dict(_UPGRADES),
49
+ }
50
+
51
+
52
+ def fixture_turn_data() -> dict[str, Any]:
53
+ """A ``BotTurn`` as JSON: team 1 against team 2, a neutral castle, one group."""
54
+ return {
55
+ "game_id": 1,
56
+ "team_color": 1,
57
+ "upgrades": deepcopy(_UPGRADE_TABLE),
58
+ "memory": None,
59
+ "turn_deadline_at": None,
60
+ "generation": {
61
+ "id": 12,
62
+ "castles": [
63
+ _castle(0, 1, 30, -3.0, 0.0),
64
+ _castle(1, 2, 30, 3.0, 0.0),
65
+ _castle(2, 0, 10, 0.0, 2.0),
66
+ _castle(3, 1, 12, -2.0, -2.0),
67
+ ],
68
+ "soldier_groups": [
69
+ {
70
+ "id": 0,
71
+ "team_color": 2,
72
+ "soldiers": 4,
73
+ "origin_castle_id": 1,
74
+ "destination_castle_id": 0,
75
+ "x": 1.0,
76
+ "y": 0.0,
77
+ "clash_radius": 0.5,
78
+ "upgrades": dict(_UPGRADES),
79
+ }
80
+ ],
81
+ "clashes": [],
82
+ },
83
+ }
84
+
85
+
86
+ def fixture_turn() -> BotTurn:
87
+ """The same turn as a model."""
88
+ return BotTurn.model_validate(fixture_turn_data())
@@ -0,0 +1,346 @@
1
+ """The bot's logger: stdout, an optional file, and the game log.
2
+
3
+ A strategy logs with ``self.logger``, the standard ``logging.Logger`` named
4
+ ``island_battles.player``. Every client attaches its handlers to the
5
+ ``island_battles`` logger, so the SDK's own ``island_battles.sdk`` records
6
+ reach stdout and the file too. The game-log handler files each record under
7
+ the generation whose callback is running, through a context variable that the
8
+ client sets around its callbacks, so two clients in one process never mix
9
+ their lines. Records logged outside a callback go to stdout and the file only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import re
16
+ import sys
17
+ import threading
18
+ import traceback
19
+ from collections.abc import Iterator
20
+ from contextlib import contextmanager
21
+ from contextvars import ContextVar
22
+ from dataclasses import dataclass
23
+ from os import PathLike
24
+ from typing import Literal
25
+
26
+ from ._generated import GenerationLogLine
27
+
28
+ PLAYER_LOGGER = "island_battles.player"
29
+ SDK_LOGGER = "island_battles.sdk"
30
+ # The parent of both loggers, which the clients attach their handlers to.
31
+ ROOT_LOGGER = "island_battles"
32
+
33
+ # The server's limits, per player per generation.
34
+ MAX_LINES = 100
35
+ MAX_MESSAGE_LENGTH = 500
36
+ MAX_LOGGER_LENGTH = 100
37
+ CUT_NOTE = f"… (cut at {MAX_MESSAGE_LENGTH} characters)"
38
+ # NUL and the surrogate code points, which stored text can't hold.
39
+ _UNSTORABLE = re.compile("[\x00\ud800-\udfff]")
40
+
41
+ # Marks the SDK's own reports about the game log, which go to stdout and the
42
+ # file but never into the game log itself.
43
+ _NOT_FOR_GAME_LOG = "island_battles_not_for_game_log"
44
+ _FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
45
+
46
+ _sdk_logger = logging.getLogger(SDK_LOGGER)
47
+
48
+ LogLevel = Literal["debug", "info", "warning", "error"]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class _LogScope:
53
+ """The client whose code is running, and the generation it plays, if any."""
54
+
55
+ owner: object
56
+ game_log: GameLog | None
57
+
58
+
59
+ _scope: ContextVar[_LogScope | None] = ContextVar(
60
+ "island_battles_log_scope", default=None
61
+ )
62
+
63
+
64
+ def cut(message: str) -> str:
65
+ """Fit a message into one stored line, saying where it was cut."""
66
+ if len(message) <= MAX_MESSAGE_LENGTH:
67
+ return message
68
+ return message[: MAX_MESSAGE_LENGTH - len(CUT_NOTE)] + CUT_NOTE
69
+
70
+
71
+ def clean(text: str) -> str:
72
+ """Make text storable, replacing what UTF-8 text can't hold with U+FFFD.
73
+
74
+ PostgreSQL text holds no NUL, and a surrogate code point, which Python
75
+ strings may carry alone (for example from ``errors="surrogateescape"``),
76
+ has no UTF-8 encoding.
77
+ """
78
+ return _UNSTORABLE.sub("\ufffd", text)
79
+
80
+
81
+ def _level(record: logging.LogRecord) -> LogLevel:
82
+ if record.levelno >= logging.ERROR:
83
+ return "error"
84
+ if record.levelno >= logging.WARNING:
85
+ return "warning"
86
+ if record.levelno >= logging.INFO:
87
+ return "info"
88
+ return "debug"
89
+
90
+
91
+ class GameLog:
92
+ """One generation's game log lines that the client has not sent yet.
93
+
94
+ Lines are sent as they come until the player has 99 stored or pending,
95
+ counting the ``stored`` lines an earlier run already stored. The next line
96
+ is held back until the turn's last request: it is sent if no more lines
97
+ came, and otherwise replaced by an error that counts every dropped line,
98
+ so the player never passes the server's limit. With 100 lines already
99
+ stored, nothing more is sent. The handler adds lines from any thread, and
100
+ the client takes them under its command lock.
101
+ """
102
+
103
+ def __init__(self, generation_id: int, stored: int = 0) -> None:
104
+ self.generation_id = generation_id
105
+ # Reentrant, so commit can seal under it.
106
+ self._lock = threading.RLock()
107
+ self._pending: list[GenerationLogLine] = []
108
+ self._accepted = stored
109
+ self._held: GenerationLogLine | None = None
110
+ self._dropped = 0
111
+ self._sealed = False
112
+
113
+ def add(self, line: GenerationLogLine) -> bool:
114
+ """Keep a line for the next request; True when it is the first one dropped."""
115
+ with self._lock:
116
+ if self._sealed:
117
+ return False
118
+ if self._accepted < MAX_LINES - 1:
119
+ self._pending.append(line)
120
+ self._accepted += 1
121
+ return False
122
+ if (
123
+ self._accepted == MAX_LINES - 1
124
+ and self._held is None
125
+ and not self._dropped
126
+ ):
127
+ self._held = line
128
+ return False
129
+ first = not self._dropped
130
+ # The held line is dropped with the first line past the limit.
131
+ self._dropped += 2 if self._held is not None else 1
132
+ self._held = None
133
+ return first
134
+
135
+ def batch(self, last: bool) -> tuple[list[GenerationLogLine], int]:
136
+ """The lines for the next request, and how many pending lines they include.
137
+
138
+ The turn's last request also carries the held line, or the limit error
139
+ in its place when there is room for it. Nothing is removed until
140
+ ``commit``, so a rejected request leaves the lines for the next one.
141
+ """
142
+ with self._lock:
143
+ if self._sealed:
144
+ return [], 0
145
+ lines = list(self._pending)
146
+ count = len(lines)
147
+ if last and self._held is not None:
148
+ lines.append(self._held)
149
+ elif last and self._dropped and self._accepted < MAX_LINES:
150
+ lines.append(
151
+ GenerationLogLine(
152
+ level="error",
153
+ logger=SDK_LOGGER,
154
+ message=limit_error(self.generation_id, self._dropped),
155
+ )
156
+ )
157
+ return lines, count
158
+
159
+ def commit(self, count: int, last: bool) -> None:
160
+ """Forget the lines that a request sent; after the last one, take no more."""
161
+ with self._lock:
162
+ del self._pending[:count]
163
+ if last:
164
+ self.seal()
165
+
166
+ def seal(self) -> None:
167
+ """Send nothing more for this generation."""
168
+ with self._lock:
169
+ self._sealed = True
170
+ self._pending.clear()
171
+ self._held = None
172
+ self._dropped = 0
173
+
174
+
175
+ def limit_error(generation_id: int, dropped: int | None = None) -> str:
176
+ """The limit error, with the final count of dropped lines once it is known."""
177
+ rest = (
178
+ f"{dropped} lines were not stored"
179
+ if dropped is not None
180
+ else "the rest of this generation's lines are not stored"
181
+ )
182
+ return (
183
+ f"game log limit reached for generation {generation_id}: "
184
+ f"{MAX_LINES} lines per generation; {rest}"
185
+ )
186
+
187
+
188
+ def report_server_limit(generation_id: int, detail: str) -> None:
189
+ """Write to stdout and the file that the server refused a batch's lines."""
190
+ _sdk_logger.error(
191
+ "%s; the server refused the lines: %s",
192
+ limit_error(generation_id),
193
+ detail,
194
+ extra={_NOT_FOR_GAME_LOG: True},
195
+ )
196
+
197
+
198
+ class _OwnerFilter(logging.Filter):
199
+ """Pass records of one client's code, and records logged outside any client."""
200
+
201
+ def __init__(self, owner: object) -> None:
202
+ super().__init__()
203
+ self.owner = owner
204
+
205
+ def filter(self, record: logging.LogRecord) -> bool:
206
+ scope = _scope.get()
207
+ return scope is None or scope.owner is self.owner
208
+
209
+
210
+ class _GameLogFormatter(logging.Formatter):
211
+ """Formats a stored line so that cutting it keeps what matters most.
212
+
213
+ An exception comes right after the message, followed by its frames from
214
+ the innermost out, so a cut traceback still names the error and where it
215
+ was raised.
216
+ """
217
+
218
+ def format(self, record: logging.LogRecord) -> str:
219
+ # Unlike logging.Formatter, never reuse record.exc_text: the stdout
220
+ # and file handlers cache their own traceback layout there.
221
+ message = record.getMessage()
222
+ if record.exc_info and record.exc_info[1] is not None:
223
+ message += "\n" + self.formatException(record.exc_info)
224
+ if record.stack_info:
225
+ message += "\n" + self.formatStack(record.stack_info)
226
+ return message
227
+
228
+ def formatException(self, ei) -> str:
229
+ exception = traceback.TracebackException(*ei)
230
+ summary = "".join(exception.format_exception_only()).rstrip("\n")
231
+ frames = "".join(reversed(exception.stack.format())).rstrip("\n")
232
+ if not frames:
233
+ return summary
234
+ return f"{summary}\nTraceback (most recent call first):\n{frames}"
235
+
236
+
237
+ class _GameLogHandler(logging.Handler):
238
+ """Files a client's records under the generation of the running callback."""
239
+
240
+ def __init__(self, owner: object, level: int) -> None:
241
+ super().__init__(level)
242
+ self.owner = owner
243
+ self.setFormatter(_GameLogFormatter())
244
+
245
+ def emit(self, record: logging.LogRecord) -> None:
246
+ scope = _scope.get()
247
+ if (
248
+ scope is None
249
+ or scope.owner is not self.owner
250
+ or scope.game_log is None
251
+ or getattr(record, _NOT_FOR_GAME_LOG, False)
252
+ ):
253
+ return
254
+ try:
255
+ message = cut(clean(self.format(record)))
256
+ if not message:
257
+ return
258
+ line = GenerationLogLine(
259
+ level=_level(record),
260
+ logger=clean(record.name[:MAX_LOGGER_LENGTH]),
261
+ message=message,
262
+ )
263
+ if scope.game_log.add(line):
264
+ # Once per generation, as soon as a line is dropped. The final
265
+ # count is stored with the turn's last request.
266
+ _sdk_logger.error(
267
+ limit_error(scope.game_log.generation_id),
268
+ extra={_NOT_FOR_GAME_LOG: True},
269
+ )
270
+ except Exception: # noqa: BLE001 - a logging call never raises into the bot
271
+ self.handleError(record)
272
+
273
+
274
+ # The clients attached to the island_battles logger, and its level before the
275
+ # first of them lowered it.
276
+ _attached: set[ClientLogging] = set()
277
+ _level_before_clients = logging.NOTSET
278
+ _attach_lock = threading.Lock()
279
+
280
+
281
+ class ClientLogging:
282
+ """One client's handlers on the ``island_battles`` logger."""
283
+
284
+ def __init__(
285
+ self,
286
+ owner: object,
287
+ level: int,
288
+ to_stdout: bool,
289
+ file: str | PathLike[str] | None,
290
+ ) -> None:
291
+ self.owner = owner
292
+ self.level = level
293
+ self.to_stdout = to_stdout
294
+ self.file = file
295
+ self._handlers: list[logging.Handler] = []
296
+
297
+ def attach(self) -> None:
298
+ """Attach stdout and file handlers as configured, and always the game log."""
299
+ if self._handlers:
300
+ return
301
+ handlers: list[logging.Handler] = []
302
+ if self.to_stdout:
303
+ handlers.append(logging.StreamHandler(sys.stdout))
304
+ if self.file is not None:
305
+ handlers.append(logging.FileHandler(self.file, encoding="utf-8"))
306
+ for handler in handlers:
307
+ handler.setLevel(self.level)
308
+ handler.setFormatter(logging.Formatter(_FORMAT))
309
+ handlers.append(_GameLogHandler(self.owner, self.level))
310
+ owner_filter = _OwnerFilter(self.owner)
311
+ root = logging.getLogger(ROOT_LOGGER)
312
+ global _level_before_clients
313
+ with _attach_lock:
314
+ if not _attached:
315
+ _level_before_clients = root.level
316
+ _attached.add(self)
317
+ for handler in handlers:
318
+ handler.addFilter(owner_filter)
319
+ root.addHandler(handler)
320
+ # Each handler applies its client's level; the logger passes the
321
+ # lowest level any client asked for.
322
+ if root.level == logging.NOTSET or root.level > self.level:
323
+ root.setLevel(self.level)
324
+ self._handlers = handlers
325
+
326
+ def detach(self) -> None:
327
+ """Remove and close this client's handlers."""
328
+ root = logging.getLogger(ROOT_LOGGER)
329
+ with _attach_lock:
330
+ for handler in self._handlers:
331
+ root.removeHandler(handler)
332
+ handler.close()
333
+ if self in _attached:
334
+ _attached.discard(self)
335
+ if not _attached:
336
+ root.setLevel(_level_before_clients)
337
+ self._handlers = []
338
+
339
+ @contextmanager
340
+ def scope(self, game_log: GameLog | None = None) -> Iterator[None]:
341
+ """Route records to this client, and into game_log when it is given."""
342
+ token = _scope.set(_LogScope(self.owner, game_log))
343
+ try:
344
+ yield
345
+ finally:
346
+ _scope.reset(token)
@@ -0,0 +1,33 @@
1
+ """Game data exposed to player strategies.
2
+
3
+ Fields and validation are generated from ``backend/api/openapi.json``; the
4
+ distance and team utilities are inherited from ``_entities``.
5
+ """
6
+
7
+ from ._entities import NEUTRAL_TEAM_COLOR, BoardEntity, TeamType
8
+ from ._generated import (
9
+ Castle,
10
+ Clash,
11
+ ClashParticipant,
12
+ GameOutcome,
13
+ Generation,
14
+ SoldierGroup,
15
+ Upgrade,
16
+ UpgradeLevel,
17
+ UpgradeType,
18
+ )
19
+
20
+ __all__ = [
21
+ "NEUTRAL_TEAM_COLOR",
22
+ "BoardEntity",
23
+ "Castle",
24
+ "Clash",
25
+ "ClashParticipant",
26
+ "GameOutcome",
27
+ "Generation",
28
+ "SoldierGroup",
29
+ "TeamType",
30
+ "Upgrade",
31
+ "UpgradeLevel",
32
+ "UpgradeType",
33
+ ]
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.3
2
+ Name: island-battles-python
3
+ Version: 0.1.0
4
+ Summary: Async player client for Island Battles generation turns
5
+ Requires-Dist: centrifuge-python==0.6.0
6
+ Requires-Dist: httpx>=0.27,<1
7
+ Requires-Dist: pydantic>=2.12,<3
8
+ Requires-Python: >=3.14
@@ -0,0 +1,14 @@
1
+ island_battles_client/__init__.py,sha256=pGG0AyuYwYS6XVpHJ4Td8f8mvrG-rxPPA3yBe00ysD4,1821
2
+ island_battles_client/_entities.py,sha256=2CpBM0VdltpjDr5H4A2BDcqTaXuNJolc65gINy0hUwY,3404
3
+ island_battles_client/_generated.py,sha256=gedqAU7oyXGOrKqT5FaJwbc5b0INWjAcqEKZXpMMfy8,26913
4
+ island_battles_client/bot.py,sha256=ZmTPgAjpuMPc-G03RGY9VhgApT2mEk2O711c0gwWNiA,6584
5
+ island_battles_client/cli.py,sha256=6WgPjC0eEYMLAIRHNnTt0gX8wtweKLua-Oc9KfBY5m4,19772
6
+ island_battles_client/client.py,sha256=mE3XXyt4XxbCS1zmT21oltHREoICwbVjsw-XyDHGfxw,31941
7
+ island_battles_client/errors.py,sha256=G9nyS7FxedULxW8hxV2EmS_xB5OoluKZXgMNotPOeQI,2975
8
+ island_battles_client/fixture.py,sha256=YoCVfTIpggWETo1VpxUjDmy74DuvIFY5JsOwpvfQP-Y,2538
9
+ island_battles_client/game_log.py,sha256=z4ItSR3bHr5tLwmNIZjPvKXJ6yDD0HYCjpHjUCiwsXw,12547
10
+ island_battles_client/models.py,sha256=yVyUQnHHmLxsbN39Xxz41IwktdWwZ6hHyHvxyhwQU5w,661
11
+ island_battles_python-0.1.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
12
+ island_battles_python-0.1.0.dist-info/entry_points.txt,sha256=5SnOFQw43Rpc6yWmW9kLv8-IYb3g9q-sVNc4gN4lWWA,71
13
+ island_battles_python-0.1.0.dist-info/METADATA,sha256=qqE1Lez89MNzh070Eun-jI6sE144UiYsiA7wY2o6kek,257
14
+ island_battles_python-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ island-battles-bot = island_battles_client.cli:main
3
+