Commit e4558ecc authored by Brady James Garvin's avatar Brady James Garvin
Browse files

Updated the codebase for the 2019 assignment.

parent 998d9f5b
from __future__ import division
from copy import copy, deepcopy
from random import choice, shuffle, uniform
from copy import deepcopy
from random import choice, uniform
from enum import Enum
......@@ -22,15 +20,14 @@ class Place(object):
def connect_to(self, place):
"""
Create a connection between this Place and the one given; Bees will
travel from this Place to the other one.
Create a connection between this Place and the one given; Bees will travel from this Place to the other one.
"""
self.destinations.append(place)
place.sources.append(self)
def get_defender(self):
def get_defender(self): # pylint: disable=no-self-use
"""
Return the ant defending this place, if any.
Return the ant defending this place, if any.
"""
return None
......@@ -38,15 +35,10 @@ class Place(object):
"""
Add an Insect to this Place.
A plain Place can only hold bees. There can be any number of Bees in a
Place.
A plain Place can only hold bees. There can be any number of Bees in a Place.
"""
assert isinstance(insect, Bee), \
'The place {place} cannot hold {added} of the type {kind}' \
.format(place=self, added=insect, kind=type(insect).__name__)
assert insect not in self.bees, \
'The bee {bee} cannot be added to {place} twice' \
.format(place=self, bee=insect)
assert isinstance(insect, Bee), f'The place {self} cannot hold {insect} of the type {type(insect).__name__}'
assert insect not in self.bees, f'The bee {insect} cannot be added to {self} twice'
self.bees.append(insect)
insect.place = self
......@@ -54,21 +46,17 @@ class Place(object):
"""
Remove an Insect from this Place.
"""
assert insect in self.bees, \
'{bee} is not at {place} to be removed' \
.format(bee=insect, place=self)
assert insect in self.bees, f'{insect} is not at {self} to be removed'
self.bees.remove(insect)
insect.place = None
def __repr__(self):
return '{name}({x}, {y})' \
.format(name=type(self).__name__, x=self.world_x, y=self.world_y)
return f'{type(self).__name__}({self.world_x}, {self.world_y})'
class ColonyPlace(Place):
"""
A ColonyPlace, unlike a regular Place, can hold an Ant and be the target of
an Ant's attack.
A ColonyPlace, unlike a regular Place, can hold an Ant and be the target of an Ant's attack.
"""
def __init__(self, world_x, world_y):
......@@ -88,15 +76,13 @@ class ColonyPlace(Place):
"""
Add an Insect to this Place.
There can be at most one Ant in a ColonyPlace. If add_insect tries to
add more Ants than is allowed, an AssertionError is raised.
There can be at most one Ant in a ColonyPlace. If add_insect tries to add more Ants than is allowed, an
AssertionError is raised.
There can be any number of Bees in a Place.
"""
if isinstance(insect, Ant):
assert self.ant is None, \
'The place {place} cannot hold both {current} and {added}' \
.format(place=self, current=self.ant, added=insect)
assert self.ant is None, f'The place {self} cannot hold both {self.ant} and {insect}'
self.ant = insect
insect.place = self
else:
......@@ -107,57 +93,29 @@ class ColonyPlace(Place):
Remove an Insect from this Place.
"""
if isinstance(insect, Ant):
assert insect is self.ant, \
'The ant {ant} is not at {place} to be removed' \
.format(ant=insect, place=self)
assert insect is self.ant, f'The ant {insect} is not at {self} to be removed'
self.ant = None
insect.place = None
else:
super().remove_insect(insect)
class Respite(ColonyPlace):
"""
A respite is a kind of Place that boosts Bees' health when they enter it.
"""
def __init__(self, world_x, world_y, health_boost=1):
"""
Create a Respite that boosts Bees' health by the given amount.
"""
super().__init__(world_x, world_y)
self.health_boost = health_boost
def add_insect(self, insect):
"""
Add an Insect to this Place. If it is a Bee, increase its health.
"""
if isinstance(insect, Bee):
insect.health += self.health_boost
super().add_insect(insect)
class UnitType(Enum):
"""
A UnitType represents how an Insect looks to the player. It is possible
for otherwise identical Insects to have different UnitTypes, and it is also
possible for fundamentally different Insects to have the same UnitType.
A UnitType represents how an Insect looks to the player. It is possible for otherwise identical Insects to have
different UnitTypes, and it is also possible for fundamentally different Insects to have the same UnitType.
Any changes to this Enum should be accompanied by corresponding changes to
the frontend in main.py and tower.kv.
Any changes to this Enum should be accompanied by corresponding changes to the frontend in main.py and tower.kv.
STANDARD_ANT_ARCHETYPES, which appears later in this file, may also be affected.
"""
BEE = 'BEE'
HARVESTER = 'HARVESTER'
SHORT_THROWER = 'SHORT_THROWER'
THROWER = 'THROWER'
LONG_THROWER = 'LONG_THROWER'
WALL = 'WALL'
class Insect(object):
"""
An Insect, the base class of Ant and Bee, has health and damage and also a
Place.
An Insect, the base class of Ant and Bee, has health and damage and also a Place.
"""
def __init__(self, unit_type, health=1, damage=0):
......@@ -171,8 +129,7 @@ class Insect(object):
def reduce_health(self, amount):
"""
Reduce health by amount, and remove the insect from its place if it has
no health remaining.
Reduce health by amount, and remove the insect from its place if it has no health remaining.
"""
self.health -= amount
if self.health <= 0 and self.place is not None:
......@@ -185,9 +142,7 @@ class Insect(object):
pass
def __repr__(self):
return '{kind}({unit_type}, {health}, {place})' \
.format(kind=type(self).__name__, unit_type=self.unit_type,
health=self.health, place=self.place)
return f'{type(self).__name__}({self.unit_type}, {self.health}, {self.place})'
class Bee(Insect):
......@@ -197,80 +152,69 @@ class Bee(Insect):
def __init__(self, health, damage, delay):
"""
Create a Bee with the given health and damage and make it wait for
delay turns before acting.
Create a Bee with the given health and damage and make it wait for delay turns before acting.
"""
super().__init__(UnitType.BEE, health, damage)
self.delay = delay
@staticmethod
def _count_ants_in_tunnel(place):
# noinspection PyProtectedMember
"""
Recursively count the number of Ants in place or in any of the Places
that follow after it up until the end of the tunnel.
Given a network of Places:
>>> a, b, c, d, e0, e1 = [ColonyPlace(i, i) for i in range(6)]
>>> a.connect_to(b)
>>> b.connect_to(c)
>>> c.connect_to(d)
>>> d.connect_to(e0)
>>> d.connect_to(e1)
a "tunnel" is a sequence of connected places up until a branching
point. So in this example the tunnel is (a, b, c, d) because after d
the network branches out to e0 and e1.
_count_ants_in_tunnel will count the number of Ants in the tunnel
starting at the Place given. For an empty tunnel, it will return zero:
>>> Bee._count_ants_in_tunnel(a)
0
But for an occupied tunnel it will return a positive count:
>>> a.add_insect(Ant(None, 0))
>>> Bee._count_ants_in_tunnel(a)
1
And the count will include Ants anywhere in the tunnel:
>>> d.add_insect(Ant(None, 0))
>>> Bee._count_ants_in_tunnel(a)
2
Note however that Ants before the given Place are not counted, even if
the tunnel could have started earlier:
>>> Bee._count_ants_in_tunnel(b)
1
And that Ants past a branching point are not counted either:
>>> e0.add_insect(Ant(None, 0))
>>> Bee._count_ants_in_tunnel(a)
2
>>> e1.add_insect(Ant(None, 0))
>>> Bee._count_ants_in_tunnel(a)
2
"""
return 0 # stub
def fly(self):
"""
Move from the Bee's current Place to the destination of that Place. If
there are multiple destinations, choose whichever one is the entrance
to the least-defended tunnel. Break any remaining ties by choosing the
the first of the tying Places.
Move from the Bee's current Place to the destination of that Place. If there are multiple destinations, choose
one at random.
"""
if len(self.place.destinations) > 0:
destinations = copy(self.place.destinations)
shuffle(destinations)
destination = min(destinations, key=Bee._count_ants_in_tunnel)
destination = choice(self.place.destinations)
self.place.remove_insect(self)
destination.add_insect(self)
def act(self, game_state):
"""
A Bee stings the Ant that defends its place if it is blocked, but moves
to a new place otherwise. But a Bee cannot take any action if it is
still delayed.
A Bee stings the Ant that defends its place and doesn't fly if it is blocked:
>>> place = ColonyPlace(1, 0)
>>> next_place = ColonyPlace(0, 0)
>>> place.connect_to(next_place)
>>> state = GameState(places=[place, next_place], queen_place=None, ant_archetypes=[], food=0)
>>> ant = Ant(unit_type=None, food_cost=0, health=5)
>>> place.add_insect(ant)
>>> bee = Bee(health=1, damage=1, delay=0)
>>> place.add_insect(bee)
>>> bee.act(state)
>>> ant.health
4
>>> bee.place
ColonyPlace(1, 0)
but moves to a new place otherwise:
>>> place = ColonyPlace(1, 0)
>>> next_place = ColonyPlace(0, 0)
>>> place.connect_to(next_place)
>>> state = GameState(places=[place, next_place], queen_place=None, ant_archetypes=[], food=0)
>>> ant = Ant(unit_type=None, food_cost=0, health=5)
>>> next_place.add_insect(ant)
>>> bee = Bee(health=1, damage=1, delay=0)
>>> place.add_insect(bee)
>>> bee.act(state)
>>> ant.health
5
>>> bee.place
ColonyPlace(0, 0)
However, a Bee cannot take any action if it is still delayed; its delay decreases instead:
>>> place = ColonyPlace(1, 0)
>>> next_place = ColonyPlace(0, 0)
>>> place.connect_to(next_place)
>>> state = GameState(places=[place, next_place], queen_place=None, ant_archetypes=[], food=0)
>>> ant = Ant(unit_type=None, food_cost=0, health=5)
>>> place.add_insect(ant)
>>> bee = Bee(health=1, damage=1, delay=4)
>>> place.add_insect(bee)
>>> bee.act(state)
>>> ant.health
5
>>> bee.place
ColonyPlace(1, 0)
>>> bee.delay
3
"""
if self.delay > 0:
self.delay -= 1
......@@ -295,13 +239,13 @@ class Ant(Insect):
self.food_cost = food_cost
# noinspection PyMethodMayBeStatic
def blocks(self):
def blocks(self): # pylint: disable=no-self-use
"""
Determine whether the Ant blocks Bees from advancing.
"""
return True
def get_target_place(self):
def get_target_place(self): # pylint: disable=no-self-use
"""
Return the Place that the Ant's throws are targeting, if any.
"""
......@@ -315,8 +259,7 @@ class Harvester(Ant):
def __init__(self, unit_type, food_cost, health, production):
"""
Create a Harvester with the given type, cost, health and per-turn food
production.
Create a Harvester with the given type, cost, health and per-turn food production.
"""
super().__init__(unit_type, food_cost, health)
self.production = production
......@@ -333,109 +276,78 @@ class Thrower(Ant):
A Thrower throws a leaf each turn at the nearest Bee in its range.
"""
def __init__(self, unit_type, food_cost, health, damage, minimum_range=0,
maximum_range=float('inf')):
def __init__(self, unit_type, food_cost, health, damage, ammo, minimum_range=0, maximum_range=0):
"""
Create a Thrower with the given type, cost, health, and damage.
A Thrower can only target bees at distances between its minimum range
and maximum range, inclusive. A range of 0 corresponds to the Place
the Ant is in, a range of 1 corresponds to all places leading to there,
etc. Furthermore, Throwers can only target bees in the colony; they
cannot, for instance, target bees still in the hive.
A Thrower can only target bees at distances between its minimum range and maximum range, inclusive. A range of
0 corresponds to the Place the Ant is in, a range of 1 corresponds to all places leading to there, etc.
Furthermore, Throwers can only target bees in the colony; they cannot, for instance, target bees still in the
hive.
"""
super().__init__(unit_type, food_cost, health, damage)
self.ammo = ammo
self.minimum_range = minimum_range
self.maximum_range = maximum_range
@staticmethod
def _get_target_place(candidate, minimum_range, maximum_range):
"""
Recursively identify the nearest Place with a targetable bee. Only
bees in the colony and between minimum_range and maximum_range steps,
inclusive, of the candidate place are considered targetable. (Note
that all steps are counted equally, regardless of the world_x and
world_y of the Places they connect.)
"""
if isinstance(candidate, ColonyPlace) and len(candidate.bees) > 0 and \
minimum_range <= 0 <= maximum_range:
return candidate
for source in candidate.sources:
target = Thrower._get_target_place(source, minimum_range - 1,
maximum_range - 1)
if target is not None:
return target
return None
def get_target_place(self):
"""
Identify the nearest Place with a targetable bee.
Given a network of Places:
>>> z, a, b, c, d, e, f, g = [ColonyPlace(i, i) for i in range(7)] + \
[Place(7, 7)]
>>> a.connect_to(z)
>>> b.connect_to(a)
>>> c.connect_to(a)
>>> d.connect_to(b)
>>> e.connect_to(b)
>>> f.connect_to(c)
>>> g.connect_to(c)
and a Thrower at one of those Places:
>>> thrower = Thrower(UnitType.THROWER, 1, 1, 1)
>>> a.add_insect(thrower)
get_target_place will return None if there are no Bees that can reach
the given Place:
>>> z.add_insect(Bee(1, 1, 0))
>>> thrower.get_target_place() is None
True
It will also return None if there are bees that can reach the Place,
but these Bees are outside the colony:
>>> g.add_insect(Bee(1, 1, 0))
>>> thrower.get_target_place() is None
True
But if there is a Bee in the colony that can reach the Place, and that
Bee is in range, that Bee's place will be returned:
>>> d.add_insect(Bee(1, 1, 0))
>>> thrower.get_target_place()
ColonyPlace(4, 4)
If there are multiple in-range Bees at different Places,
get_target_place will ignore Places that can only be hit by "shooting
through" a valid target:
>>> b.add_insect(Bee(1, 1, 0))
>>> thrower.get_target_place()
ColonyPlace(2, 2)
"""
return self._get_target_place(self.place, self.minimum_range, self.maximum_range)
Identify the nearest Place with a targetable bee. Only bees in the colony and between minimum_range and
maximum_range steps, inclusive, of the candidate place are considered targetable. (Note that all steps are
counted equally, regardless of the world_x and world_y of the Places they connect.) Break ties by ….
"""
if self.maximum_range < 0:
return None
visited = set()
worklist = [(self.place, 0)]
while len(worklist) > 0:
candidate, distance = worklist.pop(0)
if self.minimum_range <= distance and isinstance(candidate, ColonyPlace) and len(candidate.bees) > 0:
return candidate
if candidate not in visited:
visited.add(candidate)
if distance + 1 <= self.maximum_range:
worklist.extend((source, distance + 1) for source in candidate.sources)
return None
def _get_target_bee(self):
"""
Choose a random Bee in the place that the Ant's throws are targeting,
if any.
Choose a random Bee in the place that the Ant's throws are targeting, if any.
"""
target = self.get_target_place()
return choice(target.bees) if target is not None else None
def _hit_bee(self, target_bee):
"""
Apply the effect of a thrown leaf hitting a Bee. Normally, the effect
is damage to the bee, but specialized throwers might have other
effects.
Apply the effect of a thrown leaf hitting a Bee. Normally, the effect is damage to the bee, but specialized
throwers might have other effects.
"""
target_bee.reduce_health(self.damage)
def act(self, game_state):
"""
Throw a leaf at the nearest bee, if any.
If there is a Bee approaching this Ant, and it is in-range, consume one unit of ammo to throw a leaf at that bee
and reduce its health:
>>> # Placeholder
But ignore any Bee that is out of range:
>>> # Placeholder
or that has already flown past this Ant:
>>> # Placeholder
If there are multiple in-range bees approaching, target the one that is nearest:
>>> # Placeholder
And when all of its ammo is consumed, kill the ant:
>>> # Placeholder
"""
target_bee = self._get_target_bee()
if target_bee is not None:
self._hit_bee(target_bee)
self.ammo -= 1
if self.ammo <= 0:
self.reduce_health(self.health)
class GameOutcome(Enum):
......@@ -450,21 +362,18 @@ class GameOutcome(Enum):
class GameState(object):
"""
A GameState represents the state of an entire game: the layout of the world
(including the Insects at each Place and their behaviors), the kinds of
Ants available to the player, and the player's resources.
A GameState represents the state of an entire game: the layout of the world (including the Insects at each Place and
their behaviors), the kinds of Ants available to the player, and the player's resources.
Kinds of ants are represented by archetypes, Ant instances that do not
participate in play themselves, but which are copied to create the Ants
that do appear in the game.
Kinds of ants are represented by archetypes, Ant instances that do not participate in play themselves, but which are
copied to create the Ants that do appear in the game.
"""
def __init__(self, places, queen_place, ant_archetypes, food):
"""
Construct a world from the given places, designating one place as the
Bee's target, offer the player the given archetypes, and provide the
player with the given amount of starting food. The places may be (and
usually should be) prepopulated with insects.
Construct a world from the given places, designating one place as the Bee's target, offer the player the given
archetypes, and provide the player with the given amount of starting food. The places may be (and usually
should be) prepopulated with insects.
"""
self.ant_archetypes = ant_archetypes
self.places = places
......@@ -475,8 +384,7 @@ class GameState(object):
"""
Collect a list of all of the Ants deployed in the world.
"""
return [place.get_defender() for place in self.places
if place.get_defender() is not None]
return [place.get_defender() for place in self.places if place.get_defender() is not None]
def get_bees(self):
"""
......@@ -486,12 +394,11 @@ class GameState(object):
def place_ant(self, ant_archetype, place):
"""
Make a player move to place an Ant based on the given archetype at the
given Place. Return that Ant, or None if the Ant could not be placed.
Ants can only be placed on empty Places.
Make a player move to place an Ant based on the given archetype at the given Place. Return that Ant, or None if
the Ant could not be placed. Ants can only be placed on empty Places.
"""
if ant_archetype is None or place.get_defender() is not None or \
len(place.bees) > 0 or self.food < ant_archetype.food_cost:
if ant_archetype is None or place.get_defender() is not None or len(place.bees) > 0 or\
self.food < ant_archetype.food_cost:
return None
self.food -= ant_archetype.food_cost
ant = deepcopy(ant_archetype)
......@@ -503,21 +410,17 @@ class GameState(object):
Make a player move to sacrifice (kill) an Ant.
"""
if ant is not None:
assert ant.place is not None, \
'Cannot sacrifice {ant}, which is already dead'.format(ant=ant)
assert any(place.get_defender() is ant for place in self.places), \
'Cannot sacrifice {ant}, which belongs to a different game' \
.format(ant=ant)
assert ant.place is not None, f'Cannot sacrifice {ant}, which is already dead'
assert any(place.get_defender() is ant for place in self.places),\
f'Cannot sacrifice {ant}, which belongs to a different game'
ant.reduce_health(ant.health)
def take_turn(self):
"""
If possible, cause one turn of game time to pass. During a turn, Ants
act, and then any surviving Bees act.
If possible, cause one turn of game time to pass. During a turn, Ants act, and then any surviving Bees act.
Return the GameOutcome, GameOutcome.UNRESOLVED if time passed, but
GameOutcome.LOSS or GameOutcome.WIN if time could not pass because the
game is over.
Return the GameOutcome, GameOutcome.UNRESOLVED if time passed, but GameOutcome.LOSS or GameOutcome.WIN if time
could not pass because the game is over.
"""
if len(self.queen_place.bees) > 0:
return GameOutcome.LOSS
......@@ -531,80 +434,58 @@ class GameState(object):
STANDARD_ANT_ARCHETYPES = (
Harvester(UnitType.HARVESTER, food_cost=3, health=1, production=2),
Thrower(UnitType.SHORT_THROWER, food_cost=3, health=1, damage=1,
minimum_range=0, maximum_range=2),
Thrower(UnitType.THROWER, food_cost=7, health=1, damage=1),
Thrower(UnitType.LONG_THROWER, food_cost=3, health=1, damage=1,
minimum_range=4),
Ant(UnitType.WALL, food_cost=4, health=4),
Harvester(UnitType.HARVESTER, food_cost=3, health=1, production=1),
Thrower(UnitType.THROWER, food_cost=7, health=1, damage=1, ammo=4, minimum_range=0, maximum_range=2),
)
def make_standard_hive(center_x, center_y, radius, wave_count=4, wave_size=2,
wave_growth=1, wave_interval=5, bee_health=4,
bee_damage=1):
def make_standard_game(radius=4, wave_count=4, wave_size=2, wave_growth=1, wave_interval=5, bee_health=4, bee_damage=1,
ant_archetypes=STANDARD_ANT_ARCHETYPES, food=15):
"""
Construct a list of Places to represent a Bee hive prepopulated with Bees
that will attack in waves. The hive Places are distributed uniformly in a
square with the given center and radius.
Construct the GameState for the beginning of a standard game, which has the ant queen in the center of a square of
ColonyPlaces, the Bee's hive on the periphery, and Bee's attacking in waves of increasing size. Most of the
specifics of this setup can be varied by specifying non-default arguments.
"""
hive = []
assert radius > 0, 'Cannot create a game with a nonpositive radius'
side_length = 2 * radius + 1
grid = [[ColonyPlace(x, y) for y in range(side_length)] for x in range(side_length)]
queen_place = Place(radius, radius)
# noinspection PyTypeChecker
grid[radius][radius] = queen_place
def distance_from(x, y):
return max(abs(x - radius), abs(y - radius))
boundary = []
for x in range(side_length):
for y in range(side_length):
distance = distance_from(x, y)
if distance == radius:
boundary.append(grid[x][y])
for adjacent_x in range(max(x - 1, 0), min(x + 2, side_length)):
for adjacent_y in range(max(y - 1, 0), min(y + 2, side_length)):
if (adjacent_x, adjacent_y) != (x, y) and distance_from(adjacent_x, adjacent_y) <= distance:
grid[x][y].connect_to(grid[adjacent_x][adjacent_y])
minimum_stretch = (radius + 1) / radius
maximum_stretch = (radius + 3) / radius
all_places = [place for column in grid for place in column]
for wave_index in range(wave_count):
for bee_index in range(wave_size + wave_index * wave_growth):
for _ in range(wave_size + wave_index * wave_growth):
bee = Bee(bee_health, bee_damage, wave_index * wave_interval)
hive_place = Place(center_x + uniform(-radius, radius),
uniform(max(center_y - radius, 0),