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