import heapq import sys import time debug = True t0 = time.time() def log(*msg): if debug: print("{} - ".format(str(time.time() - t0)[1:5]), *msg, file=sys.stderr, flush=True) def time_to(total, step): """ number of steps to reach total """ return total // step + (1 if total % step > 0 else 0) class BaseClass: def __repr__(self): return f"<{self.__class__.__name__}: {self.__dict__}>" class Node(BaseClass): def __init__(self, pos, path=None): self.pos = pos self.path = path or [] class PathNode(tuple): def __new__(cls, x, y, parent=None): n = tuple.__new__(cls, (x, y)) n.parent = parent n.cost = 0 return n def __repr__(self): return f"<{self[0]}, {self[1]}, c:{self.cost}>" class DiscoveryNode(tuple): def __new__(cls, x, y, cost=0, ancestors=None, matches=None): n = tuple.__new__(cls, (x, y)) n.cost = cost n.ancestors = ancestors if ancestors is not None else [] n.matches = matches if matches is not None else [] return n def __repr__(self): return f"<{self[0]}, {self[1]}>" class Queue(BaseClass): def __init__(self): self.items = [] def __bool__(self): return bool(self.items) def __repr__(self): return str(self.items) def values(self): return (v for _, v in self.items) def put(self, priority, item): while priority in [p for p, _ in self.items]: priority += 1 heapq.heappush(self.items, (priority, item)) def get(self): return heapq.heappop(self.items)[1] def get_items(self): return heapq.heappop(self.items) class ConversionStep: def __init__(self): self.pos = None self.candidates = [] def __repr__(self): return f"<{self.pos}, c:{self.candidates}>" class ConversionPath: def __init__(self): self.steps = [] def __repr__(self): return f"<{self.steps}>" @classmethod def make_from_discovery_node(cls, node): nodes = node.ancestors + [node] path = cls() found = [] for node in nodes: step = ConversionStep() step.pos = tuple(node) for m in node.matches: if m in found: continue step.candidates.append(m) found.append(m) path.steps.append(step) return path def next_candidate(self): path = [] for step in self.steps: path.append(step) if step.candidates: return path return None class Player(BaseClass): def __init__(self, id_): self.id = id_ ME = Player(int(input())) # Input gives the player id: 0 plays first OPPONENT = Player(1 - ME.id) PLAYERS_INDEX = {p.id: p for p in [ME, OPPONENT]} PLAYERS_ORDER = sorted([ME, OPPONENT], key=lambda p: p.id) class Unit(BaseClass): TYPE_CULTIST = 0 TYPE_CULT_LEADER = 1 OWNER_0 = 0 OWNER_1 = 1 NO_OWNER = 2 SHOOTING_RANGE = 6 SHOOTING_MAX_DAMAGE = 7 def __init__(self, id_): self.id = id_ self.hp = 10 self.x = None self.y = None self.owner = None @property def pos(self): return self.x, self.y @property def owned(self): return self.owner == ME.id @property def opponent(self): return self.owner == OPPONENT.id @property def neutral(self): return self.owner == self.NO_OWNER class CultLeader(Unit): pass class Action(BaseClass): pass class ActionWait(Action): def __repr__(self): return f"" def exec(self): print("WAIT") class ActionMove(Action): def __init__(self, unit, pos, message=''): self.unit = unit self.pos = pos self.message = message def __repr__(self): return f"" def exec(self): print(f"{self.unit.id} MOVE {self.pos[0]} {self.pos[1]}") class ActionShoot(Action): def __init__(self, unit, target): self.unit = unit self.target = target def __repr__(self): return f"" def exec(self): print(f"{self.unit.id} SHOOT {self.target.id}") class ActionConvert(Action): def __init__(self, unit, target): self.unit = unit self.target = target def __repr__(self): return f"" def exec(self): print(f"{self.unit.id} CONVERT {self.target.id}") class Grid(BaseClass): def __init__(self, width, height): self.width = width self.height = height self.cells = [] self.obstacles = [] self._neighbors = {} self.index = {} self.units = {} self.round = 1 self.threat = {} self.control = {} self.heat_map = {} def pre_compute(self): self.cells = [(x, y) for x in range(self.width) for y in range(self.height)] for x, y in self.cells: self._neighbors[(x, y)] = [(xn, yn) for xn, yn in [(x, y - 1), (x - 1, y), (x + 1, y), (x, y + 1)] if 0 <= xn < self.width and 0 <= yn < self.height] def reinit_round(self): self.units = {} def update_unit(self, id_, type_, hp, x, y, owner): self.units[id_] = Unit(id_) if type_ != Unit.TYPE_CULT_LEADER else CultLeader(id_) unit = self.units[id_] unit.hp = hp unit.x = x unit.y = y unit.owner = owner def update_threat_map(self): self.threat = {(x, y): 0 for x in range(self.width) for y in range(self.height)} sources = self.opponent_cultists() # On ajoute les neutres voisins du leader ennemi aux sources de menace possible opponent_cult_leader = self.opponent_cult_leader() if opponent_cult_leader: for pos in self.neighbors(*opponent_cult_leader.pos): if pos in self.index and self.index[pos].neutral: sources.append(self.index[pos]) for u in sources: shooting_zone = self.zone(u.pos, Unit.SHOOTING_RANGE) for x, y in shooting_zone: dist = shooting_zone[(x, y)] if not self.line_of_sight(u.pos, (x, y)): continue threat = Unit.SHOOTING_RANGE + 1 - dist if u.neutral: threat //= 2 if threat > self.threat[(x, y)]: self.threat[(x, y)] = threat def update_control(self): self.control = {(x, y): 0 for x in range(self.width) for y in range(self.height)} for u in self.allied_cultists(): shooting_zone = self.zone(u.pos, Unit.SHOOTING_RANGE) for x, y in shooting_zone: dist = shooting_zone[(x, y)] if not self.line_of_sight(u.pos, (x, y)): continue control = Unit.SHOOTING_RANGE + 1 - dist if control > self.control[(x, y)]: self.control[(x, y)] = control def update_heat_map(self): lines = [] self.heat_map = {(x, y): 0 for x in range(self.width) for y in range(self.height)} cult_leader = self.cult_leader() for o in self.opponent_cultists(): if cult_leader: lines += self.line(o.pos, cult_leader.pos) for n in self.neutrals(): lines += self.line(o.pos, n.pos) for pos in lines: self.heat_map[pos] += 1 def update(self): self.index = {} for unit in self.units.values(): self.index[(unit.x, unit.y)] = unit self.update_threat_map() self.update_control() self.update_heat_map() def cult_leader(self): return next((u for u in self.units.values() if type(u) is CultLeader and u.owned), None) def allied_cultists(self): return [u for u in self.units.values() if type(u) is not CultLeader and u.owned] def owned_units(self): return [u for u in self.units.values() if u.owned] def opponent_units(self): return [u for u in self.units.values() if u.opponent] def opponent_cult_leader(self): return next((u for u in self.units.values() if type(u) is CultLeader and not u.owned), None) def opponent_cultists(self): return [u for u in self.units.values() if type(u) is not CultLeader and u.opponent] def neutrals(self): return [u for u in self.units.values() if u.neutral] def list_actions(self): actions = Queue() k_convert_number = -10 k_convert_distance = 10 k_convert_danger = 30 k_shoot_opponent_cultist = 10 k_shoot_opponent_cult_leader = 5 k0_protect_cult_leader = 30 k_protect_threat_level = -5 k_cover_threat = 10 k_cover_interest = -10 k0_position = 50 k_position_distance = 10 k_position_heat = -2 k_position_danger = 5 k_position_advantage = 10 cult_leader = self.cult_leader() opponent_cult_leader = self.opponent_cult_leader() log(self.obstacles + [u.pos for u in self.allied_cultists()]) log([n.pos for n in self.neutrals()]) # compute conversion paths conversion_path = [] if cult_leader and self.neutrals(): conversion_path = self.get_conversion_path( cult_leader, key=(lambda pos: pos in self.index and self.index[pos].neutral), limit=min(4, len(self.neutrals())) ) log(conversion_path) # Conversion des neutres if cult_leader and conversion_path: path = conversion_path.next_candidate() if path: targets = [self.index[c] for c in path[-1].candidates] priority = 0 priority += k_convert_number * len(targets) priority += k_convert_distance * len(path) priority += k_convert_danger * sum([self.threat[s.pos] for s in path]) if len(path) == 1: action = ActionConvert(cult_leader, targets[0]) else: action = ActionMove(cult_leader, path[1].pos, f'go convert {",".join([str(t.id) for t in targets])}') actions.put(priority, action) # Attaque d'unités ennemies targets = self.opponent_cultists() if opponent_cult_leader: targets.append(opponent_cult_leader) advantage = sum([t.hp for t in targets]) < sum([u.hp for u in self.owned_units()]) for a in self.allied_cultists(): for u in targets: shooting_distance = self.shooting_distance(a.pos, u.pos) if shooting_distance and shooting_distance < u.SHOOTING_RANGE: action = ActionShoot(a, u) priority = (k_shoot_opponent_cult_leader if type( u) is CultLeader else k_shoot_opponent_cultist) * shooting_distance actions.put(priority, action) # Position for a in self.allied_cultists(): # on garde les trois points les plus chauds hot_spots = sorted(self.heat_map.items(), key=lambda p: p[1], reverse=True)[:3] # results = self.discover(a.pos, key=lambda x: x in [s[0] for s in hot_spots]) # for path, target_pos in results: # if not path: # break for spot_pos, heat in hot_spots: priority = k0_position priority += k_position_heat * heat priority += k_position_distance * self.manhattan(a.pos, spot_pos) priority += k_position_danger * self.threat[spot_pos] action = ActionMove(a, spot_pos, f'pos on {spot_pos}') actions.put(priority, action) # Mise en sécurité du chef if cult_leader: current_threat = self.threat[cult_leader.pos] if current_threat: covers = [n for n in self.neighbors(*cult_leader.pos) if self.can_move_on(cult_leader, n)] for pos in covers: action = ActionMove(cult_leader, pos, 'take cover') interest = conversion_path and conversion_path.steps and conversion_path.steps[0].pos == pos priority = k0_protect_cult_leader priority += k_protect_threat_level * current_threat priority += k_cover_threat * self.threat[pos] priority += k_cover_interest * interest actions.put(priority, action) return actions def in_grid(self, pos): return 0 <= pos[0] < self.width and 0 <= pos[1] < self.height def can_see_trough(self, pos): return self.in_grid(pos) and pos not in self.obstacles and pos not in self.index def can_move_on(self, unit, pos): return self.in_grid(pos) and pos not in self.obstacles and (pos not in self.index or self.index[pos] is unit) def can_discover(self, pos): return self.in_grid(pos) and pos not in self.obstacles def moving_cost(self, unit, pos): if not self.can_move_on(unit, pos): return -1 return 1 + self.threat[pos] @staticmethod def manhattan(from_, to_): xa, ya = from_ xb, yb = to_ return abs(xa - xb) + abs(ya - yb) def neighbors(self, x, y): return self._neighbors[(x, y)] def zone(self, pos, radius): x0, y0 = pos zone = {} for x in range(max(x0 - radius, 0), min(x0 + radius, self.width)): for y in range(max(y0 - radius, 0), min(y0 + radius, self.height)): dist = self.manhattan(pos, (x, y)) if dist <= radius: zone[(x, y)] = dist return zone @classmethod def line(cls, from_, to_): """ Implementation of bresenham's algorithm """ xa, ya = from_ xb, yb = to_ if (xa, ya) == (xb, yb): return [(xa, ya)] # diagonal symmetry vertically_oriented = (abs(yb - ya) > abs(xb - xa)) if vertically_oriented: ya, xa, yb, xb = xa, ya, xb, yb # horizontal symmetry reversed_sym = (xa > xb) if reversed_sym: xb, yb, xa, ya = xa, ya, xb, yb # angle dx, dy = xb - xa, yb - ya alpha = (abs(dy) / dx) offset = 0.0 step = 1 if dy > 0 else -1 result = [] y_ = ya for x_ in range(xa, xb + 1): result.append((y_, x_) if vertically_oriented else (x_, y_)) offset += alpha if offset > 0.5: y_ += step offset -= 1.0 if reversed_sym: result.reverse() return result def line_of_sight(self, from_, to_): line = self.line(from_, to_)[1:] return line if all(self.can_see_trough(c) for c in line[:-1]) else [] def shooting_distance(self, from_, to_): return len(self.line_of_sight(from_, to_)) def path(self, unit, target): nodes = Queue() its, break_on = 0, 400 origin = PathNode(*unit.pos) nodes.put(0, origin) while nodes: current = nodes.get() if current == target: path = [] previous = current while previous: if previous != unit.pos: path.insert(0, previous) previous = previous.parent return path neighbors = self.neighbors(*current) for x, y in neighbors: its += 1 if its > break_on: log(" pathfinding broken") return None if (x, y) == current.parent: continue if not self.can_move_on(unit, (x, y)): continue moving_cost = self.moving_cost(unit, (x, y)) cost = current.cost + moving_cost priority = cost + 10 * Grid.manhattan((x, y), target) node = PathNode(x, y, current) node.cost = cost nodes.put(priority, node) return None def discover(self, unit, key, limit=5): paths = [] nodes = [] its, break_on = 0, 2000 origin = DiscoveryNode(*unit.pos) nodes.append(origin) while nodes: current = nodes.pop(0) # l'ordre est important, pour que les premiers indexés soient les premiers analysés neighbors = self.neighbors(*current) for pos in neighbors: its += 1 if its > break_on: log(f" discovery broke, {len(paths)} results") return paths if key(pos): path = current.ancestors[:-1:-1] + [current] # reverse ancestors after having removed the start paths.append((path, pos)) if len(paths) >= limit: return paths continue if pos in current.ancestors: continue if not self.can_move_on(unit, pos): continue node = DiscoveryNode(*pos, current.ancestors + [current]) nodes.append(node) return paths def get_conversion_path(self, unit, key, limit=5): """ essaies de trouver le meilleur chemin pour relier des cases dont au moins une voisine valide la condition 'key' (dans la limite de 'limit')""" nodes = Queue() winners = Queue() its, break_on = 0, 1000 origin = DiscoveryNode(*unit.pos) abandon_at = 120 # number of paths explored nodes.put(0, origin) for n in self.neighbors(*unit.pos): if key(n): origin.matches.append(n) while nodes: its += 1 if its > break_on: log(f" get_conversion_path broke") break if len(nodes.items) > abandon_at: log("> get_conversion_path early exit") break current = nodes.get() for pos in self.neighbors(*current): if not self.can_move_on(unit, pos): continue moving_cost = 1 matches = [] for n in self.neighbors(*pos): if n not in current.matches and key(n): matches.append(n) cost = current.cost + moving_cost priority = 1000 * cost - (2000 * (len(current.matches) + len(matches))) priority += 100 * len( [a for a in current.ancestors if a == pos]) # décourage de revenir à une case visitée node = DiscoveryNode( pos[0], pos[1], cost, current.ancestors + [current], current.matches + matches ) if matches: winners.put(40 * node.cost - 100 * len(node.matches), node) nodes.put(priority, node) if len(current.matches) >= limit or its > break_on: break try: best_node = winners.get() except IndexError: best_node = nodes.get() return ConversionPath.make_from_discovery_node(best_node) def _repr_cell(self, pos): # return f"{self.control[pos]}/{self.threat[pos]}" # return self.heat_map[pos] if pos in self.obstacles: return "X" unit = self.index.get(pos, None) if type(unit) is CultLeader: return "C" elif unit is None: return "." else: return "U" def graph(self): return "\n".join( ["|".join([str(self._repr_cell((x, y))) for x in range(self.width)]) for y in range(self.height)]) # Create grid GRID = Grid(*[int(i) for i in input().split()]) obstacles_input = [input() for y in range(GRID.height)] GRID.obstacles = [(x, y) for y, row in enumerate(obstacles_input) for x, val in enumerate(row) if val == 'x'] GRID.pre_compute() while 1: # TODO : en cas de choix entre plusieurs neutres à convertir, privilégier un neutre se trouvant entre un ennemi et le leader # TODO: Laisser l'algo de découverte des cibles à convertir passer outre les alliés, ajouter une action "dégager le passage" pour ces unités # (prévoir le cas où cet allié ne peut pas se dégager). Le fait de passer outre un allié doit augmenter le coût de mouvement de 1 # TODO : Etre plus prudent dans le positionnement des unités; ne pas s'attaquer à plus fort que soi, et décourager plus fortement l'entrée dans une # zone de menace # TODO: donner un coup à l'action "ne rien faire", car c'est parfois la meilleure chose à faire... # TODO: ajouter une action "s'interposer" où une unité s'interpose entre le leader et un ennemi ; à mettre en balance avec le 'take cover' # TODO: remplacer le discover par un algo qui cherche le plus court chemin pour relier tous les neutres les uns après les autres GRID.reinit_round() for _ in range(int(input())): GRID.update_unit(*[int(j) for j in input().split()]) log(f"start round {GRID.round}") GRID.update() actions = GRID.list_actions() for action in actions.items: log(f"* {action}") # print("\n" + GRID.graph(), file=sys.stderr) try: action = actions.get() except IndexError: log("no action...") action = ActionWait() log(f"exec : {action}") action.exec() GRID.round += 1