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,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,,
|