Package horizons :: Package world :: Package units :: Package collectors :: Module collector
[hide private]
[frames] | no frames]

Source Code for Module horizons.world.units.collectors.collector

  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   
 23  import logging 
 24  import operator 
 25  from collections import namedtuple 
 26   
 27  from horizons.component.ambientsoundcomponent import AmbientSoundComponent 
 28  from horizons.component.restrictedpickup import RestrictedPickup 
 29  from horizons.component.storagecomponent import StorageComponent 
 30  from horizons.constants import COLLECTORS 
 31  from horizons.ext.enum import Enum 
 32  from horizons.scheduler import Scheduler 
 33  from horizons.util.pathfinding import PathBlockedError 
 34  from horizons.util.python import decorators 
 35  from horizons.util.python.callback import Callback 
 36  from horizons.util.worldobject import WorldObject 
 37  from horizons.world.units.unit import Unit 
38 39 40 -class Collector(Unit):
41 """Base class for every collector. Does not depend on any home building. 42 43 Timeline: 44 * search_job 45 * * get_job 46 * * handle_no_possible_job 47 * * begin_current_job 48 * * * setup_new_job 49 * * * move to target 50 on arrival there: 51 * begin_working 52 after some pretended working: 53 * finish_working 54 * * transfer_res 55 after subclass has done actions to finish job: 56 * end_job 57 """ 58 log = logging.getLogger("world.units.collector") 59 60 work_duration = COLLECTORS.DEFAULT_WORK_DURATION # default is 16 61 destination_always_in_building = False 62 63 # all states, any (subclass) instance may have. Keeping a list in one place 64 # is important, because every state must have a distinct number. 65 # Handling of subclass specific states is done by subclass. 66 states = Enum('idle', # doing nothing, waiting for job 67 'moving_to_target', 68 'working', 69 'moving_home', 70 'waiting_for_animal_to_stop', # herder: wait for job target to finish for collecting 71 'waiting_for_herder', # animal: has stopped, now waits for herder 72 'no_job_walking_randomly', # animal: like idle, but moving instead of standing still 73 'no_job_waiting', # animal: as idle, but no move target can be found 74 # TODO: merge no_job_waiting with idle 75 'decommissioned', # fisher ship: When home building got demolished. No more collecting. 76 ) 77 78 # INIT/DESTRUCT 79
80 - def __init__(self, x, y, slots=1, start_hidden=True, **kwargs):
81 super().__init__(slots=slots, 82 x=x, y=y, 83 **kwargs) 84 85 self.__init(self.states.idle, start_hidden) 86 87 # start searching jobs just when construction (of subclass) is completed 88 Scheduler().add_new_object(self.search_job, self, 1)
89
90 - def __init(self, state, start_hidden):
91 self.state = state 92 self.start_hidden = start_hidden 93 if self.start_hidden: 94 self.hide() 95 96 self.job = None # here we store the current job as Job object
97
98 - def remove(self):
99 """Removes the instance. Useful when the home building is destroyed""" 100 self.log.debug("%s: remove called", self) 101 self.cancel(continue_action=lambda: 42) 102 # remove from target collector list 103 self._abort_collector_job() 104 self.hide() 105 self.job = None 106 super().remove()
107
108 - def _abort_collector_job(self):
109 if self.job is None or self.state == self.states.moving_home: 110 # in the move_home state, there still is a job, but the collector is 111 # already deregistered 112 return 113 if not hasattr(self.job.object, 'remove_incoming_collector'): 114 # when loading a game fails and the world is destructed again, the 115 # worldid may not yet have been resolved to an actual in-game object 116 return 117 self.job.object.remove_incoming_collector(self)
118 119 # SAVE/LOAD 120
121 - def save(self, db):
122 super().save(db) 123 124 # save state and remaining ticks for next callback 125 # retrieve remaining ticks according current callback according to state 126 current_callback = None 127 remaining_ticks = None 128 if self.state == self.states.idle: 129 current_callback = self.search_job 130 elif self.state == self.states.working: 131 current_callback = self.finish_working 132 if current_callback is not None: 133 calls = Scheduler().get_classinst_calls(self, current_callback) 134 assert len(calls) == 1, 'Collector should have callback {} scheduled, but has {}'.format( 135 current_callback, [str(i) for i in Scheduler().get_classinst_calls(self).keys()]) 136 remaining_ticks = max(list(calls.values())[0], 1) # save a number > 0 137 138 db("INSERT INTO collector(rowid, state, remaining_ticks, start_hidden) VALUES(?, ?, ?, ?)", 139 self.worldid, self.state.index, remaining_ticks, self.start_hidden) 140 141 # save the job 142 if self.job is not None: 143 obj_id = -1 if self.job.object is None else self.job.object.worldid 144 # this is not in 3rd normal form since the object is saved multiple times but 145 # it preserves compatibility with old savegames this way. 146 for entry in self.job.reslist: 147 db("INSERT INTO collector_job(collector, object, resource, amount) VALUES(?, ?, ?, ?)", 148 self.worldid, obj_id, entry.res, entry.amount)
149
150 - def load(self, db, worldid):
151 super().load(db, worldid) 152 153 # load collector properties 154 state_id, remaining_ticks, start_hidden = \ 155 db("SELECT state, remaining_ticks, start_hidden FROM collector \ 156 WHERE rowid = ?", worldid)[0] 157 self.__init(self.states[state_id], start_hidden) 158 159 # load job 160 job_db = db("SELECT object, resource, amount FROM collector_job WHERE collector = ?", worldid) 161 if job_db: 162 reslist = [] 163 for obj, res, amount in job_db: 164 reslist.append(Job.ResListEntry(res, amount, False)) 165 # create job with worldid of object as object. This is used to defer the target resolution, 166 # which might not have been loaded 167 self.job = Job(obj, reslist) 168 169 def fix_job_object(): 170 # resolve worldid to object later 171 if self.job: 172 if self.job.object == -1: 173 self.job.object = None # e.g. when hunters have killed their prey 174 else: 175 self.job.object = WorldObject.get_object_by_id(self.job.object)
176 177 # apply state when job object is loaded for sure 178 Scheduler().add_new_object( 179 Callback.ChainedCallbacks( 180 fix_job_object, 181 Callback(self.apply_state, self.state, remaining_ticks)), 182 self, run_in=0 183 )
184
185 - def apply_state(self, state, remaining_ticks=None):
186 """Takes actions to set collector to a state. Useful after loading. 187 @param state: EnumValue from states 188 @param remaining_ticks: ticks after which current state is finished 189 """ 190 if state == self.states.idle: 191 # we do nothing, so schedule a new search for a job 192 Scheduler().add_new_object(self.search_job, self, remaining_ticks) 193 elif state == self.states.moving_to_target: 194 # we are on the way to target, so save the job 195 self.setup_new_job() 196 # and notify us, when we're at target 197 self.add_move_callback(self.begin_working) 198 self.add_blocked_callback(self.handle_path_to_job_blocked) 199 self.show() 200 elif state == self.states.working: 201 # we are at the target and work 202 # register the new job 203 self.setup_new_job() 204 # job finishes in remaining_ticks ticks 205 Scheduler().add_new_object(self.finish_working, self, remaining_ticks)
206 207 # GETTER 208
209 - def get_home_inventory(self):
210 """Returns inventory where collected res will be stored. 211 This could be the inventory of a home_building, or it's own. 212 """ 213 raise NotImplementedError
214
215 - def get_colleague_collectors(self):
216 """Returns a list of collectors, that work for the same "inventory".""" 217 return []
218
219 - def get_collectable_res(self):
220 """Return all resources the collector can collect""" 221 raise NotImplementedError
222
223 - def get_job(self):
224 """Returns the next job or None""" 225 raise NotImplementedError
226 227 # BEHAVIOR
228 - def search_job(self):
229 """Search for a job, only called if the collector does not have a job. 230 If no job is found, a new search will be scheduled in a few ticks.""" 231 self.job = self.get_job() 232 if self.job is None: 233 self.handle_no_possible_job() 234 else: 235 self.begin_current_job()
236
237 - def handle_no_possible_job(self):
238 """Called when we can't find a job. default is to wait and try again in a few secs""" 239 self.log.debug("%s: found no possible job, retry in %s ticks", self, COLLECTORS.DEFAULT_WAIT_TICKS) 240 Scheduler().add_new_object(self.search_job, self, COLLECTORS.DEFAULT_WAIT_TICKS)
241
242 - def setup_new_job(self):
243 """Executes the necessary actions to begin a new job""" 244 self.job.object.add_incoming_collector(self)
245
246 - def check_possible_job_target(self, target):
247 """Checks if we "are allowed" and able to pick up from the target""" 248 # Discard building if it works for same inventory (happens when both are storage buildings 249 # or home_building is checked out) 250 if target.get_component(StorageComponent).inventory is self.get_home_inventory(): 251 #self.log.debug("nojob: same inventory") 252 return False 253 254 if self.has_component(RestrictedPickup): # check if we're allowed to pick up there 255 return self.get_component(RestrictedPickup).pickup_allowed_at(target.id) 256 257 # pathfinding would fit in here, but it's too expensive, 258 # we just do that at targets where we are sure to get a lot of res later on. 259 260 return True
261
262 - def check_possible_job_target_for(self, target, res):
263 """Checks out if we could get res from target. 264 Does _not_ check for anything else (e.g. if we are able to walk there). 265 @param target: possible target. buildings are supported, support for more can be added. 266 @param res: resource id 267 @return: instance of Job or None, if we can't collect anything 268 """ 269 res_amount = target.get_available_pickup_amount(res, self) 270 if res_amount <= 0: 271 #self.log.debug("nojob: no pickup amount") 272 return None 273 274 # check if other collectors get this resource, because our inventory could 275 # get full if they arrive. 276 total_registered_amount_consumer = sum( 277 entry.amount for 278 collector in self.get_colleague_collectors() if 279 collector.job is not None for 280 entry in collector.job.reslist if 281 entry.res == res) 282 283 inventory = self.get_home_inventory() 284 285 # check if there are resources left to pickup 286 home_inventory_free_space = inventory.get_free_space_for(res) \ 287 - total_registered_amount_consumer 288 if home_inventory_free_space <= 0: 289 #self.log.debug("nojob: no home inventory space") 290 return None 291 292 collector_inventory_free_space = self.get_component(StorageComponent).inventory.get_free_space_for(res) 293 if collector_inventory_free_space <= 0: 294 #self.log.debug("nojob: no collector inventory space") 295 return None 296 297 possible_res_amount = min(res_amount, home_inventory_free_space, 298 collector_inventory_free_space) 299 300 target_inventory_full = (target.get_component(StorageComponent).inventory.get_free_space_for(res) == 0) 301 302 # create a new data line. 303 return Job.ResListEntry(res, possible_res_amount, target_inventory_full)
304
305 - def get_best_possible_job(self, jobs):
306 """Return best possible job from jobs. 307 "Best" means that the job is highest when the job list was sorted. 308 "Possible" means that we can find a path there. 309 @param jobs: unsorted JobList instance 310 @return: selected Job instance from list or None if no jobs are possible.""" 311 jobs.sort_jobs() 312 # check if we can move to that targets 313 for job in jobs: 314 path = self.check_move(job.object.loading_area) 315 if path: 316 job.path = path 317 return job 318 319 return None
320
321 - def begin_current_job(self, job_location=None):
322 """Starts executing the current job by registering itself and moving to target. 323 @param job_location: Where collector should work. default: job.object.loading_area""" 324 self.log.debug("%s prepares job %s", self, self.job) 325 self.setup_new_job() 326 self.show() 327 if job_location is None: 328 job_location = self.job.object.loading_area 329 self.move(job_location, self.begin_working, 330 destination_in_building=self.destination_always_in_building, 331 blocked_callback=self.handle_path_to_job_blocked, path=self.job.path) 332 self.state = self.states.moving_to_target
333
334 - def resume_movement(self):
335 """Try to resume movement after getting blocked. If that fails then wait and try again.""" 336 try: 337 self._move_tick(resume=True) 338 except PathBlockedError: 339 Scheduler().add_new_object(self.resume_movement, self, COLLECTORS.DEFAULT_WAIT_TICKS)
340
341 - def handle_path_to_job_blocked(self):
342 """Called when we get blocked while trying to move to the job location. 343 The default action is to resume movement in a few seconds.""" 344 self.log.debug("%s: got blocked while moving to the job location, trying again in %s ticks.", 345 self, COLLECTORS.DEFAULT_WAIT_TICKS) 346 Scheduler().add_new_object(self.resume_movement, self, COLLECTORS.DEFAULT_WAIT_TICKS)
347
348 - def begin_working(self):
349 """Pretends that the collector works by waiting some time. finish_working is 350 called after that time.""" 351 self.log.debug("%s begins working", self) 352 assert self.job is not None, '{} job is None in begin_working'.format(self) 353 Scheduler().add_new_object(self.finish_working, self, self.work_duration) 354 # play working sound 355 if self.has_component(AmbientSoundComponent): 356 am_comp = self.get_component(AmbientSoundComponent) 357 if am_comp.soundfiles: 358 am_comp.play_ambient(am_comp.soundfiles[0], position=self.position) 359 self.state = self.states.working
360
361 - def finish_working(self):
362 """Called when collector has stayed at the target for a while. 363 Picks up the resources. 364 Should be overridden to specify what the collector should do after this.""" 365 self.log.debug("%s finished working", self) 366 self.act("idle", self._instance.getFacingLocation(), True) 367 # deregister at the target we're at 368 self.job.object.remove_incoming_collector(self) 369 # reconsider job now: there might now be more res available than there were when we started 370 371 reslist = (self.check_possible_job_target_for( 372 self.job.object, res) for res in self.get_collectable_res()) 373 reslist = [i for i in reslist if i] 374 if reslist: 375 self.job.reslist = reslist 376 377 # transfer res (this must be the last step, it will trigger consecutive actions through the 378 # target inventory changelistener, and the collector must be in a consistent state then. 379 self.transfer_res_from_target() 380 # stop playing ambient sound if any 381 if self.has_component(AmbientSoundComponent): 382 self.get_component(AmbientSoundComponent).stop_sound()
383
384 - def transfer_res_from_target(self):
385 """Transfers resources from target to collector inventory""" 386 new_reslist = [] 387 for entry in self.job.reslist: 388 actual_amount = self.job.object.pickup_resources(entry.res, entry.amount, self) 389 if entry.amount != actual_amount: 390 new_reslist.append(Job.ResListEntry(entry.res, actual_amount, False)) 391 else: 392 new_reslist.append(entry) 393 394 remnant = self.get_component(StorageComponent).inventory.alter(entry.res, actual_amount) 395 assert remnant == 0, "{} couldn't take all of res {}; remnant: {}; planned: {}".format( 396 self, entry.res, remnant, entry.amount) 397 self.job.reslist = new_reslist
398
399 - def transfer_res_to_home(self, res, amount):
400 """Transfer resources from collector to the home inventory""" 401 self.log.debug("%s brought home %s of %s", self, amount, res) 402 remnant = self.get_home_inventory().alter(res, amount) 403 #assert remnant == 0, "Home building could not take all resources from collector." 404 remnant = self.get_component(StorageComponent).inventory.alter(res, -amount) 405 assert remnant == 0, "{} couldn't give all of res {}; remnant: {}; inventory: {}".format( 406 self, res, remnant, self.get_component(StorageComponent).inventory)
407 408 # unused reroute code removed in 2aef7bba77536da333360566467d9a2f08d38cab 409
410 - def end_job(self):
411 """Contrary to setup_new_job""" 412 # the job now is finished now 413 # before the new job can begin this will be executed 414 self.log.debug("%s end_job - waiting for new search_job", self) 415 if self.start_hidden: 416 self.hide() 417 self.job = None 418 Scheduler().add_new_object(self.search_job, self, COLLECTORS.DEFAULT_WAIT_TICKS) 419 self.state = self.states.idle
420
421 - def cancel(self, continue_action):
422 """Aborts the current job. 423 @param continue_action: Callback, gets called after cancel. Specifies what collector 424 is supposed to do now. 425 NOTE: Subclasses set this to a proper action that makes the collector continue to work. 426 If the collector is supposed to be remove, use a noop. 427 """ 428 self.stop() 429 self.log.debug("%s was cancelled, continue action is %s", self, continue_action) 430 # remove us as incoming collector at target 431 self._abort_collector_job() 432 if self.job is not None: 433 # clean up depending on state 434 if self.state == self.states.working: 435 removed_calls = Scheduler().rem_call(self, self.finish_working) 436 assert removed_calls == 1, 'removed {} calls instead of one'.format(removed_calls) 437 self.job = None 438 self.state = self.states.idle 439 # NOTE: 440 # Some blocked movement callbacks use this callback. All blocked 441 # movement callbacks have to be cancelled here, else the unit will try 442 # to continue the movement later when its state has already changed. 443 # This line should fix it sufficiently for now and the problem could be 444 # deprecated when the switch to a component-based system is accomplished. 445 Scheduler().rem_call(self, self.resume_movement) 446 continue_action()
447
448 - def __str__(self):
449 try: 450 return super().__str__() + "(state={})".format(self.state) 451 except AttributeError: # state has not been set 452 return super().__str__()
453
454 455 -class Job:
456 """Data structure for storing information of collector jobs""" 457 ResListEntry = namedtuple("ResListEntry", ["res", "amount", "target_inventory_full"]) 458
459 - def __init__(self, obj, reslist):
460 """ 461 @param obj: ResourceHandler that provides res 462 @param reslist: ResListEntry list 463 res: resource to get 464 amount: amount of resource to get 465 target_inventory_full: whether target inventory can't store any more of this res. 466 """ 467 for entry in reslist: 468 assert entry.amount >= 0 469 # can't assert that it's not 0, since the value is reset to the amount 470 # the collector actually got at the target, which might be 0. yet for new jobs 471 # amount > 0 is a necessary precondition. 472 473 self.object = obj 474 self.reslist = reslist 475 476 self.path = None # attribute to temporarily store path
477 478 @decorators.cachedproperty
479 - def amount_sum(self):
480 # NOTE: only guaranteed to be correct during job search phase 481 return sum(entry.amount for entry in self.reslist)
482 483 @decorators.cachedproperty
484 - def resources(self):
485 # NOTE: only guaranteed to be correct during job search phase 486 return [entry.res for entry in self.reslist]
487 488 @decorators.cachedproperty
490 # NOTE: only guaranteed to be correct during job search phase 491 return sum(1 for entry in self.reslist if entry.target_inventory_full)
492
493 - def __str__(self):
494 return "Job({}, {})".format(self.object, self.reslist)
495
496 497 -class JobList(list):
498 """Data structure for evaluating best jobs. 499 It's a list extended by special sort functions. 500 """ 501 order_by = Enum('rating', 'amount', 'random', 'fewest_available', 'fewest_available_and_distance', 'for_storage_collector', 'distance') 502
503 - def __init__(self, collector, job_order):
504 """ 505 @param collector: collector instance 506 @param job_order: instance of order_by-Enum 507 """ 508 super().__init__() 509 self.collector = collector 510 # choose actual function by name of enum value 511 sort_fun_name = '_sort_jobs_' + str(job_order) 512 if not hasattr(self, sort_fun_name): 513 self._selected_sort_jobs = self._sort_jobs_amount 514 print('WARNING: invalid job order: ', job_order) 515 else: 516 self._selected_sort_jobs = getattr(self, sort_fun_name)
517
518 - def sort_jobs(self):
519 """Call this to sort jobs. 520 521 The function to call is decided in `__init__`. 522 """ 523 self._selected_sort_jobs()
524
525 - def _sort_jobs_random(self):
526 """Sorts jobs randomly""" 527 self.collector.session.random.shuffle(self)
528
529 - def _sort_jobs_amount(self):
530 """Sorts the jobs by the amount of resources available""" 531 self.sort(key=operator.attrgetter('amount_sum'), reverse=True)
532
533 - def _sort_jobs_fewest_available(self, shuffle_first=True):
534 """Prefer jobs where least amount is available in obj's inventory. 535 Only considers resource of resource list with minimum amount available. 536 This is supposed to fix urgent shortages.""" 537 # shuffle list before sorting, so that jobs with same value have equal chance 538 if shuffle_first: 539 self.collector.session.random.shuffle(self) 540 inventory = self.collector.get_home_inventory() 541 self.sort(key=lambda job: min(inventory[res] for res in job.resources), reverse=False)
542
544 """Sort jobs by distance, but secondarily also consider fewest available resources""" 545 # python sort is stable, so two sequenced sorts work. 546 self._sort_jobs_fewest_available(shuffle_first=False) 547 self._sort_jobs_distance()
548
550 """Special sophisticated sorting routing for storage collectors. 551 Same as fewest_available_and_distance_, but also considers whether target inv is full.""" 552 self._sort_jobs_fewest_available_and_distance() 553 self._sort_target_inventory_full()
554
555 - def _sort_jobs_distance(self):
556 """Prefer targets that are nearer""" 557 collector_point = self.collector.position 558 self.sort(key=lambda job: collector_point.distance(job.object.loading_area))
559
561 """Prefer targets with full inventory""" 562 self.sort(key=operator.attrgetter('target_inventory_full_num'), reverse=True)
563
564 - def __str__(self):
565 return str([str(i) for i in self])
566