Package horizons :: Module main
[hide private]
[frames] | no frames]

Source Code for Module horizons.main

  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  """This is the main game file. It has grown over the years from a collection of global 
 23  variables (sic!) to something holding mainly the main gui and game session, as well as 
 24  a reference to the engine object (fife). 
 25  The functions below are used to start different kinds of games. 
 26   
 27  TUTORIAL: 
 28  Continue to horizons.session for further ingame digging. 
 29  """ 
 30   
 31   
 32  import json 
 33  import logging 
 34  import os 
 35  import os.path 
 36  import sys 
 37  import threading 
 38  import traceback 
 39  from typing import TYPE_CHECKING, Optional 
 40   
 41  from fife import fife as fife_module 
 42   
 43  import horizons.globals 
 44  from horizons.constants import AI, GAME, GAME_SPEED, GFX, NETWORK, PATHS, SINGLEPLAYER, VERSION 
 45  from horizons.extscheduler import ExtScheduler 
 46  from horizons.gui import Gui 
 47  from horizons.i18n import gettext as T 
 48  from horizons.messaging import LoadingProgress 
 49  from horizons.network.networkinterface import NetworkInterface 
 50  from horizons.savegamemanager import SavegameManager 
 51  from horizons.util.atlasloading import generate_atlases 
 52  from horizons.util.checkupdates import setup_async_update_check 
 53  from horizons.util.preloader import PreloadingThread 
 54  from horizons.util.python import parse_port 
 55  from horizons.util.python.callback import Callback 
 56  from horizons.util.savegameaccessor import SavegameAccessor 
 57  from horizons.util.startgameoptions import StartGameOptions 
 58  from horizons.util.uhdbaccessor import UhDbAccessor 
 59   
 60  if TYPE_CHECKING: 
 61          from horizons.session import Session 
 62          from development.stringpreviewwidget import StringPreviewWidget 
 63   
 64  """ 
 65  Following are a list of global variables. Their scope is this module. 
 66  Since this is not a class, in each function where these variables are 
 67  referenced, they need to be declared with 'global' keyword. 
 68   
 69  See: http://python-textbok.readthedocs.io/en/1.0/Variables_and_Scope.html 
 70  """ 
 71  gui = None # type: Optional[Gui] 
 72  session = None # type: Optional[Session] 
 73   
 74  # used to save a reference to the string previewer to ensure it is not removed by 
 75  # garbage collection 
 76  __string_previewer = None # type: Optional[StringPreviewWidget] 
 77   
 78  command_line_arguments = None 
 79   
 80  preloader = None # type: PreloadingThread 
 81   
 82   
83 -def start(_command_line_arguments):
84 """Starts the horizons. Will drop you to the main menu. 85 @param _command_line_arguments: options object from optparse.OptionParser. see run_uh.py. 86 """ 87 global debug, preloader, command_line_arguments, gui, session 88 command_line_arguments = _command_line_arguments 89 # NOTE: globals are designwise the same thing as singletons. they don't look pretty. 90 # here, we only have globals that are either trivial, or only one instance may ever exist. 91 92 from .engine import Fife 93 94 # handle commandline globals 95 debug = command_line_arguments.debug 96 97 if command_line_arguments.restore_settings: 98 # just delete the file, Settings ctor will create a new one 99 os.remove(PATHS.USER_CONFIG_FILE) 100 101 if command_line_arguments.mp_master: 102 try: 103 mpieces = command_line_arguments.mp_master.partition(':') 104 NETWORK.SERVER_ADDRESS = mpieces[0] 105 # only change port if port is specified 106 if mpieces[2]: 107 NETWORK.SERVER_PORT = parse_port(mpieces[2]) 108 except ValueError: 109 print("Error: Invalid syntax in --mp-master commandline option. Port must be a number between 1 and 65535.") 110 return False 111 112 # init fife before mp_bind is parsed, since it's needed there 113 horizons.globals.fife = Fife() 114 115 if command_line_arguments.generate_minimap: # we've been called as subprocess to generate a map preview 116 from horizons.gui.modules.singleplayermenu import generate_random_minimap 117 generate_random_minimap(* json.loads( 118 command_line_arguments.generate_minimap 119 )) 120 sys.exit(0) 121 122 if debug: # also True if a specific module is logged (but not 'fife') 123 setup_debug_mode(command_line_arguments) 124 125 if horizons.globals.fife.get_uh_setting("DebugLog"): 126 set_debug_log(True, startup=True) 127 128 if command_line_arguments.mp_bind: 129 try: 130 mpieces = command_line_arguments.mp_bind.partition(':') 131 NETWORK.CLIENT_ADDRESS = mpieces[0] 132 horizons.globals.fife.set_uh_setting("NetworkPort", parse_port(mpieces[2])) 133 except ValueError: 134 print("Error: Invalid syntax in --mp-bind commandline option. Port must be a number between 1 and 65535.") 135 return False 136 137 setup_AI_settings(command_line_arguments) 138 139 # set MAX_TICKS 140 if command_line_arguments.max_ticks: 141 GAME.MAX_TICKS = command_line_arguments.max_ticks 142 143 # Setup atlases 144 if (command_line_arguments.atlas_generation 145 and not command_line_arguments.gui_test 146 and VERSION.IS_DEV_VERSION 147 and horizons.globals.fife.get_uh_setting('AtlasesEnabled') 148 and horizons.globals.fife.get_uh_setting('AtlasGenerationEnabled')): 149 generate_atlases() 150 151 if not VERSION.IS_DEV_VERSION and horizons.globals.fife.get_uh_setting('AtlasesEnabled'): 152 GFX.USE_ATLASES = True 153 PATHS.DB_FILES = PATHS.DB_FILES + (PATHS.ATLAS_DB_PATH, ) 154 155 # init game parts 156 157 if not setup_gui_logger(command_line_arguments): 158 return False 159 160 # Check if the no-audio flag has been set. 161 if command_line_arguments.no_audio: 162 horizons.globals.fife.set_fife_setting('PlaySounds', False) 163 164 # GUI tests always run with sound disabled and SDL (so they can run under xvfb). 165 # Needs to be done before engine is initialized. 166 if command_line_arguments.gui_test: 167 horizons.globals.fife.engine.getSettings().setRenderBackend('SDL') 168 horizons.globals.fife.set_fife_setting('PlaySounds', False) 169 170 ExtScheduler.create_instance(horizons.globals.fife.pump) 171 horizons.globals.fife.init() 172 173 horizons.globals.db = _create_main_db() 174 gui = Gui() 175 SavegameManager.init() 176 horizons.globals.fife.init_animation_loader(GFX.USE_ATLASES) 177 178 from horizons.entities import Entities 179 Entities.load(horizons.globals.db, load_now=False) # create all references 180 181 # for preloading game data while in main screen 182 preloader = PreloadingThread() 183 184 # Singleplayer seed needs to be changed before startup. 185 if command_line_arguments.sp_seed: 186 SINGLEPLAYER.SEED = command_line_arguments.sp_seed 187 SINGLEPLAYER.FREEZE_PROTECTION = command_line_arguments.freeze_protection 188 189 # start something according to commandline parameters 190 startup_worked = True 191 if command_line_arguments.start_dev_map: 192 startup_worked = _start_map('development', command_line_arguments.ai_players, 193 force_player_id=command_line_arguments.force_player_id, is_map=True) 194 elif command_line_arguments.start_random_map: 195 startup_worked = _start_random_map(command_line_arguments.ai_players, force_player_id=command_line_arguments.force_player_id) 196 elif command_line_arguments.start_specific_random_map is not None: 197 startup_worked = _start_random_map(command_line_arguments.ai_players, 198 seed=command_line_arguments.start_specific_random_map, force_player_id=command_line_arguments.force_player_id) 199 elif command_line_arguments.start_map is not None: 200 startup_worked = _start_map(command_line_arguments.start_map, command_line_arguments.ai_players, 201 force_player_id=command_line_arguments.force_player_id, is_map=True) 202 elif command_line_arguments.start_scenario is not None: 203 startup_worked = _start_map(command_line_arguments.start_scenario, 0, True, force_player_id=command_line_arguments.force_player_id) 204 elif command_line_arguments.load_game is not None: 205 startup_worked = _load_cmd_map(command_line_arguments.load_game, command_line_arguments.ai_players, 206 command_line_arguments.force_player_id) 207 elif command_line_arguments.load_quicksave is not None: 208 startup_worked = _load_last_quicksave() 209 elif command_line_arguments.edit_map is not None: 210 startup_worked = edit_map(command_line_arguments.edit_map) 211 elif command_line_arguments.edit_game_map is not None: 212 startup_worked = edit_game_map(command_line_arguments.edit_game_map) 213 elif command_line_arguments.stringpreview: 214 tiny = [i for i in SavegameManager.get_maps()[0] if 'tiny' in i] 215 if not tiny: 216 tiny = SavegameManager.get_map()[0] 217 startup_worked = _start_map(tiny[0], ai_players=0, trader_enabled=False, pirate_enabled=False, 218 force_player_id=command_line_arguments.force_player_id, is_map=True) 219 from development.stringpreviewwidget import StringPreviewWidget 220 __string_previewer = StringPreviewWidget(session) 221 __string_previewer.show() 222 elif command_line_arguments.create_mp_game: 223 gui.show_main() 224 gui.windows.open(gui.multiplayermenu) 225 gui.multiplayermenu._create_game() 226 gui.windows._windows[-1].act() 227 elif command_line_arguments.join_mp_game: 228 gui.show_main() 229 gui.windows.open(gui.multiplayermenu) 230 gui.multiplayermenu._join_game() 231 else: # no commandline parameter, show main screen 232 233 # initialize update checker 234 if not command_line_arguments.gui_test: 235 setup_async_update_check() 236 237 gui.show_main() 238 if not command_line_arguments.nopreload: 239 preloader.start() 240 241 if not startup_worked: 242 # don't start main loop if startup failed 243 return False 244 245 if command_line_arguments.gamespeed is not None: 246 if session is None: 247 print("You can only set the speed via command line in combination with a game start parameter such as --start-map, etc.") 248 return False 249 session.speed_set(GAME_SPEED.TICKS_PER_SECOND * command_line_arguments.gamespeed) 250 251 if command_line_arguments.gui_test: 252 from tests.gui import TestRunner 253 TestRunner(horizons.globals.fife, command_line_arguments.gui_test) 254 255 horizons.globals.fife.run() 256 return True
257 258
259 -def setup_AI_settings(command_line_arguments):
260 if command_line_arguments.ai_highlights: 261 AI.HIGHLIGHT_PLANS = True 262 if command_line_arguments.ai_combat_highlights: 263 AI.HIGHLIGHT_COMBAT = True 264 if command_line_arguments.human_ai: 265 AI.HUMAN_AI = True
266 267
268 -def setup_debug_mode(command_line_arguments):
269 if not (command_line_arguments.debug_module 270 and 'fife' not in command_line_arguments.debug_module): 271 horizons.globals.fife._log.logToPrompt = True 272 273 if command_line_arguments.debug_log_only: 274 # This is a workaround to not show fife logs in the shell even if 275 # (due to the way the fife logger works) these logs will not be 276 # redirected to the UH logfile and instead written to a file fife.log 277 # in the current directory. See #1782 for background information. 278 horizons.globals.fife._log.logToPrompt = False 279 horizons.globals.fife._log.logToFile = True
280 281
282 -def setup_gui_logger(command_line_arguments):
283 """ 284 Install gui logger, needs to be done before instantiating Gui, otherwise we miss 285 the events of the main menu buttons 286 """ 287 if command_line_arguments.log_gui: 288 if command_line_arguments.gui_test: 289 raise Exception("Logging gui interactions doesn't work when running tests.") 290 try: 291 from tests.gui.logger import setup_gui_logger 292 setup_gui_logger() 293 except ImportError: 294 traceback.print_exc() 295 print() 296 print("Gui logging requires code that is only present in the repository and is not being installed.") 297 return False 298 return True
299 300
301 -def quit():
302 """Quits the game""" 303 global preloader 304 preloader.wait_for_finish() 305 horizons.globals.fife.quit()
306 307
308 -def quit_session():
309 """Quits the current game.""" 310 global gui, session 311 session = None 312 gui.show_main()
313 314
315 -def start_singleplayer(options):
316 """Starts a singleplayer game.""" 317 global gui, session, preloader 318 gui.show_loading_screen() 319 320 LoadingProgress.broadcast(None, 'load_objects') 321 preloader.wait_for_finish() 322 323 # remove cursor while loading 324 horizons.globals.fife.cursor.set(fife_module.CURSOR_NONE) 325 horizons.globals.fife.engine.pump() 326 horizons.globals.fife.set_cursor_image('default') 327 328 # destruct old session (right now, without waiting for gc) 329 if session is not None and session.is_alive: 330 session.end() 331 332 if options.is_editor: 333 from horizons.editor.session import EditorSession as session_class 334 else: 335 from horizons.spsession import SPSession as session_class 336 337 # start new session 338 session = session_class(horizons.globals.db) 339 340 from horizons.scenario import InvalidScenarioFileFormat # would create import loop at top 341 from horizons.util.savegameaccessor import MapFileNotFound 342 from horizons.util.savegameupgrader import SavegameTooOld 343 try: 344 session.load(options) 345 gui.close_all() 346 except InvalidScenarioFileFormat: 347 raise 348 except (MapFileNotFound, SavegameTooOld, Exception): 349 gui.close_all() 350 # don't catch errors when we should fail fast (used by tests) 351 if os.environ.get('FAIL_FAST', False): 352 raise 353 print("Failed to load", options.game_identifier) 354 traceback.print_exc() 355 if session is not None and session.is_alive: 356 try: 357 session.end() 358 except Exception: 359 print() 360 traceback.print_exc() 361 print("Additionally to failing when loading, cleanup afterwards also failed") 362 gui.show_main() 363 headline = T("Failed to start/load the game") 364 descr = T("The game you selected could not be started.") + " " + \ 365 T("The savegame might be broken or has been saved with an earlier version.") 366 gui.open_error_popup(headline, descr) 367 gui.load_game() 368 return session
369 370
371 -def prepare_multiplayer(game, trader_enabled=True, pirate_enabled=True, natural_resource_multiplier=1):
372 """Starts a multiplayer game server 373 TODO: actual game data parameter passing 374 """ 375 global gui, session, preloader 376 gui.show_loading_screen() 377 378 preloader.wait_for_finish() 379 380 # remove cursor while loading 381 horizons.globals.fife.cursor.set(fife_module.CURSOR_NONE) 382 horizons.globals.fife.engine.pump() 383 horizons.globals.fife.set_cursor_image('default') 384 385 # destruct old session (right now, without waiting for gc) 386 if session is not None and session.is_alive: 387 session.end() 388 # start new session 389 from horizons.mpsession import MPSession 390 # get random seed for game 391 uuid = game.uuid 392 random = sum([int(uuid[i: i + 2], 16) for i in range(0, len(uuid), 2)]) 393 session = MPSession(horizons.globals.db, NetworkInterface(), rng_seed=random) 394 395 # NOTE: this data passing is only temporary, maybe use a player class/struct 396 if game.is_savegame: 397 map_file = SavegameManager.get_multiplayersave_map(game.map_name) 398 else: 399 map_file = SavegameManager.get_map(game.map_name) 400 401 options = StartGameOptions.create_start_multiplayer(map_file, game.get_player_list(), not game.is_savegame) 402 session.load(options)
403 404
405 -def start_multiplayer(game):
406 global gui, session 407 gui.close_all() 408 session.start()
409 410 411 ## GAME START FUNCTIONS
412 -def _start_map(map_name, ai_players=0, is_scenario=False, 413 pirate_enabled=True, trader_enabled=True, force_player_id=None, is_map=False):
414 """Start a map specified by user 415 @param map_name: name of map or path to map 416 @return: bool, whether loading succeeded""" 417 if is_scenario: 418 map_file = _find_scenario(map_name, SavegameManager.get_available_scenarios(locales=True)) 419 else: 420 map_file = _find_map(map_name, SavegameManager.get_maps()) 421 422 if not map_file: 423 return False 424 425 options = StartGameOptions.create_start_singleplayer(map_file, is_scenario, 426 ai_players, trader_enabled, pirate_enabled, force_player_id, is_map) 427 start_singleplayer(options) 428 return True
429 430
431 -def _start_random_map(ai_players, seed=None, force_player_id=None):
432 options = StartGameOptions.create_start_random_map(ai_players, seed, force_player_id) 433 start_singleplayer(options) 434 return True
435 436
437 -def _load_cmd_map(savegame, ai_players, force_player_id=None):
438 """Load a map specified by user. 439 @param savegame: either the displayname of a savegame or a path to a savegame 440 @return: bool, whether loading succeeded""" 441 # first check for partial or exact matches in the normal savegame list 442 savegames = SavegameManager.get_saves() 443 map_file = _find_map(savegame, savegames) 444 if not map_file: 445 return False 446 447 options = StartGameOptions.create_load_game(map_file, force_player_id) 448 start_singleplayer(options) 449 return True
450 451
452 -def _find_scenario(name_or_path, scenario_db):
453 """Find a scenario by name or path specified by user. 454 @param name_or_path: scenario name or path to thereof 455 @param scenario_db: defaultdict of the format: 456 { <scenario name> : [ (<locale 1>, <path 1>), (<locale 2>, <path 2>), ... ] } 457 @return: path to the scenario file as string""" 458 game_language = horizons.globals.fife.get_locale() 459 460 # extract name and game_language locale from the path if in correct format 461 if os.path.exists(name_or_path) and name_or_path.endswith(".yaml") and "_" in os.path.basename(name_or_path): 462 name, game_language = os.path.splitext(os.path.basename(name_or_path))[0].split("_") 463 # name_or_path may be a custom scenario path without specified locale 464 elif os.path.exists(name_or_path) and name_or_path.endswith(".yaml"): 465 return name_or_path 466 elif not os.path.exists(name_or_path) and name_or_path.endswith(".yaml"): 467 print("Error: name or path '{name}' does not exist.".format(name=name_or_path)) 468 return 469 # assume name_or_path is a scenario name if no extension was specified 470 else: 471 name = name_or_path 472 473 # check if name is a valid scenario name 474 if name not in scenario_db: 475 print("Error: scenario '{name}' not in scenario database.".format(name=name)) 476 return 477 478 # check if name is ambiguous 479 found_names = [test_name for test_name in scenario_db if test_name.startswith(name)] 480 if len(found_names) > 1: 481 print("Error: search for scenario '{name}' returned multiple results.".format(name=name)) 482 print("\n".join(found_names)) 483 return 484 485 # get path to scenario by name and game_language locale 486 try: 487 path_to_scenario = dict(scenario_db[name])[game_language] 488 return path_to_scenario 489 except KeyError: 490 print("Error: could not find scenario '{name}' in scenario database. The locale '{locale}' may be wrong.".format(name=name, locale=game_language))
491 492
493 -def _find_map(name_or_path, map_db):
494 """Find a map by name or path specified by user. 495 @param name_or_path: map name or path to thereof 496 @param map_db: tuple of the format: ( (<map path 1>, <map path 2>, ...), [ <map 1>, <map 2>, ...] ) 497 @return: path to the map file as string""" 498 499 # map look-up with given valid path 500 if os.path.exists(name_or_path) and name_or_path.endswith(".sqlite"): 501 return name_or_path 502 elif not os.path.exists(name_or_path) and name_or_path.endswith(".sqlite"): 503 print("Error: name or path '{name}' does not exist.".format(name=name_or_path)) 504 return 505 # assume name_or_path is a map name if no extension specified 506 else: 507 if name_or_path not in map_db[1]: 508 print("Error: map '{name}' not in map database.".format(name=name_or_path)) 509 return 510 for path, name in zip(*map_db): 511 if name == name_or_path: 512 return path
513 514
515 -def _load_last_quicksave(currentSession=None, force_player_id=None):
516 """Load last quicksave 517 @param currentSession: value of currentSession 518 @return: bool, whether loading succeeded""" 519 save_files = SavegameManager.get_quicksaves()[0] 520 if currentSession is not None: 521 if not save_files: 522 currentSession.ingame_gui.open_popup(T("No quicksaves found"), 523 T("You need to quicksave before you can quickload.")) 524 return False 525 else: 526 if not save_files: 527 print("Error: No quicksave found.") 528 return False 529 530 save = max(save_files) 531 options = StartGameOptions.create_load_game(save, force_player_id) 532 start_singleplayer(options) 533 return True
534 535
536 -def _edit_map(map_file):
537 """ 538 Start editing the specified map file. 539 540 @param map_file: path to the map file or a list of random island strings 541 @return: bool, whether loading succeeded 542 """ 543 if not map_file: 544 return False 545 546 options = StartGameOptions.create_editor_load(map_file) 547 start_singleplayer(options) 548 return True
549 550
551 -def edit_map(map_name):
552 """ 553 Start editing the map file specified by the name. 554 555 @param map_name: name of map or path to map 556 @return: bool, whether loading succeeded 557 """ 558 return _edit_map(_find_map(map_name, SavegameManager.get_maps()))
559 560
561 -def edit_game_map(saved_game_name):
562 """ 563 Start editing the specified map. 564 565 @param map_name: name of map or path to map 566 @return: bool, whether loading succeeded 567 """ 568 saved_games = SavegameManager.get_saves() 569 saved_game_path = _find_map(saved_game_name, saved_games) 570 if not saved_game_path: 571 return False 572 573 accessor = SavegameAccessor(saved_game_path, False) 574 map_name = accessor.map_name 575 accessor.close() 576 if isinstance(map_name, list): 577 # a random map represented by a list of island strings 578 return _edit_map(map_name) 579 return edit_map(map_name)
580 581
582 -def _create_main_db():
583 """Returns a dbreader instance, that is connected to the main game data dbfiles. 584 NOTE: This data is read_only, so there are no concurrency issues.""" 585 _db = UhDbAccessor(':memory:') 586 for i in PATHS.DB_FILES: 587 with open(i, "r") as f: 588 sql = "BEGIN TRANSACTION;" + f.read() + "COMMIT;" 589 _db.execute_script(sql) 590 return _db
591 592
593 -def set_debug_log(enabled, startup=False):
594 """ 595 @param enabled: boolean if logging should be enabled 596 @param startup: True if on startup to apply settings. Won't show popup 597 """ 598 global gui, command_line_arguments 599 600 options = command_line_arguments 601 602 if enabled: # enable logging 603 if options.debug: 604 # log file is already set up, just make sure everything is logged 605 logging.getLogger().setLevel(logging.DEBUG) 606 else: # set up all anew 607 class Data: 608 debug = False 609 debug_log_only = True 610 logfile = None 611 debug_module = []
612 # use setup call reference, see run_uh.py 613 options.setup_debugging(Data) 614 options.debug = True 615 616 if not startup: 617 headline = T("Logging enabled") 618 msg = T("Logs are written to {directory}.").format(directory=PATHS.LOG_DIR) 619 # Let the ext scheduler show the popup, so that all other settings can be saved and validated 620 ExtScheduler().add_new_object(Callback(gui.open_popup, headline, msg), None) 621 622 else: #disable logging 623 logging.getLogger().setLevel(logging.WARNING) 624 # keep debug flag in options so to not reenable it fully twice 625 # on reenable, only the level will be reset 626