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,189 @@
|
|
|
1
|
+
"""The bot entry point: a function from ``BotTurn`` to ``BotTurnResult``.
|
|
2
|
+
|
|
3
|
+
A bot is stateless. It gets the turn's board and the ``memory`` it returned
|
|
4
|
+
last turn, and returns commands, game log lines and its new memory. The
|
|
5
|
+
helpers here build the pieces of a result; ``call_play`` and ``run_turn`` run
|
|
6
|
+
a bot the way the client, ``island-battles-bot serve`` and ``check`` do.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import ValidationError
|
|
19
|
+
|
|
20
|
+
from ._generated import (
|
|
21
|
+
BotTurn,
|
|
22
|
+
BotTurnResult,
|
|
23
|
+
GenerationLogLine,
|
|
24
|
+
PublicGenerationCommand,
|
|
25
|
+
SendSoldierGroupCommand,
|
|
26
|
+
SendSoldierGroupParameters,
|
|
27
|
+
UpgradeCastleCommand,
|
|
28
|
+
UpgradeCastleParameters,
|
|
29
|
+
UpgradeType,
|
|
30
|
+
)
|
|
31
|
+
from .errors import BotMemoryTooLargeError, InvalidBotResultError
|
|
32
|
+
from .game_log import (
|
|
33
|
+
PLAYER_LOGGER,
|
|
34
|
+
ClientLogging,
|
|
35
|
+
GameLog,
|
|
36
|
+
LogLevel,
|
|
37
|
+
clean,
|
|
38
|
+
cut,
|
|
39
|
+
)
|
|
40
|
+
from .models import Castle
|
|
41
|
+
|
|
42
|
+
Play = Callable[[BotTurn], BotTurnResult | Awaitable[BotTurnResult]]
|
|
43
|
+
"""A bot: ``def play(turn)`` or ``async def play(turn)``."""
|
|
44
|
+
|
|
45
|
+
MEMORY_LIMIT_BYTES = 64 * 1024
|
|
46
|
+
"""The most memory a turn may return, as compact JSON."""
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(PLAYER_LOGGER)
|
|
49
|
+
"""The bot's logger. Lines logged while a bot plays also go to the game log."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def send_soldier_group(
|
|
53
|
+
origin_castle_id: int, destination_castle_id: int, soldier_amount: int
|
|
54
|
+
) -> PublicGenerationCommand:
|
|
55
|
+
"""Build a ``send_soldier_group`` command; raises ``ValidationError`` if invalid."""
|
|
56
|
+
return PublicGenerationCommand(
|
|
57
|
+
SendSoldierGroupCommand(
|
|
58
|
+
command_type="send_soldier_group",
|
|
59
|
+
parameters=SendSoldierGroupParameters(
|
|
60
|
+
origin_castle_id=origin_castle_id,
|
|
61
|
+
destination_castle_id=destination_castle_id,
|
|
62
|
+
soldier_amount=soldier_amount,
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def upgrade_castle(
|
|
69
|
+
castle_id: int, upgrade_type: UpgradeType | str
|
|
70
|
+
) -> PublicGenerationCommand:
|
|
71
|
+
"""Build an ``upgrade_castle`` command; raises ``ValidationError`` if invalid."""
|
|
72
|
+
return PublicGenerationCommand(
|
|
73
|
+
UpgradeCastleCommand(
|
|
74
|
+
command_type="upgrade_castle",
|
|
75
|
+
parameters=UpgradeCastleParameters(
|
|
76
|
+
castle_id=castle_id, upgrade_type=upgrade_type
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def next_upgrade_cost(
|
|
83
|
+
turn: BotTurn, castle: Castle, upgrade_type: UpgradeType | str
|
|
84
|
+
) -> int | None:
|
|
85
|
+
"""Soldiers it costs the castle to buy its next level of an upgrade type.
|
|
86
|
+
|
|
87
|
+
The cost is read from ``turn.upgrades``, the server's table. Returns
|
|
88
|
+
``None`` when the castle is at the type's maximum level. Upgrades bought
|
|
89
|
+
earlier in the same result are not counted: add them to the castle's level.
|
|
90
|
+
"""
|
|
91
|
+
level = castle.upgrades[upgrade_type] + 1
|
|
92
|
+
for upgrade in turn.upgrades:
|
|
93
|
+
if upgrade.type == upgrade_type:
|
|
94
|
+
return next(
|
|
95
|
+
(
|
|
96
|
+
entry.soldier_cost
|
|
97
|
+
for entry in upgrade.levels
|
|
98
|
+
if entry.level == level
|
|
99
|
+
),
|
|
100
|
+
None,
|
|
101
|
+
)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def log_line(
|
|
106
|
+
message: str, level: LogLevel = "info", logger_name: str = PLAYER_LOGGER
|
|
107
|
+
) -> GenerationLogLine:
|
|
108
|
+
"""Build a game log line, cut to the server's limit and made storable."""
|
|
109
|
+
return GenerationLogLine(
|
|
110
|
+
level=level, logger=clean(logger_name[:100]), message=cut(clean(message))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def turn_result(
|
|
115
|
+
commands: Iterable[PublicGenerationCommand] = (),
|
|
116
|
+
logs: Iterable[GenerationLogLine] = (),
|
|
117
|
+
memory: Mapping[str, Any] | None = None,
|
|
118
|
+
) -> BotTurnResult:
|
|
119
|
+
"""Build a ``BotTurnResult``; every part is optional."""
|
|
120
|
+
return BotTurnResult(commands=tuple(commands), logs=tuple(logs), memory=memory)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_memory(memory: Mapping[str, Any] | None) -> None:
|
|
124
|
+
"""Raise ``BotMemoryTooLargeError`` when memory is over 64 KiB as compact JSON."""
|
|
125
|
+
if memory is None:
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
size = len(
|
|
129
|
+
json.dumps(memory, separators=(",", ":"), ensure_ascii=False).encode(
|
|
130
|
+
"utf-8", "surrogatepass"
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
except (TypeError, ValueError) as error:
|
|
134
|
+
raise InvalidBotResultError(f"memory is not JSON: {error}") from error
|
|
135
|
+
if size > MEMORY_LIMIT_BYTES:
|
|
136
|
+
raise BotMemoryTooLargeError(
|
|
137
|
+
f"memory is {size} bytes as compact JSON; the limit is "
|
|
138
|
+
f"{MEMORY_LIMIT_BYTES} (64 KiB)"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def call_play(play: Play, turn: BotTurn) -> BotTurnResult:
|
|
143
|
+
"""Call a bot once and return its valid result.
|
|
144
|
+
|
|
145
|
+
A sync bot runs in a worker thread, so it can't block the event loop. The
|
|
146
|
+
result must be a ``BotTurnResult`` (or a mapping that validates as one)
|
|
147
|
+
with memory of at most 64 KiB; otherwise ``InvalidBotResultError`` or
|
|
148
|
+
``BotMemoryTooLargeError`` is raised. Exceptions from the bot pass through.
|
|
149
|
+
"""
|
|
150
|
+
if inspect.iscoroutinefunction(play):
|
|
151
|
+
result = await play(turn)
|
|
152
|
+
else:
|
|
153
|
+
result = await asyncio.to_thread(play, turn)
|
|
154
|
+
if inspect.isawaitable(result):
|
|
155
|
+
result = await result
|
|
156
|
+
if isinstance(result, Mapping):
|
|
157
|
+
try:
|
|
158
|
+
result = BotTurnResult.model_validate(result)
|
|
159
|
+
except ValidationError as error:
|
|
160
|
+
raise InvalidBotResultError(f"invalid result: {error}") from error
|
|
161
|
+
if not isinstance(result, BotTurnResult):
|
|
162
|
+
raise InvalidBotResultError(
|
|
163
|
+
f"play must return a BotTurnResult, not {type(result).__name__}"
|
|
164
|
+
)
|
|
165
|
+
check_memory(result.memory)
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def add_result_lines(game_log: GameLog, result: BotTurnResult) -> None:
|
|
170
|
+
"""File the lines a result returns after those the bot logged while playing."""
|
|
171
|
+
for line in result.logs:
|
|
172
|
+
game_log.add(log_line(line.message, line.level, line.logger))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def run_turn(
|
|
176
|
+
play: Play, turn: BotTurn, client_logging: ClientLogging, stored: int = 0
|
|
177
|
+
) -> BotTurnResult:
|
|
178
|
+
"""Play one turn for a relaying caller, and return the result with its logs.
|
|
179
|
+
|
|
180
|
+
The bot's logged lines and the lines it returned become the result's
|
|
181
|
+
``logs``, within the server's limits, counting ``stored`` lines already
|
|
182
|
+
stored for the generation. Exceptions from the bot pass through.
|
|
183
|
+
"""
|
|
184
|
+
game_log = GameLog(turn.generation.id, stored)
|
|
185
|
+
with client_logging.scope(game_log):
|
|
186
|
+
result = await call_play(play, turn)
|
|
187
|
+
add_result_lines(game_log, result)
|
|
188
|
+
lines, _ = game_log.batch(True)
|
|
189
|
+
return result.model_copy(update={"logs": tuple(lines)})
|