Package horizons :: Package ai :: Package aiplayer :: Module villagebuilder
[hide private]
[frames] | no frames]

Source Code for Module horizons.ai.aiplayer.villagebuilder

  1  # ################################################### 
  2  # Copyright (C) 2008-2017 The Unknown Horizons Team 
  3  # team@unknown-horizons.org 
  4  # This file is part of Unknown Horizons. 
  5  # 
  6  # Unknown Horizons is free software; you can redistribute it and/or modify 
  7  # it under the terms of the GNU General Public License as published by 
  8  # the Free Software Foundation; either version 2 of the License, or 
  9  # (at your option) any later version. 
 10  # 
 11  # This program is distributed in the hope that it will be useful, 
 12  # but WITHOUT ANY WARRANTY; without even the implied warranty of 
 13  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
 14  # GNU General Public License for more details. 
 15  # 
 16  # You should have received a copy of the GNU General Public License 
 17  # along with this program; if not, write to the 
 18  # Free Software Foundation, Inc., 
 19  # 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA 
 20  # ################################################### 
 21   
 22  import copy 
 23  import logging 
 24  import math 
 25  from collections import defaultdict, deque 
 26   
 27  from horizons.ai.aiplayer.areabuilder import AreaBuilder 
 28  from horizons.ai.aiplayer.basicbuilder import BasicBuilder 
 29  from horizons.ai.aiplayer.constants import BUILD_RESULT, BUILDING_PURPOSE 
 30  from horizons.constants import AI, BUILDINGS 
 31  from horizons.entities import Entities 
 32  from horizons.util.shapes import Rect, distances 
33 34 35 -class VillageBuilder(AreaBuilder):
36 """ 37 An object of this class manages the village area of a settlement. 38 39 Important attributes: 40 * plan: a dictionary of the form {(x, y): (purpose, (section, seq_no)), ...} where 41 purpose is one of the BUILDING_PURPOSE constants, section is the sequence number 42 of the village section and seq_no is the sequence number of a residence or None 43 if it is another type of building. The plan is created in the beginning and 44 changed only when land is lost. 45 * special_building_assignments: {BUILDING_PURPOSE constant: {village producer coordinates: [residence coordinates, ...]}} 46 * tent_queue: deque([(x, y), ...]) of remaining residence spots in the right order 47 * num_sections: number of sections in the area 48 * current_section: 1-based number of the section that is being filled with residences 49 * roads_built: boolean showing whether all planned roads in the area have been built 50 """ 51 52 log = logging.getLogger("ai.aiplayer") 53
54 - def __init__(self, settlement_manager):
55 super().__init__(settlement_manager) 56 self.__init(settlement_manager) 57 if not self.land_manager.feeder_island: 58 self._create_plan()
59
60 - def __init(self, settlement_manager):
61 self.land_manager = settlement_manager.land_manager 62 self.tent_queue = deque() 63 self._init_cache() 64 self.roads_built = False 65 self.personality = self.owner.personality_manager.get('VillageBuilder') 66 67 if self.land_manager.feeder_island: 68 self.num_sections = 0 69 self.current_section = 0
70
71 - def save(self, db):
72 super().save(db) 73 db("INSERT INTO ai_village_builder(rowid, settlement_manager, num_sections, current_section) VALUES(?, ?, ?, ?)", 74 self.worldid, self.settlement_manager.worldid, self.num_sections, self.current_section) 75 76 db_query = 'INSERT INTO ai_village_builder_plan(village_builder, x, y, purpose, section, seq_no) VALUES(?, ?, ?, ?, ?, ?)' 77 for (x, y), (purpose, (section, seq_no)) in self.plan.items(): 78 db(db_query, self.worldid, x, y, purpose, section, seq_no)
79
80 - def _load(self, db, settlement_manager):
81 db_result = db("SELECT rowid, num_sections, current_section FROM ai_village_builder WHERE settlement_manager = ?", settlement_manager.worldid) 82 worldid, self.num_sections, self.current_section = db_result[0] 83 super()._load(db, settlement_manager, worldid) 84 self.__init(settlement_manager) 85 86 db_result = db("SELECT x, y, purpose, section, seq_no FROM ai_village_builder_plan WHERE village_builder = ?", worldid) 87 for x, y, purpose, section, seq_no in db_result: 88 self.plan[(x, y)] = (purpose, (section, seq_no)) 89 if purpose == BUILDING_PURPOSE.ROAD: 90 self.land_manager.roads.add((x, y)) 91 92 self._recreate_tent_queue() 93 self._create_special_village_building_assignments()
94
95 - def _get_village_section_coordinates(self, start_x, start_y, width, height):
96 """Return set([(x, y), ...]) of usable coordinates in the rectangle defined by the parameters.""" 97 warehouse_coords_set = set(self.land_manager.settlement.warehouse.position.tuple_iter()) 98 result = set() 99 for dx in range(width): 100 for dy in range(height): 101 coords = (start_x + dx, start_y + dy) 102 if coords in self.land_manager.village and self.land_manager.coords_usable(coords) and coords not in warehouse_coords_set: 103 result.add(coords) 104 return result
105
106 - def _create_plan(self):
107 """ 108 Create the area plan. 109 110 The algorithm: 111 * find a way to cut the village area into rectangular section_plans 112 * each section gets a plan with a main square, roads, and residence locations 113 * the plan is stitched together and other village buildings are by replacing some 114 of the residences 115 """ 116 # Sets of coordinates used for calulate the width and height 117 xs = {x for (x, _) in self.land_manager.village} 118 ys = {y for (_, y) in self.land_manager.village} 119 120 width = max(xs) - min(xs) + 1 121 height = max(ys) - min(ys) + 1 122 horizontal_sections = int(math.ceil(float(width) / self.personality.max_village_section_size)) 123 vertical_sections = int(math.ceil(float(height) / self.personality.max_village_section_size)) 124 125 section_plans = [] # [{(x, y): BUILDING_PURPOSE constant, ...}, ...] 126 vertical_roads = set() # set([x, ...]) 127 horizontal_roads = set() # set([y, ...]) 128 129 # partition with roads between the sections 130 start_y = min(ys) 131 section_width = width // horizontal_sections 132 section_height = height // vertical_sections 133 section_coords_set_list = [] 134 for i in range(vertical_sections): 135 bottom_road = i + 1 < vertical_sections 136 max_y = min(max(ys), start_y + section_height) 137 current_height = max_y - start_y + 1 138 start_x = min(xs) 139 140 for j in range(horizontal_sections): 141 right_road = j + 1 < horizontal_sections 142 max_x = min(max(xs), start_x + section_width) 143 current_width = max_x - start_x + 1 144 section_coords_set_list.append(self._get_village_section_coordinates(start_x, start_y, current_width - right_road, current_height - bottom_road)) 145 start_x += current_width 146 if i == 0 and right_road: 147 vertical_roads.add(start_x - 1) 148 149 start_y += current_height 150 if bottom_road: 151 horizontal_roads.add(start_y - 1) 152 153 for section_coords_set in section_coords_set_list: 154 section_plan = self._create_section_plan(section_coords_set, vertical_roads, horizontal_roads) 155 section_plans.append(section_plan[1]) 156 157 self._stitch_sections_together(section_plans, vertical_roads, horizontal_roads) 158 self._return_unused_space()
159
160 - def _stitch_sections_together(self, section_plans, vertical_roads, horizontal_roads):
161 """ 162 Complete creating the plan by stitching the sections together and creating the tent queue. 163 164 @param section_plans: list of section plans in the format [{(x, y): BUILDING_PURPOSE constant, ...}, ...] 165 @param vertical_roads: vertical roads between the sections in the form set([x, ...]) 166 @param horizontal_roads: horizontal roads between the sections in the form set([y, ...]) 167 """ 168 169 self.plan = {} 170 ys = set(list(zip(*self.land_manager.village.keys()))[1]) 171 for road_x in vertical_roads: 172 for road_y in ys: 173 coords = (road_x, road_y) 174 if self.land_manager.coords_usable(coords): 175 self.plan[coords] = (BUILDING_PURPOSE.ROAD, (0, None)) 176 177 xs = set(list(zip(*self.land_manager.village.keys()))[0]) 178 for road_y in horizontal_roads: 179 for road_x in xs: 180 coords = (road_x, road_y) 181 if self.land_manager.coords_usable(coords): 182 self.plan[coords] = (BUILDING_PURPOSE.ROAD, (0, None)) 183 184 for i, section_plan in enumerate(section_plans): 185 self._optimize_section_plan(section_plan) 186 tent_lookup = self._create_tent_queue(section_plan) 187 for coords, purpose in section_plan.items(): 188 self.plan[coords] = (purpose, (i, tent_lookup[coords])) 189 self.num_sections = len(section_plans) 190 self.current_section = 0 191 self._reserve_special_village_building_spots() 192 self._recreate_tent_queue() 193 194 # add potential roads to the island's network 195 for coords, (purpose, _) in self.plan.items(): 196 if purpose == BUILDING_PURPOSE.ROAD: 197 self.land_manager.roads.add(coords)
198 199 @classmethod
200 - def _remove_unreachable_roads(cls, section_plan, main_square):
201 """ 202 Remove the roads that can't be reached by starting from the main square. 203 204 @param section_plan: {(x, y): BUILDING_PURPOSE constant, ...} 205 @param main_square: Rect representing the position of the main square 206 """ 207 208 moves = [(-1, 0), (0, -1), (0, 1), (1, 0)] 209 reachable = set() 210 queue = deque() 211 for (x, y) in main_square.tuple_iter(): 212 for (dx, dy) in moves: 213 coords = (x + dx, y + dy) 214 if coords in section_plan and section_plan[coords] == BUILDING_PURPOSE.ROAD: 215 queue.append(coords) 216 reachable.add(coords) 217 218 while queue: 219 (x, y) = queue.popleft() 220 for dx, dy in moves: 221 coords = (x + dx, y + dy) 222 if coords in section_plan and section_plan[coords] == BUILDING_PURPOSE.ROAD and coords not in reachable: 223 reachable.add(coords) 224 queue.append(coords) 225 226 to_remove = [] 227 for coords, purpose in section_plan.items(): 228 if purpose == BUILDING_PURPOSE.ROAD and coords not in reachable: 229 to_remove.append(coords) 230 for coords in to_remove: 231 section_plan[coords] = BUILDING_PURPOSE.NONE
232
233 - def _get_possible_building_positions(self, section_coords_set, size):
234 """Return {(x, y): Rect, ...} that contains every size x size potential building location where only the provided coordinates are legal.""" 235 result = {} 236 for (x, y) in sorted(section_coords_set): 237 ok = True 238 for dx in range(size[0]): 239 for dy in range(size[1]): 240 coords = (x + dx, y + dy) 241 if coords not in section_coords_set or not self.land_manager.coords_usable(coords): 242 ok = False 243 break 244 if not ok: 245 break 246 if ok: 247 result[(x, y)] = Rect.init_from_topleft_and_size_tuples((x, y), size) 248 return result
249
250 - def _create_section_plan(self, section_coords_set, vertical_roads, horizontal_roads):
251 """ 252 Create the section plan that contains the main square, roads, and residence positions. 253 254 The algorithm is as follows: 255 * place the main square 256 * form a road grid to support the tents 257 * choose the best one by preferring the one with more residence locations and less 258 unreachable / blocked / parallel side by side roads. 259 260 @param section_plans: list of section plans in the format [{(x, y): BUILDING_PURPOSE constant, ...}, ...] 261 @param vertical_roads: vertical roads between the sections in the form set([x, ...]) 262 @param horizontal_roads: horizontal roads between the sections in the form set([y, ...]) 263 @return: (number of residences in the plan, the plan in the form {(x, y): BUILDING_PURPOSE constant} 264 """ 265 266 best_plan = {} 267 best_tents = 0 268 best_value = -1 269 tent_squares = [(0, 0), (0, 1), (1, 0), (1, 1)] 270 road_connections = [(-1, 0), (-1, 1), (0, -1), (0, 2), (1, -1), (1, 2), (2, 0), (2, 1)] 271 tent_radius_sq = Entities.buildings[BUILDINGS.RESIDENTIAL].radius ** 2 272 273 xs = set(x for (x, _) in section_coords_set) 274 for x in vertical_roads: 275 if x - 1 in xs or x + 1 in xs: 276 xs.add(x) 277 xs = sorted(xs) 278 279 ys = set(y for (_, y) in section_coords_set) 280 for y in horizontal_roads: 281 if y - 1 in ys or y + 1 in ys: 282 ys.add(y) 283 ys = sorted(ys) 284 285 distance_rect_rect_sq = distances.distance_rect_rect_sq 286 possible_road_positions = self._get_possible_building_positions(section_coords_set, (1, 1)) 287 possible_residence_positions = self._get_possible_building_positions(section_coords_set, Entities.buildings[BUILDINGS.RESIDENTIAL].size) 288 possible_main_square_positions = self._get_possible_building_positions(section_coords_set, Entities.buildings[BUILDINGS.MAIN_SQUARE].size) 289 290 for (x, y), main_square in sorted(possible_main_square_positions.items()): 291 section_plan = dict.fromkeys(section_coords_set, BUILDING_PURPOSE.NONE) 292 bad_roads = 0 293 good_tents = 0 294 double_roads = 0 295 296 # place the main square 297 for coords in main_square.tuple_iter(): 298 section_plan[coords] = BUILDING_PURPOSE.RESERVED 299 section_plan[(x, y)] = BUILDING_PURPOSE.MAIN_SQUARE 300 301 # place the roads running parallel to the y-axis 302 last_road_y = None 303 for road_y in ys: 304 if road_y not in horizontal_roads: 305 if road_y < y: 306 if (y - road_y) % 5 != 1: 307 continue 308 else: 309 if road_y < y + 6 or (road_y - y) % 5 != 1: 310 continue 311 312 if last_road_y == road_y - 1: 313 double_roads += 1 314 last_road_y = road_y 315 316 for road_x in xs: 317 if road_x not in vertical_roads: 318 coords = (road_x, road_y) 319 if coords in possible_road_positions: 320 section_plan[coords] = BUILDING_PURPOSE.ROAD 321 else: 322 bad_roads += 1 323 324 # place the roads running parallel to the x-axis 325 last_road_x = None 326 for road_x in xs: 327 if road_x not in vertical_roads: 328 if road_x < x: 329 if (x - road_x) % 5 != 1: 330 continue 331 else: 332 if road_x < x + 6 or (road_x - x) % 5 != 1: 333 continue 334 335 if last_road_x == road_x - 1: 336 double_roads += 1 337 last_road_x = road_x 338 339 for road_y in ys: 340 if road_y not in horizontal_roads: 341 coords = (road_x, road_y) 342 if coords in possible_road_positions: 343 section_plan[coords] = BUILDING_PURPOSE.ROAD 344 else: 345 bad_roads += 1 346 347 if bad_roads > 0: 348 self._remove_unreachable_roads(section_plan, main_square) 349 350 # place the tents 351 for coords, position in sorted(possible_residence_positions.items()): 352 ok = True 353 for dx, dy in tent_squares: 354 coords2 = (coords[0] + dx, coords[1] + dy) 355 if section_plan[coords2] != BUILDING_PURPOSE.NONE: 356 ok = False 357 break 358 if not ok: 359 continue 360 if distance_rect_rect_sq(main_square, position) > tent_radius_sq: 361 continue # unable to build or out of main square range 362 363 # is there a road connection? 364 ok = False 365 for dx, dy in road_connections: 366 coords2 = (coords[0] + dx, coords[1] + dy) 367 if coords2 in section_plan and section_plan[coords2] == BUILDING_PURPOSE.ROAD: 368 ok = True 369 break 370 371 # connection to a road tile exists, build the tent 372 if ok: 373 for dx, dy in tent_squares: 374 section_plan[(coords[0] + dx, coords[1] + dy)] = BUILDING_PURPOSE.RESERVED 375 section_plan[coords] = BUILDING_PURPOSE.RESIDENCE 376 good_tents += 1 377 378 value = self.personality.tent_value * good_tents - self.personality.bad_road_penalty * bad_roads - self.personality.double_road_penalty * double_roads 379 if best_value < value: 380 best_plan = section_plan 381 best_tents = good_tents 382 best_value = value 383 return (best_tents, best_plan)
384
385 - def _optimize_section_plan(self, section_plan):
386 """Try to fit more residences into the grid.""" 387 # calculate distance from the main square to every tile 388 road_connections = [(-1, 0), (-1, 1), (0, -1), (0, 2), (1, -1), (1, 2), (2, 0), (2, 1)] 389 tent_squares = [(0, 0), (0, 1), (1, 0), (1, 1)] 390 moves = [(-1, 0), (0, -1), (0, 1), (1, 0)] 391 distance = {} 392 queue = deque() 393 394 for coords, purpose in sorted(section_plan.items()): 395 if purpose == BUILDING_PURPOSE.MAIN_SQUARE: 396 for coords in self._get_position(coords, BUILDINGS.MAIN_SQUARE).tuple_iter(): 397 distance[coords] = 0 398 queue.append(coords) 399 400 while queue: 401 (x, y) = queue.popleft() 402 for dx, dy in moves: 403 coords = (x + dx, y + dy) 404 if coords in section_plan and coords not in distance: 405 distance[coords] = distance[(x, y)] + 1 406 queue.append(coords) 407 408 # remove planned tents from the section plan 409 for (x, y) in section_plan: 410 coords = (x, y) 411 if section_plan[coords] == BUILDING_PURPOSE.RESIDENCE: 412 for dx, dy in tent_squares: 413 section_plan[(x + dx, y + dy)] = BUILDING_PURPOSE.NONE 414 415 # create new possible tent position list 416 possible_tents = [] 417 for coords in sorted(section_plan): 418 if coords in distance and section_plan[coords] == BUILDING_PURPOSE.NONE: 419 possible_tents.append((distance[coords], coords)) 420 possible_tents.sort() 421 422 # place the tents 423 for _, (x, y) in possible_tents: 424 ok = True 425 for dx, dy in tent_squares: 426 coords = (x + dx, y + dy) 427 if coords not in section_plan or section_plan[coords] != BUILDING_PURPOSE.NONE: 428 ok = False 429 break 430 if not ok: 431 continue 432 433 # is there a road connection? 434 ok = False 435 for dx, dy in road_connections: 436 coords = (x + dx, y + dy) 437 if coords in section_plan and section_plan[coords] == BUILDING_PURPOSE.ROAD: 438 ok = True 439 break 440 441 # connection to a road tile exists, build the tent 442 if ok: 443 for dx, dy in tent_squares: 444 section_plan[(x + dx, y + dy)] = BUILDING_PURPOSE.RESERVED 445 section_plan[(x, y)] = BUILDING_PURPOSE.RESIDENCE
446
447 - def _return_unused_space(self):
448 """Return the area that remains unused after creating the plan.""" 449 not_needed = [] 450 for coords in self.land_manager.village: 451 if coords not in self.plan or self.plan[coords][0] == BUILDING_PURPOSE.NONE: 452 not_needed.append(coords) 453 for coords in not_needed: 454 # if the warehouse is (partly) in the village area then it needs to be handed over but it won't be in the plan at all 455 if coords in self.plan: 456 del self.plan[coords] 457 self.land_manager.add_to_production(coords)
458 459 @classmethod
460 - def _get_position(cls, coords, building_id):
461 """Return the position Rect of a building of the given type at the given position.""" 462 return Rect.init_from_topleft_and_size_tuples(coords, Entities.buildings[building_id].size)
463
464 - def _get_sorted_building_positions(self, building_purpose):
465 """Return a list of sorted building positions in the form [Rect, ...].""" 466 building_id = BUILDING_PURPOSE.purpose_to_building[building_purpose] 467 return sorted(self._get_position(coords, building_id) for coords, (purpose, _) in self.plan.items() if purpose == building_purpose)
468
469 - def _replace_planned_residence(self, new_purpose, max_buildings, capacity):
470 """ 471 Replace up to max_buildings residence spots with buildings of purpose new_purpose. 472 473 This function is used to amend the existing plan with village producers such as 474 pavilions, schools, and taverns. The goal is to place as few of them as needed 475 while still covering the maximum number of residences. 476 477 @param new_purpose: the BUILDING_PURPOSE constant of the new buildings 478 @param max_buildings: maximum number of residences to replace 479 @param capacity: maximum number of residences one of the new buildings can service 480 """ 481 482 distance_rect_rect_sq = distances.distance_rect_rect_sq 483 distance_rect_tuple = distances.distance_rect_tuple 484 tent_range_sq = Entities.buildings[BUILDINGS.RESIDENTIAL].radius ** 2 485 planned_tents = self._get_sorted_building_positions(BUILDING_PURPOSE.RESIDENCE) 486 487 possible_positions = copy.copy(planned_tents) 488 if new_purpose == BUILDING_PURPOSE.TAVERN: 489 # filter out the positions that are too far from the main squares and the warehouse 490 tavern_radius_sq = Entities.buildings[BUILDINGS.TAVERN].radius ** 2 491 storage_positions = self._get_sorted_building_positions(BUILDING_PURPOSE.MAIN_SQUARE) 492 storage_positions.append(self.settlement_manager.settlement.warehouse.position) 493 possible_positions = [rect for rect in possible_positions if any(distance_rect_rect_sq(rect, storage_rect) <= tavern_radius_sq for storage_rect in storage_positions)] 494 495 num_kept = int(min(len(possible_positions), max(self.personality.min_coverage_building_options, len(possible_positions) * self.personality.coverage_building_option_ratio))) 496 possible_positions = self.session.random.sample(possible_positions, num_kept) 497 498 def get_centroid(planned, blocked): 499 total_x, total_y = 0, 0 500 for position in planned_tents: 501 if position not in blocked: 502 total_x += position.left 503 total_y += position.top 504 mid_x = total_x / float(len(planned) - len(blocked)) 505 mid_y = total_y / float(len(planned) - len(blocked)) 506 return (mid_x, mid_y)
507 508 def get_centroid_distance_pairs(planned, blocked): 509 centroid = get_centroid(planned_tents, blocked) 510 positions = [] 511 for position in planned_tents: 512 if position not in blocked: 513 positions.append((distance_rect_tuple(position, centroid), position)) 514 positions.sort(reverse=True) 515 return positions
516 517 for _ in range(max_buildings): 518 if len(planned_tents) <= 1: 519 break 520 best_score = None 521 best_pos = None 522 523 for replaced_pos in possible_positions: 524 positions = get_centroid_distance_pairs(planned_tents, set([replaced_pos])) 525 score = 0 526 in_range = 0 527 for distance_to_centroid, position in positions: 528 if in_range < capacity and distance_rect_rect_sq(replaced_pos, position) <= tent_range_sq: 529 in_range += 1 530 else: 531 score += distance_to_centroid 532 if best_score is None or best_score > score: 533 best_score = score 534 best_pos = replaced_pos 535 536 in_range = 0 537 positions = list(zip(*get_centroid_distance_pairs(planned_tents, set([best_pos]))))[1] 538 for position in positions: 539 if in_range < capacity and distance_rect_rect_sq(best_pos, position) <= tent_range_sq: 540 planned_tents.remove(position) 541 in_range += 1 542 if not in_range: 543 continue 544 545 possible_positions.remove(best_pos) 546 coords = best_pos.origin.to_tuple() 547 self.register_change_list([coords], new_purpose, (self.plan[coords][1][0], None)) 548
549 - def _reserve_special_village_building_spots(self):
550 """Replace residence spots with special village buildings such as pavilions, schools, taverns, doctors and fire stations.""" 551 num_other_buildings = 0 # the maximum number of each village producer that should be placed 552 residences = len(self.tent_queue) 553 while residences > 0: 554 num_other_buildings += 3 555 residences -= 3 + self.personality.normal_coverage_building_capacity 556 557 self._replace_planned_residence(BUILDING_PURPOSE.PAVILION, num_other_buildings, self.personality.max_coverage_building_capacity) 558 self._replace_planned_residence(BUILDING_PURPOSE.VILLAGE_SCHOOL, num_other_buildings, self.personality.max_coverage_building_capacity) 559 self._replace_planned_residence(BUILDING_PURPOSE.TAVERN, num_other_buildings, self.personality.max_coverage_building_capacity) 560 561 num_fire_stations = max(0, int(round(0.5 + (len(self.tent_queue) - 3 * num_other_buildings) // self.personality.normal_fire_station_capacity))) 562 self._replace_planned_residence(BUILDING_PURPOSE.FIRE_STATION, num_fire_stations, self.personality.max_fire_station_capacity) 563 564 num_doctors = max(0, int(round(0.5 + (len(self.tent_queue) - 3 * num_other_buildings) // self.personality.normal_doctor_capacity))) 565 self._replace_planned_residence(BUILDING_PURPOSE.DOCTOR, num_doctors, self.personality.max_doctor_capacity) 566 567 self._create_special_village_building_assignments()
568
569 - def _create_special_village_building_assignments(self):
570 """ 571 Create an assignment of residence spots to special village building spots. 572 573 This is useful for deciding which of the special village buildings would be most useful. 574 """ 575 576 distance_rect_rect = distances.distance_rect_rect 577 self.special_building_assignments = {} # {BUILDING_PURPOSE constant: {village producer coordinates: [residence coordinates, ...]}} 578 residence_positions = self._get_sorted_building_positions(BUILDING_PURPOSE.RESIDENCE) 579 580 building_types = [] 581 for purpose in [BUILDING_PURPOSE.PAVILION, BUILDING_PURPOSE.VILLAGE_SCHOOL, BUILDING_PURPOSE.TAVERN]: 582 building_types.append((purpose, Entities.buildings[BUILDINGS.RESIDENTIAL].radius, self.personality.max_coverage_building_capacity)) 583 building_types.append((BUILDING_PURPOSE.FIRE_STATION, Entities.buildings[BUILDINGS.FIRE_STATION].radius, self.personality.max_fire_station_capacity)) 584 building_types.append((BUILDING_PURPOSE.DOCTOR, Entities.buildings[BUILDINGS.DOCTOR].radius, self.personality.max_doctor_capacity)) 585 586 for purpose, range, max_capacity in building_types: 587 producer_positions = sorted(self._get_position(coords, BUILDING_PURPOSE.get_building(purpose)) for coords, (pos_purpose, _) in self.plan.items() if pos_purpose == purpose) 588 self.special_building_assignments[purpose] = {} 589 for producer_position in producer_positions: 590 self.special_building_assignments[purpose][producer_position.origin.to_tuple()] = [] 591 592 options = [] 593 for producer_position in producer_positions: 594 for position in residence_positions: 595 distance = distance_rect_rect(producer_position, position) 596 if distance <= range: 597 options.append((distance, producer_position.origin.to_tuple(), position.origin.to_tuple())) 598 options.sort(reverse=True) 599 600 assigned_residence_coords = set() 601 for _, producer_coords, residence_coords in options: 602 if residence_coords in assigned_residence_coords: 603 continue 604 if len(self.special_building_assignments[purpose][producer_coords]) >= max_capacity: 605 continue 606 assigned_residence_coords.add(residence_coords) 607 self.special_building_assignments[purpose][producer_coords].append(residence_coords)
608
609 - def _create_tent_queue(self, section_plan):
610 """ 611 Place the residences of a section in a visually appealing order; save the result in the tent queue. 612 613 The algorithm: 614 * split the residences of the section into blocks where a block is formed of all 615 residence spots that share sides 616 * calculate the distance from the main square to the block 617 * form the final sequence by sorting the blocks by distance to the main square and 618 by sorting the residences of a block by their coordinates 619 620 @return: {(x, y): residence sequence number} 621 """ 622 moves = [(-1, 0), (0, -1), (0, 1), (1, 0)] 623 blocks = [] 624 block = {} 625 626 # form blocks of tents 627 main_square = None 628 for coords, purpose in sorted(section_plan.items()): 629 if purpose == BUILDING_PURPOSE.MAIN_SQUARE: 630 main_square = self._get_position(coords, BUILDINGS.MAIN_SQUARE) 631 if purpose != BUILDING_PURPOSE.RESIDENCE or coords in block: 632 continue 633 block[coords] = len(blocks) 634 635 block_list = [coords] 636 queue = deque() 637 explored = set([coords]) 638 queue.append(coords) 639 while queue: 640 (x, y) = queue.popleft() 641 for dx, dy in moves: 642 coords = (x + dx, y + dy) 643 if coords not in section_plan or coords in explored: 644 continue 645 if section_plan[coords] == BUILDING_PURPOSE.RESIDENCE or section_plan[coords] == BUILDING_PURPOSE.RESERVED: 646 explored.add(coords) 647 queue.append(coords) 648 if section_plan[coords] == BUILDING_PURPOSE.RESIDENCE: 649 block[coords] = len(blocks) 650 block_list.append(coords) 651 blocks.append(block_list) 652 653 # calculate distance from the main square to the block 654 distance_rect_tuple = distances.distance_rect_tuple 655 block_distances = [] 656 for coords_list in blocks: 657 distance = 0 658 for coords in coords_list: 659 distance += distance_rect_tuple(main_square, coords) 660 block_distances.append((distance / len(coords_list), coords_list)) 661 662 # form the sorted tent queue 663 result = defaultdict(lambda: None) 664 if block_distances: 665 for block in list(zip(*sorted(block_distances)))[1]: 666 for coords in sorted(block): 667 result[coords] = len(self.tent_queue) 668 self.tent_queue.append(coords) 669 return result
670
671 - def _recreate_tent_queue(self, removal_location=None):
672 """Recreate the tent queue making sure that the possibly removed location is missing.""" 673 queue = [] 674 for coords, (purpose, (_, seq_no)) in self.plan.items(): 675 if purpose == BUILDING_PURPOSE.RESIDENCE: 676 object = self.island.ground_map[coords].object 677 if object is None or object.id != BUILDINGS.RESIDENTIAL or removal_location == coords: 678 queue.append((seq_no, coords)) 679 if queue: 680 self.tent_queue = deque(list(zip(*sorted(queue)))[1]) 681 else: 682 self.tent_queue = deque()
683
684 - def build_roads(self):
685 """Try to build all roads in the village area, record the result in the field roads_built.""" 686 all_built = True 687 for coords, (purpose, (section, _)) in sorted(self.plan.items()): 688 if section > self.current_section or coords not in self.settlement.ground_map: 689 all_built = False 690 continue 691 if purpose != BUILDING_PURPOSE.ROAD: 692 continue 693 object = self.settlement.ground_map[coords].object 694 if object is not None and not object.buildable_upon: 695 continue 696 if not self.have_resources(BUILDINGS.TRAIL): 697 all_built = False 698 break 699 assert BasicBuilder(BUILDINGS.TRAIL, coords, 0).execute(self.land_manager) 700 self.roads_built = all_built
701
702 - def build_tent(self, coords=None):
703 """Build the next tent (or the specified one if coords is not None).""" 704 if not self.tent_queue: 705 return BUILD_RESULT.IMPOSSIBLE 706 707 # can_trigger_next_section is used to start building the next section when the old one is done 708 # if a tent is built just to extend the area then that can't trigger the next section 709 # TODO: handle extension tents nicer than just letting them die 710 can_trigger_next_section = False 711 if coords is None: 712 coords = self.tent_queue[0] 713 can_trigger_next_section = True 714 715 ok = True 716 x, y = coords 717 owned_by_other = False 718 size = Entities.buildings[BUILDINGS.RESIDENTIAL].size 719 for dx in range(size[0]): 720 for dy in range(size[1]): 721 coords2 = (x + dx, y + dy) 722 if coords2 not in self.settlement.ground_map: 723 ok = False 724 if self.island.ground_map[coords2].settlement is not None: 725 owned_by_other = True 726 727 if ok and not owned_by_other: 728 if not self.have_resources(BUILDINGS.RESIDENTIAL): 729 return BUILD_RESULT.NEED_RESOURCES 730 assert BasicBuilder(BUILDINGS.RESIDENTIAL, (x, y), 0).execute(self.land_manager) 731 732 if ok or owned_by_other: 733 if self.tent_queue[0] == coords: 734 self.tent_queue.popleft() 735 else: 736 for i in range(len(self.tent_queue)): 737 if self.tent_queue[i] == coords: 738 del self.tent_queue[i] 739 break 740 if owned_by_other: 741 self.log.debug('%s tent position owned by other player at (%d, %d)', self, x, y) 742 return BUILD_RESULT.IMPOSSIBLE 743 744 if not ok: 745 # need to extends the area, it is not owned by another player 746 self.log.debug('%s tent position not owned by the player at (%d, %d), extending settlement area instead', self, x, y) 747 return self.extend_settlement(Rect.init_from_topleft_and_size(x, y, size[0], size[1])) 748 749 if not self.roads_built: 750 self.build_roads() 751 if can_trigger_next_section and self.plan[coords][1][0] > self.current_section: 752 self.current_section = self.plan[coords][1][0] 753 return BUILD_RESULT.OK
754
755 - def handle_lost_area(self, coords_list):
756 """ 757 Handle losing the potential land in the given coordinates list. 758 759 Take the following actions: 760 * remove the lost area from the village and road areas 761 * remove village sections with impossible main squares 762 * remove all planned buildings that are now impossible 763 * TODO: if the village area takes too much of the total area then remove / reduce the remaining sections 764 """ 765 766 # remove village sections with impossible main squares 767 removed_sections = set() 768 for coords, (purpose, (section, _)) in self.plan.items(): 769 if purpose != BUILDING_PURPOSE.MAIN_SQUARE: 770 continue 771 possible = True 772 for main_square_coords in self._get_position(coords, BUILDINGS.MAIN_SQUARE).tuple_iter(): 773 if main_square_coords not in self.land_manager.village: 774 possible = False 775 break 776 if not possible: 777 # impossible to build the main square because a part of the area is owned by another player: remove the whole section 778 removed_sections.add(section) 779 780 removed_coords_list = [] 781 for coords, (purpose, (section, _)) in self.plan.items(): 782 if purpose in [BUILDING_PURPOSE.RESERVED, BUILDING_PURPOSE.NONE]: 783 continue 784 position = self._get_position(coords, BUILDING_PURPOSE.get_building(purpose)) 785 building = self.settlement.ground_map[coords].object if coords in self.settlement.ground_map else None 786 787 if section in removed_sections: 788 if purpose == BUILDING_PURPOSE.ROAD: 789 if building is None or building.id != BUILDINGS.TRAIL: 790 removed_coords_list.append(coords) 791 continue # leave existing roads behind 792 elif building is not None and not building.buildable_upon: 793 # TODO: remove the actual building 794 pass 795 796 for building_coords in position.tuple_iter(): 797 removed_coords_list.append(building_coords) 798 else: 799 # remove the planned village buildings that are no longer possible 800 if purpose == BUILDING_PURPOSE.ROAD: 801 if coords not in self.land_manager.village: 802 removed_coords_list.append(coords) 803 continue 804 805 possible = True 806 for building_coords in position.tuple_iter(): 807 if building_coords not in self.land_manager.village: 808 possible = False 809 if possible: 810 continue 811 812 for building_coords in position.tuple_iter(): 813 removed_coords_list.append(building_coords) 814 815 for coords in removed_coords_list: 816 del self.plan[coords] 817 self._recreate_tent_queue() 818 # TODO: renumber the sections 819 # TODO: create a new plan with village producers 820 self._return_unused_space() 821 self._create_special_village_building_assignments() 822 super().handle_lost_area(coords_list)
823
824 - def remove_building(self, building):
825 """Called when a building is removed from the area (the building still exists during the call).""" 826 if building.id == BUILDINGS.RESIDENTIAL: 827 self._recreate_tent_queue(building.position.origin.to_tuple()) 828 829 super().remove_building(building)
830
831 - def display(self):
832 """Show the plan on the map unless it is disabled in the settings.""" 833 if not AI.HIGHLIGHT_PLANS: 834 return 835 836 unknown_color = (255, 0, 0) 837 renderer = self.session.view.renderer['InstanceRenderer'] 838 839 tile_colors = { 840 BUILDING_PURPOSE.MAIN_SQUARE: (255, 0, 255), 841 BUILDING_PURPOSE.RESIDENCE: (255, 255, 255), 842 BUILDING_PURPOSE.ROAD: (30, 30, 30), 843 BUILDING_PURPOSE.VILLAGE_SCHOOL: (128, 128, 255), 844 BUILDING_PURPOSE.PAVILION: (255, 128, 128), 845 BUILDING_PURPOSE.TAVERN: (255, 255, 0), 846 BUILDING_PURPOSE.FIRE_STATION: (255, 64, 64), 847 BUILDING_PURPOSE.DOCTOR: (255, 128, 64), 848 BUILDING_PURPOSE.RESERVED: (0, 0, 255), 849 } 850 for coords, (purpose, _) in self.plan.items(): 851 tile = self.island.ground_map[coords] 852 color = tile_colors.get(purpose, unknown_color) 853 renderer.addColored(tile._instance, *color)
854
855 - def __str__(self):
856 return '{} VillageBuilder({})'.format( 857 self.settlement_manager, 858 self.worldid if hasattr(self, 'worldid') else 'none')
859