diff --git a/TowerForge/TowerForge.xcodeproj/project.pbxproj b/TowerForge/TowerForge.xcodeproj/project.pbxproj index 19e730c1..b458dc9e 100644 --- a/TowerForge/TowerForge.xcodeproj/project.pbxproj +++ b/TowerForge/TowerForge.xcodeproj/project.pbxproj @@ -200,6 +200,14 @@ BA436ADF2BD3CE9600BE3E4F /* CustomMissionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436ADE2BD3CE9600BE3E4F /* CustomMissionCell.swift */; }; BA436AE12BD3D66800BE3E4F /* MassKillMission.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AE02BD3D66800BE3E4F /* MassKillMission.swift */; }; BA436AE32BD3D6FF00BE3E4F /* MassDeathMission.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AE22BD3D6FF00BE3E4F /* MassDeathMission.swift */; }; + BA436AE72BD4180400BE3E4F /* MassEffectMission.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AE62BD4180400BE3E4F /* MassEffectMission.swift */; }; + BA436AEA2BD42F5400BE3E4F /* StorageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AE92BD42F5400BE3E4F /* StorageHandler.swift */; }; + BA436AEC2BD42F7800BE3E4F /* LocalStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AEB2BD42F7800BE3E4F /* LocalStorage.swift */; }; + BA436AEE2BD42F8100BE3E4F /* RemoteStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AED2BD42F8100BE3E4F /* RemoteStorage.swift */; }; + BA436AF02BD437D900BE3E4F /* LocalStorage+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AEF2BD437D900BE3E4F /* LocalStorage+Metadata.swift */; }; + BA436AF22BD443A500BE3E4F /* RemoteStorage+Access.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AF12BD443A500BE3E4F /* RemoteStorage+Access.swift */; }; + BA436AF42BD4AB8400BE3E4F /* StorageHandler+Auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AF32BD4AB8400BE3E4F /* StorageHandler+Auth.swift */; }; + BA436AF62BD4AC2200BE3E4F /* StorageHandler+Conflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA436AF52BD4AC2200BE3E4F /* StorageHandler+Conflict.swift */; }; BA443D3D2BAD9557009F0FFB /* RemoveSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA443D3C2BAD9557009F0FFB /* RemoveSystem.swift */; }; BA443D3F2BAD9774009F0FFB /* RemoveEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA443D3E2BAD9774009F0FFB /* RemoveEvent.swift */; }; BA443D422BAD9885009F0FFB /* DamageEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA443D412BAD9885009F0FFB /* DamageEventTests.swift */; }; @@ -220,18 +228,12 @@ BA82C75F2BCB1528000515A0 /* AchievementsDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C75E2BCB1528000515A0 /* AchievementsDatabase.swift */; }; BA82C7612BCBBA8A000515A0 /* FiftyKillsAchievement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7602BCBBA8A000515A0 /* FiftyKillsAchievement.swift */; }; BA82C7632BCBBB2A000515A0 /* Double+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7622BCBBB2A000515A0 /* Double+Extensions.swift */; }; - BA82C7652BCBC868000515A0 /* LocalStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7642BCBC868000515A0 /* LocalStorageManager.swift */; }; BA82C7672BCBCB00000515A0 /* StatisticsDatabase+Codable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7662BCBCB00000515A0 /* StatisticsDatabase+Codable.swift */; }; - BA82C7692BCBD21F000515A0 /* RemoteStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7682BCBD21F000515A0 /* RemoteStorageManager.swift */; }; - BA82C76B2BCBD682000515A0 /* StorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C76A2BCBD682000515A0 /* StorageManager.swift */; }; BA82C76D2BCBD8F7000515A0 /* StatisticsDatabase+Merge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C76C2BCBD8F7000515A0 /* StatisticsDatabase+Merge.swift */; }; BA82C76F2BCBDE91000515A0 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C76E2BCBDE91000515A0 /* Metadata.swift */; }; - BA82C7732BCBF657000515A0 /* LocalMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7722BCBF657000515A0 /* LocalMetadataManager.swift */; }; BA82C7752BCC689F000515A0 /* AchievementsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7742BCC689F000515A0 /* AchievementsFactory.swift */; }; - BA82C7772BCC6913000515A0 /* HundredKillsAchievement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7762BCC6913000515A0 /* HundredKillsAchievement.swift */; }; + BA82C7772BCC6913000515A0 /* ThousandKillsAchievement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7762BCC6913000515A0 /* ThousandKillsAchievement.swift */; }; BA82C7792BCC6943000515A0 /* CenturionAchievement.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7782BCC6943000515A0 /* CenturionAchievement.swift */; }; - BA82C77B2BCD05DC000515A0 /* MetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C77A2BCD05DC000515A0 /* MetadataManager.swift */; }; - BA82C77D2BCD07F4000515A0 /* RemoteMetadataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C77C2BCD07F4000515A0 /* RemoteMetadataManager.swift */; }; BA82C7802BCD284D000515A0 /* InferenceEngineFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C77F2BCD284D000515A0 /* InferenceEngineFactory.swift */; }; BA82C7832BCD2B30000515A0 /* Mission.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7822BCD2B30000515A0 /* Mission.swift */; }; BA82C7862BCD2B44000515A0 /* MissionTypeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7852BCD2B44000515A0 /* MissionTypeWrapper.swift */; }; @@ -247,7 +249,6 @@ BA82C79D2BCDD9ED000515A0 /* InferenceEngineTypeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C79C2BCDD9ED000515A0 /* InferenceEngineTypeWrapper.swift */; }; BA82C79F2BCE7FBA000515A0 /* AbstractGoal.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C79E2BCE7FBA000515A0 /* AbstractGoal.swift */; }; BA82C7A22BCE8138000515A0 /* AbstractGoalTypeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA82C7A12BCE8138000515A0 /* AbstractGoalTypeWrapper.swift */; }; - BAEC99FA2BD13F2600E0C437 /* AbstractTypeWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEC99F92BD13F2600E0C437 /* AbstractTypeWrapper.swift */; }; BAEC99FC2BD15AAB00E0C437 /* StorageDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEC99FB2BD15AAB00E0C437 /* StorageDatabase.swift */; }; BAEC99FE2BD15E0200E0C437 /* PlayerStatsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEC99FD2BD15E0200E0C437 /* PlayerStatsViewController.swift */; }; BAEC9A002BD1A4B700E0C437 /* CustomAchievementCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAEC99FF2BD1A4B700E0C437 /* CustomAchievementCell.swift */; }; @@ -476,6 +477,14 @@ BA436ADE2BD3CE9600BE3E4F /* CustomMissionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomMissionCell.swift; sourceTree = ""; }; BA436AE02BD3D66800BE3E4F /* MassKillMission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MassKillMission.swift; sourceTree = ""; }; BA436AE22BD3D6FF00BE3E4F /* MassDeathMission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MassDeathMission.swift; sourceTree = ""; }; + BA436AE62BD4180400BE3E4F /* MassEffectMission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MassEffectMission.swift; sourceTree = ""; }; + BA436AE92BD42F5400BE3E4F /* StorageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageHandler.swift; sourceTree = ""; }; + BA436AEB2BD42F7800BE3E4F /* LocalStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStorage.swift; sourceTree = ""; }; + BA436AED2BD42F8100BE3E4F /* RemoteStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteStorage.swift; sourceTree = ""; }; + BA436AEF2BD437D900BE3E4F /* LocalStorage+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LocalStorage+Metadata.swift"; sourceTree = ""; }; + BA436AF12BD443A500BE3E4F /* RemoteStorage+Access.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RemoteStorage+Access.swift"; sourceTree = ""; }; + BA436AF32BD4AB8400BE3E4F /* StorageHandler+Auth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageHandler+Auth.swift"; sourceTree = ""; }; + BA436AF52BD4AC2200BE3E4F /* StorageHandler+Conflict.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageHandler+Conflict.swift"; sourceTree = ""; }; BA443D3C2BAD9557009F0FFB /* RemoveSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveSystem.swift; sourceTree = ""; }; BA443D3E2BAD9774009F0FFB /* RemoveEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveEvent.swift; sourceTree = ""; }; BA443D412BAD9885009F0FFB /* DamageEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamageEventTests.swift; sourceTree = ""; }; @@ -496,18 +505,12 @@ BA82C75E2BCB1528000515A0 /* AchievementsDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementsDatabase.swift; sourceTree = ""; }; BA82C7602BCBBA8A000515A0 /* FiftyKillsAchievement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiftyKillsAchievement.swift; sourceTree = ""; }; BA82C7622BCBBB2A000515A0 /* Double+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Double+Extensions.swift"; path = "TowerForge/Commons/Extensions/Double+Extensions.swift"; sourceTree = SOURCE_ROOT; }; - BA82C7642BCBC868000515A0 /* LocalStorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStorageManager.swift; sourceTree = ""; }; BA82C7662BCBCB00000515A0 /* StatisticsDatabase+Codable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatisticsDatabase+Codable.swift"; sourceTree = ""; }; - BA82C7682BCBD21F000515A0 /* RemoteStorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteStorageManager.swift; sourceTree = ""; }; - BA82C76A2BCBD682000515A0 /* StorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageManager.swift; sourceTree = ""; }; BA82C76C2BCBD8F7000515A0 /* StatisticsDatabase+Merge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatisticsDatabase+Merge.swift"; sourceTree = ""; }; BA82C76E2BCBDE91000515A0 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; - BA82C7722BCBF657000515A0 /* LocalMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMetadataManager.swift; sourceTree = ""; }; BA82C7742BCC689F000515A0 /* AchievementsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementsFactory.swift; sourceTree = ""; }; - BA82C7762BCC6913000515A0 /* HundredKillsAchievement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HundredKillsAchievement.swift; sourceTree = ""; }; + BA82C7762BCC6913000515A0 /* ThousandKillsAchievement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThousandKillsAchievement.swift; sourceTree = ""; }; BA82C7782BCC6943000515A0 /* CenturionAchievement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenturionAchievement.swift; sourceTree = ""; }; - BA82C77A2BCD05DC000515A0 /* MetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataManager.swift; sourceTree = ""; }; - BA82C77C2BCD07F4000515A0 /* RemoteMetadataManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteMetadataManager.swift; sourceTree = ""; }; BA82C77F2BCD284D000515A0 /* InferenceEngineFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InferenceEngineFactory.swift; sourceTree = ""; }; BA82C7822BCD2B30000515A0 /* Mission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mission.swift; sourceTree = ""; }; BA82C7852BCD2B44000515A0 /* MissionTypeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MissionTypeWrapper.swift; sourceTree = ""; }; @@ -524,7 +527,6 @@ BA82C79E2BCE7FBA000515A0 /* AbstractGoal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractGoal.swift; sourceTree = ""; }; BA82C7A12BCE8138000515A0 /* AbstractGoalTypeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractGoalTypeWrapper.swift; sourceTree = ""; }; BABB7C052BA9A41000D54DAE /* TowerForceTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TowerForceTestPlan.xctestplan; sourceTree = ""; }; - BAEC99F92BD13F2600E0C437 /* AbstractTypeWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbstractTypeWrapper.swift; sourceTree = ""; }; BAEC99FB2BD15AAB00E0C437 /* StorageDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageDatabase.swift; sourceTree = ""; }; BAEC99FD2BD15E0200E0C437 /* PlayerStatsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStatsViewController.swift; sourceTree = ""; }; BAEC99FF2BD1A4B700E0C437 /* CustomAchievementCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomAchievementCell.swift; sourceTree = ""; }; @@ -913,7 +915,7 @@ 5295A2082BAAE14B005018A8 /* ViewControllers */, 52DF5FDB2BA32CEF00135367 /* LevelModule */, BAFFB9332BB0A24400D8301F /* GameModule */, - BAFFB9512BB342E200D8301F /* Storage */, + BA436AE82BD42F1100BE3E4F /* StorageAPI */, BA2F5ABF2BC80BD200CBD8E9 /* Metrics */, ); path = TowerForge; @@ -1038,6 +1040,22 @@ path = Views; sourceTree = ""; }; + BA436AE82BD42F1100BE3E4F /* StorageAPI */ = { + isa = PBXGroup; + children = ( + BA82C76E2BCBDE91000515A0 /* Metadata.swift */, + BA436AE92BD42F5400BE3E4F /* StorageHandler.swift */, + BA436AF32BD4AB8400BE3E4F /* StorageHandler+Auth.swift */, + BA436AF52BD4AC2200BE3E4F /* StorageHandler+Conflict.swift */, + BAEC99FB2BD15AAB00E0C437 /* StorageDatabase.swift */, + BA436AEB2BD42F7800BE3E4F /* LocalStorage.swift */, + BA436AEF2BD437D900BE3E4F /* LocalStorage+Metadata.swift */, + BA436AED2BD42F8100BE3E4F /* RemoteStorage.swift */, + BA436AF12BD443A500BE3E4F /* RemoteStorage+Access.swift */, + ); + path = StorageAPI; + sourceTree = ""; + }; BA443D402BAD9872009F0FFB /* EventTests */ = { isa = PBXGroup; children = ( @@ -1084,7 +1102,7 @@ isa = PBXGroup; children = ( BA82C7602BCBBA8A000515A0 /* FiftyKillsAchievement.swift */, - BA82C7762BCC6913000515A0 /* HundredKillsAchievement.swift */, + BA82C7762BCC6913000515A0 /* ThousandKillsAchievement.swift */, BA82C7782BCC6943000515A0 /* CenturionAchievement.swift */, ); path = Implemented; @@ -1138,6 +1156,7 @@ BA82C78D2BCD2D2B000515A0 /* MassDamageMission.swift */, BA436AE02BD3D66800BE3E4F /* MassKillMission.swift */, BA436AE22BD3D6FF00BE3E4F /* MassDeathMission.swift */, + BA436AE62BD4180400BE3E4F /* MassEffectMission.swift */, ); path = Implemented; sourceTree = ""; @@ -1383,7 +1402,6 @@ children = ( BAFFB9482BB0ABC400D8301F /* Logger.swift */, BAFFB9692BB9A64000D8301F /* ObjectSet.swift */, - BAEC99F92BD13F2600E0C437 /* AbstractTypeWrapper.swift */, ); path = Utilities; sourceTree = ""; @@ -1401,21 +1419,6 @@ path = GameModuleTests; sourceTree = ""; }; - BAFFB9512BB342E200D8301F /* Storage */ = { - isa = PBXGroup; - children = ( - BAEC99FB2BD15AAB00E0C437 /* StorageDatabase.swift */, - BA82C76E2BCBDE91000515A0 /* Metadata.swift */, - BA82C77A2BCD05DC000515A0 /* MetadataManager.swift */, - BA82C76A2BCBD682000515A0 /* StorageManager.swift */, - BA82C7642BCBC868000515A0 /* LocalStorageManager.swift */, - BA82C7722BCBF657000515A0 /* LocalMetadataManager.swift */, - BA82C7682BCBD21F000515A0 /* RemoteStorageManager.swift */, - BA82C77C2BCD07F4000515A0 /* RemoteMetadataManager.swift */, - ); - path = Storage; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -1598,12 +1601,13 @@ 9B0406122BB889940026E903 /* PowerUpNode.swift in Sources */, 3CCF9CAF2BAB1A96004D170E /* SceneUpdateDelegate.swift in Sources */, BA82C7402BC8674A000515A0 /* StatisticsFactory.swift in Sources */, + BA436AF42BD4AB8400BE3E4F /* StorageHandler+Auth.swift in Sources */, 3CAC4A692BB697A400A5D22E /* SpriteRenderStage.swift in Sources */, 523C29302BBD0916004C6EAC /* GameWaitingRoomViewController.swift in Sources */, BA2F5AC12BC80BE500CBD8E9 /* Statistic.swift in Sources */, 3C9955A12BA47DA500D33FA5 /* BaseTower.swift in Sources */, - BAEC99FA2BD13F2600E0C437 /* AbstractTypeWrapper.swift in Sources */, 5299D1432BC3AB38003EF746 /* GameRankProvider.swift in Sources */, + BA436AE72BD4180400BE3E4F /* MassEffectMission.swift in Sources */, 5250B42F2BAE0DB000F16CF6 /* LabelComponent.swift in Sources */, 3CCF9CB32BAB1F42004D170E /* SystemManager.swift in Sources */, 5295A20F2BAAE7CF005018A8 /* TeamController.swift in Sources */, @@ -1630,7 +1634,6 @@ BA82C75A2BCB0DFD000515A0 /* AchievementTypeWrapper.swift in Sources */, 3C9955C22BA5838900D33FA5 /* EventOutput.swift in Sources */, 523E5C4C2BC53F70007444DA /* WaveSpawnEvent.swift in Sources */, - BA82C77D2BCD07F4000515A0 /* RemoteMetadataManager.swift in Sources */, 5240D0912BAF3453004F1486 /* Life.swift in Sources */, 52A794192BBC630F0083C976 /* RoomData.swift in Sources */, BA82C7632BCBBB2A000515A0 /* Double+Extensions.swift in Sources */, @@ -1669,7 +1672,6 @@ BA436ADF2BD3CE9600BE3E4F /* CustomMissionCell.swift in Sources */, 3C3CBDF92BB821500001B8A9 /* TFScene.swift in Sources */, BA82C79F2BCE7FBA000515A0 /* AbstractGoal.swift in Sources */, - BA82C7652BCBC868000515A0 /* LocalStorageManager.swift in Sources */, 9B8696552BAD759F0002377C /* Grid.swift in Sources */, 527A077E2BB3F75700CD9D08 /* Timer.swift in Sources */, 52578B872BA6209700B4D76C /* DamageComponent.swift in Sources */, @@ -1692,7 +1694,9 @@ 9B0406162BB89E140026E903 /* InvulnerabilityPowerUpDelegate.swift in Sources */, 5295A2152BAAF335005018A8 /* UnitSelectionNode.swift in Sources */, 52DF5FF92BA35D2B00135367 /* MovableComponent.swift in Sources */, + BA436AEA2BD42F5400BE3E4F /* StorageHandler.swift in Sources */, 5299D1342BC31067003EF746 /* AuthenticationProtocol.swift in Sources */, + BA436AF22BD443A500BE3E4F /* RemoteStorage+Access.swift in Sources */, 527A07822BB3F8D300CD9D08 /* TimerProp.swift in Sources */, 52DF5FDE2BA32D7E00135367 /* EntityManager.swift in Sources */, 3CBE72FB2BC8D63E00CC446A /* RemoteDamageEvent.swift in Sources */, @@ -1707,24 +1711,26 @@ 52A794092BBC35F20083C976 /* Constant.swift in Sources */, 3C9955C82BA5865C00D33FA5 /* ConcurrentEvent.swift in Sources */, 52F268702BB4B319009599AD /* GameModeViewController.swift in Sources */, + BA436AEC2BD42F7800BE3E4F /* LocalStorage.swift in Sources */, BA82C7862BCD2B44000515A0 /* MissionTypeWrapper.swift in Sources */, BA82C7502BC8A20A000515A0 /* TotalDeathsStatistic.swift in Sources */, 527A07762BB3E4CF00CD9D08 /* GameState.swift in Sources */, 3C9955AD2BA483B100D33FA5 /* TFSystem.swift in Sources */, BA82C79D2BCDD9ED000515A0 /* InferenceEngineTypeWrapper.swift in Sources */, - BA82C77B2BCD05DC000515A0 /* MetadataManager.swift in Sources */, 3CAC4A672BB6975200A5D22E /* RenderStage.swift in Sources */, BA82C75D2BCB1451000515A0 /* InferenceEngine.swift in Sources */, 3C9955BE2BA57E4B00D33FA5 /* EventManager.swift in Sources */, + BA436AF02BD437D900BE3E4F /* LocalStorage+Metadata.swift in Sources */, BAFFB9852BBDBA7D00D8301F /* MediaEnums.swift in Sources */, 527A07842BB3FD9A00CD9D08 /* TimerSystem.swift in Sources */, BA82C7462BC8797F000515A0 /* StatisticsDatabase.swift in Sources */, + BA436AEE2BD42F8100BE3E4F /* RemoteStorage.swift in Sources */, 3C3CBDF72BB81D970001B8A9 /* TFCameraNode.swift in Sources */, 9B274DCA2BD252330062715C /* NoCostPowerUpDelegate.swift in Sources */, BA82C76D2BCBD8F7000515A0 /* StatisticsDatabase+Merge.swift in Sources */, 3CE951562BACA0CF008B2785 /* Collidable.swift in Sources */, BA82C7422BC86FE1000515A0 /* TotalGamesStatistic.swift in Sources */, - BA82C7772BCC6913000515A0 /* HundredKillsAchievement.swift in Sources */, + BA82C7772BCC6913000515A0 /* ThousandKillsAchievement.swift in Sources */, BA2F5ABE2BC80A8B00CBD8E9 /* Achievement.swift in Sources */, 3CBECF8C2BBE9A41005EF39B /* TFRemoteEvent.swift in Sources */, 5240D0A92BB333B5004F1486 /* PointProp.swift in Sources */, @@ -1738,6 +1744,7 @@ 3C9955AF2BA48FD200D33FA5 /* MeleeUnit.swift in Sources */, 5240D0AD2BB33D4C004F1486 /* PositionSystem.swift in Sources */, 5295A2022BA9FBD9005018A8 /* SceneManagerDelegate.swift in Sources */, + BA436AF62BD4AC2200BE3E4F /* StorageHandler+Conflict.swift in Sources */, 9B274DC42BD24B210062715C /* DamagePowerUp.swift in Sources */, 52DF5FE12BA3349600135367 /* TFTextures.swift in Sources */, 520062582BA8ED73000DBA30 /* HomeComponent.swift in Sources */, @@ -1758,14 +1765,12 @@ 52A794062BBC32A10083C976 /* FirebaseRepositoryProtocol.swift in Sources */, 3CD7DE382BCEB6D200CB21F0 /* RemoteConcedeEvent.swift in Sources */, BA82C7442BC86FFE000515A0 /* GameStartEvent.swift in Sources */, - BA82C7692BCBD21F000515A0 /* RemoteStorageManager.swift in Sources */, 9B0406102BB879990026E903 /* InvulnerabilityPowerUp.swift in Sources */, BA436AE12BD3D66800BE3E4F /* MassKillMission.swift in Sources */, 5299D1412BC3AA3A003EF746 /* GameRankData.swift in Sources */, 52F930E72BC63F7F003D11B5 /* LeaderboardSelectionViewController.swift in Sources */, BA82C76F2BCBDE91000515A0 /* Metadata.swift in Sources */, BA82C7752BCC689F000515A0 /* AchievementsFactory.swift in Sources */, - BA82C76B2BCBD682000515A0 /* StorageManager.swift in Sources */, 9BD669682BAFDE5E00DC8C4C /* GridDelegate.swift in Sources */, 52DF5FEB2BA3400C00135367 /* TFAnimatableNode.swift in Sources */, 3C3CBDFF2BB8708A0001B8A9 /* CGPoint+Extensions.swift in Sources */, @@ -1803,7 +1808,6 @@ BA82C7902BCD2FAF000515A0 /* TotalDamageDealtStatistic.swift in Sources */, 3CD37AA32BBEC0F900222D8A /* FirebaseRemoteEventPublisher.swift in Sources */, 3CAC4A6B2BB6992F00A5D22E /* TFNode.swift in Sources */, - BA82C7732BCBF657000515A0 /* LocalMetadataManager.swift in Sources */, 3CBE73012BC8D69A00CC446A /* RemoteLifeEvent.swift in Sources */, 3CAC4A6D2BB6A13B00A5D22E /* PositionRenderStage.swift in Sources */, BA82C7942BCDAA83000515A0 /* InferenceDataDelegate.swift in Sources */, diff --git a/TowerForge/TowerForge/AppMain/Application/AppDelegate.swift b/TowerForge/TowerForge/AppMain/Application/AppDelegate.swift index 0a68e094..8c99fa3b 100644 --- a/TowerForge/TowerForge/AppMain/Application/AppDelegate.swift +++ b/TowerForge/TowerForge/AppMain/Application/AppDelegate.swift @@ -23,7 +23,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { FirebaseApp.configure() /// Initialize all local storage - StorageManager.initializeAllStorage() + StorageHandler.initializeLocalStorageIfNotPresent() /// Prepare audio player to begin playing music AudioManager.shared.setupAllAudioPlayers() diff --git a/TowerForge/TowerForge/AppMain/Storyboards/Base.lproj/Main.storyboard b/TowerForge/TowerForge/AppMain/Storyboards/Base.lproj/Main.storyboard index b10cb969..a218a533 100644 --- a/TowerForge/TowerForge/AppMain/Storyboards/Base.lproj/Main.storyboard +++ b/TowerForge/TowerForge/AppMain/Storyboards/Base.lproj/Main.storyboard @@ -92,6 +92,7 @@ + @@ -122,7 +123,6 @@ - @@ -337,6 +337,60 @@ + + + + + + + + + + + @@ -344,50 +398,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TowerForge/TowerForge/Commons/Enums/StorageEnums.swift b/TowerForge/TowerForge/Commons/Enums/StorageEnums.swift index bf169c07..a11dccd0 100644 --- a/TowerForge/TowerForge/Commons/Enums/StorageEnums.swift +++ b/TowerForge/TowerForge/Commons/Enums/StorageEnums.swift @@ -34,6 +34,7 @@ class StorageEnums { enum StorageConflictResolution: String { case MERGE case KEEP_LATEST_ONLY + case PRESERVE_LOCAL } struct DynamicCodingKeys: CodingKey { diff --git a/TowerForge/TowerForge/Commons/Extensions/Double+Extensions.swift b/TowerForge/TowerForge/Commons/Extensions/Double+Extensions.swift index 2e4ff8b2..50f41344 100644 --- a/TowerForge/TowerForge/Commons/Extensions/Double+Extensions.swift +++ b/TowerForge/TowerForge/Commons/Extensions/Double+Extensions.swift @@ -8,7 +8,7 @@ import Foundation extension Double { - static var unit: Double { 1.0 } + static let unit: Double = 1.0 var half: Double { self * 0.5 } var twice: Double { self * 2.0 } var oneHalf: Double { self * 1.5 } @@ -18,13 +18,13 @@ extension Double { } extension Int { - static var unit: Int { 1 } - static var zero: Int { 0 } - static var negativeUnit: Int { -1 } + static let unit: Int = 1 + static let zero: Int = 0 + static let negativeUnit: Int = -1 } extension CGFloat { - static var unit: Double { Double.unit } + static let unit = CGFloat(Double.unit) var half: Double { Double(self).half } var twice: Double { Double(self).twice } var square: Double { Double(self).square } diff --git a/TowerForge/TowerForge/Commons/Utilities/AbstractTypeWrapper.swift b/TowerForge/TowerForge/Commons/Utilities/AbstractTypeWrapper.swift deleted file mode 100644 index 4aaa008a..00000000 --- a/TowerForge/TowerForge/Commons/Utilities/AbstractTypeWrapper.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// AbstractTypeWrapper.swift -// TowerForge -// -// Created by Rubesh on 18/4/24. -// - -import Foundation - -/// TODO: Replace type wrappers with this. -protocol AbstractTypeWrapper: Equatable, Hashable { - associatedtype T: Any - var type: T.Type { get } - - var asString: String { get } -} - -struct GenericTypeWrapper: AbstractTypeWrapper { - let type: T.Type - - var asString: String { - String(describing: type) - } - - static func == (lhs: GenericTypeWrapper, rhs: GenericTypeWrapper) -> Bool { - lhs.type == rhs.type - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(type)) - } -} - -extension GenericTypeWrapper: Codable { - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - let typeName = self.asString - try container.encode(typeName) - } - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - let typeName = try container.decode(String.self) - - guard let statType = typeName.asTFClassFromString as? T.Type else { - Logger.log("Error at decoding StatisticType", Self.self) - - let context = DecodingError.Context(codingPath: container.codingPath, - debugDescription: "Cannot decode \(typeName) as Statistic.Type") - - throw DecodingError.typeMismatch(T.Type.self, context) - } - - self.type = statType - } -} - -protocol TypeRepresentable: AnyObject { - static var asType: GenericTypeWrapper { get } -} - -extension TypeRepresentable { - static var asType: GenericTypeWrapper { - GenericTypeWrapper(type: Self.self) - } -} diff --git a/TowerForge/TowerForge/Firebase/Authentication/AuthenticationManager.swift b/TowerForge/TowerForge/Firebase/Authentication/AuthenticationManager.swift index 61877aed..2dde2f2d 100644 --- a/TowerForge/TowerForge/Firebase/Authentication/AuthenticationManager.swift +++ b/TowerForge/TowerForge/Firebase/Authentication/AuthenticationManager.swift @@ -86,7 +86,9 @@ class AuthenticationManager: AuthenticationProtocol { let userData = AuthenticationData(userId: user.uid, email: email, username: user.displayName) - StorageManager.onLogin(with: userData.userId) // TODO: Consider if there might be a better way to do this + // StorageManager.onLogin(with: userData.userId) // TODO: Consider if there might be a better way to do this + // Constants.CURRENT_PLAYER_ID = userData.userId + Logger.log("LOGIN: userId is \(userData.userId), email is \(userData.email)", self) self.delegate?.onLogin() completion(userData, nil) } @@ -96,7 +98,6 @@ class AuthenticationManager: AuthenticationProtocol { do { try Auth.auth().signOut() self.delegate?.onLogout() - StorageManager.onLogout() completion(nil) } catch let error as NSError { completion(error) @@ -106,6 +107,7 @@ class AuthenticationManager: AuthenticationProtocol { if let currentUser = Auth.auth().currentUser { // User is currently logged in, fetch user data Logger.log(String(describing: currentUser), self) + let userData = AuthenticationData(userId: currentUser.uid, email: currentUser.email ?? "", username: currentUser.displayName) diff --git a/TowerForge/TowerForge/Firebase/Authentication/AuthenticationProvider.swift b/TowerForge/TowerForge/Firebase/Authentication/AuthenticationProvider.swift index 1c99efa5..be74952c 100644 --- a/TowerForge/TowerForge/Firebase/Authentication/AuthenticationProvider.swift +++ b/TowerForge/TowerForge/Firebase/Authentication/AuthenticationProvider.swift @@ -55,12 +55,16 @@ class AuthenticationProvider { observers.forEach { $0.onLogout() } } - func getCurrentUserId() -> String? { - var currentUserId: String? - self.authenticationManager.getUserData { authData, _ in - currentUserId = authData?.userId + func getCurrentUserId(completion: @escaping (String?, Error?) -> Void) { + self.authenticationManager.getUserData { authData, error in + if let error = error { + Logger.log("Error retrieving CurrentUserId \(error)", self) + completion(nil, error) + } else if let playerId = authData?.userId { + Logger.log("Successfully retrieved currentUserId", self) + completion(playerId, nil) + } } - return currentUserId } } diff --git a/TowerForge/TowerForge/GameModule/GameWorld.swift b/TowerForge/TowerForge/GameModule/GameWorld.swift index a9a29b5a..4978c2de 100644 --- a/TowerForge/TowerForge/GameModule/GameWorld.swift +++ b/TowerForge/TowerForge/GameModule/GameWorld.swift @@ -19,7 +19,9 @@ class GameWorld { private var renderer: Renderer? private let worldBounds: CGRect private var popup: StatePopupNode - private var statisticsEngine = StatisticsEngine() + + private var storageHandler = StorageHandler() + private var statisticsEngine: StatisticsEngine unowned var scene: GameScene? { didSet { setUpScene() } } unowned var delegate: SceneManagerDelegate? @@ -33,6 +35,8 @@ class GameWorld { powerUpSelectionNode = PowerUpSelectionNode(eventManager: gameEngine.eventManager) grid = Grid(screenSize: worldBounds) popup = StatePopupNode() + storageHandler = StorageHandler() + statisticsEngine = StatisticsEngine(with: storageHandler) setUp() } @@ -126,6 +130,6 @@ extension GameWorld: GameEngineDelegate { func onGameCompleted(gameState: GameState, gameResults: [GameResult]) { Logger.log("\(gameState)", self) delegate?.showGameOverScene(isWin: gameState == .WIN, results: gameResults) - statisticsEngine.finalize() + statisticsEngine.finalizeAndSave() } } diff --git a/TowerForge/TowerForge/Metrics/Achievements/AchievementsEngine.swift b/TowerForge/TowerForge/Metrics/Achievements/AchievementsEngine.swift index 1cd2d421..83d609ad 100644 --- a/TowerForge/TowerForge/Metrics/Achievements/AchievementsEngine.swift +++ b/TowerForge/TowerForge/Metrics/Achievements/AchievementsEngine.swift @@ -17,7 +17,7 @@ class AchievementsEngine: InferenceEngine, InferenceDataDelegate { var achievementsDatabase: AchievementsDatabase var statisticsDatabase: StatisticsDatabase { - statisticsEngine.statistics + statisticsEngine.statisticsDatabase } init(_ statisticsEngine: StatisticsEngine) { diff --git a/TowerForge/TowerForge/Metrics/Achievements/AchievementsFactory.swift b/TowerForge/TowerForge/Metrics/Achievements/AchievementsFactory.swift index 1ce2b3cb..b3fe2488 100644 --- a/TowerForge/TowerForge/Metrics/Achievements/AchievementsFactory.swift +++ b/TowerForge/TowerForge/Metrics/Achievements/AchievementsFactory.swift @@ -12,7 +12,7 @@ class AchievementsFactory { static var availableAchievementTypes: [String: Achievement.Type] = [ String(describing: FiftyKillsAchievement.self): FiftyKillsAchievement.self, - String(describing: HundredKillsAchievement.self): HundredKillsAchievement.self, + String(describing: ThousandKillsAchievement.self): ThousandKillsAchievement.self, String(describing: CenturionAchievement.self): CenturionAchievement.self ] diff --git a/TowerForge/TowerForge/Metrics/Achievements/Implemented/HundredKillsAchievement.swift b/TowerForge/TowerForge/Metrics/Achievements/Implemented/ThousandKillsAchievement.swift similarity index 70% rename from TowerForge/TowerForge/Metrics/Achievements/Implemented/HundredKillsAchievement.swift rename to TowerForge/TowerForge/Metrics/Achievements/Implemented/ThousandKillsAchievement.swift index 58e70b32..24288616 100644 --- a/TowerForge/TowerForge/Metrics/Achievements/Implemented/HundredKillsAchievement.swift +++ b/TowerForge/TowerForge/Metrics/Achievements/Implemented/ThousandKillsAchievement.swift @@ -7,14 +7,14 @@ import Foundation -final class HundredKillsAchievement: Achievement { - var name: String = "100 Kills" - var description: String = "Attain 100 total kills in TowerForge" +final class ThousandKillsAchievement: Achievement { + var name: String = "1000 Kills" + var description: String = "Attain 1000 total kills in TowerForge" var currentParameters: [StatisticTypeWrapper: any Statistic] = [:] static var definedParameters: [StatisticTypeWrapper: Double] = [ - TotalKillsStatistic.asType: 200.0 + TotalKillsStatistic.asType: 1_000.0 ] init(dependentStatistics: [Statistic]) { diff --git a/TowerForge/TowerForge/Metrics/Missions/Implemented/MassEffectMission.swift b/TowerForge/TowerForge/Metrics/Missions/Implemented/MassEffectMission.swift new file mode 100644 index 00000000..301c07cb --- /dev/null +++ b/TowerForge/TowerForge/Metrics/Missions/Implemented/MassEffectMission.swift @@ -0,0 +1,27 @@ +// +// MassEffectMission.swift +// TowerForge +// +// Created by Rubesh on 20/4/24. +// + +import Foundation + +final class MassEffectMission: Mission { + var name: String = "Mass Effect" + var description: String = "Attain 100 Kills & Deaths in 1 game" + var currentParameters: [StatisticTypeWrapper: any Statistic] + + static var definedParameters: [StatisticTypeWrapper: Double] { + [ + TotalKillsStatistic.asType: 100.0, + TotalDeathsStatistic.asType: 100.0 + ] + } + + init(dependentStatistics: [Statistic]) { + var stats: [StatisticTypeWrapper: any Statistic] = [:] + dependentStatistics.forEach { stats[$0.statisticName] = $0 } + self.currentParameters = stats + } +} diff --git a/TowerForge/TowerForge/Metrics/Missions/MissionsEngine.swift b/TowerForge/TowerForge/Metrics/Missions/MissionsEngine.swift index 017eb69c..2955dcdc 100644 --- a/TowerForge/TowerForge/Metrics/Missions/MissionsEngine.swift +++ b/TowerForge/TowerForge/Metrics/Missions/MissionsEngine.swift @@ -11,7 +11,7 @@ class MissionsEngine: InferenceEngine, InferenceDataDelegate { unowned var statisticsEngine: StatisticsEngine var missionsDatabase: MissionsDatabase var statisticsDatabase: StatisticsDatabase { - statisticsEngine.statistics + statisticsEngine.statisticsDatabase } init(_ statisticsEngine: StatisticsEngine) { diff --git a/TowerForge/TowerForge/Metrics/Missions/MissionsFactory.swift b/TowerForge/TowerForge/Metrics/Missions/MissionsFactory.swift index 6c9eeef5..65a87a25 100644 --- a/TowerForge/TowerForge/Metrics/Missions/MissionsFactory.swift +++ b/TowerForge/TowerForge/Metrics/Missions/MissionsFactory.swift @@ -13,7 +13,8 @@ class MissionsFactory { [ String(describing: MassDamageMission.self): MassDamageMission.self, String(describing: MassKillMission.self): MassKillMission.self, - String(describing: MassDeathMission.self): MassDeathMission.self + String(describing: MassDeathMission.self): MassDeathMission.self, + String(describing: MassEffectMission.self): MassEffectMission.self ] static func registerMissionType(_ stat: T) { diff --git a/TowerForge/TowerForge/Metrics/Ranking/RankingEngine.swift b/TowerForge/TowerForge/Metrics/Ranking/RankingEngine.swift index f24c8d2c..524069ee 100644 --- a/TowerForge/TowerForge/Metrics/Ranking/RankingEngine.swift +++ b/TowerForge/TowerForge/Metrics/Ranking/RankingEngine.swift @@ -9,16 +9,22 @@ import Foundation /// The RankingEngine is responsible for generating rank and exp information. class RankingEngine: InferenceEngine, InferenceDataDelegate { + unowned var statisticsEngine: StatisticsEngine - // TODO: Consider expanding to more formula for .e.g double exp. static var defaultExpFormula: ((StatisticsDatabase) -> Double) = { $0.statistics.values.map { $0.rankValue }.reduce(into: .zero) { $0 += $1 } } - unowned var statisticsEngine: StatisticsEngine + init(_ statisticsEngine: StatisticsEngine) { + self.statisticsEngine = statisticsEngine + } + + deinit { + Logger.log("DEINIT: RankingEngine is deinitialized", self) + } var statisticsDatabase: StatisticsDatabase { - statisticsEngine.statistics + statisticsEngine.statisticsDatabase } var currentExp: Double { @@ -29,8 +35,12 @@ class RankingEngine: InferenceEngine, InferenceDataDelegate { Rank.allCases.first { $0.valueRange.contains(Int(self.currentExp)) } ?? .PRIVATE } - var currentKd: Double { - getPermanentValueFor(TotalKillsStatistic.self) / getPermanentValueFor(TotalDeathsStatistic.self) + /// Returns the current kill/death ratio as a double between 0 and 1. + var currentKdRatio: Double { + let kills = getPermanentValueFor(TotalKillsStatistic.self) + let deaths = getPermanentValueFor(TotalDeathsStatistic.self) + + return kills > 0 && deaths > 0 ? kills / deaths : .zero } var currentExpAsString: String { @@ -46,13 +56,21 @@ class RankingEngine: InferenceEngine, InferenceDataDelegate { currentRank.isOfficer() } - init(_ statisticsEngine: StatisticsEngine) { - self.statisticsEngine = statisticsEngine + func getPermanentValueFor(_ stat: T.Type) -> Double { + statisticsDatabase.getStatistic(for: stat.asType)?.permanentValue ?? .zero } func updateOnReceive() { } - func getPermanentValueFor(_ stat: T.Type) -> Double { - statisticsDatabase.getStatistic(for: stat.asType)?.permanentValue ?? .zero + func percentageToNextRank() -> Double { + let minScore = currentRank.valueRange.lowerBound + let currentScore = Int(currentExp) + let maxScore = currentRank.valueRange.upperBound + let range = Double(maxScore - minScore) + let adjustedScore = Double(currentScore - minScore) + let percentageToNextRank = adjustedScore / range + + return percentageToNextRank + } } diff --git a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDamageDealtStatistic.swift b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDamageDealtStatistic.swift index e1855c74..1355421e 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDamageDealtStatistic.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDamageDealtStatistic.swift @@ -54,4 +54,23 @@ final class TotalDamageDealtStatistic: Statistic { self.init(permanentValue: value, currentValue: current) } + + func merge(with that: T) -> T? { + guard let that = that as? Self else { + return nil + } + + let largerPermanent = Double.maximum(self.permanentValue, that.permanentValue) + let largerCurrent = Double.maximum(self.currentValue, that.currentValue) + let largerMaxCurrent = Double.maximum(self.maximumCurrentValue, that.maximumCurrentValue) + + guard let stat = Self(permanentValue: largerPermanent, + currentValue: largerCurrent, + maxCurrentValue: largerMaxCurrent) as? T else { + Logger.log("Statistic merging failed for \(self.toString())", self) + return nil + } + + return stat + } } diff --git a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDeathsStatistic.swift b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDeathsStatistic.swift index 54b2343d..17cdecce 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDeathsStatistic.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalDeathsStatistic.swift @@ -55,4 +55,23 @@ final class TotalDeathsStatistic: Statistic { self.init(permanentValue: value, currentValue: current) } + func merge(with that: T) -> T? { + guard let that = that as? Self else { + return nil + } + + let largerPermanent = Double.maximum(self.permanentValue, that.permanentValue) + let largerCurrent = Double.maximum(self.currentValue, that.currentValue) + let largerMaxCurrent = Double.maximum(self.maximumCurrentValue, that.maximumCurrentValue) + + guard let stat = Self(permanentValue: largerPermanent, + currentValue: largerCurrent, + maxCurrentValue: largerMaxCurrent) as? T else { + Logger.log("Statistic merging failed for \(self.toString())", self) + return nil + } + + return stat + } + } diff --git a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalGamesStatistic.swift b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalGamesStatistic.swift index fecb18b0..e4142471 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalGamesStatistic.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalGamesStatistic.swift @@ -29,7 +29,7 @@ final class TotalGamesStatistic: Statistic { func getStatisticUpdateLinks() -> StatisticUpdateLinkDatabase { let eventType = TFEventTypeWrapper(type: GameStartEvent.self) let eventUpdateClosure: (Statistic, GameStartEvent?) -> Void = { statistic, event in - statistic.updateCurrentValue(by: 1.0) + statistic.updatePermanentValue(by: 1.0) Logger.log("Updating statistic with event detail: \(String(describing: event))", self) } @@ -52,4 +52,23 @@ final class TotalGamesStatistic: Statistic { self.init(permanentValue: value, currentValue: current, maxCurrentValue: max) } + func merge(with that: T) -> T? { + guard let that = that as? Self else { + return nil + } + + let largerPermanent = Double.maximum(self.permanentValue, that.permanentValue) + let largerCurrent = Double.maximum(self.currentValue, that.currentValue) + let largerMaxCurrent = Double.maximum(self.maximumCurrentValue, that.maximumCurrentValue) + + guard let stat = Self(permanentValue: largerPermanent, + currentValue: largerCurrent, + maxCurrentValue: largerMaxCurrent) as? T else { + Logger.log("Statistic merging failed for \(self.toString())", self) + return nil + } + + return stat + } + } diff --git a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalKillsStatistic.swift b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalKillsStatistic.swift index f406d296..08bc30d9 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalKillsStatistic.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/Implemented/TotalKillsStatistic.swift @@ -53,4 +53,24 @@ final class TotalKillsStatistic: Statistic { self.init(permanentValue: value, currentValue: current, maxCurrentValue: max) } + + func merge(with that: T) -> T? { + guard let that = that as? Self else { + return nil + } + + let largerPermanent = Double.maximum(self.permanentValue, that.permanentValue) + let largerCurrent = Double.maximum(self.currentValue, that.currentValue) + let largerMaxCurrent = Double.maximum(self.maximumCurrentValue, that.maximumCurrentValue) + + guard let stat = Self(permanentValue: largerPermanent, + currentValue: largerCurrent, + maxCurrentValue: largerMaxCurrent) as? T else { + Logger.log("Statistic merging failed for \(self.toString())", self) + return nil + } + + return stat + } + } diff --git a/TowerForge/TowerForge/Metrics/Statistics/Statistic.swift b/TowerForge/TowerForge/Metrics/Statistics/Statistic.swift index 80dc1dd1..23092438 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/Statistic.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/Statistic.swift @@ -32,6 +32,9 @@ protocol Statistic: AnyObject, Codable { /// The significance value which this Statistic holds in generating XP static var expMultiplier: Double { get } + /// Function to specify merging of Statistics + func merge(with that: T) -> T? + /// Returns a StatisticUpdateLinkDatabase pertaining to this Statistic. /// Conforming Statistic types will have to implement their own links between event /// types and the action to take upon reception of that event's execution. @@ -48,6 +51,10 @@ extension Statistic { self.init(permanentValue: .zero, currentValue: .zero, maxCurrentValue: .zero) } + func toString() -> String { + "[\(prettyName): \(permanentValue)]" + } + static func equals(lhs: Self, rhs: Self) -> Bool { (lhs.statisticName == rhs.statisticName) && (lhs.permanentValue == rhs.permanentValue) @@ -137,18 +144,6 @@ extension Statistic { self.getStatisticUpdateLinks().getAllEventTypes() } - /*func update(for event: T) { - let eventType = T.asType - guard let updateLink = self.getStatisticUpdateLinks().getStatisticUpdateActor(for: eventType) else { - return - } - - updateLink.updateStatistic(statistic: self, withEvent: T.self) - - updateLink?(self) - Logger.log("Value update for eventType \(eventType)", self) - }*/ - /// Updates the statistic according to an UpdateActor that is retrieved from the /// StatisticsUpdateLinkDatabase func update(for event: T) { diff --git a/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase+Merge.swift b/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase+Merge.swift index d3ad43f1..7a95be18 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase+Merge.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase+Merge.swift @@ -19,7 +19,7 @@ extension StatisticsDatabase: Equatable { return lhs.statistics.keys.allSatisfy { (lhs.statistics[$0]?.statisticName == rhs.statistics[$0]?.statisticName) && (lhs.statistics[$0]?.permanentValue == rhs.statistics[$0]?.permanentValue) && - (lhs.statistics[$0]?.currentValue == rhs.statistics[$0]?.currentValue) + (lhs.statistics[$0]?.maximumCurrentValue == rhs.statistics[$0]?.maximumCurrentValue) } } @@ -31,10 +31,15 @@ extension StatisticsDatabase: Equatable { /// - 3. There should not be any keys in the final database that are not within the first or second database. /// - 4. For duplicate keys that have the same value, either value can be retained in the final database. static func merge(this: StatisticsDatabase?, that: StatisticsDatabase?) -> StatisticsDatabase? { + Logger.log("SDB: Merge function entered.", self) guard this != nil || that != nil else { + Logger.log("SDB: Both nil, returning nil", self) return nil } + Logger.log("MERGE --- THIS DB is \(String(describing: this?.toString()))", self) + Logger.log("MERGE --- THAT DB is \(String(describing: that?.toString()))", self) + var lhs = StatisticsDatabase() var rhs = StatisticsDatabase() @@ -53,30 +58,25 @@ extension StatisticsDatabase: Equatable { mergedStats.statistics[key] = lhsStat } - // Merge rhs statistics and resolve conflicts for (key, rhsStat) in rhs.statistics { + Logger.log("MERGE-LOOP: Statistic RHS \(key) is " + + "\(String(describing: rhsStat.toString()))", self) if let lhsStat = mergedStats.statistics[key] { + Logger.log("MERGE-LOOP: Statistic LHS \(key) is " + + "\(String(describing: lhsStat.toString()))", self) - // If lhs has the key, compare and choose the one with the greater magnitude. - if lhsStat.permanentValue < rhsStat.permanentValue || lhsStat.currentValue < rhsStat.currentValue { - - mergedStats.statistics[key]?.permanentValue = Double.maximumMagnitude(lhsStat.permanentValue, - rhsStat.permanentValue) + mergedStats.statistics[key] = lhsStat.merge(with: rhsStat) - mergedStats.statistics[key]?.currentValue = Double.maximumMagnitude(lhsStat.currentValue, - rhsStat.currentValue) - - mergedStats.statistics[key]?.currentValue = Double.maximumMagnitude(lhsStat.maximumCurrentValue, - rhsStat.maximumCurrentValue) - } - // If they are equal, lhsStat is already set, so do nothing. + Logger.log("MERGE-LOOP: MERGED Statistic \(key) updated to " + + "\(String(describing: mergedStats.statistics[key]?.toString()))", self) } else { - - // If lhs does not have the key, simply add the rhs stat. mergedStats.statistics[key] = rhsStat + Logger.log("MERGE-LOOP: Statistic \(key) created with " + + "\(String(describing: mergedStats.statistics[key]?.toString()))", self) } } + Logger.log("SDB: Merged stats contain \(mergedStats.toString())", self) return mergedStats } } diff --git a/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase.swift b/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase.swift index 88c2f7f1..11011d4d 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/StatisticsDatabase.swift @@ -23,7 +23,17 @@ final class StatisticsDatabase: StorageDatabase { statistics[statName] } + func getPermanentValueFor(_ stat: T.Type) -> Double { + self.getStatistic(for: stat.asType)?.permanentValue ?? .zero + } + func setToDefault() { statistics = StatisticsFactory.getDefaultStatisticsDatabase().statistics } + + func toString() -> String { + var output = "" + statistics.values.forEach { output += ($0.toString() + "\n") } + return output + } } diff --git a/TowerForge/TowerForge/Metrics/Statistics/StatisticsEngine.swift b/TowerForge/TowerForge/Metrics/Statistics/StatisticsEngine.swift index 03836640..dec08504 100644 --- a/TowerForge/TowerForge/Metrics/Statistics/StatisticsEngine.swift +++ b/TowerForge/TowerForge/Metrics/Statistics/StatisticsEngine.swift @@ -7,32 +7,38 @@ import Foundation -class StatisticsEngine { +class StatisticsEngine: InferenceDataDelegate { /// Core storage of Statistics - var statistics = StatisticsDatabase() + weak var statsEngineDelegate: StatisticsEngineDelegate? + var statisticsDatabase: StatisticsDatabase var eventStatisticLinks = EventStatisticLinkDatabase() var inferenceEngines: [InferenceEngineTypeWrapper: InferenceEngine] = [:] - init() { + init(with storageHandler: StatisticsEngineDelegate) { + self.statsEngineDelegate = storageHandler + self.statisticsDatabase = storageHandler.statisticsDatabase self.initializeStatistics() self.setUpLinks() self.setUpInferenceEngines() } + deinit { + Logger.log("DEINIT: StatisticsEngine is deinitialized", self) + } + /// Add statistics links manually private func setUpLinks() { let links = StatisticsFactory.eventStatisticLinks for key in links.keys { links[key]?.forEach { eventStatisticLinks.addStatisticLink(for: key.type, - with: statistics.getStatistic(for: $0)) + with: statisticsDatabase.getStatistic(for: $0)) } } } private func initializeStatistics() { eventStatisticLinks = StatisticsFactory.getDefaultEventLinkDatabase() - loadStatistics() } private func setUpInferenceEngines() { @@ -50,8 +56,9 @@ class StatisticsEngine { } /// Transfers over all transient metrics within statistics to permanent value. - func finalize() { - statistics.statistics.values.forEach { $0.finalizeStatistic() } + func finalizeAndSave() { + Logger.log("------------------- STATISTICS ARE FINALIZED ----------------------- ", self) + statisticsDatabase.statistics.values.forEach { $0.finalizeStatistic() } saveStatistics() } @@ -62,40 +69,17 @@ class StatisticsEngine { return } - // stats.forEach { $0.update(for: eventType) } stats.forEach { $0.update(for: event) } saveStatistics() } - /// TODO: Consider if passing the stats database directly is better or - /// to follow delegate pattern and have unowned statsEngine/db variables inside - /// InferenceEngines func notifyInferenceEngines() { inferenceEngines.values.forEach { $0.updateOnReceive() } } - func getCurrentRank() -> Rank? { - if let rankEngine = inferenceEngines[RankingEngine.asType] as? RankingEngine { - return rankEngine.currentRank - } - return nil - } - - func getCurrentExp() -> Double? { - if let rankEngine = inferenceEngines[RankingEngine.asType] as? RankingEngine { - return rankEngine.currentExp - } - return nil - } - private func saveStatistics() { - _ = StorageManager.saveUniversally(statistics) - } - - private func loadStatistics() { - if let loadedStats = StorageManager.loadUniversally() { - statistics = loadedStats - } + Logger.log("STATISTICS_ENGINE SAVE: Statistics save triggered", self) + statsEngineDelegate?.save() } } diff --git a/TowerForge/TowerForge/Storage/LocalMetadataManager.swift b/TowerForge/TowerForge/Storage/LocalMetadataManager.swift deleted file mode 100644 index d5449fbd..00000000 --- a/TowerForge/TowerForge/Storage/LocalMetadataManager.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// LocalMetadataManager.swift -// TowerForge -// -// Created by Rubesh on 14/4/24. -// - -import Foundation - -/// This extension allows the LocalStorageManager to facilitate metadata storage -/// and loading functionality. -/// -/// A custom Metadata class is implemented to provide more nuanced control over -/// metadata storage as opposed to using FileAttributesKey, although the option -/// to retrieve iOS-defined metadata is still available via a custom method. -class LocalMetadataManager { - - static let folderName = Constants.LOCAL_STORAGE_CONTAINER_NAME - static let metadataFileName = Constants.METADATA_FILE_NAME - static let fileManager = FileManager.default - - static func initializeUserIdentifier() { - let metadata = LocalMetadataManager.checkAndCreateMetadata() - Constants.CURRENT_PLAYER_ID = metadata.uniqueIdentifier - Constants.CURRENT_DEVICE_ID = metadata.uniqueIdentifier - Logger.log("Current player set to \(Constants.CURRENT_PLAYER_ID)", self) - RemoteMetadataManager.initializeRemoteMetadata() - } - - static func checkAndCreateMetadata() -> Metadata { - if let existingMetadata = loadMetadataFromLocalStorage() { - Logger.log("Existing metadata loaded", self) - return existingMetadata - } else { - let newMetadata = Metadata() - Logger.log("New metadata being created", self) - saveMetadataToLocalStorage(newMetadata) - return newMetadata - } - } - - static func updateMetadataInLocalStorage() { - let metadata = loadMetadataFromLocalStorage() ?? Metadata() - metadata.lastUpdated = Date.now - saveMetadataToLocalStorage(metadata) - Logger.log("Metadata updated at: \(metadata.lastUpdated)", self) - // RemoteMetadataManager.updateMetadataInFirebase() - } - - static func saveMetadataToLocalStorage(_ metadata: Metadata) { - do { - let folderURL = try LocalStorageManager.createFolderIfNeeded(folderName: folderName) - let fileURL = folderURL.appendingPathComponent(metadataFileName) - let data = try JSONEncoder().encode(metadata) - try data.write(to: fileURL) - Logger.log("Metadata saved at: \(fileURL.path)", self) - } catch { - Logger.log("Failed to save metadata: \(error)", self) - } - } - - static func loadMetadataFromLocalStorage() -> Metadata? { - do { - let folderURL = try LocalStorageManager.createFolderIfNeeded(folderName: folderName) - let fileURL = folderURL.appendingPathComponent(metadataFileName) - let data = try Data(contentsOf: fileURL) - let metadata = try JSONDecoder().decode(Metadata.self, from: data) - return metadata - } catch { - Logger.log("Failed to load metadata: \(error)", self) - return nil - } - } - - static func deleteMetadataFromLocalStorage() { - do { - let folderURL = try LocalStorageManager.createFolderIfNeeded(folderName: folderName) - let fileURL = folderURL.appendingPathComponent(metadataFileName) - try fileManager.removeItem(at: fileURL) - Logger.log("Metadata successfully deleted.", self) - } catch { - Logger.log("Error deleting metadata: \(error)", self) - } - } -} diff --git a/TowerForge/TowerForge/Storage/Metadata.swift b/TowerForge/TowerForge/Storage/Metadata.swift deleted file mode 100644 index abeca6e7..00000000 --- a/TowerForge/TowerForge/Storage/Metadata.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// Metadata.swift -// TowerForge -// -// Created by Rubesh on 14/4/24. -// - -import Foundation - -/// The metadata class is used to encapsulate -/// -/// - Information about device and the current user for use with Remote Storage -/// - Meta-information about files stored locally, possibly for use with conflict resolution. -class Metadata: Codable, Comparable, Equatable { - let uniqueIdentifier: String - var lastUpdated: Date - - init(lastUpdated: Date, - uniqueIdentifier: String = Constants.CURRENT_PLAYER_ID) { - self.lastUpdated = lastUpdated - self.uniqueIdentifier = uniqueIdentifier - } - - required init() { - self.lastUpdated = Date() - self.uniqueIdentifier = UUID().uuidString - } - - static func == (lhs: Metadata, rhs: Metadata) -> Bool { - lhs.lastUpdated == rhs.lastUpdated && lhs.uniqueIdentifier == rhs.uniqueIdentifier - } - - static func < (lhs: Metadata, rhs: Metadata) -> Bool { - lhs.lastUpdated < rhs.lastUpdated - } - - static func > (lhs: Metadata, rhs: Metadata) -> Bool { - lhs.lastUpdated > rhs.lastUpdated - } - - static func latest(lhs: Metadata, rhs: Metadata) -> Metadata { - rhs.lastUpdated > lhs.lastUpdated ? rhs : lhs - } -} diff --git a/TowerForge/TowerForge/Storage/MetadataManager.swift b/TowerForge/TowerForge/Storage/MetadataManager.swift deleted file mode 100644 index 4537d774..00000000 --- a/TowerForge/TowerForge/Storage/MetadataManager.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// MetadataManager.swift -// TowerForge -// -// Created by Rubesh on 15/4/24. -// - -import Foundation - -class MetadataManager { - - /// Utility function that returns the StorageLocation indicator for the location that - /// contains the most recent data. - /// - /// This is to be used in circumstances where simple merging cannot work, like in the - /// case of Statistics database. Metadata's comformance to comparable allows for the - /// comparision metric to be determined. - /// - /// Representation invariant is that the Metadata being compared contain the same - /// uuid identifier. - static func getLocationWithLatestMetadata() -> StorageLocation? { - var remoteMetadataExists = false - var localMetadataExists = false - - var remoteMetadata: Metadata? - let localMetadata = LocalMetadataManager.loadMetadataFromLocalStorage() - - RemoteMetadataManager.remoteMetadataExistsForCurrentPlayer { remoteMetadataExists = $0 } - localMetadataExists = localMetadata != nil - - /* --- Accounting for edge cases --- */ - - // A nil value will automatically imply inferiority to any given value - guard remoteMetadataExists || localMetadataExists else { - return nil - } - - if remoteMetadataExists && !localMetadataExists { - return .Remote - } - - if !remoteMetadataExists && localMetadataExists { - return .Local - } - /* ------------------------------- */ - - // The check above will ensure that the metadata file exists remotely, thus, - // there is no need to account for the case where the metadata might be missing. - RemoteMetadataManager.loadMetadataFromFirebase { metadata, error in - if error != nil { - Logger.log("Error occured at retriving metadata") - return - } - - remoteMetadata = metadata - } - - guard let remoteMetadata = remoteMetadata, let localMetadata = localMetadata else { - return nil - } - - return remoteMetadata > localMetadata ? .Remote : .Local - } -} diff --git a/TowerForge/TowerForge/Storage/RemoteMetadataManager.swift b/TowerForge/TowerForge/Storage/RemoteMetadataManager.swift deleted file mode 100644 index 7f4dc835..00000000 --- a/TowerForge/TowerForge/Storage/RemoteMetadataManager.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// RemoteMetadataManager.swift -// TowerForge -// -// Created by Rubesh on 15/4/24. -// - -import Foundation -import FirebaseDatabaseInternal - -class RemoteMetadataManager { - static var currentPlayer: String = Constants.CURRENT_PLAYER_ID - static var currentDevice: String = Constants.CURRENT_DEVICE_ID - - /// Queries the firebase backend to determine if remote metadata exists for the current player - static func remoteMetadataExistsForCurrentPlayer(completion: @escaping (Bool) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Metadata) - - databaseReference.child(currentPlayer).getData(completion: { error, snapshot in - if let error = error { - Logger.log("Error checking data existence: \(error.localizedDescription)", self) - completion(false) // Assuming no data exists if an error occurs - return - } - - if snapshot?.exists() != nil && snapshot?.value != nil { - completion(true) - } else { - completion(false) - } - }) - } - - static func initializeRemoteMetadata() { - Self.loadMetadataFromFirebase { metadata, error in - if let error = error { - Logger.log("Error initializing metadata: \(error)", self) - return - } - - if let metadata = metadata { - Logger.log("Metadata database already initialized : \(metadata)", self) - return - } - - // No error but no metadata implies that metadata is empty, thus initialize new one - Logger.log("No error but empty metadata, new one will be created", self) - let remoteMetadata = Metadata(lastUpdated: Date.now, uniqueIdentifier: Constants.CURRENT_PLAYER_ID) - - Self.saveMetadataToFirebase(remoteMetadata) { error in - if let error = error { - Logger.log("Saving metadata to firebase error: \(error)", self) - } else { - Logger.log("Saving metadata to firebase success", self) - } - } - } - } - - static func updateMetadataInFirebase() { - var existingRemoteMetadata = Metadata(lastUpdated: Date.now, - uniqueIdentifier: Constants.CURRENT_PLAYER_ID) - - Self.loadMetadataFromFirebase { metadata, error in - if let error = error { - Logger.log("Error occured while loading metadata from firebase for update --- \(error)", self) - return - } - - if let metadata = metadata { - existingRemoteMetadata = metadata - } - } - - existingRemoteMetadata.lastUpdated = Date.now - Logger.log("Metadata updated at: \(String(describing: existingRemoteMetadata.lastUpdated))", self) - - Self.saveMetadataToFirebase(existingRemoteMetadata) { error in - if let error = error { - Logger.log("Error occured while saving metadata to firebase for update --- \(error)", self) - return - } - } - } - - static func loadMetadataFromFirebase(completion: @escaping (Metadata?, Error?) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Metadata) - - databaseReference.child(currentPlayer).getData(completion: { error, snapshot in - if let error = error { - Logger.log(error.localizedDescription, self) - completion(nil, error) - return - } - - guard let value = snapshot?.value as? [String: Any], - let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) else { - completion(nil, nil) - return - } - - do { - let decoder = JSONDecoder() - let metadata = try decoder.decode(Metadata.self, from: jsonData) - completion(metadata, nil) - } catch { - Logger.log("Error decoding Metadata from Firebase: \(error)", self) - completion(nil, error) - } - }) - } - - static func saveMetadataToFirebase(_ stats: Metadata, completion: @escaping (Error?) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Metadata) - - do { - let encoder = JSONEncoder() - let data = try encoder.encode(stats) - let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] - - databaseReference.child(currentPlayer).setValue(dictionary) { error, _ in - if let error = error { - Logger.log("Metadata could not be saved: \(error).", Metadata.self) - completion(error) - } else { - Logger.log("Metadata saved to Firebase successfully!", Metadata.self) - completion(nil) - } - } - } catch { - Logger.log("Error encoding StatisticsDatabase: \(error)", Metadata.self) - completion(error) - } - } - - static func deleteMetadataFromFirebase(completion: @escaping (Error?) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Metadata) - - // Remove the data at the specific currentPlayer node - databaseReference.child(currentPlayer).removeValue { error, _ in - if let error = error { - Logger.log("Error deleting data: \(error).", self) - completion(error) - return - } - Logger.log("Metadata for player \(currentPlayer) successfully deleted from Firebase.", self) - completion(nil) - } - } -} diff --git a/TowerForge/TowerForge/Storage/RemoteStorageManager.swift b/TowerForge/TowerForge/Storage/RemoteStorageManager.swift deleted file mode 100644 index 062d0846..00000000 --- a/TowerForge/TowerForge/Storage/RemoteStorageManager.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// RemoteStorageManager.swift -// TowerForge -// -// Created by Rubesh on 14/4/24. -// - -import Foundation -import FirebaseDatabaseInternal - -/// A utility class to provide standard storage operations and interaction with -/// the Firebase Database. -/// -/// Currently the Storage means is limited to storing Statistics only, possible -/// expansion to a generic types can be considered. -class RemoteStorageManager { - static var currentPlayer: String = Constants.CURRENT_PLAYER_ID - static var currentDevice: String = Constants.CURRENT_DEVICE_ID - - static func initializeRemoteStatisticsDatabase() { - Self.loadDatabaseFromFirebase { statisticsDatabase, error in - if let error = error { - Logger.log("Error initializing data: \(error)", self) - return - } - - if statisticsDatabase != nil { - Logger.log("Statistics database already initialized.", self) - return - } - - // No error but no database implies that database is empty, thus initialize new one - Logger.log("No error but empty database, new one will be created", self) - let remoteStorage = StatisticsFactory.getDefaultStatisticsDatabase() - - Self.saveDatabaseToFirebase(remoteStorage) { error in - if let error = error { - Logger.log("Saving to firebase error: \(error)", self) - } else { - Logger.log("Saving to firebase success", self) - } - } - } - } - - /// Queries the firebase backend to determine if remote storage exists for the current player - static func remoteStorageExistsForCurrentPlayer(completion: @escaping (Bool) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Statistics) - - databaseReference.child(currentPlayer).getData(completion: { error, snapshot in - if let error = error { - Logger.log("Error checking data existence: \(error.localizedDescription)", self) - completion(false) // Assuming no data exists if an error occurs - return - } - - if snapshot?.exists() != nil && snapshot?.value != nil { - completion(true) - } else { - completion(false) - } - }) - } - - static func loadDatabaseFromFirebase(completion: @escaping (StatisticsDatabase?, Error?) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Statistics) - - databaseReference.child(currentPlayer).getData(completion: { error, snapshot in - if let error = error { - Logger.log(error.localizedDescription, self) - completion(nil, error) - return - } - - guard let value = snapshot?.value as? [String: Any], - let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) else { - completion(nil, nil) - return - } - - do { - let decoder = JSONDecoder() - let statsDatabase = try decoder.decode(StatisticsDatabase.self, from: jsonData) - completion(statsDatabase, nil) - } catch { - Logger.log("Error decoding StatisticsDatabase from Firebase: \(error)", self) - completion(nil, error) - } - }) - } - - static func saveDatabaseToFirebase(_ stats: StatisticsDatabase, completion: @escaping (Error?) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Statistics) - - do { - let encoder = JSONEncoder() - let data = try encoder.encode(stats) - let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] - - databaseReference.child(currentPlayer).setValue(dictionary) { error, _ in - if let error = error { - Logger.log("StatisticsDatabase could not be saved: \(error).", StatisticsDatabase.self) - completion(error) - } else { - Logger.log("StatisticsDatabase saved to Firebase successfully!", StatisticsDatabase.self) - completion(nil) - } - } - } catch { - Logger.log("Error encoding StatisticsDatabase: \(error)", StatisticsDatabase.self) - completion(error) - } - - RemoteMetadataManager.updateMetadataInFirebase() - } - - /// Deletes the player's statistics database from Firebase - static func deleteDatabaseFromFirebase(completion: @escaping (Error?) -> Void) { - let databaseReference = FirebaseDatabaseReference(.Statistics) - - // Remove the data at the specific currentPlayer node - databaseReference.child(currentPlayer).removeValue { error, _ in - if let error = error { - Logger.log("Error deleting data: \(error).", self) - completion(error) - return - } - Logger.log("StatisticsDatabase for player \(currentPlayer) successfully deleted from Firebase.", self) - completion(nil) - } - } -} diff --git a/TowerForge/TowerForge/Storage/StorageManager.swift b/TowerForge/TowerForge/Storage/StorageManager.swift deleted file mode 100644 index 0695f803..00000000 --- a/TowerForge/TowerForge/Storage/StorageManager.swift +++ /dev/null @@ -1,180 +0,0 @@ -// -// StorageManager.swift -// TowerForge -// -// Created by Rubesh on 14/4/24. -// - -import Foundation - -/// The class responsible for providing application wide Storage access and -/// synchronizing between Local Storage and Remote Storage. The application interacts only -/// with StorageManager which handles all storage operations. -class StorageManager { - static var CONFLICT_RESOLUTION = Constants.CONFLICT_RESOLTION - - static func initializeAllStorage() { - LocalMetadataManager.initializeUserIdentifier() - LocalStorageManager.initializeLocalStatisticsDatabase() - } - - static var defaultErrorClosure: (Error?) -> Void = { error in - if let error = error { - Logger.log("Generic error message invoked: \(error)") - } else { - Logger.log("Generic success message invoked.") - } - } - - static func onLogin(with userId: String) { - let localStorage = LocalStorageManager.loadDatabaseFromLocalStorage() - ?? StatisticsFactory.getDefaultStatisticsDatabase() - - _ = Self.saveUniversally(localStorage) - - Constants.CURRENT_PLAYER_ID = userId - var remoteStorage = StatisticsDatabase() - - RemoteStorageManager.loadDatabaseFromFirebase { statisticsDatabase, error in - if let error = error { - Logger.log("Error loading data: \(error)", self) - } else if let statisticsDatabase = statisticsDatabase { - Logger.log("Successfully loaded statistics database.", self) - remoteStorage = statisticsDatabase - } else { - // No error and no database implies that database is empty, thus initialize new one - Logger.log("No error and empty database, new one will be created", self) - remoteStorage = StatisticsFactory.getDefaultStatisticsDatabase() - } - } - - if let finalStorage = Self.resolveConflict(this: localStorage, that: remoteStorage) { - _ = Self.saveUniversally(finalStorage) - } - - } - - static func onLogout() { - Constants.CURRENT_PLAYER_ID = Constants.CURRENT_DEVICE_ID - } - - static func resetAllStorage() { - Self.deleteAllRemoteStorage() - Self.deleteAllLocalStorage() - } - - static func deleteAllLocalStorage() { - LocalStorageManager.deleteDatabaseFromLocalStorage() - LocalMetadataManager.deleteMetadataFromLocalStorage() - } - - static func deleteAllRemoteStorage() { - RemoteStorageManager.deleteDatabaseFromFirebase { error in - if let error = error { - Logger.log("Deletion of all remote storage failed by StorageManager: \(error)", self) - } else { - Logger.log("Delete of all remote storage success", self) - } - } - } - - static func saveUniversally(_ statistics: StatisticsDatabase) -> StatisticsDatabase? { - LocalStorageManager.saveDatabaseToLocalStorage(statistics) - return Self.pushToRemote() - } - - static func loadUniversally() -> StatisticsDatabase? { - if let stats = LocalStorageManager.loadDatabaseFromLocalStorage() { - return stats - } else { - let localStorage = StatisticsFactory.getDefaultStatisticsDatabase() - return saveUniversally(localStorage) - } - } - - /// Returns the StatisticsDatabase from the location that corresponds to the most recent - /// save. - static func loadLatest() -> StatisticsDatabase? { - var stats: StatisticsDatabase? - - guard let location = MetadataManager.getLocationWithLatestMetadata() else { - return nil - } - - switch location { - case .Local: - stats = LocalStorageManager.loadDatabaseFromLocalStorage() - case .Remote: - RemoteStorageManager.loadDatabaseFromFirebase { statsData, error in - if error != nil { - Logger.log("Error occured loading from database") - } - - stats = statsData - } - } - - return stats - } - - private static func resolveConflict(this: StatisticsDatabase, that: StatisticsDatabase) -> StatisticsDatabase? { - var finalStorage: StatisticsDatabase? - - switch CONFLICT_RESOLUTION { - case .MERGE: - finalStorage = StatisticsDatabase.merge(this: this, that: that) - case .KEEP_LATEST_ONLY: - finalStorage = Self.loadLatest() - } - - return finalStorage - } - - /// Pushes local data to remote - /// - Firstly loads data from local storage (or creates empty storage if it doesn't exist) - /// - Then loads data from remote storage (or creates empty storage if it doesn't exist) - /// - Compares both data, merges them, and pushes back to remote. - /// - /// This ensures that no information is overwritten in the process. - private static func pushToRemote() -> StatisticsDatabase? { - // Explicitly load storage to ensure that uploaded data is - var localStorage: StatisticsDatabase - var remoteStorage = StatisticsDatabase() - - if let localStats = LocalStorageManager.loadDatabaseFromLocalStorage() { - localStorage = localStats - } else { - LocalStorageManager.initializeLocalStatisticsDatabase() - localStorage = StatisticsFactory.getDefaultStatisticsDatabase() - } - - RemoteStorageManager.loadDatabaseFromFirebase { statisticsDatabase, error in - if let error = error { - Logger.log("Error loading data: \(error)", self) - } else if let statisticsDatabase = statisticsDatabase { - Logger.log("Successfully loaded statistics database.", self) - remoteStorage = statisticsDatabase - } else { - // No error and no database implies that database is empty, thus initialize new one - Logger.log("No error and empty database, new one will be created", self) - remoteStorage = StatisticsFactory.getDefaultStatisticsDatabase() - } - } - - guard let finalStorage = StatisticsDatabase.merge(this: localStorage, that: remoteStorage) else { - return nil - } - - RemoteStorageManager.saveDatabaseToFirebase(finalStorage) { error in - if let error = error { - Logger.log("Saving to firebase error: \(error)", self) - } else { - Logger.log("Saving to firebase success", self) - } - } - - LocalStorageManager.saveDatabaseToLocalStorage(finalStorage) - return finalStorage - } - -} diff --git a/TowerForge/TowerForge/StorageAPI/LocalStorage+Metadata.swift b/TowerForge/TowerForge/StorageAPI/LocalStorage+Metadata.swift new file mode 100644 index 00000000..9d021bfd --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/LocalStorage+Metadata.swift @@ -0,0 +1,66 @@ +// +// LocalStorage+Metadata.swift +// TowerForge +// +// Created by Rubesh on 21/4/24. +// + +import Foundation + +/// This extension adds utility methods specifically for local metadata operations +extension LocalStorage { + static func initializeMetadataToLocalStorage() { + if let metadata = LocalStorage.loadMetadataFromLocalStorage() { + Logger.log("--- Metadata exists locally", Self.self) + Constants.CURRENT_PLAYER_ID = metadata.uniqueIdentifier + Constants.CURRENT_DEVICE_ID = metadata.deviceIdentifier + } else { + let defaultMetadata = Metadata() + Constants.CURRENT_PLAYER_ID = defaultMetadata.uniqueIdentifier + Constants.CURRENT_DEVICE_ID = defaultMetadata.deviceIdentifier + + LocalStorage.saveMetadataToLocalStorage(defaultMetadata) + Logger.log("--- Created and saved a new empty metadata file.", Self.self) + Logger.log("--- Current id: \(Constants.CURRENT_DEVICE_ID)", Self.self) + } + } + + static func saveMetadataToLocalStorage(_ metadata: Metadata) { + do { + let folderURL = try Self.createFolderIfNeeded(folderName: folderName) + let fileURL = folderURL.appendingPathComponent(metadataName) + let data = try JSONEncoder().encode(metadata) + try data.write(to: fileURL) + Logger.log("Metadata saved at: \(fileURL.path)", self) + } catch { + Logger.log("Failed to save metadata: \(error)", self) + } + } + + static func loadMetadataFromLocalStorage() -> Metadata? { + do { + let folderURL = try Self.createFolderIfNeeded(folderName: folderName) + let fileURL = folderURL.appendingPathComponent(metadataName) + let data = try Data(contentsOf: fileURL) + let metadata = try JSONDecoder().decode(Metadata.self, from: data) + return metadata + } catch { + Logger.log("Failed to load metadata: \(error)", self) + return nil + } + } + + /// Deletes the stored metadata from file + static func deleteMetadataFromLocalStorage() { + do { + let folderURL = try Self.createFolderIfNeeded(folderName: folderName) + let fileURL = folderURL.appendingPathComponent(metadataName) + try FileManager.default.removeItem(at: fileURL) + Logger.log("Metadata successfully deleted.", self) + } catch { + Logger.log("Error deleting metadata: \(error)", self) + } + + Logger.log("Metadata successfully deleted.", self) + } +} diff --git a/TowerForge/TowerForge/Storage/LocalStorageManager.swift b/TowerForge/TowerForge/StorageAPI/LocalStorage.swift similarity index 65% rename from TowerForge/TowerForge/Storage/LocalStorageManager.swift rename to TowerForge/TowerForge/StorageAPI/LocalStorage.swift index eeca5936..4a619745 100644 --- a/TowerForge/TowerForge/Storage/LocalStorageManager.swift +++ b/TowerForge/TowerForge/StorageAPI/LocalStorage.swift @@ -1,64 +1,31 @@ // -// LocalStorageManager.swift +// LocalStorage.swift // TowerForge // -// Created by Rubesh on 14/4/24. +// Created by Rubesh on 21/4/24. // import Foundation -/// A utility class to provide standard storage operations and interaction with -/// the on-device files storage system via the FileManager API. -/// -/// Currently the Storage means is limited to storing Statistics only, possible -/// expansion to a generic type can be considered. -class LocalStorageManager { +/// Utility class to provide static methods for local storage operations +class LocalStorage { static let folderName = Constants.LOCAL_STORAGE_CONTAINER_NAME static let fileName = Constants.LOCAL_STORAGE_FILE_NAME static let metadataName = Constants.METADATA_FILE_NAME - /// Creates an empty local file to store the database if one doesn't already exist. - /// Called by the AppDelegate when the application is run. - static func initializeLocalStatisticsDatabase() { + private init() { } + + static func initializeDatabaseToLocalStorage() { + /// Initialize StatisticsDatabase if Self.loadDatabaseFromLocalStorage() != nil { - Logger.log("Database exists locally", Self.self) + Logger.log("--- Database exists locally", Self.self) } else { - Self.saveDatabaseToLocalStorage(StatisticsFactory.getDefaultStatisticsDatabase()) - Logger.log("Created and saved a new empty database.", Self.self) - } - - RemoteStorageManager.initializeRemoteStatisticsDatabase() - } - - /// Helper function to construct a FileURL - static func fileURL(for directory: FileManager.SearchPathDirectory, withName name: String) throws -> URL { - let fileManager = FileManager.default - - return try fileManager.url(for: directory, - in: .userDomainMask, - appropriateFor: nil, - create: true).appendingPathComponent(name) - } - - /// Helper function to create a folder using the shared FileManager for a given folderName - static func createFolderIfNeeded(folderName: String) throws -> URL { - let fileManager = FileManager.default - let documentsURL: URL = try fileManager.url(for: .documentDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: false) - - let folderURL = documentsURL.appendingPathComponent(folderName) - if !fileManager.fileExists(atPath: folderURL.path) { - try fileManager.createDirectory(at: folderURL, - withIntermediateDirectories: true, - attributes: nil) + let defaultDatabase = StatisticsFactory.getDefaultStatisticsDatabase() + Self.saveDatabaseToLocalStorage(defaultDatabase) + Logger.log("--- Created and saved a new empty database.", Self.self) } - return folderURL } -} -extension LocalStorageManager { /// Saves the input statistics database to file static func saveDatabaseToLocalStorage(_ stats: StatisticsDatabase) { let encoder = JSONEncoder() @@ -72,11 +39,9 @@ extension LocalStorageManager { } catch { Logger.log("Error saving statistics Database: \(error)", self) } - - LocalMetadataManager.updateMetadataInLocalStorage() } - /// Loads a database (with the class constant folderName and fileName) from file + /// Returns the StatisticsDatabase if it exists at the specific location or nil otherwise. static func loadDatabaseFromLocalStorage() -> StatisticsDatabase? { do { let folderURL = try Self.createFolderIfNeeded(folderName: Self.folderName) @@ -90,7 +55,7 @@ extension LocalStorageManager { } } - /// Deletes the stored database from file + /// Deletes the stored statistics database from file static func deleteDatabaseFromLocalStorage() { do { let folderURL = try Self.createFolderIfNeeded(folderName: Self.folderName) @@ -102,17 +67,30 @@ extension LocalStorageManager { Logger.log("Database successfully deleted.", self) } - /// Retrieves file attributes provided by iOS for a given filename in the document directory. - static func getDatabaseFileAttributes() -> [FileAttributeKey: Any]? { - do { - let folderURL = try createFolderIfNeeded(folderName: folderName) - let fileURL = folderURL.appendingPathComponent(Self.fileName) - let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) - return attributes - } catch { - Logger.log("Error retrieving file metadata: \(error)", self) - return nil - } + /// Helper function to construct a FileURL + static func fileURL(for directory: FileManager.SearchPathDirectory, withName name: String) throws -> URL { + let fileManager = FileManager.default + + return try fileManager.url(for: directory, + in: .userDomainMask, + appropriateFor: nil, + create: true).appendingPathComponent(name) } + /// Helper function to create a folder using the shared FileManager for a given folderName + static func createFolderIfNeeded(folderName: String) throws -> URL { + let fileManager = FileManager.default + let documentsURL: URL = try fileManager.url(for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false) + + let folderURL = documentsURL.appendingPathComponent(folderName) + if !fileManager.fileExists(atPath: folderURL.path) { + try fileManager.createDirectory(at: folderURL, + withIntermediateDirectories: true, + attributes: nil) + } + return folderURL + } } diff --git a/TowerForge/TowerForge/StorageAPI/Metadata.swift b/TowerForge/TowerForge/StorageAPI/Metadata.swift new file mode 100644 index 00000000..671911d7 --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/Metadata.swift @@ -0,0 +1,80 @@ +// +// Metadata.swift +// TowerForge +// +// Created by Rubesh on 14/4/24. +// + +import Foundation + +/// The metadata class is used to encapsulate +/// +/// - Information about device and the current user for use with Remote Storage +/// - Meta-information about files stored locally, possibly for use with conflict resolution. +class Metadata: StorageDatabase, Comparable, Equatable { + + static var currentPlayerId: String { Constants.CURRENT_PLAYER_ID } + static var currentDeviceId: String { Constants.CURRENT_DEVICE_ID } + + var deviceIdentifier: String + var uniqueIdentifier: String + var lastUpdated: Date + + init(lastUpdated: Date = .now, + uniqueIdentifier: String = Metadata.currentPlayerId, + deviceIdentifer: String = Metadata.currentDeviceId) { + self.lastUpdated = lastUpdated + self.uniqueIdentifier = uniqueIdentifier + self.deviceIdentifier = deviceIdentifer + } + + init() { + self.lastUpdated = .now + let id = UUID().uuidString + self.uniqueIdentifier = id + self.deviceIdentifier = id + } + + func updateTimeToNow() { + lastUpdated = .now + Logger.log("Metadata time updated to \(self.lastUpdated.ISO8601Format())", self) + } + + func updateIdentifierToCurrentID() { + uniqueIdentifier = Self.currentPlayerId + Logger.log("Metadata id updated to currentPlayer \(self.uniqueIdentifier)", self) + } + + func resetIdentifier() { + uniqueIdentifier = Self.currentDeviceId + Logger.log("Metadata id updated to currentDevice \(self.uniqueIdentifier)", self) + } + + static func == (lhs: Metadata, rhs: Metadata) -> Bool { + lhs.lastUpdated == rhs.lastUpdated && lhs.uniqueIdentifier == rhs.uniqueIdentifier + } + + static func < (lhs: Metadata, rhs: Metadata) -> Bool { + lhs.lastUpdated < rhs.lastUpdated + } + + static func > (lhs: Metadata, rhs: Metadata) -> Bool { + lhs.lastUpdated > rhs.lastUpdated + } + + static func latest(lhs: Metadata, rhs: Metadata) -> Metadata { + rhs.lastUpdated > lhs.lastUpdated ? rhs : lhs + } + + static func merge(this: Metadata?, that: Metadata?) -> Self? { + guard let lhs = this, let rhs = that else { + return nil + } + + guard let latest = Metadata.latest(lhs: lhs, rhs: rhs) as? Self else { + return nil + } + + return latest + } +} diff --git a/TowerForge/TowerForge/StorageAPI/RemoteStorage+Access.swift b/TowerForge/TowerForge/StorageAPI/RemoteStorage+Access.swift new file mode 100644 index 00000000..72061971 --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/RemoteStorage+Access.swift @@ -0,0 +1,148 @@ +// +// RemoteStorage+Metadata.swift +// TowerForge +// +// Created by Rubesh on 21/4/24. +// + +import Foundation + +/// This class adds utility methods specifically for access operations. Given +/// the nature of the remote backend, closures are used for async operations. +/// This class abstracts away the closure invocation with default access +/// functions, for both storage and metadata. +extension RemoteStorage { + + static func checkIfPlayerMetadataExistsAsync(for playerId: String) async -> Bool { + await withCheckedContinuation { continuation in + Self.remoteStorageExists(for: .Metadata, player: playerId) { exists in + continuation.resume(returning: exists) + } + } + } + + static func checkIfRemotePlayerDataExists(playerId: String, completion: @escaping (Bool) -> Void) { + // Check for player storage existence + RemoteStorage.checkIfPlayerDatabaseExists(for: playerId) { storageExists in + // Check for player metadata existence + RemoteStorage.checkIfPlayerMetadataExists(for: playerId) { metadataExists in + // Check for any inconsistencies, if either one don't exist, consider player to be non-existent + if (storageExists && !metadataExists) || (!storageExists && metadataExists) { + Logger.log("Inconsistency error: Storage Exists: \(storageExists), Metadata Exists: \(metadataExists)") + } + + // Return the combined result + completion(storageExists && metadataExists) + } + } + } + + /// Checks if a player's metadata exists + static func checkIfPlayerMetadataExists(for playerId: String, + completion: @escaping (Bool) -> Void) { + + Self.remoteStorageExists(for: .Metadata, player: playerId, completion: completion) + } + + /// Checks if a player's statistics exists + static func checkIfPlayerDatabaseExists(for playerId: String, + completion: @escaping (Bool) -> Void) { + + Self.remoteStorageExists(for: .Statistics, player: playerId, completion: completion) + } + + /// Checks if a player's statistics exists without requiring a closure input + + static func saveMetadataToFirebase(player: String, + with inputData: Metadata, + completion: @escaping (Bool) -> Void) { + let metadataCompletion: (Error?) -> Void = { error in + if let error = error { + Logger.log("Saving metadata to firebase error: \(error)", self) + completion(false) + } else { + Logger.log("Saving metadata to firebase success", self) + completion(true) + } + } + + Self.saveDataToFirebase(for: .Metadata, + player: player, + with: inputData, + completion: metadataCompletion) + } + + static func saveDatabaseToFirebase(player: String, + with inputData: StatisticsDatabase, + completion: @escaping (Bool) -> Void) { + let storageCompletion: (Error?) -> Void = { error in + if let error = error { + Logger.log("Saving storage to firebase error: \(error)", self) + completion(false) + } else { + Logger.log("Saving storage to firebase success", self) + completion(true) + } + } + + Self.saveDataToFirebase(for: .Statistics, + player: player, + with: inputData, + completion: storageCompletion) + } + + /// Deletes metadata for the specified player from firebase + static func deleteMetadataFromFirebase(player: String) { + let completion: (Error?) -> Void = { error in + if let error = error { + Logger.log("Deleting metadata from firebase error: \(error)", self) + } else { + Logger.log("Deleting Metadata from firebase success", self) + } + } + + Self.deleteDataFromFirebase(for: .Metadata, player: player, completion: completion) + } + + /// Deletes storage for the specific player from Firebase + static func deleteDatabaseFromFirebase(player: String) { + let completion: (Error?) -> Void = { error in + if let error = error { + Logger.log("Deleting storage from firebase error: \(error)", self) + } else { + Logger.log("Saving storage from firebase success", self) + } + } + + Self.deleteDataFromFirebase(for: .Statistics, player: player, completion: completion) + } + + static func loadMetadataFromFirebase(player: String, + completion: @escaping (Metadata?, Error?) -> Void) { + Self.checkIfPlayerDatabaseExists(for: player) { exists in + guard exists else { + completion(nil, nil) // Consider custom error + return + } + + Self.loadDataFromFirebase(for: .Metadata, player: player) { (metadata: Metadata?, error: Error?) in + completion(metadata, error) + } + } + } + + static func loadDatabaseFromFirebase(player: String, + completion: @escaping (StatisticsDatabase?, Error?) -> Void) { + Self.checkIfPlayerDatabaseExists(for: player) { exists in + guard exists else { + completion(nil, nil) // Consider custom error + return + } + + Self.loadDataFromFirebase(for: .Statistics, + player: player) { (statistics: StatisticsDatabase?, error: Error?) in + completion(statistics, error) + } + } + } +} diff --git a/TowerForge/TowerForge/StorageAPI/RemoteStorage.swift b/TowerForge/TowerForge/StorageAPI/RemoteStorage.swift new file mode 100644 index 00000000..59e5af7e --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/RemoteStorage.swift @@ -0,0 +1,112 @@ +// +// RemoteStroage.swift +// TowerForge +// +// Created by Rubesh on 21/4/24. +// + +import Foundation + +/// Utility class to provide static methods for accessing remote storage +class RemoteStorage { + + private init() { } + + /// Queries the firebase backend to determine if remote storage exists for the current player + static func remoteStorageExists(for ref: FirebaseReference, + player: String, + completion: @escaping (Bool) -> Void) { + let databaseReference = FirebaseDatabaseReference(ref) + + databaseReference.child(player).getData(completion: { error, snapshot in + if let error = error { + Logger.log("Error checking data existence: \(error.localizedDescription)", self) + completion(false) // Assuming no data exists if an error occurs + return + } + + // Snapshot must exist AND be non-empty in order to be considered existing + if snapshot?.exists() != nil && snapshot?.value != nil { + completion(true) + } else { + completion(false) + } + }) + } + + /// Saves the input StorageDatabase to firebase + static func saveDataToFirebase(for ref: FirebaseReference, + player: String, + with inputData: any StorageDatabase, + completion: @escaping (Error?) -> Void) { + let databaseReference = FirebaseDatabaseReference(ref) + + do { + let encoder = JSONEncoder() + let data = try encoder.encode(inputData) + let dictionary = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] + + databaseReference.child(player).setValue(dictionary) { error, _ in + if let error = error { + Logger.log("Data could not be saved: \(error).", Self.self) + completion(error) + } else { + Logger.log("Data saved to Firebase successfully!", Self.self) + completion(nil) + } + } + } catch { + Logger.log("Error encoding Data: \(error)", Self.self) + completion(error) + } + } + + /// Saves the input StorageDatabase to firebase + static func deleteDataFromFirebase(for ref: FirebaseReference, + player: String, + completion: @escaping (Error?) -> Void) { + let databaseReference = FirebaseDatabaseReference(ref) + + // Remove the data at the specific player node + databaseReference.child(player).removeValue { error, _ in + if let error = error { + Logger.log("Error deleting data: \(error).", self) + completion(error) + return + } + + Logger.log("Data for player \(player) successfully deleted from Firebase.", self) + completion(nil) + } + } + + static func loadDataFromFirebase(for ref: FirebaseReference, + player: String, + completion: @escaping (T?, Error?) -> Void) { + let databaseReference = FirebaseDatabaseReference(ref) + + databaseReference.child(player).getData(completion: { error, snapshot in + if let error = error { + Logger.log("Error loading data from firebase: \(error.localizedDescription)", self) + completion(nil, error) + return + } + + guard let value = snapshot?.value as? [String: Any], + let jsonData = try? JSONSerialization.data(withJSONObject: value, options: []) else { + completion(nil, nil) + return + } + + do { + let decoder = JSONDecoder() + let storageDatabase = try decoder.decode(T.self, from: jsonData) + completion(storageDatabase, nil) + } catch { + Logger.log("Error decoding StatisticsDatabase from Firebase: \(error)", self) + completion(nil, error) + } + }) + } + +} diff --git a/TowerForge/TowerForge/Storage/StorageDatabase.swift b/TowerForge/TowerForge/StorageAPI/StorageDatabase.swift similarity index 63% rename from TowerForge/TowerForge/Storage/StorageDatabase.swift rename to TowerForge/TowerForge/StorageAPI/StorageDatabase.swift index 4c1e13e0..f2fcf802 100644 --- a/TowerForge/TowerForge/Storage/StorageDatabase.swift +++ b/TowerForge/TowerForge/StorageAPI/StorageDatabase.swift @@ -7,7 +7,9 @@ import Foundation -protocol StorageDatabase: Codable { +protocol StorageDatabase: Codable, AnyObject { func encode(to encoder: Encoder) throws init(from decoder: Decoder) throws + + static func merge(this: Self?, that: Self?) -> Self? } diff --git a/TowerForge/TowerForge/StorageAPI/StorageHandler+Auth.swift b/TowerForge/TowerForge/StorageAPI/StorageHandler+Auth.swift new file mode 100644 index 00000000..1b0f0497 --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/StorageHandler+Auth.swift @@ -0,0 +1,117 @@ +// +// StorageHandler+Auth.swift +// TowerForge +// +// Created by Rubesh on 21/4/24. +// + +import Foundation + +/// This extension adds authentication methods to StorageHandler +extension StorageHandler { + + func onLogin() { + Logger.log("LOGIN: IS CALLED FROM STORAGE HANDLER", Self.self) + + authenticationProvider.getCurrentUserId { [weak self] userId, error in + guard let self = self else { + return + } + + if let error = error { + Logger.log("IMPT: onLogin failed from STORAGE_HANDLER: \(error.localizedDescription)", self) + return + } + + guard let userId = userId else { + Logger.log("IMPT: onLogin failed due to userId nil from STORAGE_HANDLER", self) + return + } + + // Update the playerId and metadata locally + self.localUpdatePlayerIdAndMetadata(with: userId) + + if !Self.isLoggedIn { + Self.isLoggedIn = true + // Asynchronously check if remote data exists for currentPlayerId + self.checkIfRemotePlayerDataExists { exists in + if exists { + Logger.log("ONLOGIN ---- REMOTE PLAYER DATA EXISTS") + self.onReLogin { reloginSuccess in + if !reloginSuccess { + Logger.log("RE-LOGIN FAILED: Remote player exists but Re-login failure", self) + } + } + } else { + Logger.log("ONLOGIN ---- REMOTE PLAYER DATA DOESN'T EXIST") + self.onFirstLogin() + } + } + } + } + } + + /// Helper function to asynchronously check if both Metadata and Storage exist + func checkIfRemotePlayerDataExists(completion: @escaping (Bool) -> Void) { + RemoteStorage.checkIfRemotePlayerDataExists(playerId: Self.currentPlayerId, completion: completion) + } + + func onFirstLogin() { + Logger.log("FIRST LOGIN ENTERED", self) + self.save() + } + + /// Returns true if re-login success, false otherwise + func onReLogin(completion: @escaping (Bool) -> Void) { + Logger.log("RE-LOGIN ENTERED", self) + // Executed upon confirmation that both types of data exist remotely + // 1. Load metadata from firebase + RemoteStorage.loadMetadataFromFirebase(player: Self.currentPlayerId) { remoteMetadata, _ in + guard let remoteMetadata = remoteMetadata else { + Logger.log("RELOGIN ERROR: REMOTE METADATA NOT FOUND") + completion(false) + return + } + + // 2. Load statistics from firebase + RemoteStorage.loadDatabaseFromFirebase(player: Self.currentPlayerId) { remoteStorage, _ in + guard let remoteStorage = remoteStorage else { + Logger.log("RELOGIN ERROR: REMOTE STORAGE NOT FOUND") + completion(false) + return + } + + // 3. Resolve conflict between remote statistics and current statistics + Self.resolveConflict(this: self.statisticsDatabase, that: remoteStorage) { resolvedStats in + guard let finalStorage = resolvedStats else { + Logger.log("RELOGIN ERROR: CONFLICT RESOLUTION FAILURE") + completion(false) + return + } + + // 4. Update current instance to resolve storage + self.statisticsDatabase = finalStorage + + // 5. Save newly resolved storage universally + self.save() + } + } + } + } + + func localUpdatePlayerIdAndMetadata(with userId: String) { + Constants.CURRENT_PLAYER_ID = userId + metadata.updateIdentifierToCurrentID() + self.localSave() + } + + func onLogout() { + Self.isLoggedIn = false + Constants.CURRENT_PLAYER_ID = Constants.CURRENT_DEVICE_ID + Logger.log("LOGOUT: CALLED FROM STORAGE_HANDLER", Self.self) + self.save() // Save any potential unsaved changes + metadata.resetIdentifier() // Reset metadata to original value + Logger.log("LOGOUT: metadata reset to \(metadata.uniqueIdentifier)", Self.self) + self.localSave() // Save updated metadata + } +} diff --git a/TowerForge/TowerForge/StorageAPI/StorageHandler+Conflict.swift b/TowerForge/TowerForge/StorageAPI/StorageHandler+Conflict.swift new file mode 100644 index 00000000..9722c257 --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/StorageHandler+Conflict.swift @@ -0,0 +1,84 @@ +// +// StorageHandler+Conflict.swift +// TowerForge +// +// Created by Rubesh on 21/4/24. +// + +import Foundation + +/// This extension adds conflict resolution methods to StorageHandler +extension StorageHandler { + + /// Returns the StatisticsDatabase from the location that corresponds to the most recent save. + static func getLocationWithLatestMetadata(completion: @escaping (StorageLocation?) -> Void) { + RemoteStorage.loadMetadataFromFirebase(player: Self.currentPlayerId) { remoteMetadata, remoteError in + let localMetadata = LocalStorage.loadMetadataFromLocalStorage() + + // Handle errors or nil cases + if let remoteError = remoteError { + Logger.log("Error occurred retrieving metadata: \(remoteError)", self) + } + + switch (remoteMetadata, localMetadata) { + case (_?, nil): + completion(.Remote) + case (nil, _?): + completion(.Local) + case (let remote?, let local?): + completion(remote > local ? .Remote : .Local) + default: + completion(nil) + } + } + } + + static func loadLatest(completion: @escaping (StatisticsDatabase?) -> Void) { + Self.getLocationWithLatestMetadata { location in + switch location { + + case .Local: + if let stats = LocalStorage.loadDatabaseFromLocalStorage() { + completion(stats) + } else { + Logger.log("Error: Failed to load local database.", self) + completion(nil) + } + + case .Remote: + RemoteStorage.loadDatabaseFromFirebase(player: Self.currentPlayerId) { statsData, error in + if let error = error { + Logger.log("Error occurred loading from database: \(error)", self) + completion(nil) + } else { + completion(statsData) + } + } + + default: + Logger.log("No valid metadata found, cannot determine latest storage.", self) + completion(nil) + } + } + } + + static func resolveConflict(this: StatisticsDatabase, + that: StatisticsDatabase, + completion: @escaping (StatisticsDatabase?) -> Void) { + + switch CONFLICT_RESOLUTION { + case .MERGE: + Logger.log("RESOLVE --- THIS DB is \(String(describing: this.toString()))", self) + Logger.log("RESOLVE --- THAT DB is \(String(describing: that.toString()))", self) + completion(StatisticsDatabase.merge(this: this, that: that)) + return + case .KEEP_LATEST_ONLY: + Self.loadLatest { completion($0) } + return + case .PRESERVE_LOCAL: + completion(this) + return + } + } + +} diff --git a/TowerForge/TowerForge/StorageAPI/StorageHandler.swift b/TowerForge/TowerForge/StorageAPI/StorageHandler.swift new file mode 100644 index 00000000..6fd32451 --- /dev/null +++ b/TowerForge/TowerForge/StorageAPI/StorageHandler.swift @@ -0,0 +1,121 @@ +// +// StorageHandler.swift +// TowerForge +// +// Created by Rubesh on 21/4/24. +// + +import Foundation + +protocol StatisticsEngineDelegate: AnyObject { + var statisticsDatabase: StatisticsDatabase { get set } + func save() +} + +class StorageHandler: AuthenticationDelegate, StatisticsEngineDelegate { + + static let folderName = Constants.LOCAL_STORAGE_CONTAINER_NAME + static let fileName = Constants.LOCAL_STORAGE_FILE_NAME + static let metadataName = Constants.METADATA_FILE_NAME + + static var isLoggedIn = false + static var CONFLICT_RESOLUTION: StorageConflictResolution { Constants.CONFLICT_RESOLTION } + static var currentPlayerId: String { Constants.CURRENT_PLAYER_ID } + static var currentDeviceId: String { Constants.CURRENT_DEVICE_ID } + + let authenticationProvider = AuthenticationProvider() + var statisticsDatabase = StatisticsDatabase() + var metadata = Metadata() + + init() { + Self.initializeLocalStorageIfNotPresent() + authenticationProvider.addObserver(self) + + // Set statistic DB to default if it doesn't exist + if let statistics = LocalStorage.loadDatabaseFromLocalStorage() { + statisticsDatabase = statistics + } else { + statisticsDatabase = StatisticsFactory.getDefaultStatisticsDatabase() + } + + // Set metadata to default if it doesn't exists + if let metadata = LocalStorage.loadMetadataFromLocalStorage() { + self.metadata = metadata + } else { + self.metadata = Metadata() + } + } + + deinit { + Logger.log("StorageHandler: Deinit is called", self) + } + + /// Initializes empty statistics and metadata if they don't already exist locally + /// Called by the AppDelegate when the application is run. + static func initializeLocalStorageIfNotPresent() { + Logger.log("Initializing Metadata", Self.self) + LocalStorage.initializeMetadataToLocalStorage() + + Logger.log("Initializing LocalStorage", Self.self) + LocalStorage.initializeDatabaseToLocalStorage() + } + + /// Universal save + func save() { + Logger.log("U-SAVE: Saving Stats and Metadata Universally for \(Self.currentPlayerId)", Self.self) + self.localSave() + self.remoteSave() + } + + func localSave() { + Logger.log("L-SAVE: Saving Stats and Metadata to LocalStorage", Self.self) + LocalStorage.saveDatabaseToLocalStorage(self.statisticsDatabase) + metadata.updateTimeToNow() + LocalStorage.saveMetadataToLocalStorage(self.metadata) + } + + func remoteSave() { + guard authenticationProvider.isUserLoggedIn() else { + return + } + Logger.log("R-SAVE: Saving Stats and Metadata to RemoteData for \(Self.currentPlayerId)", Self.self) + RemoteStorage.saveDatabaseToFirebase(player: Self.currentPlayerId, with: self.statisticsDatabase) { + if $0 { + Logger.log("R-SAVE-DB SUCCESS", self) + } else { + Logger.log("R-SAVE-DB FAILURE", self) + } + } + + metadata.updateTimeToNow() + RemoteStorage.saveMetadataToFirebase(player: Self.currentPlayerId, with: self.metadata) { + if $0 { + Logger.log("R-SAVE-MD SUCCESS", self) + } else { + Logger.log("R-SAVE-MD FAILURE", self) + } + } + } + + /// Universal delete + func delete() { + Logger.log("U-DELETE: Deleting Stats and Metadata Universally", Self.self) + self.statisticsDatabase.setToDefault() + self.metadata = Metadata() + self.localDelete() + self.remoteDelete() + } + + func localDelete() { + Logger.log("L-DELETE: Deleting Stats and Metadata from LocalStorage", Self.self) + LocalStorage.deleteDatabaseFromLocalStorage() + LocalStorage.deleteMetadataFromLocalStorage() + } + + func remoteDelete() { + Logger.log("R-DELETE: Deleting Stats and Metadata from LocalStorage", Self.self) + RemoteStorage.deleteDatabaseFromFirebase(player: Self.currentPlayerId) + RemoteStorage.deleteMetadataFromFirebase(player: Self.currentPlayerId) + } + +} diff --git a/TowerForge/TowerForge/ViewControllers/PlayerStatsViewController.swift b/TowerForge/TowerForge/ViewControllers/PlayerStatsViewController.swift index b5967fdf..113e95a6 100644 --- a/TowerForge/TowerForge/ViewControllers/PlayerStatsViewController.swift +++ b/TowerForge/TowerForge/ViewControllers/PlayerStatsViewController.swift @@ -15,6 +15,7 @@ class PlayerStatsViewController: UIViewController, UITableViewDataSource, UITabl @IBOutlet private var rankNameLabel: UILabel! @IBOutlet private var characterImage: UIImageView! + @IBOutlet private var rankProgress: UIProgressView! @IBOutlet private var currentExp: UILabel! @IBOutlet private var totalKills: UILabel! @@ -22,41 +23,40 @@ class PlayerStatsViewController: UIViewController, UITableViewDataSource, UITabl @IBOutlet private var kdRatio: UILabel! @IBOutlet private var totalGames: UILabel! - var statsEngine = StatisticsEngine() - + @IBOutlet private var pte: UILabel! + @IBOutlet private var cpl: UILabel! + @IBOutlet private var sgt: UILabel! + @IBOutlet private var lta: UILabel! + @IBOutlet private var cpt: UILabel! + @IBOutlet private var maj: UILabel! + @IBOutlet private var col: UILabel! + @IBOutlet private var gen: UILabel! + + var statisticsEngine = StatisticsEngine(with: StorageHandler()) + var statisticsDatabase: StatisticsDatabase { statisticsEngine.statisticsDatabase } var achievements: AchievementsDatabase = getAchievements() var missions: MissionsDatabase = getMissions() - - var rankingEngine: RankingEngine { - let rankEngine = statsEngine.inferenceEngines[RankingEngine.asType] as? RankingEngine - return rankEngine! - } + var rankingEngine: RankingEngine { RankingEngine(statisticsEngine) } var rank: Rank { rankingEngine.currentRank } var exp: String { rankingEngine.currentExpAsString } - var kd: Double { rankingEngine.currentKd } + var kd: Double { rankingEngine.currentKdRatio } var kills: Int { Int(rankingEngine.getPermanentValueFor(TotalKillsStatistic.self)) } var deaths: Int { Int(rankingEngine.getPermanentValueFor(TotalDeathsStatistic.self)) } var games: Int { Int(rankingEngine.getPermanentValueFor(TotalGamesStatistic.self)) } - static func getAchievements() -> AchievementsDatabase { - let statsEngine = StatisticsEngine() - let achEngine = statsEngine.inferenceEngines[AchievementsEngine.asType] as? AchievementsEngine - achEngine!.achievementsDatabase.setToDefault() - return achEngine!.achievementsDatabase - } - static func getMissions() -> MissionsDatabase { - let statsEngine = StatisticsEngine() + let statsEngine = StatisticsEngine(with: StorageHandler()) let missionsEngine = statsEngine.inferenceEngines[MissionsEngine.asType] as? MissionsEngine missionsEngine!.missionsDatabase.setToDefault() return missionsEngine!.missionsDatabase } - static func getRankingEngine() -> RankingEngine { - let statsEngine = StatisticsEngine() - let rankEngine = statsEngine.inferenceEngines[RankingEngine.asType] as? RankingEngine - return rankEngine! + static func getAchievements() -> AchievementsDatabase { + let statsEngine = StatisticsEngine(with: StorageHandler()) + let achEngine = statsEngine.inferenceEngines[AchievementsEngine.asType] as? AchievementsEngine + achEngine!.achievementsDatabase.setToDefault() + return achEngine!.achievementsDatabase } override func viewDidLoad() { @@ -70,7 +70,13 @@ class PlayerStatsViewController: UIViewController, UITableViewDataSource, UITabl achievementsView.allowsSelection = false missionsView.allowsSelection = false - // rankImageView.image = UIImage(named: currentRank.imageIdentifer) + initializePlayerStats() + initializeRanks() + highlightCurrentRank() + reloadAll() + } + + func initializePlayerStats() { rankNameLabel.text = String("--- Rank: \(rank.rawValue) ---") characterImage.image = rank.isOfficer() ? UIImage(named: "Shooter-1") : UIImage(named: "melee-1") currentExp.text = String("XP: \(exp)") @@ -79,18 +85,41 @@ class PlayerStatsViewController: UIViewController, UITableViewDataSource, UITabl totalGames.text = String("Games: \(games)") kdRatio.text = String("K/D Ratio: ") + String(format: "%.2f", kd) - /* TODO: Add background image - let backgroundImage = UIImageView(frame: UIScreen.main.bounds) - backgroundImage.image = UIImage(named: "stone-tile") - backgroundImage.contentMode = .scaleAspectFill - view.addSubview(backgroundImage) // Add the image view to the view hierarchy - view.sendSubviewToBack(backgroundImage) // Send the image to the background - */ + rankProgress.progress = Float(rankingEngine.percentageToNextRank()) + Logger.log("Current progress is \(rankProgress.progress)", self) + } - reloadAchievements() + func initializeRanks() { + pte.text = "PTE" + cpl.text = "CPL" + sgt.text = "SGT" + lta.text = "LTA" + cpt.text = "CPT" + maj.text = "MAJ" + col.text = "COL" + gen.text = "GEN" } - // MARK: - Table view data source + func highlightCurrentRank() { + switch rank { + case .PRIVATE: + pte.textColor = .red + case .CORPORAL: + cpl.textColor = .red + case .SERGEANT: + sgt.textColor = .red + case .LIEUTENANT: + lta.textColor = .red + case .CAPTAIN: + cpt.textColor = .red + case .MAJOR: + maj.textColor = .red + case .COLONEL: + col.textColor = .red + case .GENERAL: + gen.textColor = .red + } + } func numberOfSections(in tableView: UITableView) -> Int { 1 @@ -100,33 +129,42 @@ class PlayerStatsViewController: UIViewController, UITableViewDataSource, UITabl if tableView == achievementsView { return achievements.count } else if tableView == missionsView { - return missions.missions.count + return missions.count } return 0 } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { if tableView == achievementsView { - guard let cell = tableView.dequeueReusableCell(withIdentifier: "cell", - for: indexPath) as? CustomAchievementCell else { - fatalError("Could not dequeue CustomAchievementCell") - } - - let achievementPair = achievements.asSortedArray[indexPath.row] - let achievement = achievementPair.value - - // Configure the cell elements - cell.nameLabel.text = achievement.name - cell.descriptionLabel.text = achievement.description - cell.achievementImageView.image = UIImage(named: achievement.imageIdentifier) - cell.progressView.progress = Float(achievement.overallProgressRateRounded) - // cell.progressPercentage.text = String(describing: Float(achievement.overallProgressRateRounded)) - let statusImageName = achievement.isComplete ? "checkmark.circle" : "x.circle" - cell.statusImageView.image = UIImage(systemName: statusImageName) - - return cell + return getCustomAchievementCell(tableView: tableView, cellForRowAt: indexPath) } + return getCustomMissionCell(tableView: tableView, cellForRowAt: indexPath) + } + + func getCustomAchievementCell(tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let cell = tableView.dequeueReusableCell(withIdentifier: "cell", + for: indexPath) as? CustomAchievementCell else { + fatalError("Could not dequeue CustomAchievementCell") + } + + let achievementPair = achievements.asSortedArray[indexPath.row] + let achievement = achievementPair.value + + // Configure the cell elements + cell.nameLabel.text = achievement.name + cell.descriptionLabel.text = achievement.description + cell.achievementImageView.image = UIImage(named: achievement.imageIdentifier) + cell.progressView.progress = Float(achievement.overallProgressRateRounded) + // cell.progressPercentage.text = String(describing: Float(achievement.overallProgressRateRounded)) + let statusImageName = achievement.isComplete ? "checkmark.circle" : "x.circle" + cell.statusImageView.image = UIImage(systemName: statusImageName) + cell.statusImageView.tintColor = achievement.isComplete ? .green : .red + + return cell + } + + func getCustomMissionCell(tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let missionCell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) as? CustomMissionCell else { fatalError("Could not dequeue CustomMissionCell") @@ -141,14 +179,16 @@ class PlayerStatsViewController: UIViewController, UITableViewDataSource, UITabl missionCell.missionImageView.image = UIImage(named: mission.imageIdentifier) let statusImageName = mission.isComplete ? "checkmark.circle" : "x.circle" missionCell.statusImageView.image = UIImage(systemName: statusImageName) + missionCell.statusImageView.tintColor = mission.isComplete ? .green : .red return missionCell - } - func reloadAchievements() { + func reloadAll() { achievementsView.reloadData() + missionsView.reloadData() rankNameLabel.reloadInputViews() + view.reloadInputViews() } }