Skip to content

Commit

Permalink
Merge pull request #119 from ChrisNeedham24/MCR-103
Browse files Browse the repository at this point in the history
MCR-103 Added backwards compatibility for old saves.
  • Loading branch information
ChrisNeedham24 authored Feb 12, 2023
2 parents 74fa98b + 50b4c61 commit cae350e
Show file tree
Hide file tree
Showing 4 changed files with 440 additions and 26 deletions.
3 changes: 1 addition & 2 deletions source/display/menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 13 additions & 24 deletions source/saving/game_save_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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


Expand Down
155 changes: 155 additions & 0 deletions source/saving/save_migrator.py
Original file line number Diff line number Diff line change
@@ -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]
Loading

0 comments on commit cae350e

Please sign in to comment.