using EDPlayerJournal.Entries; namespace EDPlayerJournal.BGS; internal class TransactionParserContext { public string? CurrentSystem { get; set; } public ulong? CurrentSystemAddress { get; set; } public string? CurrentStation { get; set; } public string? ControllingFaction { get; set; } public bool IsOnFoot { get; set; } = false; public string? LastRecordedAwardingFaction { get; set; } public ulong? HighestCombatBond { get; set; } public bool HaveSeenCapShip { get; set; } = false; public bool HaveSeenCaptain { get; set; } = false; public bool HaveSeenSpecOps { get; set; } = false; public bool HaveSeenCorrespondent { get; set; } = false; /// /// Returns true if the current session is legacy /// public bool IsLegacy { get; set; } = false; /// /// How many on foot kills were done. /// public ulong OnFootKills { get; set; } = 0; /// /// How many ship kills were done. /// public ulong ShipKills { get; set; } = 0; /// /// A list of accepted missions index by their mission ID /// public Dictionary AcceptedMissions { get; } = new(); public Dictionary AcceptedMissionLocation { get; } = new(); /// /// A way to lookup a system by its system id /// public Dictionary SystemsByID { get; } = new(); /// /// A list of factions present in the given star system /// public Dictionary> SystemFactions { get; } = new(); /// /// To which faction a given named NPC belonged to. /// public Dictionary NPCFaction { get; } = new(); /// /// Buy costs for a given commodity /// public Dictionary BuyCost = new(); public void DiscernCombatZone(TransactionList transactions, Entry e) { string grade = "Low"; string cztype; ulong? highest = HighestCombatBond; if (highest == null || LastRecordedAwardingFaction == null) { return; } if (OnFootKills > 0) { cztype = "On Foot"; // High on foot combat zones have enforcers that bring 80k a pop if (highest >= 80000) { grade = "High"; } else if (highest >= 40000) { grade = "Medium"; } else { grade = "Low"; } } else if (ShipKills > 0) { // Ship combat zones can be identified by the amount of kills if (ShipKills > 20) { grade = "High"; } else if (ShipKills > 10) { grade = "Medium"; } // Cap ship, means a high conflict zone if (HaveSeenCapShip) { grade = "High"; } else { int warzoneNpcs = new List() { HaveSeenCaptain, HaveSeenCorrespondent, HaveSeenSpecOps } .Where(x => x == true) .Count() ; if (warzoneNpcs >= 2 && grade != "High") { // Only large combat zones have two NPCs grade = "High"; } else if (warzoneNpcs >= 1 && grade == "Low") { grade = "Medium"; } } cztype = "Ship"; } else { transactions.AddIncomplete(new CombatZone(), "Failed to discern combat zone type"); return; } CombatZone zone = new CombatZone() { System = CurrentSystem, Faction = LastRecordedAwardingFaction, IsLegacy = IsLegacy, Grade = grade, Type = cztype, // Sad truth is, if HaveSeenXXX is false, we just don't know for certain CapitalShip = HaveSeenCapShip ? true : null, SpecOps = HaveSeenSpecOps ? true : null, Correspondent = HaveSeenCorrespondent ? true : null, Captain = HaveSeenCaptain ? true : null, }; zone.Entries.Add(e); transactions.Add(zone); } public void RecordCombatBond(FactionKillBondEntry e) { if (HighestCombatBond == null || e.Reward > HighestCombatBond) { HighestCombatBond = e.Reward; } LastRecordedAwardingFaction = e.AwardingFaction; if (IsOnFoot) { ++OnFootKills; } else { ++ShipKills; } } public void ResetCombatZone() { HighestCombatBond = null; HaveSeenCapShip = false; HaveSeenCaptain = false; HaveSeenCorrespondent = false; HaveSeenSpecOps = false; LastRecordedAwardingFaction = null; OnFootKills = 0; ShipKills = 0; } public void BoughtCargo(string? cargo, long? cost) { if (cargo == null || cost == null) { return; } BuyCost[cargo] = cost.Value; } public List? GetFactions(string? system) { if (system == null || !SystemFactions.ContainsKey(system)) { return null; } return SystemFactions[system]; } public void MissionAccepted(MissionAcceptedEntry accepted) { if (CurrentSystem == null || CurrentSystemAddress == null) { throw new Exception("Mission accepted without knowing where."); } if (accepted.Mission == null) { throw new Exception("Mission is null"); } AcceptedMissions.TryAdd(accepted.Mission.MissionID, accepted); Location location = new() { StarSystem = CurrentSystem, SystemAddress = CurrentSystemAddress.Value, Station = (CurrentStation ?? ""), }; AcceptedMissionLocation.TryAdd(accepted.Mission.MissionID, location); } } public class TransactionList : List { public void AddIncomplete(Transaction underlying, string reason) { Add(new IncompleteTransaction(underlying, reason)); } } internal interface TransactionParserPart{ /// /// Parse a given entry of the entry type specified when declaring to implement this /// interface. You must add your transaction to the passed list in case you did have /// enough information to parse one or more. You may update the parser context /// with new information in case the entry yielded new information. /// Throw an exception if there was an error or a malformed entry. If you have an /// incomplete entry, i.e. not enough information to complete one, add an /// IncompleteTransaction to the list /// /// The entry to parse /// Parsing context that may contain useful information /// List of parsed transactions public void Parse(Entry entry, TransactionParserContext context, TransactionList transactions); } /// /// The location parser only updates the context with useful information, and does not /// by itself generate any transactions. Location is the best information gatherer here /// as we are getting controlling faction, system factions, address and station name. /// internal class LocationParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { LocationEntry? entry = e as LocationEntry; if (entry == null) { throw new NotImplementedException(); } if (entry.StarSystem == null) { throw new InvalidJournalEntryException(); } context.CurrentSystem = entry.StarSystem; context.CurrentSystemAddress = entry.SystemAddress; context.SystemsByID.TryAdd(entry.SystemAddress, entry.StarSystem); if (!string.IsNullOrEmpty(entry.SystemFaction)) { context.ControllingFaction = entry.SystemFaction; } if (!string.IsNullOrEmpty(entry.StationName)) { context.CurrentStation = entry.StationName; } if (!context.SystemFactions.ContainsKey(entry.StarSystem) && entry.SystemFactions != null && entry.SystemFactions.Count > 0) { context.SystemFactions[entry.StarSystem] = entry.SystemFactions; } } } internal class FSDJumpParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { FSDJumpEntry? entry = e as FSDJumpEntry; if (entry == null) { throw new NotImplementedException(); } if (entry.StarSystem == null) { throw new InvalidJournalEntryException(); } // If you FSD jump straight out of the combat zone into a different system // then the combat zone will be placed in the wrong system otherwise. // This call needs to be *before* changing the current system. context.DiscernCombatZone(transactions, e); context.ResetCombatZone(); context.CurrentSystem = entry.StarSystem; context.CurrentSystemAddress = entry.SystemAddress; context.SystemsByID.TryAdd(entry.SystemAddress, entry.StarSystem); if (!string.IsNullOrEmpty(entry.SystemFaction)) { context.ControllingFaction = entry.SystemFaction; } if (!context.SystemFactions.ContainsKey(entry.StarSystem) && entry.SystemFactions != null && entry.SystemFactions.Count > 0) { context.SystemFactions[entry.StarSystem] = entry.SystemFactions; } } } internal class DockedParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { DockedEntry? entry = e as DockedEntry; if (entry == null) { throw new NotImplementedException(); } if (entry.StarSystem == null || entry.SystemAddress == null) { throw new InvalidJournalEntryException(); } context.CurrentSystem = entry.StarSystem; context.CurrentSystemAddress = entry.SystemAddress; context.SystemsByID.TryAdd(entry.SystemAddress.Value, entry.StarSystem); if (!string.IsNullOrEmpty(entry.StationFaction)) { context.ControllingFaction = entry.StationFaction; } if (!string.IsNullOrEmpty(entry.StationName)) { context.CurrentStation = entry.StationName; } } } /// /// With ship targeted we might find out to which faction a given NPC belonged. This is /// useful later when said NPC gets killed or murdered. /// internal class ShipTargetedParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { ShipTargetedEntry? entry = e as ShipTargetedEntry; if (entry == null) { throw new NotImplementedException(); } // Scan happens in stages, and sometimes this information is not known // yet. Do now throw an error, this is expected behaviour. if (!string.IsNullOrEmpty(entry.PilotNameLocalised) && !string.IsNullOrEmpty(entry.Faction)) { context.NPCFaction.TryAdd(entry.PilotNameLocalised, entry.Faction); } // We have seen a captain? if (NPCs.IsWarzoneCaptain(entry.PilotName)) { context.HaveSeenCaptain = true; } // Spec ops? if (NPCs.IsSpecOps(entry.PilotName)) { context.HaveSeenSpecOps = true; } // Correspondent? if (NPCs.IsWarzoneCorrespondent(entry.PilotName)) { context.HaveSeenCorrespondent = true; } } } /// /// Commit crime can result in a transaction, especially if the crime committed is /// murder. /// internal class CommitCrimeParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { CommitCrimeEntry? entry = e as CommitCrimeEntry; if (entry == null) { throw new NotImplementedException(); } // Right now we only care for murder if (!entry.IsMurder) { return; } string? victim = entry.Victim; if (victim == null) { victim = entry.VictimLocalised; } // If they were not properly scanned prior to the murder we do not have // This information. But in the end the name of the NPC does not matter. if (victim == null) { victim = "Unknown"; } string faction; if (entry.IsCrime(CrimeTypes.OnFootMurder)) { if (entry.Faction == null) { transactions.AddIncomplete( new FoulMurder(), "On foot murder victim did not have a faction" ); return; } // On foot murders are different, there the faction is // the faction the NPC belonged too faction = entry.Faction; } else { if (!context.NPCFaction.ContainsKey(victim)) { transactions.AddIncomplete( new FoulMurder(), "Crime victim was not properly scanned." ); return; } faction = context.NPCFaction[victim]; } transactions.Add(new FoulMurder(entry) { System = context.CurrentSystem, IsLegacy = context.IsLegacy, Faction = faction, }); } } internal class MissionAcceptedParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { MissionAcceptedEntry? entry = e as MissionAcceptedEntry; if (entry == null) { throw new NotImplementedException(); } if (context.CurrentSystem == null || context.CurrentSystemAddress == null) { transactions.AddIncomplete(new MissionCompleted(), "Could not determine current location on mission acceptance." ); return; } try { context.MissionAccepted(entry); } catch (Exception exception) { transactions.AddIncomplete(new MissionCompleted(), exception.Message ); } } } internal class MissionCompletedParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { MissionCompletedEntry? entry = e as MissionCompletedEntry; if (entry == null || entry.Mission == null) { throw new NotImplementedException(); } MissionAcceptedEntry? accepted = null; Location? accepted_location = null; string? target_faction_name = entry.Mission.TargetFaction; string? source_faction_name = entry.Mission.Faction; // We did not find when the mission was accepted. if (!context.AcceptedMissions.TryGetValue(entry.Mission.MissionID, out accepted)) { transactions.AddIncomplete(new MissionCompleted(), String.Format("Mission acceptance for mission id {0} was not found", entry.Mission.MissionID)); return; } if (!context.AcceptedMissionLocation.TryGetValue(entry.Mission.MissionID, out accepted_location)) { transactions.AddIncomplete(new MissionCompleted(), String.Format("Location for acceptance for mission id {0} was not found", entry.Mission.MissionID)); return; } // This block does some preliminary "repairs" on the influences block of a completed // mission. Sometimes these entries are broken, or are missing information for later // parsing. foreach (var other in entry.Mission.Influences) { string faction = other.Key; if (string.IsNullOrEmpty(faction)) { // Target faction might be empty string, in special cases. For example if you // scan a surface installation, and the target faction of the surface installation // gets negative REP, but the surface installation is not owned by anyone. continue; } // Fun ahead: sometimes the influence list is empty for a faction entry. Here // we try to repair it. if (other.Value.Count == 0) { if (string.Compare(target_faction_name, faction, true) == 0) { if (context.CurrentSystemAddress == null) { continue; } other.Value.Add(context.CurrentSystemAddress.Value, ""); // Mission gave no influence to the target faction, so we assume // the target faction was in the same system. } else if (string.Compare(source_faction_name, faction, true) == 0) { // This happens if the source faction is not getting any influence // This could be if the source faction is in a conflict, and thus does // not gain any influence at all. other.Value.Add(accepted_location.SystemAddress, ""); // Just check if the target/source faction are the same, in which case // we also have to make an additional entry if (string.Compare(source_faction_name, target_faction_name, true) == 0 && context.CurrentSystemAddress != null) { other.Value.Add(context.CurrentSystemAddress.Value, ""); } } } // Now actually parse completed mission foreach (var influences in other.Value) { ulong system_address = influences.Key; string? system; if (!context.SystemsByID.TryGetValue(system_address, out system)) { transactions.AddIncomplete(new MissionCompleted(), string.Format("Unknown system {0}, unable to assign that mission a target", system_address) ); continue; } if (string.Compare(faction, source_faction_name, true) == 0 && system_address == accepted_location.SystemAddress) { // Source and target faction are the same, and this is the block // for the source system. So we make a full mission completed entry. transactions.Add(new MissionCompleted(entry) { System = accepted_location.StarSystem, Faction = source_faction_name, SystemAddress = accepted_location.SystemAddress, Station = accepted_location.Station, IsLegacy = context.IsLegacy, }); } else if (string.Compare(faction, source_faction_name, true) != 0 || (string.Compare(faction, source_faction_name) == 0 && system_address != accepted_location.SystemAddress)) { // Source or target faction are different, and/or the system // differs. Sometimes missions go to different systems but to // the same faction. transactions.Add(new InfluenceSupport() { Faction = faction, Influence = influences.Value, System = system, SystemAddress = system_address, RelevantMission = entry, IsLegacy = context.IsLegacy, }); } } } } } internal class MissionFailedParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { MissionAcceptedEntry? accepted = null; Location? accepted_location = null; string? accepted_system = null; MissionFailedEntry? entry = e as MissionFailedEntry; if (entry == null) { throw new NotImplementedException(); } if (entry.Mission == null) { throw new InvalidJournalEntryException("No mission specified in mission failure"); } if (!context.AcceptedMissions.TryGetValue(entry.Mission.MissionID, out accepted)) { transactions.AddIncomplete(new MissionFailed(), "Mission acceptance was not found" ); return; } if (!context.AcceptedMissionLocation.TryGetValue(entry.Mission.MissionID, out accepted_location)) { transactions.AddIncomplete(new MissionFailed(), "Unable to figure out where failed mission was accepted" ); return; } if (!context.SystemsByID.TryGetValue(accepted_location.SystemAddress, out accepted_system)) { transactions.AddIncomplete(new MissionFailed(), "Unable to figure out in which system failed mission was accepted" ); return; } transactions.Add(new MissionFailed() { Accepted = accepted, Faction = accepted.Mission?.Faction, Failed = entry, Station = accepted_location.Station, System = accepted_location.StarSystem, SystemAddress = accepted_location.SystemAddress, IsLegacy = context.IsLegacy, }); } } internal class SellExplorationDataParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { SellExplorationDataEntry? entry = e as SellExplorationDataEntry; if (entry == null) { throw new NotImplementedException(); } transactions.Add(new Cartographics(entry) { System = context.CurrentSystem, Station = context.CurrentStation, Faction = context.ControllingFaction, IsLegacy = context.IsLegacy, }); } } internal class SellOrganicDataParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { SellOrganicDataEntry? entry = e as SellOrganicDataEntry; if (entry == null) { throw new NotImplementedException(); } transactions.Add(new OrganicData(entry) { System = context.CurrentSystem, Station = context.CurrentStation, Faction = context.ControllingFaction, IsLegacy = context.IsLegacy, }); } } internal class MultiSellExplorationDataParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { MultiSellExplorationDataEntry? entry = e as MultiSellExplorationDataEntry; if (entry == null) { throw new NotImplementedException(); } transactions.Add(new Cartographics(entry) { System = context.CurrentSystem, Station = context.CurrentStation, Faction = context.ControllingFaction, IsLegacy = context.IsLegacy, }); } } internal class RedeemVoucherParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { RedeemVoucherEntry? entry = e as RedeemVoucherEntry; if (entry == null) { throw new NotImplementedException(); } if (context.CurrentSystem == null) { transactions.AddIncomplete(new Vouchers(), "Could not find out where the vouchers were redeemed" ); return; } List? current_factions = context.GetFactions(context.CurrentSystem); if (current_factions == null) { transactions.AddIncomplete(new Vouchers(), "Current system factions are unknown, so vouchers were ineffective"); } foreach (string faction in entry.Factions) { bool relevantBond = false; string relevantFaction = faction; if (string.Compare(faction, Factions.PilotsFederationVouchers) == 0) { // Target faction is pilots' federation, so we assume thargoid bonks // Also assign this combat bond to the Pilots Federation relevantFaction = Factions.PilotsFederation; relevantBond = true; } if (current_factions != null && !relevantBond) { // If we have local factions, and it ain't thargoid bonds see if the bonds were // useful in the current system if (current_factions.Find(x => string.Compare(x.Name, faction, true) == 0) != null) { relevantBond = true; } else { transactions.AddIncomplete(new Vouchers(), string.Format("Vouchers for {0} had no effect in {1} since said " + "faction is not present here", faction, context.CurrentSystem) ); } } if (relevantBond) { transactions.Add(new Vouchers(entry) { System = context.CurrentSystem, Station = context.CurrentStation, Faction = relevantFaction, ControllingFaction = context.ControllingFaction, IsLegacy = context.IsLegacy, }); } } } } internal class SellMicroResourcesParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { SellMicroResourcesEntry? entry = e as SellMicroResourcesEntry; if (entry == null) { throw new NotImplementedException(); } transactions.Add(new SellMicroResources(entry) { System = context.CurrentSystem, Station = context.CurrentStation, Faction = context.ControllingFaction, IsLegacy = context.IsLegacy, }); } } internal class SearchAndRescueParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { SearchAndRescueEntry? entry = e as SearchAndRescueEntry; if (entry == null) { throw new NotImplementedException(); } transactions.Add(new SearchAndRescue(entry) { Faction = context.ControllingFaction, Station = context.CurrentStation, System = context.CurrentSystem, IsLegacy = context.IsLegacy, }); } } internal class MarketBuyParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { MarketBuyEntry? entry = e as MarketBuyEntry; if (entry == null) { throw new NotImplementedException(); } context.BoughtCargo(entry.Type, entry.BuyPrice); transactions.Add(new BuyCargo(entry) { Faction = context.ControllingFaction, Station = context.CurrentStation, System = context.CurrentSystem, IsLegacy = context.IsLegacy, }); } } internal class MarketSellParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { long profit = 0; MarketSellEntry? entry = e as MarketSellEntry; if (entry == null) { throw new NotImplementedException(); } if (entry.Type == null) { throw new InvalidJournalEntryException("market sell contains no cargo type"); } if (context.BuyCost.ContainsKey(entry.Type)) { long avg = context.BuyCost[entry.Type]; profit = (long)entry.TotalSale - (avg * entry.Count); } transactions.Add(new SellCargo(entry) { Faction = context.ControllingFaction, Station = context.CurrentStation, System = context.CurrentSystem, Profit = profit, IsLegacy = context.IsLegacy, }); } } internal class FactionKillBondParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { FactionKillBondEntry? entry = e as FactionKillBondEntry; if (entry == null) { throw new NotImplementedException(); } if (Factions.IsThargoid(entry.VictimFaction)) { // Thargoid bonk transactions.Add(new ThargoidKill(entry) { System = context.CurrentSystem, Faction = Factions.PilotsFederation, Station = context.CurrentStation, IsLegacy = context.IsLegacy, }); // We are done return; } context.RecordCombatBond(entry); } } internal class EmbarkDisembarkParser : TransactionParserPart { public void Parse(Entry e, TransactionParserContext context, TransactionList transactions) { if (e.Is(Events.Embark)) { context.IsOnFoot = false; } else if (e.Is(Events.Disembark)) { context.IsOnFoot = true; } } } internal class SupercruiseEntryParser : TransactionParserPart { public void Parse(Entry entry, TransactionParserContext context, TransactionList transactions) { context.DiscernCombatZone(transactions, entry); context.ResetCombatZone(); } } internal class ShutdownParser : TransactionParserPart { public void Parse(Entry entry, TransactionParserContext context, TransactionList transactions) { context.DiscernCombatZone(transactions, entry); context.ResetCombatZone(); } } internal class CapShipBondParser : TransactionParserPart { public void Parse(Entry entry, TransactionParserContext context, TransactionList transactions) { if (entry.GetType() != typeof(CapShipBondEntry)) { return; } context.HaveSeenCapShip = true; } } internal class FileHeaderParser : TransactionParserPart { public void Parse(Entry entry, TransactionParserContext context, TransactionList transactions) { FileHeaderEntry? fileheader = entry as FileHeaderEntry; if (fileheader == null) { return; } context.IsLegacy = fileheader.IsLegacy; } } public class TransactionParser { private static Dictionary ParserParts { get; } = new() { { Events.CapShipBond, new CapShipBondParser() }, { Events.CommitCrime, new CommitCrimeParser() }, { Events.Disembark, new EmbarkDisembarkParser() }, { Events.Docked, new DockedParser() }, { Events.Embark, new EmbarkDisembarkParser() }, { Events.FactionKillBond, new FactionKillBondParser() }, { Events.FileHeader, new FileHeaderParser() }, { Events.FSDJump, new FSDJumpParser() }, { Events.Location, new LocationParser() }, { Events.MarketBuy, new MarketBuyParser() }, { Events.MarketSell, new MarketSellParser() }, { Events.MissionAccepted, new MissionAcceptedParser() }, { Events.MissionCompleted, new MissionCompletedParser() }, { Events.MissionFailed, new MissionFailedParser() }, { Events.MultiSellExplorationData, new MultiSellExplorationDataParser() }, { Events.RedeemVoucher, new RedeemVoucherParser() }, { Events.SearchAndRescue, new SearchAndRescueParser() }, { Events.SellExplorationData, new SellExplorationDataParser() }, { Events.SellMicroResources, new SellMicroResourcesParser() }, { Events.SellOrganicData, new SellOrganicDataParser() }, { Events.ShipTargeted, new ShipTargetedParser() }, { Events.Shutdown, new ShutdownParser() }, { Events.SupercruiseEntry, new SupercruiseEntryParser() }, }; public bool IsRelevant(string entry) { return ParserParts.ContainsKey(entry); } public bool IsRelevant(Entry e) { if (e.Event == null) { return false; } return IsRelevant(e.Event); } public List? Parse(IEnumerable entries) { TransactionList transactions = new(); TransactionParserContext context = new(); foreach (Entry entry in entries) { if (entry.Event == null) { throw new InvalidJournalEntryException(); } if (!ParserParts.ContainsKey(entry.Event)) { continue; } TransactionParserPart transactionParserPart = ParserParts[entry.Event]; transactionParserPart.Parse(entry, context, transactions); } return transactions.ToList(); } }