From 181f6d852db1e337ea4f2ab3555e7e7ed67af712 Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Thu, 9 Feb 2023 21:19:37 +1100 Subject: [PATCH 1/8] MCR-103 Determined all changes and began migration. --- source/saving/game_save_manager.py | 175 ++++++++++++++--------------- source/saving/save_migrator.py | 41 +++++++ 2 files changed, 127 insertions(+), 89 deletions(-) create mode 100644 source/saving/save_migrator.py diff --git a/source/saving/game_save_manager.py b/source/saving/game_save_manager.py index df7d789..4a0beec 100644 --- a/source/saving/game_save_manager.py +++ b/source/saving/game_save_manager.py @@ -6,6 +6,7 @@ import pyxel from source.display.board import Board +from source.saving.save_migrator import migrate_unit_plan from source.util.calculator import clamp from source.foundation.catalogue import get_blessing, get_project, get_unit_plan, get_improvement from source.game_management.game_controller import GameController @@ -67,98 +68,94 @@ def load_game(game_state: GameState, game_controller: GameController): saves.reverse() all_saves = autosaves + saves - try: - with open(os.path.join(SAVES_DIR, all_saves[game_controller.menu.save_idx]), "r", - encoding="utf-8") as save_file: - # Use a custom object hook when loading the JSON so that the resulting objects have attribute access. - save = json.loads(save_file.read(), object_hook=ObjectConverter) - # Load in the quads. - quads = [[None] * 100 for _ in range(90)] - for i in range(90): - for j in range(100): - quads[i][j] = save.quads[i * 100 + j] - # The biomes require special loading. - quads[i][j].biome = Biome[quads[i][j].biome] - game_state.players = save.players - # The list of tuples that is quads_seen needs special loading, as do a few other of the same type, - # because tuples do not exist in JSON, so they are represented as arrays, which will clearly not work. - for i in range(len(game_state.players[0].quads_seen)): - game_state.players[0].quads_seen[i] = ( - game_state.players[0].quads_seen[i][0], game_state.players[0].quads_seen[i][1]) - game_state.players[0].quads_seen = set(game_state.players[0].quads_seen) - for p in game_state.players: - for idx, u in enumerate(p.units): - # We can do a direct conversion to Unit and UnitPlan objects for units. - plan_prereq = None if u.plan.prereq is None else get_blessing(u.plan.prereq.name) - p.units[idx] = Unit(u.health, u.remaining_stamina, (u.location[0], u.location[1]), u.garrisoned, - UnitPlan(u.plan.power, u.plan.max_health, u.plan.total_stamina, - u.plan.name, plan_prereq, u.plan.cost, u.plan.can_settle, - u.plan.heals), - u.has_acted, u.besieging) - for s in p.settlements: - # Make sure we remove the settlement's name so that we don't get duplicates. - game_controller.namer.remove_settlement_name(s.name, s.quads[0].biome) - # Another tuple-array fix. - s.location = (s.location[0], s.location[1]) - if s.current_work is not None: - # Get the actual Improvement, Project, or UnitPlan objects for the current work. We use - # hasattr() because improvements have an effect where projects do not, and projects have - # a type where unit plans do not. - if hasattr(s.current_work.construction, "effect"): - s.current_work.construction = get_improvement(s.current_work.construction.name) - elif hasattr(s.current_work.construction, "type"): - s.current_work.construction = get_project(s.current_work.construction.name) - else: - s.current_work.construction = get_unit_plan(s.current_work.construction.name) - for idx, imp in enumerate(s.improvements): - # Do another direct conversion for improvements. - s.improvements[idx] = get_improvement(imp.name) - # Also convert all units in garrisons to Unit objects. - for idx, u in enumerate(s.garrison): - s.garrison[idx] = Unit(u.health, u.remaining_stamina, (u.location[0], u.location[1]), - u.garrisoned, u.plan, u.has_acted, u.besieging) - # We also do direct conversions to Blessing objects for the ongoing one, if there is one, - # as well as any previously-completed ones. - if p.ongoing_blessing: - p.ongoing_blessing.blessing = get_blessing(p.ongoing_blessing.blessing.name) - for idx, bls in enumerate(p.blessings): - p.blessings[idx] = get_blessing(bls.name) - if p.ai_playstyle is not None: - p.ai_playstyle = AIPlaystyle(AttackPlaystyle[p.ai_playstyle.attacking], - ExpansionPlaystyle[p.ai_playstyle.expansion]) - p.imminent_victories = set(p.imminent_victories) - p.faction = Faction(p.faction) - # For the AI players, we can just make quads_seen an empty set, as it's not used. - for i in range(1, len(game_state.players)): - game_state.players[i].quads_seen = set() + # try: + with open(os.path.join(SAVES_DIR, all_saves[game_controller.menu.save_idx]), "r", + encoding="utf-8") as save_file: + # Use a custom object hook when loading the JSON so that the resulting objects have attribute access. + save = json.loads(save_file.read(), object_hook=ObjectConverter) + # Load in the quads. + quads = [[None] * 100 for _ in range(90)] + for i in range(90): + for j in range(100): + quads[i][j] = save.quads[i * 100 + j] + # The biomes require special loading. + quads[i][j].biome = Biome[quads[i][j].biome] + game_state.players = save.players + # The list of tuples that is quads_seen needs special loading, as do a few other of the same type, + # because tuples do not exist in JSON, so they are represented as arrays, which will clearly not work. + for i in range(len(game_state.players[0].quads_seen)): + game_state.players[0].quads_seen[i] = ( + game_state.players[0].quads_seen[i][0], game_state.players[0].quads_seen[i][1]) + game_state.players[0].quads_seen = set(game_state.players[0].quads_seen) + for p in game_state.players: + for idx, u in enumerate(p.units): + # We can do a direct conversion to Unit and UnitPlan objects for units. + p.units[idx] = Unit(u.health, u.remaining_stamina, (u.location[0], u.location[1]), u.garrisoned, + migrate_unit_plan(u.plan), u.has_acted, u.besieging) + for s in p.settlements: + # Make sure we remove the settlement's name so that we don't get duplicates. + game_controller.namer.remove_settlement_name(s.name, s.quads[0].biome) + # Another tuple-array fix. + s.location = (s.location[0], s.location[1]) + if s.current_work is not None: + # Get the actual Improvement, Project, or UnitPlan objects for the current work. We use + # hasattr() because improvements have an effect where projects do not, and projects have + # a type where unit plans do not. + if hasattr(s.current_work.construction, "effect"): + s.current_work.construction = get_improvement(s.current_work.construction.name) + elif hasattr(s.current_work.construction, "type"): + s.current_work.construction = get_project(s.current_work.construction.name) + else: + s.current_work.construction = get_unit_plan(s.current_work.construction.name) + for idx, imp in enumerate(s.improvements): + # Do another direct conversion for improvements. + s.improvements[idx] = get_improvement(imp.name) + # Also convert all units in garrisons to Unit objects. + for idx, u in enumerate(s.garrison): + s.garrison[idx] = Unit(u.health, u.remaining_stamina, (u.location[0], u.location[1]), + u.garrisoned, u.plan, u.has_acted, u.besieging) + # We also do direct conversions to Blessing objects for the ongoing one, if there is one, + # as well as any previously-completed ones. + if p.ongoing_blessing: + p.ongoing_blessing.blessing = get_blessing(p.ongoing_blessing.blessing.name) + for idx, bls in enumerate(p.blessings): + p.blessings[idx] = get_blessing(bls.name) + if p.ai_playstyle is not None: + p.ai_playstyle = AIPlaystyle(AttackPlaystyle[p.ai_playstyle.attacking], + ExpansionPlaystyle[p.ai_playstyle.expansion]) + p.imminent_victories = set(p.imminent_victories) + p.faction = Faction(p.faction) + # For the AI players, we can just make quads_seen an empty set, as it's not used. + for i in range(1, len(game_state.players)): + game_state.players[i].quads_seen = set() - game_state.heathens = [] - for h in save.heathens: - # Do another direct conversion for the heathens. - game_state.heathens.append(Heathen(h.health, h.remaining_stamina, (h.location[0], h.location[1]), - UnitPlan(h.plan.power, h.plan.max_health, 2, h.plan.name, None, 0), - h.has_attacked)) + game_state.heathens = [] + for h in save.heathens: + # Do another direct conversion for the heathens. + game_state.heathens.append(Heathen(h.health, h.remaining_stamina, (h.location[0], h.location[1]), + UnitPlan(h.plan.power, h.plan.max_health, 2, h.plan.name, None, 0), + h.has_attacked)) - game_state.turn = save.turn - game_state.until_night = save.night_status.until - game_state.nighttime_left = save.night_status.remaining - game_cfg = save.cfg - save_file.close() - # Now do all the same logic we do when starting a game. - pyxel.mouse(visible=True) - game_state.game_started = True - game_state.on_menu = False - game_state.board = Board(game_cfg, game_controller.namer, quads) - game_controller.move_maker.board_ref = game_state.board - # Initialise the map position to the player's first settlement. - game_state.map_pos = (clamp(game_state.players[0].settlements[0].location[0] - 12, -1, 77), - clamp(game_state.players[0].settlements[0].location[1] - 11, -1, 69)) - game_state.board.overlay.current_player = game_state.players[0] - game_controller.music_player.stop_menu_music() - game_controller.music_player.play_game_music() + game_state.turn = save.turn + game_state.until_night = save.night_status.until + game_state.nighttime_left = save.night_status.remaining + game_cfg = save.cfg + save_file.close() + # Now do all the same logic we do when starting a game. + pyxel.mouse(visible=True) + game_state.game_started = True + game_state.on_menu = False + game_state.board = Board(game_cfg, game_controller.namer, quads) + game_controller.move_maker.board_ref = game_state.board + # Initialise the map position to the player's first settlement. + game_state.map_pos = (clamp(game_state.players[0].settlements[0].location[0] - 12, -1, 77), + clamp(game_state.players[0].settlements[0].location[1] - 11, -1, 69)) + game_state.board.overlay.current_player = game_state.players[0] + game_controller.music_player.stop_menu_music() + game_controller.music_player.play_game_music() # pylint: disable=broad-except - except Exception: - game_controller.menu.load_failed = True + # except Exception: + # game_controller.menu.load_failed = True def get_saves(game_controller: GameController): diff --git a/source/saving/save_migrator.py b/source/saving/save_migrator.py new file mode 100644 index 0000000..a81c915 --- /dev/null +++ b/source/saving/save_migrator.py @@ -0,0 +1,41 @@ +from source.foundation.catalogue import get_blessing +from source.foundation.models import UnitPlan + +""" +The following migrations have occurred during Microcosm's development: + +v1.1 +- AI players had their playstyle expanded to not only include attacking, but also expansion. + This is migrated by mapping directly to the attacking attribute, and setting the expansion attribute to neutral. +- Relics were added, adding the is_relic attribute to Quads. This is migrated by setting all quads without the attribute + to False. +- Imminent victories were added for players, telling players when they or others are close to winning the game. This can + be migrated by simpling setting them to an empty set if the attribute is not present, as it will be populated next + turn. +- The eliminated attribute was added for players, which can be determined by checking the number of settlements the + player has. + +v1.2 +- Climatic effects were added to the game as a part of its configuration. This can be mapped to False. + +v2.0 +- Factions were added for players. As it would be difficult for players to choose, their faction can be determined from + the colour they chose. +- The player faction was also added to the game configuration, which can be determined in the same way as above. + +v2.2 +- Healing units were added. All existing unit plans can be mapped to False for the heals attribute. +- The has_attacked attribute was changed to has_acted for units, and this can be directly mapped across. +- The sieging attribute for units was changed to besieging, and this can also be mapped directly. +- The under_siege_by attribute keeping track of the optional unit besieging the settlement was changed to a simple + boolean attribute called besieged. Migration can occur by mapping to True if the value is not None. +""" + + +def migrate_unit_plan(unit_plan) -> UnitPlan: + plan_prereq = None if unit_plan.prereq is None else get_blessing(unit_plan.prereq.name) + will_heal: bool = unit_plan.heals if hasattr(unit_plan, "heals") else False + + return UnitPlan(unit_plan.power, unit_plan.max_health, unit_plan.total_stamina, + unit_plan.name, plan_prereq, unit_plan.cost, unit_plan.can_settle, + will_heal) From 3e6472fe47bbc79fb6bdf1fff847df380d7faa37 Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Fri, 10 Feb 2023 20:45:14 +1100 Subject: [PATCH 2/8] MCR-103 Finished migration. --- source/saving/game_save_manager.py | 25 ++++------- source/saving/save_migrator.py | 69 ++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/source/saving/game_save_manager.py b/source/saving/game_save_manager.py index 4a0beec..aff870b 100644 --- a/source/saving/game_save_manager.py +++ b/source/saving/game_save_manager.py @@ -6,7 +6,8 @@ import pyxel from source.display.board import Board -from source.saving.save_migrator import migrate_unit_plan +from source.saving.save_migrator import migrate_unit_plan, migrate_unit, migrate_player, migrate_climatic_effects, \ + migrate_quad, migrate_settlement, migrate_game_config from source.util.calculator import clamp from source.foundation.catalogue import get_blessing, get_project, get_unit_plan, get_improvement from source.game_management.game_controller import GameController @@ -77,9 +78,7 @@ def load_game(game_state: GameState, game_controller: GameController): quads = [[None] * 100 for _ in range(90)] for i in range(90): for j in range(100): - quads[i][j] = save.quads[i * 100 + j] - # The biomes require special loading. - quads[i][j].biome = Biome[quads[i][j].biome] + quads[i][j] = migrate_quad(save.quads[i * 100 + j]) game_state.players = save.players # The list of tuples that is quads_seen needs special loading, as do a few other of the same type, # because tuples do not exist in JSON, so they are represented as arrays, which will clearly not work. @@ -90,8 +89,7 @@ def load_game(game_state: GameState, game_controller: GameController): for p in game_state.players: for idx, u in enumerate(p.units): # We can do a direct conversion to Unit and UnitPlan objects for units. - p.units[idx] = Unit(u.health, u.remaining_stamina, (u.location[0], u.location[1]), u.garrisoned, - migrate_unit_plan(u.plan), u.has_acted, u.besieging) + p.units[idx] = migrate_unit(u) for s in p.settlements: # Make sure we remove the settlement's name so that we don't get duplicates. game_controller.namer.remove_settlement_name(s.name, s.quads[0].biome) @@ -112,19 +110,15 @@ def load_game(game_state: GameState, game_controller: GameController): s.improvements[idx] = get_improvement(imp.name) # Also convert all units in garrisons to Unit objects. for idx, u in enumerate(s.garrison): - s.garrison[idx] = Unit(u.health, u.remaining_stamina, (u.location[0], u.location[1]), - u.garrisoned, u.plan, u.has_acted, u.besieging) + s.garrison[idx] = migrate_unit(u) + migrate_settlement(s) # We also do direct conversions to Blessing objects for the ongoing one, if there is one, # as well as any previously-completed ones. if p.ongoing_blessing: p.ongoing_blessing.blessing = get_blessing(p.ongoing_blessing.blessing.name) for idx, bls in enumerate(p.blessings): p.blessings[idx] = get_blessing(bls.name) - if p.ai_playstyle is not None: - p.ai_playstyle = AIPlaystyle(AttackPlaystyle[p.ai_playstyle.attacking], - ExpansionPlaystyle[p.ai_playstyle.expansion]) - p.imminent_victories = set(p.imminent_victories) - p.faction = Faction(p.faction) + migrate_player(p) # For the AI players, we can just make quads_seen an empty set, as it's not used. for i in range(1, len(game_state.players)): game_state.players[i].quads_seen = set() @@ -137,9 +131,8 @@ def load_game(game_state: GameState, game_controller: GameController): h.has_attacked)) game_state.turn = save.turn - game_state.until_night = save.night_status.until - game_state.nighttime_left = save.night_status.remaining - game_cfg = save.cfg + migrate_climatic_effects(game_state, save) + game_cfg = migrate_game_config(save.cfg) save_file.close() # Now do all the same logic we do when starting a game. pyxel.mouse(visible=True) diff --git a/source/saving/save_migrator.py b/source/saving/save_migrator.py index a81c915..34c6f37 100644 --- a/source/saving/save_migrator.py +++ b/source/saving/save_migrator.py @@ -1,5 +1,7 @@ -from source.foundation.catalogue import get_blessing -from source.foundation.models import UnitPlan +from source.foundation.catalogue import get_blessing, FACTION_COLOURS +from source.foundation.models import UnitPlan, Unit, Faction, AIPlaystyle, AttackPlaystyle, ExpansionPlaystyle, Quad, \ + Biome, GameConfig +from source.game_management.game_state import GameState """ The following migrations have occurred during Microcosm's development: @@ -16,12 +18,14 @@ player has. v1.2 -- Climatic effects were added to the game as a part of its configuration. This can be mapped to False. +- Climatic effects were added to the game as a part of its configuration. This can be mapped to False, and night + counters can be mapped to zero. v2.0 - Factions were added for players. As it would be difficult for players to choose, their faction can be determined from the colour they chose. -- The player faction was also added to the game configuration, which can be determined in the same way as above. +- The player faction was also added to the game configuration, replacing player colour, which can be determined in the + same way as above. v2.2 - Healing units were added. All existing unit plans can be mapped to False for the heals attribute. @@ -39,3 +43,60 @@ def migrate_unit_plan(unit_plan) -> UnitPlan: return UnitPlan(unit_plan.power, unit_plan.max_health, unit_plan.total_stamina, unit_plan.name, plan_prereq, unit_plan.cost, unit_plan.can_settle, will_heal) + + +def migrate_unit(unit) -> Unit: + will_have_acted: bool = unit.has_acted if hasattr(unit, "has_acted") else unit.has_attacked + will_be_besieging: bool = unit.besieging if hasattr(unit, "besieging") else unit.sieging + return Unit(unit.health, unit.remaining_stamina, (unit.location[0], unit.location[1]), unit.garrisoned, + migrate_unit_plan(unit.plan), will_have_acted, will_be_besieging) + + +def migrate_player(player): + if player.ai_playstyle is not None: + if hasattr(player.ai_playstyle, "attacking"): + player.ai_playstyle = AIPlaystyle(AttackPlaystyle[player.ai_playstyle.attacking], + ExpansionPlaystyle[player.ai_playstyle.expansion]) + else: + player.ai_playstyle = AIPlaystyle(AttackPlaystyle[player.ai_playstyle], ExpansionPlaystyle.NEUTRAL) + player.imminent_victories = set(player.imminent_victories) if hasattr(player, "imminent_victories") else set() + player.faction = Faction(player.faction) if hasattr(player, "faction") else get_faction_for_colour(player.colour) + if not hasattr(player, "eliminated"): + player.eliminated = len(player.settlements) == 0 + + +def migrate_climatic_effects(game_state: GameState, save): + game_state.until_night = save.night_status.until if hasattr(save, "night_status") else 0 + game_state.nighttime_left = save.night_status.remaining if hasattr(save, "night_status") else 0 + + +def migrate_quad(quad) -> Quad: + new_quad = quad + # The biomes require special loading. + new_quad.biome = Biome[new_quad.biome] + new_quad.is_relic = new_quad.is_relic if hasattr(new_quad, "is_relic") else False + return new_quad + + +def migrate_settlement(settlement): + if not hasattr(settlement, "besieged"): + if settlement.under_siege_by is not None: + settlement.besieged = True + else: + settlement.besieged = False + + +def migrate_game_config(config) -> GameConfig: + if not hasattr(config, "climatic_effects"): + config.climatic_effects = False + if not hasattr(config, "player_faction"): + config.player_faction = get_faction_for_colour(config.player_colour) + delattr(config, "player_colour") + return config + + +def get_faction_for_colour(colour: int) -> Faction: + factions = list(FACTION_COLOURS.keys()) + colours = list(FACTION_COLOURS.values()) + idx = colours.index(colour) + return factions[idx] From a10a609aa1675e48cf8e220ffb65e2c1a883a8e4 Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Fri, 10 Feb 2023 21:14:18 +1100 Subject: [PATCH 3/8] MCR-103 Narrowed except from Exception. --- source/saving/game_save_manager.py | 156 ++++++++++++++--------------- source/saving/save_migrator.py | 15 ++- 2 files changed, 91 insertions(+), 80 deletions(-) diff --git a/source/saving/game_save_manager.py b/source/saving/game_save_manager.py index aff870b..1a0bee5 100644 --- a/source/saving/game_save_manager.py +++ b/source/saving/game_save_manager.py @@ -2,6 +2,7 @@ import os from datetime import datetime from itertools import chain +from json import JSONDecodeError import pyxel @@ -69,86 +70,85 @@ def load_game(game_state: GameState, game_controller: GameController): saves.reverse() all_saves = autosaves + saves - # try: - with open(os.path.join(SAVES_DIR, all_saves[game_controller.menu.save_idx]), "r", - encoding="utf-8") as save_file: - # Use a custom object hook when loading the JSON so that the resulting objects have attribute access. - save = json.loads(save_file.read(), object_hook=ObjectConverter) - # Load in the quads. - quads = [[None] * 100 for _ in range(90)] - for i in range(90): - for j in range(100): - quads[i][j] = migrate_quad(save.quads[i * 100 + j]) - game_state.players = save.players - # The list of tuples that is quads_seen needs special loading, as do a few other of the same type, - # because tuples do not exist in JSON, so they are represented as arrays, which will clearly not work. - for i in range(len(game_state.players[0].quads_seen)): - game_state.players[0].quads_seen[i] = ( - game_state.players[0].quads_seen[i][0], game_state.players[0].quads_seen[i][1]) - game_state.players[0].quads_seen = set(game_state.players[0].quads_seen) - for p in game_state.players: - for idx, u in enumerate(p.units): - # We can do a direct conversion to Unit and UnitPlan objects for units. - p.units[idx] = migrate_unit(u) - for s in p.settlements: - # Make sure we remove the settlement's name so that we don't get duplicates. - game_controller.namer.remove_settlement_name(s.name, s.quads[0].biome) - # Another tuple-array fix. - s.location = (s.location[0], s.location[1]) - if s.current_work is not None: - # Get the actual Improvement, Project, or UnitPlan objects for the current work. We use - # hasattr() because improvements have an effect where projects do not, and projects have - # a type where unit plans do not. - if hasattr(s.current_work.construction, "effect"): - s.current_work.construction = get_improvement(s.current_work.construction.name) - elif hasattr(s.current_work.construction, "type"): - s.current_work.construction = get_project(s.current_work.construction.name) - else: - s.current_work.construction = get_unit_plan(s.current_work.construction.name) - for idx, imp in enumerate(s.improvements): - # Do another direct conversion for improvements. - s.improvements[idx] = get_improvement(imp.name) - # Also convert all units in garrisons to Unit objects. - for idx, u in enumerate(s.garrison): - s.garrison[idx] = migrate_unit(u) - migrate_settlement(s) - # We also do direct conversions to Blessing objects for the ongoing one, if there is one, - # as well as any previously-completed ones. - if p.ongoing_blessing: - p.ongoing_blessing.blessing = get_blessing(p.ongoing_blessing.blessing.name) - for idx, bls in enumerate(p.blessings): - p.blessings[idx] = get_blessing(bls.name) - migrate_player(p) - # For the AI players, we can just make quads_seen an empty set, as it's not used. - for i in range(1, len(game_state.players)): - game_state.players[i].quads_seen = set() + try: + with open(os.path.join(SAVES_DIR, all_saves[game_controller.menu.save_idx]), "r", + encoding="utf-8") as save_file: + # Use a custom object hook when loading the JSON so that the resulting objects have attribute access. + save = json.loads(save_file.read(), object_hook=ObjectConverter) + # Load in the quads. + quads = [[None] * 100 for _ in range(90)] + for i in range(90): + for j in range(100): + quads[i][j] = migrate_quad(save.quads[i * 100 + j]) + game_state.players = save.players + # The list of tuples that is quads_seen needs special loading, as do a few other of the same type, + # because tuples do not exist in JSON, so they are represented as arrays, which will clearly not work. + for i in range(len(game_state.players[0].quads_seen)): + game_state.players[0].quads_seen[i] = ( + game_state.players[0].quads_seen[i][0], game_state.players[0].quads_seen[i][1]) + game_state.players[0].quads_seen = set(game_state.players[0].quads_seen) + for p in game_state.players: + for idx, u in enumerate(p.units): + # We can do a direct conversion to Unit and UnitPlan objects for units. + p.units[idx] = migrate_unit(u) + for s in p.settlements: + # Make sure we remove the settlement's name so that we don't get duplicates. + game_controller.namer.remove_settlement_name(s.name, s.quads[0].biome) + # Another tuple-array fix. + s.location = (s.location[0], s.location[1]) + if s.current_work is not None: + # Get the actual Improvement, Project, or UnitPlan objects for the current work. We use + # hasattr() because improvements have an effect where projects do not, and projects have + # a type where unit plans do not. + if hasattr(s.current_work.construction, "effect"): + s.current_work.construction = get_improvement(s.current_work.construction.name) + elif hasattr(s.current_work.construction, "type"): + s.current_work.construction = get_project(s.current_work.construction.name) + else: + s.current_work.construction = get_unit_plan(s.current_work.construction.name) + for idx, imp in enumerate(s.improvements): + # Do another direct conversion for improvements. + s.improvements[idx] = get_improvement(imp.name) + # Also convert all units in garrisons to Unit objects. + for idx, u in enumerate(s.garrison): + s.garrison[idx] = migrate_unit(u) + migrate_settlement(s) + # We also do direct conversions to Blessing objects for the ongoing one, if there is one, + # as well as any previously-completed ones. + if p.ongoing_blessing: + p.ongoing_blessing.blessing = get_blessing(p.ongoing_blessing.blessing.name) + for idx, bls in enumerate(p.blessings): + p.blessings[idx] = get_blessing(bls.name) + migrate_player(p) + # For the AI players, we can just make quads_seen an empty set, as it's not used. + for i in range(1, len(game_state.players)): + game_state.players[i].quads_seen = set() - game_state.heathens = [] - for h in save.heathens: - # Do another direct conversion for the heathens. - game_state.heathens.append(Heathen(h.health, h.remaining_stamina, (h.location[0], h.location[1]), - UnitPlan(h.plan.power, h.plan.max_health, 2, h.plan.name, None, 0), - h.has_attacked)) + game_state.heathens = [] + for h in save.heathens: + # Do another direct conversion for the heathens. + game_state.heathens.append(Heathen(h.health, h.remaining_stamina, (h.location[0], h.location[1]), + UnitPlan(h.plan.power, h.plan.max_health, 2, h.plan.name, None, 0), + h.has_attacked)) - game_state.turn = save.turn - migrate_climatic_effects(game_state, save) - game_cfg = migrate_game_config(save.cfg) - save_file.close() - # Now do all the same logic we do when starting a game. - pyxel.mouse(visible=True) - game_state.game_started = True - game_state.on_menu = False - game_state.board = Board(game_cfg, game_controller.namer, quads) - game_controller.move_maker.board_ref = game_state.board - # Initialise the map position to the player's first settlement. - game_state.map_pos = (clamp(game_state.players[0].settlements[0].location[0] - 12, -1, 77), - clamp(game_state.players[0].settlements[0].location[1] - 11, -1, 69)) - game_state.board.overlay.current_player = game_state.players[0] - game_controller.music_player.stop_menu_music() - game_controller.music_player.play_game_music() - # pylint: disable=broad-except - # except Exception: - # game_controller.menu.load_failed = True + game_state.turn = save.turn + migrate_climatic_effects(game_state, save) + game_cfg = migrate_game_config(save.cfg) + save_file.close() + # Now do all the same logic we do when starting a game. + pyxel.mouse(visible=True) + game_state.game_started = True + game_state.on_menu = False + game_state.board = Board(game_cfg, game_controller.namer, quads) + game_controller.move_maker.board_ref = game_state.board + # Initialise the map position to the player's first settlement. + game_state.map_pos = (clamp(game_state.players[0].settlements[0].location[0] - 12, -1, 77), + clamp(game_state.players[0].settlements[0].location[1] - 11, -1, 69)) + game_state.board.overlay.current_player = game_state.players[0] + game_controller.music_player.stop_menu_music() + game_controller.music_player.play_game_music() + except (JSONDecodeError, AttributeError, KeyError, StopIteration, ValueError): + game_controller.menu.load_failed = True def get_saves(game_controller: GameController): diff --git a/source/saving/save_migrator.py b/source/saving/save_migrator.py index 34c6f37..f838cb2 100644 --- a/source/saving/save_migrator.py +++ b/source/saving/save_migrator.py @@ -46,8 +46,18 @@ def migrate_unit_plan(unit_plan) -> UnitPlan: def migrate_unit(unit) -> Unit: - will_have_acted: bool = unit.has_acted if hasattr(unit, "has_acted") else unit.has_attacked - will_be_besieging: bool = unit.besieging if hasattr(unit, "besieging") else unit.sieging + will_have_acted: bool + if hasattr(unit, "has_acted"): + will_have_acted = unit.has_acted + else: + will_have_acted = unit.has_attacked + delattr(unit, "has_attacked") + will_be_besieging: bool + if hasattr(unit, "besieging"): + will_be_besieging = unit.besieging + else: + will_be_besieging = unit.sieging + delattr(unit, "sieging") return Unit(unit.health, unit.remaining_stamina, (unit.location[0], unit.location[1]), unit.garrisoned, migrate_unit_plan(unit.plan), will_have_acted, will_be_besieging) @@ -84,6 +94,7 @@ def migrate_settlement(settlement): settlement.besieged = True else: settlement.besieged = False + delattr(settlement, "under_siege_by") def migrate_game_config(config) -> GameConfig: From b9fe2ad60ee6dac9ab8d1b3c12c166efedda3bfc Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Fri, 10 Feb 2023 21:16:31 +1100 Subject: [PATCH 4/8] MCR-103 Updated game load fail error message. --- source/display/menu.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/source/display/menu.py b/source/display/menu.py index e5fa287..0e6b7a5 100644 --- a/source/display/menu.py +++ b/source/display/menu.py @@ -169,8 +169,7 @@ def draw(self): pyxel.rect(25, 76, 150, 58, pyxel.COLOR_BLACK) pyxel.text(85, 81, "Oh no!", pyxel.COLOR_RED) pyxel.text(35, 92, "Error: This game save is invalid.", pyxel.COLOR_RED) - pyxel.text(55, 100, "It's either corrupted or", pyxel.COLOR_RED) - pyxel.text(43, 108, "incompatible with this version.", pyxel.COLOR_RED) + pyxel.text(53, 100, "It's probably corrupted.", pyxel.COLOR_RED) pyxel.text(56, 120, "Press SPACE to go back", pyxel.COLOR_WHITE) else: From f941a3853a42955d62485fd0bdbb66c71452f412 Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Fri, 10 Feb 2023 21:27:32 +1100 Subject: [PATCH 5/8] MCR-103 Added first test. --- source/tests/test_save_migrator.py | 46 ++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 source/tests/test_save_migrator.py diff --git a/source/tests/test_save_migrator.py b/source/tests/test_save_migrator.py new file mode 100644 index 0000000..3b1e79a --- /dev/null +++ b/source/tests/test_save_migrator.py @@ -0,0 +1,46 @@ +import unittest + +from source.foundation.models import UnitPlan +from source.saving.save_encoder import ObjectConverter +from source.saving.save_migrator import migrate_unit_plan + + +class SaveMigratorTest(unittest.TestCase): + def test_unit_plan(self): + test_power = 100 + test_max_health = 200 + test_total_stamina = 4 + test_name = "Bob" + test_cost = 350 + + test_loaded_plan: ObjectConverter = ObjectConverter({ + "power": test_power, + "max_health": test_max_health, + "total_stamina": test_total_stamina, + "name": test_name, + "prereq": None, + "cost": test_cost, + "can_settle": False, + "heals": True + }) + + migrated_plan: UnitPlan = migrate_unit_plan(test_loaded_plan) + + self.assertEqual(test_power, migrated_plan.power) + self.assertEqual(test_max_health, migrated_plan.max_health) + self.assertEqual(test_total_stamina, migrated_plan.total_stamina) + self.assertEqual(test_name, migrated_plan.name) + self.assertIsNone(migrated_plan.prereq) + self.assertEqual(test_cost, migrated_plan.cost) + self.assertFalse(migrated_plan.can_settle) + self.assertTrue(migrated_plan.heals) + + delattr(test_loaded_plan, "heals") + + outdated_plan: UnitPlan = migrate_unit_plan(test_loaded_plan) + self.assertFalse(outdated_plan.heals) + + + +if __name__ == '__main__': + unittest.main() From cb26c79f7819a2504c5d93f53baf01913752f486 Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Sat, 11 Feb 2023 17:29:02 +1100 Subject: [PATCH 6/8] MCR-103 Added some more tests. --- source/tests/test_save_migrator.py | 126 ++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 2 deletions(-) diff --git a/source/tests/test_save_migrator.py b/source/tests/test_save_migrator.py index 3b1e79a..d31ff35 100644 --- a/source/tests/test_save_migrator.py +++ b/source/tests/test_save_migrator.py @@ -1,8 +1,14 @@ import unittest -from source.foundation.models import UnitPlan +import pyxel + +from source.foundation.catalogue import UNIT_PLANS +from source.foundation.models import UnitPlan, Unit, AttackPlaystyle, ExpansionPlaystyle, VictoryType, Faction, \ + Settlement, Biome, Quad +from source.game_management.game_state import GameState from source.saving.save_encoder import ObjectConverter -from source.saving.save_migrator import migrate_unit_plan +from source.saving.save_migrator import migrate_unit_plan, migrate_unit, migrate_player, migrate_climatic_effects, \ + migrate_quad class SaveMigratorTest(unittest.TestCase): @@ -40,6 +46,122 @@ def test_unit_plan(self): outdated_plan: UnitPlan = migrate_unit_plan(test_loaded_plan) self.assertFalse(outdated_plan.heals) + def test_unit(self): + test_health = 300 + test_remaining_stamina = 3 + test_location = [1, 2] + + test_loaded_unit: ObjectConverter = ObjectConverter({ + "health": test_health, + "remaining_stamina": test_remaining_stamina, + "location": test_location, + "garrisoned": False, + "plan": UNIT_PLANS[0], + "has_acted": True, + "besieging": False + }) + + migrated_unit: Unit = migrate_unit(test_loaded_unit) + + self.assertEqual(test_health, migrated_unit.health) + self.assertEqual(test_remaining_stamina, migrated_unit.remaining_stamina) + self.assertTupleEqual((test_location[0], test_location[1]), migrated_unit.location) + self.assertFalse(migrated_unit.garrisoned) + self.assertEqual(UNIT_PLANS[0], migrated_unit.plan) + self.assertTrue(migrated_unit.has_acted) + self.assertFalse(migrated_unit.besieging) + + delattr(test_loaded_unit, "has_acted") + delattr(test_loaded_unit, "besieging") + test_loaded_unit.__dict__["has_attacked"] = True + test_loaded_unit.__dict__["sieging"] = False + + outdated_unit: Unit = migrate_unit(test_loaded_unit) + self.assertTrue(outdated_unit.has_acted) + self.assertFalse(outdated_unit.besieging) + + def test_player(self): + test_attack_playstyle = AttackPlaystyle.AGGRESSIVE.value + test_expansion_playstyle = ExpansionPlaystyle.HERMIT.value + test_imminent_victories = [VictoryType.SERENDIPITY.value] + test_faction = Faction.FUNDAMENTALISTS.value + + test_loaded_ai_playstyle: ObjectConverter = ObjectConverter({ + "attacking": test_attack_playstyle, + "expansion": test_expansion_playstyle + }) + test_loaded_player: ObjectConverter = ObjectConverter({ + "ai_playstyle": test_loaded_ai_playstyle, + "imminent_victories": test_imminent_victories, + "faction": test_faction, + "colour": pyxel.COLOR_ORANGE, + "eliminated": False, + "settlements": [Settlement("A", (1, 2), [], [], [])] + }) + + migrate_player(test_loaded_player) + + self.assertEqual(test_attack_playstyle, test_loaded_player.ai_playstyle.attacking) + self.assertEqual(test_expansion_playstyle, test_loaded_player.ai_playstyle.expansion) + self.assertSetEqual(set(test_imminent_victories), test_loaded_player.imminent_victories) + self.assertEqual(test_faction, test_loaded_player.faction) + + test_loaded_player.__dict__["ai_playstyle"] = test_attack_playstyle + delattr(test_loaded_player, "imminent_victories") + delattr(test_loaded_player, "faction") + delattr(test_loaded_player, "eliminated") + + migrate_player(test_loaded_player) + + self.assertEqual(test_attack_playstyle, test_loaded_player.ai_playstyle.attacking) + self.assertEqual(ExpansionPlaystyle.NEUTRAL, test_loaded_player.ai_playstyle.expansion) + self.assertSetEqual(set(), test_loaded_player.imminent_victories) + self.assertEqual(test_faction, test_loaded_player.faction) + self.assertFalse(test_loaded_player.eliminated) + + def test_climatic_effects(self): + test_until_night = 3 + test_nighttime_left = 0 + + test_loaded_night_status: ObjectConverter = ObjectConverter({ + "until": test_until_night, + "remaining": test_nighttime_left + }) + test_loaded_save: ObjectConverter = ObjectConverter({ + "night_status": test_loaded_night_status + }) + test_game_state = GameState() + + migrate_climatic_effects(test_game_state, test_loaded_save) + + self.assertEqual(test_game_state.until_night, test_until_night) + self.assertEqual(test_game_state.nighttime_left, test_nighttime_left) + + delattr(test_loaded_save, "night_status") + + migrate_climatic_effects(test_game_state, test_loaded_save) + + self.assertFalse(test_game_state.until_night) + self.assertFalse(test_game_state.nighttime_left) + + def test_quad(self): + test_biome = Biome.FOREST.value + + test_loaded_quad: ObjectConverter = ObjectConverter({ + "biome": test_biome, + "is_relic": True + }) + + migrated_quad: Quad = migrate_quad(test_loaded_quad) + + self.assertEqual(test_biome, migrated_quad.biome) + self.assertTrue(migrated_quad.is_relic) + + delattr(test_loaded_quad, "is_relic") + + outdated_quad: Quad = migrate_quad(test_loaded_quad) + + self.assertFalse(outdated_quad.is_relic) if __name__ == '__main__': From 897e8cc8213836d429202d4f4fc85daf500be6c7 Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Sun, 12 Feb 2023 19:48:31 +1100 Subject: [PATCH 7/8] MCR-103 Finished tests. --- source/tests/test_save_migrator.py | 44 ++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/source/tests/test_save_migrator.py b/source/tests/test_save_migrator.py index d31ff35..d4198db 100644 --- a/source/tests/test_save_migrator.py +++ b/source/tests/test_save_migrator.py @@ -4,11 +4,11 @@ from source.foundation.catalogue import UNIT_PLANS from source.foundation.models import UnitPlan, Unit, AttackPlaystyle, ExpansionPlaystyle, VictoryType, Faction, \ - Settlement, Biome, Quad + Settlement, Biome, Quad, GameConfig from source.game_management.game_state import GameState from source.saving.save_encoder import ObjectConverter from source.saving.save_migrator import migrate_unit_plan, migrate_unit, migrate_player, migrate_climatic_effects, \ - migrate_quad + migrate_quad, migrate_settlement, migrate_game_config class SaveMigratorTest(unittest.TestCase): @@ -79,6 +79,8 @@ def test_unit(self): outdated_unit: Unit = migrate_unit(test_loaded_unit) self.assertTrue(outdated_unit.has_acted) self.assertFalse(outdated_unit.besieging) + self.assertFalse(hasattr(outdated_unit, "has_attacked")) + self.assertFalse(hasattr(outdated_unit, "sieging")) def test_player(self): test_attack_playstyle = AttackPlaystyle.AGGRESSIVE.value @@ -163,6 +165,44 @@ def test_quad(self): self.assertFalse(outdated_quad.is_relic) + def test_settlement(self): + test_loaded_besieged_settlement: ObjectConverter = ObjectConverter({ + "under_siege_by": Unit(1, 2, (3, 4), False, UNIT_PLANS[0]) + }) + + migrate_settlement(test_loaded_besieged_settlement) + + self.assertTrue(test_loaded_besieged_settlement.besieged) + self.assertFalse(hasattr(test_loaded_besieged_settlement, "under_siege_by")) + + test_loaded_settlement = ObjectConverter({ + "under_siege_by": None + }) + + migrate_settlement(test_loaded_settlement) + + self.assertFalse(test_loaded_settlement.besieged) + self.assertFalse(hasattr(test_loaded_settlement, "under_siege_by")) + + def test_game_config(self): + test_player_count = 9 + + test_loaded_config: ObjectConverter = ObjectConverter({ + "player_count": test_player_count, + "player_colour": pyxel.COLOR_ORANGE, + "biome_clustering": True, + "fog_of_war": True + }) + + outdated_config: GameConfig = migrate_game_config(test_loaded_config) + + self.assertFalse(outdated_config.climatic_effects) + self.assertEqual(Faction.FUNDAMENTALISTS, outdated_config.player_faction) + self.assertFalse(hasattr(outdated_config, "player_colour")) + self.assertEqual(test_player_count, outdated_config.player_count) + self.assertTrue(outdated_config.biome_clustering) + self.assertTrue(outdated_config.fog_of_war) + if __name__ == '__main__': unittest.main() From 50b4c61de54ff7162ffeaa1859c8996f61ff624f Mon Sep 17 00:00:00 2001 From: ChrisNeedham24 Date: Sun, 12 Feb 2023 20:14:37 +1100 Subject: [PATCH 8/8] MCR-103 Added documentation. --- source/saving/game_save_manager.py | 9 ++--- source/saving/save_migrator.py | 42 ++++++++++++++++++++ source/tests/test_save_migrator.py | 63 ++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/source/saving/game_save_manager.py b/source/saving/game_save_manager.py index 1a0bee5..da9dd8e 100644 --- a/source/saving/game_save_manager.py +++ b/source/saving/game_save_manager.py @@ -7,15 +7,14 @@ import pyxel from source.display.board import Board -from source.saving.save_migrator import migrate_unit_plan, migrate_unit, migrate_player, migrate_climatic_effects, \ - migrate_quad, migrate_settlement, migrate_game_config -from source.util.calculator import clamp from source.foundation.catalogue import get_blessing, get_project, get_unit_plan, get_improvement +from source.foundation.models import Heathen, UnitPlan from source.game_management.game_controller import GameController from source.game_management.game_state import GameState -from source.foundation.models import Biome, Heathen, UnitPlan, AttackPlaystyle, AIPlaystyle, Unit, ExpansionPlaystyle, \ - Faction from source.saving.save_encoder import SaveEncoder, ObjectConverter +from source.saving.save_migrator import migrate_unit, migrate_player, migrate_climatic_effects, \ + migrate_quad, migrate_settlement, migrate_game_config +from source.util.calculator import clamp # The prefix attached to save files created by the autosave feature. AUTOSAVE_PREFIX = "auto" diff --git a/source/saving/save_migrator.py b/source/saving/save_migrator.py index f838cb2..7bf6a16 100644 --- a/source/saving/save_migrator.py +++ b/source/saving/save_migrator.py @@ -37,6 +37,11 @@ def migrate_unit_plan(unit_plan) -> UnitPlan: + """ + Apply the heals attribute migration for UnitPlans, if required. + :param unit_plan: The loaded unit plan object. + :return: An optionally-migrated UnitPlan representation. + """ plan_prereq = None if unit_plan.prereq is None else get_blessing(unit_plan.prereq.name) will_heal: bool = unit_plan.heals if hasattr(unit_plan, "heals") else False @@ -46,6 +51,13 @@ def migrate_unit_plan(unit_plan) -> UnitPlan: def migrate_unit(unit) -> Unit: + """ + Apply the has_attacked to has_acted and sieging to besieging migrations for Units, if required. + :param unit: The loaded unit object. + :return: An optionally-migrated Unit representation. + """ + # Note for the below migrations that if we detect an outdated attribute, we migrate it and then delete it so that it + # does not pollute future saves. will_have_acted: bool if hasattr(unit, "has_acted"): will_have_acted = unit.has_acted @@ -63,6 +75,10 @@ def migrate_unit(unit) -> Unit: def migrate_player(player): + """ + Apply the ai_playstyle, imminent_victories, faction, and eliminated migrations for Players, if required. + :param player: The loaded player object. + """ if player.ai_playstyle is not None: if hasattr(player.ai_playstyle, "attacking"): player.ai_playstyle = AIPlaystyle(AttackPlaystyle[player.ai_playstyle.attacking], @@ -76,11 +92,21 @@ def migrate_player(player): def migrate_climatic_effects(game_state: GameState, save): + """ + Apply the night_status migrations for the game state, if required. + :param game_state: The state of the game being loaded in. + :param save: The loaded save data. + """ game_state.until_night = save.night_status.until if hasattr(save, "night_status") else 0 game_state.nighttime_left = save.night_status.remaining if hasattr(save, "night_status") else 0 def migrate_quad(quad) -> Quad: + """ + Apply the is_relic migration for Quads, if required. + :param quad: The loaded quad object. + :return: An optionally-migrated Quad representation. + """ new_quad = quad # The biomes require special loading. new_quad.biome = Biome[new_quad.biome] @@ -89,24 +115,40 @@ def migrate_quad(quad) -> Quad: def migrate_settlement(settlement): + """ + Apply the besieged migration for Settlements, if required. + :param settlement: The loaded settlement object. + """ if not hasattr(settlement, "besieged"): if settlement.under_siege_by is not None: settlement.besieged = True else: settlement.besieged = False + # We now delete the old attribute so that it does not pollute future saves. delattr(settlement, "under_siege_by") def migrate_game_config(config) -> GameConfig: + """ + Apply the climatic_effects and player_faction migrations for game configuration, if required. + :param config: The loaded game configuration. + :return: An optionally-migrated GameConfig representation. + """ if not hasattr(config, "climatic_effects"): config.climatic_effects = False if not hasattr(config, "player_faction"): config.player_faction = get_faction_for_colour(config.player_colour) + # We now delete the old attribute so that it does not pollute future saves. delattr(config, "player_colour") return config def get_faction_for_colour(colour: int) -> Faction: + """ + Utility function that retrieves the faction for the supplied colour. Used for colour-to-faction migrations. + :param colour: The colour to retrieve the faction for. + :return: The faction for the supplied colour. + """ factions = list(FACTION_COLOURS.keys()) colours = list(FACTION_COLOURS.values()) idx = colours.index(colour) diff --git a/source/tests/test_save_migrator.py b/source/tests/test_save_migrator.py index d4198db..8ef1a18 100644 --- a/source/tests/test_save_migrator.py +++ b/source/tests/test_save_migrator.py @@ -12,13 +12,21 @@ class SaveMigratorTest(unittest.TestCase): + """ + The test class for save_migrator.py. + """ + def test_unit_plan(self): + """ + Ensure that migrations occur correctly for UnitPlans. + """ test_power = 100 test_max_health = 200 test_total_stamina = 4 test_name = "Bob" test_cost = 350 + # Simulate an up-to-date loaded unit plan. test_loaded_plan: ObjectConverter = ObjectConverter({ "power": test_power, "max_health": test_max_health, @@ -32,6 +40,7 @@ def test_unit_plan(self): migrated_plan: UnitPlan = migrate_unit_plan(test_loaded_plan) + # For up-to-date unit plans, the attributes should all map across directly. self.assertEqual(test_power, migrated_plan.power) self.assertEqual(test_max_health, migrated_plan.max_health) self.assertEqual(test_total_stamina, migrated_plan.total_stamina) @@ -41,16 +50,22 @@ def test_unit_plan(self): self.assertFalse(migrated_plan.can_settle) self.assertTrue(migrated_plan.heals) + # Now delete the heals attribute, to simulate an outdated save. delattr(test_loaded_plan, "heals") outdated_plan: UnitPlan = migrate_unit_plan(test_loaded_plan) + # Old unit plans should be mapped to False. self.assertFalse(outdated_plan.heals) def test_unit(self): + """ + Ensure that migrations occur correctly for Units. + """ test_health = 300 test_remaining_stamina = 3 test_location = [1, 2] + # Simulate an up-to-date loaded unit. test_loaded_unit: ObjectConverter = ObjectConverter({ "health": test_health, "remaining_stamina": test_remaining_stamina, @@ -63,6 +78,7 @@ def test_unit(self): migrated_unit: Unit = migrate_unit(test_loaded_unit) + # For up-to-date units, the attributes should all map across directly. self.assertEqual(test_health, migrated_unit.health) self.assertEqual(test_remaining_stamina, migrated_unit.remaining_stamina) self.assertTupleEqual((test_location[0], test_location[1]), migrated_unit.location) @@ -71,23 +87,31 @@ def test_unit(self): self.assertTrue(migrated_unit.has_acted) self.assertFalse(migrated_unit.besieging) + # Now delete the has_acted and besieging attributes, replacing them with the outdated has_attacked and sieging + # attributes. delattr(test_loaded_unit, "has_acted") delattr(test_loaded_unit, "besieging") test_loaded_unit.__dict__["has_attacked"] = True test_loaded_unit.__dict__["sieging"] = False outdated_unit: Unit = migrate_unit(test_loaded_unit) + # We expect the outdated attributes to be mapped to the new ones. self.assertTrue(outdated_unit.has_acted) self.assertFalse(outdated_unit.besieging) + # We also expect that the old attributes are deleted. self.assertFalse(hasattr(outdated_unit, "has_attacked")) self.assertFalse(hasattr(outdated_unit, "sieging")) def test_player(self): + """ + Ensure that migrations occur correctly for players. + """ test_attack_playstyle = AttackPlaystyle.AGGRESSIVE.value test_expansion_playstyle = ExpansionPlaystyle.HERMIT.value test_imminent_victories = [VictoryType.SERENDIPITY.value] test_faction = Faction.FUNDAMENTALISTS.value + # Simulate an up-to-date loaded AI playstyle and an up-to-date loaded player. test_loaded_ai_playstyle: ObjectConverter = ObjectConverter({ "attacking": test_attack_playstyle, "expansion": test_expansion_playstyle @@ -103,11 +127,14 @@ def test_player(self): migrate_player(test_loaded_player) + # For up-to-date players, the attributes should all map across directly. self.assertEqual(test_attack_playstyle, test_loaded_player.ai_playstyle.attacking) self.assertEqual(test_expansion_playstyle, test_loaded_player.ai_playstyle.expansion) self.assertSetEqual(set(test_imminent_victories), test_loaded_player.imminent_victories) self.assertEqual(test_faction, test_loaded_player.faction) + # Now, convert the object to be like an outdated save, where AI playstyle only consisted of attacking, and the + # imminent victories, faction, and eliminated attributes did not exist. test_loaded_player.__dict__["ai_playstyle"] = test_attack_playstyle delattr(test_loaded_player, "imminent_victories") delattr(test_loaded_player, "faction") @@ -115,16 +142,24 @@ def test_player(self): migrate_player(test_loaded_player) + # We expect the attacking playstyle to be mapped across, and the expansion playstyle to be set to neutral. self.assertEqual(test_attack_playstyle, test_loaded_player.ai_playstyle.attacking) self.assertEqual(ExpansionPlaystyle.NEUTRAL, test_loaded_player.ai_playstyle.expansion) + # Imminent victories should be initialised to an empty set, since it'll be populated next turn anyway. self.assertSetEqual(set(), test_loaded_player.imminent_victories) + # The player's faction should have been determined based on the player's colour. self.assertEqual(test_faction, test_loaded_player.faction) + # Lastly, the player should not be eliminated, since they have a settlement. self.assertFalse(test_loaded_player.eliminated) def test_climatic_effects(self): + """ + Ensure that migrations occur correctly for game state. + """ test_until_night = 3 test_nighttime_left = 0 + # Simulate an up-to-date loaded save. test_loaded_night_status: ObjectConverter = ObjectConverter({ "until": test_until_night, "remaining": test_nighttime_left @@ -136,19 +171,27 @@ def test_climatic_effects(self): migrate_climatic_effects(test_game_state, test_loaded_save) + # For up-to-date saves, the attributes should be mapped directly. self.assertEqual(test_game_state.until_night, test_until_night) self.assertEqual(test_game_state.nighttime_left, test_nighttime_left) + # Now delete the night_status attribute, to simulate an outdated save from before the introduction of climatic + # effects. delattr(test_loaded_save, "night_status") migrate_climatic_effects(test_game_state, test_loaded_save) + # Since the day-night flow will not occur, we expect both to be initialised to zero. self.assertFalse(test_game_state.until_night) self.assertFalse(test_game_state.nighttime_left) def test_quad(self): + """ + Ensure that migrations occur correctly for quads. + """ test_biome = Biome.FOREST.value + # Simulate an up-to-date loaded quad. test_loaded_quad: ObjectConverter = ObjectConverter({ "biome": test_biome, "is_relic": True @@ -156,37 +199,53 @@ def test_quad(self): migrated_quad: Quad = migrate_quad(test_loaded_quad) + # For up-to-date quads, we expect the attributes to be mapped over directly. self.assertEqual(test_biome, migrated_quad.biome) self.assertTrue(migrated_quad.is_relic) + # Now if we delete the is_relic attribute, we are replicating an outdated save. delattr(test_loaded_quad, "is_relic") outdated_quad: Quad = migrate_quad(test_loaded_quad) + # Even without the attribute, outdated quads should have is_relic set to False. self.assertFalse(outdated_quad.is_relic) def test_settlement(self): + """ + Ensure that migrations occur correctly for settlements. + """ + # Simulate an outdated loaded settlement under siege. test_loaded_besieged_settlement: ObjectConverter = ObjectConverter({ "under_siege_by": Unit(1, 2, (3, 4), False, UNIT_PLANS[0]) }) migrate_settlement(test_loaded_besieged_settlement) + # The besieged attribute should have been determined based on the outdated under_siege_by attribute, which + # itself should also have been removed. self.assertTrue(test_loaded_besieged_settlement.besieged) self.assertFalse(hasattr(test_loaded_besieged_settlement, "under_siege_by")) + # Simulate an outdated loaded settlement that is not under siege. test_loaded_settlement = ObjectConverter({ "under_siege_by": None }) migrate_settlement(test_loaded_settlement) + # Once again, the besieged attribute should have been determined based on the under_siege_by attribute, which + # itself should also have been removed. self.assertFalse(test_loaded_settlement.besieged) self.assertFalse(hasattr(test_loaded_settlement, "under_siege_by")) def test_game_config(self): + """ + Ensure that migrations occur correctly for game configuration. + """ test_player_count = 9 + # Simulate an outdated loaded game configuration. test_loaded_config: ObjectConverter = ObjectConverter({ "player_count": test_player_count, "player_colour": pyxel.COLOR_ORANGE, @@ -196,9 +255,13 @@ def test_game_config(self): outdated_config: GameConfig = migrate_game_config(test_loaded_config) + # Since this save was from before the introduction of climatic effects, it should have been mapped to False. self.assertFalse(outdated_config.climatic_effects) + # The player faction should have been determined from the player_colour attribute, which should have been + # deleted. self.assertEqual(Faction.FUNDAMENTALISTS, outdated_config.player_faction) self.assertFalse(hasattr(outdated_config, "player_colour")) + # The other three unchanged attributes should have been mapped across directly. self.assertEqual(test_player_count, outdated_config.player_count) self.assertTrue(outdated_config.biome_clustering) self.assertTrue(outdated_config.fog_of_war)