This repository has been archived by the owner on Mar 27, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcharacter.py
340 lines (254 loc) · 10.6 KB
/
character.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
from collections import defaultdict
from enum import Enum
import math
from typing import (
Callable,
ClassVar,
Generic,
Iterable,
Optional,
Mapping,
Protocol,
Set,
Tuple,
TypeVar,
Union,
)
import attr
from space import BoundingBox, Vector
State = Union["Living", "Dead", "Undead"]
ActionType = TypeVar("ActionType", covariant=True)
class LifeState(Enum):
LIVING = 1
DEAD = 2
UNDEAD = 3
@classmethod
def for_character(cls, character: "Character") -> "LifeState":
return character.life_state
def shortest(vectors: Iterable[Vector]) -> Vector:
return min(vectors, key=lambda v: v.distance)
class Actions(Protocol[ActionType]):
def move(self, vector: Vector) -> ActionType:
...
def attack(self, vector: Vector) -> ActionType:
...
def change_state(self, new_state: State) -> ActionType:
...
class Viewpoint(Protocol):
def occupied_points_in(self, box: BoundingBox) -> Set[Vector]:
...
def nearest_to(self, origin: Vector, key: LifeState) -> Optional[Vector]:
...
class SupportsNearestHuman(Protocol):
@property
def nearest_human(self) -> Optional[Vector]:
...
class TargetVectors:
def __init__(self, viewpoint: Viewpoint):
self._viewpoint = viewpoint
@property
def nearest_human(self) -> Optional[Vector]:
return self.nearest_human_to(Vector.ZERO)
def nearest_human_to(self, offset: Vector) -> Optional[Vector]:
return self._viewpoint.nearest_to(offset, LifeState.LIVING)
def nearest_zombie_to(self, offset: Vector) -> Optional[Vector]:
return self._viewpoint.nearest_to(offset, LifeState.UNDEAD)
@attr.s(auto_attribs=True)
class MoveOption:
move: Vector
upper_bound: float
def best_move_upper_bound(
moves: Iterable[Vector], nearest_func: Callable[[Vector], Optional[Vector]]
) -> Vector:
"""Pick a move that maximises the distance from the nearest enemy.
This function takes a slightly more considered approach than its
predecessor, based on the idea that we don't want to call the "find my
nearest enemy" function more often than we have to.
As such, the algorithm is:
- Start out every available move with an unlimited potential distance
- Pick an arbitrary move and set it as our best option
- Find out where the nearest enemy would be after that move
- Use that information to limit the maximum possible distances for
every other move we know about. For instance, if not moving at all
would leave us with an enemy at (-10, 0), we know that moving 2 cells
to the right could only ever increase that to a maximum of 12.
- Trim out any moves where we know that move could never outmatch the
best move we know about
- If there are still moves available that might be better, repeat using
the move with the most potential
In the best case, this algorithm works out as follows:
- Work out where the nearest enemy is
- Guess that our best move is to run directly away from it
- Check our closest enemy after that, find that it's better than we
could get from any other move, and pick that one
This only leaves us running the nearest-enemy function twice, rather than
25 times for a 5-by-5 square.
"""
# Moves that take us further away are good; shorter moves break ties
move_key = lambda option: (-option.upper_bound, option.move.distance)
options = sorted([MoveOption(move, math.inf) for move in moves], key=move_key)
if not options:
raise ValueError("Attempting to choose from no available moves")
best_option: Optional[MoveOption] = None
while options:
# Either we'll set this as our new best candidate, or we'll discard it. Either
# way, it's no longer needed in our options to explore
candidate = options.pop(0)
nearest = nearest_func(candidate.move)
if nearest is None:
return candidate.move
candidate.upper_bound = nearest.distance
if best_option is None or candidate.upper_bound > best_option.upper_bound:
best_option = candidate
for option in options:
option.upper_bound = min(
option.upper_bound, (nearest + (candidate.move - option.move)).distance
)
options = sorted(
[o for o in options if o.upper_bound > best_option.upper_bound],
key=move_key,
)
# We have an earlier check that there are options available, so we've gone
# around the loop at least once, so we have either broken out and returned,
# or we've assigned something to `best_option`.
assert best_option is not None
return best_option.move
class Living:
life_state = LifeState.LIVING
movement_range = BoundingBox.range(2)
next_state = None
def attack(self, target_vectors: TargetVectors) -> Optional[Vector]:
return None
def best_move(
self, target_vectors: TargetVectors, available_moves: Iterable[Vector]
) -> Vector:
def nearest_zombie(move: Vector) -> Optional[Vector]:
if (nearest := target_vectors.nearest_zombie_to(move)) is not None:
return nearest - move
else:
return None
return best_move_upper_bound(available_moves, nearest_zombie)
class Dead:
def __init__(self, age: int = 0):
self._age = age
life_state = LifeState.DEAD
movement_range = BoundingBox.range(0)
_resurrection_age: ClassVar[int] = 20
def attack(self, target_vectors: TargetVectors) -> Optional[Vector]:
return None
def best_move(
self, target_vectors: TargetVectors, available_moves: Iterable[Vector]
) -> Vector:
if Vector.ZERO not in available_moves:
raise ValueError("Zero move unavailable for dead character")
return Vector.ZERO
@property
def next_state(self) -> State:
if self._age >= self._resurrection_age:
return Undead()
else:
return Dead(self._age + 1)
def __eq__(self, other: object) -> bool:
return isinstance(other, Dead) and self._age == other._age
class Undead:
life_state = LifeState.UNDEAD
movement_range = BoundingBox.range(1)
attack_range = BoundingBox.range(1)
next_state = None
def attack(self, target_vectors: SupportsNearestHuman) -> Optional[Vector]:
nearest_human = target_vectors.nearest_human
if nearest_human is not None and nearest_human in self.attack_range:
return nearest_human
else:
return None
def best_move(
self, target_vectors: SupportsNearestHuman, available_moves: Iterable[Vector]
) -> Vector:
nearest_human = target_vectors.nearest_human
if nearest_human:
def move_rank(move: Vector) -> Tuple[float, float]:
assert nearest_human is not None
return ((nearest_human - move).distance, move.distance)
return min(available_moves, key=move_rank)
else:
return shortest(available_moves)
def __eq__(self, other: object) -> bool:
return isinstance(other, Undead)
class Character:
def __init__(self, state: State):
self._state = state
@property
def life_state(self) -> LifeState:
return self._state.life_state
def next_action(
self,
environment: Viewpoint,
limits: BoundingBox,
actions: Actions[ActionType],
) -> ActionType:
new_state = self._state.next_state
if new_state:
return actions.change_state(new_state)
target_vector = self.attack(environment)
if target_vector:
return actions.attack(target_vector)
move = self.move(environment, limits)
return actions.move(move)
def move(self, environment: Viewpoint, limits: BoundingBox) -> Vector:
"""Choose where to move next.
Arguments:
environment: the character's current environment. This is currently
passed in as a Viewpoint instance, supporting the
`occupied_points_in` and `nearest_to` methods.
limits: any limits on the character's movement provided by the
edges of the world. This can be anything that reponds to the
`in` operator.
Return a Vector object representing this character's intended move. If
the character does not intend to (or cannot) move, return a zero
vector.
"""
target_vectors = TargetVectors(environment)
moves = self._available_moves(limits, environment)
return self._state.best_move(target_vectors, moves)
def _available_moves(
self, limits: BoundingBox, environment: Viewpoint
) -> Iterable[Vector]:
character_range = self._state.movement_range.intersect(limits)
obstacles = environment.occupied_points_in(character_range) - {Vector.ZERO}
return available_moves(character_range, obstacles)
def attack(self, environment: Viewpoint) -> Optional[Vector]:
return self._state.attack(TargetVectors(environment))
def with_state(self, new_state: State) -> "Character":
return Character(state=new_state)
def attacked(self) -> "Character":
return self.with_state(Dead())
def default_human() -> Character:
return Character(state=Living())
def default_zombie() -> Character:
return Character(state=Undead())
def available_moves(
character_range: Iterable[Vector],
obstacles: Set[Vector],
) -> Set[Vector]:
"""Determine available moves for a character.
This function checks not only that the target spaces are empty, but that there's a
path that allows the character to reach them by passing only through empty spaces.
"""
if Vector.ZERO not in character_range or Vector.ZERO in obstacles:
raise ValueError("Zero movement unavailable for character")
def max_offset(move: Vector) -> int:
return max(abs(move.dx), abs(move.dy))
def reachable(a: Vector, b: Vector) -> bool:
return abs(a.dx - b.dx) <= 1 and abs(a.dy - b.dy) <= 1
moves_by_offset: Mapping[int, Set[Vector]] = defaultdict(set)
for move in character_range:
moves_by_offset[max_offset(move)].add(move)
available_moves = {Vector.ZERO}
for offset in range(1, max(moves_by_offset.keys()) + 1):
available_moves |= {
outer_move
for outer_move in moves_by_offset[offset]
if outer_move not in obstacles
and any(reachable(outer_move, inner_move) for inner_move in available_moves)
}
return available_moves