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.round = 1 self.cells = [] self.obstacles = [] self._neighbors = {} self.index = {} self.units = {} self.threat = {} self.conversion_path = [] self.cult_leader = None self.opponent_cult_leader = None self.allied_cultists = [] self.owned_units = [] self.opponent_units = [] self.opponent_cultists = [] self.neutrals = [] 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_index(self): self.index = {} self.cult_leader = None self.opponent_cult_leader = None self.allied_cultists = [] self.owned_units = [] self.opponent_units = [] self.opponent_cultists = [] self.neutrals = [] for unit in self.units.values(): self.index[(unit.x, unit.y)] = unit if unit.owner == ME.id: self.owned_units.append(unit) if type(unit) is CultLeader: self.cult_leader = unit else: self.allied_cultists.append(unit) elif unit.owner == OPPONENT.id: self.opponent_units.append(unit) if type(unit) is CultLeader: self.opponent_cult_leader = unit else: self.opponent_cultists.append(unit) else: self.neutrals.append(unit) 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 if self.opponent_cult_leader: for pos in self.neighbors(*self.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 compute_conversion_path(self): conversion_path = [] if self.cult_leader and self.neutrals: conversion_path = self.get_conversion_path( self.cult_leader, key=(lambda pos: pos in self.index and self.index[pos].neutral), limit=min(4, len(self.neutrals)) ) log(conversion_path) self.conversion_path = conversion_path def update(self): log('update indexes') self.update_index() self.update_threat_map() self.compute_conversion_path() # log(self.obstacles + [u.pos for u in self.allied_cultists]) # log([n.pos for n in self.neutrals]) def build_actions(self): actions = Queue() # Leader take cover k0_protect_cult_leader = 30 k_protect_threat_level = -5 k_cover_threat = 10 k_cover_interest = -10 if self.cult_leader: current_threat = self.threat[self.cult_leader.pos] if current_threat: covers = [n for n in self.neighbors(*self.cult_leader.pos) if self.can_move_on(self.cult_leader, n)] for pos in covers: action = ActionMove(self.cult_leader, pos, f'take cover (t: {current_threat})') interest = self.conversion_path and self.conversion_path.steps and pos in [s.pos for s in self.conversion_path.steps] 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) # Convert k_convert_number = -10 k_convert_distance = 10 k_convert_danger = 20 if self.cult_leader and self.conversion_path: path = self.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(self.cult_leader, targets[0]) else: action = ActionMove(self.cult_leader, path[1].pos, f'go convert {",".join([str(t.id) for t in targets])}') actions.put(priority, action) # Shoot opponent units k_shoot_opponent_cultist = 8 k_shoot_opponent_cult_leader = 4 k_shoot_movement_needed = 15 for a in self.allied_cultists: for u in self.opponent_units: shooting_distance = self.shooting_distance(a.pos, u.pos) if not shooting_distance: continue if shooting_distance <= u.SHOOTING_RANGE: # la cible est à portée action = ActionShoot(a, u) priority = (k_shoot_opponent_cult_leader if type( u) is CultLeader else k_shoot_opponent_cultist) * shooting_distance log(self.line_of_sight(a.pos, u.pos)) actions.put(priority, action) else: # la cible est hors de portée, mais elle est plus faible et sans soutien # TODO: implémenter pass # Position k0_position = 50 k_advantage = 40 k_position_distance = 10 k_position_heat = -2 k_position_danger = 10 advantage = len(self.owned_units) > len(self.opponent_units) + len(self.neutrals) for a in self.allied_cultists: if self.threat[a.pos]: # l'unité est déjà dans une zone à risque # TODO: envisager un retrait continue else: # l'unité semble en sécurité, go to front-line nearest_frontline = self.discover(a, key=lambda x: self.threat[x] == 1, limit=1) if not nearest_frontline: log(f" {u.id} can not join nearest frontline") continue path, target = nearest_frontline[0] if path: log(f"{a.id} - {path} - {target}") # already in place priority = k0_position priority += k_position_distance * len(path) priority += k_position_danger * sum(self.threat[p] for p in path) priority += k_advantage * advantage action = ActionMove(a, path[0], f'go to frontline {target} by {path}') actions.put(priority, action) # TODO: action 'take cover' pour les unités aussi # TODO: action 'peace-keeping': tirer sur les neutres qu'on ne pourra pas convertir avant l'ennemi # TODO: action 'intercept': une unité se place entre un tireur ennemi et le leader; en dernier recours # TODO: action 'do nothing' : parfois, c'est la meilleure chose à faire 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, start, target): """ adapted from https://github.com/fragkakis/bresenham/blob/master/src/main/java/org/fragkakis/Bresenham.java if strict is true, None is return if an obstacle interrupted the line; else a partial line is returned (from start to obstacle) """ line = [] x0, y0 = start x1, y1 = target if y0 > y1: # on fait toujours de bas en haut, du coup on inverse au besoin x0, y0, x1, y1 = x1, y1, x0, y0 dx = abs(x1 - x0) dy = abs(y1 - y0) sx = 1 if x0 < x1 else -1 sy = 1 if y0 < y1 else -1 err = dx - dy x, y = x0, y0 while 1: line.append((x, y)) if x == x1 and y == y1: break e2 = 2 * err if e2 > (-1 * dy): err -= dy x += sx if e2 < dx: err += dx y += sy return line 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 current != unit.pos and key(pos): path = current.ancestors[1:] + [current] 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, 0, 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: if nodes: best_node = nodes.get() else: best_node = origin return ConversionPath.make_from_discovery_node(best_node) def _repr_cell(self, pos): return f"{self.threat[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: 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.build_actions() # print("\n" + GRID.graph(), file=sys.stderr) for action in actions.items: log(f"* {action}") try: action = actions.get() except IndexError: log("no action...") action = ActionWait() log(f"exec : {action}") action.exec() GRID.round += 1