using EDPlayerJournal.Entries; using System.Collections.Generic; using System.Reflection.Metadata.Ecma335; 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; } /// /// 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 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(); } 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); } } } /// /// 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"; } if (!context.NPCFaction.ContainsKey(victim)) { transactions.AddIncomplete( new FoulMurder(), "Crime victim was not properly scanned." ); return; } string faction = context.NPCFaction[victim]; transactions.Add(new FoulMurder(entry) { System = context.CurrentSystem, 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) { throw new NotImplementedException(); } if (entry.MissionID == null) { throw new InvalidJournalEntryException("mission completed has no mission ID"); } MissionAcceptedEntry? accepted = null; Location? accepted_location = null; string? target_faction_name = entry.TargetFaction; string? source_faction_name = entry.Faction; // We did not find when the mission was accepted. if (!context.AcceptedMissions.TryGetValue(entry.MissionID.Value, out accepted)) { transactions.AddIncomplete(new MissionCompleted(), String.Format("Mission acceptance for mission id {0} was not found", entry.MissionID.Value)); return; } if (!context.AcceptedMissionLocation.TryGetValue(entry.MissionID.Value, out accepted_location)) { transactions.AddIncomplete(new MissionCompleted(), String.Format("Location for acceptance for mission id {0} was not found", entry.MissionID.Value)); 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.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, }); } 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, }); } } } } } 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 (!context.AcceptedMissions.TryGetValue(entry.MissionID, out accepted)) { transactions.AddIncomplete(new MissionFailed(), "Mission acceptance was not found" ); return; } if (!context.AcceptedMissionLocation.TryGetValue(entry.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, }); } } 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, }); } } 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, }); } } 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, }); } } 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"); return; } foreach (string faction in entry.Factions) { if (current_factions.Find(x => string.Compare(x.Name, faction, true) == 0) == null) { transactions.AddIncomplete(new Vouchers(), string.Format("Vouchers for {0} were ignored in {1} since said " + "faction is not present here", faction, context.CurrentSystem) ); continue; } transactions.Add(new Vouchers(entry) { System = context.CurrentSystem, Station = context.CurrentStation, Faction = faction, ControllingFaction = context.ControllingFaction, }); } } } 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, }); } } 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, }); } } 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, }); } } 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, }); } } public class TransactionParser { private static Dictionary ParserParts { get; } = new() { { Events.CommitCrime, new CommitCrimeParser() }, { Events.Docked, new DockedParser() }, { 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() }, }; 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(); } }