using Archipelago.HollowKnight.IC.Tags; using Archipelago.MultiClient.Net; using Archipelago.MultiClient.Net.Exceptions; using Archipelago.MultiClient.Net.Models; using ItemChanger; using ItemChanger.Internal; using ItemChanger.Modules; using ItemChanger.Tags; using ItemChanger.Items; using MenuChanger; using Modding; using Newtonsoft.Json; using System; using System.Linq; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Threading.Tasks; namespace Archipelago.HollowKnight.IC.Modules { /// /// Handles the sending and receiving of items from the server /// public class ItemNetworkingModule : Module { /// /// A preset GiveInfo structure that avoids creating geo and places messages in the corner. /// public static GiveInfo RemoteGiveInfo = new() { FlingType = FlingType.DirectDeposit, Callback = null, Container = Container.Unknown, MessageType = MessageType.Corner }; /// /// A preset GiveInfo structure that avoids creating geo and outputs no messages, e.g. for Start Items. /// public static GiveInfo SilentGiveInfo = new() { FlingType = FlingType.DirectDeposit, Callback = null, Container = Container.Unknown, MessageType = MessageType.None }; private ArchipelagoSession session => ArchipelagoMod.Instance.session; private bool networkErrored; private bool readyToSendReceiveChecks; [JsonProperty] private List deferredLocationChecks = []; [JsonProperty] private bool hasEverRecievedStartingGeo = false; public override void Initialize() { networkErrored = false; readyToSendReceiveChecks = false; ModHooks.HeroUpdateHook += PollForItems; On.GameManager.FinishedEnteringScene += DoInitialSyncAndStartSendReceive; } public override void Unload() { // DoInitialSyncAndStartSendReceive unsubscribes itself ModHooks.HeroUpdateHook -= PollForItems; session.Locations.CheckedLocationsUpdated -= OnLocationChecksUpdated; } public async Task SendLocationsAsync(params long[] locationIds) { if (!readyToSendReceiveChecks) { deferredLocationChecks.AddRange(locationIds); return; } if (networkErrored) { deferredLocationChecks.AddRange(locationIds); ReportDisconnect(); return; } try { await Task.Run(() => session.Locations.CompleteLocationChecks(locationIds)).TimeoutAfter(1000); } catch (Exception ex) when (ex is TimeoutException or ArchipelagoSocketClosedException) { ArchipelagoMod.Instance.LogWarn("SendLocationsAsync disconnected"); deferredLocationChecks.AddRange(locationIds); ReportDisconnect(); } catch (Exception ex) { ArchipelagoMod.Instance.LogError("Unexpected error in SendLocationsAsync"); ArchipelagoMod.Instance.LogError(ex); deferredLocationChecks.AddRange(locationIds); ReportDisconnect(); } } public void MarkLocationAsChecked(long locationId, bool silentGive) { // Called when marking a location as checked remotely (i.e. through ReceiveItem, etc.) // This also grants items at said locations. bool hadNewlyObtainedItems = false; bool hadUnobtainedItems = false; ArchipelagoMod.Instance.LogDebug($"Marking location {locationId} as checked."); if (!ArchipelagoPlacementTag.PlacementsByLocationId.TryGetValue(locationId, out AbstractPlacement pmt)) { ArchipelagoMod.Instance.LogDebug($"Could not find a placement for location {locationId}"); return; } foreach (AbstractItem item in pmt.Items) { if (!item.GetTag(out ArchipelagoItemTag tag)) { hadUnobtainedItems = true; continue; } if (item.WasEverObtained()) { continue; } if (tag.Location != locationId) { hadUnobtainedItems = true; continue; } hadNewlyObtainedItems = true; pmt.AddVisitFlag(VisitState.ObtainedAnyItem); GiveInfo giveInfo = silentGive ? SilentGiveInfo : RemoteGiveInfo; item.Give(pmt, giveInfo.Clone()); } if (hadNewlyObtainedItems && !hadUnobtainedItems) { pmt.AddVisitFlag(VisitState.Opened | VisitState.Dropped | VisitState.Accepted | VisitState.ObtainedAnyItem); } } private async void DoInitialSyncAndStartSendReceive(On.GameManager.orig_FinishedEnteringScene orig, GameManager self) { orig(self); if (!readyToSendReceiveChecks) { On.GameManager.FinishedEnteringScene -= DoInitialSyncAndStartSendReceive; if (!hasEverRecievedStartingGeo) { HeroController.instance.AddGeo(ArchipelagoMod.Instance.SlotData.Options.StartingGeo); hasEverRecievedStartingGeo = true; } readyToSendReceiveChecks = true; await Synchronize(); session.Locations.CheckedLocationsUpdated += OnLocationChecksUpdated; } } private void OnLocationChecksUpdated(ReadOnlyCollection newCheckedLocations) { foreach (long location in newCheckedLocations) { ThreadSupport.BeginInvoke(() => MarkLocationAsChecked(location, false)); } } private void PollForItems() { if (!readyToSendReceiveChecks || !session.Items.Any()) { return; } ReceiveNextItem(false); } private async Task Synchronize() { // receive from the server any items that are pending while (ReceiveNextItem(true)) { } // ensure any already-checked locations (co-op, restarting save) are marked cleared foreach (long location in session.Locations.AllLocationsChecked) { MarkLocationAsChecked(location, true); } // send out any pending items that didn't get to the network from the previous session long[] pendingLocations = deferredLocationChecks.ToArray(); deferredLocationChecks.Clear(); await SendLocationsAsync(pendingLocations); } private bool ReceiveNextItem(bool silentGive) { if (!session.Items.Any()) { return false; // No items are waiting. } ItemInfo itemInfo = session.Items.DequeueItem(); // Read the next item try { ReceiveItem(itemInfo, silentGive); } catch (Exception ex) { ArchipelagoMod.Instance.LogError($"Unexpected exception during receive for item {JsonConvert.SerializeObject(itemInfo.ToSerializable())}: {ex}"); } return true; } private void ReceiveItem(ItemInfo itemInfo, bool silentGive) { string name = itemInfo.ItemName; ArchipelagoMod.Instance.LogDebug($"Receiving item {itemInfo.ItemId} with name {name}. " + $"Slot is {itemInfo.Player}. Location is {itemInfo.LocationId} with name {itemInfo.LocationName}"); ArchipelagoRemoteItemCounterModule remoteTracker = ItemChangerMod.Modules.GetOrAdd(); bool shouldReceive = remoteTracker.ShouldReceiveServerItem(itemInfo); remoteTracker.IncrementServerCountForItem(itemInfo); ArchipelagoMod.Instance.LogDebug($"shouldRecieve has value {shouldReceive}"); ArchipelagoMod.Instance.LogDebug($"ActivePlayer.Slot is {ArchipelagoMod.Instance.session.Players.ActivePlayer.Slot}"); // If we have received a spell, we should unlock a dreamer as well with that setting enabled if(ArchipelagoMod.Instance.SlotData.Options.AltBlackEgg) { string[] spellNames = ["Vengeful_Spirit", "Shade_Soul", "Desolate_Dive", "Descending_Dark", "Howling_Wraiths", "Abyss_Shriek"]; if (spellNames.Any(name.Contains)) { if (shouldReceive || (itemInfo.Player == ArchipelagoMod.Instance.session.Players.ActivePlayer.Slot && itemInfo.LocationId > 0)) { DreamerItem dreamItem = new DreamerItem(); GiveInfo dreamInfo = silentGive ? SilentGiveInfo : RemoteGiveInfo; dreamItem.GiveImmediate(dreamInfo.Clone()); } } } // If we have received enough mask shards to gain a Mask, we should unlock a White Fragment as well with that setting enabled if(ArchipelagoMod.Instance.SlotData.Options.AltRadiance) { string[] shardNames = ["Mask_Shard", "Double_Mask_Shard", "Full_Mask"]; if (shardNames.Any(name.Contains)) { if (shouldReceive || (itemInfo.Player == ArchipelagoMod.Instance.session.Players.ActivePlayer.Slot && itemInfo.LocationId > 0)) { // Getting our current player instance PlayerData pd = PlayerData.instance; // Counting our current heart pieces int heartPieces = pd.GetInt(nameof(PlayerData.heartPieces)); ArchipelagoMod.Instance.LogDebug($"Current heartPieces value: {heartPieces}"); int heartIndex = Array.IndexOf(shardNames, name); if ((itemInfo.Player == ArchipelagoMod.Instance.session.Players.ActivePlayer.Slot && itemInfo.LocationId > 0)) { heartPieces = (heartPieces + 3) % 4; //local mask shards get applied before this function so we account for that here } switch (heartIndex) { case 0: heartPieces += 1; break; case 1: heartPieces += 2; break; case 2: heartPieces += 4; break; } ArchipelagoMod.Instance.LogDebug($"Post addition heartPieces value: {heartPieces}"); // If we have over 3 heart pieces we get a White Fragment if ( heartPieces >= 4) { WhiteFragmentItem fragmentItem = new WhiteFragmentItem(); int royalCharmLevel = pd.GetInt(nameof(PlayerData.royalCharmState)); fragmentItem.royalCharmLevel = 2; switch (royalCharmLevel) { case 1: case 2: fragmentItem.royalCharmLevel = 3; break; case 3: fragmentItem.royalCharmLevel = 4; break; } GiveInfo fragmentInfo = silentGive ? SilentGiveInfo : RemoteGiveInfo; fragmentItem.GiveImmediate(fragmentInfo.Clone()); } } } } if (itemInfo.Player == ArchipelagoMod.Instance.session.Players.ActivePlayer.Slot && itemInfo.LocationId > 0) { MarkLocationAsChecked(itemInfo.LocationId, silentGive); return; } if (!shouldReceive) { ArchipelagoMod.Instance.LogDebug($"Fast-forwarding past already received item {name} from {itemInfo.Player} at location {itemInfo.LocationDisplayName} ({itemInfo.LocationId})"); return; } // If we're still here, this is an item from someone else. We'll make up our own dummy placement and grant the item. AbstractItem item = Finder.GetItem(name); if (item == null) { ArchipelagoMod.Instance.LogWarn($"Could not find an item named '{name}'. " + $"This means that item {itemInfo.ItemId} was not received."); return; } ArchipelagoRemoteItemTag remoteItemTag = new(itemInfo); item.AddTag(remoteItemTag); string sender; if (itemInfo.LocationId == -1) { sender = "Cheat Console"; } else if (itemInfo.LocationId == -2) { sender = "Start"; } else if (itemInfo.Player == 0) { sender = "Archipelago"; } else { sender = session.Players.GetPlayerName(itemInfo.Player); } InteropTag recentItemsTag = item.AddTag(); recentItemsTag.Message = "RecentItems"; recentItemsTag.Properties["DisplaySource"] = sender; RemotePlacement pmt = RemotePlacement.GetOrAddSingleton(); item.Load(); pmt.Add(item); GiveInfo giveInfo = silentGive ? SilentGiveInfo : RemoteGiveInfo; item.Give(pmt, giveInfo.Clone()); } public void ReportDisconnect() { networkErrored = true; MessageController.Enqueue( null, "Error: Lost connection to Archipelago server" ); MessageController.Enqueue( null, "Reload your save to attempt to reconnect." ); } } }