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: diff --git a/source/saving/game_save_manager.py b/source/saving/game_save_manager.py index df7d789..da9dd8e 100644 --- a/source/saving/game_save_manager.py +++ b/source/saving/game_save_manager.py @@ -2,17 +2,19 @@ import os from datetime import datetime from itertools import chain +from json import JSONDecodeError import pyxel from source.display.board import Board -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" @@ -76,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. @@ -89,12 +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. - 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) + 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) @@ -115,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() @@ -140,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) @@ -156,8 +146,7 @@ def load_game(game_state: GameState, game_controller: GameController): 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: + except (JSONDecodeError, AttributeError, KeyError, StopIteration, ValueError): game_controller.menu.load_failed = True diff --git a/source/saving/save_migrator.py b/source/saving/save_migrator.py new file mode 100644 index 0000000..7bf6a16 --- /dev/null +++ b/source/saving/save_migrator.py @@ -0,0 +1,155 @@ +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: + +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, 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, 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. +- 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: + """ + 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 + + 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: + """ + 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 + 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) + + +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], + 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): + """ + 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] + new_quad.is_relic = new_quad.is_relic if hasattr(new_quad, "is_relic") else False + return new_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) + return factions[idx] diff --git a/source/tests/test_save_migrator.py b/source/tests/test_save_migrator.py new file mode 100644 index 0000000..8ef1a18 --- /dev/null +++ b/source/tests/test_save_migrator.py @@ -0,0 +1,271 @@ +import unittest + +import pyxel + +from source.foundation.catalogue import UNIT_PLANS +from source.foundation.models import UnitPlan, Unit, AttackPlaystyle, ExpansionPlaystyle, VictoryType, Faction, \ + 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_settlement, migrate_game_config + + +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, + "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) + + # 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) + 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) + + # 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, + "location": test_location, + "garrisoned": False, + "plan": UNIT_PLANS[0], + "has_acted": True, + "besieging": False + }) + + 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) + self.assertFalse(migrated_unit.garrisoned) + self.assertEqual(UNIT_PLANS[0], migrated_unit.plan) + 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 + }) + 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) + + # 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") + delattr(test_loaded_player, "eliminated") + + 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 + }) + test_loaded_save: ObjectConverter = ObjectConverter({ + "night_status": test_loaded_night_status + }) + test_game_state = GameState() + + 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 + }) + + 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, + "biome_clustering": True, + "fog_of_war": True + }) + + 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) + + +if __name__ == '__main__': + unittest.main()