diff --git a/.editorconfig b/.editorconfig index 60fa5122..9bf17a4b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -62,11 +62,11 @@ resharper_web_config_module_not_resolved_highlighting = warning resharper_web_config_type_not_resolved_highlighting = warning resharper_web_config_wrong_module_highlighting = warning -[{*.har,*.inputactions,*.jsb2,*.jsb3,*.json,*.yml,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] +[{*.har,*.inputactions,*.jsb2,*.jsb3,*.yml,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] indent_style = space indent_size = 2 -[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}] +[*.{appxmanifest,asax,ascx,aspx,axaml,build,c,c++,cc,cginc,compute,cp,cpp,cs,cshtml,cu,cuh,cxx,dtd,fs,fsi,fsscript,fsx,fx,fxh,h,hh,hlsl,hlsli,hlslinc,hpp,hxx,inc,inl,ino,ipp,json,master,ml,mli,mpp,mq4,mq5,mqh,nuspec,paml,razor,resw,resx,shader,skin,tpp,usf,ush,vb,xaml,xamlx,xoml,xsd}] indent_style = space indent_size = 4 tab_width = 4 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index db22e786..6300b636 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -26,7 +26,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} - name: Download dependencies - run: wget https://files.catbox.moe/r5p6rr.gpg -O deps.zip.gpg + run: wget https://files.catbox.moe/t156ho.gpg -O deps.zip.gpg - name: Decrypt dependencies run: gpg --quiet --batch --yes --decrypt --passphrase="${{ secrets.DEPENDENCIES_ZIP_PASSPHRASE }}" --output deps.zip deps.zip.gpg @@ -44,7 +44,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v5 with: - dotnet-version: 6.0.x + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore ${{ github.workspace }} @@ -60,6 +60,7 @@ jobs: ${{ github.workspace }}/HKMP/bin/Release/net472/HKMP.dll ${{ github.workspace }}/HKMP/bin/Release/net472/HKMP.xml ${{ github.workspace }}/HKMP/bin/Release/net472/HKMP.pdb + ${{ github.workspace }}/HKMP/bin/Release/net472/BouncyCastle.Cryptography.dll - name: Upload HKMPServer artifact uses: actions/upload-artifact@v4 @@ -68,3 +69,7 @@ jobs: path: | ${{ github.workspace }}/HKMPServer/bin/Release/net472/HKMPServer.exe ${{ github.workspace }}/HKMPServer/bin/Release/net472/HKMPServer.pdb + ${{ github.workspace }}/HKMPServer/bin/Release/net472/HKMP.dll + ${{ github.workspace }}/HKMPServer/bin/Release/net472/HKMP.pdb + ${{ github.workspace }}/HKMPServer/bin/Release/net472/Newtonsoft.Json.dll + ${{ github.workspace }}/HKMPServer/bin/Release/net472/BouncyCastle.Cryptography.dll diff --git a/HKMP/Animation/AnimationEffect.cs b/HKMP/Animation/AnimationEffect.cs index c430c28c..2fb04664 100644 --- a/HKMP/Animation/AnimationEffect.cs +++ b/HKMP/Animation/AnimationEffect.cs @@ -26,21 +26,19 @@ public void SetServerSettings(ServerSettings serverSettings) { } /// - /// Locate the damages_enemy FSM and change the attack type to generic. This will avoid the local - /// player taking knock back from remote players hitting shields etc. + /// Locate the damages_enemy FSM and change the attack direction to the given direciton. This will ensure that + /// enemies are getting knocked back in the correct direction from remote player's attacks. /// /// The target GameObject to change. - protected static void ChangeAttackTypeOfFsm(GameObject targetObject) { + /// The direction in float that the damage is coming from. + protected static void ChangeAttackDirection(GameObject targetObject, float direction) { var damageFsm = targetObject.LocateMyFSM("damages_enemy"); if (damageFsm == null) { return; } - - var takeDamage = damageFsm.GetFirstAction("Send Event"); - takeDamage.AttackType.Value = (int) AttackTypes.Generic; - takeDamage = damageFsm.GetFirstAction("Parent"); - takeDamage.AttackType.Value = (int) AttackTypes.Generic; - takeDamage = damageFsm.GetFirstAction("Grandparent"); - takeDamage.AttackType.Value = (int) AttackTypes.Generic; + + // Find the variable that controls the slash direction for damaging enemies + var directionVar = damageFsm.FsmVariables.GetFsmFloat("direction"); + directionVar.Value = direction; } } diff --git a/HKMP/Animation/AnimationManager.cs b/HKMP/Animation/AnimationManager.cs index 105968b7..37949d58 100644 --- a/HKMP/Animation/AnimationManager.cs +++ b/HKMP/Animation/AnimationManager.cs @@ -10,7 +10,6 @@ using Hkmp.Game.Client; using Hkmp.Game.Settings; using Hkmp.Networking.Client; -using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; using Hkmp.Util; using HutongGames.PlayMaker.Actions; @@ -18,6 +17,8 @@ using UnityEngine; using UnityEngine.SceneManagement; using Logger = Hkmp.Logging.Logger; +using Object = UnityEngine.Object; +using Random = UnityEngine.Random; namespace Hkmp.Animation; @@ -33,14 +34,14 @@ internal class AnimationManager { /// /// Animations that are allowed to loop, because they need to transmit the effect. /// - private static readonly string[] AllowedLoopAnimations = { "Focus Get", "Run" }; + private static readonly string[] AllowedLoopAnimations = ["Focus Get", "Run"]; /// /// Clip names of animations that are handled by the animation controller. /// - private static readonly string[] AnimationControllerClipNames = { + private static readonly string[] AnimationControllerClipNames = [ "Airborne" - }; + ]; /// /// The animation effect for cancelling the Crystal Dash Charge. Stored since it needs to be called @@ -406,20 +407,29 @@ internal class AnimationManager { public AnimationManager( NetClient netClient, - PlayerManager playerManager, - PacketManager packetManager, - ServerSettings serverSettings + PlayerManager playerManager ) { _netClient = netClient; _playerManager = playerManager; _chargedEffectStopwatch = new Stopwatch(); _chargedEndEffectStopwatch = new Stopwatch(); + } - // Register packet handler - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerDeath, - OnPlayerDeath); + /// + /// Initialize the animation manager by registering packet handlers and initializing animation effects. + /// + public void Initialize(ServerSettings serverSettings) { + // Set the server settings for all animation effects + foreach (var effect in AnimationEffects.Values) { + effect.SetServerSettings(serverSettings); + } + } + /// + /// Register the game hooks for the animation manager. + /// + public void RegisterHooks() { // Register scene change, which is where we update the animation event handler UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChange; @@ -443,18 +453,39 @@ ServerSettings serverSettings On.GameManager.HazardRespawn += GameManagerOnHazardRespawn; // Register when the HeroController starts, so we can register dung trail events - On.HeroController.Start += HeroControllerOnStart; + CustomHooks.HeroControllerStartAction += HeroControllerOnStart; // Relinquish Control cancels a lot of effects, so we need to broadcast the end of these effects On.HeroController.RelinquishControl += HeroControllerOnRelinquishControl; // Register when the player dies to send the animation ModHooks.BeforePlayerDeadHook += OnDeath; + } - // Set the server settings for all animation effects - foreach (var effect in AnimationEffects.Values) { - effect.SetServerSettings(serverSettings); - } + /// + /// Deregister the game hooks for the animation manager. + /// + public void DeregisterHooks() { + UnityEngine.SceneManagement.SceneManager.activeSceneChanged -= OnSceneChange; + + On.HeroAnimationController.Play -= HeroAnimationControllerOnPlay; + On.HeroAnimationController.PlayFromFrame -= HeroAnimationControllerOnPlayFromFrame; + + On.tk2dSpriteAnimator.WarpClipToLocalTime -= Tk2dSpriteAnimatorOnWarpClipToLocalTime; + On.tk2dSpriteAnimator.ProcessEvents -= Tk2dSpriteAnimatorOnProcessEvents; + + On.HeroController.CancelDash -= HeroControllerOnCancelDash; + + ModHooks.HeroUpdateHook -= OnHeroUpdateHook; + + On.HeroController.DieFromHazard -= HeroControllerOnDieFromHazard; + On.GameManager.HazardRespawn -= GameManagerOnHazardRespawn; + + CustomHooks.HeroControllerStartAction -= HeroControllerOnStart; + + On.HeroController.RelinquishControl -= HeroControllerOnRelinquishControl; + + ModHooks.BeforePlayerDeadHook -= OnDeath; } /// @@ -470,15 +501,13 @@ public void OnPlayerAnimationUpdate(ushort id, int clipId, int frame, bool[] eff var animationClip = (AnimationClip) clipId; - if (AnimationEffects.ContainsKey(animationClip)) { + if (AnimationEffects.TryGetValue(animationClip, out var animationEffect)) { var playerObject = _playerManager.GetPlayerObject(id); - if (playerObject == null) { + if (!playerObject) { // Logger.Get().Warn(this, $"Tried to play animation effect {clipName} with ID: {id}, but player object doesn't exist"); return; } - var animationEffect = AnimationEffects[animationClip]; - // Check if the animation effect is a DamageAnimationEffect and if so, // set whether it should deal damage based on player teams if (animationEffect is DamageAnimationEffect damageAnimationEffect) { @@ -507,7 +536,7 @@ public void OnPlayerAnimationUpdate(ushort id, int clipId, int frame, bool[] eff /// The frame that the animation should play from. public void UpdatePlayerAnimation(ushort id, int clipId, int frame) { var playerObject = _playerManager.GetPlayerObject(id); - if (playerObject == null) { + if (!playerObject) { // Logger.Get().Warn(this, $"Tried to update animation, but there was not matching player object for ID {id}"); return; } @@ -615,8 +644,8 @@ private void OnAnimationEvent(tk2dSpriteAnimationClip clip) { var animationClip = ClipEnumNames[clip.name]; // Check whether there is an effect that adds info to this packet - if (AnimationEffects.ContainsKey(animationClip)) { - var effectInfo = AnimationEffects[animationClip].GetEffectInfo(); + if (AnimationEffects.TryGetValue(animationClip, out var effect)) { + var effectInfo = effect.GetEffectInfo(); _netClient.UpdateManager.UpdatePlayerAnimation(animationClip, 0, effectInfo); } else { @@ -783,7 +812,7 @@ float time orig(self, clip, time); var localPlayer = HeroController.instance; - if (localPlayer == null) { + if (!localPlayer) { return; } @@ -820,7 +849,7 @@ int direction orig(self, start, last, direction); var localPlayer = HeroController.instance; - if (localPlayer == null) { + if (!localPlayer) { return; } @@ -862,10 +891,10 @@ private IEnumerator HeroControllerOnDieFromHazard(On.HeroController.orig_DieFrom return orig(self, hazardType, angle); } - _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.HazardDeath, 0, new[] { + _netClient.UpdateManager.UpdatePlayerAnimation(AnimationClip.HazardDeath, 0, [ hazardType.Equals(HazardType.SPIKES), hazardType.Equals(HazardType.ACID) - }); + ]); // Execute the original method and return its value return orig(self, hazardType, angle); @@ -891,7 +920,7 @@ private void GameManagerOnHazardRespawn(On.GameManager.orig_HazardRespawn orig, /// Callback method for when a player death is received. /// /// The generic client data for this event. - private void OnPlayerDeath(GenericClientData data) { + public void OnPlayerDeath(GenericClientData data) { // And play the death animation for the ID in the packet MonoBehaviourUtil.Instance.StartCoroutine(PlayDeathAnimation(data.Id)); } @@ -1004,12 +1033,7 @@ private IEnumerator PlayDeathAnimation(ushort id) { /// /// Callback method on the HeroController#Start method. /// - /// The original method. - /// The HeroController instance. - private void HeroControllerOnStart(On.HeroController.orig_Start orig, HeroController self) { - // Execute original method - orig(self); - + private void HeroControllerOnStart() { SetDescendingDarkLandEffectDelay(); RegisterDefenderCrestEffects(); } @@ -1061,12 +1085,12 @@ private void SetDescendingDarkLandEffectDelay() { /// private void RegisterDefenderCrestEffects() { var charmEffects = HeroController.instance.gameObject.FindGameObjectInChildren("Charm Effects"); - if (charmEffects == null) { + if (!charmEffects) { return; } var dungObject = charmEffects.FindGameObjectInChildren("Dung"); - if (dungObject == null) { + if (!dungObject) { return; } diff --git a/HKMP/Animation/Effects/CycloneSlash.cs b/HKMP/Animation/Effects/CycloneSlash.cs index ca386c16..1765ee1e 100644 --- a/HKMP/Animation/Effects/CycloneSlash.cs +++ b/HKMP/Animation/Effects/CycloneSlash.cs @@ -47,13 +47,13 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { cycloneObj, playerAttacks.transform ); - cycloneSlash.layer = 22; + cycloneSlash.layer = 17; var hitLComponent = cycloneSlash.FindGameObjectInChildren("Hit L"); - ChangeAttackTypeOfFsm(hitLComponent); + ChangeAttackDirection(hitLComponent, 0f); var hitRComponent = cycloneSlash.FindGameObjectInChildren("Hit R"); - ChangeAttackTypeOfFsm(hitRComponent); + ChangeAttackDirection(hitRComponent, 180f); cycloneSlash.SetActive(true); diff --git a/HKMP/Animation/Effects/DashBase.cs b/HKMP/Animation/Effects/DashBase.cs index 4b4ef1bf..ef7e1ea6 100644 --- a/HKMP/Animation/Effects/DashBase.cs +++ b/HKMP/Animation/Effects/DashBase.cs @@ -124,20 +124,38 @@ protected void Play(GameObject playerObject, bool[] effectInfo, bool shadowDash, // Lastly, disable the player collider, since we are in a shadow dash // We only do this, if we don't have sharp shadow playerObject.GetComponent().enabled = false; - } else if ( - !ServerSettings.IsBodyDamageEnabled && - ServerSettings.IsPvpEnabled && - ShouldDoDamage && - damage != 0 - ) { - // If body damage is disabled, but PvP is enabled and we are performing a sharp shadow dash - // we need to enable the DamageHero component and move the player object to the correct layer - // to allow the local player to collide with it - playerObject.layer = 11; - - var damageHero = playerObject.GetComponent(); - damageHero.enabled = true; - damageHero.damageDealt = damage; + } else { + var localPlayerAttacks = HeroController.instance.gameObject.FindGameObjectInChildren("Attacks"); + var playerAttacks = playerObject.FindGameObjectInChildren("Attacks"); + + var sharpShadowPrefab = localPlayerAttacks.FindGameObjectInChildren("Sharp Shadow"); + + var sharpShadowObject = Object.Instantiate( + sharpShadowPrefab, + playerAttacks.transform + ); + sharpShadowObject.name = "Sharp Shadow"; + sharpShadowObject.SetActive(true); + sharpShadowObject.layer = 17; + + if ( + !ServerSettings.IsBodyDamageEnabled && + ServerSettings.IsPvpEnabled && + ShouldDoDamage && + damage != 0 + ) { + // If body damage is disabled, but PvP is enabled and we are performing a sharp shadow dash + // we need to enable the DamageHero component and move the player object to the correct layer + // to allow the local player to collide with it + playerObject.layer = 11; + + var damageHero = playerObject.GetComponent(); + damageHero.enabled = true; + damageHero.damageDealt = damage; + } + + // As a failsafe, we remove the sharp shadow object again on a timer + Object.Destroy(sharpShadowObject, 1f); } } else { // Instantiate the dash burst relative to the player effects diff --git a/HKMP/Animation/Effects/DashEnd.cs b/HKMP/Animation/Effects/DashEnd.cs index 83df0388..e2dfc1a1 100644 --- a/HKMP/Animation/Effects/DashEnd.cs +++ b/HKMP/Animation/Effects/DashEnd.cs @@ -22,24 +22,30 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { } var playerEffects = playerObject.FindGameObjectInChildren("Effects"); - if (playerEffects == null) { - return; - } - - var dashParticles = playerEffects.FindGameObjectInChildren("Dash Particles"); - if (dashParticles != null) { + if (playerEffects != null) { + var dashParticles = playerEffects.FindGameObjectInChildren("Dash Particles"); + if (dashParticles != null) { #pragma warning disable 0618 - // Disable emission - dashParticles.GetComponent().enableEmission = false; + // Disable emission + dashParticles.GetComponent().enableEmission = false; #pragma warning restore 0618 - } + } - var shadowDashParticles = playerEffects.FindGameObjectInChildren("Shadow Dash Particles"); - if (shadowDashParticles != null) { + var shadowDashParticles = playerEffects.FindGameObjectInChildren("Shadow Dash Particles"); + if (shadowDashParticles != null) { #pragma warning disable 0618 - // Disable emission - shadowDashParticles.GetComponent().enableEmission = false; + // Disable emission + shadowDashParticles.GetComponent().enableEmission = false; #pragma warning restore 0618 + } + } + + var playerAttacks = playerObject.FindGameObjectInChildren("Attacks"); + if (playerAttacks != null) { + var sharpShadow = playerAttacks.FindGameObjectInChildren("Sharp Shadow"); + if (sharpShadow != null) { + Object.Destroy(sharpShadow); + } } } diff --git a/HKMP/Animation/Effects/DashSlash.cs b/HKMP/Animation/Effects/DashSlash.cs index 5e644f77..c1526913 100644 --- a/HKMP/Animation/Effects/DashSlash.cs +++ b/HKMP/Animation/Effects/DashSlash.cs @@ -35,21 +35,36 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { dashSlashObject, playerObject.transform.parent ); + dashSlash.layer = 17; // Since we anchor the dash slash on the player container instead of the player object // (to prevent it from flipping when the knight turns around) we need to adjust the scale based // on which direction the knight is facing + var localScale = playerObject.transform.localScale; + var playerScaleX = localScale.x; var dashSlashTransform = dashSlash.transform; var dashSlashScale = dashSlashTransform.localScale; dashSlashTransform.localScale = new Vector3( - dashSlashScale.x * playerObject.transform.localScale.x, + dashSlashScale.x * playerScaleX, dashSlashScale.y, dashSlashScale.z ); - dashSlash.layer = 22; - - ChangeAttackTypeOfFsm(dashSlash); + // Check which direction the knight is facing for the damages_enemy FSM + var facingRight = playerScaleX > 0; + ChangeAttackDirection(dashSlash, facingRight ? 180f : 0f); + + var controlColliderFsm = dashSlash.LocateMyFSM("Control Collider"); + // If the player is not facing right, the local position set by the FSM is not right given that we are spawning + // the dash slash on the player container instead of on the player object + if (!facingRight) { + var startPosVar = controlColliderFsm.FsmVariables.GetFsmVector3("Start Pos"); + startPosVar.Value = new Vector3( + startPosVar.Value.x * -1, + startPosVar.Value.y, + startPosVar.Value.z + ); + } dashSlash.SetActive(true); @@ -58,38 +73,40 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { // Set the newly instantiate collider to state Init, to reset it // in case the local player was already performing it - dashSlash.LocateMyFSM("Control Collider").SetState("Init"); + if (controlColliderFsm.ActiveStateName != "Init") { + controlColliderFsm.SetState("Init"); + } var damage = ServerSettings.DashSlashDamage; if (ServerSettings.IsPvpEnabled && ShouldDoDamage) { - // Somehow adding a DamageHero component or the parry FSM simply to the dash slash object doesn't work, - // so we create a separate object for it - var dashSlashCollider = Object.Instantiate( - new GameObject( - "DashSlashCollider", - typeof(PolygonCollider2D) - ), - dashSlash.transform - ); - dashSlashCollider.SetActive(true); - dashSlashCollider.layer = 22; + // Since the dash slash should deal damage to other players, we create a separate object for that purpose + var pvpCollider = new GameObject("PvP Collider", typeof(PolygonCollider2D)); + + var transform = pvpCollider.transform; + transform.SetParent(dashSlash.transform); + transform.localPosition = new Vector3(0, 0, 0); + transform.localScale = new Vector3(1, 1, 0); + + pvpCollider.SetActive(true); + pvpCollider.layer = 22; // Copy over the polygon collider points - dashSlashCollider.GetComponent().points = + pvpCollider.GetComponent().points = dashSlash.GetComponent().points; if (ServerSettings.AllowParries) { - AddParryFsm(dashSlashCollider); + AddParryFsm(pvpCollider); } if (damage != 0) { - dashSlashCollider.AddComponent().damageDealt = damage; + pvpCollider.AddComponent().damageDealt = damage; } } // Get the animator, figure out the duration of the animation and destroy the object accordingly afterwards var dashSlashAnimator = dashSlash.GetComponent(); - var dashSlashAnimationDuration = dashSlashAnimator.DefaultClip.frames.Length / dashSlashAnimator.ClipFps; + var defaultClip = dashSlashAnimator.DefaultClip; + var dashSlashAnimationDuration = defaultClip.frames.Length / defaultClip.fps; Object.Destroy(dashSlash, dashSlashAnimationDuration); } diff --git a/HKMP/Animation/Effects/DescendingDarkLand.cs b/HKMP/Animation/Effects/DescendingDarkLand.cs index cfc26a9c..c77274f9 100644 --- a/HKMP/Animation/Effects/DescendingDarkLand.cs +++ b/HKMP/Animation/Effects/DescendingDarkLand.cs @@ -54,14 +54,19 @@ private IEnumerator PlayEffectInCoroutine(GameObject playerObject) { playerSpells.transform ); quakeSlam.SetActive(true); - quakeSlam.layer = 22; + quakeSlam.layer = 9; + var hitL = quakeSlam.FindGameObjectInChildren("Hit L"); + hitL.layer = 17; + var hitR = quakeSlam.FindGameObjectInChildren("Hit R"); + hitR.layer = 17; + // If PvP is enabled add a DamageHero component to both hitbox sides var damage = ServerSettings.DescendingDarkDamage; if (ServerSettings.IsPvpEnabled && ShouldDoDamage && damage != 0) { - quakeSlam.FindGameObjectInChildren("Hit L").AddComponent().damageDealt = damage; - quakeSlam.FindGameObjectInChildren("Hit R").AddComponent().damageDealt = damage; + hitL.AddComponent().damageDealt = damage; + hitR.AddComponent().damageDealt = damage; } // The FSM has a Wait action of 0.75 as a fallback for when the animationTrigger is not called. @@ -85,14 +90,16 @@ private IEnumerator PlayEffectInCoroutine(GameObject playerObject) { playerSpells.transform ); qMega.SetActive(true); + qMega.layer = 9; + // Play the Q Mega animation from the first frame qMega.GetComponent().PlayFromFrame(0); // Enable the correct layer var qMegaHitL = qMega.FindGameObjectInChildren("Hit L"); - qMegaHitL.layer = 22; + qMegaHitL.layer = 17; var qMegaHitR = qMega.FindGameObjectInChildren("Hit R"); - qMegaHitR.layer = 22; + qMegaHitR.layer = 17; if (ServerSettings.IsPvpEnabled && ShouldDoDamage && damage != 0) { qMegaHitL.AddComponent().damageDealt = damage; diff --git a/HKMP/Animation/Effects/DesolateDiveLand.cs b/HKMP/Animation/Effects/DesolateDiveLand.cs index dda27a92..7a1bd061 100644 --- a/HKMP/Animation/Effects/DesolateDiveLand.cs +++ b/HKMP/Animation/Effects/DesolateDiveLand.cs @@ -53,14 +53,19 @@ private IEnumerator PlayEffectInCoroutine(GameObject playerObject) { playerSpells.transform ); quakeSlam.SetActive(true); - quakeSlam.layer = 22; + quakeSlam.layer = 9; + + var hitL = quakeSlam.FindGameObjectInChildren("Hit L"); + hitL.layer = 17; + var hitR = quakeSlam.FindGameObjectInChildren("Hit R"); + hitR.layer = 17; // If PvP is enabled add a DamageHero component to both hitbox sides var damage = ServerSettings.DesolateDiveDamage; if (ServerSettings.IsPvpEnabled && ShouldDoDamage && damage != 0) { - quakeSlam.FindGameObjectInChildren("Hit L").AddComponent().damageDealt = damage; - quakeSlam.FindGameObjectInChildren("Hit R").AddComponent().damageDealt = damage; + hitL.AddComponent().damageDealt = damage; + hitR.AddComponent().damageDealt = damage; } // Obtain the Q1 Pillar prefab and instantiate it relative to the player object diff --git a/HKMP/Animation/Effects/FireballBase.cs b/HKMP/Animation/Effects/FireballBase.cs index 08dc9669..a4b4f0b4 100644 --- a/HKMP/Animation/Effects/FireballBase.cs +++ b/HKMP/Animation/Effects/FireballBase.cs @@ -125,7 +125,7 @@ int damage // Make sure the object is scaled according to which direction the player is facing dungFluke.transform.rotation = Quaternion.Euler(0, 0, 26 * -localScale.x); - dungFluke.layer = 22; + dungFluke.layer = 17; var shamanStoneModifier = hasShamanStoneCharm ? 1.1f : 1.0f; @@ -164,7 +164,7 @@ int damage Quaternion.identity ); fireball.SetActive(true); - fireball.layer = 22; + fireball.layer = 17; // We add a fireball component that deals with spawning the moving fireball var fireballComponent = fireball.AddComponent(); @@ -309,9 +309,7 @@ private IEnumerator StartDungFluke(GameObject dungFluke) { ); dungCloud.SetActive(true); - dungCloud.layer = 22; - - Object.Destroy(dungCloud.GetComponent()); + dungCloud.layer = 17; // Get the control FSM and the audio clip corresponding to the explosion of the dungFluke // We need it later diff --git a/HKMP/Animation/Effects/FocusBurst.cs b/HKMP/Animation/Effects/FocusBurst.cs index 951d7120..3ece1eca 100644 --- a/HKMP/Animation/Effects/FocusBurst.cs +++ b/HKMP/Animation/Effects/FocusBurst.cs @@ -74,7 +74,7 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { Quaternion.identity ); cloud.SetActive(true); - cloud.layer = 22; + cloud.layer = 17; // Destroy the FSM so it doesn't use local player variables Object.Destroy(cloud.LocateMyFSM("Control")); diff --git a/HKMP/Animation/Effects/GreatSlash.cs b/HKMP/Animation/Effects/GreatSlash.cs index 7a8f2efe..83259bca 100644 --- a/HKMP/Animation/Effects/GreatSlash.cs +++ b/HKMP/Animation/Effects/GreatSlash.cs @@ -36,9 +36,11 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { greatSlashObject, playerAttacks.transform ); - greatSlash.layer = 22; - - ChangeAttackTypeOfFsm(greatSlash); + greatSlash.layer = 17; + + // Check which direction the knight is facing for the damages_enemy FSM + var facingRight = playerObject.transform.localScale.x > 0; + ChangeAttackDirection(greatSlash, facingRight ? 180f : 0f); greatSlash.SetActive(true); @@ -48,12 +50,26 @@ public override void Play(GameObject playerObject, bool[] effectInfo) { var damage = ServerSettings.GreatSlashDamage; if (ServerSettings.IsPvpEnabled && ShouldDoDamage) { + // Since the great slash should deal damage to other players, we create a separate object for that purpose + var pvpCollider = new GameObject("PvP Collider", typeof(PolygonCollider2D)); + + var transform = pvpCollider.transform; + transform.SetParent(greatSlash.transform); + transform.localPosition = new Vector3(0, 0, 0); + transform.localScale = new Vector3(1, 1, 0); + + pvpCollider.SetActive(true); + pvpCollider.layer = 22; + + pvpCollider.GetComponent().points = + greatSlash.GetComponent().points; + if (ServerSettings.AllowParries) { - AddParryFsm(greatSlash); + AddParryFsm(pvpCollider); } if (damage != 0) { - greatSlash.AddComponent().damageDealt = damage; + pvpCollider.AddComponent().damageDealt = damage; } } diff --git a/HKMP/Animation/Effects/QuakeDownBase.cs b/HKMP/Animation/Effects/QuakeDownBase.cs index 5e830332..6fb53be7 100644 --- a/HKMP/Animation/Effects/QuakeDownBase.cs +++ b/HKMP/Animation/Effects/QuakeDownBase.cs @@ -48,6 +48,7 @@ protected void Play(GameObject playerObject, bool[] effectInfo, string qTrailPre localPlayerSpells.FindGameObjectInChildren(qTrailPrefabName), playerSpells.transform ); + qTrail.layer = 17; qTrail.SetActive(true); // Assign a name so we reference it later, when we need to delete it qTrail.name = qTrailPrefabName; diff --git a/HKMP/Animation/Effects/ScreamBase.cs b/HKMP/Animation/Effects/ScreamBase.cs index f6975101..6a83f752 100644 --- a/HKMP/Animation/Effects/ScreamBase.cs +++ b/HKMP/Animation/Effects/ScreamBase.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Collections.Generic; using Hkmp.Util; using HutongGames.PlayMaker.Actions; using UnityEngine; @@ -46,56 +45,28 @@ protected IEnumerator Play(GameObject playerObject, string screamClipName, strin playerSpells.transform ); screamHeads.SetActive(true); + screamHeads.layer = 9; // We don't want to deactivate this when the local player is being hit Object.Destroy(screamHeads.LocateMyFSM("Deactivate on Hit")); // For each (L, R and U) of the scream objects, we need to do a few things var objectNames = new[] { "Hit L", "Hit R", "Hit U" }; - // Also store a few objects that we need to destroy later - var objectsToDestroy = new List(); foreach (var objectName in objectNames) { var screamHitObject = screamHeads.FindGameObjectInChildren(objectName); - Object.Destroy(screamHitObject.LocateMyFSM("damages_enemy")); - var screamHitDamager = Object.Instantiate( - new GameObject(objectName), - screamHitObject.transform - ); - screamHitDamager.layer = 22; - - // Add the object to the list to destroy it later - objectsToDestroy.Add(screamHitDamager); - - // Create a new polygon collider - var screamHitDamagerPoly = screamHitDamager.AddComponent(); - screamHitDamagerPoly.isTrigger = true; - - // Obtain the original polygon collider - var screamHitPoly = screamHitObject.GetComponent(); - - // Copy over the polygon collider points - screamHitDamagerPoly.points = screamHitPoly.points; - - // If PvP is enabled, add a DamageHero component to the damager objects + // If PvP is enabled, add a DamageHero component to the scream hit objects if (ServerSettings.IsPvpEnabled && ShouldDoDamage && damage != 0) { - screamHitDamager.AddComponent().damageDealt = damage; + screamHitObject.AddComponent().damageDealt = damage; } - - // Delete the original polygon collider, we don't need it anymore - Object.Destroy(screamHitPoly); } // Wait for the duration of the scream animation - var duration = playerObject.GetComponent().GetClipByName("Scream 2 Get") - .Duration; + var duration = playerObject.GetComponent().GetClipByName("Scream 2 Get").Duration; yield return new WaitForSeconds(duration); // Then destroy the leftover objects Object.Destroy(screamHeads); - foreach (var gameObject in objectsToDestroy) { - Object.Destroy(gameObject); - } } /// diff --git a/HKMP/Animation/Effects/SlashBase.cs b/HKMP/Animation/Effects/SlashBase.cs index 334ed540..65de041a 100644 --- a/HKMP/Animation/Effects/SlashBase.cs +++ b/HKMP/Animation/Effects/SlashBase.cs @@ -60,8 +60,21 @@ protected void Play(GameObject playerObject, bool[] effectInfo, GameObject prefa // Instantiate the slash gameObject from the given prefab // and use the attack gameObject as transform reference var slash = Object.Instantiate(prefab, playerAttacks.transform); - slash.layer = 22; + slash.layer = 17; + float direction; + if (type is SlashType.Wall or SlashType.Normal or SlashType.Alt) { + // For wall, normal and alt slash, we need to check the direction the knight is facing + var facingRight = playerObject.transform.localScale.x > 0; + direction = facingRight ? 180f : 0f; + } else if (type is SlashType.Up) { + direction = 90f; + } else { + direction = 270f; + } + + ChangeAttackDirection(slash, direction); + // Set the base scale of the slash based on the slash type, this prevents remote nail slashes to occur // larger than they should be if they are based on the prefab from Long Nail/Mark of Pride/both slash var baseScale = _baseScales[type]; @@ -146,55 +159,40 @@ protected void Play(GameObject playerObject, bool[] effectInfo, GameObject prefa slash.GetComponent().enabled = true; + // Enable both the polygon collider of the slash and of its child object var polygonCollider = slash.GetComponent(); - polygonCollider.enabled = true; - - // Instantiate additional game object that can interact with enemies so remote enemies can be hit - GameObject enemySlash; - { - enemySlash = Object.Instantiate(prefab, playerAttacks.transform); - enemySlash.layer = 17; - enemySlash.name = "Enemy Slash"; - enemySlash.transform.localScale = slash.transform.localScale; - - var typesToRemove = new[] { - typeof(MeshFilter), typeof(MeshRenderer), typeof(tk2dSprite), typeof(tk2dSpriteAnimator), - typeof(NailSlash), - typeof(AudioSource) - }; - foreach (var typeToRemove in typesToRemove) { - Object.Destroy(enemySlash.GetComponent(typeToRemove)); - } - - for (var i = 0; i < enemySlash.transform.childCount; i++) { - Object.Destroy(enemySlash.transform.GetChild(i)); - } - - polygonCollider = enemySlash.GetComponent(); - polygonCollider.enabled = true; - - var damagesEnemyFsm = slash.LocateMyFSM("damages_enemy"); - Object.Destroy(damagesEnemyFsm); - - ChangeAttackTypeOfFsm(enemySlash); - } + + var clashTink = slash.transform.Find("Clash Tink").GetComponent(); + clashTink.enabled = true; var damage = ServerSettings.NailDamage; if (ServerSettings.IsPvpEnabled && ShouldDoDamage) { + // Since the slash should deal damage to other players, we create a separate object for that purpose + var pvpCollider = new GameObject("PvP Collider", typeof(PolygonCollider2D)); + + var transform = pvpCollider.transform; + transform.SetParent(slash.transform); + transform.localPosition = new Vector3(0, 0, 0); + transform.localScale = new Vector3(1, 1, 0); + + pvpCollider.SetActive(true); + pvpCollider.layer = 22; + + pvpCollider.GetComponent().points = polygonCollider.points; + if (ServerSettings.AllowParries) { - AddParryFsm(slash); + AddParryFsm(pvpCollider); } if (damage != 0) { - slash.AddComponent().damageDealt = damage; + pvpCollider.AddComponent().damageDealt = damage; } } // After the animation is finished, we can destroy the slash object var animationDuration = slashAnimator.CurrentClip.Duration; Object.Destroy(slash, animationDuration); - Object.Destroy(enemySlash, animationDuration); if (!hasGrubberflyElegyCharm || isOnOneHealth && !hasFuryCharm diff --git a/HKMP/Api/Addon/AddonLoader.cs b/HKMP/Api/Addon/AddonLoader.cs index 7e4b951a..396ebb70 100644 --- a/HKMP/Api/Addon/AddonLoader.cs +++ b/HKMP/Api/Addon/AddonLoader.cs @@ -16,6 +16,16 @@ internal abstract class AddonLoader { /// private const string AssemblyFilePattern = "*.dll"; + /// + /// List of file names (including extension) of files that should be skipped when trying to load addons. + /// These are dependencies of either HKMP or HKMPServer. + /// + private static readonly string[] ExcludedFileNames = [ + "HKMP.dll", + "Newtonsoft.Json.dll", + "BouncyCastle.Cryptography.dll" + ]; + /// /// The directory in which to look for assembly files. /// @@ -54,6 +64,11 @@ protected List LoadAddons() { var assemblyPaths = GetAssemblyPaths(); foreach (var assemblyPath in assemblyPaths) { + if (ExcludedFileNames.Contains(Path.GetFileName(assemblyPath))) { + Logger.Debug($"Skipping loading assembly at: {assemblyPath}"); + continue; + } + Logger.Info($"Trying to load assembly at: {assemblyPath}"); Assembly assembly; diff --git a/HKMP/Api/Client/IClientManager.cs b/HKMP/Api/Client/IClientManager.cs index 5b8e1e01..cb8c5f2e 100644 --- a/HKMP/Api/Client/IClientManager.cs +++ b/HKMP/Api/Client/IClientManager.cs @@ -57,12 +57,14 @@ public interface IClientManager { /// Changes the team of the local player. /// /// The team value. + [Obsolete("ChangeTeam is deprecated. Team changes are handled by the IServerManager.")] void ChangeTeam(Team team); /// /// Changes the skin of the local player. /// /// The ID of the skin. + [Obsolete("ChangeSkin is deprecated. Skin changes are handled by the IServerManager.")] void ChangeSkin(byte skinId); /// diff --git a/HKMP/Api/Client/IPlayerMapEntry.cs b/HKMP/Api/Client/IPlayerMapEntry.cs index 3d92ca8a..159f992b 100644 --- a/HKMP/Api/Client/IPlayerMapEntry.cs +++ b/HKMP/Api/Client/IPlayerMapEntry.cs @@ -1,4 +1,4 @@ -using Vector2 = Hkmp.Math.Vector2; +using Hkmp.Math; namespace Hkmp.Api.Client; diff --git a/HKMP/Api/Client/IUiManager.cs b/HKMP/Api/Client/IUiManager.cs index c4a25fd3..bf285f2d 100644 --- a/HKMP/Api/Client/IUiManager.cs +++ b/HKMP/Api/Client/IUiManager.cs @@ -1,3 +1,4 @@ +using System; namespace Hkmp.Api.Client; @@ -13,11 +14,13 @@ public interface IUiManager { /// /// Disables the ability for the user to select a team. /// + [Obsolete("DisableTeamSelection is deprecated. There is no UI anymore for changing team. Changing teams is handled by the IServerManager.")] void DisableTeamSelection(); /// /// Enables the ability for the user to select a team if it was disabled. /// + [Obsolete("EnableTeamSelection is deprecated. There is no UI anymore for changing team. Changing teams is handled by the IServerManager.")] void EnableTeamSelection(); /// diff --git a/HKMP/Api/Client/Networking/ClientAddonNetworkReceiver.cs b/HKMP/Api/Client/Networking/ClientAddonNetworkReceiver.cs index ea2a5a54..6536ba14 100644 --- a/HKMP/Api/Client/Networking/ClientAddonNetworkReceiver.cs +++ b/HKMP/Api/Client/Networking/ClientAddonNetworkReceiver.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using Hkmp.Collection; using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Update; namespace Hkmp.Api.Client.Networking; @@ -60,7 +61,7 @@ public void CommitPacketHandlers() { ); foreach (var idHandlerPair in PacketHandlers) { - PacketManager.RegisterClientAddonPacketHandler( + PacketManager.RegisterClientAddonUpdatePacketHandler( ClientAddon.Id.Value, idHandlerPair.Key, idHandlerPair.Value @@ -104,7 +105,7 @@ public void RegisterPacketHandler(TPacketId packetId, Action handler) { PacketHandlers[idValue] = ClientPacketHandler; if (ClientAddon.Id.HasValue) { - PacketManager.RegisterClientAddonPacketHandler( + PacketManager.RegisterClientAddonUpdatePacketHandler( ClientAddon.Id.Value, idValue, ClientPacketHandler @@ -130,7 +131,7 @@ GenericClientPacketHandler handler PacketHandlers[idValue] = ClientPacketHandler; if (ClientAddon.Id.HasValue) { - PacketManager.RegisterClientAddonPacketHandler( + PacketManager.RegisterClientAddonUpdatePacketHandler( ClientAddon.Id.Value, idValue, ClientPacketHandler @@ -152,7 +153,7 @@ public void DeregisterPacketHandler(TPacketId packetId) { PacketHandlers.Remove(idValue); if (ClientAddon.Id.HasValue) { - PacketManager.DeregisterClientAddonPacketHandler(ClientAddon.Id.Value, idValue); + PacketManager.DeregisterClientAddonUpdatePacketHandler(ClientAddon.Id.Value, idValue); } } diff --git a/HKMP/Api/Server/Networking/ServerAddonNetworkReceiver.cs b/HKMP/Api/Server/Networking/ServerAddonNetworkReceiver.cs index 8a652b35..2dfc184d 100644 --- a/HKMP/Api/Server/Networking/ServerAddonNetworkReceiver.cs +++ b/HKMP/Api/Server/Networking/ServerAddonNetworkReceiver.cs @@ -51,7 +51,7 @@ public void RegisterPacketHandler(TPacketId packetId, Action handler) { throw new InvalidOperationException(NoAddonIdMsg); } - _packetManager.RegisterServerAddonPacketHandler( + _packetManager.RegisterServerAddonUpdatePacketHandler( _serverAddon.Id.Value, idValue, (id, _) => handler(id) @@ -70,7 +70,7 @@ public void RegisterPacketHandler(TPacketId packetId, throw new InvalidOperationException(NoAddonIdMsg); } - _packetManager.RegisterServerAddonPacketHandler( + _packetManager.RegisterServerAddonUpdatePacketHandler( _serverAddon.Id.Value, idValue, (id, iPacketData) => handler(id, (TPacketData) iPacketData) @@ -88,7 +88,7 @@ public void DeregisterPacketHandler(TPacketId packetId) { throw new InvalidOperationException(NoAddonIdMsg); } - _packetManager.DeregisterServerAddonPacketHandler(_serverAddon.Id.Value, idValue); + _packetManager.DeregisterServerAddonUpdatePacketHandler(_serverAddon.Id.Value, idValue); } /// diff --git a/HKMP/Collection/BiLookup.cs b/HKMP/Collection/BiLookup.cs index 2949ea20..a312da5f 100644 --- a/HKMP/Collection/BiLookup.cs +++ b/HKMP/Collection/BiLookup.cs @@ -135,6 +135,14 @@ public bool ContainsSecond(TSecond index) { return _inverse.ContainsKey(index); } + /// + /// Removes all values from the BiLookup. + /// + public void Clear() { + _normal.Clear(); + _inverse.Clear(); + } + /// public IEnumerator> GetEnumerator() { return _normal.GetEnumerator(); diff --git a/HKMP/Fsm/FsmPatcher.cs b/HKMP/Fsm/FsmPatcher.cs index fc6f819b..c1250676 100644 --- a/HKMP/Fsm/FsmPatcher.cs +++ b/HKMP/Fsm/FsmPatcher.cs @@ -15,6 +15,13 @@ public void RegisterHooks() { On.PlayMakerFSM.OnEnable += OnFsmEnable; } + /// + /// Deregisters the hooks necessary to patch. + /// + public void DeregisterHooks() { + On.PlayMakerFSM.OnEnable -= OnFsmEnable; + } + /// /// Callback method for the PlayMakerFSM#OnEnable hook. /// @@ -36,5 +43,124 @@ private void OnFsmEnable(On.PlayMakerFSM.orig_OnEnable orig, PlayMakerFSM self) triggerAction.collideTag.Value = "Player"; triggerAction.collideTag.UseVariable = false; } + + // Patch the break floor FSM to make sure the Hero Range is not checked so remote players can break the floor + if (self.Fsm.Name.Equals("break_floor")) { + var boolTestAction = self.GetAction("Check If Nail", 0); + if (boolTestAction != null) { + self.RemoveAction("Check If Nail", 0); + } + } + + // Patch Switch Control FSMs to ignore the range requirements to allow remote players from hitting them + if (self.Fsm.Name.Equals("Switch Control")) { + if (self.GetState("Range") != null) { + self.RemoveFirstAction("Range"); + } + + if (self.GetState("Check If Nail") != null) { + self.RemoveFirstAction("Check If Nail"); + } + } + + // Patch the Mantis Throne Main to not rely on animation events from the local player in case another + // player challenges the boss + if (self.name.Equals("Mantis Lord Throne 2") && self.Fsm.Name.Equals("Mantis Throne Main")) { + // Get the animation action for the animation clip that is played + var animationAction = self.GetFirstAction("End Challenge"); + + // Get the game object for the animation and check if it is not null + var go = self.Fsm.GetOwnerDefaultTarget(animationAction.gameObject); + if (go == null) { + return; + } + + // Get the animator from the game object, the clip from the action and its length + var animator = go.GetComponent(); + var clip = animator.GetClipByName(animationAction.clipName.Value); + var length = clip.Duration; + + // Get the original watch animation action for the FSM event it sends + var watchAnimationAction = self.GetFirstAction("End Challenge"); + // If the action was already removed before (maybe because the OnEnable triggers multiple times) + // we don't have to do anything anymore + if (watchAnimationAction == null) { + return; + } + + // Insert a wait action that takes exactly the duration of the animation and sends the original event + // when it finishes + self.InsertAction("End Challenge", new Wait { + time = length, + finishEvent = watchAnimationAction.animationCompleteEvent + }, 2); + + // Remove the original watch animation action + self.RemoveFirstAction("End Challenge"); + } + + // Patch the Toll Machine FSM to set the 'activated' bool earlier in the FSM so that it synchronises better + if (self.name.StartsWith("Toll Gate Machine") && self.Fsm.Name.Equals("Toll Machine")) { + var setBoolAction = self.GetFirstAction("Open Gates"); + if (setBoolAction == null) { + return; + } + + self.InsertAction("Box Disappear Anim", setBoolAction, 0); + self.RemoveFirstAction("Open Gates"); + } + + // Patch the tutorial collapser FSMs to set the 'activated' bool earlier in the FSM so that it synchronises better + if (self.name == "Collapser Tute 01" && self.Fsm.Name.Equals("collapse tute")) { + var setBoolAction = self.GetFirstAction("Break"); + if (setBoolAction == null) { + return; + } + + self.InsertAction("Crumble", setBoolAction, 0); + self.RemoveFirstAction("Break"); + } + + // Patch the collapser FSMs to set the 'activated' bool earlier in the FSM so that it synchronises better + if (self.name.StartsWith("Collapser Small") && self.Fsm.Name.Equals("collapse small")) { + var setBoolAction = self.GetFirstAction("Break"); + if (setBoolAction == null) { + return; + } + + self.InsertAction("Split", setBoolAction, 0); + self.RemoveFirstAction("Break"); + } + + // Patch the 'Conversation Control' FSM of the Flower Quest end to set the player data bool earlier in the + // FSM so that it synchronises better + if (self.name.StartsWith("Inspect Region") && self.Fsm.Name.Equals("Conversation Control")) { + var setPdBoolAction = self.GetFirstAction("Flowers"); + if (setPdBoolAction == null) { + return; + } + + self.InsertAction("Glow", setPdBoolAction, 0); + self.RemoveFirstAction("Flowers"); + } + + // Patch the 'Chest Control' FSM of the Geo chests object to ensure that they can be opened by remote players + // by removing the range check on it + if (self.name.StartsWith("Chest") && self.Fsm.Name.Equals("Chest Control")) { + var boolTestAction = self.GetFirstAction("Range?"); + if (boolTestAction == null) { + Logger.Warn("Could not patch 'Chest Control' of 'Chest' object, action is missing"); + return; + } + + boolTestAction.isFalse = boolTestAction.isTrue; + } + + // Patch the 'Ascend' FSM of the Abyss Pit to give a bit more delay before it starts rising if certain triggers + // have been hit. Otherwise, other players have no time to react. + if (self.name == "Abyss Pit" && self.Fsm.Name == "Ascend") { + var iTweenAction = self.GetFirstAction("Ascend"); + iTweenAction.delay.Value = 2f; + } } } diff --git a/HKMP/Fsm/PositionInterpolation.cs b/HKMP/Fsm/PositionInterpolation.cs index e924acc5..07a58341 100644 --- a/HKMP/Fsm/PositionInterpolation.cs +++ b/HKMP/Fsm/PositionInterpolation.cs @@ -38,10 +38,10 @@ public void Start() { /// The new position as Vector3. public void SetNewPosition(Vector3 newPosition) { #if no_interpolation - transform.position = newPosition; + transform.localPosition = newPosition; #else if (_firstUpdate) { - transform.position = newPosition; + transform.localPosition = newPosition; _firstUpdate = false; return; @@ -62,15 +62,15 @@ public void SetNewPosition(Vector3 newPosition) { /// An enumerator for this coroutine. private IEnumerator LerpPosition(Vector3 targetPosition, float duration) { var time = 0f; - var startPosition = transform.position; + var startPosition = transform.localPosition; while (time < duration) { - transform.position = Vector3.Lerp(startPosition, targetPosition, time / duration); + transform.localPosition = Vector3.Lerp(startPosition, targetPosition, time / duration); time += Time.deltaTime; yield return null; } - transform.position = targetPosition; + transform.localPosition = targetPosition; #endif } } diff --git a/HKMP/Game/Client/ClientManager.cs b/HKMP/Game/Client/ClientManager.cs index c498aeb9..c4e1b259 100644 --- a/HKMP/Game/Client/ClientManager.cs +++ b/HKMP/Game/Client/ClientManager.cs @@ -6,12 +6,14 @@ using Hkmp.Eventing; using Hkmp.Fsm; using Hkmp.Game.Client.Entity; +using Hkmp.Game.Client.Save; using Hkmp.Game.Command.Client; using Hkmp.Game.Server; using Hkmp.Game.Settings; using Hkmp.Networking.Client; using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; using Hkmp.Ui; using Hkmp.Util; using Modding; @@ -35,9 +37,9 @@ internal class ClientManager : IClientManager { private readonly NetClient _netClient; /// - /// The server manager instance. + /// The packet manager instance for registering packet handler when we connect to a server. /// - private readonly ServerManager _serverManager; + private readonly PacketManager _packetManager; /// /// The UI manager instance. @@ -79,6 +81,21 @@ internal class ClientManager : IClientManager { /// private readonly PauseManager _pauseManager; + /// + /// The save manager instance. + /// + private readonly SaveManager _saveManager; + + /// + /// The game patcher instance. + /// + private readonly GamePatcher _gamePatcher; + + /// + /// The FSM patcher instance. + /// + private readonly FsmPatcher _fsmPatcher; + /// /// The client addon manager instance. /// @@ -94,6 +111,58 @@ internal class ClientManager : IClientManager { /// private readonly Dictionary _playerData; + /// + /// Whether we are automatically connected to an in-game hosted server. + /// This is used to determine whether to apply save data from the server to the client and warp them to a bench. + /// + private bool _autoConnect; + + /// + /// The username that was last used to connect with. + /// + private string _username; + + /// + /// Keeps track of the last updated location of the local player object. + /// + private Vector3 _lastPosition; + + /// + /// Keeps track of the last updated scale of the local player object. + /// + private Vector3 _lastScale; + + /// + /// The last scene that the player was in, to check whether we should be sending that we left a certain scene. + /// + private string _lastScene; + + /// + /// Whether full synchronisation is enabled for the server we are connected to. + /// + private bool _fullSynchronisation; + + /// + /// Whether we have already determined whether we are scene host or not for the entity system. + /// + private bool _sceneHostDetermined; + + /// + /// Event for when the server settings change after being received from the server. + /// The parameter for the action is a copy of the last received server settings. + /// + public event Action ServerSettingsChangedEvent; + + /// + /// Event for when the player's team changes after being received from the server. + /// + public event Action TeamChangedEvent; + + /// + /// Event for when the player's skin changes after being received from the server. + /// + public event Action SkinChangedEvent; + #endregion #region IClientManager properties @@ -141,152 +210,266 @@ public string Username { #endregion - /// - /// The username that was last used to connect with. - /// - private string _username; - - /// - /// Keeps track of the last updated location of the local player object. - /// - private Vector3 _lastPosition; - - /// - /// Keeps track of the last updated scale of the local player object. - /// - private Vector3 _lastScale; - - /// - /// Whether the scene has just changed and we are in a scene change. - /// - private bool _sceneChanged; - - /// - /// Whether we have already determined whether we are scene host or not for the entity system. - /// - private bool _sceneHostDetermined; - public ClientManager( NetClient netClient, - ServerManager serverManager, PacketManager packetManager, UiManager uiManager, ServerSettings serverSettings, ModSettings modSettings ) { _netClient = netClient; - _serverManager = serverManager; + _packetManager = packetManager; _uiManager = uiManager; _serverSettings = serverSettings; _modSettings = modSettings; _playerData = new Dictionary(); - _playerManager = new PlayerManager(packetManager, serverSettings, _playerData); - _animationManager = new AnimationManager(netClient, _playerManager, packetManager, serverSettings); + _playerManager = new PlayerManager(serverSettings, _playerData); + _animationManager = new AnimationManager(netClient, _playerManager); _mapManager = new MapManager(netClient, serverSettings); _entityManager = new EntityManager(netClient); + + _saveManager = new SaveManager(netClient, _entityManager); _pauseManager = new PauseManager(netClient); - _pauseManager.RegisterHooks(); - - new FsmPatcher().RegisterHooks(); + _gamePatcher = new GamePatcher(netClient); + _fsmPatcher = new FsmPatcher(); _commandManager = new ClientCommandManager(); var eventAggregator = new EventAggregator(); var clientApi = new ClientApi(this, _commandManager, uiManager, netClient, eventAggregator); _addonManager = new ClientAddonManager(clientApi, _modSettings); + } + + #region Internal client-manager methods + + /// + /// Initialize the client manager by initializing other classes and setting, hooking, or otherwise handling things + /// that only need to be done once. + /// + public void Initialize(ServerManager serverManager) { + _playerManager.Initialize(); + _animationManager.Initialize(_serverSettings); + _mapManager.Initialize(); + + _entityManager.Initialize(); + + _saveManager.Initialize(); + + CustomHooks.Initialize(); RegisterCommands(); - + ModHooks.FinishedLoadingModsHook += _addonManager.LoadAddons; - + // Check if there is a valid authentication key and if not, generate a new one - if (!AuthUtil.IsValidAuthKey(modSettings.AuthKey)) { - modSettings.AuthKey = AuthUtil.GenerateAuthKey(); + if (!AuthUtil.IsValidAuthKey(_modSettings.AuthKey)) { + _modSettings.AuthKey = AuthUtil.GenerateAuthKey(); } - + // Then authorize the key on the locally hosted server - serverManager.AuthorizeKey(modSettings.AuthKey); - - // Register packet handlers - packetManager.RegisterClientPacketHandler(ClientPacketId.HelloClient, OnHelloClient); - packetManager.RegisterClientPacketHandler(ClientPacketId.ServerClientDisconnect, - OnDisconnect); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerConnect, OnPlayerConnect); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerDisconnect, - OnPlayerDisconnect); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerEnterScene, - OnPlayerEnterScene); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerAlreadyInScene, - OnPlayerAlreadyInScene); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerLeaveScene, - OnPlayerLeaveScene); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerUpdate, OnPlayerUpdate); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerMapUpdate, - OnPlayerMapUpdate); - packetManager.RegisterClientPacketHandler(ClientPacketId.EntityUpdate, OnEntityUpdate); - packetManager.RegisterClientPacketHandler(ClientPacketId.ServerSettingsUpdated, - OnServerSettingsUpdated); - packetManager.RegisterClientPacketHandler(ClientPacketId.ChatMessage, OnChatMessage); + serverManager.AuthorizeKey(_modSettings.AuthKey); // Register handlers for events from UI - uiManager.ConnectInterface.ConnectButtonPressed += Connect; - uiManager.ConnectInterface.DisconnectButtonPressed += () => Disconnect(); - uiManager.SettingsInterface.OnTeamRadioButtonChange += InternalChangeTeam; - uiManager.SettingsInterface.OnSkinIdChange += InternalChangeSkin; + _uiManager.RequestClientConnectEvent += (address, port, username, autoConnect) => { + _autoConnect = autoConnect; + Connect(address, port, username); + }; + _uiManager.RequestClientDisconnectEvent += Disconnect; + _uiManager.RequestServerStartHostEvent += _ => { + _saveManager.IsHostingServer = true; + }; + _uiManager.RequestServerStopHostEvent += () => { + _saveManager.IsHostingServer = false; + }; UiManager.InternalChatBox.ChatInputEvent += OnChatInput; - netClient.ConnectEvent += _ => uiManager.OnSuccessfulConnect(); - netClient.ConnectFailedEvent += OnConnectFailed; - - // Register the Hero Controller Start, which is when the local player spawns - On.HeroController.Start += (orig, self) => { - // Execute the original method - orig(self); - // If we are connect to a server, add a username to the player object - if (netClient.IsConnected) { - _playerManager.AddNameToPlayer( - HeroController.instance.gameObject, - _username, - _playerManager.LocalPlayerTeam - ); - } - }; - - // Register handlers for scene change and player update - UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChange; - On.HeroController.Update += OnPlayerUpdate; + _netClient.ConnectEvent += _ => _uiManager.OnSuccessfulConnect(); + _netClient.ConnectFailedEvent += OnConnectFailed; // Register client connect and timeout handler - netClient.ConnectEvent += OnClientConnect; - netClient.TimeoutEvent += OnTimeout; + _netClient.ConnectEvent += OnClientConnect; + _netClient.TimeoutEvent += OnTimeout; + } + /// + /// Register the hooks of the client manager and the internal instances. + /// + private void RegisterHooks() { + // Have internal components register their hooks + _playerManager.RegisterHooks(); + _animationManager.RegisterHooks(); + _mapManager.RegisterHooks(); + _pauseManager.RegisterHooks(); + _gamePatcher.RegisterHooks(); + _fsmPatcher.RegisterHooks(); + + if (_fullSynchronisation) { + _entityManager.RegisterHooks(); + _saveManager.RegisterHooks(); + } + + // Register handlers for various things + UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChange; + CustomHooks.HeroControllerStartAction += OnHeroControllerStart; + On.HeroController.Update += OnPlayerUpdate; + + CustomHooks.AfterEnterSceneHeroTransformed += OnEnterScene; + // Register application quit handler ModHooks.ApplicationQuitHook += OnApplicationQuit; } - #region Internal client-manager methods + /// + /// Deregister the hooks of the client manager and the internal instances. + /// + private void DeregisterHooks() { + // Have internal components deregister their hooks + _playerManager.DeregisterHooks(); + _animationManager.DeregisterHooks(); + _mapManager.DeregisterHooks(); + _pauseManager.DeregisterHooks(); + _gamePatcher.DeregisterHooks(); + _fsmPatcher.DeregisterHooks(); + + if (_fullSynchronisation) { + _entityManager.DeregisterHooks(); + _saveManager.DeregisterHooks(); + } + + // Deregister handlers for various things + UnityEngine.SceneManagement.SceneManager.activeSceneChanged -= OnSceneChange; + CustomHooks.HeroControllerStartAction -= OnHeroControllerStart; + On.HeroController.Update -= OnPlayerUpdate; + + CustomHooks.AfterEnterSceneHeroTransformed -= OnEnterScene; + + // Deregister application quit handler + ModHooks.ApplicationQuitHook -= OnApplicationQuit; + } /// /// Register the default client commands. /// private void RegisterCommands() { - _commandManager.RegisterCommand(new ConnectCommand(this)); - _commandManager.RegisterCommand(new HostCommand(_serverManager)); _commandManager.RegisterCommand(new AddonCommand(_addonManager, _netClient)); } + /// + /// Register the packet handlers. + /// + private void RegisterPacketHandlers() { + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.ServerClientDisconnect, + OnDisconnect + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerConnect, + OnPlayerConnect + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerDisconnect, + OnPlayerDisconnect + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerEnterScene, + OnPlayerEnterScene + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerAlreadyInScene, + OnPlayerAlreadyInScene + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerLeaveScene, + OnPlayerLeaveScene + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerUpdate, + OnPlayerUpdate + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerMapUpdate, + OnPlayerMapUpdate + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.ServerSettingsUpdated, + OnServerSettingsUpdated + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.ChatMessage, + OnChatMessage + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerSetting, + OnPlayerSettingUpdate + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.PlayerDeath, + OnPlayerDeath + ); + + // Register packet handlers related to full synchronisation + if (_fullSynchronisation) { + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.EntitySpawn, + OnEntitySpawn + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.EntityUpdate, + OnEntityUpdate + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.ReliableEntityUpdate, + OnReliableEntityUpdate + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.SceneHostTransfer, + OnSceneHostTransfer + ); + _packetManager.RegisterClientUpdatePacketHandler( + ClientUpdatePacketId.SaveUpdate, + OnSaveUpdate + ); + } + } + + /// + /// De-register packet handlers. + /// + private void DeregisterPacketHandlers() { + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.ServerClientDisconnect); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerConnect); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerDisconnect); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerEnterScene); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerAlreadyInScene); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerLeaveScene); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerUpdate); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerMapUpdate); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.ServerSettingsUpdated); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.ChatMessage); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerSetting); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.PlayerDeath); + + if (_fullSynchronisation) { + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.EntitySpawn); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.EntityUpdate); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.ReliableEntityUpdate); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.SceneHostTransfer); + _packetManager.DeregisterClientUpdatePacketHandler(ClientUpdatePacketId.SaveUpdate); + } + } + /// /// Connect the client to the server with the given address, port and username. /// /// The address of the server. /// The port of the server. /// The username of the client. - public void Connect(string address, int port, string username) { + private void Connect(string address, int port, string username) { Logger.Info($"Connecting client to server: {address}:{port} as {username}"); // Stop existing client @@ -323,6 +506,10 @@ public void Disconnect() { /// Internal logic for disconnecting from the server. /// private void InternalDisconnect() { + Logger.Info("Disconnecting from server"); + + _autoConnect = false; + _netClient.Disconnect(); // Let the player manager know we disconnected @@ -339,6 +526,10 @@ private void InternalDisconnect() { if (UIManager.instance.uiState.Equals(UIState.PAUSED)) { _pauseManager.SetTimeScale(0); } + + // Deregister the hooks and handlers + DeregisterHooks(); + DeregisterPacketHandlers(); try { DisconnectEvent?.Invoke(); @@ -352,19 +543,20 @@ private void InternalDisconnect() { /// Callback method for when the connection to the server fails with a given result. /// /// The result of the failed connection. - private void OnConnectFailed(ConnectFailedResult result) { + private void OnConnectFailed(ConnectionFailedResult result) { _uiManager.OnFailedConnect(result); - if (result.Type == ConnectFailedResult.FailType.InvalidAddons) { + if (result.Reason == ConnectionFailedReason.InvalidAddons) { // Inform the user of the correct addons that the server needs UiManager.InternalChatBox.AddMessage("Server requires the following addons:"); // Keep track of addons that the client has that the server does not, by removing all addons // that the server reports to have var clientAddonData = _addonManager.GetNetworkedAddonData(); + var serverAddonData = ((ConnectionInvalidAddonsResult) result).AddonData; // First check for each of the addons that the server has, whether the client has them or not - foreach (var addonData in result.AddonData) { + foreach (var addonData in serverAddonData) { var addonName = addonData.Identifier; var addonVersion = addonData.Version; var message = $" {addonName} v{addonVersion}"; @@ -412,106 +604,65 @@ private void OnChatInput(string message) { _netClient.UpdateManager.SetChatMessage(message); } - /// - /// Internal method for changing the local player team. - /// - /// The new team. - private void InternalChangeTeam(Team team) { - if (!_netClient.IsConnected) { - return; - } - - if (!_serverSettings.TeamsEnabled) { - Logger.Debug("Team are not enabled by server"); - return; - } - - if (_playerManager.LocalPlayerTeam == team) { - return; - } - - _playerManager.OnLocalPlayerTeamUpdate(team); - - _netClient.UpdateManager.SetTeamUpdate(team); - - UiManager.InternalChatBox.AddMessage($"You are now in Team {team}"); - } - - /// - /// Internal method for changing the local player skin. - /// - /// The ID of the new skin. - private void InternalChangeSkin(byte skinId) { - if (!_netClient.IsConnected) { - return; - } - - if (!_serverSettings.AllowSkins) { - Logger.Debug("User changed skin ID, but skins are not allowed by server"); - return; - } - - Logger.Debug($"Changed local player skin to ID: {skinId}"); - - // Let the player manager handle the skin updating and send the change to the server - _playerManager.UpdateLocalPlayerSkin(skinId); - _netClient.UpdateManager.SetSkinUpdate(skinId); - } - /// /// Callback method for when the net client establishes a connection with a server. /// - /// The login response received from the server. - private void OnClientConnect(LoginResponse loginResponse) { - // First relay the addon order from the login response to the addon manager - _addonManager.UpdateNetworkedAddonOrder(loginResponse.AddonOrder); - - // We should only be able to connect during a gameplay scene, - // which is when the player is spawned already, so we can add the username - _playerManager.AddNameToPlayer(HeroController.instance.gameObject, _username, - _playerManager.LocalPlayerTeam); - - Logger.Info("Client is connected, sending Hello packet"); + /// The server info received from the server. + private void OnClientConnect(ServerInfo serverInfo) { + Logger.Info("Received server info from server"); + + // Update the locally stored server settings + _serverSettings.SetAllProperties(serverInfo.ServerSettingsUpdate.ServerSettings); + // Call the event that the settings were updated + ServerSettingsChangedEvent?.Invoke(serverInfo.ServerSettingsUpdate.ServerSettings); + + // Note whether full synchronisation is enabled + _fullSynchronisation = serverInfo.FullSynchronisation; + + // Register hooks and packet handlers before we load into the game + RegisterHooks(); + RegisterPacketHandlers(); + + // Relay the addon order from the server info to the addon manager + _addonManager.UpdateNetworkedAddonOrder(serverInfo.AddonOrder); + + if (!_autoConnect) { + if (_fullSynchronisation) { + // This was not an auto-connect and full synchronisation is enabled, so we set save data. + // Otherwise, with hosting we already have the save data, or with no full synchronisation, we don't + // need it. + _saveManager.SetSaveWithData(serverInfo.CurrentSave); + _uiManager.EnterGameFromMultiplayerMenu(serverInfo.CurrentSave.NewForPlayer); + } else { + // This was not an auto-connect and full synchronisation is disabled, so we need to prompt the user + // with a local save file that they want to use + _uiManager.OpenSaveSlotSelection(saveSelected => { + // If this callback executes, but we have not selected a save (by pressing the back button on the + // save selection screen, we need to disconnect from the server again, because we are not entering + // the world + if (saveSelected) { + return; + } - // If we are in a non-gameplay scene, we transmit that we are not active yet - var currentSceneName = SceneUtil.GetCurrentSceneName(); - if (SceneUtil.IsNonGameplayScene(currentSceneName)) { - Logger.Error( - $"Client connected during a non-gameplay scene named {currentSceneName}, this should never happen!"); - return; + Disconnect(); + }); + } } - var transform = HeroController.instance.transform; - var position = transform.position; - - Logger.Info("Sending Hello packet"); - - _netClient.UpdateManager.SetHelloServerData( - SceneUtil.GetCurrentSceneName(), - new Vector2(position.x, position.y), - transform.localScale.x > 0, - (ushort) AnimationManager.GetCurrentAnimationClip() - ); - - // Since we are probably in the pause menu when we connect, set the timescale so the game - // is running while paused - _pauseManager.SetTimeScale(1.0f); - - UiManager.InternalChatBox.AddMessage("You are connected to the server"); - } - - /// - /// Callback method for when we receive the HelloClient data. - /// - /// The HelloClient packet data. - private void OnHelloClient(HelloClient helloClient) { - Logger.Info("Received HelloClient from server"); - // Fill the player data dictionary with the info from the packet - foreach (var (id, username) in helloClient.ClientInfo) { + foreach (var (id, username) in serverInfo.PlayerInfo) { _playerData[id] = new ClientPlayerData(id, username); } + // Add the username to the player if we are in-game already + if (HeroController.instance && HeroController.instance.gameObject) { + _playerManager.AddNameToPlayer( + HeroController.instance.gameObject, + _username, + _playerManager.LocalPlayerTeam + ); + } + try { ConnectEvent?.Invoke(); } catch (Exception e) { @@ -519,6 +670,21 @@ private void OnHelloClient(HelloClient helloClient) { $"Exception thrown while invoking Connect event:\n{e}"); } } + + /// + /// Callback method for when the HeroController is started so we can add the username to the player object. + /// + private void OnHeroControllerStart() { + Logger.Debug($"OnHeroControllerStart called, netclient connected: {_netClient.IsConnected}"); + + if (_netClient.IsConnected) { + _playerManager.AddNameToPlayer( + HeroController.instance.gameObject, + _username, + _playerManager.LocalPlayerTeam + ); + } + } /// /// Callback method for when we receive a server disconnect. @@ -533,6 +699,8 @@ private void OnDisconnect(ServerClientDisconnect disconnect) { } else if (disconnect.Reason == DisconnectReason.Shutdown) { UiManager.InternalChatBox.AddMessage("You are disconnected from the server (server is shutting down)"); } + + _uiManager.ReturnToMainMenuFromGame(); // Disconnect without sending the server that we disconnect, because the server knows that already InternalDisconnect(); @@ -606,17 +774,35 @@ private void OnPlayerAlreadyInScene(ClientPlayerAlreadyInScene alreadyInScene) { OnPlayerEnterScene(playerEnterScene); } - if (alreadyInScene.SceneHost) { - // Notify the entity manager that we are scene host - _entityManager.OnBecomeSceneHost(); - } else { - // Notify the entity manager that we are scene client (non-host) - _entityManager.OnBecomeSceneClient(); - } + if (_fullSynchronisation) { + if (alreadyInScene.SceneHost) { + // Notify the entity manager that we are scene host + _entityManager.InitializeSceneHost(); + } else { + // Notify the entity manager that we are scene client (non-host) + _entityManager.InitializeSceneClient(); + } + + foreach (var entitySpawn in alreadyInScene.EntitySpawnList) { + Logger.Info( + $"Updating already in scene spawned entity with ID: {entitySpawn.Id}, types: {entitySpawn.SpawningType}, {entitySpawn.SpawnedType}"); + _entityManager.SpawnEntity(entitySpawn.Id, entitySpawn.SpawningType, entitySpawn.SpawnedType); + } - // Whether there were players in the scene or not, we have now determined whether - // we are the scene host - _sceneHostDetermined = true; + foreach (var entityUpdate in alreadyInScene.EntityUpdateList) { + Logger.Info($"Updating already in scene entity with ID: {entityUpdate.Id}"); + _entityManager.HandleEntityUpdate(entityUpdate, true); + } + + foreach (var entityUpdate in alreadyInScene.ReliableEntityUpdateList) { + Logger.Info($"Updating already in scene reliable entity data with ID: {entityUpdate.Id}"); + _entityManager.HandleReliableEntityUpdate(entityUpdate, true); + } + + // Whether there were players in the scene or not, we have now determined whether + // we are the scene host + _sceneHostDetermined = true; + } } /// @@ -657,8 +843,8 @@ private void OnPlayerEnterScene(ClientPlayerEnterScene enterSceneData) { /// /// Callback method for when a player leaves our scene. /// - /// The generic client packet data. - private void OnPlayerLeaveScene(GenericClientData data) { + /// The client player leave scene packet data. + private void OnPlayerLeaveScene(ClientPlayerLeaveScene data) { var id = data.Id; Logger.Info($"Player {id} left scene"); @@ -668,6 +854,11 @@ private void OnPlayerLeaveScene(GenericClientData data) { return; } + if (UnityEngine.SceneManagement.SceneManager.GetActiveScene().name != data.SceneName) { + Logger.Info($"Player is leaving other scene than we are currently in ({data.SceneName}), ignoring"); + return; + } + // Recycle corresponding player _playerManager.RecyclePlayer(id); @@ -723,38 +914,71 @@ private void OnPlayerUpdate(PlayerUpdate playerUpdate) { private void OnPlayerMapUpdate(PlayerMapUpdate playerMapUpdate) { _mapManager.UpdatePlayerHasIcon(playerMapUpdate.Id, playerMapUpdate.HasIcon); } + + /// + /// Callback method for when an entity spawn is received. + /// + /// The EntitySpawn packet data. + private void OnEntitySpawn(EntitySpawn entitySpawn) { + if (!_fullSynchronisation) { + return; + } + + _entityManager.SpawnEntity(entitySpawn.Id, entitySpawn.SpawningType, entitySpawn.SpawnedType); + } /// /// Callback method for when an entity update is received. /// /// The EntityUpdate packet data. private void OnEntityUpdate(EntityUpdate entityUpdate) { + if (!_fullSynchronisation) { + return; + } + // We only propagate entity updates to the entity manager if we have determined the scene host if (!_sceneHostDetermined) { return; } - if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Position)) { - _entityManager.UpdateEntityPosition((EntityType) entityUpdate.EntityType, entityUpdate.Id, - entityUpdate.Position); + _entityManager.HandleEntityUpdate(entityUpdate); + } + + /// + /// Callback method for when a reliable entity update is received. + /// + /// The ReliableEntityUpdate packet data. + private void OnReliableEntityUpdate(ReliableEntityUpdate entityUpdate) { + if (!_fullSynchronisation) { + return; } - if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.State)) { - List variables; + // We only propagate entity updates to the entity manager if we have determined the scene host + if (!_sceneHostDetermined) { + return; + } - if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Variables)) { - variables = entityUpdate.Variables; - } else { - variables = new List(); - } + _entityManager.HandleReliableEntityUpdate(entityUpdate); + } + + /// + /// Callback method for when a host transfer is received. + /// + /// The HostTransfer packet data. + private void OnSceneHostTransfer(HostTransfer hostTransfer) { + if (!_fullSynchronisation) { + return; + } - _entityManager.UpdateEntityState( - (EntityType) entityUpdate.EntityType, - entityUpdate.Id, - entityUpdate.State, - variables - ); + Logger.Info($"Received scene host transfer for scene: {hostTransfer.SceneName}"); + + var currentScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; + if (currentScene != hostTransfer.SceneName) { + Logger.Info($" Current scene ({currentScene}) does not match scene for host transfer, ignoring"); + return; } + + _entityManager.BecomeSceneHost(); } /// @@ -770,33 +994,35 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { var teamsChanged = false; var allowSkinsChanged = false; + var newServerSettings = update.ServerSettings; + // Check whether the PvP state changed - if (_serverSettings.IsPvpEnabled != update.ServerSettings.IsPvpEnabled) { + if (_serverSettings.IsPvpEnabled != newServerSettings.IsPvpEnabled) { pvpChanged = true; - var message = $"PvP is now {(update.ServerSettings.IsPvpEnabled ? "enabled" : "disabled")}"; + var message = $"PvP is now {(newServerSettings.IsPvpEnabled ? "enabled" : "disabled")}"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); } // Check whether the body damage state changed - if (_serverSettings.IsBodyDamageEnabled != update.ServerSettings.IsBodyDamageEnabled) { + if (_serverSettings.IsBodyDamageEnabled != newServerSettings.IsBodyDamageEnabled) { bodyDamageChanged = true; var message = - $"Body damage is now {(update.ServerSettings.IsBodyDamageEnabled ? "enabled" : "disabled")}"; + $"Body damage is now {(newServerSettings.IsBodyDamageEnabled ? "enabled" : "disabled")}"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); } // Check whether the always show map icons state changed - if (_serverSettings.AlwaysShowMapIcons != update.ServerSettings.AlwaysShowMapIcons) { + if (_serverSettings.AlwaysShowMapIcons != newServerSettings.AlwaysShowMapIcons) { alwaysShowMapChanged = true; var message = - $"Map icons are now{(update.ServerSettings.AlwaysShowMapIcons ? "" : " not")} always visible"; + $"Map icons are now{(newServerSettings.AlwaysShowMapIcons ? "" : " not")} always visible"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); @@ -804,48 +1030,50 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // Check whether the wayward compass broadcast state changed if (_serverSettings.OnlyBroadcastMapIconWithWaywardCompass != - update.ServerSettings.OnlyBroadcastMapIconWithWaywardCompass) { + newServerSettings.OnlyBroadcastMapIconWithWaywardCompass) { onlyCompassChanged = true; var message = - $"Map icons are {(update.ServerSettings.OnlyBroadcastMapIconWithWaywardCompass ? "now only" : "not")} broadcast when wearing the Wayward Compass charm"; + $"Map icons are {(newServerSettings.OnlyBroadcastMapIconWithWaywardCompass ? "now only" : "not")} broadcast when wearing the Wayward Compass charm"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); } // Check whether the display names setting changed - if (_serverSettings.DisplayNames != update.ServerSettings.DisplayNames) { + if (_serverSettings.DisplayNames != newServerSettings.DisplayNames) { displayNamesChanged = true; - var message = $"Names are {(update.ServerSettings.DisplayNames ? "now" : "no longer")} displayed"; + var message = $"Names are {(newServerSettings.DisplayNames ? "now" : "no longer")} displayed"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); } // Check whether the teams enabled setting changed - if (_serverSettings.TeamsEnabled != update.ServerSettings.TeamsEnabled) { + if (_serverSettings.TeamsEnabled != newServerSettings.TeamsEnabled) { teamsChanged = true; - var message = $"Teams are {(update.ServerSettings.TeamsEnabled ? "now" : "no longer")} enabled"; + var message = $"Teams are {(newServerSettings.TeamsEnabled ? "now" : "no longer")} enabled"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); } // Check whether allow skins setting changed - if (_serverSettings.AllowSkins != update.ServerSettings.AllowSkins) { + if (_serverSettings.AllowSkins != newServerSettings.AllowSkins) { allowSkinsChanged = true; - var message = $"Skins are {(update.ServerSettings.AllowSkins ? "now" : "no longer")} enabled"; + var message = $"Skins are {(newServerSettings.AllowSkins ? "now" : "no longer")} enabled"; UiManager.InternalChatBox.AddMessage(message); Logger.Info(message); } // Update the settings so callbacks can read updated values - _serverSettings.SetAllProperties(update.ServerSettings); + _serverSettings.SetAllProperties(newServerSettings); + // Call the event that the settings were updated + ServerSettingsChangedEvent?.Invoke(newServerSettings); // Only update the player manager if the either PvP or body damage have been changed if (pvpChanged || bodyDamageChanged || displayNamesChanged) { @@ -860,17 +1088,22 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { // If the teams setting changed, we invoke the registered event handler if they exist if (teamsChanged) { - // If the team setting was disabled, we reset all teams + // If the team setting was disabled, we reset all teams and call the event if (!_serverSettings.TeamsEnabled) { _playerManager.ResetAllTeams(); + + TeamChangedEvent?.Invoke(Team.None); } - _uiManager.OnTeamSettingChange(); + // _uiManager.OnTeamSettingChange(); } - // If the allow skins setting changed and it is no longer allowed, we reset all existing skins + // If the allow skins setting changed, and it is no longer allowed, we reset all existing skins and call the + // event if (allowSkinsChanged && !_serverSettings.AllowSkins) { _playerManager.ResetAllPlayerSkins(); + + SkinChangedEvent?.Invoke(0); } } @@ -881,6 +1114,7 @@ private void OnServerSettingsUpdated(ServerSettingsUpdate update) { /// The new scene instance. private void OnSceneChange(Scene oldScene, Scene newScene) { Logger.Info($"Scene changed from {oldScene.name} to {newScene.name}"); + Logger.Debug($" Current scene: {UnityEngine.SceneManagement.SceneManager.GetActiveScene().name}"); // Always recycle existing players, because we changed scenes _playerManager.RecycleAllPlayers(); @@ -895,17 +1129,14 @@ private void OnSceneChange(Scene oldScene, Scene newScene) { return; } - _sceneChanged = true; - // Reset the status of whether we determined the scene host or not _sceneHostDetermined = false; - // Ignore scene changes from and to non-gameplay scenes - if (SceneUtil.IsNonGameplayScene(oldScene.name) && SceneUtil.IsNonGameplayScene(newScene.name)) { - return; + // If the old scene is a gameplay scene, we need to notify the server that we left + if (!SceneUtil.IsNonGameplayScene(oldScene.name) && oldScene.name == _lastScene) { + Logger.Debug("Left scene, sending to server"); + _netClient.UpdateManager.SetLeftScene(oldScene.name); } - - _netClient.UpdateManager.SetLeftScene(); } /// @@ -936,39 +1167,7 @@ private void OnPlayerUpdate(On.HeroController.orig_Update orig, HeroController s // Update the last position, since it changed _lastPosition = newPosition; - if (_sceneChanged) { - _sceneChanged = false; - - - // Set some default values for the packet variables in case we don't have a HeroController instance - // This might happen when we are in a non-gameplay scene without the knight - var position = Vector2.Zero; - var scale = Vector3.zero; - ushort animationClipId = 0; - - // If we do have a HeroController instance, use its values - if (HeroController.instance != null) { - var transform = HeroController.instance.transform; - var transformPos = transform.position; - - position = new Vector2(transformPos.x, transformPos.y); - scale = transform.localScale; - animationClipId = (ushort) AnimationManager.GetCurrentAnimationClip(); - } - - Logger.Debug("Sending EnterScene packet"); - - _netClient.UpdateManager.SetEnterSceneData( - SceneUtil.GetCurrentSceneName(), - position, - scale.x > 0, - animationClipId - ); - } else { - // If this was not the first position update after a scene change, - // we can simply send a position update packet - _netClient.UpdateManager.UpdatePlayerPosition(new Vector2(newPosition.x, newPosition.y)); - } + _netClient.UpdateManager.UpdatePlayerPosition(new Vector2(newPosition.x, newPosition.y)); } var newScale = heroTransform.localScale; @@ -981,6 +1180,42 @@ private void OnPlayerUpdate(On.HeroController.orig_Update orig, HeroController s } } + /// + /// Callback method for the local player enters a scene. Used to network to the server that a scene is entered. + /// + private void OnEnterScene() { + var sceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; + + Logger.Debug($"OnEnterScene, scene: {sceneName}"); + + _lastScene = sceneName; + + // Set some default values for the packet variables in case we don't have a HeroController instance + // This might happen when we are in a non-gameplay scene without the knight + var position = Vector2.Zero; + var scale = Vector3.zero; + ushort animationClipId = 0; + + // If we do have a HeroController instance, use its values + if (HeroController.instance) { + var transform = HeroController.instance.transform; + var transformPos = transform.position; + + position = new Vector2(transformPos.x, transformPos.y); + scale = transform.localScale; + animationClipId = (ushort) AnimationManager.GetCurrentAnimationClip(); + } + + Logger.Debug($"Sending EnterScene packet"); + + _netClient.UpdateManager.SetEnterSceneData( + SceneUtil.GetCurrentSceneName(), + position, + scale.x > 0, + animationClipId + ); + } + /// /// Callback method for when a chat message is received. /// @@ -989,6 +1224,52 @@ private void OnChatMessage(ChatMessage chatMessage) { UiManager.InternalChatBox.AddMessage(chatMessage.Message); } + /// + /// Callback method for when a player setting update is received. + /// + /// The packet data. + private void OnPlayerSettingUpdate(ClientPlayerSettingUpdate settingUpdate) { + if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { + if (settingUpdate.Self) { + _playerManager.OnPlayerTeamUpdate(true, settingUpdate.Team); + + TeamChangedEvent?.Invoke(settingUpdate.Team); + } else { + _playerManager.OnPlayerTeamUpdate(false, settingUpdate.Team, settingUpdate.Id); + } + } + + if (settingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { + if (settingUpdate.Self) { + _playerManager.OnPlayerSkinUpdate(true, settingUpdate.SkinId); + + SkinChangedEvent?.Invoke(settingUpdate.SkinId); + } else { + _playerManager.OnPlayerSkinUpdate(false, settingUpdate.SkinId, settingUpdate.Id); + } + } + } + + /// + /// Callback method for when a player death packet is received. + /// + /// The GenericClientData packet data. + private void OnPlayerDeath(GenericClientData deathData) { + _animationManager.OnPlayerDeath(deathData); + } + + /// + /// Callback method for when a save update is received. + /// + /// The SaveUpdate packet data. + private void OnSaveUpdate(SaveUpdate saveUpdate) { + if (!_fullSynchronisation) { + return; + } + + _saveManager.UpdateSaveWithData(saveUpdate); + } + /// /// Callback method for when the net client is timed out. /// @@ -997,7 +1278,10 @@ private void OnTimeout() { return; } - Logger.Info("Connection to server timed out, disconnecting"); + Logger.Info("Connection to server timed out, moving to main menu"); + + _uiManager.ReturnToMainMenuFromGame(); + UiManager.InternalChatBox.AddMessage("You are disconnected from the server (server timed out)"); Disconnect(); @@ -1036,20 +1320,10 @@ public bool TryGetPlayer(ushort id, out IClientPlayer player) { /// public void ChangeTeam(Team team) { - if (!_netClient.IsConnected) { - throw new InvalidOperationException("Client is not connected, cannot change team"); - } - - InternalChangeTeam(team); } /// public void ChangeSkin(byte skinId) { - if (!_netClient.IsConnected) { - throw new InvalidOperationException("Client is not connected, cannot change skin"); - } - - InternalChangeSkin(skinId); } #endregion diff --git a/HKMP/Game/Client/CustomHooks.cs b/HKMP/Game/Client/CustomHooks.cs new file mode 100644 index 00000000..282c6d28 --- /dev/null +++ b/HKMP/Game/Client/CustomHooks.cs @@ -0,0 +1,262 @@ +using System; +using System.Reflection; +using Hkmp.Logging; +using HutongGames.PlayMaker; +using HutongGames.PlayMaker.Actions; +using Mono.Cecil.Cil; +using MonoMod.Cil; +using MonoMod.RuntimeDetour; +using UnityEngine.Audio; + +namespace Hkmp.Game.Client; + +// TODO: create method for de-registering the hooks +/// +/// Static class that manages and exposes custom hooks that are not possible with On hooks or ModHooks. Uses IL modification +/// to embed event calls in certain methods. +/// +public static class CustomHooks { + /// + /// The binding flags for obtaining certain types for hooking. + /// + private const BindingFlags BindingFlags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + /// + /// The instruction match set for matching the instructions below. This is the call to HeroInPosition.Invoke. + /// + // IL_01ae: ldloc.1 // V_1 + // IL_01af: ldfld class HeroController/HeroInPosition HeroController::heroInPosition + // IL_01b4: ldc.i4.0 + // IL_01b5: callvirt instance void HeroController/HeroInPosition::Invoke(bool) + private static readonly Func[] HeroInPositionInstructions = [ + i => i.MatchLdfld(typeof(HeroController), "heroInPosition"), + i => i.MatchLdcI4(out _), + i => i.MatchCallvirt(typeof(HeroController.HeroInPosition), "Invoke") + ]; + + /// + /// IL Hook instance for the HeroController EnterScene hook. + /// + private static ILHook _heroControllerEnterSceneIlHook; + /// + /// IL Hook instance for the HeroController Respawn hook. + /// + private static ILHook _heroControllerRespawnIlHook; + + /// + /// Event for when the player object is done being transformed (changed position, scale) after entering a scene. + /// + public static event Action AfterEnterSceneHeroTransformed; + + /// + /// Event for when the AudioManager.ApplyMusicCue method is called from the ApplyMusicCue FSM action. + /// + public static event Action ApplyMusicCueFromFsmAction; + + /// + /// Event for when the AudioMixerSnapshot.TransitionTo method is called from the TransitionToAudioSnapshot FSM + /// action. + /// + public static event Action TransitionToAudioSnapshotFromFsmAction; + + /// + /// Internal event for . + /// + private static event Action HeroControllerStartActionInternal; + + /// + /// Event that executes when the HeroController starts or executes its subscriber immediately if the HeroContoller + /// is already active. + /// + public static event Action HeroControllerStartAction { + add { + if (HeroController.UnsafeInstance) { + value.Invoke(); + } + + HeroControllerStartActionInternal += value; + } + + remove => HeroControllerStartActionInternal -= value; + } + + /// + /// Initialize the class by registering the IL/On hooks. + /// + public static void Initialize() { + IL.HeroController.Start += HeroControllerOnStart; + IL.HeroController.EnterSceneDreamGate += HeroControllerOnEnterSceneDreamGate; + + var type = typeof(HeroController).GetNestedType("d__469", BindingFlags); + _heroControllerEnterSceneIlHook = new ILHook(type.GetMethod("MoveNext", BindingFlags), HeroControllerOnEnterScene); + + type = typeof(HeroController).GetNestedType("d__473", BindingFlags); + _heroControllerRespawnIlHook = new ILHook(type.GetMethod("MoveNext", BindingFlags), HeroControllerOnRespawn); + + IL.HutongGames.PlayMaker.Actions.ApplyMusicCue.OnEnter += ApplyMusicCueOnEnter; + IL.HutongGames.PlayMaker.Actions.TransitionToAudioSnapshot.OnEnter += TransitionToAudioSnapshotOnEnter; + + On.HeroController.Start += HeroControllerOnStart; + } + + /// + /// IL Hook for the HeroController Start method. Calls an event within the method. + /// + private static void HeroControllerOnStart(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + EmitAfterEnterSceneEventHeroInPosition(c); + } catch (Exception e) { + Logger.Error($"Could not change HeroControllerOnStart IL: \n{e}"); + } + } + + /// + /// IL Hook for the HeroController EnterSceneDreamGate method. Calls an event within the method. + /// + private static void HeroControllerOnEnterSceneDreamGate(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + EmitAfterEnterSceneEventHeroInPosition(c); + } catch (Exception e) { + Logger.Error($"Could not change HeroControllerOnEnterSceneDreamGate IL: \n{e}"); + } + } + + /// + /// IL Hook for the HeroController EnterScene method. Calls an event multiple times within the method. + /// + private static void HeroControllerOnEnterScene(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + for (var i = 0; i < 2; i++) { + EmitAfterEnterSceneEventHeroInPosition(c); + } + + // IL_0634: ldloc.1 // V_1 + // IL_0635: callvirt instance void HeroController::FaceRight() + Func[] faceDirectionInstructions = [ + i => i.MatchLdloc(1), + i => + i.MatchCallvirt(typeof(HeroController), "FaceRight") || + i.MatchCallvirt(typeof(HeroController), "FaceLeft") + ]; + + for (var i = 0; i < 2; i++) { + c.GotoNext( + MoveType.After, + HeroInPositionInstructions + ); + + c.GotoNext( + MoveType.After, + faceDirectionInstructions + ); + + c.EmitDelegate(() => { AfterEnterSceneHeroTransformed?.Invoke(); }); + } + + EmitAfterEnterSceneEventHeroInPosition(c); + } catch (Exception e) { + Logger.Error($"Could not change HeroController#EnterScene IL: \n{e}"); + } + } + + /// + /// IL Hook for the HeroController Respawn method. Calls an event multiple times within the method. + /// + private static void HeroControllerOnRespawn(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + for (var i = 0; i < 2; i++) { + EmitAfterEnterSceneEventHeroInPosition(c); + } + } catch (Exception e) { + Logger.Error($"Could not change HeroControllerOnRespawn IL: \n{e}"); + } + } + + /// + /// Emit the delegate for calling the event after the + /// 'HeroInPosition' instructions. + /// + /// The IL cursor on which to match the instructions and emit the delegate. + private static void EmitAfterEnterSceneEventHeroInPosition(ILCursor c) { + c.GotoNext( + MoveType.After, + HeroInPositionInstructions + ); + + c.EmitDelegate(() => { AfterEnterSceneHeroTransformed?.Invoke(); }); + } + + /// + /// IL Hook for the ApplyMusicCue OnEnter method. Calls an event in the method after the ApplyMusicCue call is + /// made. + /// + private static void ApplyMusicCueOnEnter(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // IL_005d: ldc.i4.0 + // IL_005e: callvirt instance void AudioManager::ApplyMusicCue(class MusicCue, float32, float32, bool) + c.GotoNext( + MoveType.After, + i => i.MatchLdcI4(0), + i => i.MatchCallvirt(typeof(AudioManager), "ApplyMusicCue") + ); + + // Put the instance of the ApplyMusicCue class onto the stack + c.Emit(OpCodes.Ldarg_0); + + // Emit a delegate for firing the event with the ApplyMusicCue instance + c.EmitDelegate>(action => { ApplyMusicCueFromFsmAction?.Invoke(action); }); + } catch (Exception e) { + Logger.Error($"Could not change ApplyMusicCueOnEnter IL: \n{e}"); + } + } + + /// + /// IL Hook for the TransitionToAudioSnapshot OnEnter method. Calls an event in the method after the TransitionTo + /// call is made. + /// + private static void TransitionToAudioSnapshotOnEnter(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // IL_0021: callvirt instance float32 [PlayMaker]HutongGames.PlayMaker.FsmFloat::get_Value() + // IL_0026: callvirt instance void [UnityEngine.AudioModule]UnityEngine.Audio.AudioMixerSnapshot::TransitionTo(float32) + c.GotoNext( + MoveType.After, + i => i.MatchCallvirt(typeof(FsmFloat), "get_Value"), + i => i.MatchCallvirt(typeof(AudioMixerSnapshot), "TransitionTo") + ); + + // Put the instance of the TransitionToAudioSnapshot class onto the stack + c.Emit(OpCodes.Ldarg_0); + + // Emit a delegate for firing the event with the TransitionToAudioSnapshot instance + c.EmitDelegate>(action => { TransitionToAudioSnapshotFromFsmAction?.Invoke(action); }); + } catch (Exception e) { + Logger.Error($"Could not change TransitionToAudioSnapshotOnEnter IL: \n{e}"); + } + } + + /// + /// On hook for when the HeroController starts, so we can invoke our custom event. + /// + private static void HeroControllerOnStart(On.HeroController.orig_Start orig, HeroController self) { + orig(self); + HeroControllerStartActionInternal?.Invoke(); + } +} diff --git a/HKMP/Game/Client/Entity/Action/ActionRegistry.cs b/HKMP/Game/Client/Entity/Action/ActionRegistry.cs new file mode 100644 index 00000000..27cb3623 --- /dev/null +++ b/HKMP/Game/Client/Entity/Action/ActionRegistry.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using Hkmp.Util; +using HutongGames.PlayMaker; +using Newtonsoft.Json; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Action; + +/// +/// Static class that manages loading and storing of action data. Specifically, which actions execute every frame. +/// +internal static class ActionRegistry { + /// + /// The file path of the embedded resource file for the action registry. + /// + private const string ActionRegistryFilePath = "Hkmp.Resource.action-registry.json"; + + /// + /// List of all entity registry entries that are loaded from the embedded file. + /// + private static List Entries { get; } + + static ActionRegistry() { + Entries = FileUtil.LoadObjectFromEmbeddedJson>(ActionRegistryFilePath); + if (Entries == null) { + Logger.Warn("Could not load action registry"); + } + } + + /// + /// Checks whether the given FSM action is an action that executes continuously. This is the case for actions + /// that have the "everyFrame" field and the value for this field is true or in several other specific cases + /// (such as actions related to collisions). + /// + /// The FSM action to check. + /// true if the action executes continuously; otherwise false. + public static bool IsActionContinuous(FsmStateAction action) { + var entry = Entries.FirstOrDefault(entry => entry.Type == action.GetType().Name); + if (entry == null) { + return false; + } + + if (entry.UpdateField == null) { + return true; + } + + var type = action.GetType(); + var fieldInfo = type.GetField(entry.UpdateField); + if (fieldInfo == null) { + Logger.Warn($"Could not find field on FSM state action class: {type}, {entry.UpdateField}"); + return false; + } + + var value = fieldInfo.GetValue(action); + if (value is bool boolValue) { + return boolValue; + } + + if (value is FsmInt fsmIntValue) { + return fsmIntValue.Value > 0; + } + + Logger.Warn($"Could not find type of the field on FSM state action class: {type}, {entry.UpdateField}"); + return false; + } +} + +/// +/// Class representing a single entry in the action registry that contains the relevant data for an action. +/// +internal class ActionRegistryEntry { + /// + /// The type of the action. + /// + [JsonProperty("type")] + public string Type { get; set; } + + /// + /// The name of the field that controls whether the actions is checked every frame. + /// + [JsonProperty("update_field")] + public string UpdateField { get; set; } +} diff --git a/HKMP/Game/Client/Entity/Action/EntityFsmActions.cs b/HKMP/Game/Client/Entity/Action/EntityFsmActions.cs new file mode 100644 index 00000000..0b78dde0 --- /dev/null +++ b/HKMP/Game/Client/Entity/Action/EntityFsmActions.cs @@ -0,0 +1,3240 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using HutongGames.PlayMaker; +using HutongGames.PlayMaker.Actions; +using Modding; +using Mono.Cecil.Cil; +using MonoMod.Cil; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; +using Object = UnityEngine.Object; +using Random = UnityEngine.Random; + +// ReSharper disable UnusedMember.Local +// ReSharper disable UnusedParameter.Local + +namespace Hkmp.Game.Client.Entity.Action; + +/// +/// Static class containing method that transform FSM actions into network-able data and applying networked data +/// into the FSM actions implementations. +/// +internal static class EntityFsmActions { + /// + /// The prefix of a method name that transforms an FSM action into network-able data. + /// + private const string GetMethodNamePrefix = "Get"; + /// + /// The prefix of a method name that applies network data into an FSM action. + /// + private const string ApplyMethodNamePrefix = "Apply"; + + /// + /// Binding flags for accessing the private static methods in this class. + /// + private const BindingFlags StaticNonPublicFlags = BindingFlags.Static | BindingFlags.NonPublic; + + /// + /// Set containing types of actions that are supported for transformation by a method in this class. + /// + public static readonly HashSet SupportedActionTypes = new(); + + /// + /// Event that is called when an entity is spawned from an object. + /// + public static event Func EntitySpawnEvent; + + /// + /// Dictionary mapping a type of an FSM action to the corresponding method info of the "get" method in this class. + /// + private static readonly Dictionary TypeGetMethodInfos = new(); + /// + /// Dictionary mapping a type of an FSM action to the corresponding method info of the "apply" method in this class. + /// + private static readonly Dictionary TypeApplyMethodInfos = new(); + + /// + /// Dictionary containing queues of objects for a FSM action that has been executed on a host entity. + /// Used to log the results of random calls to network to clients. + /// + private static readonly Dictionary> RandomActionValues = new(); + + /// + /// List of actions that are executing while in a state and need to be stopped again when the state is exited. + /// + private static readonly List ActionsInState = new(); + + /// + /// Static constructor that initializes the set and dictionaries by checking all methods in the class. + /// + /// + static EntityFsmActions() { + var methodInfos = typeof(EntityFsmActions).GetMethods(StaticNonPublicFlags); + + foreach (var methodInfo in methodInfos) { + var parameterInfos = methodInfo.GetParameters(); + if (parameterInfos.Length != 2) { + // Can't be a method that gets or applies entity network data + continue; + } + + // Filter out the base methods + var parameterType = parameterInfos[1].ParameterType; + if (parameterType.IsAbstract || !parameterType.IsSubclassOf(typeof(FsmStateAction))) { + continue; + } + + SupportedActionTypes.Add(parameterType); + + if (methodInfo.Name.StartsWith(GetMethodNamePrefix)) { + TypeGetMethodInfos.Add(parameterType, methodInfo); + } else if (methodInfo.Name.StartsWith(ApplyMethodNamePrefix)) { + TypeApplyMethodInfos.Add(parameterType, methodInfo); + } else { + throw new Exception("Method was defined that does not adhere to the method naming"); + } + } + + // Register the IL hooks for modifying FSM action methods + IL.HutongGames.PlayMaker.Actions.FlingObjectsFromGlobalPool.OnEnter += FlingObjectsFromGlobalPoolOnEnter; + IL.HutongGames.PlayMaker.Actions.FlingObjectsFromGlobalPoolVel.OnEnter += FlingObjectsFromGlobalPoolVelOnEnter; + IL.HutongGames.PlayMaker.Actions.FlingObjectsFromGlobalPoolTime.OnUpdate += FlingObjectsFromGlobalPoolTimeOnUpdate; + IL.HutongGames.PlayMaker.Actions.GetRandomChild.DoGetRandomChild += GetRandomChildOnDoGetRandomChild; + + // Register IL hooks for the OnEnter method of certain classes. These OnEnter methods do not + // have a method body and thus no IL instructions (apart from ret). Hooking this in the FsmActionHooks class + // will not work, so we emit a NOP instruction to the body to make it hookable + void EmitNop(ILContext il) => new ILCursor(il).Emit(OpCodes.Nop); + + IL.HutongGames.PlayMaker.Actions.FlingObjectsFromGlobalPoolTime.OnEnter += EmitNop; + IL.SpawnBloodTime.OnEnter += EmitNop; + } + + /// + /// Gets network-able data from the given action and puts it in the given instance. + /// + /// The instance to put the data into. + /// The action to transform. + /// Whether from this action network-able data was made. + /// Thrown if there is no suitable method for the action and thus + /// no network data is written. + public static bool GetNetworkDataFromAction(EntityNetworkData data, FsmStateAction action) { + var actionType = action.GetType(); + if (!TypeGetMethodInfos.TryGetValue(actionType, out var methodInfo)) { + throw new InvalidOperationException( + $"Given action type: {action.GetType()} does not have an associated method to get"); + } + + var returnObject = methodInfo.Invoke( + null, + StaticNonPublicFlags, + null, + new object[] { data, action }, + null! + ); + + // Return whether the return object is a bool and has the value 'true' + return returnObject is true; + } + + /// + /// Reads networked data from the given instance and mimics the execution of the given FSM action. + /// + /// The instance from which to get the data. + /// The FSM action to mimic execution for. + /// Thrown if there is no suitable method for the action and thus + /// no FSM action will be mimicked. + public static void ApplyNetworkDataFromAction(EntityNetworkData data, FsmStateAction action) { + var actionType = action.GetType(); + if (!TypeApplyMethodInfos.TryGetValue(actionType, out var methodInfo)) { + throw new InvalidOperationException( + $"Given action type: {action.GetType()} does not have an associated method to apply"); + } + + try { + methodInfo.Invoke( + null, + StaticNonPublicFlags, + null, + new object[] { data, action }, + null! + ); + } catch (Exception e) { + Logger.Warn($"Apply method threw exception: {e.GetType()}, {e.Message}, {e.StackTrace}"); + + e = e.InnerException; + while (e != null) { + Logger.Warn($" Inner exception: {e.GetType()}, {e.Message}, {e.StackTrace}"); + + e = e.InnerException; + } + } + } + + /// + /// Checks whether the given game object is in the entity registry and can thus be registered as an entity in + /// the system. + /// + /// The game object to check for. + /// true if the given game object is in the entity registry; otherwise false. + private static bool IsObjectInRegistry(GameObject gameObject) { + return EntityRegistry.TryGetEntry(gameObject, out _); + } + + /// + /// Method to call the spawn event externally. TODO: refactor this into something more appropriate + /// + /// The spawn details for the event. + /// Whether an entity was registered from this spawn. + public static bool CallEntitySpawnEvent(EntitySpawnDetails details) { + return EntitySpawnEvent != null && EntitySpawnEvent.Invoke(details); + } + + /// + /// Emit intercept instruction on the next Unity Random Range() call for the given IL cursor. + /// + /// The cursor for the IL context of the method. + /// The return type of the random call. + /// The type of the FSM state action in which the random call occurs. + private static void EmitRandomInterceptInstructions(ILCursor c) where TObject : FsmStateAction { + // Goto the next call instruction for Random.Range() + c.GotoNext(i => i.MatchCall(typeof(Random), "Range")); + + // Move the cursor after the call instruction + c.Index++; + + // Push the current instance of the class onto the stack + c.Emit(OpCodes.Ldarg_0); + + // Emit a delegate that pops the current random value off the stack and puts it back after some processing + c.EmitDelegate>((value, instance) => { + // We need to check whether the game object that is being spawned with this action is not an object + // managed by the system. Because if so, we do not store the random values because the action for it + // is not being networked. Only the game object spawn is networked with an EntitySpawn packet directly. + var fsmGameObject = ReflectionHelper.GetField(instance, "gameObject"); + if (fsmGameObject != null && fsmGameObject.Value != null && IsObjectInRegistry(fsmGameObject.Value)) { + return value; + } + + if (!RandomActionValues.TryGetValue(instance, out var queue)) { + queue = new Queue(); + RandomActionValues[instance] = queue; + } + + queue.Enqueue(value); + + return value; + }); + } + + /// + /// IL edit method for modifying the + /// method to store the results of the random calls. + /// + private static void FlingObjectsFromGlobalPoolOnEnter(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Emit instructions for Random.Range calls for 1 int and 4 floats + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + + // Reset cursor + c = new ILCursor(il); + + // Goto the next call instruction for ObjectPoolExtensions.Spawn + c.GotoNext(i => i.MatchCall(typeof(ObjectPoolExtensions), "Spawn")); + + // Move the cursor after the call instruction + c.Index++; + + // Push the current instance of the class onto the stack + c.Emit(OpCodes.Ldarg_0); + + // Emit a delegate that pops the spawned game object off the stack and uses it, then puts it back again + c.EmitDelegate>((go, action) => { + //Logger.Debug($"Delegate of FlingObjectsFromGlobalPool: {go.name}"); + if (EntitySpawnEvent != null && EntitySpawnEvent.Invoke(new EntitySpawnDetails { + Type = EntitySpawnType.FsmAction, + Action = action, + GameObject = go + })) { + Logger.Debug("FlingObjectsFromGlobalPool IL spawned object is entity"); + } + + return go; + }); + } catch (Exception e) { + Logger.Error($"Could not change FlingObjectsFromGlobalPool#OnEnter IL:\n{e}"); + } + } + + /// + /// IL edit method for modifying the + /// method to store the results of the random calls. + /// + private static void FlingObjectsFromGlobalPoolVelOnEnter(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Emit instructions for Random.Range calls for 1 int and 4 floats + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + EmitRandomInterceptInstructions(c); + + // Reset cursor + c = new ILCursor(il); + + // Goto the next call instruction for ObjectPoolExtensions.Spawn + c.GotoNext(i => i.MatchCall(typeof(ObjectPoolExtensions), "Spawn")); + + // Move the cursor after the call instruction + c.Index++; + + // Push the current instance of the class onto the stack + c.Emit(OpCodes.Ldarg_0); + + // Emit a delegate that pops the spawned game object off the stack and uses it, then puts it back again + c.EmitDelegate>((go, action) => { + //Logger.Debug($"Delegate of FlingObjectsFromGlobalPoolVel: {go.name}"); + if (EntitySpawnEvent != null && EntitySpawnEvent.Invoke(new EntitySpawnDetails { + Type = EntitySpawnType.FsmAction, + Action = action, + GameObject = go + })) { + Logger.Debug("FlingObjectsFromGlobalPoolVel IL spawned object is entity"); + } + + return go; + }); + } catch (Exception e) { + Logger.Error($"Could not change FlingObjectsFromGlobalPoolVel#OnEnter IL:\n{e}"); + } + } + + /// + /// IL edit method for modifying the + /// method to network the repeated spawning of objects. + /// + private static void FlingObjectsFromGlobalPoolTimeOnUpdate(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Goto the next call instruction for Random.Range() + c.GotoNext(i => i.MatchCall(typeof(ObjectPoolExtensions), "Spawn")); + + // Move the cursor after the call instruction + c.Index++; + + // Push the current instance of the class onto the stack + c.Emit(OpCodes.Ldarg_0); + + // Emit a delegate that pops the spawned object off the stack and pushes it onto it again + c.EmitDelegate>((gameObject, action) => { + EntitySpawnEvent?.Invoke(new EntitySpawnDetails { + Type = EntitySpawnType.FsmAction, + Action = action, + GameObject = gameObject + }); + + return gameObject; + }); + } catch (Exception e) { + Logger.Error($"Could not change FlingObjectsFromGlobalPoolTime#OnUpdate IL:\n{e}"); + } + } + + /// + /// IL edit method for modifying the DoGetRandomChild + /// method to store the results of the random calls. + /// + private static void GetRandomChildOnDoGetRandomChild(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Emit instructions for Random.Range calls for 1 int and 4 floats + EmitRandomInterceptInstructions(c); + } catch (Exception e) { + Logger.Error($"Could not change GetRandomChild#DoGetRandomChild IL:\n{e}"); + } + } + + /// + /// Register a state change for the given FSM. Will propagate this change to all actions that are running in + /// that state. + /// + /// The FSM that changed states. + /// The name of the state that was changed to. + public static void RegisterStateChange(HutongGames.PlayMaker.Fsm fsm, string stateName) { + Logger.Debug($"RegisterStateChange: {fsm.Name}, {stateName}"); + + for (var i = ActionsInState.Count - 1; i >= 0; i--) { + var actionInState = ActionsInState[i]; + + Logger.Debug($" Action in state: {actionInState.Fsm.Name}, {actionInState.StateName}"); + + if (actionInState.Fsm == fsm && actionInState.StateName != stateName) { + Logger.Debug("EntityFsmActions: state changed, cancelling action in state"); + actionInState.ExitState(); + ActionsInState.RemoveAt(i); + } + } + } + + #region SpawnObjectFromGlobalPool + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SpawnObjectFromGlobalPool action) { + // We first check whether this action results in the spawning of an entity that is managed by the + // system. Because if so, it would already be handled by an EntitySpawn packet instead, and this will only + // duplicate the spawning and leave it uncontrolled. So we don't send the data at all + if (EntitySpawnEvent != null && EntitySpawnEvent.Invoke(new EntitySpawnDetails { + Type = EntitySpawnType.FsmAction, + Action = action, + GameObject = action.storeObject.Value + })) { + Logger.Debug($"Tried getting SpawnObjectFromGlobalPool network data, but spawned object is entity"); + return false; + } + + var position = Vector3.zero; + var euler = Vector3.up; + + var spawnPoint = action.spawnPoint.Value; + if (spawnPoint != null) { + position = spawnPoint.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } else { + euler = spawnPoint.transform.eulerAngles; + } + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } + } + + data.Packet.Write(position.x); + data.Packet.Write(position.y); + data.Packet.Write(position.z); + + data.Packet.Write(euler.x); + data.Packet.Write(euler.y); + data.Packet.Write(euler.z); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SpawnObjectFromGlobalPool action) { + var position = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + var euler = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + + if (action.gameObject != null) { + var spawnedObject = action.gameObject.Value.Spawn(position, Quaternion.Euler(euler)); + action.storeObject.Value = spawnedObject; + } + } + + #endregion + + #region FlingObjectsFromGlobalPool + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FlingObjectsFromGlobalPool action) { + // We first check whether the game object belonging to the Rigidbody2D in the action is an object that is + // managed by the system. Because if so, it means that we have already caught its spawning in the IL hook + // for the action and sent an EntitySpawn packet instead. So we need not also network this action separately. + var rigidbody = ReflectionHelper.GetField(action, "rb2d"); + if (rigidbody != null && rigidbody.gameObject != null && IsObjectInRegistry(rigidbody.gameObject)) { + Logger.Debug("Skipping getting network data for FlingObjectsFromGlobalPool, because spawned objects are managed by system"); + return false; + } + + var position = Vector3.zero; + + var spawnPoint = action.spawnPoint.Value; + if (spawnPoint != null) { + position = spawnPoint.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + } else if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!RandomActionValues.TryGetValue(action, out var queue)) { + return false; + } + + if (queue.Count == 0) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPool has not enough items in queue 1"); + return false; + } + + data.Packet.Write(position.x); + data.Packet.Write(position.y); + data.Packet.Write(position.z); + + var numSpawns = (int) queue.Dequeue(); + data.Packet.Write((byte) numSpawns); + + for (var i = 0; i < numSpawns; i++) { + if (action.originVariationX != null) { + if (queue.Count == 0) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPool has not enough items in queue 2"); + return false; + } + + var originVariationX = (float) queue.Dequeue(); + data.Packet.Write(originVariationX); + } else { + data.Packet.Write(0f); + } + + if (action.originVariationY != null) { + if (queue.Count == 0) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPool has not enough items in queue 3"); + return false; + } + + var originVariationY = (float) queue.Dequeue(); + data.Packet.Write(originVariationY); + } else { + data.Packet.Write(0f); + } + + if (queue.Count < 2) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPool has not enough items in queue 4"); + queue.Clear(); + return false; + } + + var speed = (float) queue.Dequeue(); + var angle = (float) queue.Dequeue(); + + data.Packet.Write(speed); + data.Packet.Write(angle); + } + + queue.Clear(); + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FlingObjectsFromGlobalPool action) { + var position = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + + var numSpawns = data.Packet.ReadByte(); + for (var i = 0; i < numSpawns; i++) { + var go = action.gameObject.Value.Spawn(position, Quaternion.Euler(Vector3.zero)); + + var originVariationX = data.Packet.ReadFloat(); + position.x += originVariationX; + + var originVariationY = data.Packet.ReadFloat(); + position.y += originVariationY; + + go.transform.position = position; + + var speed = data.Packet.ReadFloat(); + var angle = data.Packet.ReadFloat(); + + var x = speed * Mathf.Cos(angle * ((float) System.Math.PI / 180f)); + var y = speed * Mathf.Sin(angle * ((float) System.Math.PI / 180f)); + + var rigidBody = go.GetComponent(); + if (rigidBody == null) { + return; + } + + rigidBody.velocity = new Vector2(x, y); + + if (!action.FSM.IsNone) { + FSMUtility.LocateFSM(go, action.FSM.Value).SendEvent(action.FSMEvent.Value); + } + } + } + + #endregion + + #region FlingObjectsFromGlobalPoolVel + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FlingObjectsFromGlobalPoolVel action) { + // We first check whether the game object belonging to the Rigidbody2D in the action is an object that is + // managed by the system. Because if so, it means that we have already caught its spawning in the IL hook + // for the action and sent an EntitySpawn packet instead. So we need not also network this action separately. + var rigidbody = ReflectionHelper.GetField(action, "rb2d"); + if (rigidbody != null && rigidbody.gameObject != null && IsObjectInRegistry(rigidbody.gameObject)) { + Logger.Debug("Skipping getting network data for FlingObjectsFromGlobalPool, because spawned objects are managed by system"); + return false; + } + + var position = Vector3.zero; + + var spawnPoint = action.spawnPoint.Value; + if (spawnPoint != null) { + position = spawnPoint.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + } else if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!RandomActionValues.TryGetValue(action, out var queue)) { + return false; + } + + if (queue.Count == 0) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPoolVel has not enough items in queue 1"); + return false; + } + + data.Packet.Write(position.x); + data.Packet.Write(position.y); + data.Packet.Write(position.z); + + var numSpawns = (int) queue.Dequeue(); + data.Packet.Write((byte) numSpawns); + + for (var i = 0; i < numSpawns; i++) { + if (action.originVariationX != null) { + if (queue.Count == 0) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPoolVel has not enough items in queue 2"); + return false; + } + + var originVariationX = (float) queue.Dequeue(); + data.Packet.Write(originVariationX); + } else { + data.Packet.Write(0f); + } + + if (action.originVariationY != null) { + if (queue.Count == 0) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPoolVel has not enough items in queue 3"); + return false; + } + + var originVariationY = (float) queue.Dequeue(); + data.Packet.Write(originVariationY); + } else { + data.Packet.Write(0f); + } + + if (queue.Count < 2) { + Logger.Debug("Getting data for FlingObjectsFromGlobalPoolVel has not enough items in queue 4"); + queue.Clear(); + return false; + } + + var speedX = (float) queue.Dequeue(); + var speedY = (float) queue.Dequeue(); + + data.Packet.Write(speedX); + data.Packet.Write(speedY); + } + + queue.Clear(); + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FlingObjectsFromGlobalPoolVel action) { + var position = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + + var numSpawns = data.Packet.ReadByte(); + for (var i = 0; i < numSpawns; i++) { + var go = action.gameObject.Value.Spawn(position, Quaternion.Euler(Vector3.zero)); + + var originVariationX = data.Packet.ReadFloat(); + position.x += originVariationX; + + var originVariationY = data.Packet.ReadFloat(); + position.y += originVariationY; + + go.transform.position = position; + + var speedX = data.Packet.ReadFloat(); + var speedY = data.Packet.ReadFloat(); + + var rigidBody = go.GetComponent(); + if (rigidBody == null) { + return; + } + + rigidBody.velocity = new Vector2(speedX, speedY); + } + } + + #endregion + + #region CreateObject + + private static bool GetNetworkDataFromAction(EntityNetworkData data, CreateObject action) { + // We first check whether this action results in the spawning of an entity that is managed by the + // system. Because if so, it would already be handled by an EntitySpawn packet instead, and this will only + // duplicate the spawning and leave it uncontrolled. So we don't send the data at all + if (EntitySpawnEvent != null && EntitySpawnEvent.Invoke(new EntitySpawnDetails { + Type = EntitySpawnType.FsmAction, + Action = action, + GameObject = action.storeObject.Value + })) { + Logger.Debug($"Tried getting CreateObject network data, but spawned object is entity"); + return false; + } + + var original = action.gameObject.Value; + if (original == null) { + return false; + } + + var position = Vector3.zero; + var euler = Vector3.zero; + + if (action.spawnPoint.Value != null) { + position = action.spawnPoint.Value.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + + euler = !action.rotation.IsNone ? action.rotation.Value : action.spawnPoint.Value.transform.eulerAngles; + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } + } + + data.Packet.Write(position.x); + data.Packet.Write(position.y); + data.Packet.Write(position.z); + + data.Packet.Write(euler.x); + data.Packet.Write(euler.y); + data.Packet.Write(euler.z); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, CreateObject action) { + Logger.Debug("ApplyNetworkDataFromAction CreateObject"); + + Vector3 position; + Vector3 euler; + + if (data == null) { + position = Vector3.zero; + euler = Vector3.zero; + if (action.spawnPoint.Value != null) { + position = action.spawnPoint.Value.transform.position; + + if (!action.position.IsNone) { + position += action.position.Value; + } + + euler = !action.rotation.IsNone ? + action.rotation.Value : + action.spawnPoint.Value.transform.eulerAngles; + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } + } + } else { + position = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + euler = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + } + + var original = action.gameObject.Value; + if (original == null) { + return; + } + + var spawnedObject = Object.Instantiate(original, position, Quaternion.Euler(euler)); + action.storeObject.Value = spawnedObject; + } + + #endregion + + #region FireAtTarget + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FireAtTarget action) { + var target = action.target; + + var position = target.Value.transform.position; + data.Packet.Write(position.x); + data.Packet.Write(position.y); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FireAtTarget action) { + var posX = data.Packet.ReadFloat(); + var posY = data.Packet.ReadFloat(); + + var selfGameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (selfGameObject == null) { + return; + } + + var selfPosition = selfGameObject.transform.position; + + var rigidBody = selfGameObject.GetComponent(); + if (rigidBody == null) { + return; + } + + var num = Mathf.Atan2( + posY + action.position.Value.y - selfPosition.y, + posX + action.position.Value.x - selfPosition.x + ) * 57.295776f; + + if (!action.spread.IsNone) { + num += Random.Range(-action.spread.Value, action.spread.Value); + } + + rigidBody.velocity = new Vector2( + action.speed.Value * Mathf.Cos(num * ((float)System.Math.PI / 180f)), + action.speed.Value * Mathf.Sin(num * ((float)System.Math.PI / 180f)) + ); + } + + #endregion + + #region SetScale + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetScale action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == action.Fsm.GameObject) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + return false; + } + + var scale = action.vector.IsNone ? gameObject.transform.localScale : action.vector.Value; + if (!action.x.IsNone) { + scale.x = action.x.Value; + } + + if (!action.y.IsNone) { + scale.y = action.y.Value; + } + + if (!action.z.IsNone) { + scale.z = action.z.Value; + } + + data.Packet.Write(scale.x); + data.Packet.Write(scale.y); + data.Packet.Write(scale.z); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetScale action) { + Vector3 scale; + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + + if (data == null) { + scale = action.vector.IsNone ? gameObject.transform.localScale : action.vector.Value; + + if (!action.x.IsNone) { + scale.x = action.x.Value; + } + + if (!action.y.IsNone) { + scale.y = action.y.Value; + } + + if (!action.z.IsNone) { + scale.z = action.z.Value; + } + } else { + scale = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + } + + gameObject.transform.localScale = scale; + } + + #endregion + + #region SetFsmBool + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetFsmBool action) { + if (action.setValue == null) { + return false; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == action.Fsm.GameObject) { + return false; + } + + var setValue = action.setValue.Value; + data.Packet.Write(setValue); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetFsmBool action) { + var setValue = data.Packet.ReadBool(); + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var fsm = ActionHelpers.GetGameObjectFsm(gameObject, action.fsmName.Value); + if (fsm == null) { + return; + } + + var fsmBool = fsm.FsmVariables.FindFsmBool(action.variableName.Value); + if (fsmBool == null) { + return; + } + + fsmBool.Value = setValue; + } + + #endregion + + #region SetFsmInt + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetFsmInt action) { + if (action.setValue == null) { + return false; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == action.Fsm.GameObject) { + return false; + } + + var setValue = action.setValue.Value; + data.Packet.Write(setValue); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetFsmInt action) { + var setValue = data.Packet.ReadInt(); + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var fsm = ActionHelpers.GetGameObjectFsm(gameObject, action.fsmName.Value); + if (fsm == null) { + return; + } + + var fsmInt = fsm.FsmVariables.GetFsmInt(action.variableName.Value); + if (fsmInt == null) { + return; + } + + fsmInt.Value = setValue; + } + + #endregion + + #region SetFsmFloat + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetFsmFloat action) { + // TODO: if action.setValue can be a reference, make sure to network it + if (action.setValue == null) { + return false; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == action.Fsm.GameObject) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetFsmFloat action) { + if (action.setValue == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var fsm = ActionHelpers.GetGameObjectFsm(gameObject, action.fsmName.Value); + if (fsm == null) { + return; + } + + var fsmFloat = fsm.FsmVariables.GetFsmFloat(action.variableName.Value); + if (fsmFloat == null) { + return; + } + + fsmFloat.Value = action.setValue.Value; + } + + #endregion + + #region SetFsmString + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetFsmString action) { + // TODO: if action.setValue can be a reference, make sure to network it + if (action.setValue == null) { + return false; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == action.Fsm.GameObject) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetFsmString action) { + if (action.setValue == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var fsm = ActionHelpers.GetGameObjectFsm(gameObject, action.fsmName.Value); + if (fsm == null) { + return; + } + + var fsmString = fsm.FsmVariables.GetFsmString(action.variableName.Value); + if (fsmString == null) { + return; + } + + fsmString.Value = action.setValue.Value; + } + + #endregion + + #region SetParticleEmission + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetParticleEmission action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetParticleEmission action) { + if (action.Fsm == null) { + return; + } + + if (action.emission == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var particleSystem = gameObject.GetComponent(); + if (particleSystem == null) { + return; + } + +#pragma warning disable CS0618 + particleSystem.enableEmission = action.emission.Value; +#pragma warning restore CS0618 + } + + #endregion + + #region SetParticleEmissionRate + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetParticleEmissionRate action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetParticleEmissionRate action) { + if (action.gameObject == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var particleSystem = gameObject.GetComponent(); + if (particleSystem == null) { + return; + } + + Action(); + + if (action.everyFrame) { + MonoBehaviourUtil.Instance.OnUpdateEvent += Action; + + new ActionInState { + Fsm = action.Fsm, + StateName = action.State.Name, + ExitAction = () => MonoBehaviourUtil.Instance.OnUpdateEvent -= Action + }.Register(); + } + + void Action() { +#pragma warning disable CS0618 + particleSystem.emissionRate = action.emissionRate.Value; +#pragma warning restore CS0618 + } + } + + #endregion + + #region SetParticleEmissionSpeed + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetParticleEmissionSpeed action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetParticleEmissionSpeed action) { + if (action.gameObject == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var particleSystem = gameObject.GetComponent(); + if (particleSystem == null) { + return; + } + + Action(); + + if (action.everyFrame) { + MonoBehaviourUtil.Instance.OnUpdateEvent += Action; + + new ActionInState { + Fsm = action.Fsm, + StateName = action.State.Name, + ExitAction = () => MonoBehaviourUtil.Instance.OnUpdateEvent -= Action + }.Register(); + } + + void Action() { +#pragma warning disable CS0618 + particleSystem.startSpeed = action.emissionSpeed.Value; +#pragma warning restore CS0618 + } + } + + #endregion + + #region PlayParticleEmitter + + private static bool GetNetworkDataFromAction(EntityNetworkData data, PlayParticleEmitter action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, PlayParticleEmitter action) { + if (action.emit == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var particleSystem = gameObject.GetComponent(); + if (particleSystem == null) { + return; + } + + if (!particleSystem.isPlaying && action.emit.Value <= 0) { + particleSystem.Play(); + } else if (action.emit.Value > 0) { + particleSystem.Emit(action.emit.Value); + } + } + + #endregion + + #region StopParticleEmitter + + private static bool GetNetworkDataFromAction(EntityNetworkData data, StopParticleEmitter action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, StopParticleEmitter action) { + if (action.gameObject == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var particleSystem = gameObject.GetComponent(); + if (particleSystem == null) { + return; + } + + if (particleSystem.isPlaying) { + particleSystem.Stop(); + } + } + + #endregion + + #region SetGameObject + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetGameObject action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetGameObject action) { + action.variable.Value = action.gameObject.Value; + } + + #endregion + + #region GetOwner + + private static bool GetNetworkDataFromAction(EntityNetworkData data, GetOwner action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, GetOwner action) { + action.storeGameObject.Value = action.Owner; + } + + #endregion + + #region GetHero + + private static bool GetNetworkDataFromAction(EntityNetworkData data, GetHero action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, GetHero action) { + var heroController = HeroController.instance; + action.storeResult.Value = heroController == null ? null : heroController.gameObject; + } + + #endregion + + #region GetChild + + private static bool GetNetworkDataFromAction(EntityNetworkData data, GetChild action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, GetChild action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + + var result = ReflectionHelper.CallMethod( + typeof(GetChild), + "DoGetChildByName", + gameObject, + action.childName.Value, + action.withTag.Value + ); + + action.storeResult.Value = result; + } + + #endregion + + #region FindChild + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FindChild action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FindChild action) { + if (action.Fsm == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var transform = gameObject.transform.Find(action.childName.Value); + action.storeResult.Value = transform == null ? null : transform.gameObject; + } + + #endregion + + #region FindGameObject + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FindGameObject action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FindGameObject action) { + if (action.withTag.Value == "Untagged") { + action.store.Value = GameObject.Find(action.objectName.Value); + return; + } + + if (string.IsNullOrEmpty(action.objectName.Value)) { + action.store.Value = GameObject.FindGameObjectWithTag(action.withTag.Value); + return; + } + + foreach (var gameObject in GameObject.FindGameObjectsWithTag(action.withTag.Value)) { + if (gameObject.name == action.objectName.Value) { + action.store.Value = gameObject; + return; + } + } + + action.store.Value = null; + } + + #endregion + + #region SetProperty + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetProperty action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetProperty action) { + action.targetProperty.SetValue(); + } + + #endregion + + #region SetParent + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetParent action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetParent action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var parent = action.parent.Value; + gameObject.transform.parent = parent != null ? parent.transform : null; + + if (action.resetLocalPosition.Value) { + gameObject.transform.localPosition = Vector3.zero; + } + + if (action.resetLocalRotation.Value) { + gameObject.transform.localRotation = Quaternion.identity; + } + + if (parent == null) { + var fsms = gameObject.GetComponents(); + foreach (var fsm in fsms) { + if (fsm.Fsm.Name.Equals("destroy_if_gameobject_null")) { + Object.Destroy(fsm); + + Logger.Debug($"De-parented object contained \"{fsm.Fsm.Name}\" FSM, removing it"); + } + } + } + } + + #endregion + + #region FindAlertRange + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FindAlertRange action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FindAlertRange action) { + action.storeResult.Value = AlertRange.Find(action.target.GetSafe(action), action.childName); + } + + #endregion + + #region GetParent + + private static bool GetNetworkDataFromAction(EntityNetworkData data, GetParent action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, GetParent action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + action.storeResult.Value = null; + return; + } + + var parent = gameObject.transform.parent; + if (parent == null) { + action.storeResult.Value = null; + return; + } + + action.storeResult.Value = parent.gameObject; + } + + #endregion + + #region SetVelocity2d + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetVelocity2d action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting SetVelocity2d network data, but entity is in registry"); + return false; + } + + var rigidbody = gameObject.GetComponent(); + if (rigidbody == null) { + return false; + } + + var vector = action.vector.IsNone ? rigidbody.velocity : action.vector.Value; + if (!action.x.IsNone) { + vector.x = action.x.Value; + } + + if (!action.y.IsNone) { + vector.y = action.y.Value; + } + + data.Packet.Write(vector.x); + data.Packet.Write(vector.y); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetVelocity2d action) { + var vector = new Vector2( + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var rigidbody = gameObject.GetComponent(); + if (rigidbody == null) { + return; + } + + rigidbody.velocity = vector; + } + + #endregion + + #region SetMeshRenderer + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetMeshRenderer action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetMeshRenderer action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var meshRenderer = gameObject.GetComponent(); + if (meshRenderer == null) { + return; + } + + meshRenderer.enabled = action.active.Value; + } + + #endregion + + #region SetPosition + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetPosition action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting SetPosition network data, but entity is in registry"); + return false; + } + + Vector3 vector3; + if (!action.vector.IsNone) { + vector3 = action.vector.Value; + } else { + if (action.space == Space.World) { + vector3 = gameObject.transform.position; + } else { + vector3 = gameObject.transform.localPosition; + } + } + + if (!action.x.IsNone) { + vector3.x = action.x.Value; + } + + if (!action.y.IsNone) { + vector3.y = action.y.Value; + } + + if (!action.z.IsNone) { + vector3.z = action.z.Value; + } + + data.Packet.Write(vector3.x); + data.Packet.Write(vector3.y); + data.Packet.Write(vector3.z); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetPosition action) { + Vector3 vector3; + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + + if (data == null) { + if (gameObject == null) { + return; + } + + if (!action.vector.IsNone) { + vector3 = action.vector.Value; + } else { + if (action.space == Space.World) { + vector3 = gameObject.transform.position; + } else { + vector3 = gameObject.transform.localPosition; + } + } + + if (!action.x.IsNone) { + vector3.x = action.x.Value; + } + + if (!action.y.IsNone) { + vector3.y = action.y.Value; + } + + if (!action.z.IsNone) { + vector3.z = action.z.Value; + } + } else { + vector3 = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + + if (gameObject == null) { + return; + } + } + + if (action.space == Space.World) { + gameObject.transform.position = vector3; + } else { + gameObject.transform.localPosition = vector3; + } + } + + #endregion + + #region SetRotation + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetRotation action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting SetPosition network data, but entity is in registry"); + return false; + } + + Vector3 vector3; + if (action.quaternion.IsNone) { + if (action.vector.IsNone) { + if (action.space == Space.Self) { + vector3 = gameObject.transform.localEulerAngles; + } else { + vector3 = gameObject.transform.eulerAngles; + } + } else { + vector3 = action.vector.Value; + } + } else { + vector3 = action.quaternion.Value.eulerAngles; + } + + if (!action.xAngle.IsNone) { + vector3.x = action.xAngle.Value; + } + + if (!action.yAngle.IsNone) { + vector3.y = action.yAngle.Value; + } + + if (!action.zAngle.IsNone) { + vector3.z = action.zAngle.Value; + } + + data.Packet.Write(vector3.x); + data.Packet.Write(vector3.y); + data.Packet.Write(vector3.z); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetRotation action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + + if (data == null) { + Logger.Error("No data passed for applying SetRotation action"); + return; + } + + var vector3 = new Vector3( + data.Packet.ReadFloat(), + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + + if (gameObject == null) { + return; + } + + if (action.space == Space.Self) { + gameObject.transform.localEulerAngles = vector3; + } else { + gameObject.transform.eulerAngles = vector3; + } + } + + #endregion + + #region ActivateGameObject + + private static bool GetNetworkDataFromAction(EntityNetworkData data, ActivateGameObject action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting ActivateGameObject network data, but entity is in registry"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, ActivateGameObject action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + void SetActiveRecursively(GameObject go, bool state) { + go.SetActive(state); + + foreach (UnityEngine.Component component in go.transform) { + SetActiveRecursively(component.gameObject, state); + } + } + + if (action.recursive.Value) { + SetActiveRecursively(gameObject, action.activate.Value); + } else { + gameObject.SetActive(action.activate.Value); + } + } + + #endregion + + #region Tk2dPlayAnimation + + private static bool GetNetworkDataFromAction(EntityNetworkData data, Tk2dPlayAnimation action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting Tk2dPlayAnimation network data, but entity is in registry"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, Tk2dPlayAnimation action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var animator = gameObject.GetComponent(); + if (animator == null) { + return; + } + + animator.Play(action.clipName.Value); + } + + #endregion + + #region Tk2dPlayFrame + + private static bool GetNetworkDataFromAction(EntityNetworkData data, Tk2dPlayFrame action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting Tk2dPlayFrame network data, but entity is in registry"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, Tk2dPlayFrame action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var animator = gameObject.GetComponent(); + if (animator == null) { + return; + } + + animator.PlayFromFrame(action.frame.Value); + } + + #endregion + + #region Tk2dPlayAnimationWithEvents + + private static bool GetNetworkDataFromAction(EntityNetworkData data, Tk2dPlayAnimationWithEvents action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting Tk2dPlayAnimationWithEvents network data, but entity is in registry"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, Tk2dPlayAnimationWithEvents action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var animator = gameObject.GetComponent(); + if (animator == null) { + return; + } + + animator.Play(action.clipName.Value); + } + + #endregion + + #region SpawnBlood + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SpawnBlood action) { + if (GlobalPrefabDefaults.Instance == null) { + return false; + } + + data.Packet.Write((short) action.spawnMin.Value); + data.Packet.Write((short) action.spawnMax.Value); + + data.Packet.Write(action.speedMin.Value); + data.Packet.Write(action.speedMax.Value); + data.Packet.Write(action.angleMin.Value); + data.Packet.Write(action.angleMax.Value); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SpawnBlood action) { + var spawnMin = data.Packet.ReadShort(); + var spawnMax = data.Packet.ReadShort(); + + var speedMin = data.Packet.ReadFloat(); + var speedMax = data.Packet.ReadFloat(); + + var angleMin = data.Packet.ReadFloat(); + var angleMax = data.Packet.ReadFloat(); + + var position = action.position.Value; + if (action.spawnPoint.Value != null) { + position += action.spawnPoint.Value.transform.position; + } + + GlobalPrefabDefaults.Instance.SpawnBlood( + position, + spawnMin, + spawnMax, + speedMin, + speedMax, + angleMin, + angleMax, + action.colorOverride.IsNone ? new Color?() : action.colorOverride.Value + ); + } + + #endregion + + #region SendEventByName + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SendEventByName action) { + if (action.eventTarget.gameObject.GameObject.Value == action.Fsm.GameObject.gameObject) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SendEventByName action) { + if (action.delay.Value < 1.0 / 1000.0) { + action.Fsm.Event(action.eventTarget, action.sendEvent.Value); + } else { + // We need to delay the event sending ourselves, because the FSM that we are executing in is not enabled + // The usual implementation of SendEventByName will thus not work + MonoBehaviourUtil.Instance.StartCoroutine(DelayEvent()); + + IEnumerator DelayEvent() { + yield return new WaitForSeconds(action.delay.Value); + + action.Fsm.Event(action.eventTarget, action.sendEvent.Value); + } + } + } + + #endregion + + #region SendEventByNameV2 + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SendEventByNameV2 action) { + if (action.eventTarget.gameObject.GameObject.Value == action.Fsm.GameObject.gameObject) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SendEventByNameV2 action) { + if (action.delay.Value < 1.0 / 1000.0) { + action.Fsm.Event(action.eventTarget, action.sendEvent.Value); + } else { + // We need to delay the event sending ourselves, because the FSM that we are executing in is not enabled + // The usual implementation of SendEventByNameV2 will thus not work + MonoBehaviourUtil.Instance.StartCoroutine(DelayEvent()); + + IEnumerator DelayEvent() { + yield return new WaitForSeconds(action.delay.Value); + + action.Fsm.Event(action.eventTarget, action.sendEvent.Value); + } + } + } + + #endregion + + #region SendHealthManagerDeathEvent + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SendHealthManagerDeathEvent action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SendHealthManagerDeathEvent action) { + var gameObject = action.target.OwnerOption == OwnerDefaultOption.UseOwner + ? action.Owner + : action.target.GameObject.Value; + + if (gameObject == null) { + return; + } + + var healthManager = gameObject.GetComponent(); + if (healthManager == null) { + return; + } + + healthManager.SendDeathEvent(); + } + + #endregion + + #region ActivateAllChildren + + private static bool GetNetworkDataFromAction(EntityNetworkData data, ActivateAllChildren action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, ActivateAllChildren action) { + var gameObject = action.gameObject.Value; + if (gameObject == null) { + return; + } + + foreach (UnityEngine.Component component in gameObject.transform) { + component.gameObject.SetActive(action.activate); + } + } + + #endregion + + #region SetBoxCollider2DSizeVector + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetBoxCollider2DSizeVector action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetBoxCollider2DSizeVector action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject1); + if (gameObject == null) { + return; + } + + var collider = gameObject.GetComponent(); + if (collider == null) { + return; + } + + if (!action.size.IsNone) { + collider.size = action.size.Value; + } + + if (!action.offset.IsNone) { + collider.offset = action.offset.Value; + } + } + + #endregion + + #region SetVelocityAsAngle + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetVelocityAsAngle action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting SetVelocityAsAngle network data, but entity is in registry"); + return false; + } + + data.Packet.Write(action.speed.Value); + data.Packet.Write(action.angle.Value); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetVelocityAsAngle action) { + var speed = data.Packet.ReadFloat(); + var angle = data.Packet.ReadFloat(); + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var rigidbody = gameObject.GetComponent(); + if (rigidbody == null) { + return; + } + + var x = speed * Mathf.Cos(angle * ((float) System.Math.PI / 180f)); + var y = speed * Mathf.Sin(angle * ((float) System.Math.PI / 180f)); + + rigidbody.velocity = new Vector2(x, y); + } + + #endregion + + #region iTweenMoveBy + + private static bool GetNetworkDataFromAction(EntityNetworkData data, iTweenMoveBy action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, iTweenMoveBy action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var id = ReflectionHelper.GetField(action, "itweenID"); + + var args = new Hashtable { + { "amount", action.vector.IsNone ? Vector3.zero : action.vector.Value }, + { + action.speed.IsNone ? "time" : "speed", + (float) (action.speed.IsNone + ? (action.time.IsNone ? 1.0 : action.time.Value) + : (double) action.speed.Value) + }, + { "delay", (float) (action.delay.IsNone ? 0.0 : (double) action.delay.Value) }, + { "easetype", action.easeType }, + { "looptype", action.loopType }, + { "oncomplete", "iTweenOnComplete" }, + { "oncompleteparams", id }, + { "onstart", "iTweenOnStart" }, + { "onstartparams", id }, + { "ignoretimescale", !action.realTime.IsNone && action.realTime.Value }, + { "space", action.space }, + { "name", action.id.IsNone ? "" : (object) action.id.Value }, + { "axis", action.axis == iTweenFsmAction.AxisRestriction.none ? "" : (object) Enum.GetName(typeof (iTweenFsmAction.AxisRestriction), action.axis) } + }; + + if (!action.orientToPath.IsNone) { + args.Add("orienttopath", action.orientToPath.Value); + } + + if (!action.lookAtObject.IsNone) { + args.Add("looktarget", + action.lookAtVector.IsNone + ? action.lookAtObject.Value.transform.position + : action.lookAtObject.Value.transform.position + action.lookAtVector.Value + ); + } else if (!action.lookAtVector.IsNone) { + args.Add("looktarget", action.lookAtVector.Value); + } + + if (!action.lookAtObject.IsNone || !action.lookAtVector.IsNone) { + args.Add("looktime", (float) (action.lookTime.IsNone ? 0.0 : (double) action.lookTime.Value)); + } + + ReflectionHelper.SetField(action, "itweenType", "move"); + + iTween.MoveBy(gameObject, args); + } + + #endregion + + #region iTweenScaleTo + + private static bool GetNetworkDataFromAction(EntityNetworkData data, iTweenScaleTo action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + return false; + } + + return action.loopType == iTween.LoopType.none; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, iTweenScaleTo action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var vector = action.vectorScale.IsNone ? Vector3.zero : action.vectorScale.Value; + + if (!action.transformScale.IsNone && action.transformScale.Value) { + vector = action.transformScale.Value.transform.localScale + vector; + } + + var id = ReflectionHelper.GetField(action, "itweenID"); + + iTween.ScaleTo(gameObject, iTween.Hash( + "scale", + vector, + "name", + action.id.IsNone ? "" : action.id.Value, + action.speed.IsNone ? "time" : "speed", + (float) (action.speed.IsNone ? action.time.IsNone ? 1.0 : action.time.Value : (double) action.speed.Value), + "delay", + (float) (action.delay.IsNone ? 0.0 : (double) action.delay.Value), + "easetype", + action.easeType, + "looptype", + action.loopType, + "oncomplete", + "iTweenOnComplete", + "oncompleteparams", + id, + "onstart", + "iTweenOnStart", + "onstartparams", + id, + "ignoretimescale", + (action.realTime.IsNone ? 0 : action.realTime.Value ? 1 : 0) > 0 + )); + } + + #endregion + + #region SetTag + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetTag action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetTag action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + gameObject.tag = action.tag.Value; + } + + #endregion + + #region DestroyObject + + private static bool GetNetworkDataFromAction(EntityNetworkData data, DestroyObject action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, DestroyObject action) { + var gameObject = action.gameObject.Value; + if (gameObject == null) { + return; + } + + var delay = action.delay.Value; + if (delay <= 0) { + Object.Destroy(gameObject); + } else { + Object.Destroy(gameObject, delay); + } + + if (action.detachChildren.Value) { + gameObject.transform.DetachChildren(); + } + } + + #endregion + + #region SetGravity2dScale + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetGravity2dScale action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting SetGravity2dScale network data, but entity is in registry"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetGravity2dScale action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var rigidBody = gameObject.GetComponent(); + if (rigidBody == null) { + return; + } + + rigidBody.gravityScale = action.gravityScale.Value; + } + + #endregion + + #region SetCollider + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetCollider action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return false; + } + + if (IsObjectInRegistry(gameObject)) { + Logger.Debug("Tried getting SetCollider network data, but entity is in registry"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetCollider action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var collider = gameObject.GetComponent(); + if (collider == null) { + return; + } + + collider.enabled = action.active.Value; + } + + #endregion + + #region SetStringValue + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetStringValue action) { + if (action.stringVariable == null || action.stringValue == null) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetStringValue action) { + action.stringVariable.Value = action.stringValue.Value; + } + + #endregion + + #region GetRandomChild + + private static bool GetNetworkDataFromAction(EntityNetworkData data, GetRandomChild action) { + if (!RandomActionValues.TryGetValue(action, out var queue)) { + return false; + } + + if (queue.Count == 0) { + Logger.Debug("Getting data for GetRandomChild has not enough items in queue"); + return false; + } + + var randomIndex = (int) queue.Dequeue(); + data.Packet.Write((byte) randomIndex); + + queue.Clear(); + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, GetRandomChild action) { + var randomIndex = data.Packet.ReadByte(); + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var childCount = gameObject.transform.childCount; + if (childCount == 0) { + return; + } + + action.storeResult.Value = gameObject.transform.GetChild(randomIndex).gameObject; + } + + #endregion + + #region DestroyComponent + + private static bool GetNetworkDataFromAction(EntityNetworkData data, DestroyComponent action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, DestroyComponent action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var component = gameObject.GetComponent(ReflectionUtils.GetGlobalType(action.component.Value)); + if (component == null) { + return; + } + + Object.Destroy(component); + } + + #endregion + + #region AddComponent + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AddComponent action) { + if (action.removeOnExit.Value) { + Logger.Debug("Tried getting data for AddComponent action, but removeOnExit is true"); + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AddComponent action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var component = gameObject.AddComponent(ReflectionUtils.GetGlobalType(action.component.Value)); + action.storeComponent.Value = component; + } + + #endregion + + #region PreBuildTK2DSprites + + private static bool GetNetworkDataFromAction(EntityNetworkData data, PreBuildTK2DSprites action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, PreBuildTK2DSprites action) { + var gameObject = action.gameObject.Value; + if (gameObject == null) { + return; + } + + tk2dSprite[] sprites; + + if (action.useChildren) { + sprites = gameObject.GetComponentsInChildren(true); + } else { + sprites = gameObject.GetComponents(); + } + + foreach (var sprite in sprites) { + sprite.ForceBuild(); + } + } + + #endregion + + #region GetPosition + + private static bool GetNetworkDataFromAction(EntityNetworkData data, GetPosition action) { + Logger.Debug($"Getting network data for GetPosition: {action.Fsm.GameObject.name}, {action.Fsm.Name}"); + + return action.Fsm.GameObject.name.StartsWith("Colosseum Manager") && action.Fsm.Name.Equals("Battle Control"); + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, GetPosition action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var vector3 = action.space == Space.World ? gameObject.transform.position : gameObject.transform.localPosition; + action.vector.Value = vector3; + action.x.Value = vector3.x; + action.y.Value = vector3.y; + action.z.Value = vector3.z; + } + + #endregion + + #region CallMethodProper + + private static bool GetNetworkDataFromAction(EntityNetworkData data, CallMethodProper action) { + Logger.Debug($"Getting network data for CallMethodProper: {action.Fsm.GameObject.name}, {action.Fsm.Name}"); + + return action.Fsm.GameObject.name.StartsWith("Colosseum Manager") && + action.Fsm.Name.Equals("Battle Control") || + action.Fsm.GameObject.name.StartsWith("Mantis Lord Throne") && + action.Fsm.Name.Equals("Mantis Throne Main") || + action.Fsm.GameObject.name.Equals("Radiance") && + action.Fsm.Name.Equals("Control"); + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, CallMethodProper action) { + if (action.behaviour.Value == null) { + return; + } + + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var component = gameObject.GetComponent(action.behaviour.Value) as MonoBehaviour; + if (component == null) { + return; + } + + var type = component.GetType(); + var methodInfo = type.GetMethod(action.methodName.Value); + if (methodInfo == null) { + return; + } + + var parameterInfo = methodInfo.GetParameters(); + + object obj; + if (parameterInfo.Length == 0) { + obj = methodInfo.Invoke(component, null); + } else { + var paramArray = new object[action.parameters.Length]; + + for (var i = 0; i < action.parameters.Length; i++) { + var fsmVar = action.parameters[i]; + fsmVar.UpdateValue(); + paramArray[i] = fsmVar.GetValue(); + } + + try { + obj = methodInfo.Invoke(component, paramArray); + } catch (Exception e) { + Logger.Error($"Error applying CallMethodProper:\n{e}"); + return; + } + } + + if (action.storeResult.Type == VariableType.Unknown) { + return; + } + + action.storeResult.SetValue(obj); + } + + #endregion + + #region AudioPlay + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioPlay action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioPlay action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null || !audioSource.enabled) { + return; + } + + var audioClip = action.oneShotClip.Value as AudioClip; + if (audioClip == null) { + audioSource.Play(); + + if (action.volume.IsNone) { + return; + } + + audioSource.volume = action.volume.Value; + return; + } + + if (!action.volume.IsNone) { + audioSource.PlayOneShot(audioClip, action.volume.Value); + return; + } + + audioSource.PlayOneShot(audioClip); + } + + #endregion + + #region AudioPlaySimple + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioPlaySimple action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioPlaySimple action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null) { + return; + } + + var audioClip = action.oneShotClip.Value as AudioClip; + if (audioClip == null) { + if (!audioSource.isPlaying) { + audioSource.Play(); + } + + if (!action.volume.IsNone) { + audioSource.volume = action.volume.Value; + } + } else { + if (!action.volume.IsNone) { + audioSource.PlayOneShot(audioClip, action.volume.Value); + } else { + audioSource.PlayOneShot(audioClip); + } + } + } + + #endregion + + #region AudioPlayerOneShot + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioPlayerOneShot action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioPlayerOneShot action) { + // TODO: delay? + + if (action.audioClips.Length == 0) { + return; + } + + var audioPlayerPrefab = action.audioPlayer.Value; + var audioPlayer = audioPlayerPrefab.Spawn( + action.spawnPoint.Value.transform.position, Quaternion.Euler(Vector3.up) + ); + + var audioSource = audioPlayer.GetComponent(); + + action.storePlayer.Value = audioPlayer; + + var randomWeightedIndex = ActionHelpers.GetRandomWeightedIndex(action.weights); + if (randomWeightedIndex != -1) { + var audioClip = action.audioClips[randomWeightedIndex]; + if (audioClip != null) { + audioSource.pitch = Random.Range(action.pitchMin.Value, action.pitchMax.Value); + audioSource.PlayOneShot(audioClip); + } + } + + audioSource.volume = action.volume.Value; + } + + #endregion + + #region AudioPlayerOneShotSingle + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioPlayerOneShotSingle action) { + if (action.audioPlayer.IsNone || action.spawnPoint.IsNone || action.spawnPoint.Value == null) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioPlayerOneShotSingle action) { + // TODO: delay? + + if (action.audioPlayer.IsNone || action.spawnPoint.IsNone || action.spawnPoint.Value == null) { + return; + } + + var audioPlayer = action.audioPlayer.Value; + var position = action.spawnPoint.Value.transform.position; + var up = Vector3.up; + + if (audioPlayer == null) { + return; + } + + audioPlayer = audioPlayer.Spawn(position, Quaternion.Euler(up)); + var audioSource = audioPlayer.GetComponent(); + action.storePlayer.Value = audioPlayer; + + var audioClip = action.audioClip.Value as AudioClip; + audioSource.pitch = Random.Range(action.pitchMin.Value, action.pitchMax.Value); + audioSource.volume = action.volume.Value; + + if (audioClip == null) { + return; + } + + audioSource.PlayOneShot(audioClip); + } + + #endregion + + #region SetAudioClip + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetAudioClip action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetAudioClip action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null) { + return; + } + + audioSource.clip = action.audioClip.Value as AudioClip; + } + + #endregion + + #region SetAudioPitch + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetAudioPitch action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetAudioPitch action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null) { + return; + } + + audioSource.pitch = action.pitch.Value; + } + + #endregion + + #region AudioStop + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioStop action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioStop action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null) { + return; + } + + audioSource.Stop(); + } + + #endregion + + #region SetAudioVolume + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetAudioVolume action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetAudioVolume action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null) { + return; + } + + audioSource.volume = action.volume.Value; + } + + #endregion + + #region AudioPlayRandom + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioPlayRandom action) { + if (action.audioClips.Length == 0) { + return false; + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioPlayRandom action) { + if (action.audioClips.Length == 0) { + return; + } + + var audioSource = action.gameObject.Value.GetComponent(); + + var randomWeightedIndex = ActionHelpers.GetRandomWeightedIndex(action.weights); + if (randomWeightedIndex == -1) { + return; + } + + var audioClip = action.audioClips[randomWeightedIndex]; + if (audioClip == null) { + return; + } + + audioSource.pitch = Random.Range(action.pitchMin.Value, action.pitchMax.Value); + audioSource.PlayOneShot(audioClip); + } + + #endregion + + #region AudioPlayInState + + private static bool GetNetworkDataFromAction(EntityNetworkData data, AudioPlayInState action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, AudioPlayInState action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var audioSource = gameObject.GetComponent(); + if (audioSource == null) { + return; + } + + if (!audioSource.isPlaying) { + audioSource.Play(); + } + + if (!action.volume.IsNone) { + audioSource.volume = action.volume.Value; + } + + var exitAction = () => { + audioSource.Stop(); + }; + + new ActionInState { + Fsm = action.Fsm, + StateName = action.State.Name, + ExitAction = exitAction + }.Register(); + } + + #endregion + + #region SpawnBloodTime + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SpawnBloodTime action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SpawnBloodTime action) { + var position = action.position.Value; + if (action.spawnPoint.Value != null) { + position += action.spawnPoint.Value.transform.position; + } + + var spawnMin = (short) action.spawnMin.Value; + var spawnMax = (short) action.spawnMax.Value; + + var speedMin = action.speedMin.Value; + var speedMax = action.speedMax.Value; + + var angleMin = action.angleMin.Value; + var angleMax = action.angleMax.Value; + + var color = action.colorOverride.IsNone ? new Color?() : action.colorOverride.Value; + + var coroutine = MonoBehaviourUtil.Instance.StartCoroutine(Behaviour()); + + new ActionInState { + Fsm = action.Fsm, + StateName = action.State.Name, + Coroutine = coroutine + }.Register(); + + IEnumerator Behaviour() { + while (true) { + yield return new WaitForSeconds(action.delay.Value); + + if (GlobalPrefabDefaults.Instance == null) { + break; + } + + GlobalPrefabDefaults.Instance.SpawnBlood( + position, + spawnMin, + spawnMax, + speedMin, + speedMax, + angleMin, + angleMax, + color + ); + } + } + } + + #endregion + + #region FlingObjectsFromGlobalPoolTime + + private static bool GetNetworkDataFromAction(EntityNetworkData data, FlingObjectsFromGlobalPoolTime action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, FlingObjectsFromGlobalPoolTime action) { + var coroutine = MonoBehaviourUtil.Instance.StartCoroutine(Behaviour()); + + new ActionInState { + Fsm = action.Fsm, + StateName = action.State.Name, + Coroutine = coroutine + }.Register(); + + IEnumerator Behaviour() { + while (true) { + yield return new WaitForSeconds(action.frequency.Value); + + if (action.gameObject.Value == null) { + break; + } + + var position = Vector3.zero; + + var spawnPoint = action.spawnPoint.Value; + if (spawnPoint != null) { + position = spawnPoint.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + } else if (!action.position.IsNone) { + position = action.position.Value; + } + + var numSpawns = Random.Range(action.spawnMin.Value, action.spawnMax.Value + 1); + for (var i = 0; i < numSpawns; i++) { + var gameObject = action.gameObject.Value.Spawn(position, Quaternion.Euler(Vector3.zero)); + + if (action.originVariationX != null) { + position.x += Random.Range(-action.originVariationX.Value, action.originVariationX.Value); + } + + if (action.originVariationY != null) { + position.y += Random.Range(-action.originVariationY.Value, action.originVariationY.Value); + } + + gameObject.transform.position = position; + + var rigidBody = gameObject.GetComponent(); + if (rigidBody == null) { + continue; + } + + var speed = Random.Range(action.speedMin.Value, action.speedMax.Value); + var angle = Random.Range(action.angleMin.Value, action.angleMax.Value); + + var x = speed * Mathf.Cos(angle * ((float) System.Math.PI / 180f)); + var y = speed * Mathf.Sin(angle * ((float) System.Math.PI / 180f)); + + rigidBody.velocity = new Vector2(x, y); + } + } + } + } + + #endregion + + #region SendMessage + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SendMessage action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SendMessage action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + object parameter = null; + switch (action.functionCall.ParameterType) { + case "Array": + parameter = action.functionCall.ArrayParameter.Values; + break; + case "Color": + parameter = action.functionCall.ColorParameter.Value; + break; + case "Enum": + parameter = action.functionCall.EnumParameter.Value; + break; + case "GameObject": + parameter = action.functionCall.GameObjectParameter.Value; + break; + case "Material": + parameter = action.functionCall.MaterialParameter.Value; + break; + case "Object": + parameter = action.functionCall.ObjectParameter.Value; + break; + case "Quaternion": + parameter = action.functionCall.QuaternionParameter.Value; + break; + case "Rect": + parameter = action.functionCall.RectParamater.Value; + break; + case "Texture": + parameter = action.functionCall.TextureParameter.Value; + break; + case "Vector2": + parameter = action.functionCall.Vector2Parameter.Value; + break; + case "Vector3": + parameter = action.functionCall.Vector3Parameter.Value; + break; + case "bool": + parameter = action.functionCall.BoolParameter.Value; + break; + case "float": + parameter = action.functionCall.FloatParameter.Value; + break; + case "int": + parameter = action.functionCall.IntParameter.Value; + break; + case "string": + parameter = action.functionCall.StringParameter.Value; + break; + } + + switch (action.delivery) { + case SendMessage.MessageType.SendMessage: + gameObject.SendMessage(action.functionCall.FunctionName, parameter, action.options); + break; + case SendMessage.MessageType.SendMessageUpwards: + gameObject.SendMessageUpwards(action.functionCall.FunctionName, parameter, action.options); + break; + case SendMessage.MessageType.BroadcastMessage: + gameObject.BroadcastMessage(action.functionCall.FunctionName, parameter, action.options); + break; + } + } + + #endregion + + #region SetCircleCollider + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetCircleCollider action) { + return action.gameObject != null; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetCircleCollider action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var collider = gameObject.GetComponent(); + if (collider != null) { + collider.enabled = action.active.Value; + } + } + + #endregion + + #region SetPolygonCollider + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetPolygonCollider action) { + return action.gameObject != null; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetPolygonCollider action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var collider = gameObject.GetComponent(); + if (collider != null) { + collider.enabled = action.active.Value; + } + } + + #endregion + + #region PreSpawnGameObjects + + private static bool GetNetworkDataFromAction(EntityNetworkData data, PreSpawnGameObjects action) { + if (EntitySpawnEvent != null) { + var spawnedEntity = false; + + var arr = action.storeArray.Values; + for (var i = 0; i < arr.Length; i++) { + var spawnedGo = (GameObject) arr[i]; + + if (EntitySpawnEvent.Invoke(new EntitySpawnDetails { + Type = EntitySpawnType.FsmAction, + Action = action, + GameObject = spawnedGo + })) { + Logger.Debug("Tried getting PreSpawnGameObjects network data, but spawned objects contains entity"); + spawnedEntity = true; + } + } + + if (spawnedEntity) { + return false; + } + } + + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, PreSpawnGameObjects action) { + if (action.prefab.Value == null) { + return; + } + + if (action.storeArray.IsNone) { + return; + } + + if (action.spawnAmount.Value <= 0 || action.spawnAmountMultiplier.Value <= 0) { + return; + } + + var length = action.spawnAmount.Value * action.spawnAmountMultiplier.Value; + action.storeArray.Resize(length); + + for (var i = 0; i < length; i++) { + var go = Object.Instantiate(action.prefab.Value); + go.SetActive(false); + action.storeArray.Values[i] = go; + } + } + + #endregion + + #region SetSpriteRenderer + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetSpriteRenderer action) { + return action.gameObject != null; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetSpriteRenderer action) { + var gameObject = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (gameObject == null) { + return; + } + + var spriteRenderer = gameObject.GetComponent(); + if (spriteRenderer != null) { + spriteRenderer.enabled = action.active.Value; + } + } + + #endregion + + #region MoveLiftChain + + private static bool GetNetworkDataFromAction(EntityNetworkData data, MoveLiftChain action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, MoveLiftChain action) { + var go = action.target.GetSafe(action); + if (go == null) { + return; + } + + var liftChain = go.GetComponent(); + if (liftChain == null) { + return; + } + + ReflectionHelper.CallMethod(action, "Apply", [liftChain]); + } + + #endregion + + #region StopLiftChain + + private static bool GetNetworkDataFromAction(EntityNetworkData data, StopLiftChain action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, StopLiftChain action) { + var go = action.target.GetSafe(action); + if (go == null) { + return; + } + + var liftChain = go.GetComponent(); + if (liftChain == null) { + return; + } + + ReflectionHelper.CallMethod(action, "Apply", [liftChain]); + } + + #endregion + + #region SetIsKinematic2d + + private static bool GetNetworkDataFromAction(EntityNetworkData data, SetIsKinematic2d action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, SetIsKinematic2d action) { + var go = action.Fsm.GetOwnerDefaultTarget(action.gameObject); + if (go == null) { + return; + } + + var rigidbody = go.GetComponent(); + if (rigidbody == null) { + return; + } + + rigidbody.isKinematic = action.isKinematic.Value; + } + + #endregion + + #region EndGGBossScene + + private static bool GetNetworkDataFromAction(EntityNetworkData data, EndGGBossScene action) { + return true; + } + + private static void ApplyNetworkDataFromAction(EntityNetworkData data, EndGGBossScene action) { + if (BossSceneController.Instance) { + BossSceneController.Instance.EndBossScene(); + } + } + + #endregion + + /// + /// Class that keeps track of an action that executes while in a certain state of the FSM. + /// + private class ActionInState { + /// + /// The FSM of the action that is executing. + /// + public HutongGames.PlayMaker.Fsm Fsm { get; init; } + + /// + /// The name of the state in which this action executes. + /// + public string StateName { get; init; } + + /// + /// The coroutine that should be stopped when the state is exited. + /// + public Coroutine Coroutine { private get; init; } + + /// + /// The action that should be executed when the state is exited. + /// + public System.Action ExitAction { private get; init; } + + /// + /// Register this action by adding it to the list. + /// + public void Register() { + ActionsInState.Add(this); + } + + /// + /// Call when the state is exited, will stop the coroutine and execute the exit action. + /// + public void ExitState() { + if (Coroutine != null) { + MonoBehaviourUtil.Instance.StopCoroutine(Coroutine); + } + + ExitAction?.Invoke(); + } + } +} diff --git a/HKMP/Game/Client/Entity/Action/FsmActionHooks.cs b/HKMP/Game/Client/Entity/Action/FsmActionHooks.cs new file mode 100644 index 00000000..d2f51a27 --- /dev/null +++ b/HKMP/Game/Client/Entity/Action/FsmActionHooks.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using Hkmp.Logging; +using HutongGames.PlayMaker; +using MonoMod.RuntimeDetour; +using UnityEngine.SceneManagement; + +namespace Hkmp.Game.Client.Entity.Action; + +/// +/// Static class for registering callbacks on the "OnEnter" method of an class. +/// +internal static class FsmActionHooks { + /// + /// Dictionary mapping types (subtypes of ) to an hook class. + /// + private static readonly Dictionary TypeEvents; + + /// + /// List of all registered hooks. Used to loop over and remove all. + /// + // ReSharper disable once CollectionNeverQueried.Local + private static readonly List Hooks; + + static FsmActionHooks() { + TypeEvents = new Dictionary(); + Hooks = new List(); + } + + /// + /// Register hooks for the FSM actions. + /// + public static void RegisterHooks() { + UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChanged; + } + + /// + /// Deregister hooks for the FSM actions. This will also clear the registered hooks for the type events. + /// + public static void DeregisterHooks() { + UnityEngine.SceneManagement.SceneManager.activeSceneChanged -= OnSceneChanged; + + ResetHooks(); + } + + /// + /// Register an action as callback on the "OnEnter" method of an class. + /// + /// The subtype of to register the callback for. + /// The action that will be called when the "OnEnter" method executes with the instance + /// as the parameter to the action. + public static void RegisterFsmStateActionType(Type type, Action action) { + if (!TypeEvents.TryGetValue(type, out var fsmActionHook)) { + fsmActionHook = new FsmActionHook(); + + var onEnterMethodInfo = type.GetMethod("OnEnter"); + + Hooks.Add(new Hook( + onEnterMethodInfo, + OnActionEntered + )); + + TypeEvents.Add(type, fsmActionHook); + } + + fsmActionHook.HookEvent += action; + } + + /// + /// Callback method on the "OnEnter" method for a specific class. + /// + /// The original method. + /// The instance on which it was called. + private static void OnActionEntered(Action orig, FsmStateAction self) { + orig(self); + + if (!TypeEvents.TryGetValue(self.GetType(), out var fsmActionHook)) { + Logger.Warn("Hook was fired but no associated hook class was found"); + return; + } + + fsmActionHook.InvokeEvent(self); + } + + /// + /// Callback method for when the scene changes, used to reset all hooks. + /// + private static void OnSceneChanged(Scene oldScene, Scene newScene) { + ResetHooks(); + } + + /// + /// Reset all hooks for the type events. + /// + private static void ResetHooks() { + foreach (var actionHook in TypeEvents.Values) { + actionHook.Clear(); + } + } + + /// + /// A wrapper class containing an event for all callbacks of a hook. + /// + private class FsmActionHook { + /// + /// Event for the callbacks on the hook. + /// + public event Action HookEvent; + + /// + /// Invokes all (if any) callbacks to this hook. + /// + /// The instance on which the hook triggered. + public void InvokeEvent(FsmStateAction fsmStateAction) { + HookEvent?.Invoke(fsmStateAction); + } + + /// + /// Clear all subscriptions to the hook event. + /// + public void Clear() { + HookEvent = null; + } + } +} diff --git a/HKMP/Game/Client/Entity/Action/HookedEntityAction.cs b/HKMP/Game/Client/Entity/Action/HookedEntityAction.cs new file mode 100644 index 00000000..311d844c --- /dev/null +++ b/HKMP/Game/Client/Entity/Action/HookedEntityAction.cs @@ -0,0 +1,28 @@ +using HutongGames.PlayMaker; + +namespace Hkmp.Game.Client.Entity.Action; + +/// +/// A hooked FSM action for an entity. +/// +internal class HookedEntityAction { + /// + /// The instance of the action that was hooked. + /// + public FsmStateAction Action { get; set; } + + /// + /// The index of the FSM in which the action was hooked. + /// + public int FsmIndex { get; set; } + + /// + /// The index of the state in which the action was hooked. + /// + public int StateIndex { get; set; } + + /// + /// The index of the hooked action. + /// + public int ActionIndex { get; set; } +} \ No newline at end of file diff --git a/HKMP/Game/Client/Entity/Component/ChallengePromptComponent.cs b/HKMP/Game/Client/Entity/Component/ChallengePromptComponent.cs new file mode 100644 index 00000000..c71e604f --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/ChallengePromptComponent.cs @@ -0,0 +1,93 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using HutongGames.PlayMaker.Actions; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the challenge prompt that appears for Mantis Lords. +internal class ChallengePromptComponent : EntityComponent { + /// + /// The game object that handles the challenge prompt pop-up. + /// + private readonly GameObject _promptObj; + + /// + /// The FSM corresponding to the challenge prompt object. + /// + private readonly PlayMakerFSM _promptFsm; + + public ChallengePromptComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + var hostObj = gameObject.Host; + var parent = hostObj.transform.parent; + var parentTransform = parent.Find("Challenge Prompt"); + if (!parentTransform) { + Logger.Debug("Could not find Challenge Prompt object"); + return; + } + + _promptObj = parent.Find("Challenge Prompt").gameObject; + _promptFsm = _promptObj.LocateMyFSM("Challenge Start"); + + _promptFsm.InsertMethod("Take Control", 6, () => { + var data = new EntityNetworkData { + Type = EntityComponentType.ChallengePrompt + }; + data.Packet.Write(0); + + SendData(data); + }); + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var type = data.Packet.ReadByte(); + + // If the player is a scene client we destroy the prompt, otherwise we start the fight by progressing the FSM + if (IsControlled) { + if (_promptObj != null) { + Object.Destroy(_promptObj); + } + } else { + // Remove actions that rely on the local player + _promptFsm.RemoveFirstAction("Challenge"); + _promptFsm.RemoveFirstAction("Challenge"); + + // Get some actions that we want to re-use in another state + var activateObjAction = _promptFsm.GetFirstAction("Take Control"); + var sendEventAction = _promptFsm.GetFirstAction("Take Control"); + + // Put these actions in the Challenge state for execution + _promptFsm.InsertAction("Challenge", activateObjAction, 0); + _promptFsm.InsertAction("Challenge", sendEventAction, 1); + + // Get the watch animation events action so we can get the FsmEvent is sends + var watchAnimationEvent = _promptFsm.GetFirstAction("Challenge Audio"); + + // Insert a method that sends the event to go to the next stage instead of waiting for the animation to finish + _promptFsm.InsertMethod("Challenge Audio", 1, () => { + _promptFsm.Fsm.Event(watchAnimationEvent.animationCompleteEvent); + }); + // Remove the original action + _promptFsm.RemoveFirstAction("Challenge Audio"); + + // Start the FSM from the state 'Challenge' + _promptFsm.SetState("Challenge"); + } + } + + /// + public override void Destroy() { + } +} diff --git a/HKMP/Game/Client/Entity/Component/ChildrenActivationComponent.cs b/HKMP/Game/Client/Entity/Component/ChildrenActivationComponent.cs new file mode 100644 index 00000000..4ca4b4fc --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/ChildrenActivationComponent.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the activation of the children of an entity. +internal class ChildrenActivationComponent : EntityComponent { + private readonly List _hostChildren; + private readonly List _clientChildren; + + private bool _lastActive; + + public ChildrenActivationComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + _hostChildren = gameObject.Host.GetChildren(); + if (_hostChildren.Count == 0) { + return; + } + + _lastActive = _hostChildren[0].activeSelf; + + _clientChildren = gameObject.Client.GetChildren(); + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback for checking the gravity scale each update. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + var newActive = _hostChildren[0].activeSelf; + if (newActive != _lastActive) { + _lastActive = newActive; + + var data = new EntityNetworkData { + Type = EntityComponentType.ChildrenActivation + }; + data.Packet.Write(newActive); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + if (!IsControlled) { + return; + } + + var newActive = data.Packet.ReadBool(); + + foreach (var child in _hostChildren) { + child.SetActive(newActive); + } + foreach (var child in _clientChildren) { + child.SetActive(newActive); + } + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/ClimberComponent.cs b/HKMP/Game/Client/Entity/Component/ClimberComponent.cs new file mode 100644 index 00000000..2df0a2c8 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/ClimberComponent.cs @@ -0,0 +1,39 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the climber behaviour of the entity. +internal class ClimberComponent : EntityComponent { + /// + /// The unity component of the entity. + /// + private readonly Climber _climber; + + public ClimberComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + Climber climber + ) : base(netClient, entityId, gameObject) { + _climber = climber; + _climber.enabled = false; + } + + /// + public override void InitializeHost() { + if (_climber != null) { + _climber.enabled = true; + } + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + } + + /// + public override void Destroy() { + } +} diff --git a/HKMP/Game/Client/Entity/Component/ColliderComponent.cs b/HKMP/Game/Client/Entity/Component/ColliderComponent.cs new file mode 100644 index 00000000..8a94b082 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/ColliderComponent.cs @@ -0,0 +1,88 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the unity component of an entity. +internal class ColliderComponent : EntityComponent { + /// + /// Host-client pair for the box collider of the entity. + /// + private readonly HostClientPair _collider; + + /// + /// Optional bool indicating whether the collider was last enabled. + /// + private bool? _lastEnabled; + + public ColliderComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair collider + ) : base(netClient, entityId, gameObject) { + _collider = collider; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdateCollider; + } + + /// + /// Callback for checking the collider each update. + /// + private void OnUpdateCollider() { + if (IsControlled) { + return; + } + + if (_collider.Host == null) { + return; + } + + var newEnabled = _collider.Host.enabled; + if (!_lastEnabled.HasValue || newEnabled != _lastEnabled.Value) { + Logger.Info($"Collider of {GameObject.Host.name} enabled changed to: {newEnabled}"); + _lastEnabled = newEnabled; + + var data = new EntityNetworkData { + Type = EntityComponentType.Collider + }; + data.Packet.Write(newEnabled); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + Logger.Info($"Received collider update for {GameObject.Client.name}"); + + if (!IsControlled) { + Logger.Info(" Entity was not controlled"); + return; + } + + var enabled = data.Packet.ReadBool(); + if (_collider.Host != null) { + _collider.Host.enabled = enabled; + } + + if (_collider.Client != null) { + _collider.Client.enabled = enabled; + } + + Logger.Info($" Enabled: {enabled}"); + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdateCollider; + } +} diff --git a/HKMP/Game/Client/Entity/Component/ComponentFactory.cs b/HKMP/Game/Client/Entity/Component/ComponentFactory.cs new file mode 100644 index 00000000..1d6f86b0 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/ComponentFactory.cs @@ -0,0 +1,84 @@ +using System; +using Hkmp.Networking.Client; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// Factory class that instantiates by type and additional parameters. +/// +internal static class ComponentFactory { + /// + /// Instantiate an by their type. + /// + /// The type of the component. + /// The net client for passing to the constructor of the component. + /// The entity ID for passing to the constructor of the component. + /// The host and client objects for passing to the constructor of the component. + /// The instantiated entity component. + /// Thrown if the type is not one that can be instantiated + /// here. + public static EntityComponent InstantiateByType( + EntityComponentType type, + NetClient netClient, + ushort entityId, + HostClientPair objects + ) { + Rigidbody2D rigidBody; + + switch (type) { + case EntityComponentType.Rotation: + return new RotationComponent(netClient, entityId, objects); + case EntityComponentType.Velocity: + rigidBody = objects.Host.GetComponent(); + return new VelocityComponent(netClient, entityId, objects, rigidBody); + case EntityComponentType.GravityScale: + rigidBody = objects.Host.GetComponent(); + return new GravityScaleComponent(netClient, entityId, objects, rigidBody); + case EntityComponentType.ZPosition: + return new ZPositionComponent(netClient, entityId, objects); + case EntityComponentType.EnemySpawner: + var spawnerClient = objects.Client.GetComponent(); + var spawnerHost = objects.Host.GetComponent(); + + return new EnemySpawnerComponent(netClient, entityId, objects, new HostClientPair { + Client = spawnerClient, + Host = spawnerHost + }); + case EntityComponentType.ChildrenActivation: + return new ChildrenActivationComponent(netClient, entityId, objects); + case EntityComponentType.SpawnJar: + var spawnJarClient = objects.Client.GetComponent(); + var spawnJarHost = objects.Host.GetComponent(); + + return new SpawnJarComponent(netClient, entityId, objects, new HostClientPair { + Client = spawnJarClient, + Host = spawnJarHost + }); + case EntityComponentType.SpriteRenderer: + var spriteRendererClient = objects.Client.GetComponent(); + var spriteRendererHost = objects.Host.GetComponent(); + + return new SpriteRendererComponent(netClient, entityId, objects, new HostClientPair { + Client = spriteRendererClient, + Host = spriteRendererHost + }); + case EntityComponentType.ChallengePrompt: + return new ChallengePromptComponent(netClient, entityId, objects); + case EntityComponentType.Music: + if (MusicComponent.CreateInstance(netClient, entityId, objects, out var musicComponent)) { + return musicComponent; + } + + return null; + case EntityComponentType.FlipPlatform: + return new FlipPlatformComponent(netClient, entityId, objects); + case EntityComponentType.DreamPlatform: + return new DreamPlatformComponent(netClient, entityId, objects); + case EntityComponentType.HazardRespawn: + return new HazardRespawnComponent(netClient, entityId, objects); + default: + throw new ArgumentOutOfRangeException(nameof(type), type, $"Could not instantiate entity component for type: {type}"); + } + } +} diff --git a/HKMP/Game/Client/Entity/Component/DamageHeroComponent.cs b/HKMP/Game/Client/Entity/Component/DamageHeroComponent.cs new file mode 100644 index 00000000..9fbe3c8c --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/DamageHeroComponent.cs @@ -0,0 +1,73 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the damage that an entity deals to the player. +internal class DamageHeroComponent : EntityComponent { + /// + /// The host-client pair of unity components of the entity. + /// + private readonly HostClientPair _damageHero; + + /// + /// The last value of damage dealt for the damage hero. + /// + private int _lastDamageDealt; + + public DamageHeroComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair damageHero + ) : base(netClient, entityId, gameObject) { + _damageHero = damageHero; + _lastDamageDealt = damageHero.Host.damageDealt; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback method to check for damage hero updates. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + var newDamageDealt = _damageHero.Host.damageDealt; + if (newDamageDealt != _lastDamageDealt) { + _lastDamageDealt = newDamageDealt; + + var data = new EntityNetworkData { + Type = EntityComponentType.DamageHero + }; + data.Packet.Write((byte) newDamageDealt); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var damageDealt = data.Packet.ReadByte(); + _damageHero.Host.damageDealt = damageDealt; + _damageHero.Client.damageDealt = damageDealt; + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/DreamPlatformComponent.cs b/HKMP/Game/Client/Entity/Component/DreamPlatformComponent.cs new file mode 100644 index 00000000..5cc886a1 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/DreamPlatformComponent.cs @@ -0,0 +1,196 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the platforms that (dis)appear in dream sequences. +internal class DreamPlatformComponent : EntityComponent { + /// + /// Host-client pair of the DreamPlatform components. + /// + private readonly HostClientPair _platform; + + /// + /// The number of players currently in range of the platform. + /// + private ushort _numInRange; + /// + /// Whether the local player is in range of the platform. + /// + private bool _isInRange; + + public DreamPlatformComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + _platform = new HostClientPair { + Client = gameObject.Client.GetComponent(), + Host = gameObject.Host.GetComponent() + }; + + if (!_platform.Client.showOnEnable) { + _platform.Client.outerCollider.OnTriggerExited += OuterColliderOnTriggerExited; + _platform.Host.outerCollider.OnTriggerExited += OuterColliderOnTriggerExited; + + _platform.Client.innerCollider.OnTriggerEntered += InnerColliderOnTriggerEntered; + _platform.Host.innerCollider.OnTriggerEntered += InnerColliderOnTriggerEntered; + + On.DreamPlatform.Start += DreamPlatformOnStart; + } + } + + /// + /// Hook for the Start method of DreamPlatform. Used to prevent the original method from registering event + /// handlers to the trigger enter/exit. + /// + private void DreamPlatformOnStart(On.DreamPlatform.orig_Start orig, DreamPlatform self) { + if (self == _platform.Client || self == _platform.Host) { + return; + } + + orig(self); + } + + /// + /// Show the correct platform based on whether the entity is controlled or not. + /// + private void Show() { + if (IsControlled) { + _platform.Client.Show(); + } else { + _platform.Host.Show(); + } + } + + /// + /// Hide the correct platform based on whether the entity is controlled or not. + /// + private void Hide() { + if (IsControlled) { + _platform.Client.Hide(); + } else { + _platform.Host.Hide(); + } + } + + /// + /// Event handler for when the trigger for the outer collider of the platform is exited. + /// + private void OuterColliderOnTriggerExited(Collider2D collider, GameObject sender) { + // If we haven't been in range of the platform but trigger the exit, we do not want to update anything + if (!_isInRange) { + return; + } + + _isInRange = false; + + _numInRange--; + + // If the number of players in range is now 0 (or lower), we can hide the platform + if (_numInRange == 0) { + Hide(); + } + + var data = new EntityNetworkData { + Type = EntityComponentType.DreamPlatform + }; + + data.Packet.Write(_numInRange); + data.Packet.Write((byte) 0); + + SendData(data); + } + + /// + /// Event handler for when the trigger for the inner collider of the platform is entered. + /// + private void InnerColliderOnTriggerEntered(Collider2D collider, GameObject sender) { + _isInRange = true; + + EnterPlatform(); + + var data = new EntityNetworkData { + Type = EntityComponentType.DreamPlatform + }; + + data.Packet.Write(_numInRange); + data.Packet.Write((byte) 1); + + SendData(data); + } + + /// + /// Exit the platform and decrease the number of players in range. If the number hits zero, the platform will be + /// hidden. + /// + private void ExitPlatform() { + _numInRange--; + + // If the number of players in range is now 0 (or lower), we can hide the platform + if (_numInRange == 0) { + Hide(); + } + } + + /// + /// Enter the platform and increase the number of players in range. If the number is exactly 1, the platform will + /// be shown. + /// + private void EnterPlatform() { + _numInRange++; + + // If the number of players in range is exactly 1 now, we show the platform + if (_numInRange == 1) { + Show(); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var numInRange = data.Packet.ReadUShort(); + var action = data.Packet.ReadByte(); + + if (alreadyInSceneUpdate) { + _numInRange = numInRange; + + if (_numInRange > 0) { + Show(); + } + + return; + } + + if (action == 0) { + // Action 0 is exited collider + ExitPlatform(); + } else if (action == 1) { + // Action 1 is entered collider + EnterPlatform(); + } else { + Logger.Error($"Could not process unknown action for DreamPlatformComponent update: {action}"); + } + } + + /// + public override void Destroy() { + if (_platform.Client != null) { + _platform.Client.outerCollider.OnTriggerExited -= OuterColliderOnTriggerExited; + _platform.Client.innerCollider.OnTriggerEntered -= InnerColliderOnTriggerEntered; + } + + if (_platform.Host != null) { + _platform.Host.outerCollider.OnTriggerExited -= OuterColliderOnTriggerExited; + _platform.Host.innerCollider.OnTriggerEntered -= InnerColliderOnTriggerEntered; + } + + On.DreamPlatform.Start -= DreamPlatformOnStart; + } +} diff --git a/HKMP/Game/Client/Entity/Component/EnemySpawnerComponent.cs b/HKMP/Game/Client/Entity/Component/EnemySpawnerComponent.cs new file mode 100644 index 00000000..9b8f8e71 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/EnemySpawnerComponent.cs @@ -0,0 +1,92 @@ +using System.Collections; +using Hkmp.Game.Client.Entity.Action; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the EnemySpawner behaviour of the entity. +internal class EnemySpawnerComponent : EntityComponent { + /// + /// The unity component of the entity. + /// + private readonly HostClientPair _spawner; + + public EnemySpawnerComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair spawner + ) : base(netClient, entityId, gameObject) { + _spawner = spawner; + spawner.Client.enabled = false; + + On.EnemySpawner.Start += EnemySpawnerOnStart; + spawner.Host.OnEnemySpawned += OnEnemySpawned; + } + + /// + /// Hook for when the EnemySpawner starts to check whether the interpolation move happens. + /// + private void EnemySpawnerOnStart(On.EnemySpawner.orig_Start orig, EnemySpawner self) { + orig(self); + + if (self != _spawner.Host) { + return; + } + + // If the game object is still active after this method, we know that the interpolation was + // initiated + if (self.gameObject.activeSelf) { + var data = new EntityNetworkData { + Type = EntityComponentType.EnemySpawner + }; + SendData(data); + } + } + + /// + /// Hook for when the enemy object is spawned from the spawner so we can call the spawn event. + /// + /// The spawned game object. + private void OnEnemySpawned(GameObject obj) { + EntityFsmActions.CallEntitySpawnEvent(new EntitySpawnDetails { + Type = EntitySpawnType.EnemySpawnerComponent, + GameObject = obj + }); + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + iTween.MoveBy(_spawner.Client.gameObject, new Hashtable { + { + "amount", + _spawner.Client.moveBy + }, { + "time", + _spawner.Client.easeTime + }, { + "easetype", + _spawner.Client.easeType + }, { + "space", + Space.World + } + }); + } + + /// + public override void Destroy() { + On.EnemySpawner.Start -= EnemySpawnerOnStart; + + if (_spawner.Host != null) { + _spawner.Host.OnEnemySpawned -= OnEnemySpawned; + } + } +} diff --git a/HKMP/Game/Client/Entity/Component/EntityComponent.cs b/HKMP/Game/Client/Entity/Component/EntityComponent.cs new file mode 100644 index 00000000..4ffa5a57 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/EntityComponent.cs @@ -0,0 +1,95 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// Class for a generalizable part of an entity that requires networking for a specific feature. +/// +internal abstract class EntityComponent { + /// + /// The net client for networking. + /// + private readonly NetClient _netClient; + /// + /// The ID of the entity. + /// + private readonly ushort _entityId; + + /// + /// Host-client pair of the game objects of the entity. + /// + protected readonly HostClientPair GameObject; + + /// + /// Whether the entity is controlled. + /// + public bool IsControlled { get; set; } + + protected EntityComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) { + _netClient = netClient; + _entityId = entityId; + + GameObject = gameObject; + + IsControlled = true; + } + + /// + /// Send the given . + /// + /// The data to send. + protected void SendData(EntityNetworkData data) { + _netClient.UpdateManager.AddEntityData(_entityId, data); + } + + /// + /// Initializes the entity component when the client user is the scene host. + /// + public abstract void InitializeHost(); + + /// + /// Update the entity component with the given data. + /// + /// The data to update with. + /// Whether this data is from an already in scene packet. + public abstract void Update(EntityNetworkData data, bool alreadyInSceneUpdate); + /// + /// Destroy the entity component. + /// + public abstract void Destroy(); +} + +/// +/// Enum for data types. +/// +[JsonConverter(typeof(StringEnumConverter))] +internal enum EntityComponentType : ushort { + Fsm = 0, + Death, + Invincibility, + Rotation, + Collider, + DamageHero, + MeshRenderer, + Velocity, + GravityScale, + ZPosition, + Climber, + EnemySpawner, + ChildrenActivation, + SpawnJar, + SpriteRenderer, + ChallengePrompt, + Music, + FlipPlatform, + DreamPlatform, + HazardRespawn +} diff --git a/HKMP/Game/Client/Entity/Component/FlipPlatformComponent.cs b/HKMP/Game/Client/Entity/Component/FlipPlatformComponent.cs new file mode 100644 index 00000000..a1d88c1d --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/FlipPlatformComponent.cs @@ -0,0 +1,123 @@ +using System.Collections; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using Modding; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the flipping of platforms in Crystal Peak. +internal class FlipPlatformComponent : EntityComponent { + /// + /// Host-client pair of the FlipPlatform behaviours. + /// + private readonly HostClientPair _platform; + + /// + /// The last boolean value of the 'hitCancel' boolean in the behaviour. + /// + private bool _lastHitCancel; + + public FlipPlatformComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + _platform = new HostClientPair { + Client = gameObject.Client.GetComponent(), + Host = gameObject.Host.GetComponent() + }; + + On.FlipPlatform.Flip += FlipPlatformOnFlip; + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Hook method that fires when the Flip method is called on the behaviour. Will network that the platform + /// should be flipped regardless of scene host. + /// + private IEnumerator FlipPlatformOnFlip(On.FlipPlatform.orig_Flip orig, FlipPlatform self) { + if (self != _platform.Client && self != _platform.Host) { + yield return orig(self); + yield break; + } + + var data = new EntityNetworkData { + Type = EntityComponentType.FlipPlatform + }; + + data.Packet.Write((byte) 0); + + SendData(data); + + yield return orig(self); + } + + /// + /// Update method that checks the value of the 'hitCancel' boolean and conditionally networks it indicating that + /// the platform should be flipped back. + /// + private void OnUpdate() { + var platform = IsControlled ? _platform.Client : _platform.Host; + + var hitCancel = ReflectionHelper.GetField(platform, "hitCancel"); + if (hitCancel == _lastHitCancel) { + return; + } + + _lastHitCancel = hitCancel; + + if (!hitCancel) { + return; + } + + var data = new EntityNetworkData { + Type = EntityComponentType.FlipPlatform + }; + + data.Packet.Write((byte) 1); + + SendData(data); + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var platform = IsControlled ? _platform.Client : _platform.Host; + + var type = data.Packet.ReadByte(); + + if (type == 0) { + var idleRoutine = ReflectionHelper.GetField(platform, "idleRoutine"); + var flipRoutine = ReflectionHelper.GetField(platform, "flipRoutine"); + + if (idleRoutine != null) { + platform.StopCoroutine(idleRoutine); + } + + if (flipRoutine != null) { + return; + } + + var flipCall = ReflectionHelper.CallMethod(platform, "Flip"); + flipRoutine = platform.StartCoroutine(flipCall); + ReflectionHelper.SetField(platform, "flipRoutine", flipRoutine); + } else if (type == 1) { + ReflectionHelper.SetField(platform, "hitCancel", true); + } else { + Logger.Error("Received unknown type of data for FlipPlatform"); + } + } + + /// + public override void Destroy() { + On.FlipPlatform.Flip -= FlipPlatformOnFlip; + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/GravityScaleComponent.cs b/HKMP/Game/Client/Entity/Component/GravityScaleComponent.cs new file mode 100644 index 00000000..cb705dc9 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/GravityScaleComponent.cs @@ -0,0 +1,86 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the gravity scale of an entity. +internal class GravityScaleComponent : EntityComponent { + /// + /// The host unity component of the entity. + /// + private readonly Rigidbody2D _rigidbody; + + /// + /// The last value of the gravity scale. + /// + private float _lastScale; + + /// + /// The gravity scale received from updates to this component. Used to keep track of the gravity scale as we + /// cannot apply it the rigidbody of our host object as long as the host object is not active. + /// + private float? _receivedGravityScale; + + public GravityScaleComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + Rigidbody2D rigidbody + ) : base(netClient, entityId, gameObject) { + _rigidbody = rigidbody; + _lastScale = rigidbody.gravityScale; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback for checking the gravity scale each update. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + if (_receivedGravityScale.HasValue && GameObject.Host.activeInHierarchy) { + _rigidbody.gravityScale = _receivedGravityScale.Value; + _receivedGravityScale = null; + } + + var newGravityScale = _rigidbody.gravityScale; + if (!newGravityScale.Equals(_lastScale)) { + _lastScale = newGravityScale; + + var data = new EntityNetworkData { + Type = EntityComponentType.GravityScale + }; + data.Packet.Write(newGravityScale); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + if (!IsControlled) { + return; + } + + _receivedGravityScale = data.Packet.ReadFloat(); + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/HazardRespawnComponent.cs b/HKMP/Game/Client/Entity/Component/HazardRespawnComponent.cs new file mode 100644 index 00000000..ea1e71f7 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/HazardRespawnComponent.cs @@ -0,0 +1,225 @@ +using System.Collections.Generic; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using Modding; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the hazard respawn changes within certain bossfights. Currently only Radiance and Absolute +/// Radiance. +internal class HazardRespawnComponent : EntityComponent { + /// + /// The offset of the climb hazard respawn indices. + /// + private const int ClimbRespawnOffset = 1; + + /// + /// The Control FSM of the host entity. + /// + private readonly PlayMakerFSM _hostControlFsm; + /// + /// The game object that holds the Ascend Respawn objects. + /// + private readonly GameObject _ascendRespawnsObject; + /// + /// List of hazard respawn trigger behaviours. + /// + private readonly List _hazardRespawnTriggers; + + /// + /// The last state of 'active' of the Ascend Respawn object. + /// + private bool _lastActiveAscendsRespawns; + /// + /// The index of the highest respawn that has been triggered locally or received from the server. + /// + private int _highestRespawn = -1; + + public HazardRespawnComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + var host = gameObject.Host; + _hostControlFsm = host.LocateMyFSM("Control"); + if (!_hostControlFsm) { + Logger.Error("Could not find 'Control' FSM on Radiance host object"); + return; + } + + _hostControlFsm.InsertMethod("Climb Plats1", 7, () => { + Logger.Debug("Climb Plats1 state reached, sending hazard respawn data"); + + _highestRespawn = 0; + + var data = new EntityNetworkData { + Type = EntityComponentType.HazardRespawn + }; + + data.Packet.Write((byte) 0); + data.Packet.Write(_lastActiveAscendsRespawns); + + SendData(data); + }); + + _hazardRespawnTriggers = []; + + // Find the Ascend Respawns objects and add all HazardRespawnTrigger behaviours to the list + var hostParent = host.transform.parent; + if (hostParent) { + _ascendRespawnsObject = hostParent.gameObject.FindGameObjectInChildren("Ascend Respawns"); + if (_ascendRespawnsObject) { + var children = _ascendRespawnsObject.GetChildren(); + foreach (var child in children) { + var hazardRespawnTrigger = child.GetComponent(); + if (hazardRespawnTrigger) { + _hazardRespawnTriggers.Add(hazardRespawnTrigger); + + Logger.Debug($"Added '{hazardRespawnTrigger.gameObject.name}' to list of hazard respawn triggers"); + } + } + } + } + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + On.HazardRespawnTrigger.OnTriggerEnter2D += HazardRespawnTriggerOnTriggerEnter2D; + } + + /// + /// Hook for the "OnTriggerEnter2D" method of HazardRespawnTrigger to network that a certain hazard respawn trigger + /// has been reached. + /// + private void HazardRespawnTriggerOnTriggerEnter2D( + On.HazardRespawnTrigger.orig_OnTriggerEnter2D orig, + HazardRespawnTrigger self, + Collider2D otherCollider + ) { + var inactive = ReflectionHelper.GetField(self, "inactive"); + if (inactive || otherCollider.gameObject.layer != 9) { + orig(self, otherCollider); + return; + } + + orig(self, otherCollider); + + var name = self.gameObject.name; + + Logger.Debug($"Player triggered hazard respawn: {name}"); + + var numRespawn = ClimbRespawnOffset; + if (name.Contains("(") && name.Contains(")")) { + if (int.TryParse(name.Split('(')[1].Split(')')[0], out var result)) { + numRespawn += result; + } + } + + Logger.Debug($"Num respawn: {numRespawn}"); + + if (numRespawn > _highestRespawn) { + _highestRespawn = numRespawn; + + var data = new EntityNetworkData { + Type = EntityComponentType.HazardRespawn + }; + + data.Packet.Write((byte) numRespawn); + data.Packet.Write(_lastActiveAscendsRespawns); + + SendData(data); + + Logger.Debug("Num respawn exceeds highest respawn, sending to server"); + } else { + Logger.Debug("Num respawn is less than or equal to highest respawn"); + } + } + + /// + /// Update hook to check for changes in the active state of the Ascend Respawns object and network them. + /// + private void OnUpdate() { + if (IsControlled || !_ascendRespawnsObject) { + return; + } + + var active = _ascendRespawnsObject.activeSelf; + if (active != _lastActiveAscendsRespawns) { + _lastActiveAscendsRespawns = active; + + Logger.Debug($"Ascends Respawns object changed active: {active}, sending to server"); + + var data = new EntityNetworkData { + Type = EntityComponentType.HazardRespawn + }; + + data.Packet.Write((byte) _highestRespawn); + data.Packet.Write(active); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var numRespawn = data.Packet.ReadByte(); + var ascendsRespawnsActive = data.Packet.ReadBool(); + + if (IsControlled) { + _ascendRespawnsObject.SetActive(ascendsRespawnsActive); + } + + if (numRespawn <= _highestRespawn) { + Logger.Debug("Num respawn received is less than or equal to already registered highest respawn"); + return; + } + + if (numRespawn == 0) { + var p2AHazardVar = _hostControlFsm.FsmVariables.GetFsmGameObject("P2A Hazard"); + if (p2AHazardVar == null) { + Logger.Error("Could not find P2A Hazard variable in host FSM"); + return; + } + + var p2AHazard = p2AHazardVar.Value; + if (!p2AHazard) { + Logger.Error("P2A Hazard variable value is null in host FSM"); + return; + } + + Logger.Debug("Setting hazard respawn to plats hazard respawn"); + HeroController.instance.SetHazardRespawn(p2AHazard.transform.position, true); + } else { + if (numRespawn > _hazardRespawnTriggers.Count) { + Logger.Error($"Received numRespawn = {numRespawn}, but there is no matching hazard respawn trigger"); + return; + } + + // Loop over all earlier triggers and set them to inactive + HazardRespawnTrigger hazardRespawnTrigger; + for (var i = numRespawn; i > 0; i--) { + hazardRespawnTrigger = _hazardRespawnTriggers[i - 1]; + ReflectionHelper.SetField(hazardRespawnTrigger, "inactive", true); + } + + hazardRespawnTrigger = _hazardRespawnTriggers[numRespawn - 1]; + PlayerData.instance.SetHazardRespawn(hazardRespawnTrigger.respawnMarker); + + Logger.Debug($"Setting hazard respawn to climb phase respawn: {hazardRespawnTrigger.gameObject.name}"); + } + + _highestRespawn = numRespawn; + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + On.HazardRespawnTrigger.OnTriggerEnter2D -= HazardRespawnTriggerOnTriggerEnter2D; + } +} diff --git a/HKMP/Game/Client/Entity/Component/HealthManagerComponent.cs b/HKMP/Game/Client/Entity/Component/HealthManagerComponent.cs new file mode 100644 index 00000000..4dea2033 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/HealthManagerComponent.cs @@ -0,0 +1,181 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +// TODO: make sure that the data sent on death is saved as state on the server, so new clients entering +// scenes can start with the entity disabled/already dead +// TODO: periodically (or on hit) sync the health of the entity so on scene host transfer we can reset health +/// +/// This component manages the component of the entity. +internal class HealthManagerComponent : EntityComponent { + /// + /// Host-client pair of health manager components of the entity. + /// + private readonly HostClientPair _healthManager; + + /// + /// Boolean indicating whether the health manager of the client entity is allowed to die. + /// + private bool _allowDeath; + + /// + /// The last value for the "invincible" variable of the health manager. + /// + private bool _lastInvincible; + + /// + /// The last value for the "invincibleFromDirection" variable of the health manager. + /// + private int _lastInvincibleFromDirection; + + public HealthManagerComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair healthManager + ) : base(netClient, entityId, gameObject) { + _healthManager = healthManager; + + _lastInvincible = healthManager.Host.IsInvincible; + _lastInvincibleFromDirection = healthManager.Host.InvincibleFromDirection; + + On.HealthManager.Die += HealthManagerOnDie; + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback method for when the health manager dies. + /// + /// The original method. + /// The health manager instance. + /// The direction of the attack that caused the death. + /// The type of attack that caused the death. + /// Whether to ignore evasion. + private void HealthManagerOnDie( + On.HealthManager.orig_Die orig, + HealthManager self, + float? attackDirection, + AttackTypes attackType, + bool ignoreEvasion + ) { + if (self != _healthManager.Host && self != _healthManager.Client) { + orig(self, attackDirection, attackType, ignoreEvasion); + return; + } + + if (self == _healthManager.Client) { + if (!_allowDeath) { + Logger.Info("HealthManager Die was called on client entity"); + } else { + Logger.Info("HealthManager Die was called on client entity, but it is allowed death"); + + orig(self, attackDirection, attackType, ignoreEvasion); + + _allowDeath = false; + } + + return; + } + + Logger.Info("HealthManager Die was called on host entity"); + + orig(self, attackDirection, attackType, ignoreEvasion); + + var data = new EntityNetworkData { + Type = EntityComponentType.Death + }; + + if (attackDirection.HasValue) { + data.Packet.Write(true); + data.Packet.Write(attackDirection.Value); + } else { + data.Packet.Write(false); + } + + data.Packet.Write((byte)attackType); + + data.Packet.Write(ignoreEvasion); + + SendData(data); + } + + /// + /// Callback method for updates to check whether invincibility changes. + /// + private void OnUpdate() { + var data = new EntityNetworkData { + Type = EntityComponentType.Invincibility + }; + + var shouldSend = false; + + var newInvincible = _healthManager.Host.IsInvincible; + if (newInvincible != _lastInvincible) { + _lastInvincible = newInvincible; + shouldSend = true; + } + data.Packet.Write(newInvincible); + + var newInvincibleFromDir = _healthManager.Host.InvincibleFromDirection; + if (newInvincibleFromDir != _lastInvincibleFromDirection) { + _lastInvincibleFromDirection = newInvincibleFromDir; + shouldSend = true; + } + data.Packet.Write((byte) newInvincibleFromDir); + + if (shouldSend) { + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + Logger.Info("Received health manager update"); + + if (!IsControlled) { + Logger.Info(" Entity was not controlled"); + return; + } + + if (data.Type == EntityComponentType.Death) { + var attackDirection = new float?(); + if (data.Packet.ReadBool()) { + attackDirection = data.Packet.ReadFloat(); + } + + var attackType = (AttackTypes) data.Packet.ReadByte(); + var ignoreEvasion = data.Packet.ReadBool(); + + // Set a boolean to indicate that the client health manager is allowed to execute the Die method + _allowDeath = true; + _healthManager.Client.Die(attackDirection, attackType, ignoreEvasion); + } else if (data.Type == EntityComponentType.Invincibility) { + var newInvincible = data.Packet.ReadBool(); + var newInvincibleFromDir = data.Packet.ReadByte(); + + if (_healthManager.Host != null) { + _healthManager.Host.IsInvincible = newInvincible; + _healthManager.Host.InvincibleFromDirection = newInvincibleFromDir; + } + + if (_healthManager.Client != null) { + _healthManager.Client.IsInvincible = newInvincible; + _healthManager.Client.InvincibleFromDirection = newInvincibleFromDir; + } + } + } + + /// + public override void Destroy() { + On.HealthManager.Die -= HealthManagerOnDie; + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/MeshRendererComponent.cs b/HKMP/Game/Client/Entity/Component/MeshRendererComponent.cs new file mode 100644 index 00000000..198c856e --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/MeshRendererComponent.cs @@ -0,0 +1,82 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +// TODO: optimization idea: only add this component to objects where the FSM has an action that enables/disables the +// mesh renderer + +/// +/// This component manages the mesh renderer of the entity. +internal class MeshRendererComponent : EntityComponent { + /// + /// The host-client pair of unity components of the entity. + /// + private readonly HostClientPair _meshRenderer; + + /// + /// The last value of 'enabled' for the mesh renderer. + /// + private bool _lastEnabled; + + public MeshRendererComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair meshRenderer + ) : base(netClient, entityId, gameObject) { + _meshRenderer = meshRenderer; + _lastEnabled = meshRenderer.Host.enabled; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback method to check for mesh renderer updates. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + var newEnabled = _meshRenderer.Host.enabled; + if (newEnabled != _lastEnabled) { + _lastEnabled = newEnabled; + + var data = new EntityNetworkData { + Type = EntityComponentType.MeshRenderer + }; + data.Packet.Write(newEnabled); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var enabled = data.Packet.ReadBool(); + + if (_meshRenderer.Host != null) { + _meshRenderer.Host.enabled = enabled; + } + + if (_meshRenderer.Client != null) { + _meshRenderer.Client.enabled = enabled; + } + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/MusicComponent.cs b/HKMP/Game/Client/Entity/Component/MusicComponent.cs new file mode 100644 index 00000000..4a13fcab --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/MusicComponent.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections.Generic; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using HutongGames.PlayMaker.Actions; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using UnityEngine; +using UnityEngine.Audio; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the music that plays for boss fights. +internal class MusicComponent : EntityComponent { + /// + /// The file path of the embedded resource file for music data. + /// + private const string MusicDataFilePath = "Hkmp.Resource.music-data.json"; + + /// + /// Static list of MusicCueData instances that is loaded from an embedded JSON file. + /// Used for coupling IDs to music cues that can then be used for bidirectional lookups. + /// + private static readonly List MusicCueDataList; + /// + /// Static list of AudioMixerSnapshotData instances that is loaded from an embedded JSON file. + /// Used for coupling IDs to audio snapshots that can then be used for bidirectional lookups. + /// + private static readonly List SnapshotDataList; + + /// + /// The singleton instance of MusicComponent to ensure we only have one MusicComponent responsible for + /// synchronising music in a scene. + /// + private static MusicComponent _instance; + + /// + /// The index of the last played music cue, so we don't restart them unnecessarily. + /// + private byte _lastMusicCueIndex; + /// + /// The index of the last played audio snapshot, so we don't restart them unnecessarily. + /// + private byte _lastSnapshotIndex; + + /// + /// Static constructor responsible for loading data from the JSON and registering static hooks. + /// + static MusicComponent() { + var dataPair = FileUtil.LoadObjectFromEmbeddedJson< + (List, List) + >(MusicDataFilePath); + + MusicCueDataList = dataPair.Item1; + SnapshotDataList = dataPair.Item2; + + byte index = 1; + foreach (var data in MusicCueDataList) { + data.Index = index++; + } + + foreach (var data in SnapshotDataList) { + data.Index = index++; + } + } + + /// + /// Try to create a new instance of MusicComponent if it doesn't exist yet. This will prevent the creation of + /// more instances by keeping track of a singleton instance. + /// + /// The NetClient instance for networking data. + /// The entity ID that this component is attached to. + /// The host-client pair of game objects of the entity. + /// The created instance of MusicComponent if successful, otherwise null. + /// True if a new component could be created, false if a component already existed. + public static bool CreateInstance( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + out MusicComponent musicComponent + ) { + if (_instance == null) { + _instance = new MusicComponent(netClient, entityId, gameObject); + musicComponent = _instance; + return true; + } + + musicComponent = null; + return false; + } + + /// + /// Register hooks for music-related operations. + /// + public static void RegisterHooks() { + On.PlayMakerFSM.OnEnable += OnFsmEnable; + } + + /// + /// Deregister hooks for music-related operations. + /// + public static void DeregisterHooks() { + On.PlayMakerFSM.OnEnable -= OnFsmEnable; + } + + /// + /// Clear the current singleton instance of the component. + /// + public static void ClearInstance() { + _instance = null; + } + + /// + /// Get the MusicCueData instance from the list for which the given predicate holds. + /// + /// The predicate function that should return true for the MusicCueData that is + /// requested. + /// The MusicCueData for which the predicate holds, or null if no such instance could + /// be found. + /// True if the MusicCueData was found, false otherwise. + private static bool GetMusicCueData(Func predicate, out MusicCueData musicCueData) { + foreach (var data in MusicCueDataList) { + if (predicate.Invoke(data)) { + musicCueData = data; + return true; + } + } + + musicCueData = null; + return false; + } + + /// + /// Get the AudioMixerSnapshotData instance from the list for which the given predicate holds. + /// + /// The predicate function that should return true for the AudioMixerSnapshotData that is + /// requested. + /// The AudioMixerSnapshotData for which the predicate holds, or null if no such + /// instance could be found. + /// True if the AudioMixerSnapshotData was found, false otherwise. + private static bool GetAudioMixerSnapshotData(Func predicate, out AudioMixerSnapshotData snapshotData) { + foreach (var data in SnapshotDataList) { + if (predicate.Invoke(data)) { + snapshotData = data; + return true; + } + } + + snapshotData = null; + return false; + } + + private MusicComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + CustomHooks.ApplyMusicCueFromFsmAction += OnApplyMusicCue; + CustomHooks.TransitionToAudioSnapshotFromFsmAction += OnTransitionToAudioSnapshot; + } + + /// + /// Hook that is called when the AudioManager.ApplyMusicCue is called from an ApplyMusicCue FSM action. + /// Used to network the starting of a music cue for the scene host. + /// + /// The ApplyMusicCue FSM action responsible for the call. + private void OnApplyMusicCue(ApplyMusicCue action) { + Logger.Debug($"OnApplyMusicCue: {action.Fsm.GameObject.gameObject.name}, {action.Fsm.Name}"); + + if (IsControlled) { + return; + } + + var musicCue = action.musicCue.Value; + if (musicCue == null) { + return; + } + + foreach (var musicCueData in MusicCueDataList) { + if (musicCueData.MusicCue == musicCue || musicCueData.Name == musicCue.name) { + Logger.Debug($" Sending data, index: {musicCueData.Index}"); + + var networkData = new EntityNetworkData { + Type = EntityComponentType.Music + }; + networkData.Packet.Write(musicCueData.Index); + networkData.Packet.Write(_lastSnapshotIndex); + + SendData(networkData); + + _lastMusicCueIndex = musicCueData.Index; + + return; + } + } + } + + /// + /// Hook that is called when the AudioMixerSnapshot.TransitionTo is called from an TransitionToAudioSnapshot FSM + /// action. Used to network the starting of a audio snapshot for the scene host. + /// + /// The TransitionToAudioSnapshot FSM action responsible for the call. + private void OnTransitionToAudioSnapshot(TransitionToAudioSnapshot action) { + Logger.Debug($"OnTransitionToAudioSnapshot: {action.Fsm.GameObject.gameObject.name}, {action.Fsm.Name}"); + + if (action.Fsm.Name.Equals("Door Control")) { + Logger.Debug(" Was door control, allowing"); + return; + } + + if (IsControlled) { + return; + } + + var snapshot = action.snapshot.Value; + if (snapshot == null) { + return; + } + + foreach (var snapshotData in SnapshotDataList) { + if (snapshotData.Snapshot == snapshot || snapshotData.Name == snapshot.name) { + Logger.Debug($" Sending data, index: {snapshotData.Index}"); + + var networkData = new EntityNetworkData { + Type = EntityComponentType.Music + }; + networkData.Packet.Write(_lastMusicCueIndex); + networkData.Packet.Write(snapshotData.Index); + + SendData(networkData); + + _lastSnapshotIndex = snapshotData.Index; + + return; + } + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + Logger.Debug("Update MusicComponent"); + + if (!IsControlled) { + Logger.Debug(" Not controlled, skipping"); + return; + } + + var musicCueIndex = data.Packet.ReadByte(); + var snapshotIndex = data.Packet.ReadByte(); + + Logger.Debug($"Applying entity network data for music component with indices: {musicCueIndex}, {snapshotIndex}"); + + if (musicCueIndex != _lastMusicCueIndex) { + ApplyIndex(musicCueIndex); + _lastMusicCueIndex = musicCueIndex; + } + + if (snapshotIndex != _lastSnapshotIndex) { + ApplyIndex(snapshotIndex); + _lastSnapshotIndex = snapshotIndex; + } + + void ApplyIndex(byte index) { + foreach (var musicCueData in MusicCueDataList) { + if (musicCueData.Index != index) { + continue; + } + + if (musicCueData.MusicCue == null) { + continue; + } + + Logger.Debug($" Found music cue ({musicCueData.Name}, {musicCueData.Type}), applying it"); + + var gm = global::GameManager.instance; + gm.AudioManager.ApplyMusicCue(musicCueData.MusicCue, 0f, 0f, false); + return; + } + + foreach (var snapshotData in SnapshotDataList) { + if (snapshotData.Index != index) { + continue; + } + + if (snapshotData.Snapshot == null) { + continue; + } + + Logger.Debug(" Found audio mixer snapshot, transitioning to it"); + snapshotData.Snapshot.TransitionTo(0f); + return; + } + + Logger.Debug(" Could not find music cue or audio mixer snapshot matching ID"); + } + } + + /// + public override void Destroy() { + CustomHooks.ApplyMusicCueFromFsmAction -= OnApplyMusicCue; + CustomHooks.TransitionToAudioSnapshotFromFsmAction -= OnTransitionToAudioSnapshot; + } + + /// + /// Hook for when an FSM becomes enabled. Used to check for ApplyMusicCue or TransitionToAudioSnapshot actions + /// such that their audio data can be added to the lists of data. + /// + private static void OnFsmEnable(On.PlayMakerFSM.orig_OnEnable orig, PlayMakerFSM self) { + orig(self); + + foreach (var state in self.FsmStates) { + foreach (var action in state.Actions) { + if (action is ApplyMusicCue applyMusicCue) { + var musicCue = applyMusicCue.musicCue.Value as MusicCue; + if (musicCue == null) { + continue; + } + + Logger.Debug($"Found music cue '{musicCue.name}' in FSM '{self.Fsm.Name}', '{state.Name}'"); + + if (GetMusicCueData( + data => data.Name.Equals(musicCue.name), + out var musicCueData + )) { + Logger.Debug($" Adding to data with type: {musicCueData.Type}"); + musicCueData.MusicCue = musicCue; + } + } else if (action is TransitionToAudioSnapshot snapshotAction) { + var snapshot = snapshotAction.snapshot.Value as AudioMixerSnapshot; + if (snapshot == null) { + continue; + } + + Logger.Debug($"Found audio mixer snapshot '{snapshot.name}' in FSM '{self.Fsm.Name}', '{state.Name}'"); + + if (GetAudioMixerSnapshotData( + data => data.Name.Equals(snapshot.name), + out var snapshotData + )) { + Logger.Debug($" Adding to data with type: {snapshotData.Type}"); + snapshotData.Snapshot = snapshot; + } + } + } + } + } + + /// + /// Data for music cues, used for looking up the index or the music cue from index for networking purposes. + /// + private class MusicCueData { + public MusicCueType Type { get; set; } + public string Name { get; set; } + [JsonIgnore] + public byte Index { get; set; } + [JsonIgnore] + public MusicCue MusicCue { get; set; } + } + + /// + /// Data for audio snapshots, used for looking up the index or the audio snapshot from index for networking + /// purposes. + /// + private class AudioMixerSnapshotData { + public AudioMixerSnapshotType Type { get; set; } + public string Name { get; set; } + [JsonIgnore] + public byte Index { get; set; } + [JsonIgnore] + public AudioMixerSnapshot Snapshot { get; set; } + } + + /// + /// Enum for music cue types. + /// + [JsonConverter(typeof(StringEnumConverter))] + private enum MusicCueType { + None, + FalseKnight, + Hornet, + GGHornet, + MantisLords, + SoulMaster, + SoulMaster2, + GGHeavy, + EnemyBattle, + DreamFight, + Hive, + HiveKnight, + DungDefender, + BrokenVessel, + Nosk, + TheHollowKnight, + Greenpath, + Waterways + } + + /// + /// Enum for audio snapshot types. + /// + [JsonConverter(typeof(StringEnumConverter))] + private enum AudioMixerSnapshotType { + Silent, + None, + Off, + Normal + } +} diff --git a/HKMP/Game/Client/Entity/Component/RotationComponent.cs b/HKMP/Game/Client/Entity/Component/RotationComponent.cs new file mode 100644 index 00000000..ca4c973f --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/RotationComponent.cs @@ -0,0 +1,77 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the rotation of the entity. +internal class RotationComponent : EntityComponent { + /// + /// The last rotation of the entity. + /// + private Vector3 _lastRotation; + + public RotationComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdateRotation; + } + + /// + /// Callback method to check for rotation updates. + /// + private void OnUpdateRotation() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + var transform = GameObject.Host.transform; + + var newRotation = transform.rotation.eulerAngles; + if (newRotation != _lastRotation) { + _lastRotation = newRotation; + + var data = new EntityNetworkData { + Type = EntityComponentType.Rotation + }; + data.Packet.Write(newRotation.z); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var rotation = data.Packet.ReadFloat(); + + SetRotation(GameObject.Host); + SetRotation(GameObject.Client); + + void SetRotation(GameObject obj) { + var transform = obj.transform; + var eulerAngles = transform.eulerAngles; + transform.eulerAngles = new Vector3( + eulerAngles.x, + eulerAngles.y, + rotation + ); + } + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdateRotation; + } +} diff --git a/HKMP/Game/Client/Entity/Component/SpawnJarComponent.cs b/HKMP/Game/Client/Entity/Component/SpawnJarComponent.cs new file mode 100644 index 00000000..26e25361 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/SpawnJarComponent.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections; +using Hkmp.Game.Client.Entity.Action; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using Modding; +using MonoMod.Cil; +using MonoMod.RuntimeDetour.HookGen; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; +using Random = UnityEngine.Random; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the SpawnJarControl behaviour of the entity. +internal class SpawnJarComponent : EntityComponent { + /// + /// The unity component of the entity. + /// + private readonly HostClientPair _spawnJar; + + /// + /// Whether this is the first spawn for the jar. + /// + private bool _firstSpawn; + + public SpawnJarComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair spawnJar + ) : base(netClient, entityId, gameObject) { + _spawnJar = spawnJar; + spawnJar.Client.enabled = false; + + On.SpawnJarControl.OnEnable += SpawnJarControlOnEnable; + + // We can't simply hook the Behaviour method itself because it returns a state machine for the IEnumerator + // Instead we get the state machine target with MonoMod and get the hook that way + HookEndpointManager.Modify( + MonoMod.Utils.Extensions.GetStateMachineTarget( + ReflectionHelper.GetMethodInfo( + typeof(SpawnJarControl), + "Behaviour" + ) + ), + SpawnJarControlOnBehaviour + ); + + _firstSpawn = true; + } + + /// + /// Hook on the OnEnable method of the SpawnJarControl to network that it should start on the client-side. + /// + private void SpawnJarControlOnEnable(On.SpawnJarControl.orig_OnEnable orig, SpawnJarControl self) { + orig(self); + + if (self != _spawnJar.Host) { + return; + } + + if (_firstSpawn) { + return; + } + + var data = new EntityNetworkData { + Type = EntityComponentType.SpawnJar + }; + SendData(data); + + Logger.Debug("Sending SpawnJarComponent data OnEnable"); + } + + /// + /// IL hook for modifying the Behaviour method to grab the game object that is spawned from the jar. + /// + /// The IL context for the method. + private void SpawnJarControlOnBehaviour(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Goto the next call instruction Spawn twice, the first one is a the spawning of a nail strike + c.GotoNext(i => i.MatchCall(typeof(ObjectPoolExtensions), "Spawn")); + c.GotoNext(i => i.MatchCall(typeof(ObjectPoolExtensions), "Spawn")); + + // Move the cursor after the call instruction + c.Index++; + + // Emit a delegate that pops the current game object off the stack (our spawned object) and uses it + // before putting it back on the stack again + c.EmitDelegate>(gameObject => { + Logger.Debug($"SpawnJarControl spawned entity: {gameObject.name}"); + EntityFsmActions.CallEntitySpawnEvent(new EntitySpawnDetails { + Type = EntitySpawnType.SpawnJarComponent, + GameObject = gameObject + }); + + return gameObject; + }); + } catch (Exception e) { + Logger.Error($"Could not change SpawnJarControl#Behaviour IL:\n{e}"); + } + } + + /// + public override void InitializeHost() { + var data = new EntityNetworkData { + Type = EntityComponentType.SpawnJar + }; + SendData(data); + + Logger.Debug("Sending SpawnJarComponent data InitializeHost"); + + _firstSpawn = false; + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + Logger.Debug("Received SpawnJarComponent data"); + MonoBehaviourUtil.Instance.StartCoroutine(Behaviour()); + IEnumerator Behaviour() { + var jar = _spawnJar.Client; + + jar.transform.SetPositionZ(0.01f); + + jar.readyDust.Play(); + + yield return new WaitForSeconds(0.5f); + + var body = jar.GetComponent(); + body.angularVelocity = Random.Range(0, 2) > 0 ? -300f : 300f; + + jar.readyDust.Stop(); + jar.dustTrail.Play(); + + var sprite = jar.GetComponent(); + sprite.enabled = true; + + // The same while loop with a slightly higher threshold for breaking, because sometimes it doesn't reach + // that position quite yet due to network instability + while (jar.transform.position.y > jar.breakY + 0.1f) { + yield return null; + } + + GameCameras.instance.cameraShakeFSM.SendEvent("EnemyKillShake"); + + var position = jar.transform.position; + + jar.dustTrail.Stop(); + jar.ptBreakS.Play(); + jar.ptBreakL.Play(); + jar.strikeNailR.Spawn(position); + + body.angularVelocity = 0.0f; + + sprite.enabled = false; + + jar.breakSound.SpawnAndPlayOneShot(jar.audioSourcePrefab, position); + } + } + + /// + public override void Destroy() { + On.SpawnJarControl.OnEnable -= SpawnJarControlOnEnable; + + HookEndpointManager.Unmodify( + MonoMod.Utils.Extensions.GetStateMachineTarget( + ReflectionHelper.GetMethodInfo( + typeof(SpawnJarControl), + "Behaviour" + ) + ), + SpawnJarControlOnBehaviour + ); + } +} diff --git a/HKMP/Game/Client/Entity/Component/SpriteRendererComponent.cs b/HKMP/Game/Client/Entity/Component/SpriteRendererComponent.cs new file mode 100644 index 00000000..e4e94244 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/SpriteRendererComponent.cs @@ -0,0 +1,73 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the sprite renderer of the entity. +internal class SpriteRendererComponent : EntityComponent { + /// + /// The host-client pair of unity components of the entity. + /// + private readonly HostClientPair _spriteRenderer; + + /// + /// The last value of 'enabled' for the sprite renderer. + /// + private bool _lastEnabled; + + public SpriteRendererComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + HostClientPair spriteRenderer + ) : base(netClient, entityId, gameObject) { + _spriteRenderer = spriteRenderer; + _lastEnabled = spriteRenderer.Host.enabled; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback method to check for sprite renderer updates. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + var newEnabled = _spriteRenderer.Host.enabled; + if (newEnabled != _lastEnabled) { + _lastEnabled = newEnabled; + + var data = new EntityNetworkData { + Type = EntityComponentType.SpriteRenderer + }; + data.Packet.Write(newEnabled); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + var enabled = data.Packet.ReadBool(); + _spriteRenderer.Host.enabled = enabled; + _spriteRenderer.Client.enabled = enabled; + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/VelocityComponent.cs b/HKMP/Game/Client/Entity/Component/VelocityComponent.cs new file mode 100644 index 00000000..a3a2d76b --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/VelocityComponent.cs @@ -0,0 +1,91 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the velocity of the entity. +internal class VelocityComponent : EntityComponent { + /// + /// The host unity component of the entity. + /// + private readonly Rigidbody2D _rigidbody; + + /// + /// The last value of 'velocity' for the rigidbody. + /// + private Vector2 _lastVelocity; + + /// + /// The velocity received from updates to this component. Used to keep track of the velocity as we cannot + /// apply it the rigidbody of our host object as long as the host object is not active. + /// + private Vector2? _receivedVelocity; + + public VelocityComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject, + Rigidbody2D rigidbody + ) : base(netClient, entityId, gameObject) { + _rigidbody = rigidbody; + _lastVelocity = rigidbody.velocity; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback method to check for updates. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + if (_receivedVelocity.HasValue && GameObject.Host.activeInHierarchy) { + _rigidbody.velocity = _receivedVelocity.Value; + _receivedVelocity = null; + } + + var newVelocity = _rigidbody.velocity; + if (newVelocity != _lastVelocity) { + _lastVelocity = newVelocity; + + var data = new EntityNetworkData { + Type = EntityComponentType.Velocity + }; + data.Packet.Write(newVelocity.x); + data.Packet.Write(newVelocity.y); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + if (!IsControlled) { + return; + } + + var velocity = new Vector2( + data.Packet.ReadFloat(), + data.Packet.ReadFloat() + ); + _receivedVelocity = velocity; + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Component/ZPositionComponent.cs b/HKMP/Game/Client/Entity/Component/ZPositionComponent.cs new file mode 100644 index 00000000..d7adea53 --- /dev/null +++ b/HKMP/Game/Client/Entity/Component/ZPositionComponent.cs @@ -0,0 +1,81 @@ +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity.Component; + +/// +/// This component manages the Z-position of an entity. +internal class ZPositionComponent : EntityComponent { + /// + /// The last value of the Z position. + /// + private float _lastZ; + + public ZPositionComponent( + NetClient netClient, + ushort entityId, + HostClientPair gameObject + ) : base(netClient, entityId, gameObject) { + _lastZ = gameObject.Host.transform.position.z; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; + } + + /// + /// Callback for checking the Z-position each update. + /// + private void OnUpdate() { + if (IsControlled) { + return; + } + + if (GameObject.Host == null) { + return; + } + + var newZ = GameObject.Host.transform.position.z; + if (!_lastZ.Equals(newZ)) { + _lastZ = newZ; + + var data = new EntityNetworkData { + Type = EntityComponentType.ZPosition + }; + data.Packet.Write(newZ); + + SendData(data); + } + } + + /// + public override void InitializeHost() { + } + + /// + public override void Update(EntityNetworkData data, bool alreadyInSceneUpdate) { + if (!IsControlled) { + return; + } + + var newZ = data.Packet.ReadFloat(); + + SetZ(GameObject.Host); + SetZ(GameObject.Client); + + void SetZ(GameObject gameObject) { + var position = gameObject.transform.position; + gameObject.transform.position = new Vector3( + position.x, + position.y, + newZ + ); + } + } + + /// + public override void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + } +} diff --git a/HKMP/Game/Client/Entity/Entity.cs b/HKMP/Game/Client/Entity/Entity.cs index bff99ba0..d951f99d 100644 --- a/HKMP/Game/Client/Entity/Entity.cs +++ b/HKMP/Game/Client/Entity/Entity.cs @@ -1,243 +1,1434 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Hkmp.Collection; using Hkmp.Fsm; +using Hkmp.Game.Client.Entity.Action; +using Hkmp.Game.Client.Entity.Component; using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; using Hkmp.Util; using HutongGames.PlayMaker; +using Modding; +using On.HutongGames.PlayMaker.Actions; using UnityEngine; using Logger = Hkmp.Logging.Logger; using Vector2 = Hkmp.Math.Vector2; namespace Hkmp.Game.Client.Entity; -internal abstract class Entity : IEntity { +/// +/// A networked entity that is either sending behaviour updates to the server or is entirely controlled by +/// updates from the server. +/// +internal class Entity { + /// + /// The net client for networking. + /// private readonly NetClient _netClient; - private readonly EntityType _entityType; - private readonly byte _entityId; - private readonly Queue _stateVariableUpdates; - private bool _inUpdateState; + /// + /// Whether the entity has a parent entity. + /// + private readonly bool _hasParent; - protected readonly GameObject GameObject; + /// + /// The ID of the entity. + /// + public ushort Id { get; } - public bool IsControlled { get; private set; } - public bool AllowEventSending { get; set; } + /// + /// The type of the entity. + /// + public EntityType Type { get; } - // Dictionary containing per state name an array of transitions that the state normally has - // This is used to revert nulling out the transitions to prevent it from continuing - private readonly Dictionary _stateTransitions; + /// + /// Host-client pair for the game objects. + /// + public HostClientPair Object { get; } - protected PlayMakerFSM Fsm; + /// + /// Host-client pair for the sprite animators. + /// + private readonly HostClientPair _animator; - protected Entity( + /// + /// Bi-directional lookup for animation clip names to IDs. + /// + private readonly BiLookup _animationClipNameIds; + + /// + /// Host-client pair for the lists of FSMs on the entity. + /// + private readonly HostClientPair> _fsms; + + /// + /// Dictionary mapping data types to entity components. + /// + private readonly Dictionary _components; + + /// + /// Dictionary mapping FSM actions to their entity action data instances. + /// + private readonly Dictionary _hookedActions; + /// + /// Set of FSM action types that have been hooked to prevent duplicate hooks. + /// + private readonly HashSet _hookedTypes; + + /// + /// Whether the unity game object for the host entity was originally active. + /// + private bool _originalIsActive; + + /// + /// Whether the entity is controlled, i.e. in control by updates from the server. + /// + private bool _isControlled; + + /// + /// Whether the scene host is determined, or alternatively whether the entity has been determined to be a host or client entity. + /// + private bool _isSceneHostDetermined; + + /// + /// The last position of the entity. + /// + private Vector3 _lastPosition; + /// + /// The last scale of the entity. + /// + private Vector3 _lastScale; + /// + /// Whether the game object for the entity was last active. + /// + private bool _lastIsActive; + + /// + /// Whether to allow the client entity to animate itself. + /// + private bool _allowClientAnimation; + + /// + /// List of snapshots for each FSM of a host entity that contain latest values for state and FSM variables. + /// Used to check whether state/variables change and to update the server accordingly. + /// + private readonly List _fsmSnapshots; + + public Entity( NetClient netClient, - EntityType entityType, - byte entityId, - GameObject gameObject + ushort id, + EntityType type, + GameObject hostObject, + GameObject clientObject = null, + params EntityComponentType[] types ) { _netClient = netClient; - _entityType = entityType; - _entityId = entityId; - GameObject = gameObject; + Id = id; + + Type = type; + + _isControlled = true; + + if (clientObject == null) { + Object = new HostClientPair { + Host = hostObject, + Client = UnityEngine.Object.Instantiate( + hostObject, + hostObject.transform.position, + hostObject.transform.rotation + ) + }; + + DestroyManagedChildren(Object.Client); + + _hasParent = false; + } else { + Object = new HostClientPair { + Host = hostObject, + Client = clientObject + }; + + _hasParent = true; + } + + Object.Client.transform.localScale = _lastScale = _hasParent + ? Object.Host.transform.localScale + : Object.Host.transform.lossyScale; - _stateVariableUpdates = new Queue(); + // Store whether the host object was active and set it not active until we know if we are scene host + _originalIsActive = Object.Host.activeSelf; - _stateTransitions = new Dictionary(); + _lastIsActive = _hasParent ? Object.Host.activeSelf : Object.Host.activeInHierarchy; + + Logger.Info( + $"Entity '{Object.Host.name}' was original active: {_originalIsActive}, last active: {_lastIsActive}"); // Add a position interpolation component to the enemy so we can smooth out position updates - GameObject.AddComponent(); + Object.Client.AddComponent(); - // Register an update event to send position updates + // Register an update event to send position updates and check for certain value changes MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdate; - } - private void OnUpdate() { - // We don't send updates when this FSM is controlled or when we are not allowed to send events yet - if (IsControlled || !AllowEventSending) { - return; + _animator = new HostClientPair { + Host = Object.Host.GetComponent(), + Client = Object.Client.GetComponent() + }; + if (_animator.Host != null) { + _animationClipNameIds = new BiLookup(); + + var index = 0; + foreach (var animationClip in _animator.Host.Library.clips) { + if (_animationClipNameIds.ContainsFirst(animationClip.name)) { + continue; + } + + _animationClipNameIds.Add(animationClip.name, (byte)index++); + + if (index > byte.MaxValue) { + Logger.Error($"Too many animation clips to fit in a byte for entity: {Object.Client.name}"); + break; + } + } + + On.tk2dSpriteAnimator.Play_tk2dSpriteAnimationClip_float_float += OnAnimationPlayed; } + + // Always disallow the client object from being recycled, because it will simply be destroyed + On.ObjectPool.Recycle_GameObject += ObjectPoolOnRecycleGameObject; + + // Register a hook for the ActivateGameObject action to update the active state of the host game object + // before scene host is determined + ActivateGameObject.DoActivateGameObject += OnDoActivateGameObject; - var transformPos = GameObject.transform.position; + _fsms = new HostClientPair> { + Host = Object.Host.GetComponents().ToList(), + Client = Object.Client.GetComponents().ToList() + }; - _netClient.UpdateManager.UpdateEntityPosition( - _entityType, - _entityId, - new Vector2(transformPos.x, transformPos.y) - ); + _hookedActions = new Dictionary(); + _hookedTypes = new HashSet(); + _fsmSnapshots = new List(); + foreach (var fsm in _fsms.Host) { + ProcessHostFsm(fsm); + } + + // Remove all components that (re-)activate FSMs + foreach (var fsmActivator in Object.Client.GetComponents()) { + fsmActivator.StopAllCoroutines(); + UnityEngine.Object.Destroy(fsmActivator); + } + + foreach (var fsm in _fsms.Client) { + ProcessClientFsm(fsm); + } + + _components = new Dictionary(); + HandleComponents(types); + + HandleEnemyDeathEffects(); + + Object.Host.SetActive(false); + Object.Client.SetActive(false); + + CheckGodhome(); + + // // Debug code that logs each action's OnEnter method call + // foreach (var fsm in _fsms.Host) { + // foreach (var state in fsm.FsmStates) { + // foreach (var action in state.Actions) { + // FsmActionHooks.RegisterFsmStateActionType(action.GetType(), stateAction => { + // if (stateAction != action) { + // return; + // } + // + // Logger.Debug($"Entity ({Id}, {Type}) has host FSM enter action: {state.Name}, {action.GetType()}, {state.Actions.ToList().IndexOf(action)}"); + // }); + // } + // } + // } } - public void TakeControl() { - if (IsControlled) { - return; + /// + /// Destroy the children of the given game object that are registered entities in the system themselves. + /// Recursively go through the non-registered children as well. + /// + /// The root game object to start searching for children. + private void DestroyManagedChildren(GameObject root) { + foreach (var child in root.GetChildren()) { + if (EntityRegistry.TryGetEntry(child, out var entry)) { + Logger.Debug($"Found managed child: {child.name}, {entry.Type}, destroying it"); + UnityEngine.Object.Destroy(child); + } else { + DestroyManagedChildren(child); + } + } + } + + /// + /// Processes the given FSM for the host entity by hooking supported FSM actions. + /// + /// The Playmaker FSM to process. + private void ProcessHostFsm(PlayMakerFSM fsm) { + Logger.Info($"Processing host FSM: {fsm.Fsm.Name}"); + + EntityInitializer.CheckPreProcessFsm(fsm); + + for (var i = 0; i < fsm.FsmStates.Length; i++) { + var state = fsm.FsmStates[i]; + var stateName = state.Name; + + for (var j = 0; j < state.Actions.Length; j++) { + var action = state.Actions[j]; + if (!action.Enabled) { + continue; + } + + if (!EntityFsmActions.SupportedActionTypes.Contains(action.GetType())) { + continue; + } + + if (action.Fsm == null) { + Logger.Error($"FSM in action for state ({i}, {state.Name}), action ({j}, {action.GetType()}) is null"); + continue; + } + + _hookedActions[action] = new HookedEntityAction { + Action = action, + FsmIndex = _fsms.Host.IndexOf(fsm), + StateIndex = i, + ActionIndex = j + }; + Logger.Info( + $"Created hooked action: {action.GetType()}, {_fsms.Host.IndexOf(fsm)}, {stateName}, {j}"); + + if (_hookedTypes.Add(action.GetType())) { + FsmActionHooks.RegisterFsmStateActionType(action.GetType(), OnActionEntered); + } + } } - IsControlled = true; + var snapshot = new FsmSnapshot { + CurrentState = fsm.ActiveStateName + }; - InternalTakeControl(); + snapshot.Floats = fsm.FsmVariables.FloatVariables.Select(f => f.Value).ToArray(); + snapshot.Ints = fsm.FsmVariables.IntVariables.Select(i => i.Value).ToArray(); + snapshot.Bools = fsm.FsmVariables.BoolVariables.Select(b => b.Value).ToArray(); + snapshot.Strings = fsm.FsmVariables.StringVariables.Select(s => s.Value).ToArray(); + snapshot.Vector2s = fsm.FsmVariables.Vector2Variables.Select(v => v.Value).ToArray(); + snapshot.Vector3s = fsm.FsmVariables.Vector3Variables.Select(v => v.Value).ToArray(); + + _fsmSnapshots.Add(snapshot); + } + + /// + /// Processes the given FSM for the client entity by disabling it. + /// + /// The Playmaker FSM to process. + private void ProcessClientFsm(PlayMakerFSM fsm) { + Logger.Info($"Processing client FSM: {fsm.Fsm.Name}"); + EntityInitializer.InitializeFsm(fsm); + fsm.enabled = false; } - protected abstract void InternalTakeControl(); + /// + /// Check the host and client objects for components that are supported for networking. + /// + private void HandleComponents(EntityComponentType[] types) { + var addedComponentsString = $"Adding components to entity ({Object.Host.name}, {Id}):"; + + var hostHealthManager = Object.Host.GetComponent(); + var clientHealthManager = Object.Client.GetComponent(); + if (hostHealthManager != null && clientHealthManager != null) { + var healthManager = new HostClientPair { + Host = hostHealthManager, + Client = clientHealthManager + }; - public void ReleaseControl() { - if (!IsControlled) { - return; + var hmComponent = new HealthManagerComponent( + _netClient, + Id, + Object, + healthManager + ); + _components[EntityComponentType.Death] = hmComponent; + _components[EntityComponentType.Invincibility] = hmComponent; + + // Check if the object from the health manager is in any of the colosseum trial scenes and remove the + // geo drops from them if so + var goScene = hostHealthManager.gameObject.scene.name; + if (goScene is "Room_Colosseum_Bronze" or "Room_Colosseum_Silver" or "Room_Colosseum_Gold") { + clientHealthManager.SetGeoSmall(0); + clientHealthManager.SetGeoMedium(0); + clientHealthManager.SetGeoLarge(0); + } + + addedComponentsString += " Death Invincibility"; + } + + var climber = Object.Client.GetComponent(); + if (climber != null) { + _components[EntityComponentType.Climber] = new ClimberComponent( + _netClient, + Id, + Object, + climber + ); + _components[EntityComponentType.Rotation] = new RotationComponent( + _netClient, + Id, + Object + ); + + addedComponentsString += " Climber Rotation"; + } + + var hostCollider = Object.Host.GetComponent(); + var clientCollider = Object.Client.GetComponent(); + if (hostCollider != null && clientCollider != null) { + Logger.Info($"Adding collider component to entity: {Object.Host.name}"); + + var collider = new HostClientPair { + Host = hostCollider, + Client = clientCollider + }; + + _components[EntityComponentType.Collider] = new ColliderComponent( + _netClient, + Id, + Object, + collider + ); + + addedComponentsString += " Collider"; } - IsControlled = false; + var hostDamageHero = Object.Host.GetComponent(); + var clientDamageHero = Object.Client.GetComponent(); + if (hostDamageHero != null && clientDamageHero != null) { + Logger.Info($"Adding DamageHero component to entity: {Object.Host.name}"); - InternalReleaseControl(); + var damageHero = new HostClientPair { + Host = hostDamageHero, + Client = clientDamageHero + }; + + _components[EntityComponentType.DamageHero] = new DamageHeroComponent( + _netClient, + Id, + Object, + damageHero + ); + + addedComponentsString += " DamageHero"; + } + + var hostMeshRenderer = Object.Host.GetComponent(); + var clientMeshRenderer = Object.Client.GetComponent(); + if (hostMeshRenderer != null && clientMeshRenderer != null) { + Logger.Info($"Adding MeshRenderer component to entity: {Object.Host.name}"); + + var meshRenderer = new HostClientPair { + Host = hostMeshRenderer, + Client = clientMeshRenderer + }; + + _components[EntityComponentType.MeshRenderer] = new MeshRendererComponent( + _netClient, + Id, + Object, + meshRenderer + ); + + addedComponentsString += " MeshRenderer"; + } + + EntityInitializer.RemoveClientTypes(Object.Client); + + // Instantiate all types defined in the entity registry, which are passed to the constructor + foreach (var type in types) { + var component = ComponentFactory.InstantiateByType(type, _netClient, Id, Object); + if (component == null) { + Logger.Debug($"Could not instantiate component for type: {type}"); + } else { + _components[type] = component; + } + + addedComponentsString += $" {type}"; + } + + Logger.Debug(addedComponentsString); } - protected abstract void InternalReleaseControl(); + /// + /// Handle specifics for a set of enemies that rely on EnemyDeathEffects for additional enemies. + /// + private void HandleEnemyDeathEffects() { + string corpseName; + switch (Type) { + case EntityType.Ooma: + corpseName = "Corpse Jellyfish(Clone)"; + break; + case EntityType.Flukemon: + corpseName = "Corpse Flukeman(Clone)"; + break; + case EntityType.HuskHornhead: + corpseName = "Zombie Spider 2(Clone)"; + break; + case EntityType.WanderingHusk: + corpseName = "Zombie Spider 1(Clone)"; + break; + case EntityType.DungDefender: + corpseName = "Corpse Dung Defender(Clone)"; + break; + case EntityType.BrokenVessel: + corpseName = "Corpse Infected Knight(Clone)"; + break; + case EntityType.LostKin: + corpseName = "Corpse Infected Knight Dream(Clone)"; + break; + default: + return; + } + + Logger.Debug($"Entity ({Id}, {Type}) has corpse that is also enemy, deleting death effects and corpse from client entity"); + + var enemyDeathEffects = Object.Client.GetComponent(); + if (enemyDeathEffects == null) { + Logger.Debug(" EnemyDeathEffects is null, cannot remove"); + } + UnityEngine.Object.Destroy(enemyDeathEffects); - public void UpdatePosition(Vector2 position) { - var unityPos = new Vector3(position.X, position.Y); + var corpse = Object.Client.FindGameObjectInChildren(corpseName); + if (corpse != null) { + Logger.Debug($" Destroying corpse of client object: {corpse.name}"); + UnityEngine.Object.Destroy(corpse); + } else { + Logger.Debug(" Could not find corpse of client object"); + } + } - GameObject.GetComponent().SetNewPosition(unityPos); + /// + /// Checks whether this is an enemy in a godhome fight. If that's the case, the health manager of the client + /// object will have their death be registered as a trigger for the boss scene controller. This ensures that + /// fights will end on scene clients if the client objects die. + /// + private void CheckGodhome() { + var bossSceneControllers = UnityEngine.Object.FindObjectsOfType(); + var bossSceneController = bossSceneControllers.FirstOrDefault( + con => con.gameObject.scene.Equals(UnityEngine.SceneManagement.SceneManager.GetActiveScene()) + ); + if (bossSceneController == null) { + return; + } + + var hostHealthManager = Object.Host.GetComponent(); + if (hostHealthManager == null) { + return; + } + + var clientHealthManager = Object.Client.GetComponent(); + if (clientHealthManager == null) { + Logger.Debug($"Entity ({Id}, {Type}) has HealthManager on host but not on client"); + return; + } + + if (!bossSceneController.bosses.Contains(hostHealthManager)) { + return; + } + + Logger.Debug($"Entity ({Id}, {Type}) is contained in the boss scene controller, registering on death"); + + clientHealthManager.OnDeath += () => { + Logger.Debug("OnDeath triggered for health manager in boss scene controller"); + + var bossesLeft = ReflectionHelper.GetField(bossSceneController, "bossesLeft"); + ReflectionHelper.SetField(bossSceneController, "bossesLeft", bossesLeft - 1); + + ReflectionHelper.CallMethod(bossSceneController, "CheckBossesDead"); + }; } - public void UpdateState(byte state, List variables) { - if (IsInterruptingState(state)) { - Logger.Info("Received update is interrupting state, starting update"); + /// + /// Callback method for entering a hooked FSM action. + /// + /// The FSM action instance that was entered. + private void OnActionEntered(FsmStateAction self) { + if (_isControlled) { + return; + } + + if (!_hookedActions.TryGetValue(self, out var hookedEntityAction)) { + return; + } + + Logger.Info( + $"Entity ({Id}, {Type}) hooked action: {self.Fsm.Name}, {self.State.Name}, {self.GetType()} ({hookedEntityAction.FsmIndex}, {hookedEntityAction.StateIndex}, {hookedEntityAction.ActionIndex})"); + + var networkData = new EntityNetworkData { + Type = EntityComponentType.Fsm + }; + + if (_fsms.Host.Count > 1) { + networkData.Packet.Write((byte)hookedEntityAction.FsmIndex); + } + + networkData.Packet.Write((byte)hookedEntityAction.StateIndex); + networkData.Packet.Write((byte)hookedEntityAction.ActionIndex); - _inUpdateState = true; + // Only if the GetNetworkDataFromAction method returns true do we add the entity data + // for sending + if (EntityFsmActions.GetNetworkDataFromAction(networkData, self)) { + _netClient.UpdateManager.AddEntityData(Id, networkData); + } + } - // Since we interrupt everything that was going on, we can clear the existing queue - _stateVariableUpdates.Clear(); + /// + /// Callback method for handling updates. + /// + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator")] + private void OnUpdate() { + if (Object.Host == null) { + if (_lastIsActive) { + // If the host object was active, but now it null (or destroyed in Unity), we can send + // to the server that the entity can be regarded as inactive + if (Object.Client == null) { + Logger.Info($"Entity ({Id}, {Type}) host and client object is null (or destroyed) and was active"); + } else { + Logger.Info($"Entity '{Object.Client.name}' host object is null (or destroyed) and was active"); + } - StartQueuedUpdate(state, variables); + _lastIsActive = false; + + _netClient.UpdateManager.UpdateEntityIsActive( + Id, + false + ); + } return; } - if (!_inUpdateState) { - Logger.Info("Queue is empty, starting new update"); + var hostObjectActive = Object.Host.activeSelf; - _inUpdateState = true; + if (_isControlled) { + if (hostObjectActive) { + if (!_isSceneHostDetermined) { + Logger.Info($"Entity '{Object.Host.name}' host object became active, but scene host is not determined yet, re-disabling for now"); + _originalIsActive = true; + } else { + Logger.Info($"Entity '{Object.Host.name}' host object became active, re-disabling"); + } - // If we are not currently updating the state, we can queue it immediately - StartQueuedUpdate(state, variables); + Object.Host.SetActive(false); + } return; } - Logger.Info("Queue is non-empty, queueing new update"); + var transform = Object.Host.transform; + + var newPosition = _hasParent ? transform.localPosition : transform.position; + if (newPosition != _lastPosition) { + _lastPosition = newPosition; + + _netClient.UpdateManager.UpdateEntityPosition( + Id, + new Vector2(newPosition.x, newPosition.y) + ); + } + + const float epsilon = 0.0001f; + + var newScale = _hasParent ? transform.localScale : transform.lossyScale; + if (newScale != _lastScale) { + var scaleData = new EntityUpdate.ScaleData { + origin = true + }; + + if (newScale.x != _lastScale.x) { + scaleData.x = true; + scaleData.xScale = newScale.x; + + if (System.Math.Abs(newScale.x - _lastScale.x * -1) < epsilon) { + scaleData.xFlipped = true; + } + } + + if (newScale.y != _lastScale.y) { + scaleData.y = true; + scaleData.yScale = newScale.y; + + if (System.Math.Abs(newScale.y - _lastScale.y * -1) < epsilon) { + scaleData.yFlipped = true; + } + } + + if (newScale.z != _lastScale.z) { + scaleData.z = true; + scaleData.zScale = newScale.z; + + if (System.Math.Abs(newScale.z - _lastScale.z * -1) < epsilon) { + scaleData.zFlipped = true; + } + } + + _netClient.UpdateManager.UpdateEntityScale(Id, scaleData); + + _lastScale = newScale; + } + + var newActive = _hasParent ? Object.Host.activeSelf : Object.Host.activeInHierarchy; + if (newActive != _lastIsActive) { + _lastIsActive = newActive; + + Logger.Info($"Entity '{Object.Host.name}' changed active: {newActive}"); + + _netClient.UpdateManager.UpdateEntityIsActive( + Id, + newActive + ); + } + + for (byte fsmIndex = 0; fsmIndex < _fsms.Host.Count; fsmIndex++) { + var fsm = _fsms.Host[fsmIndex]; + var snapshot = _fsmSnapshots[fsmIndex]; + + var data = new EntityHostFsmData(); + + var lastStateName = snapshot.CurrentState; + if (fsm.ActiveStateName != lastStateName) { + snapshot.CurrentState = fsm.ActiveStateName; + + data.Types.Add(EntityHostFsmData.Type.State); + data.CurrentState = (byte) Array.IndexOf(fsm.FsmStates, fsm.Fsm.ActiveState); + + Logger.Debug($"Entity ({Id}, {Type}) host changed states: {lastStateName}, {fsm.ActiveStateName}"); + } + + // Define a method that allows generalization of checking for changes in all FSM variables + void CondAddData( + TVar[] fsmVars, + TBase[] snapshotArray, + Func fsmVarValue, + EntityHostFsmData.Type type, + Dictionary dataDict + ) { + for (byte i = 0; i < fsmVars.Length; i++) { + var fsmVar = fsmVars[i]; + var snapshotVar = snapshotArray[i]; + + if (snapshotVar == null) { + Logger.Warn("No last value found for FSM var"); + continue; + } + + var value = fsmVarValue.Invoke(fsmVar); + if (!value.Equals(snapshotVar)) { + // Update the value in the snapshot since it changed + snapshotArray[i] = value; + + data.Types.Add(type); + // Some funky casting here to make sure we can use this method with Vector2 and Vector3 + // Since there is a mismatch between our Hkmp.Math.Vector2 and Unity's Vector2 + // But our types have explicit converters, so casting is possible + if (value is UnityEngine.Vector2 vec2) { + dataDict[i] = (TData) (object) (Vector2) vec2; + } else if (value is Vector3 vec3) { + dataDict[i] = (TData) (object) (Hkmp.Math.Vector3) vec3; + } else { + dataDict[i] = (TData) (object) value; + } + } + } + } + + CondAddData( + fsm.FsmVariables.FloatVariables, + snapshot.Floats, + fsmFloat => fsmFloat.Value, + EntityHostFsmData.Type.Floats, + data.Floats + ); + CondAddData( + fsm.FsmVariables.IntVariables, + snapshot.Ints, + fsmInt => fsmInt.Value, + EntityHostFsmData.Type.Ints, + data.Ints + ); + CondAddData( + fsm.FsmVariables.BoolVariables, + snapshot.Bools, + fsmBool => fsmBool.Value, + EntityHostFsmData.Type.Bools, + data.Bools + ); + CondAddData( + fsm.FsmVariables.StringVariables, + snapshot.Strings, + fsmString => fsmString.Value, + EntityHostFsmData.Type.Strings, + data.Strings + ); + CondAddData( + fsm.FsmVariables.Vector2Variables, + snapshot.Vector2s, + fsmVec2 => fsmVec2.Value, + EntityHostFsmData.Type.Vector2s, + data.Vec2s + ); + CondAddData( + fsm.FsmVariables.Vector3Variables, + snapshot.Vector3s, + fsmVec3 => fsmVec3.Value, + EntityHostFsmData.Type.Vector3s, + data.Vec3s + ); - // There is already an update running, so we queue this one - _stateVariableUpdates.Enqueue(new StateVariableUpdate { - State = state, - Variables = variables - }); + if (data.Types.Count > 0) { + _netClient.UpdateManager.AddEntityHostFsmData(Id, fsmIndex, data); + } + } } - /** - * Called when the previous state update is done. - * Usually called on specific points in the entity's FSM. - */ - protected void StateUpdateDone() { - // If the queue is empty when we are done, we reset the boolean - // so that a new state update can be started immediately - if (_stateVariableUpdates.Count == 0) { - Logger.Info("Queue is empty"); - _inUpdateState = false; + /// + /// Callback method for when the sprite animator plays an animation. + /// + /// The original method. + /// The sprite animator instance. + /// The animation clip that was played. + /// The start time of the animation clip. + /// The FPS override for the clip. + private void OnAnimationPlayed( + On.tk2dSpriteAnimator.orig_Play_tk2dSpriteAnimationClip_float_float orig, + tk2dSpriteAnimator self, + tk2dSpriteAnimationClip clip, + float clipStartTime, + float overrideFps + ) { + if (self == _animator.Client) { + if (!_allowClientAnimation) { + Logger.Info($"Entity '{Object.Client.name}' client animator tried playing animation"); + } else { + // Logger.Info($"Entity '{_object.Client.name}' client animator was allowed to play animation"); + + orig(self, clip, clipStartTime, overrideFps); + + _allowClientAnimation = false; + } + + return; + } + + orig(self, clip, clipStartTime, overrideFps); + + if (self != _animator.Host) { + return; + } + + if (_isControlled) { + return; + } + + if (!_animationClipNameIds.TryGetValue(clip.name, out var animationId)) { + Logger.Warn($"Entity '{Object.Client.name}' played unknown animation: {clip.name}"); return; } - Logger.Info("Queue is non-empty, starting next"); + Logger.Info($"Entity '{Object.Host.name}' sends animation: {clip.name}, {animationId}, {clip.wrapMode}"); + _netClient.UpdateManager.UpdateEntityAnimation( + Id, + animationId, + (byte)clip.wrapMode + ); + } + + /// + /// Callback method for when a game object is recycled. Used to prevent client objects from being recycled, which + /// shouldn't happen because they are instantiated manually instead of from a pool. + /// + private void ObjectPoolOnRecycleGameObject(On.ObjectPool.orig_Recycle_GameObject orig, GameObject obj) { + if (obj == Object.Client) { + Logger.Debug($"Client object of entity: {Id}, {Type} tried to be recycled"); + return; + } + + orig(obj); + } + + /// + /// Callback method for when the 'active' of the host game object is changed. Used to update whether the host + /// game object should return to what active state after the scene host is determined. + /// + private void OnDoActivateGameObject(ActivateGameObject.orig_DoActivateGameObject orig, HutongGames.PlayMaker.Actions.ActivateGameObject self) { + // If the game object in the action is not our host game object, we skip it + if (self.Fsm.GetOwnerDefaultTarget(self.gameObject) != Object.Host || Object.Host == null) { + orig(self); + return; + } + + // If the host client is determined already we skip (although this hook should have been deregistered + if (_isSceneHostDetermined) { + orig(self); + return; + } + + Logger.Debug($"Entity '{Object.Host.name}' tried changing active of host object, while host is not determined yet, updating original active to: {self.activate.Value}"); - // Get the next queued update and start it - var stateVariableUpdate = _stateVariableUpdates.Dequeue(); - StartQueuedUpdate(stateVariableUpdate.State, stateVariableUpdate.Variables); + // Update the original active value to whatever this action will set + // Also, we do not let this action execute any further since we do not want it to modify our host object + // before the scene host is determined + _originalIsActive = self.activate.Value; } - /** - * Start a (previously queued) update with given state index and variable list. - */ - protected abstract void StartQueuedUpdate(byte state, List variable); + /// + /// Initializes the entity when the client user is the scene host. + /// + public void InitializeHost() { + Object.Host.SetActive(_originalIsActive); - /** - * Whether the given state index represents a state that should interrupt - * other updating states. - */ - protected abstract bool IsInterruptingState(byte state); + // Also update the last active variable to account for this potential change + // Otherwise we might trigger the update sending of activity twice + _lastIsActive = _hasParent ? Object.Host.activeSelf : Object.Host.activeInHierarchy; - public void Destroy() { - AllowEventSending = false; + Logger.Info( + $"Initializing entity '{Object.Host.name}' with active: {_originalIsActive}, sending active: {_lastIsActive}"); - MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + _netClient.UpdateManager.UpdateEntityIsActive(Id, _lastIsActive); + + _isControlled = false; + _isSceneHostDetermined = true; + + foreach (var component in _components.Values) { + component.IsControlled = false; + component.InitializeHost(); + } + + // Deregister the hook for updating the active value of the host object + ActivateGameObject.DoActivateGameObject -= OnDoActivateGameObject; } - protected void SendStateUpdate(byte state) { - _netClient.UpdateManager.UpdateEntityState(_entityType, _entityId, state); + /// + /// Initializes the entity when the client user is a scene client. Only sets a variable to indicate the scene + /// host has been determined. + /// + public void InitializeClient() { + _isSceneHostDetermined = true; + + // Deregister the hook for updating the active value of the host object + ActivateGameObject.DoActivateGameObject -= OnDoActivateGameObject; } - protected void SendStateUpdate(byte state, List variables) { - _netClient.UpdateManager.UpdateEntityStateAndVariables(_entityType, _entityId, state, variables); + /// + /// Makes the entity a host entity if the client user became the scene host. + /// + public void MakeHost() { + Logger.Info($"Making entity ({Id}, {Type}) a host entity"); + + // If the client object is null, we don't have to care about doing anything for the host object anymore + if (Object.Client == null) { + if (Object.Host != null) { + Object.Host.SetActive(false); + } + + _isControlled = false; + + foreach (var component in _components.Values) { + component.IsControlled = false; + } + + Logger.Debug(" Client object null, enabling host object and returning"); + return; + } + + if (_hasParent) { + Logger.Debug(" Entity has parent, only setting local transform"); + + Object.Host.transform.localPosition = _lastPosition = Object.Client.transform.localPosition; + Object.Host.transform.localScale = _lastScale = Object.Client.transform.localScale; + } else { + Logger.Debug(" Entity has no parent, calculating transform"); + + var clientPos = Object.Client.transform.localPosition; + var parentPos = Vector3.zero; + if (Object.Host.transform.parent != null) { + parentPos = Object.Host.transform.parent.position; + } + + var newPosX = clientPos.x - parentPos.x; + var newPosY = clientPos.y - parentPos.y; + var newPosZ = clientPos.z - parentPos.z; + + Object.Host.transform.localPosition = _lastPosition = new Vector3(newPosX, newPosY, newPosZ); + + // Since the scale of the client object is the entire scale we have and the host object scale can be in a + // hierarchy, we need to calculate what the new local scale of the host will be to match the client scale + var clientScale = Object.Client.transform.localScale; + var hostLocalScale = Object.Host.transform.localScale; + var hostLossyScale = Object.Host.transform.lossyScale; + + var newScaleX = hostLocalScale.x == 0 || hostLossyScale.x == 0 + ? 0f + : clientScale.x / (hostLossyScale.x / hostLocalScale.x); + var newScaleY = hostLocalScale.y == 0 || hostLossyScale.y == 0 + ? 0f + : clientScale.y / (hostLossyScale.y / hostLocalScale.y); + var newScaleZ = hostLocalScale.z == 0 || hostLossyScale.z == 0 + ? 0f + : clientScale.z / (hostLossyScale.z / hostLocalScale.z); + + Object.Host.transform.localScale = _lastScale = new Vector3(newScaleX, newScaleY, newScaleZ); + } + + // Make sure that the sprite animator doesn't play the default clip after enabling the object + if (_animator.Host != null) { + _animator.Host.playAutomatically = false; + } + + var clientActive = Object.Client.activeSelf; + Object.Client.SetActive(false); + Object.Host.SetActive(clientActive); + + Logger.Debug($" Set Active of host object to: {clientActive}, disabling client object"); + + // We need to set the isKinematic property of rigid bodies to ensure physics work again after enabling + // the host object. In Hornet 1 this is necessary because another state sets this property normally in the + // fight. See the "Wake" or "Refight Ready" state of the "Control" FSM on Hornet 1. + // For the Mantis Lord and City Elevator entity, this should never be disabled, since they are always kinematic. + var rigidBody = Object.Host.GetComponent(); + if (rigidBody != null && Type != EntityType.MantisLord && Type != EntityType.CityElevator) { + Logger.Debug(" Resetting isKinematic of Rigidbody to ensure physics work for host object"); + rigidBody.isKinematic = false; + } + + _lastIsActive = _hasParent ? Object.Host.activeSelf : Object.Host.activeInHierarchy; + + _isControlled = false; + + foreach (var component in _components.Values) { + component.IsControlled = false; + } + + if (_animator.Client != null) { + var currentClip = _animator.Client.CurrentClip; + if (currentClip != null) { + var clientAnimation = currentClip.name; + var wrapMode = currentClip.wrapMode; + + Logger.Debug($" Animator and current clip present, updating animation: {clientAnimation}, {wrapMode}"); + + LateUpdateAnimation(_animator.Host, clientAnimation, wrapMode); + } + } + + Logger.Debug(" Restoring FSMs from snapshots"); + + for (var fsmIndex = 0; fsmIndex < _fsms.Host.Count; fsmIndex++) { + var fsm = _fsms.Host[fsmIndex]; + + Logger.Debug($" Restoring FSM: {fsm.Fsm.Name}"); + + // Force initialize the host FSM, since it might have been disabled before initializing + EntityInitializer.InitializeFsm(fsm); + + var snapshot = _fsmSnapshots[fsmIndex]; + + for (var i = 0; i < snapshot.Floats.Length; i++) { + fsm.FsmVariables.FloatVariables[i].Value = snapshot.Floats[i]; + } + for (var i = 0; i < snapshot.Ints.Length; i++) { + fsm.FsmVariables.IntVariables[i].Value = snapshot.Ints[i]; + } + for (var i = 0; i < snapshot.Bools.Length; i++) { + fsm.FsmVariables.BoolVariables[i].Value = snapshot.Bools[i]; + } + for (var i = 0; i < snapshot.Strings.Length; i++) { + fsm.FsmVariables.StringVariables[i].Value = snapshot.Strings[i]; + } + for (var i = 0; i < snapshot.Vector2s.Length; i++) { + fsm.FsmVariables.Vector2Variables[i].Value = snapshot.Vector2s[i]; + } + for (var i = 0; i < snapshot.Vector3s.Length; i++) { + fsm.FsmVariables.Vector3Variables[i].Value = snapshot.Vector3s[i]; + } + + // Before setting the state, we replace the actions of the to-be state to only include the ones that + // should be executed again (including actions with "everyFrame" on true or that continuously check + // collisions for example). + var state = fsm.GetState(snapshot.CurrentState); + if (state == null) { + Logger.Debug(" Not setting FSM state, because current state is empty"); + continue; + } + + Logger.Debug($" Setting FSM state: {snapshot.CurrentState}"); + + var oldActions = state.Actions; + var newActions = oldActions.Where(ActionRegistry.IsActionContinuous).ToArray(); + + Logger.Debug($" Only using actions: {string.Join(", ", newActions.Select(a => a.GetType().ToString()))}"); + + // Replace the actions, set the state and reset the actions again + state.Actions = newActions; + fsm.SetState(snapshot.CurrentState); + state.Actions = oldActions; + } } - protected void RemoveOutgoingTransitions(string stateName) { - _stateTransitions[stateName] = Fsm.GetState(stateName).Transitions; + /// + /// Updates the position of the client entity. + /// + /// The new position. + public void UpdatePosition(Vector2 position) { + if (Object.Client == null || Object.Host == null) { + Logger.Warn($"Cannot update position for entity ({Id}, {Type}), client or host object is null"); + return; + } + + var unityPos = new Vector3( + position.X, + position.Y, + _hasParent ? Object.Host.transform.localPosition.z : Object.Host.transform.position.z + ); - foreach (var transition in _stateTransitions[stateName]) { - Logger.Info($"Removing transition in state: {stateName}, to: {transition.ToState}"); + var positionInterpolation = Object.Client.GetComponent(); + if (positionInterpolation == null) { + return; } - Fsm.GetState(stateName).Transitions = new FsmTransition[0]; + positionInterpolation.SetNewPosition(unityPos); } - protected void RemoveOutgoingTransition(string stateName, string toState) { - // Get the current array of transitions - var originalTransitions = Fsm.GetState(stateName).Transitions; + /// + /// Updates the scale of the client entity. + /// + /// The new scale data. + public void UpdateScale(EntityUpdate.ScaleData scale) { + if (Object.Client == null) { + Logger.Warn($"Cannot update scale for entity ({Id}, {Type}), client object is null"); + return; + } + + var transform = Object.Client.transform; + var localScale = transform.localScale; + + if (scale.x) { + if (scale.xFlipped) { + var currentScaleX = localScale.x; + + if (currentScaleX > 0 != scale.xPos) { + currentScaleX *= -1; - // We don't want to overwrite the originally stored transitions, - // so we only store it if the key doesn't exist yet - if (!_stateTransitions.TryGetValue(stateName, out _)) { - _stateTransitions[stateName] = originalTransitions; + localScale.x = currentScaleX; + } + } else { + localScale.x = scale.xScale; + } } + + if (scale.y) { + if (scale.yFlipped) { + var currentScaleY = localScale.y; - // Try to find the transition that has a destination state with the given name - var newTransitions = originalTransitions.ToList(); - foreach (var transition in originalTransitions) { - if (transition.ToState.Equals(toState)) { - newTransitions.Remove(transition); - break; + if (currentScaleY > 0 != scale.yPos) { + currentScaleY *= -1; + + localScale.y = currentScaleY; + } + } else { + localScale.y = scale.yScale; } } - Fsm.GetState(stateName).Transitions = newTransitions.ToArray(); - } + if (scale.z) { + if (scale.zFlipped) { + var currentScaleZ = localScale.z; - protected Action CreateStateUpdateMethod(Action action) { - return () => { - if (IsControlled || !AllowEventSending) { - return; + if (currentScaleZ > 0 != scale.zPos) { + currentScaleZ *= -1; + + localScale.z = currentScaleZ; + } + } else { + localScale.z = scale.zScale; } + } - action.Invoke(); - }; + transform.localScale = localScale; } - protected void RestoreAllOutgoingTransitions() { - foreach (var stateTransitionPair in _stateTransitions) { - Fsm.GetState(stateTransitionPair.Key).Transitions = stateTransitionPair.Value; + /// + /// Updates the animation of the client entity. + /// + /// The ID of the animation. + /// The wrap mode of the animation clip. + /// Whether this update is when entering a new scene. + public void UpdateAnimation( + byte animationId, + tk2dSpriteAnimationClip.WrapMode wrapMode, + bool alreadyInSceneUpdate + ) { + if (_animator.Client == null) { + Logger.Warn($"Entity '{Object.Client.name}' received animation while client animator does not exist"); + return; + } + + if (!_animationClipNameIds.TryGetValue(animationId, out var clipName)) { + Logger.Warn($"Entity '{Object.Client.name}' received unknown animation ID: {animationId}"); + return; + } + + Logger.Info($"Entity '{Object.Client.name}' received animation: {animationId}, {clipName}, {wrapMode}"); + + // All paths lead to calling the Play method of the sprite animator that is hooked, so we allow the call + // through the hook + _allowClientAnimation = true; + + if (alreadyInSceneUpdate) { + // Since this is an animation update from an entity that was already present in a scene, + // we need to determine where to start playing this specific animation + LateUpdateAnimation(_animator.Client, clipName, wrapMode); } - _stateTransitions.Clear(); + // Otherwise, default to just playing the clip + _animator.Client.Play(clipName); } - protected void RestoreOutgoingTransitions(string stateName) { - if (!_stateTransitions.TryGetValue(stateName, out var transitions)) { - Logger.Warn($"Tried to restore transitions for state named: {stateName}, but they are not stored"); + /// + /// Update the animation for the given animator with the given clip name and wrap mode. This assumes that we need + /// to replicate animation behaviour for a late update. + /// + /// The sprite animator to update. + /// The name of the animation clip. + /// The wrap mode for the animation. + private void LateUpdateAnimation( + tk2dSpriteAnimator animator, + string clipName, + tk2dSpriteAnimationClip.WrapMode wrapMode + ) { + if (wrapMode == tk2dSpriteAnimationClip.WrapMode.Loop) { + animator.Play(clipName); + return; + } + + var clip = animator.GetClipByName(clipName); + + Logger.Debug($"Entity ({Id}, {Type}) LateUpdateAnimation: {clip.name}, {wrapMode}"); + + if (wrapMode == tk2dSpriteAnimationClip.WrapMode.LoopSection) { + // The clip loops in a specific section in the frames, so we start playing + // it from the start of that section + animator.PlayFromFrame(clipName, clip.loopStart); return; } - Fsm.GetState(stateName).Transitions = transitions; - _stateTransitions.Remove(stateName); + if (wrapMode == tk2dSpriteAnimationClip.WrapMode.Once || + wrapMode == tk2dSpriteAnimationClip.WrapMode.Single) { + // Since the clip was played once, it stops on the last frame, + // so we emulate that by only "playing" the last frame of the clip + var clipLength = clip.frames.Length; + animator.PlayFromFrame(clipName, clipLength - 1); + + // Logger.Info( + // $" Played animation: {clipName}, {clipLength - 1} on {_animator.Client.name}, {_animator.Client.GetHashCode()}"); + } + } + + /// + /// Updates whether the game object for the client entity is active. + /// + /// The new value for active. + public void UpdateIsActive(bool active) { + if (Object.Client != null) { + Logger.Info($"Entity '{Object.Client.name}' received active: {active}"); + Object.Client.SetActive(active); + } else { + Logger.Warn($"Entity ({Id}, {Type}) could not update active, because client object is null"); + } + } + + /// + /// Updates generic data for the client entity. + /// + /// A list of data to update the client entity with. + /// Whether this data is from an already in scene update. + public void UpdateData(List entityNetworkData, bool alreadyInSceneUpdate) { + foreach (var data in entityNetworkData) { + if (data.Type == EntityComponentType.Fsm) { + PlayMakerFSM fsm; + byte stateIndex; + byte actionIndex; + + if (_fsms.Client.Count > 1) { + // Do a check on the length of the data + if (data.Packet.Length < 3) { + continue; + } + + var fsmIndex = data.Packet.ReadByte(); + fsm = _fsms.Client[fsmIndex]; + + stateIndex = data.Packet.ReadByte(); + actionIndex = data.Packet.ReadByte(); + } else { + // Do a check on the length of the data + if (data.Packet.Length < 2) { + continue; + } + + fsm = _fsms.Client[0]; + + stateIndex = data.Packet.ReadByte(); + actionIndex = data.Packet.ReadByte(); + } + + var state = fsm.FsmStates[stateIndex]; + var action = state.Actions[actionIndex]; + + Logger.Info($"Received entity network data for FSM: {fsm.Fsm.Name}, {state.Name}, {actionIndex} ({action.GetType()})"); + + EntityFsmActions.ApplyNetworkDataFromAction(data, action); + + continue; + } + + if (_components.TryGetValue(data.Type, out var component)) { + component.Update(data, alreadyInSceneUpdate); + } + } + } + + /// + /// Update the FSMs of the host entity to prepare for host transfer or disconnects. + /// + /// Dictionary mapping FSM index to data. + public void UpdateHostFsmData(Dictionary hostFsmData) { + foreach (var fsmPair in hostFsmData) { + var fsmIndex = fsmPair.Key; + var data = fsmPair.Value; + + if (_fsms.Host.Count <= fsmIndex) { + Logger.Warn($"Tried to update host FSM data for unknown FSM index: {fsmIndex}"); + continue; + } + + var hostFsm = _fsms.Host[fsmIndex]; + var snapshot = _fsmSnapshots[fsmIndex]; + + if (data.Types.Contains(EntityHostFsmData.Type.State)) { + var states = hostFsm.FsmStates; + if (states.Length <= data.CurrentState) { + Logger.Warn($"Tried to update host FSM state for unknown state index: {data.CurrentState}"); + } else { + var stateName = states[data.CurrentState].Name; + + snapshot.CurrentState = stateName; + + // Also propagate this state change to the EntityFsmActions class with the client FSM for the + // same index + EntityFsmActions.RegisterStateChange(_fsms.Client[fsmIndex].Fsm, stateName); + } + } + + var fsms = new[] { hostFsm, _fsms.Client[fsmIndex] }; + + void CondUpdateVars( + EntityHostFsmData.Type type, + Dictionary dataDict, + TFsm[] fsmVarArray, + Action setValueAction + ) { + if (data.Types.Contains(type)) { + foreach (var pair in dataDict) { + if (fsmVarArray.Length <= pair.Key) { + Logger.Warn($"Tried to update host FSM var ({typeof(TBase)}) for unknown index: {pair.Key}"); + } else { + setValueAction.Invoke(pair.Key, fsmVarArray[pair.Key], pair.Value); + } + } + } + } + + foreach (var fsm in fsms) { + CondUpdateVars( + EntityHostFsmData.Type.Floats, + data.Floats, + fsm.FsmVariables.FloatVariables, + (index, fsmVar, value) => { + fsmVar.Value = value; + snapshot.Floats[index] = value; + } + ); + CondUpdateVars( + EntityHostFsmData.Type.Ints, + data.Ints, + fsm.FsmVariables.IntVariables, + (index, fsmVar, value) => { + fsmVar.Value = value; + snapshot.Ints[index] = value; + } + ); + CondUpdateVars( + EntityHostFsmData.Type.Bools, + data.Bools, + fsm.FsmVariables.BoolVariables, + (index, fsmVar, value) => { + fsmVar.Value = value; + snapshot.Bools[index] = value; + } + ); + CondUpdateVars( + EntityHostFsmData.Type.Strings, + data.Strings, + fsm.FsmVariables.StringVariables, + (index, fsmVar, value) => { + fsmVar.Value = value; + snapshot.Strings[index] = value; + } + ); + CondUpdateVars( + EntityHostFsmData.Type.Vector2s, + data.Vec2s, + fsm.FsmVariables.Vector2Variables, + (index, fsmVar, value) => { + fsmVar.Value = (UnityEngine.Vector2) value; + snapshot.Vector2s[index] = (UnityEngine.Vector2) value; + } + ); + CondUpdateVars( + EntityHostFsmData.Type.Vector3s, + data.Vec3s, + fsm.FsmVariables.Vector3Variables, + (index, fsmVar, value) => { + fsmVar.Value = (Vector3) value; + snapshot.Vector3s[index] = (Vector3) value; + } + ); + } + } + } + + /// + /// Destroys the entity. + /// + public void Destroy() { + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdate; + On.tk2dSpriteAnimator.Play_tk2dSpriteAnimationClip_float_float -= OnAnimationPlayed; + On.ObjectPool.Recycle_GameObject -= ObjectPoolOnRecycleGameObject; + + foreach (var component in _components.Values.Distinct()) { + component.Destroy(); + } } - private class StateVariableUpdate { - public byte State { get; set; } - public List Variables { get; set; } + /// + /// Get the list of client FSMs. + /// + /// A list containing the client FSM instances. + public List GetClientFsms() { + return _fsms.Client; } } diff --git a/HKMP/Game/Client/Entity/EntityInitializer.cs b/HKMP/Game/Client/Entity/EntityInitializer.cs new file mode 100644 index 00000000..bc5bd770 --- /dev/null +++ b/HKMP/Game/Client/Entity/EntityInitializer.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Hkmp.Game.Client.Entity.Action; +using HutongGames.PlayMaker; +using HutongGames.PlayMaker.Actions; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity; + +/// +/// Class that manages initializing client-side entities to ensure they have correct references within FSM actions +/// to game objects (such as child objects). +/// +internal static class EntityInitializer { + /// + /// Array of state names that indicates that it is a initializing state. + /// + private static readonly string[] InitStateNames = { + "init", + "initiate", + "initialise", + "initialize", + "dormant", + "pause", + "init pause", + "deparents", + "opened" // For battle gates + }; + + /// + /// Array of types that should be removed from client-side enemies so it doesn't interfere with remote behaviour. + /// + private static readonly Type[] ToRemoveTypes = { + typeof(Walker), + typeof(Rigidbody2D), + typeof(BigCentipede) + }; + + /// + /// Array of types of actions that should be skipped during initialization. + /// + private static readonly Type[] ToSkipTypes = { + typeof(Tk2dPlayAnimation), + typeof(ActivateAllChildren), + typeof(SetCollider) // TODO: test whether this has effects on other entities during host transfer (this was added for battle gates) + }; + + /// + /// Initialize the FSM of a client entity by finding initialize states and executing the actions in those states. + /// + /// The FSM to initialize. + public static void InitializeFsm(PlayMakerFSM fsm) { + // Create a list of states to initialize later + var statesToInit = new List(); + // Keep track of the indices where the individual initialization states begin in our final list + var indices = new int[InitStateNames.Length]; + + CheckPreProcessFsm(fsm); + + // Go over each state in the FSM + foreach (var state in fsm.FsmStates) { + var stateName = state.Name.ToLower(); + var index = Array.IndexOf(InitStateNames, stateName); + // Check if it is a "init" state + if (index == -1) { + continue; + } + + // Then insert it at the correct index according to our tracked indices + statesToInit.Insert(indices[index], state); + // Increase all indices that come after, since we inserted something before + for (var i = index; i < indices.Length; i++) { + indices[i]++; + } + } + + // Now we can loop over the states in the same order as our "InitStateNames" array + foreach (var state in statesToInit) { + Logger.Debug($"Found initialization state: {state.Name}, executing actions"); + + // Go over each action and try to execute it by applying empty data to it + foreach (var action in state.Actions) { + if (!action.Enabled) { + continue; + } + + if (ToSkipTypes.Contains(action.GetType())) { + continue; + } + + if (!EntityFsmActions.SupportedActionTypes.Contains(action.GetType())) { + continue; + } + + if (action.Fsm == null) { + Logger.Error($"FSM in action for state '{state.Name}', action '{action.GetType()}' is null"); + continue; + } + + Logger.Debug($" Executing action {action.GetType()} for initialization"); + + EntityFsmActions.ApplyNetworkDataFromAction(null, action); + } + } + } + + /// + /// Remove all types that should be removed from a client-side entity object. + /// + /// The game object on which to remove the types. + public static void RemoveClientTypes(GameObject gameObject) { + foreach (var type in ToRemoveTypes) { + var component = gameObject.GetComponent(type); + if (component != null) { + UnityEngine.Object.Destroy(component); + } + } + } + + /// + /// Check whether the given FSM needs to be pre-processed by doing a loop over all states and actions to see if + /// any references to the FSM are missing, which causes issues if we want to hook or initialize the FSM. + /// + /// The PlayMaker FSM to check and potentially pre-process. + public static void CheckPreProcessFsm(PlayMakerFSM fsm) { + foreach (var state in fsm.FsmStates) { + if (state.Fsm == null) { + Logger.Debug($"Reference to FSM in state '{state.Name}' was null, pre-processing FSM..."); + fsm.Preprocess(); + break; + } + + var wasPreProcessed = false; + foreach (var action in state.Actions) { + if (action.Fsm == null) { + Logger.Debug($"Reference to FSM in action '{action.GetType()}' in state '{state.Name}' was null, pre-processing FSM..."); + fsm.Preprocess(); + wasPreProcessed = true; + break; + } + } + + if (wasPreProcessed) { + break; + } + } + } +} diff --git a/HKMP/Game/Client/Entity/EntityManager.cs b/HKMP/Game/Client/Entity/EntityManager.cs index 2b7daa1d..43cb5484 100644 --- a/HKMP/Game/Client/Entity/EntityManager.cs +++ b/HKMP/Game/Client/Entity/EntityManager.cs @@ -1,147 +1,546 @@ +using System; using System.Collections.Generic; +using System.Linq; +using Hkmp.Game.Client.Entity.Action; +using Hkmp.Game.Client.Entity.Component; using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; +using HutongGames.PlayMaker.Actions; +using Modding; using UnityEngine; using UnityEngine.SceneManagement; +using FindGameObject = On.HutongGames.PlayMaker.Actions.FindGameObject; using Logger = Hkmp.Logging.Logger; -using Vector2 = Hkmp.Math.Vector2; +using Object = UnityEngine.Object; namespace Hkmp.Game.Client.Entity; +/// +/// Manager class that handles entity creation, updating, networking and destruction. +/// internal class EntityManager { + /// + /// The net client for networking. + /// private readonly NetClient _netClient; - private readonly Dictionary<(EntityType, byte), IEntity> _entities; + /// + /// Dictionary mapping entity IDs to their respective entity instances. + /// + private readonly Dictionary _entities; - private bool _isSceneHost; + /// + /// Whether the scene host is determined for this scene locally. + /// + public bool IsSceneHostDetermined { get; private set; } + + /// + /// Whether the client user is the scene host. + /// + public bool IsSceneHost { get; private set; } + + /// + /// Queue of entity updates that have not been applied yet because of a missing entity. + /// Usually this occurs because the entities are loaded later than the updates are received when the local player + /// enters a new scene. + /// + private readonly Queue _receivedUpdates; public EntityManager(NetClient netClient) { _netClient = netClient; - _entities = new Dictionary<(EntityType, byte), IEntity>(); + _entities = new Dictionary(); + _receivedUpdates = new Queue(); + } - // ModHooks.Instance.OnEnableEnemyHook += OnEnableEnemyHook; + /// + /// Initialize the entity manager by initializing the processor and action hooks. + /// + public void Initialize() { + EntityProcessor.Initialize(_entities, _netClient); + } + /// + /// Register the hooks for entity-related operations. + /// + public void RegisterHooks() { + FsmActionHooks.RegisterHooks(); + MusicComponent.RegisterHooks(); + + EntityFsmActions.EntitySpawnEvent += OnGameObjectSpawned; + UnityEngine.SceneManagement.SceneManager.sceneLoaded += OnSceneLoaded; UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChanged; + + FindGameObject.Find += OnFindGameObject; + } + + /// + /// Deregister the hooks for entity-related operations. + /// + public void DeregisterHooks() { + FsmActionHooks.DeregisterHooks(); + MusicComponent.DeregisterHooks(); + + EntityFsmActions.EntitySpawnEvent -= OnGameObjectSpawned; + UnityEngine.SceneManagement.SceneManager.sceneLoaded -= OnSceneLoaded; + UnityEngine.SceneManagement.SceneManager.activeSceneChanged -= OnSceneChanged; + + FindGameObject.Find -= OnFindGameObject; + + ClearEntities(); } - public void OnBecomeSceneHost() { - Logger.Info("Releasing control of all registered entities"); + /// + /// Initializes the entity manager if we are the scene host. + /// + public void InitializeSceneHost() { + Logger.Info("We are scene host, releasing control of all registered entities"); - _isSceneHost = true; + IsSceneHost = true; foreach (var entity in _entities.Values) { - if (entity.IsControlled) { - entity.ReleaseControl(); - } - - entity.AllowEventSending = true; + entity.InitializeHost(); } + + IsSceneHostDetermined = true; + + CheckReceivedUpdates(); } - public void OnBecomeSceneClient() { - Logger.Info("Taking control of all registered entities"); + /// + /// Initializes the entity manager if we are a scene client. + /// + public void InitializeSceneClient() { + Logger.Info("We are scene client, taking control of all registered entities"); - _isSceneHost = false; + IsSceneHost = false; foreach (var entity in _entities.Values) { - if (!entity.IsControlled) { - entity.TakeControl(); - } - - entity.AllowEventSending = false; + entity.InitializeClient(); } + + IsSceneHostDetermined = true; + + CheckReceivedUpdates(); } - private void OnSceneChanged(Scene oldScene, Scene newScene) { - Logger.Info("Clearing all registered entities"); + /// + /// Updates the entity manager if we become the scene host. + /// + public void BecomeSceneHost() { + Logger.Info("Becoming scene host"); + + IsSceneHost = true; foreach (var entity in _entities.Values) { - entity.Destroy(); + entity.MakeHost(); } - - _entities.Clear(); } - private bool OnEnableEnemyHook(GameObject enemy, bool isDead) { - var enemyName = enemy.name; + /// + /// Spawn an entity with the given ID and type, that was spawned from the given entity type. + /// + /// The ID of the entity. + /// The type of the entity that spawned the new entity. + /// The type of the spawned entity. + public void SpawnEntity(ushort id, EntityType spawningType, EntityType spawnedType) { + Logger.Info($"Trying to spawn entity with ID {id} with types: {spawningType}, {spawnedType}"); + + // If an entity with the new ID already exists, we return + if (_entities.ContainsKey(id)) { + Logger.Info($" Entity with ID {id} already exists, assuming it has been spawned by action"); + return; + } - IEntity entity = null; + // Find an entity that has the same type as the spawning type. Doesn't matter if it is the correct instance, + // because the FSMs and components will be identical + var spawningEntity = _entities.Values.FirstOrDefault( + e => e.Type == spawningType + ); - if (enemyName.StartsWith("False Knight New")) { - var trimmedName = enemyName.Replace("False Knight New", "").Trim(); + if (spawningEntity == null) { + Logger.Warn("Could not find entity with same type for spawning"); + return; + } - byte enemyId; - if (trimmedName.Length == 0) { - enemyId = 0; - } else { - if (!byte.TryParse(trimmedName, out enemyId)) { - Logger.Info($"Could not parse enemy index as byte ({enemyName})"); + var spawnedObject = EntitySpawner.SpawnEntityGameObject( + spawningType, + spawnedType, + spawningEntity.Object.Client, + spawningEntity.GetClientFsms() + ); + + var processor = new EntityProcessor { + GameObject = spawnedObject, + IsSceneHost = IsSceneHost, + IsSceneHostDetermined = IsSceneHostDetermined, + LateLoad = true, + SpawnedId = id + }.Process(); + + if (!processor.Success) { + Logger.Warn($"Could not process game object of spawned entity: {spawnedObject.name}"); + } + } + + /// + /// Method for handling received entity updates. + /// + /// The entity update to handle. + /// Whether this is the update from the already in scene packet. + public bool HandleEntityUpdate(EntityUpdate entityUpdate, bool alreadyInSceneUpdate = false) { + if (IsSceneHost) { + return true; + } - return isDead; - } + if (!_entities.TryGetValue(entityUpdate.Id, out var entity) || !IsSceneHostDetermined) { + if (IsSceneHostDetermined) { + Logger.Debug($"Could not find entity ({entityUpdate.Id}) to apply update for; storing update for now"); + } else { + Logger.Debug("Scene host is not determined yet to apply update; storing update for now"); } - Logger.Info($"Registering enabled enemy, name: {enemyName}, id: {enemyId}"); - - entity = new FalseKnight(_netClient, enemyId, enemy); + _receivedUpdates.Enqueue(entityUpdate); + + return false; + } - _entities[(EntityType.FalseKnight, enemyId)] = entity; + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Position)) { + entity.UpdatePosition(entityUpdate.Position); } - if (entity == null) { - return isDead; + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Scale)) { + entity.UpdateScale(entityUpdate.Scale); + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Animation)) { + entity.UpdateAnimation( + entityUpdate.AnimationId, + (tk2dSpriteAnimationClip.WrapMode) entityUpdate.AnimationWrapMode, + alreadyInSceneUpdate + ); } - if (_isSceneHost) { - Logger.Info("Releasing control of registered enemy"); + return true; + } - if (entity.IsControlled) { - entity.ReleaseControl(); + /// + /// Method for handling received reliable entity updates. + /// + /// The reliable entity update to handle. + /// Whether this is the update from the already in scene packet. + public bool HandleReliableEntityUpdate(ReliableEntityUpdate entityUpdate, bool alreadyInSceneUpdate = false) { + if (!_entities.TryGetValue(entityUpdate.Id, out var entity) || !IsSceneHostDetermined) { + if (IsSceneHostDetermined) { + Logger.Debug($"Could not find entity ({entityUpdate.Id}) to apply update for; storing update for now"); + } else { + Logger.Debug("Scene host is not determined yet to apply update; storing update for now"); } + + _receivedUpdates.Enqueue(entityUpdate); - entity.AllowEventSending = true; - } else { - Logger.Info("Taking control of registered enemy"); + return false; + } - if (!entity.IsControlled) { - entity.TakeControl(); + // Check if we are not the scene host for processing this data, active state and host FSM data should only + // be applied if we not the scene host, while data type below should always be applied + if (!IsSceneHost) { + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Active)) { + entity.UpdateIsActive(entityUpdate.IsActive); + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.HostFsm)) { + entity.UpdateHostFsmData(entityUpdate.HostFsmData); } + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Data)) { + entity.UpdateData(entityUpdate.GenericData, alreadyInSceneUpdate); + } + + return true; + } + + /// + /// Callback method for when a game object is spawned from an existing entity. + /// + /// The entity spawn details containing how the entity was spawned. + /// Whether an entity was registered from this spawn. + private bool OnGameObjectSpawned(EntitySpawnDetails details) { + if (_entities.Values.Any(existingEntity => existingEntity.Object.Host == details.GameObject)) { + Logger.Debug("Spawned object was already a registered entity"); + return true; + } - entity.AllowEventSending = false; + var processor = new EntityProcessor { + GameObject = details.GameObject, + IsSceneHost = IsSceneHost, + IsSceneHostDetermined = IsSceneHostDetermined, + LateLoad = true + }.Process(); + + if (!processor.Success) { + return false; + } + + if (!IsSceneHost) { + Logger.Warn("Game object was spawned while not scene host, this shouldn't happen"); + return false; + } + + string spawningObjectName; + EntityType spawningType; + var topLevelEntity = processor.Entities[0]; + + if (details.Type == EntitySpawnType.FsmAction) { + spawningObjectName = details.Action.Fsm.GameObject.name; + if (EntityRegistry.TryGetEntry(details.Action.Fsm.GameObject, out var entry)) { + spawningType = entry.Type; + } else { + Logger.Warn("Could not find registry entry for spawning type of object"); + return false; + } + } else if (details.Type == EntitySpawnType.EnemySpawnerComponent) { + spawningObjectName = "Vengefly Summon"; + spawningType = EntityType.VengeflySummon; + } else if (details.Type == EntitySpawnType.SpawnJarComponent) { + spawningObjectName = "Spawn Jar"; + spawningType = EntityType.CollectorJar; + } else { + Logger.Error($"Invalid EntitySpawnDetails type: {details.Type}"); + return false; } - return isDead; + Logger.Info( + $"Notifying server of entity ({spawningObjectName}, {spawningType}) spawning entity ({details.GameObject.name}, {topLevelEntity.Type}) with ID {topLevelEntity.Id}"); + _netClient.UpdateManager.SetEntitySpawn( + topLevelEntity.Id, + spawningType, + topLevelEntity.Type + ); + + return true; + } + + /// + /// Check to see if there are received un-applied entity updates. + /// + private void CheckReceivedUpdates() { + while (_receivedUpdates.Count != 0) { + var update = _receivedUpdates.Peek(); + + if (_entities.TryGetValue(update.Id, out _)) { + Logger.Debug("Found un-applied entity update, applying now"); + + bool handled; + if (update is EntityUpdate entityUpdate) { + handled = HandleEntityUpdate(entityUpdate); + } else if (update is ReliableEntityUpdate reliableEntityUpdate) { + handled = HandleReliableEntityUpdate(reliableEntityUpdate); + } else { + continue; + } + + if (handled) { + _receivedUpdates.Dequeue(); + } + } + } } - public void UpdateEntityPosition(EntityType entityType, byte id, Vector2 position) { - if (!_entities.TryGetValue((entityType, id), out var entity)) { - Logger.Info( - $"Tried to update entity position for (type, ID) = ({entityType}, {id}), but there was no entry"); + /// + /// Callback method for when the scene changes. Will clear existing entities and start checking for + /// new entities. + /// + /// The old scene. + /// The new scene. + private void OnSceneChanged(Scene oldScene, Scene newScene) { + Logger.Info("Scene changed, clearing registered entities"); + + ClearEntities(); + + if (!_netClient.IsConnected) { return; } - // Check whether the entity is already controlled, and if not - // take control of it - if (!entity.IsControlled) { - entity.TakeControl(); + IsSceneHostDetermined = false; + + FindEntitiesInScene(newScene, false); + + // Since we have tried finding entities in the scene, we also check whether there are un-applied updates for + // those entities + CheckReceivedUpdates(); + } + + /// + /// Clears all the registered entities, and resets static components. + /// + private void ClearEntities() { + foreach (var entity in _entities.Values) { + entity.Destroy(); } - entity.UpdatePosition(position); + // Clear the list of entities and the queue of received updates that have not been applied yet + _entities.Clear(); + _receivedUpdates.Clear(); + + MusicComponent.ClearInstance(); } - public void UpdateEntityState(EntityType entityType, byte id, byte stateIndex, List variables) { - if (!_entities.TryGetValue((entityType, id), out var entity)) { - Logger.Info( - $"Tried to update entity state for (type, ID) = ({entityType}, {id}), but there was no entry"); + /// + /// Callback method for when a scene is loaded. + /// + /// The scene that is loaded. + /// The load scene mode. + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { + var currentSceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; + // If this scene is a boss or boss-defeated scene it starts with the same name, so we skip all other + // loaded scenes + if (!scene.name.StartsWith(currentSceneName) || scene.name.Equals(currentSceneName)) { return; } - // Check whether the entity is already controlled, and if not - // take control of it - if (!entity.IsControlled) { - entity.TakeControl(); + Logger.Info($"Additional scene loaded ({scene.name}), looking for entities"); + + FindEntitiesInScene(scene, true); + + // Since we have tried finding entities in the scene, we also check whether there are un-applied updates for + // those entities + CheckReceivedUpdates(); + } + + /// + /// Find entities to register in the given scene. + /// + /// The scene to find entities in. + /// Whether this scene was loaded late. + private void FindEntitiesInScene(Scene scene, bool lateLoad) { + // Find all EnemyDeathEffects components + var objectsToCheck = Object.FindObjectsOfType() + // Filter out EnemyDeathEffects components not in the current scene + .Where(e => e.gameObject.scene == scene) + // Project each death effect to their GameObject and the corpse of the pre-instantiated EnemyDeathEffects + // component + .SelectMany(enemyDeathEffects => { + try { + enemyDeathEffects.PreInstantiate(); + } catch (Exception) { + // If we get an exception it might not be possible to pre-instantiate the enemy death effects + // This can happen when the object uses a PersonalObjectPool, which can't be pre-instantiated + // this early, so we return only the original gameobject + return new[] { enemyDeathEffects.gameObject }; + } + + var corpse = ReflectionHelper.GetField( + enemyDeathEffects, + "corpse" + ); + + return new[] { enemyDeathEffects.gameObject, corpse }; + }) + // Concatenate all GameObjects for PlayMakerFSM components in the current scene, and check whether it is the + // FSM for a Colosseum Cage, in which case we pre-instantiate the enemy inside and concatenate it as well + .Concat(Object.FindObjectsOfType(true) + .Where(fsm => fsm.gameObject.scene == scene) + .SelectMany(fsm => { + if (!fsm.name.StartsWith("Colosseum Cage Small") && + !fsm.name.StartsWith("Colosseum Cage Large") && + !fsm.name.StartsWith("Colosseum Cage Zote")) { + return new[] { fsm.gameObject }; + } + + if (!fsm.Fsm.Name.Equals("Spawn") && + !fsm.Fsm.Name.Equals("Control") + ) { + return new[] { fsm.gameObject }; + } + + var createAction = fsm.GetFirstAction("Init"); + EntityFsmActions.ApplyNetworkDataFromAction(null, createAction); + + createAction.Enabled = false; + + var createdObject = createAction.storeObject.Value; + if (createdObject == null) { + return new[] { fsm.gameObject }; + } + + var fsmTransform = fsm.gameObject.transform; + + createdObject.transform.position = fsmTransform.position; + createdObject.transform.rotation = Quaternion.Euler(fsmTransform.eulerAngles); + createdObject.SetActive(false); + + return new[] { fsm.gameObject, createdObject }; + }) + ) + // Project each GameObject into its children including itself + .SelectMany(obj => obj == null ? Array.Empty() : obj.GetChildren().Prepend(obj)) + // Concatenate all GameObjects for Climber components (Tiktiks) + .Concat(Object.FindObjectsOfType(true).Select(climber => climber.gameObject)) + // Concatenate all GameObjects for Walker components (Amblooms) + .Concat(Object.FindObjectsOfType(true).Select(walker => walker.gameObject)) + // Concatenate all GameObjects for BigCentipede components (Garpedes) + .Concat(Object.FindObjectsOfType(true).Select(centipede => centipede.gameObject)) + // Concatenate all GameObjects for CameraLockArea components + .Concat(Object.FindObjectsOfType(true).Select(cameraLockArea => cameraLockArea.gameObject)) + // Concatenate all GameObjects for FlipPlatform components + .Concat(Object.FindObjectsOfType(true).Select(flipPlatform => flipPlatform.gameObject)) + // Concatenate all GameObjects for DreamPlatform components + .Concat(Object.FindObjectsOfType(true).Select(dreamPlatform => dreamPlatform.gameObject)) + // Filter out GameObjects not in the current scene + .Where(obj => obj.scene == scene) + .Distinct(); + + foreach (var obj in objectsToCheck) { + new EntityProcessor { + GameObject = obj, + IsSceneHost = IsSceneHost, + IsSceneHostDetermined = IsSceneHostDetermined, + LateLoad = lateLoad + }.Process(); + } + } + + /// + /// Callback method for when the find method of FindGameObject is called. This is to look for objects that are + /// normally not found by the action due to our entity system making certain objects inactive. If we notice that + /// the find failed, but the name to look for was one of our host objects in the entity system, we set that object + /// instead. + /// + private void OnFindGameObject(FindGameObject.orig_Find orig, HutongGames.PlayMaker.Actions.FindGameObject self) { + orig(self); + + // If the object was found after the method executed, we skip + if (self.store.Value != null) { + return; + } + + // Check for specific instances where we don't want to manually find the entity, because it messes + // with the logic of the FSM + if (self.State.Name.Equals("Can Roller?") && self.Fsm.Name.Equals("Blocker Control")) { + return; + } + + Logger.Debug($"OnFindGameObject, find failed: looking for '{self.objectName.Value}'"); + + // If the object to find is tagged we skip, since this doesn't happen in our case + if (self.withTag.Value != "Untagged") { + return; } - // Simply update the state with this new index - entity.UpdateState(stateIndex, variables); + // Check if the name we are looking for is one of our registered entity's host objects + foreach (var entity in _entities.Values) { + var obj = entity.Object.Host; + if (obj != null && obj.name == self.objectName.Value) { + // The host object of the entity matches the name the action was looking for, so we set the variable + self.store.Value = obj; + + Logger.Debug($" Name matches host object of entity: ({entity.Id}, {entity.Type})"); + return; + } + } + + Logger.Debug(" Name did not match any entity"); } } diff --git a/HKMP/Game/Client/Entity/EntityProcessor.cs b/HKMP/Game/Client/Entity/EntityProcessor.cs new file mode 100644 index 00000000..4a1af57b --- /dev/null +++ b/HKMP/Game/Client/Entity/EntityProcessor.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Hkmp.Game.Client.Entity.Component; +using Hkmp.Networking.Client; +using Hkmp.Util; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity; + +/// +/// Data processing class that receives a set of parameters and processing a given game object into an entity if +/// applicable. Will recursively process children of the game object as well and report success and the list of +/// entities created. +/// +internal class EntityProcessor { + /// + /// Reference to the dictionary of entities from the entity manager. + /// + private static Dictionary _entities; + /// + /// The net client used to pass onto constructed entities. + /// + private static NetClient _netClient; + + /// + /// The last used entity ID. + /// + private static ushort _lastId; + + /// + /// The game object to process. + /// + public GameObject GameObject { get; init; } + /// + /// Whether the local client is the scene host. + /// + public bool IsSceneHost { get; init; } + /// + /// Whether the scene host is determined for this scene locally. + /// + public bool IsSceneHostDetermined { get; init; } + /// + /// Whether the processing of this entity should happen under the assumption that was a late load of the + /// game object. + /// + public bool LateLoad { get; init; } + /// + /// Whether the game object was spawned and should have the designated ID. + /// + public ushort? SpawnedId { get; init; } + + /// + /// The list of entities that were created during the processing. + /// + public List Entities { get; } = new(); + /// + /// Whether the processing of the entity was a success. + /// + public bool Success => Entities.Count > 0; + + /// + /// Initialize the entity processor with a reference to the entity dict and the net client. + /// + /// A reference to the dictionary of entities from the entity manager. + /// The net client instance to pass onto constructed entities. + public static void Initialize(Dictionary entities, NetClient netClient) { + _entities = entities; + _netClient = netClient; + + UnityEngine.SceneManagement.SceneManager.activeSceneChanged += (_, _) => { + _lastId = 0; + }; + } + + /// + /// Process the game object set in this instance with the parameter set in this instance. + /// + /// The instance of this class for convenience. + public EntityProcessor Process() { + Process(GameObject); + return this; + } + + /// + /// Process the given game object to (potentially) become an entity. Check for child objects as well and will + /// recursively process those. + /// + /// The game object to process. + /// Entity registry entries to check against for the entity or null to use all + /// top-level entries. + /// The client object of the parent entity for this entity or null if no such + /// parent exists. + private void Process( + GameObject gameObject, + IEnumerable entries = null, + GameObject parentClientObject = null + ) { + EntityRegistryEntry foundEntry; + + // If the given entries are null, query the entity registry for all top-level entries + // Otherwise only use the given entries (used for child entities) + if (entries == null) { + if (!EntityRegistry.TryGetEntry(gameObject, out foundEntry)) { + return; + } + } else { + if (!EntityRegistry.TryGetEntry(entries, gameObject, out foundEntry)) { + return; + } + } + + ushort id; + + // If a spawned ID is defined we check whether an entity with the given ID already exists + // Otherwise we find a new ID that isn't used yet + if (SpawnedId.HasValue) { + id = SpawnedId.Value; + + if (_entities.ContainsKey(id)) { + Logger.Warn($"Tried registering entity with forced ID ({id}), but an entity with the ID already exists"); + return; + } + } else { + if (_entities.Count >= ushort.MaxValue) { + Logger.Error("Could not register entity because ID space is full!"); + return; + } + + // First find a usable ID that is not registered to an entity already + while (_entities.ContainsKey(_lastId)) { + _lastId++; + } + + id = _lastId; + _lastId++; + } + + // Get the array of component types for the entity or create an empty one if it is null + var componentTypes = foundEntry.ComponentTypes ?? Array.Empty(); + + // Depending on whether a parent object was given we create the entity with this parent object + Entity entity; + if (parentClientObject == null) { + Logger.Info($"Registering entity ({foundEntry.Type}) '{gameObject.name}' with ID '{id}'"); + + entity = new Entity( + _netClient, + id, + foundEntry.Type, + gameObject, + types: componentTypes + ); + } else { + Logger.Info($"Registering entity ({foundEntry.Type}) '{gameObject.name}' with ID '{id}' with parent: {parentClientObject.name}"); + + // Find the correct child of the client object of the parent entity + var clientObject = parentClientObject.GetChildren() + .FirstOrDefault(c => { + if (Entities.Any(processedEntity => processedEntity.Object.Client == c)) { + return false; + } + + return c.name.Contains(foundEntry.BaseObjectName); + }); + if (clientObject == null) { + Logger.Warn("Could not find child of client object of parent entity"); + return; + } + + Logger.Debug($"Found child of client object of parent entity: {clientObject.name}, {clientObject.GetInstanceID()}"); + + entity = new Entity( + _netClient, + id, + foundEntry.Type, + gameObject, + clientObject, + componentTypes + ); + } + + _entities[id] = entity; + + Entities.Add(entity); + + // If this entry has child entries, we recursively check the children of the object as well + if (foundEntry.Children != null) { + foreach (var childObj in gameObject.GetChildren()) { + Process(childObj, foundEntry.Children, entity.Object.Client); + } + } + + if (LateLoad && IsSceneHostDetermined) { + if (IsSceneHost) { + // Since this is a late load it needs to be initialized as host if we are the scene host + entity.InitializeHost(); + } else { + // Since this is a late load we need to update the 'active' state of the entity + entity.UpdateIsActive(true); + } + } + } +} diff --git a/HKMP/Game/Client/Entity/EntityRegistry.cs b/HKMP/Game/Client/Entity/EntityRegistry.cs new file mode 100644 index 00000000..055ea79c --- /dev/null +++ b/HKMP/Game/Client/Entity/EntityRegistry.cs @@ -0,0 +1,177 @@ +using System.Collections.Generic; +using System.Linq; +using Hkmp.Game.Client.Entity.Component; +using Hkmp.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Entity; + +/// +/// Static class that manages loading and storing of entity data. Such as names of game objects, names of FSMs and +/// corresponding types. +/// +internal static class EntityRegistry { + /// + /// The file path of the embedded resource file for the entity registry. + /// + private const string EntityRegistryFilePath = "Hkmp.Resource.entity-registry.json"; + + /// + /// List of all entity registry entries that are loaded from the embedded file. + /// + private static List Entries { get; } + + static EntityRegistry() { + Entries = FileUtil.LoadObjectFromEmbeddedJson>(EntityRegistryFilePath); + if (Entries == null) { + Logger.Warn("Could not load entity registry"); + } + } + + /// + /// Try to get the corresponding entry from the given enumerable of entries and the given game object. + /// + /// The game object to find the entry for. + /// The entry if it was found; otherwise null. + /// true if the entry was found; otherwise false. + public static bool TryGetEntry( + GameObject gameObject, + out EntityRegistryEntry foundEntry + ) { + return TryGetEntry(Entries, gameObject, out foundEntry); + } + + /// + /// Try to get the corresponding entry from the given enumerable of entries and the given game object. + /// + /// The enumerable of entries to check for. + /// The game object to find the entry for. + /// The entry if it was found; otherwise null. + /// true if the entry was found; otherwise false. + public static bool TryGetEntry( + IEnumerable entries, + GameObject gameObject, + out EntityRegistryEntry foundEntry + ) { + var longestBaseName = 0; + foundEntry = null; + + foreach (var entry in entries) { + if (!gameObject.name.Contains(entry.BaseObjectName)) { + continue; + } + + // If the entry has an FSM name defined and the child object does not have any FSM components + // that match this name, we continue + if (entry.FsmName != null && !gameObject.GetComponents().Any( + childFsm => childFsm.Fsm.Name.Equals(entry.FsmName) + )) { + continue; + } + + // If the entry has a parent name defined, we need to check if the parent of the game object matches it + if (entry.ParentName != null) { + var parent = gameObject.transform.parent; + // No parent at all, so it trivially doesn't match the name + if (!parent) { + continue; + } + + if (!parent.gameObject.name.Contains(entry.ParentName)) { + continue; + } + } + + // Specifically check for entries that don't have a defined FSM whether they contain the + // correct component(s) + if (entry.Type == EntityType.DreamPlatform) { + if (!gameObject.GetComponent()) { + continue; + } + } else if (entry.Type == EntityType.Tiktik) { + if (!gameObject.GetComponent()) { + continue; + } + } else if (entry.Type == EntityType.VengeflySummon) { + if (!gameObject.GetComponent()) { + continue; + } + } else if (entry.Type == EntityType.CollectorJar) { + if (!gameObject.GetComponent()) { + continue; + } + } else if (entry.Type == EntityType.Garpede) { + if (!gameObject.GetComponent()) { + continue; + } + } else if (entry.Type == EntityType.ShadowCreeper) { + if (!gameObject.GetComponent()) { + continue; + } + } else if (entry.Type == EntityType.GrimmFireball) { + if (!gameObject.GetComponent()) { + continue; + } + } + + var baseNameLength = entry.BaseObjectName.Length; + if (baseNameLength > longestBaseName) { + longestBaseName = baseNameLength; + foundEntry = entry; + } + } + + if (longestBaseName == 0) { + foundEntry = null; + return false; + } + + return true; + } +} + +/// +/// Class representing a single entry in the entity registry that contains the relevant data for an entity type. +/// +internal class EntityRegistryEntry { + /// + /// The base of the game object name of the entity. + /// For example: "Zombie Leaper", which in-game can be represented as "Zombie Leaper (Clone) (1)" + /// + [JsonProperty("base_object_name")] + public string BaseObjectName { get; set; } + + /// + /// The type of the entity. + /// + [JsonConverter(typeof(StringEnumConverter))] + [JsonProperty("type")] + public EntityType Type { get; set; } + + /// + /// The name of the FSM that this entity has. Can be empty if the entity does not have a FSM. + /// + [JsonProperty("fsm_name")] + public string FsmName { get; set; } + + /// + /// The name of the parent of this object. Can be empty if there is no parent or it is not relevant. + /// + [JsonProperty("parent_name")] + public string ParentName { get; set; } + + /// + /// Array of additional entity component that should be initialized for the entity. + /// + [JsonProperty("components")] + public EntityComponentType[] ComponentTypes { get; set; } + + /// + /// List of entries that are children of this entry. + /// + [JsonProperty("children")] + public List Children { get; set; } +} diff --git a/HKMP/Game/Client/Entity/EntitySpawnDetails.cs b/HKMP/Game/Client/Entity/EntitySpawnDetails.cs new file mode 100644 index 00000000..01db24ae --- /dev/null +++ b/HKMP/Game/Client/Entity/EntitySpawnDetails.cs @@ -0,0 +1,33 @@ +using HutongGames.PlayMaker; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity; + +/// +/// Class that holds the details for how an entity was spawned on the host client. +/// +internal class EntitySpawnDetails { + /// + /// The type of the spawn. + /// + public EntitySpawnType Type { get; init; } + + /// + /// The FSM action responsible for spawning the entity. + /// + public FsmStateAction Action { get; init; } + + /// + /// The game object that was spawned. + /// + public GameObject GameObject { get; init; } +} + +/// +/// Enumeration of types of possible spawns for the entity. +/// +internal enum EntitySpawnType { + FsmAction, + EnemySpawnerComponent, + SpawnJarComponent +} diff --git a/HKMP/Game/Client/Entity/EntitySpawner.cs b/HKMP/Game/Client/Entity/EntitySpawner.cs new file mode 100644 index 00000000..28880c36 --- /dev/null +++ b/HKMP/Game/Client/Entity/EntitySpawner.cs @@ -0,0 +1,590 @@ +using System; +using System.Collections.Generic; +using Hkmp.Util; +using HutongGames.PlayMaker.Actions; +using Modding; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; +using Object = UnityEngine.Object; + +namespace Hkmp.Game.Client.Entity; + +/// +/// Static class that has implementations for spawning entities that are usually spawned by other entities in-game. +/// +internal static class EntitySpawner { + /// + /// Prefab for the Vengefly that can be summoned from a jar in The Collector boss fight. + /// + private static GameObject _collectorVengeflyPrefab; + /// + /// Prefab for the Aspid Hunter that can be summoned from a jar in The Collector boss fight. + /// + private static GameObject _collectorAspidPrefab; + /// + /// Prefab for the Baldur that can be summoned from a jar in The Collector boss fight. + /// + private static GameObject _collectorBaldurPrefab; + + /// + /// Spawn the game object for an entity with the given type that is spawned from the other given type. + /// + /// The type of the entity that spawns the new entity. + /// The type of the spawned entity. + /// The client game object from the spawning entity. + /// The list of client FSMs from the spawning entity. + /// The game object for the spawned entity. + public static GameObject SpawnEntityGameObject( + EntityType spawningType, + EntityType spawnedType, + GameObject clientObject, + List clientFsms + ) { + Logger.Info($"Trying to spawn entity game object for: {spawningType}, {spawnedType}"); + + if (spawningType == EntityType.ElderBaldur && spawnedType == EntityType.Baldur) { + return SpawnBaldurGameObject(clientFsms[0]); + } + + if (spawningType == EntityType.VengeflyKing) { + if (spawnedType == EntityType.VengeflySummon) { + return SpawnVengeflySummonObject(clientFsms[0]); + } + + if (spawnedType == EntityType.Vengefly) { + return SpawnVengeflyFromKing(clientFsms[0]); + } + } + + if (spawningType == EntityType.VengeflySummon && spawnedType == EntityType.Vengefly) { + return SpawnVengeflyObjectFromSummon(clientObject); + } + + if (spawningType == EntityType.Sporg && spawnedType == EntityType.SporgSpore) { + return SpawnSporgSpore(clientFsms[0]); + } + + if (spawningType == EntityType.OomaCorpse && spawnedType == EntityType.OomaCore) { + return SpawnOomaCoreObject(clientFsms[0]); + } + + if (spawnedType == EntityType.SoulOrb) { + if (spawningType == EntityType.SoulTwister) { + return SpawnSoulTwisterOrbObject(clientFsms[0]); + } + if (spawningType == EntityType.SoulWarrior) { + return SpawnSoulWarriorOrbObject(clientFsms[0]); + } + if (spawningType is EntityType.SoulMaster or EntityType.SoulTyrant) { + return SpawnSoulMasterOrbObject(clientFsms[0]); + } + if (spawningType == EntityType.SoulMasterOrbSpinner) { + return SpawnOrbSpinnerOrbObject(clientFsms[2]); + } + + if (spawningType is EntityType.SoulMasterPhase2 or EntityType.SoulTyrantPhase2) { + return SpawnSoulMaster2OrbObject(clientFsms[0]); + } + } + + if (spawningType == EntityType.TheCollector && spawnedType == EntityType.CollectorJar) { + return SpawnCollectorJarObject(clientFsms[0]); + } + + if (spawningType == EntityType.CollectorJar) { + return SpawnCollectorJarContents(clientObject, spawnedType); + } + + if (spawningType == EntityType.DungDefender) { + return SpawnDungBallObject(clientFsms[0], spawnedType); + } + + if (spawningType == EntityType.Nosk && spawnedType == EntityType.NoskBlob) { + return SpawnNoskBlobObject(clientFsms[0]); + } + + if (spawningType == EntityType.BrokenVessel && spawnedType == EntityType.InfectedBalloon) { + return SpawnBrokenVesselBalloonObject(clientFsms[7]); + } + + if (spawningType == EntityType.LostKin && spawnedType == EntityType.InfectedBalloon) { + return SpawnBrokenVesselBalloonObject(clientFsms[2]); + } + + if (spawningType == EntityType.MantisPetra && spawnedType == EntityType.MantisPetraScythe) { + return SpawnMantisPetraScytheObject(clientFsms[0]); + } + + if (spawningType == EntityType.Galien && spawnedType == EntityType.GalienMiniScythe) { + // The reason we do not use a hardcoded index from the FSMs is because the Shield Attack FSM is indexed + // differently depending on whether Markoth is in Godhome or not + return SpawnGalienMiniScytheObject(clientFsms.Find(fsm => fsm.Fsm.Name.Equals("Summon Minis"))); + } + + if (spawningType == EntityType.Markoth && spawnedType == EntityType.MarkothShield) { + // The reason we do not use a hardcoded index from the FSMs is because the Shield Attack FSM is indexed + // differently depending on whether Markoth is in Godhome or not + return SpawnMarkothShieldObject(clientFsms.Find(fsm => fsm.Fsm.Name.Equals("Shield Attack"))); + } + + if (spawningType == EntityType.Kingsmould && spawnedType == EntityType.KingsmouldBlade) { + return SpawnKingsmouldBladeObject(clientFsms[0]); + } + + if (spawningType == EntityType.HornetSentinelSpikes && spawnedType == EntityType.HornetSentinelSpike) { + return SpawnHornetSentinelSpikeObject(clientFsms[0]); + } + + if (spawningType == EntityType.GrimmkinSpawner) { + return SpawnGrimmkinObject(clientFsms[0]); + } + + if (spawningType is EntityType.Grimm or EntityType.NightmareKingGrimm) { + if (spawnedType == EntityType.GrimmFireball) { + return SpawnGrimmFireballObject(clientFsms[0]); + } + + if (spawnedType is EntityType.GrimmBat or EntityType.NightmareKingGrimmBat) { + return SpawnGrimmFirebatObject(clientFsms[0]); + } + } + + if (spawningType is EntityType.Radiance or EntityType.AbsoluteRadiance) { + if (spawnedType == EntityType.RadianceOrb) { + return SpawnRadianceOrb(clientFsms[3]); + } + + if (spawnedType == EntityType.RadianceNail) { + return SpawnRadianceNail(clientFsms[3]); + } + + if (spawnedType == EntityType.RadianceNailComb) { + return SpawnRadianceNailComb(clientFsms[3]); + } + } + + if (spawningType == EntityType.RadianceNailComb && spawnedType == EntityType.RadianceNail) { + return SpawnRadianceNailFromComb(clientFsms[0]); + } + + if (spawningType == EntityType.WingedNosk) { + if (spawnedType == EntityType.InfectedBalloon) { + return SpawnInfectedBalloonWingedNoskObject(clientFsms[0]); + } + + if (spawnedType == EntityType.NoskBlob) { + return SpawnWingedNoskBlobObject(clientFsms[0]); + } + } + + if (spawningType == EntityType.WingedNoskGlobDropper && spawnedType == EntityType.NoskBlob) { + return SpawnWingedNoskGlobDropper(clientFsms[0]); + } + + Logger.Warn($"No implementation for spawning entity game object: {spawningType}, {spawnedType}"); + + return null; + } + + private static GameObject SpawnFromCreateObject(CreateObject action) { + var gameObject = action.gameObject.Value; + + var position = Vector3.zero; + var euler = Vector3.zero; + if (action.spawnPoint.Value != null) { + position = action.spawnPoint.Value.transform.position; + + if (!action.position.IsNone) { + position += action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } else { + euler = action.spawnPoint.Value.transform.eulerAngles; + } + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } + } + + var createdObject = Object.Instantiate(gameObject, position, Quaternion.Euler(euler)); + + return createdObject; + } + + private static GameObject SpawnFromGlobalPool(SpawnObjectFromGlobalPool action, GameObject gameObject) { + var position = Vector3.zero; + var euler = Vector3.up; + if (action.spawnPoint.Value != null) { + position = action.spawnPoint.Value.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + + if (action.rotation.IsNone) { + euler = action.spawnPoint.Value.transform.eulerAngles; + } else { + euler = action.rotation.Value; + } + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + + if (!action.rotation.IsNone) { + euler = action.rotation.Value; + } + } + + var spawnedObject = gameObject.Spawn(position, Quaternion.Euler(euler)); + + return spawnedObject; + } + + private static GameObject SpawnFromFlingGlobalPool(FlingObjectsFromGlobalPool action, GameObject gameObject) { + var position = Vector3.zero; + var zero = Vector3.zero; + if (action.spawnPoint.Value != null) { + position = action.spawnPoint.Value.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + } + + var spawnedObject = gameObject.Spawn(position, Quaternion.Euler(zero)); + return spawnedObject; + } + + private static GameObject SpawnFromFlingGlobalPoolTime( + FlingObjectsFromGlobalPoolTime action, + GameObject gameObject + ) { + var position = Vector3.zero; + var zero = Vector3.zero; + if (action.spawnPoint.Value != null) { + position = action.spawnPoint.Value.transform.position; + if (!action.position.IsNone) { + position += action.position.Value; + } + } else { + if (!action.position.IsNone) { + position = action.position.Value; + } + } + + var spawnedObject = gameObject.Spawn(position, Quaternion.Euler(zero)); + return spawnedObject; + } + + private static GameObject SpawnBaldurGameObject(PlayMakerFSM fsm) { + var setGameObjectAction = fsm.GetFirstAction("Roller"); + var spawnAction = fsm.GetFirstAction("Fire"); + + var gameObject = setGameObjectAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnVengeflySummonObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Summon"); + + return SpawnFromCreateObject(action); + } + + private static GameObject SpawnVengeflyObjectFromSummon(GameObject spawningObject) { + var enemySpawner = spawningObject.GetComponent(); + if (enemySpawner == null) { + Logger.Error("Could not create Vengefly object from summon because EnemySpawner component is null"); + return null; + } + + // We check if the object has been created already in the Awake() of the EnemySpawner + // If not, we have to instantiate a new object and return that instead + var spawnedEnemy = ReflectionHelper.GetField(enemySpawner, "spawnedEnemy"); + if (spawnedEnemy == null) { + spawnedEnemy = Object.Instantiate(enemySpawner.enemyPrefab); + } + + spawnedEnemy.SetActive(true); + + return spawnedEnemy; + } + + private static GameObject SpawnVengeflyFromKing(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Set GG"); + if (action == null) { + return null; + } + + if (action.prefab.Value == null) { + return null; + } + + // Check if the store array exists and needs to be resized + if (!action.storeArray.IsNone) { + var length = action.spawnAmount.Value * action.spawnAmountMultiplier.Value; + if (action.storeArray.Values.Length != length) { + action.storeArray.Resize(length); + } + } + + var go = Object.Instantiate(action.prefab.Value); + + // Find a space in the store array to store this object in case we are host transferred + // That way, the pre-spawning of objects is already done + if (!action.storeArray.IsNone) { + var arr = action.storeArray.Values; + for (var i = 0; i < arr.Length; i++) { + if (arr[i] == null) { + arr[i] = go; + break; + } + } + } + + return go; + } + + private static GameObject SpawnOomaCoreObject(PlayMakerFSM fsm) { + var action = fsm.GetAction("Explode", 3); + + return SpawnFromCreateObject(action); + } + + private static GameObject SpawnSporgSpore(PlayMakerFSM fsm) { + var spawnAction = fsm.GetFirstAction("Fire"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnSoulTwisterOrbObject(PlayMakerFSM fsm) { + var spawnAction = fsm.GetFirstAction("Fire"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnSoulWarriorOrbObject(PlayMakerFSM fsm) { + var spawnAction = fsm.GetFirstAction("Shoot"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnSoulMasterOrbObject(PlayMakerFSM fsm) { + var spawnAction = fsm.GetFirstAction("Shot"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnOrbSpinnerOrbObject(PlayMakerFSM fsm) { + var spawnAction = fsm.GetFirstAction("Spawn"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnSoulMaster2OrbObject(PlayMakerFSM fsm) { + var spawnAction = fsm.GetFirstAction("Spawn Fireball"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnCollectorJarObject(PlayMakerFSM fsm) { + var setContents = fsm.GetFirstAction("Buzzer"); + _collectorVengeflyPrefab = setContents.enemyPrefab.Value; + setContents = fsm.GetFirstAction("Spitter"); + _collectorAspidPrefab = setContents.enemyPrefab.Value; + setContents = fsm.GetFirstAction("Roller"); + _collectorBaldurPrefab = setContents.enemyPrefab.Value; + + var spawnAction = fsm.GetFirstAction("Spawn"); + var gameObject = spawnAction.gameObject.Value; + + return SpawnFromGlobalPool(spawnAction, gameObject); + } + + private static GameObject SpawnCollectorJarContents(GameObject spawningObject, EntityType spawnedType) { + var spawnJarControl = spawningObject.GetComponent(); + if (spawnJarControl == null) { + Logger.Error("Could not find SpawnJarControl behaviour on spawning object"); + return null; + } + + GameObject gameObject; + int health; + var position = spawnJarControl.transform.position; + + switch (spawnedType) { + case EntityType.Vengefly: + gameObject = _collectorVengeflyPrefab.Spawn(position); + health = 8; + break; + case EntityType.AspidHunter: + gameObject = _collectorAspidPrefab.Spawn(position); + health = 15; + break; + case EntityType.Baldur: + gameObject = _collectorBaldurPrefab.Spawn(position); + health = 15; + break; + default: + Logger.Error($"Could not spawn object from collector jar: {spawnedType}"); + return null; + } + + var healthManager = gameObject.GetComponent(); + if (healthManager != null) { + healthManager.hp = health; + } + + gameObject.tag = "Boss"; + + return gameObject; + } + + private static GameObject SpawnDungBallObject(PlayMakerFSM fsm, EntityType spawnedType) { + SpawnObjectFromGlobalPool action; + GameObject gameObject; + + if (spawnedType == EntityType.LargeDungBall) { + action = fsm.GetFirstAction("Throw 1"); + gameObject = action.gameObject.Value; + } else if (spawnedType == EntityType.SmallDungBall) { + action = fsm.GetFirstAction("Erupt Out"); + gameObject = action.gameObject.Value; + } else { + throw new InvalidOperationException($"Could not spawn spawned type from Dung Defender: {spawnedType}"); + } + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnNoskBlobObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Roof Drop"); + var gameObject = action.gameObject.Value; + + return SpawnFromFlingGlobalPoolTime(action, gameObject); + } + + private static GameObject SpawnBrokenVesselBalloonObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Spawn"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnMantisPetraScytheObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Shoot"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnGalienMiniScytheObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Summon"); + + return SpawnFromCreateObject(action); + } + + private static GameObject SpawnMarkothShieldObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Init"); + + return SpawnFromCreateObject(action); + } + + private static GameObject SpawnKingsmouldBladeObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Throw"); + + return SpawnFromCreateObject(action); + } + + private static GameObject SpawnHornetSentinelSpikeObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Spawn 1"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnGrimmkinObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Level 3"); + + return SpawnFromCreateObject(action); + } + + private static GameObject SpawnGrimmFireballObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Fire Low R"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnGrimmFirebatObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Firebat 1"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnRadianceOrb(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Spawn Fireball"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnRadianceNail(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("CW Spawn"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnRadianceNailComb(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Comb Top"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnRadianceNailFromComb(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("RG1"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnInfectedBalloonWingedNoskObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Summon"); + var gameObject = action.gameObject.Value; + + return SpawnFromGlobalPool(action, gameObject); + } + + private static GameObject SpawnWingedNoskBlobObject(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Spit 1"); + var gameObject = action.gameObject.Value; + + return SpawnFromFlingGlobalPool(action, gameObject); + } + + private static GameObject SpawnWingedNoskGlobDropper(PlayMakerFSM fsm) { + var action = fsm.GetFirstAction("Drop"); + var gameObject = action.gameObject.Value; + + return SpawnFromFlingGlobalPool(action, gameObject); + } +} diff --git a/HKMP/Game/Client/Entity/EntityType.cs b/HKMP/Game/Client/Entity/EntityType.cs index 87422590..afb146b3 100644 --- a/HKMP/Game/Client/Entity/EntityType.cs +++ b/HKMP/Game/Client/Entity/EntityType.cs @@ -1,5 +1,244 @@ -namespace Hkmp.Game.Client.Entity; +namespace Hkmp.Game.Client.Entity; +/// +/// Enumeration of entity types. The entries more closely resemble the canonical name rather than the internal naming. +/// internal enum EntityType { - FalseKnight = 1, + BattleGate = 0, + CameraLockArea, + CityElevator, + CrystalPeakPlatform, + DreamPlatform, + Crawlid, + Tiktik, + Vengefly, + WanderingHusk, + HuskBully, + HuskHornhead, + Gruzzer, + AspidHunter, + HuskGuard, + LeapingHusk, + AspidMother, + AspidHatchling, + HuskWarrior, + Goam, + Baldur, + ElderBaldur, + FalseKnight, + FalseKnightHead, + FalseKnightBarrels, + FalseKnightFloor, + GruzMother, + BroodingMawlek, + BroodingMawlekArm, + BroodingMawlekHead, + BroodingMawlekDummy, + Mosscreep, + Mossfly, + Mosskin, + VolatileMosskin, + FoolEater, + Squit, + Obble, + Gulka, + Durandoo, + Duranda, + MassiveMossCharger, + MossCharger, + MossKnight, + Aluba, + VengeflyKing, + VengeflySummon, + ChargedLumafly, + Hornet, + Uoma, + Ooma, + OomaCorpse, + OomaCore, + Uumuu, + UumuuQuirrel, + Ambloom, + Fungling, + Fungoon, + Sporg, + SporgSpore, + FungifiedHusk, + Shrumeling, + ShrumalWarrior, + ShrumalOgre, + MantisYouth, + MantisWarrior, + MantisThrone, + MantisLordBattle, + MantisLord, + MantisLordCage, + MantisLordFloor, + HuskSentry, + HeavySentry, + WingedSentry, + LanceSentry, + Mistake, + Folly, + SoulTwister, + SoulOrb, + SoulWarrior, + SoulMaster, + SoulMasterOrbSpinner, + SoulMasterFakeQuake, + SoulMasterWindow, + SoulMasterPhase2, + HuskDandy, + CowardlyHusk, + GluttonousHusk, + GorgeousHusk, + GreatHuskSentry, + WatcherKnight, + TheCollector, + CollectorJar, + Belfly, + Pilflip, + Hwurmp, + HwurmpChild, + Bluggsac, + Flukefey, + Flukemon, + FlukemonBot, + FlukemonTop, + DungDefender, + LargeDungBall, + SmallDungBall, + DungDefenderBurrow, + DungDefenderCorpse, + Flukemarm, + Shardmite, + Glimback, + CrystalHunter, + CrystalCrawler, + CrystalTurret, + HuskMiner, + CrystallisedHusk, + CrystalGuardian, + CrystalGuardianLaser, + EnragedGuardian, + FuriousVengefly, + VolatileGruzzer, + ViolentHusk, + SlobberingHusk, + Dirtcarver, + CarverHatcher, + Garpede, + CorpseCreeper, + Deepling, + Deephunter, + LittleWeaver, + StalkingDevout, + Nosk, + NoskBlob, + ShadowCreeper, + LesserMawlek, + Mawlurk, + InfectedBalloon, + BrokenVessel, + BrokenVesselCorpse, + LostKin, + LostKinCorpse, + Boofly, + PrimalAspid, + Hopper, + GreatHopper, + GrubMimic, + Hiveling, + HiveSoldier, + HiveGuardian, + HuskHive, + SpinyHusk, + Loodle, + MantisPetra, + MantisPetraScythe, + MantisPetraSummon, + MantisTraitor, + TraitorLord, + ColosseumManager, + ColosseumCageSmall, + ColosseumCageLarge, + ColosseumPlatform, + ColosseumSpike, + ColosseumWall, + SharpBaldur, + ArmouredSquit, + BattleObble, + Oblobble, + ShieldedFool, + SturdyFool, + WingedFool, + HeavyFool, + //DeathLoodle, + VoltTwister, + ColosseumCageZote, + Zote, + Tamer, + Beast, + Xero, + XeroNail, + Gorb, + ElderHu, + Marmu, + NoEyes, + Galien, + GalienScythe, + GalienMiniScythe, + Markoth, + MarkothShield, + Wingmould, + Kingsmould, + KingsmouldBlade, + Sibling, + HornetSentinelSpikes, + HornetSentinelSpike, + HollowKnight, + Radiance, + RadianceOrb, + RadianceNailComb, + RadianceNail, + GreyPrinceZote, + Zoteling, + VolatileZoteling, + WhiteDefender, + GrimmkinSpawner, + GrimmkinNovice, + GrimmkinMaster, + GrimmkinNightmare, + Grimm, + GrimmBat, + NightmareKingGrimm, + NightmareKingGrimmBat, + GrimmSpikes, + GrimmFireball, + HiveKnight, + HiveKnightSpike, + HiveKnightBee, + Flukemunga, + PaleLurker, + Revek, + SoulTyrant, + SoulTyrantPhase2, + OroMato, + Oro, + Mato, + Sheo, + Sly, + PureVessel, + PureVesselBlast, + WingedNosk, + WingedNoskGlobDropper, + TurretZoteling, + LankyZoteling, + HeadOfZote, + FlukeZoteling, + ZoteCurse, + HeavyZoteling, + AbsoluteRadiance, + RadiancePlatform, + RadianceAbyss } diff --git a/HKMP/Game/Client/Entity/FalseKnight.cs b/HKMP/Game/Client/Entity/FalseKnight.cs deleted file mode 100644 index adb5e51d..00000000 --- a/HKMP/Game/Client/Entity/FalseKnight.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System; -using System.Collections.Generic; -using Hkmp.Networking.Client; -using Hkmp.Util; -using UnityEngine; -using Logger = Hkmp.Logging.Logger; - -namespace Hkmp.Game.Client.Entity; - -internal class FalseKnight : Entity { - private static readonly Dictionary SimpleEventStates = new Dictionary { - { State.Fall, "Start Fall" }, - { State.TurnR, "Turn R" }, - { State.TurnL, "Turn L" }, - { State.Run, "Run Antic" }, - { State.JumpAttackRight, "JA Right" }, - { State.JumpAttackLeft, "JA Left" }, - { State.StunTurnL, "Stun Turn L" }, - { State.StunTurnR, "Stun Turn R" }, - { State.StunStart, "Stun Start" }, - { State.OpenUp, "Open Uuup" }, - { State.Hit, "Hit" }, - { State.StunFail, "Stun Fail" }, - { State.Recover, "Recover" }, - { State.ToPhase2, "To Phase 2" }, - { State.ToPhase3, "To Phase 3" }, - { State.JumpAttack2, "JA Antic 2" }, - { State.Hit2, "Hit 2" }, - { State.Death, "Death Anim Start" } - }; - - private static readonly string[] StateUpdateResetNames = { - // After the initial fall, the FSM will end up here - "First Idle", - // Most sequences end back up in the Idle state - "Idle", - // The run sequence splits in jumping left or right - "JA Check Hero Pos", - // The slam sequences (jump and non-jump) end in "Voice? 2" - "Voice? 2", - // The move choice state has a lot of outputs, and some states end up here - "Move Choice", - // Part of the stun/stagger sequence, might be transitioned to - // after a global Stun event - "Check Direction", - // The end of the tumble through the air after a stun - "Roll End", - // When False Knight's armor is opened and the is popped out - "Opened", - // After the ground slamming rage is over - "Rage Check", - // When False Knight's falls through the floor and its armor opens up - "Opened 2" - }; - - private static readonly List InterruptingStates = new List { - State.StunTurnL, - State.StunTurnR, - State.StunStart, - State.Recover - }; - - private bool _isInitialized; - - public FalseKnight( - NetClient netClient, - byte entityId, - GameObject gameObject - ) : base( - netClient, - EntityType.FalseKnight, - entityId, - gameObject - ) { - Fsm = gameObject.LocateMyFSM("FalseyControl"); - - CreateEvents(); - } - - private void CreateEvents() { - // - // Insert methods for sending updates over network for reached states - // - foreach (var stateNamePair in SimpleEventStates) { - Fsm.InsertMethod(stateNamePair.Value, 0, CreateStateUpdateMethod(() => { - Logger.Info($"Sending {stateNamePair.Key} state"); - SendStateUpdate((byte) stateNamePair.Key); - })); - } - - Fsm.InsertMethod("Jump Antic", 0, CreateStateUpdateMethod(() => { - var variables = new List(); - - // Get the Jump X variable from the FSM and add it as bytes to the variables list - var jumpXFloat = Fsm.FsmVariables.GetFsmFloat("Jump X").Value; - variables.AddRange(BitConverter.GetBytes(jumpXFloat)); - - Logger.Info($"Sending Jump state with variable: {jumpXFloat}"); - - SendStateUpdate((byte) State.Jump, variables); - })); - - Fsm.InsertMethod("S Jump", 0, CreateStateUpdateMethod(() => { - var variables = new List(); - - // Get the Jump X variable from the FSM and add it as bytes to the variables list - var jumpXFloat = Fsm.FsmVariables.GetFsmFloat("Jump X").Value; - variables.AddRange(BitConverter.GetBytes(jumpXFloat)); - - Logger.Info($"Sending Slam Jump state with variable: {jumpXFloat}"); - - SendStateUpdate((byte) State.SlamJump, variables); - })); - - Fsm.InsertMethod("S Attack Antic", 0, CreateStateUpdateMethod(() => { - var variables = new List(); - - var shockwaveXOriginFloat = Fsm.FsmVariables.GetFsmFloat("Shockwave X Origin").Value; - variables.AddRange(BitConverter.GetBytes(shockwaveXOriginFloat)); - - var shockwaveGoingRightBool = Fsm.FsmVariables.GetFsmBool("Shockwave Going Right").Value; - variables.AddRange(BitConverter.GetBytes(shockwaveGoingRightBool)); - - Logger.Info( - $"Sending Slam Attack state with variables: {shockwaveXOriginFloat}, {shockwaveGoingRightBool}"); - SendStateUpdate((byte) State.SlamAttack, variables); - })); - - // - // Insert methods for resetting the update state, so we can start/receive the next update - // - foreach (var stateName in StateUpdateResetNames) { - Fsm.InsertMethod(stateName, 0, StateUpdateDone); - } - - Fsm.InsertMethod("Turn R", 8, StateUpdateDone); - Fsm.InsertMethod("Turn L", 8, StateUpdateDone); - } - - protected override void InternalTakeControl() { - foreach (var stateName in StateUpdateResetNames) { - RemoveOutgoingTransitions(stateName); - } - - // Make sure that the FSM doesn't even start at all, - // by removing transitions of one of the first states - RemoveOutgoingTransitions("Dormant"); - - RemoveOutgoingTransition("Hit", "Recover"); - } - - protected override void InternalReleaseControl() { - RestoreAllOutgoingTransitions(); - } - - protected override void StartQueuedUpdate(byte state, List variables) { - var variableArray = variables.ToArray(); - - var enumState = (State) state; - - // If we not initialized before this state update, we need to - // do it before we set the FSM states and variables - if (!_isInitialized) { - InitializeForIntermediateState(); - } - - _isInitialized = true; - - if (SimpleEventStates.TryGetValue(enumState, out var stateName)) { - Logger.Info($"Received {enumState} state"); - Fsm.SetState(stateName); - - return; - } - - switch ((State) state) { - case State.Jump: - var jumpXFloat = BitConverter.ToSingle(variableArray, 0); - - Logger.Info($"Received Jump state with variable: {jumpXFloat}"); - - Fsm.FsmVariables.GetFsmFloat("Jump X").Value = jumpXFloat; - - Fsm.SetState("Jump Antic"); - break; - case State.SlamJump: - var slamJumpXFloat = BitConverter.ToSingle(variableArray, 0); - - Logger.Info($"Received Slam Jump state with variable: {slamJumpXFloat}"); - - Fsm.FsmVariables.GetFsmFloat("Jump X").Value = slamJumpXFloat; - - Fsm.SetState("S Jump"); - break; - case State.SlamAttack: - var shockwaveXOriginFloat = BitConverter.ToSingle(variableArray, 0); - var shockwaveGoingRightBool = BitConverter.ToBoolean(variableArray, 4); - - Logger.Info( - $"Received Slam Attack state with variables: {shockwaveXOriginFloat}, {shockwaveGoingRightBool}"); - - Fsm.FsmVariables.GetFsmFloat("Shockwave X Origin").Value = shockwaveXOriginFloat; - Fsm.FsmVariables.GetFsmBool("Shockwave Going Right").Value = shockwaveGoingRightBool; - - Fsm.SetState("S Attack Antic"); - break; - } - } - - protected override bool IsInterruptingState(byte state) { - return InterruptingStates.Contains((State) state); - } - - private void InitializeForIntermediateState() { - // The mesh renderer is disabled until the Start Fall state - GameObject.GetComponent().enabled = true; - - // Same holds for rigidbody properties - var rigidbody = GameObject.GetComponent(); - rigidbody.isKinematic = false; - rigidbody.gravityScale = 1; - } - - private enum State { - Fall = 0, - Jump, - TurnR, - TurnL, - Run, - JumpAttackRight, - JumpAttackLeft, - SlamJump, - SlamAttack, - StunTurnL, - StunTurnR, - StunStart, - OpenUp, - Hit, - StunFail, - Recover, - ToPhase2, - ToPhase3, - JumpAttack2, - Hit2, - Death, - } -} diff --git a/HKMP/Game/Client/Entity/FsmSnapshot.cs b/HKMP/Game/Client/Entity/FsmSnapshot.cs new file mode 100644 index 00000000..b60b126f --- /dev/null +++ b/HKMP/Game/Client/Entity/FsmSnapshot.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Hkmp.Game.Client.Entity; + +/// +/// Snapshot of FSM that includes current state and current values for all FSM variables. +/// Used to check for any changes in state/variables to network with the server. +/// +internal class FsmSnapshot { + /// + /// The name of the current (or last) state of the FSM. + /// + public string CurrentState { get; set; } + + /// + /// Dictionary of names of float variables and corresponding (current/last) value. + /// + public float[] Floats { set; get; } + /// + /// Dictionary of names of int variables and corresponding (current/last) value. + /// + public int[] Ints { set; get; } + /// + /// Dictionary of names of bool variables and corresponding (current/last) value. + /// + public bool[] Bools { set; get; } + /// + /// Dictionary of names of string variables and corresponding (current/last) value. + /// + public string[] Strings { set; get; } + /// + /// Dictionary of names of vector2 variables and corresponding (current/last) value. + /// + public Vector2[] Vector2s { set; get; } + /// + /// Dictionary of names of vector3 variables and corresponding (current/last) value. + /// + public Vector3[] Vector3s { set; get; } +} diff --git a/HKMP/Game/Client/Entity/HostClientPair.cs b/HKMP/Game/Client/Entity/HostClientPair.cs new file mode 100644 index 00000000..1e44db86 --- /dev/null +++ b/HKMP/Game/Client/Entity/HostClientPair.cs @@ -0,0 +1,16 @@ +namespace Hkmp.Game.Client.Entity; + +/// +/// Simple generic class for a pair of objects that are shared by the client and host. +/// +/// The type of the objects. +internal class HostClientPair { + /// + /// The client object. + /// + public T Client { get; set; } + /// + /// The host object. + /// + public T Host { get; set; } +} \ No newline at end of file diff --git a/HKMP/Game/Client/Entity/IEntity.cs b/HKMP/Game/Client/Entity/IEntity.cs deleted file mode 100644 index 943807bd..00000000 --- a/HKMP/Game/Client/Entity/IEntity.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using Hkmp.Math; - -namespace Hkmp.Game.Client.Entity; - -internal interface IEntity { - bool IsControlled { get; } - bool AllowEventSending { get; set; } - - void TakeControl(); - - void ReleaseControl(); - - void UpdatePosition(Vector2 position); - - void UpdateState(byte state, List variables); - - void Destroy(); -} diff --git a/HKMP/Game/Client/GamePatcher.cs b/HKMP/Game/Client/GamePatcher.cs new file mode 100644 index 00000000..74333c2c --- /dev/null +++ b/HKMP/Game/Client/GamePatcher.cs @@ -0,0 +1,617 @@ +using System; +using System.Reflection; +using GlobalEnums; +using Hkmp.Networking.Client; +using Modding; +using Mono.Cecil.Cil; +using MonoMod.Cil; +using MonoMod.RuntimeDetour; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client; + +/// +/// Class that manager patches such as IL and On hooks that are standalone patches for the multiplayer to function +/// correctly. +/// +internal class GamePatcher { + /// + /// The binding flags for obtaining certain types for hooking. + /// + private const BindingFlags BindingFlags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + /// + /// The NetClient instance to check if we are connected to a server. + /// + private readonly NetClient _netClient; + + /// + /// The IL Hook for the bridge lever method. + /// + private ILHook _bridgeLeverIlHook; + + public GamePatcher(NetClient netClient) { + _netClient = netClient; + } + + /// + /// Register the hooks. + /// + public void RegisterHooks() { + // Register IL hook for changing the behaviour of tink effects + IL.TinkEffect.OnTriggerEnter2D += TinkEffectOnTriggerEnter2D; + + IL.HealthManager.Invincible += HealthManagerOnInvincible; + + IL.HealthManager.TakeDamage += HealthManagerOnTakeDamage; + + On.BridgeLever.OnTriggerEnter2D += BridgeLeverOnTriggerEnter2D; + + var type = typeof(BridgeLever).GetNestedType("d__13", BindingFlags); + _bridgeLeverIlHook = new ILHook(type.GetMethod("MoveNext", BindingFlags), BridgeLeverOnOpenBridge); + + On.HutongGames.PlayMaker.Actions.CallMethodProper.DoMethodCall += CallMethodProperOnDoMethodCall; + + IL.CameraLockArea.IsInApplicableGameState += CameraLockAreaOnIsInApplicableGameState; + + On.IgnoreHeroCollision.Ignore += IgnoreHeroCollisionOnIgnore; + + On.SceneAdditiveLoadConditional.OnEnable += SceneAdditiveLoadConditionalOnEnable; + } + + /// + /// De-register the hooks. + /// + public void DeregisterHooks() { + IL.TinkEffect.OnTriggerEnter2D -= TinkEffectOnTriggerEnter2D; + + IL.HealthManager.Invincible -= HealthManagerOnInvincible; + + IL.HealthManager.TakeDamage -= HealthManagerOnTakeDamage; + + On.BridgeLever.OnTriggerEnter2D -= BridgeLeverOnTriggerEnter2D; + + _bridgeLeverIlHook?.Dispose(); + + On.HutongGames.PlayMaker.Actions.CallMethodProper.DoMethodCall -= CallMethodProperOnDoMethodCall; + + IL.CameraLockArea.IsInApplicableGameState -= CameraLockAreaOnIsInApplicableGameState; + + On.IgnoreHeroCollision.Ignore -= IgnoreHeroCollisionOnIgnore; + } + + /// + /// IL hook to change the TinkEffect OnTriggerEnter2D to not trigger certain effects of it on remote players. + /// This method will insert IL to check whether the player responsible for the attack is the local player and + /// based on this, omit certain effects. + /// + private void TinkEffectOnTriggerEnter2D(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Load the 'collision' argument onto the stack + c.Emit(OpCodes.Ldarg_1); + + // Keep track of a whether the local player is responsible for the hit + var isLocalPlayer = true; + + // Emit a delegate that pops the collision argument from the stack, checks whether the parent + // of the collider is the knight and changes the above bool based on this + c.EmitDelegate>(collider => { + var parent = collider.transform.parent; + if (parent == null) { + isLocalPlayer = true; + return; + } + + parent = parent.parent; + if (parent == null) { + isLocalPlayer = true; + return; + } + + isLocalPlayer = parent.gameObject.name == "Knight"; + }); + + // Define a label to branch to after the camera shake call + var afterCameraShakeLabel = c.DefineLabel(); + + // Goto before the camera shake call, which is after the 'if' instructions + c.GotoNext( + MoveType.After, + i => i.MatchLdfld(typeof(TinkEffect), "gameCam"), + i => i.MatchCall(typeof(UnityEngine.Object), "op_Implicit"), + i => i.MatchBrfalse(out _) + ); + + // Emit the 'isLocalPlayer' bool to the stack + c.EmitDelegate(() => isLocalPlayer); + + // Emit an instruction that branches to after the camera shake based on the bool + c.Emit(OpCodes.Brfalse, afterCameraShakeLabel); + + // Goto after the camera shake call + c.GotoNext( + MoveType.After, + i => i.MatchLdfld(typeof(GameCameras), "cameraShakeFSM"), + i => i.MatchLdstr("EnemyKillShake"), + i => i.MatchCallvirt(typeof(PlayMakerFSM), "SendEvent") + ); + + // Mark the label for branching here + c.MarkLabel(afterCameraShakeLabel); + + // Goto after setting the 'position2' local variable + c.GotoNext( + MoveType.After, + i => i.MatchCallvirt(typeof(Component), "get_transform"), + i => i.MatchCallvirt(typeof(Transform), "get_position"), + i => i.MatchStloc(3) + ); + + // Define a label for branching + var afterRemotePositionLabel = c.DefineLabel(); + + // Emit the 'isLocalPlayer' bool to the stack + c.EmitDelegate(() => isLocalPlayer); + + // Emit the instruction for branching to behind the setting of the remote player position (below) + c.Emit(OpCodes.Brtrue, afterRemotePositionLabel); + + // Load the 'collision' argument onto the stack + c.Emit(OpCodes.Ldarg_1); + // Emit a delegate that pops the 'collision' argument from the stack and pushes the position of the + // remote player to the stack + c.EmitDelegate>(collider => collider.transform.parent.parent.position); + + // Emit the instruction for pushing the stack value into the 'position2' local variable + c.Emit(OpCodes.Stloc, 3); + + // Mark the label for branching after setting the remote position, that we skip when the local player + // is responsible for the hit + c.MarkLabel(afterRemotePositionLabel); + + // Loop 3 times for the 3 'Recoil' method calls to HeroController + for (var i = 0; i < 3; i++) { + // Goto before the 'Recoil' method call + c.GotoNext( + MoveType.Before, + inst => inst.MatchCall(typeof(HeroController), "get_instance") + ); + + // Define a label for branching + var afterRecoilLabel = c.DefineLabel(); + + // Emit the 'isLocalPlayer' bool to the stack + c.EmitDelegate(() => isLocalPlayer); + // Emit the instruction for branching after the 'Recoil' call + c.Emit(OpCodes.Brfalse, afterRecoilLabel); + + // Goto after the FSM 'SendEvent' call + c.GotoNext( + MoveType.After, + inst => inst.MatchCallvirt(typeof(PlayMakerFSM), "SendEvent") + ); + + // Mark the label for branching here + c.MarkLabel(afterRecoilLabel); + } + + // Goto after the last if statement that checks for the 'sendFSMEvent' variable + c.GotoNext( + MoveType.After, + i => i.MatchLdarg(0), + i => i.MatchLdfld(typeof(TinkEffect), "sendFSMEvent"), + i => i.MatchBrfalse(out _) + ); + + // Define a label to branch to + var afterSendEventLabel = c.DefineLabel(); + + // Emit the 'isLocalPlayer' bool to the stack + c.EmitDelegate(() => isLocalPlayer); + // Emit the instruction for branching after the 'SendEvent' call in case of a remote player hit + c.Emit(OpCodes.Brfalse, afterSendEventLabel); + + // Goto after the 'SendEvent' call to mark the label + c.GotoNext( + MoveType.After, + i => i.MatchLdarg(0), + i => i.MatchLdfld(typeof(TinkEffect), "FSMEvent"), + i => i.MatchCallvirt(typeof(PlayMakerFSM), "SendEvent") + ); + + // Mark the label to branch to here + c.MarkLabel(afterSendEventLabel); + } catch (Exception e) { + Logger.Error($"Could not change TinkEffect#OnTriggerEnter2D IL:\n{e}"); + } + } + + private void HealthManagerOnInvincible(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Load the 'hitInstance' argument onto the stack + c.Emit(OpCodes.Ldarg_1); + + // Keep track of a whether the local player is responsible for the hit + var isLocalPlayer = true; + + // Emit a delegate that pops the collision argument from the stack, checks whether the parent + // of the collider is the knight and changes the above bool based on this + c.EmitDelegate>(hitInstance => { + if (hitInstance.Source == null) { + isLocalPlayer = true; + return; + } + + var parent = hitInstance.Source.transform.parent; + if (parent == null) { + isLocalPlayer = true; + return; + } + + parent = parent.parent; + if (parent == null) { + isLocalPlayer = true; + return; + } + + isLocalPlayer = parent.gameObject.name == "Knight"; + }); + + c.GotoNext( + MoveType.Before, + i => i.MatchLdarg(1), + i => i.MatchLdfld(typeof(HitInstance), "AttackType"), + i => i.MatchBrtrue(out _) + ); + + var afterRecoilFreezeShakeLabel = c.DefineLabel(); + + c.EmitDelegate(() => isLocalPlayer); + c.Emit(OpCodes.Brfalse, afterRecoilFreezeShakeLabel); + + c.GotoNext( + MoveType.After, + i => i.MatchLdfld(typeof(GameCameras), "cameraShakeFSM"), + i => i.MatchLdstr("EnemyKillShake"), + i => i.MatchCallvirt(typeof(PlayMakerFSM), "SendEvent") + ); + + c.MarkLabel(afterRecoilFreezeShakeLabel); + } catch (Exception e) { + Logger.Error($"Could not change HealthManager#OnInvincible IL:\n{e}"); + } + } + + /// + /// IL Hook to modify the behaviour of the TakeDamage method in HealthManager. This modification adds a + /// conditional branch in case the nail swing from the HitInstance was from a remote player to ensure that + /// soul is not gained for remote hits. + /// + private void HealthManagerOnTakeDamage(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Goto the next virtual call to HeroController.SoulGain() + c.GotoNext(i => i.MatchCallvirt(typeof(HeroController), "SoulGain")); + + // Move the cursor to before the call and call virtual instructions + c.Index -= 1; + + // Emit the instruction to load the first parameter (hitInstance) onto the stack + c.Emit(OpCodes.Ldarg_1); + + // Emit a delegate that takes the hitInstance parameter from the stack and pushes a boolean on the stack + // that indicates whether the hitInstance was from a remote player's nail swing + c.EmitDelegate>(hitInstance => { + if (hitInstance.Source == null || hitInstance.Source.transform == null) { + return false; + } + + // Find the top-level parent of the hit instance + var transform = hitInstance.Source.transform; + while (transform.parent != null) { + transform = transform.parent; + } + + var go = transform.gameObject; + + return go.tag != "Player"; + }); + + // Define a label for the branch instruction + var afterLabel = c.DefineLabel(); + + // Emit the branch (on true) instruction with the label + c.Emit(OpCodes.Brtrue, afterLabel); + + // Move the cursor after the SoulGain method call + c.Index += 2; + + // Mark the label here, so we branch after the SoulGain method call on true + c.MarkLabel(afterLabel); + } catch (Exception e) { + Logger.Error($"Could not change HealthManager#TakeDamage IL:\n{e}"); + } + } + + /// + /// Whether the local player hit the bridge lever. + /// + private bool _localPlayerBridgeLever; + + /// + /// On Hook that stores a boolean depending on whether the local player hit the bridge lever or not. Used in the + /// IL Hook below. + /// + private void BridgeLeverOnTriggerEnter2D(On.BridgeLever.orig_OnTriggerEnter2D orig, BridgeLever self, Collider2D collision) { + var activated = ReflectionHelper.GetField(self, "activated"); + + if (!activated && collision.tag == "Nail Attack") { + _localPlayerBridgeLever = collision.transform.parent?.parent?.tag == "Player"; + } + + orig(self, collision); + } + + /// + /// IL Hook to modify the OpenBridge method of BridgeLever to exclude locking players in place that did not hit + /// the lever. + /// + private void BridgeLeverOnOpenBridge(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Define the collection of instructions that matches the FreezeMoment call + Func[] freezeMomentInstructions = [ + i => i.MatchCall(typeof(global::GameManager), "get_instance"), + i => i.MatchLdcI4(1), + i => i.MatchCallvirt(typeof(global::GameManager), "FreezeMoment") + ]; + + // Goto after the FreezeMoment call + c.GotoNext(MoveType.Before, freezeMomentInstructions); + + // Emit a delegate that puts the boolean on the stack + c.EmitDelegate(() => _localPlayerBridgeLever); + + // Define the label to branch to + var afterFreezeLabel = c.DefineLabel(); + + // Then emit an instruction that branches to after the freeze if the boolean is false + c.Emit(OpCodes.Brfalse, afterFreezeLabel); + + // Goto after the FreezeMoment call + c.GotoNext(MoveType.After, freezeMomentInstructions); + + // Mark the label after the FreezeMoment call so we branch here + c.MarkLabel(afterFreezeLabel); + + // Goto after the rumble call + c.GotoNext( + MoveType.After, + i => i.MatchCall(typeof(GameCameras), "get_instance"), + i => i.MatchLdfld(typeof(GameCameras), "cameraShakeFSM"), + i => i.MatchLdstr("RumblingMed"), + i => i.MatchLdcI4(1), + i => i.MatchCall(typeof(FSMUtility), "SetBool") + ); + + // Emit a delegate that puts the boolean on the stack + c.EmitDelegate(() => _localPlayerBridgeLever); + + // Define the label to branch to + var afterRoarEnterLabel = c.DefineLabel(); + + // Emit another instruction that branches over the roar enter FSM calls + c.Emit(OpCodes.Brfalse, afterRoarEnterLabel); + + // Goto after the roar enter call + c.GotoNext( + MoveType.After, + i => i.MatchLdstr("ROAR ENTER"), + i => i.MatchLdcI4(0), + i => i.MatchCall(typeof(FSMUtility), "SendEventToGameObject") + ); + + // Mark the label after the Roar Enter call so we branch here + c.MarkLabel(afterRoarEnterLabel); + + // Define the collection of instructions that matches the roar exit FSM call + Func[] roarExitInstructions = [ + i => i.MatchCall(typeof(HeroController), "get_instance"), + i => i.MatchCallvirt(typeof(Component), "get_gameObject"), + i => i.MatchLdstr("ROAR EXIT"), + i => i.MatchLdcI4(0), + i => i.MatchCall(typeof(FSMUtility), "SendEventToGameObject") + ]; + + // Goto before the roar exit FSM call + c.GotoNext(MoveType.Before, roarExitInstructions); + + // Emit a delegate that puts the boolean on the stack + c.EmitDelegate(() => _localPlayerBridgeLever); + + // Define the label to branch to + var afterRoarExitLabel = c.DefineLabel(); + + // Emit the last instruction to branch over the roar exit call + c.Emit(OpCodes.Brfalse, afterRoarExitLabel); + + // Goto after the roar exit FSM call + c.GotoNext(MoveType.After, roarExitInstructions); + + // Mark the label so we branch here + c.MarkLabel(afterRoarExitLabel); + } catch (Exception e) { + Logger.Error($"Could not change BridgeLever#OnOpenBridge IL: \n{e}"); + } + } + + /// + /// Hook for the 'DoMethodCall' method in the 'CallMethodProper' FSM action. This is used for the Crystal Shot + /// game object to ensure that knockback is not applied to the local player if a remote player hits the crystal. + /// + private void CallMethodProperOnDoMethodCall( + On.HutongGames.PlayMaker.Actions.CallMethodProper.orig_DoMethodCall orig, + HutongGames.PlayMaker.Actions.CallMethodProper self + ) { + // If the 'behaviour' and 'methodName' strings do not match, we execute the original method and return + if (self.behaviour.Value != "HeroController" || + self.methodName.Value != "RecoilLeft" && + self.methodName.Value != "RecoilRight" && + self.methodName.Value != "RecoilDown" && + self.methodName.Value != "Bounce" + ) { + orig(self); + return; + } + + // If the state does not match, we return as well + if (!self.State.Name.StartsWith("No Box ") && !self.State.Name.StartsWith("Blocked ")) { + orig(self); + return; + } + + Transform attacks; + + // Find either the 'Damager' or the 'Collider' game object from the FSM variables, and check up the hierarchy. + // If it matches the local player's object, then we know it was the local player's slash and not a remote + // player's slash + var damager = self.Fsm.Variables.FindFsmGameObject("Damager"); + var collider = self.Fsm.Variables.FindFsmGameObject("Collider"); + if (damager != null && damager.Value != null) { + attacks = damager.Value.transform.parent; + } else if (collider != null && collider.Value != null && collider.Value.transform.parent != null) { + attacks = collider.Value.transform.parent.parent; + } else { + orig(self); + return; + } + + if (attacks == null) { + orig(self); + return; + } + + var knight = attacks.parent; + if (knight == null) { + orig(self); + return; + } + + if (knight.name.Equals("Knight")) { + orig(self); + } + } + + /// + /// IL Hook for the 'IsInApplicableGameState' method in 'CameraLockArea'. This is used to add a check + /// for being in the pause menu and connected to a server. Otherwise, the camera will sometimes not lock while + /// in the pause menu during host transfers. + /// + private void CameraLockAreaOnIsInApplicableGameState(ILContext il) { + try { + // Create a cursor for this context + var c = new ILCursor(il); + + // Goto after the first 'gameState' check in the method + // IL_0011: ldloc.0 // unsafeInstance + // IL_0012: ldfld valuetype GlobalEnums.GameState GameManager::gameState + // IL_0017: ldc.i4.4 + // IL_0018: beq.s IL_0024 + c.GotoNext( + MoveType.After, + i => i.MatchLdloc(0), + i => i.MatchLdfld(typeof(global::GameManager), "gameState"), + i => i.MatchLdcI4(4), + i => i.MatchBeq(out _) + ); + + // Define a label for branching to if the conditions fail + var afterChecksLabel = c.DefineLabel(); + + // Load GameManager 'unsafeInstance' onto evaluation stack + c.Emit(OpCodes.Ldloc, 0); + // Check if the game state is paused and push a boolean onto the stack based on the result + c.EmitDelegate>(gm => gm.gameState == GameState.PAUSED); + // If the game state is not paused, we branch to the last check in the method + c.Emit(OpCodes.Brfalse, afterChecksLabel); + + // Check if the NetClient is connected and push a boolean onto the stack based on the result + c.EmitDelegate(() => _netClient.IsConnected); + // If we are not connected, we branch to the last check in the method + c.Emit(OpCodes.Brfalse, afterChecksLabel); + + var returnTrueLabel = c.DefineLabel(); + + // If the previous two checks succeeded, we branch to the return true label + c.Emit(OpCodes.Br, returnTrueLabel); + + // Mark the label after our new checks but before the last check of the method + c.MarkLabel(afterChecksLabel); + + // Goto before the 'return true' IL so we can branch here if our checks succeed + // IL_0024: ldc.i4.1 + // IL_0025: ret + c.GotoNext( + MoveType.Before, + i => i.MatchLdcI4(1), + i => i.MatchRet() + ); + + // Mark the label for return true here + c.MarkLabel(returnTrueLabel); + } catch (Exception e) { + Logger.Error($"Could not change CameraLockArea#IsInApplicableGameState IL: \n{e}"); + } + } + + // TODO: this is a temporary solution and requires further investigation on why this happens + /// + /// Hook for the 'Ignore' method in the 'IgnoreHeroCollision' MonoBehaviour. This is simply a hook that doesn't do + /// anything, but prevents NRE's happening in the method do to it now being redirected by MonoMod. + /// + private void IgnoreHeroCollisionOnIgnore(On.IgnoreHeroCollision.orig_Ignore orig, IgnoreHeroCollision self) { + if (!self) { + Logger.Error("IgnoreHeroCollision's object is null, cannot do collision check"); + return; + } + + try { + self.GetComponent(); + } catch (NullReferenceException) { + Logger.Error("IgnoreHeroCollision gave NRE on getting component"); + return; + } + + orig(self); + } + + /// + /// Hook for the 'OnEnable' method in the 'SceneAdditiveLoadConditional' MonoBehaviour. This is to change certain + /// conditions about additional scene loads where they should happen regardless of the condition in multiplayer. + /// + private void SceneAdditiveLoadConditionalOnEnable( + On.SceneAdditiveLoadConditional.orig_OnEnable orig, + SceneAdditiveLoadConditional self + ) { + if (self.gameObject.scene.name == "Fungus3_23") { + // Remove the extra boolean test for 'hasShadowDash', which is not needed given that there is a + // Shade Gate at the beginning of the room + self.extraBoolTests = []; + orig(self); + return; + } + + orig(self); + } +} diff --git a/HKMP/Game/Client/MapManager.cs b/HKMP/Game/Client/MapManager.cs index da806fe8..adb5860b 100644 --- a/HKMP/Game/Client/MapManager.cs +++ b/HKMP/Game/Client/MapManager.cs @@ -51,9 +51,20 @@ public MapManager(NetClient netClient, ServerSettings serverSettings) { _serverSettings = serverSettings; _mapEntries = new Dictionary(); + } + /// + /// Initialize the map manager. + /// + public void Initialize() { + // Register the disconnect event so we can remove map icons _netClient.DisconnectEvent += OnDisconnect; + } + /// + /// Register the hooks needed for map related operations. + /// + public void RegisterHooks() { // Register a hero controller update callback, so we can update the map icon position On.HeroController.Update += HeroControllerOnUpdate; @@ -64,6 +75,17 @@ public MapManager(NetClient netClient, ServerSettings serverSettings) { On.GameMap.PositionCompass += OnPositionCompass; } + /// + /// Deregister the hooks needed for map related operations. + /// + public void DeregisterHooks() { + On.HeroController.Update -= HeroControllerOnUpdate; + + On.GameMap.CloseQuickMap -= OnCloseQuickMap; + + On.GameMap.PositionCompass -= OnPositionCompass; + } + /// /// Callback method for the HeroController#Update method. /// @@ -184,7 +206,15 @@ private bool TryGetMapLocation(out Vector3 mapLocation) { 0f ); - var size = sceneObject.GetComponent().sprite.bounds.size; + var spriteRenderer = sceneObject.GetComponent(); + if (spriteRenderer == null) { + // The sprite renderer being null happens in some transitions, but does not mean the player does + // not have a map icon anymore + mapLocation = _lastPosition; + return true; + } + + var size = spriteRenderer.sprite.bounds.size; Vector3 position; diff --git a/HKMP/Game/Client/PauseManager.cs b/HKMP/Game/Client/PauseManager.cs index f268eb02..c8319280 100644 --- a/HKMP/Game/Client/PauseManager.cs +++ b/HKMP/Game/Client/PauseManager.cs @@ -39,6 +39,20 @@ public void RegisterHooks() { ModHooks.BeforePlayerDeadHook += OnDeath; } + /// + /// Deregisters the required method hooks. + /// + public void DeregisterHooks() { + On.InputHandler.Update -= InputHandlerOnUpdate; + On.UIManager.TogglePauseGame -= UIManagerOnTogglePauseGame; + + On.HeroController.Pause -= HeroControllerOnPause; + On.TransitionPoint.OnTriggerEnter2D -= TransitionPointOnOnTriggerEnter2D; + On.HeroController.DieFromHazard -= HeroControllerOnDieFromHazard; + + ModHooks.BeforePlayerDeadHook -= OnDeath; + } + /// /// Callback method for the UIManager#TogglePauseGame method. /// diff --git a/HKMP/Game/Client/PlayerManager.cs b/HKMP/Game/Client/PlayerManager.cs index 32ac1565..8708482c 100644 --- a/HKMP/Game/Client/PlayerManager.cs +++ b/HKMP/Game/Client/PlayerManager.cs @@ -3,10 +3,9 @@ using Hkmp.Fsm; using Hkmp.Game.Client.Skin; using Hkmp.Game.Settings; -using Hkmp.Networking.Packet; -using Hkmp.Networking.Packet.Data; using Hkmp.Ui.Resources; using Hkmp.Util; +using Modding.Utils; using TMPro; using UnityEngine; using Logger = Hkmp.Logging.Logger; @@ -81,7 +80,6 @@ internal class PlayerManager { private readonly Dictionary _activePlayers; public PlayerManager( - PacketManager packetManager, ServerSettings serverSettings, Dictionary playerData ) { @@ -93,26 +91,48 @@ Dictionary playerData _inactivePlayers = new Queue(); _activePlayers = new Dictionary(); + } - On.HeroController.Start += (orig, self) => { - orig(self); + /// + /// Intialize the player manager by register packet handlers and initialize the skin manager. + /// + public void Initialize() { + _skinManager.Initialize(); + } - if (_playerContainerPrefab == null) { - CreatePlayerPool(); - } - }; + /// + /// Register the relevant hooks for player-related operations. + /// + public void RegisterHooks() { + _skinManager.RegisterHooks(); + + CustomHooks.HeroControllerStartAction += HeroControllerOnStart; + } + + /// + /// Deregister the relevant hooks for player-related operations. + /// + public void DeregisterHooks() { + _skinManager.DeregisterHooks(); + + CustomHooks.HeroControllerStartAction -= HeroControllerOnStart; + } - // Register packet handlers - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerTeamUpdate, - OnPlayerTeamUpdate); - packetManager.RegisterClientPacketHandler(ClientPacketId.PlayerSkinUpdate, - OnPlayerSkinUpdate); + /// + /// Callback method for when the HeroController starts so we can create the player pool. + /// + private void HeroControllerOnStart() { + TryCreatePlayerPool(); } /// - /// Create the initial pool of player objects. + /// Try to create the initial pool of player objects if it hasn't been created yet. /// - private void CreatePlayerPool() { + private void TryCreatePlayerPool() { + if (_playerContainerPrefab) { + return; + } + // Create a player container prefab, used to spawn players _playerContainerPrefab = new GameObject(PlayerContainerPrefabName); @@ -159,7 +179,14 @@ private void CreatePlayerPool() { nonBouncer.active = false; // Add some extra gameObjects related to animation effects - new GameObject("Attacks") { layer = 9 }.transform.SetParent(playerPrefab.transform); + var attacks = new GameObject("Attacks") { layer = 9 }; + attacks.transform.SetParent(playerPrefab.transform); + // Add rigid body to make sure collisions still work + var rigidbody = attacks.AddComponent(); + rigidbody.collisionDetectionMode = CollisionDetectionMode2D.Continuous; + rigidbody.gravityScale = 0; + rigidbody.isKinematic = true; + new GameObject("Effects") { layer = 9 }.transform.SetParent(playerPrefab.transform); new GameObject("Spells") { layer = 9 }.transform.SetParent(playerPrefab.transform); @@ -202,7 +229,7 @@ public void UpdatePosition(ushort id, Vector2 position) { } var playerContainer = playerData.PlayerContainer; - if (playerContainer != null) { + if (playerContainer) { var unityPosition = new Vector3(position.X, position.Y); playerContainer.GetComponent().SetNewPosition(unityPosition); @@ -230,9 +257,9 @@ public void UpdateScale(ushort id, bool scale) { /// /// The GameObject representing the player. /// The new scale as a boolean, true indicating a X scale of 1, - /// false indicating a X scale of -1. + /// false indicating an X scale of -1. private void SetPlayerObjectBoolScale(GameObject playerObject, bool scale) { - if (playerObject == null) { + if (!playerObject) { return; } @@ -333,7 +360,7 @@ public void RecycleAllPlayers() { /// The player data of the player. private void ResetPlayer(ClientPlayerData playerData) { var container = playerData.PlayerContainer; - if (container == null) { + if (!container) { return; } @@ -473,13 +500,13 @@ public void AddNameToPlayer(GameObject playerContainer, string name, Team team = // Create a name object to set the username to, slightly above the player object var nameObject = playerContainer.FindGameObjectInChildren(UsernameObjectName); - if (nameObject == null) { + if (!nameObject) { nameObject = CreateUsername(playerContainer); } - var textMeshObject = nameObject.GetComponent(); + var textMeshObject = nameObject.GetOrAddComponent(); - if (textMeshObject != null) { + if (textMeshObject) { textMeshObject.text = name.ToUpper(); ChangeNameColor(textMeshObject, team); } @@ -490,21 +517,28 @@ public void AddNameToPlayer(GameObject playerContainer, string name, Team team = /// /// Callback method for when a player team update is received. /// - /// The ClientPlayerTeamUpdate packet data. - private void OnPlayerTeamUpdate(ClientPlayerTeamUpdate playerTeamUpdate) { - var id = playerTeamUpdate.Id; - var team = playerTeamUpdate.Team; + /// Whether this update is for the local player. + /// The new team of the player. + /// The ID of the player that has updated their team if is true. + /// + public void OnPlayerTeamUpdate(bool self, Team team, ushort playerId = 0) { + if (self) { + Logger.Debug($"Received PlayerTeamUpdate for local player: {Enum.GetName(typeof(Team), team)}"); - Logger.Debug($"Received PlayerTeamUpdate for ID: {id}, team: {Enum.GetName(typeof(Team), team)}"); + UpdateLocalPlayerTeam(team); + return; + } + + Logger.Debug($"Received PlayerTeamUpdate for ID: {playerId}, team: {Enum.GetName(typeof(Team), team)}"); - UpdatePlayerTeam(id, team); + UpdatePlayerTeam(playerId, team); } /// /// Reset the local player's team to be None and reset all existing player names and hit-boxes. /// public void ResetAllTeams() { - OnLocalPlayerTeamUpdate(Team.None); + UpdateLocalPlayerTeam(Team.None); foreach (var id in _playerData.Keys) { UpdatePlayerTeam(id, Team.None); @@ -548,10 +582,10 @@ private void UpdatePlayerTeam(ushort id, Team team) { } /// - /// Callback method for when the team of the local player updates. + /// Update the team for the local player. /// /// The new team of the local player. - public void OnLocalPlayerTeamUpdate(Team team) { + private void UpdateLocalPlayerTeam(Team team) { LocalPlayerTeam = team; var nameObject = HeroController.instance.gameObject.FindGameObjectInChildren(UsernameObjectName); @@ -590,29 +624,35 @@ public Team GetPlayerTeam(ushort id) { return playerData.Team; } - /// - /// Update the skin of the local player. - /// - /// The ID of the skin to update to. - public void UpdateLocalPlayerSkin(byte skinId) { - _skinManager.UpdateLocalPlayerSkin(skinId); - } - /// /// Callback method for when a player updates their skin. /// - /// The ClientPlayerSkinUpdate packet data. - private void OnPlayerSkinUpdate(ClientPlayerSkinUpdate playerSkinUpdate) { - var id = playerSkinUpdate.Id; - var skinId = playerSkinUpdate.SkinId; + /// Whether this update is for the local player. + /// The ID of the new skin of the player. + /// The ID of the player that has updated their skin if is true. + /// + public void OnPlayerSkinUpdate(bool self, byte skinId, ushort playerId = 0) { + if (self) { + Logger.Debug($"Received PlayerSkinUpdate for local player: {skinId}"); + + _skinManager.UpdateLocalPlayerSkin(skinId); + return; + } + + Logger.Debug($"Received PlayerSkinUpdate for ID: {playerId}, skin ID: {skinId}"); - if (!_playerData.TryGetValue(id, out var playerData)) { - Logger.Debug($"Received PlayerSkinUpdate for ID: {id}, skinId: {skinId}"); + if (!_playerData.TryGetValue(playerId, out var playerData)) { + Logger.Debug(" Could not find player"); return; } playerData.SkinId = skinId; + // If the player is not in the local scene, we don't have to apply the skin update to the player object + if (!playerData.IsInLocalScene) { + return; + } + _skinManager.UpdatePlayerSkin(playerData.PlayerObject, skinId); } @@ -658,7 +698,7 @@ private void ChangeNameColor(TextMeshPro textMeshObject, Team team) { /// Remove the name from the local player. /// private void RemoveNameFromLocalPlayer() { - if (HeroController.instance != null) { + if (HeroController.instance) { RemoveNameFromPlayer(HeroController.instance.gameObject); } } @@ -672,7 +712,7 @@ private void RemoveNameFromPlayer(GameObject playerContainer) { var nameObject = playerContainer.FindGameObjectInChildren(UsernameObjectName); // Deactivate it if it exists - if (nameObject != null) { + if (nameObject) { nameObject.SetActive(false); } } @@ -696,15 +736,15 @@ public void OnServerSettingsUpdated(bool pvpOrBodyDamageChanged, bool displayNam if (displayNamesChanged) { foreach (var playerData in _playerData.Values) { var nameObject = playerData.PlayerContainer.FindGameObjectInChildren(UsernameObjectName); - if (nameObject != null) { + if (nameObject) { nameObject.SetActive(_serverSettings.DisplayNames); } } var localPlayerObject = HeroController.instance.gameObject; - if (localPlayerObject != null) { + if (localPlayerObject) { var nameObject = localPlayerObject.FindGameObjectInChildren(UsernameObjectName); - if (nameObject != null) { + if (nameObject) { nameObject.SetActive(_serverSettings.DisplayNames); } } @@ -746,7 +786,7 @@ private GameObject CreateUsername(GameObject playerContainer) { /// Whether body damage is enabled. private void ToggleBodyDamage(ClientPlayerData playerData, bool enabled) { var playerObject = playerData.PlayerObject; - if (playerObject == null) { + if (!playerObject) { return; } diff --git a/HKMP/Game/Client/Save/PersistentFsmData.cs b/HKMP/Game/Client/Save/PersistentFsmData.cs new file mode 100644 index 00000000..4da75452 --- /dev/null +++ b/HKMP/Game/Client/Save/PersistentFsmData.cs @@ -0,0 +1,44 @@ +using System; + +namespace Hkmp.Game.Client.Save; + +/// +/// Data class for a persistent item in the scene with the corresponding FsmInt/FsmBool. +/// +internal class PersistentFsmData { + /// + /// The persistent item key with the ID and scene name. + /// + public PersistentItemKey PersistentItemKey { get; init; } + + /// + /// Function to get the current integer value. Could be null if a boolean is used instead. + /// + public Func GetCurrentInt { get; init; } + /// + /// Action to set the current integer value. Could be null if a boolean is used instead. + /// + public Action SetCurrentInt { get; init; } + /// + /// Function to get the current boolean value. Could be null if an integer is used instead. + /// + public Func GetCurrentBool { get; init; } + /// + /// Action to set the current boolean value. Could be null if an integer is used instead. + /// + public Action SetCurrentBool { get; init; } + + /// + /// The last value for the integer if used. + /// + public int LastIntValue { get; set; } + /// + /// The last value for the boolean if used. + /// + public bool LastBoolValue { get; set; } + + /// + /// Whether an int is stored for this data. + /// + public bool IsInt => GetCurrentInt != null; +} diff --git a/HKMP/Game/Client/Save/PersistentItemKey.cs b/HKMP/Game/Client/Save/PersistentItemKey.cs new file mode 100644 index 00000000..458ba34b --- /dev/null +++ b/HKMP/Game/Client/Save/PersistentItemKey.cs @@ -0,0 +1,67 @@ +using System; + +namespace Hkmp.Game.Client.Save; + +/// +/// Class to identify a persistent item by its ID and scene name. +/// +internal class PersistentItemKey : IEquatable { + /// + /// The ID of the item. + /// + public string Id { get; init; } + /// + /// The name of the scene of the item. + /// + public string SceneName { get; init; } + + /// + public bool Equals(PersistentItemKey other) { + if (ReferenceEquals(null, other)) { + return false; + } + + if (ReferenceEquals(this, other)) { + return true; + } + + return Id == other.Id && SceneName == other.SceneName; + } + + /// + public override bool Equals(object obj) { + if (ReferenceEquals(null, obj)) { + return false; + } + + if (ReferenceEquals(this, obj)) { + return true; + } + + if (obj.GetType() != GetType()) { + return false; + } + + return Equals((PersistentItemKey) obj); + } + + /// + public override int GetHashCode() { + unchecked { + return ((Id != null ? Id.GetHashCode() : 0) * 397) ^ (SceneName != null ? SceneName.GetHashCode() : 0); + } + } + + public static bool operator ==(PersistentItemKey left, PersistentItemKey right) { + return Equals(left, right); + } + + public static bool operator !=(PersistentItemKey left, PersistentItemKey right) { + return !Equals(left, right); + } + + /// + public override string ToString() { + return $"({Id}, {SceneName})"; + } +} diff --git a/HKMP/Game/Client/Save/SaveChanges.cs b/HKMP/Game/Client/Save/SaveChanges.cs new file mode 100644 index 00000000..c980848e --- /dev/null +++ b/HKMP/Game/Client/Save/SaveChanges.cs @@ -0,0 +1,565 @@ +using System.Linq; +using Hkmp.Util; +using HutongGames.PlayMaker.Actions; +using Modding; +using UnityEngine; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Game.Client.Save; + +/// +/// Class that handles incoming save changes that have an immediate effect in the current scene for the local player. +/// E.g. breakable walls that also break in another scene, tollgates that are being paid, stag station being bought. +/// +internal class SaveChanges { + /// + /// Apply a change in player data from a save update for the given name immediately. This checks whether + /// the local player is in a scene where the changes in player data have an effect on the environment. + /// For example, a breakable wall that also opens up in another scene or a stag station being bought. + /// + /// The name of the PlayerData entry. + public void ApplyPlayerDataSaveChange(string name) { + Logger.Debug($"ApplyPlayerData for name: {name}"); + + // If we receive the dash from a save update, we need to also set the 'canDash' boolean to ensure that + // the input for dashing is accepted + if (name == "hasDash") { + PlayerData.instance.SetBool("canDash", true); + return; + } + + var currentScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; + + if (name == "crossroadsMawlekWall" && currentScene == "Crossroads_33") { + GameObject breakWall = null; + PlayMakerFSM breakWallFsm = null; + PlayMakerFSM fullWallFsm = null; + + var found = 0; + + var fsms = Object.FindObjectsOfType(true); + foreach (var fsm in fsms) { + if (fsm.Fsm.Name.Equals("playerdata_activation")) { + if (fsm.name.Equals("break_wall_left")) { + breakWall = fsm.gameObject; + breakWallFsm = fsm; + + found++; + if (found == 2) { + break; + } + } else if (fsm.name.Equals("full_wall_left")) { + fullWallFsm = fsm; + + found++; + if (found == 2) { + break; + } + } + } + } + + if (found < 2) { + Logger.Error("Could not find breakable wall objects for 'crossroadsMawlekWall' player data change"); + return; + } + + // Activate the breakWall object and set variable and state in the FSM + breakWall!.SetActive(true); + breakWallFsm.FsmVariables.GetFsmBool("Activate").Value = true; + breakWallFsm.SetState("Check Activation"); + + // Disable the full wall + fullWallFsm!.SetState("Check Activation"); + return; + } + + if (name == "dungDefenderWallBroken" && currentScene == "Abyss_01") { + GameObject wall = null; + GameObject wallBroken = null; + + var found = 0; + + var fsms = Object.FindObjectsOfType(true); + foreach (var fsm in fsms) { + if (fsm.name.Equals("dung_defender_wall")) { + wall = fsm.gameObject; + + found++; + if (found == 2) { + break; + } + } else if (fsm.name.Equals("dung_defender_wall_broken")) { + wallBroken = fsm.gameObject; + + found++; + if (found == 2) { + break; + } + } + } + + if (found < 2) { + Logger.Error("Could not find breakable wall objects for 'dungDefenderWallBroken' player data change"); + return; + } + + wall!.SetActive(false); + wallBroken!.SetActive(true); + return; + } + + if ( + name == "openedCrossroads" && currentScene == "Crossroads_47" || + name == "openedGreenpath" && currentScene == "Fungus1_16_alt" || + name == "openedFungalWastes" && currentScene == "Fungus2_02" || + name == "openedRuins1" && currentScene == "Ruins1_29" || + name == "openedRuins2" && currentScene == "Ruins2_08" || + name == "openedDeepnest" && currentScene == "Deepnest_09" || + name == "openedRoyalGardens" && currentScene == "Fungus3_40" || + name == "openedHiddenStation" && currentScene == "Abyss_22" + ) { + var go = GameObject.Find("Station Bell"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Stag Bell"); + if (fsm == null) { + return; + } + + // Add additional actions from different states to ensure the player gets control back if they are already + // in the FSM flow + if (IsInTollMachineDialogue(fsm.Fsm.ActiveStateName)) { + var action1 = fsm.GetFirstAction("Yes"); + fsm.InsertAction("Box Disappear Anim", action1, 0); + var action2 = fsm.GetFirstAction("Yes"); + action2.animationCompleteEvent = null; + fsm.InsertAction("Box Disappear Anim", action2, 0); + var action3 = fsm.GetFirstAction("Yes"); + fsm.InsertAction("Box Disappear Anim", action3, 0); + var action4 = fsm.GetFirstAction("Pause Before Box Drop"); + fsm.InsertAction("Box Disappear Anim", action4, 0); + var action5 = fsm.GetFirstAction("Pause Before Box Drop"); + fsm.InsertAction("Box Disappear Anim", action5, 0); + var action6 = fsm.GetAction("Pause Before Box Drop", 2); + fsm.InsertAction("Box Disappear Anim", action6, 0); + + HideDialogueBox(); + } + + fsm.SetState("Box Disappear Anim"); + return; + } + + if ( + name == "tollBenchCity" && currentScene == "Ruins1_31" || + name == "tollBenchAbyss" && currentScene == "Abyss_18" || + name == "tollBenchQueensGardens" && currentScene == "Fungus3_50" + ) { + var go = GameObject.Find("Toll Machine Bench"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Toll Machine Bench"); + if (fsm == null) { + return; + } + + // Add additional actions from different states to ensure the player gets control back if they are already + // in the FSM flow + if (IsInTollMachineDialogue(fsm.Fsm.ActiveStateName)) { + var action1 = fsm.GetFirstAction("Yes"); + action1.animationCompleteEvent = null; + fsm.InsertAction("Box Down", action1, 0); + var action2 = fsm.GetFirstAction("Yes"); + fsm.InsertAction("Box Down", action2, 0); + var action3 = fsm.GetFirstAction("Pause Before Box Drop"); + fsm.InsertAction("Box Down", action3, 0); + var action4 = fsm.GetFirstAction("Pause Before Box Drop"); + fsm.InsertAction("Box Down", action4, 0); + var action5 = fsm.GetAction("Pause Before Box Drop", 3); + fsm.InsertAction("Box Down", action5, 0); + + HideDialogueBox(); + } + + fsm.SetState("Box Down"); + return; + } + + if (name == "openedRestingGrounds02" && currentScene == "RestingGrounds_02") { + var go = GameObject.Find("Bottom Gate Collider"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("FSM"); + if (fsm == null) { + return; + } + + fsm.SetState("Destroy"); + return; + } + + if (name == "waterwaysGate" && currentScene == "Fungus2_23") { + var go = GameObject.Find("Waterways Gate"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Gate Control"); + if (fsm == null) { + return; + } + + fsm.SetState("Destroy"); + return; + } + + if (name == "deepnestWall") { + GameObject go = null; + if (currentScene == "Deepnest_01") { + go = GameObject.Find("Breakable Wall"); + } else if (currentScene == "Fungus2_20") { + go = GameObject.Find("Breakable Wall Waterways"); + } + + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("breakable_wall_v2"); + if (fsm == null) { + return; + } + + fsm.SetState("Pause Frame"); + return; + } + + if (name == "oneWayArchive" && currentScene == "Fungus3_02") { + var go = GameObject.Find("One Way Wall Exit"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("FSM"); + if (fsm == null) { + return; + } + + fsm.SetState("Destroy"); + return; + } + + if (name == "openedGardensStagStation" && currentScene == "Fungus3_13") { + var go = GameObject.Find("royal_garden_slide_door"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("FSM"); + if (fsm == null) { + return; + } + + fsm.SetState("Destroy"); + return; + } + + if (name == "openedCityGate" && currentScene == "Fungus2_21") { + var go = GameObject.Find("City Gate Control"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Conversation Control"); + if (fsm == null) { + return; + } + + string[] inDialogueStateNames = [ + "Hero Anim", "Key?", "Box Up YN", "Send Text", "Box Up", "No Key" + ]; + if (inDialogueStateNames.Contains(fsm.Fsm.ActiveStateName)) { + var action1 = fsm.GetFirstAction("Yes"); + fsm.InsertAction("Activate", action1, 0); + + HideDialogueBox(); + } + + fsm.RemoveFirstAction("Activate"); + fsm.RemoveFirstAction("Activate"); + + fsm.SetState("Activate"); + return; + } + + if (name == "openedWaterwaysManhole" && currentScene == "Ruins1_05b") { + var go = GameObject.Find("Waterways Machine"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Conversation Control"); + if (fsm == null) { + return; + } + + string[] inDialogueStateNames = [ + "Hero Anim", "Key?", "Box Up YN", "Send Text", "Box Up", "No Key" + ]; + if (inDialogueStateNames.Contains(fsm.Fsm.ActiveStateName)) { + var action1 = fsm.GetFirstAction("Yes"); + fsm.InsertAction("Activate", action1, 0); + + HideDialogueBox(); + } + + fsm.RemoveFirstAction("Activate"); + fsm.RemoveFirstAction("Activate"); + fsm.RemoveFirstAction("Activate"); + + fsm.SetState("Activate"); + return; + } + + if (name == "xunFlowerGiven" && currentScene == "Fungus3_49") { + var go = GameObject.Find("Inspect Region"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Conversation Control"); + if (fsm == null) { + return; + } + + // Remove a bunch of actions that only apply to the placing player + fsm.RemoveFirstAction("Flowers"); + fsm.RemoveFirstAction("Ghost Appear"); + fsm.RemoveFirstAction("Ghost Appear"); + fsm.RemoveFirstAction("Look Up"); + fsm.RemoveFirstAction("Get Up"); + + fsm.SetState("Glow"); + return; + } + + if (name == "openedMageDoor_v2" && currentScene == "Ruins1_31") { + var go = GameObject.Find("Mage Door"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Conversation Control"); + if (fsm == null) { + return; + } + + string[] inDialogueStateNames = [ + "Hero Anim", "Check Key", "Box Up YN", "Send Text", "Box Up", "No Key" + ]; + if (inDialogueStateNames.Contains(fsm.Fsm.ActiveStateName)) { + HideDialogueBox(); + } else { + fsm.RemoveFirstAction("Yes"); + } + + fsm.RemoveFirstAction("Yes"); + + fsm.SetState("Yes"); + return; + } + + if (name == "cityLift1" && currentScene == "Crossroads_49b") { + var go = GameObject.Find("Toll Machine Lift"); + var fsm = go.LocateMyFSM("Toll Machine"); + + // Hide the dialogue box if the local player is in the dialogue flow + if (IsInTollMachineDialogue(fsm.Fsm.ActiveStateName)) { + HideDialogueBox(); + } + + fsm.RemoveFirstAction("Send Message"); + + fsm.SetState("Yes"); + return; + } + + if (name == "nightmareLanternAppeared" && currentScene == "Cliffs_06") { + var go = GameObject.Find("Sycophant Dream"); + var fsm = go.LocateMyFSM("Activate Lantern"); + + fsm.SetState("Impact"); + } + } + + /// + /// Apply a change in persistent values from a save update for the given name immediately. This checks whether + /// the local player is in a scene where the changes in player data have an effect on the environment. + /// For example, a breakable wall that also opens up in another scene or a stag station being bought. + /// + /// The persistent item key containing the ID and scene name of the changed object. + public void ApplyPersistentValueSaveChange(PersistentItemKey itemKey) { + Logger.Debug($"ApplyPersistent for item data: {itemKey}"); + + var currentScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; + + if ( + itemKey.Id.StartsWith("Toll Gate Machine") && ( + itemKey.SceneName == "Mines_33" && currentScene == "Mines_33" || + itemKey.SceneName == "Fungus1_31" && currentScene == "Fungus1_31" + )) { + var go = GameObject.Find("Toll Gate Machine"); + var fsm = go.LocateMyFSM("Toll Machine"); + + // Add additional actions from different states to ensure the player gets control back if they are already + // in the FSM flow + if (IsInTollMachineDialogue(fsm.Fsm.ActiveStateName)) { + var action1 = fsm.GetFirstAction("Yes"); + action1.animationCompleteEvent = null; + fsm.InsertAction("Box Disappear Anim", action1, 0); + var action2 = fsm.GetFirstAction("Yes"); + fsm.InsertAction("Box Disappear Anim", action2, 0); + var action3 = fsm.GetFirstAction("Pause Before Box Drop"); + fsm.InsertAction("Box Disappear Anim", action3, 0); + var action4 = fsm.GetFirstAction("Pause Before Box Drop"); + fsm.InsertAction("Box Disappear Anim", action4, 0); + var action5 = fsm.GetAction("Pause Before Box Drop", 2); + fsm.InsertAction("Box Disappear Anim", action5, 0); + + HideDialogueBox(); + } + + fsm.RemoveFirstAction("Open Gates"); + + fsm.SetState("Box Disappear Anim"); + return; + } + + if (itemKey.Id == "Collapser Tute 01" && itemKey.SceneName == "Tutorial_01" && currentScene == "Tutorial_01") { + var go = GameObject.Find("Collapser Tute 01"); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("collapse tute"); + fsm.RemoveFirstAction("Force Hard Landing"); + fsm.SetState("Crumble"); + return; + } + + if (itemKey.Id.StartsWith("Collapser Small") && itemKey.SceneName == currentScene) { + var go = GameObject.Find(itemKey.Id); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("collapse small"); + if (fsm == null) { + return; + } + + fsm.SetState("Split"); + return; + } + + if (itemKey.Id.StartsWith("Quake Floor") && itemKey.SceneName == currentScene) { + var go = GameObject.Find(itemKey.Id); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("quake_floor"); + if (fsm == null) { + return; + } + + fsm.SetState("Audio"); + } + + if (itemKey.Id == "Bone Gate" && itemKey.SceneName == currentScene) { + var go = GameObject.Find(itemKey.Id); + if (go == null) { + return; + } + + var fsm = go.LocateMyFSM("Bone Gate"); + if (fsm == null) { + return; + } + + fsm.SetState("Open Audio"); + } + } + + /// + /// Whether the local player is currently in a toll machine dialogue prompt that has claimed control of the + /// character. + /// + /// The name of the current state of the dialogue FSM. + /// true if the player is in dialogue, false otherwise. + private bool IsInTollMachineDialogue(string currentStateName) { + string[] outOfDialogueStateNames = [ + "Out Of Range", "In Range", "Can Inspect?", "Cancel Frame", "Pause", "Activated?", "Paid?", "Get Price", "Init", + "Regain Control" + ]; + + return !outOfDialogueStateNames.Contains(currentStateName); + } + + /// + /// Hide the currently active dialogue box by setting the state of the 'Dialogue Page Control' FSM of the 'Text YN' + /// game object. Needs to be amended if this method should also hide dialogue boxes of other dialogue types. + /// + private void HideDialogueBox() { + var gc = GameCameras.instance; + if (gc == null) { + Logger.Warn("Could not find GameCameras instance"); + return; + } + + var hudCamera = gc.hudCamera; + if (hudCamera == null) { + Logger.Warn("Could not find hudCamera"); + return; + } + + var dialogManager = hudCamera.gameObject.FindGameObjectInChildren("DialogueManager"); + if (dialogManager == null) { + Logger.Warn("Could not find dialogueManager"); + return; + } + + void HideDialogueObject(string objectName, string heroDmgState) { + var obj = dialogManager.FindGameObjectInChildren(objectName); + if (obj != null) { + var dialogueBox = obj.GetComponent(); + if (dialogueBox == null) { + Logger.Warn($"Could not find {objectName} DialogueBox"); + return; + } + + var hidden = ReflectionHelper.GetField(dialogueBox, "hidden"); + if (hidden) { + return; + } + + var pageControlFsm = obj.LocateMyFSM("Dialogue Page Control"); + if (pageControlFsm == null) { + Logger.Warn($"Could not find {objectName} DialoguePageControl FSM"); + return; + } + pageControlFsm.SetState(heroDmgState); + } + } + + HideDialogueObject("Text YN", "Hero Damaged"); + HideDialogueObject("Text", "Pause"); + } +} diff --git a/HKMP/Game/Client/Save/SaveDataMapping.cs b/HKMP/Game/Client/Save/SaveDataMapping.cs new file mode 100644 index 00000000..e821d6df --- /dev/null +++ b/HKMP/Game/Client/Save/SaveDataMapping.cs @@ -0,0 +1,248 @@ +using System.Collections.Generic; +using System.Linq; +using Hkmp.Collection; +using Hkmp.Logging; +using Hkmp.Util; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace Hkmp.Game.Client.Save; + +/// +/// Serializable data class that stores mappings for what scene data should be synchronised and their indices used for networking. +/// +internal class SaveDataMapping { + /// + /// The file path of the embedded resource file for save data. + /// + private const string SaveDataFilePath = "Hkmp.Resource.save-data.json"; + + /// + /// The static instance of the mapping. + /// + [JsonIgnore] + private static SaveDataMapping _instance; + + /// + [JsonIgnore] + public static SaveDataMapping Instance { + get { + if (_instance == null) { + _instance = FileUtil.LoadObjectFromEmbeddedJson(SaveDataFilePath); + _instance.Initialize(); + } + + return _instance; + } + } + + /// + /// Dictionary mapping player data variable names to variable properties (containing for example whether they + /// should be synchronised). + /// + [JsonProperty("playerData")] + public Dictionary PlayerDataVarProperties { get; private set; } + + /// + /// Bi-directional lookup that maps save data names and their indices. + /// + [JsonIgnore] + public BiLookup PlayerDataIndices { get; private set; } + + /// + /// Deserialized key-value pairs for the geo rock data in the JSON. + /// +#pragma warning disable 0649 + [JsonProperty("geoRocks")] + private readonly List> _geoRockDataValues; +#pragma warning restore 0649 + + /// + /// Dictionary mapping geo rock item keys to booleans indicating whether they should be synchronised. + /// + [JsonIgnore] + public Dictionary GeoRockBools { get; private set; } + + /// + /// Bi-directional lookup that maps geo rock names and their indices. + /// + [JsonIgnore] + public BiLookup GeoRockIndices { get; private set; } + + /// + /// Deserialized key-value pairs for the persistent bool data in the JSON. + /// +#pragma warning disable 0649 + [JsonProperty("persistentBoolItems")] + private readonly List> _persistentBoolsDataValues; +#pragma warning restore 0649 + + /// + /// Dictionary mapping persistent bool item keys to variable properties (containing for example whether they + /// should be synchronised). + /// + [JsonIgnore] + public Dictionary PersistentBoolVarProperties { get; private set; } + + /// + /// Bi-directional lookup that maps persistent bool item keys and their indices. + /// + [JsonIgnore] + public BiLookup PersistentBoolIndices { get; private set; } + + /// + /// Deserialized key-value pairs for the persistent int data in the JSON. + /// +#pragma warning disable 0649 + [JsonProperty("persistentIntItems")] + private readonly List> _persistentIntDataValues; +#pragma warning restore 0649 + + /// + /// Dictionary mapping persistent int item keys to variable properties (containing for example whether they + /// should be synchronised). + /// + [JsonIgnore] + public Dictionary PersistentIntVarProperties { get; private set; } + + /// + /// Bi-directional lookup that maps persistent int item keys and their indices. + /// + [JsonIgnore] + public BiLookup PersistentIntIndices { get; private set; } + + /// + /// Deserialized list of strings that represent variable names with the type of a string list. + /// + [JsonProperty("stringListVariables")] + public readonly List StringListVariables; + + /// + /// Deserialized list of strings that represent variable names with the type of BossSequenceDoor.Completion. + /// + [JsonProperty("bossSequenceDoorCompletionVariables")] + public readonly List BossSequenceDoorCompletionVariables; + + /// + /// Deserialized list of strings that represent variable names with the type of BossStatue.Completion. + /// + [JsonProperty("bossStatueCompletionVariables")] + public readonly List BossStatueCompletionVariables; + + /// + /// Deserialized list of strings that represent variable names with the type of a vector3 list. + /// + [JsonProperty("vectorListVariables")] + public readonly List VectorListVariables; + + /// + /// Deserialized list of strings that represent variable names with the type of integer list. + /// + [JsonProperty("intListVariables")] + public readonly List IntListVariables; + + /// + /// Initializes the class by converting the deserialized data fields into the various dictionaries and lookups. + /// + public void Initialize() { + if (PlayerDataVarProperties == null) { + Logger.Warn("Player data bools for save data is null"); + return; + } + + if (_geoRockDataValues == null) { + Logger.Warn("Geo rock data values for save data is null"); + return; + } + + if (_persistentBoolsDataValues == null) { + Logger.Warn("Persistent bools data values for save data is null"); + return; + } + + if (_persistentIntDataValues == null) { + Logger.Warn("Persistent int data values for save data is null"); + return; + } + + PlayerDataIndices = new BiLookup(); + ushort index = 0; + foreach (var playerDataBool in PlayerDataVarProperties.Keys) { + PlayerDataIndices.Add(playerDataBool, index++); + } + + GeoRockBools = _geoRockDataValues.ToDictionary(kv => kv.Key, kv => kv.Value); + GeoRockIndices = new BiLookup(); + foreach (var geoRockData in GeoRockBools.Keys) { + GeoRockIndices.Add(geoRockData, index++); + } + + PersistentBoolVarProperties = _persistentBoolsDataValues.ToDictionary(kv => kv.Key, kv => kv.Value); + PersistentBoolIndices = new BiLookup(); + foreach (var persistentBoolData in PersistentBoolVarProperties.Keys) { + PersistentBoolIndices.Add(persistentBoolData, index++); + } + + PersistentIntVarProperties = _persistentIntDataValues.ToDictionary(kv => kv.Key, kv => kv.Value); + PersistentIntIndices = new BiLookup(); + foreach (var persistentIntData in PersistentIntVarProperties.Keys) { + PersistentIntIndices.Add(persistentIntData, index++); + } + + // Process special "InitialValue" values in the PlayerData variable properties + // These values can only be string lists or integers, but are read by the JSON deserializer, which means that + // they'll be of type JArray or Int64 (long), so we convert them to their proper values + foreach (var varProps in PlayerDataVarProperties.Values) { + var initialValue = varProps.InitialValue; + if (initialValue is JArray jArray) { + varProps.InitialValue = jArray.ToObject>(); + } else if (initialValue is long longValue) { + varProps.InitialValue = (int) longValue; + } + } + } + + /// + /// Properties for save data variables that denote things like whether to synchronise the values. + /// + internal class VarProperties { + /// + /// Whether to sync this value. If true, the variable indicates where to store + /// the synced values. + /// + public bool Sync { get; set; } + /// + /// The sync type of this value. Type Player is used for player specific values and Server is used for + /// global world specific values. + /// + public SyncType SyncType { get; set; } + /// + /// Whether to ignore the check for scene host when sending/processing a save data for this value. + /// + public bool IgnoreSceneHost { get; set; } + /// + /// The type of the variable that holds this value. + /// + public string VarType { get; set; } + /// + /// Whether the value for this variable should be handled additively, i.e. by networking the delta of the + /// value instead of the actual value. This is used for variables that can be modified by multiple players + /// at once and thus can have incorrect values due to networking race conditions. + /// + public bool Additive { get; set; } + /// + /// The initial value of the variable if it is not the default for the type. Otherwise, it will be null. + /// + public object InitialValue { get; set; } + } + + /// + /// The sync type for sync properties indicating whether values are global or player specific. + /// + [JsonConverter(typeof(StringEnumConverter))] + internal enum SyncType { + Player, + Server + } +} diff --git a/HKMP/Game/Client/Save/SaveManager.cs b/HKMP/Game/Client/Save/SaveManager.cs new file mode 100644 index 00000000..02137706 --- /dev/null +++ b/HKMP/Game/Client/Save/SaveManager.cs @@ -0,0 +1,1107 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using GlobalEnums; +using Hkmp.Collection; +using Hkmp.Game.Client.Entity; +using Hkmp.Networking.Client; +using Hkmp.Networking.Packet.Data; +using Hkmp.Serialization; +using Hkmp.Util; +using Modding; +using UnityEngine; +using UnityEngine.SceneManagement; +using Logger = Hkmp.Logging.Logger; +using MapZone = GlobalEnums.MapZone; +using Object = UnityEngine.Object; + +namespace Hkmp.Game.Client.Save; + +/// +/// Class that manages save data synchronisation. +/// +internal class SaveManager { + /// + /// The save data instance that contains mappings for what to sync and their indices. + /// + private static SaveDataMapping SaveDataMapping => SaveDataMapping.Instance; + + /// + /// The net client instance to send save updates. + /// + private readonly NetClient _netClient; + + /// + /// The entity manager to check whether we are scene host. + /// + private readonly EntityManager _entityManager; + + /// + /// The save changes instance to apply immediate in-world changes from received save data. + /// + private readonly SaveChanges _saveChanges; + + /// + /// List of data classes for each FSM that has a persistent int/bool or geo rock attached to it. + /// + private readonly List _persistentFsmData; + + /// + /// Dictionary of BossSequenceDoor.Completion structs in the PlayerData for comparing changes against. + /// + private readonly Dictionary _bsdCompHashes; + + /// + /// Dictionary of BossStatue.Completion structs in the PlayerData for comparing changes against. + /// + private readonly Dictionary _bsCompHashes; + + /// + /// Dictionary of hash codes for list variables in the PlayerData for comparing changes against. + /// + private readonly Dictionary _listHashes; + + /// + /// List of FieldInfo for fields in PlayerData that are simple values that should be synced. Used for looping + /// over to check for changes and network those changes. + /// + private readonly List _playerDataSimpleSyncFields; + + /// + /// Dictionary of variable names mapped to FieldInfo for fields in PlayerData that are compound values that should + /// be synced. Used for resetting the instance of PlayerData for last values and checking for updates to compound + /// values. + /// + private readonly List _playerDataCompoundSyncFields; + + /// + /// PlayerData instance that contains the last values of the currently used PlayerData for comparison checking. + /// + private PlayerData _lastPlayerData; + + /// + /// Whether the player is hosting the server, which means that player specific save data is not networked + /// to the server. + /// + public bool IsHostingServer { get; set; } + + public SaveManager(NetClient netClient, EntityManager entityManager) { + _netClient = netClient; + _entityManager = entityManager; + _saveChanges = new SaveChanges(); + + _persistentFsmData = []; + _bsdCompHashes = new Dictionary(); + _bsCompHashes = new Dictionary(); + _listHashes = new Dictionary(); + _playerDataSimpleSyncFields = []; + _playerDataCompoundSyncFields = []; + } + + /// + /// Initializes the save manager by loading the save data json. + /// + public void Initialize() { + _netClient.ConnectEvent += _ => OnConnect(); + + foreach (var field in typeof(PlayerData).GetFields()) { + var fieldName = field.Name; + + if (!SaveDataMapping.PlayerDataVarProperties.TryGetValue(fieldName, out var varProps) + || !varProps.Sync + ) { + continue; + } + + var compoundField = SaveDataMapping.StringListVariables.Contains(fieldName) || + SaveDataMapping.BossSequenceDoorCompletionVariables.Contains(fieldName) || + SaveDataMapping.BossStatueCompletionVariables.Contains(fieldName) || + SaveDataMapping.VectorListVariables.Contains(fieldName) || + SaveDataMapping.IntListVariables.Contains(fieldName); + + if (compoundField) { + _playerDataCompoundSyncFields.Add(field); + } else { + _playerDataSimpleSyncFields.Add(field); + } + } + } + + /// + /// Register the relevant hooks for save-related operations. + /// + public void RegisterHooks() { + UnityEngine.SceneManagement.SceneManager.activeSceneChanged += OnSceneChanged; + + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdatePlayerData; + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdatePersistents; + MonoBehaviourUtil.Instance.OnUpdateEvent += OnUpdateCompounds; + } + + /// + /// Deregister the relevant hooks for save-related operations. + /// + public void DeregisterHooks() { + UnityEngine.SceneManagement.SceneManager.activeSceneChanged -= OnSceneChanged; + + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdatePlayerData; + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdatePersistents; + MonoBehaviourUtil.Instance.OnUpdateEvent -= OnUpdateCompounds; + } + + /// + /// Callback method for when the player connects to a server, so we can reset the player data. + /// + private void OnConnect() { + ResetLastPlayerData(); + } + + /// + /// Resets the PlayerData instance that stores the last values of all synchronised fields. + /// + private void ResetLastPlayerData() { + var pd = PlayerData.instance; + + var pdConstructor = typeof(PlayerData).GetConstructor( + BindingFlags.NonPublic | BindingFlags.CreateInstance | BindingFlags.Instance, + null, + [], + null + ); + if (pdConstructor == null) { + Logger.Error("Could not find protected constructor of PlayerData"); + return; + } + + _lastPlayerData = (PlayerData) pdConstructor.Invoke([]); + + foreach (var field in _playerDataSimpleSyncFields) { + var value = field.GetValue(pd); + field.SetValue(_lastPlayerData, value); + } + + foreach (var field in _playerDataCompoundSyncFields) { + var value = field.GetValue(pd); + field.SetValue(_lastPlayerData, GetCompoundCopy(value)); + } + } + + /// + /// Update hook to check for changes in the PlayerData instance. + /// + private void OnUpdatePlayerData() { + var pd = PlayerData.instance; + if (_lastPlayerData == null) { + return; + } + + var gm = global::GameManager.instance; + if (!gm) { + return; + } + + if (gm.gameState == GameState.MAIN_MENU) { + return; + } + + foreach (var field in _playerDataSimpleSyncFields) { + var currentValue = field.GetValue(pd); + var lastValue = field.GetValue(_lastPlayerData); + + if (currentValue.Equals(lastValue)) { + continue; + } + + Logger.Debug($"PlayerData value changed from: {lastValue} to {currentValue}"); + + field.SetValue(_lastPlayerData, currentValue); + + if (field.FieldType == typeof(int)) { + CheckSendSaveUpdate( + field.Name, + () => EncodeSaveDataValue(currentValue), + () => { + var delta = (int) currentValue - (int) lastValue; + return EncodeSaveDataValue(delta); + } + ); + } else { + CheckSendSaveUpdate(field.Name, () => EncodeSaveDataValue(currentValue)); + } + } + } + + /// + /// Callback method for when the scene changes. Used to check for GeoRock, PersistentInt and PersistentBool + /// instances in the scene. + /// + /// The old scene. + /// The new scene. + private void OnSceneChanged(Scene oldScene, Scene newScene) { + _persistentFsmData.Clear(); + + foreach (var geoRock in Object.FindObjectsOfType()) { + var geoRockObject = geoRock.gameObject; + + if (geoRockObject.scene != newScene) { + continue; + } + + var persistentItemData = new PersistentItemKey { + Id = geoRockObject.name, + SceneName = global::GameManager.GetBaseSceneName(geoRockObject.scene.name) + }; + + Logger.Info($"Found Geo Rock in scene: {persistentItemData}"); + + var fsm = geoRock.GetComponent(); + if (!fsm) { + Logger.Info(" Could not find FSM belonging to Geo Rock object, skipping"); + continue; + } + + var fsmInt = fsm.FsmVariables.GetFsmInt("Hits"); + + var persistentFsmData = new PersistentFsmData { + PersistentItemKey = persistentItemData, + GetCurrentInt = () => fsmInt.Value, + SetCurrentInt = value => fsmInt.Value = value, + LastIntValue = fsmInt.Value + }; + + _persistentFsmData.Add(persistentFsmData); + } + + foreach (var persistentBoolItem in Object.FindObjectsOfType()) { + var itemObject = persistentBoolItem.gameObject; + + if (itemObject.scene != newScene) { + continue; + } + + var persistentItemData = new PersistentItemKey { + Id = itemObject.name, + SceneName = global::GameManager.GetBaseSceneName(itemObject.scene.name) + }; + + Logger.Info($"Found persistent bool in scene: {persistentItemData}"); + + Func getCurrentBoolFunc = null; + Action setCurrentBoolAction = null; + + var fsm = FSMUtility.FindFSMWithPersistentBool(itemObject.GetComponents()); + if (fsm) { + var fsmBool = fsm.FsmVariables.GetFsmBool("Activated"); + getCurrentBoolFunc = () => fsmBool.Value; + setCurrentBoolAction = value => fsmBool.Value = value; + } + + var vinePlatform = itemObject.GetComponent(); + if (vinePlatform) { + getCurrentBoolFunc = () => ReflectionHelper.GetField(vinePlatform, "activated"); + setCurrentBoolAction = value => ReflectionHelper.SetField(vinePlatform, "activated", value); + } + + var breakable = itemObject.GetComponent(); + if (breakable) { + getCurrentBoolFunc = () => ReflectionHelper.GetField(breakable, "isBroken"); + setCurrentBoolAction = value => ReflectionHelper.SetField(breakable, "isBroken", value); + } + + var dreamPlant = itemObject.GetComponent(); + if (dreamPlant) { + getCurrentBoolFunc = () => ReflectionHelper.GetField(dreamPlant, "completed"); + setCurrentBoolAction = value => ReflectionHelper.SetField(dreamPlant, "completed", value); + } + + var dreamPlantOrb = itemObject.GetComponent(); + if (dreamPlantOrb) { + getCurrentBoolFunc = () => ReflectionHelper.GetField(dreamPlantOrb, "pickedUp"); + setCurrentBoolAction = value => ReflectionHelper.SetField(dreamPlantOrb, "pickedUp", value); + } + + if (getCurrentBoolFunc == null) { + continue; + } + + var persistentFsmData = new PersistentFsmData { + PersistentItemKey = persistentItemData, + GetCurrentBool = getCurrentBoolFunc, + SetCurrentBool = setCurrentBoolAction, + LastBoolValue = getCurrentBoolFunc.Invoke() + }; + + _persistentFsmData.Add(persistentFsmData); + } + + foreach (var persistentIntItem in Object.FindObjectsOfType()) { + var itemObject = persistentIntItem.gameObject; + + if (itemObject.scene != newScene) { + continue; + } + + var persistentItemData = new PersistentItemKey { + Id = itemObject.name, + SceneName = global::GameManager.GetBaseSceneName(itemObject.scene.name) + }; + + Logger.Info($"Found persistent int in scene: {persistentItemData}"); + + var fsm = FSMUtility.FindFSMWithPersistentBool(itemObject.GetComponents()); + if (!fsm) { + Logger.Info(" Could not find FSM belonging to persistent int object, skipping"); + continue; + } + + var fsmInt = fsm.FsmVariables.GetFsmInt("Value"); + + var persistentFsmData = new PersistentFsmData { + PersistentItemKey = persistentItemData, + GetCurrentInt = () => fsmInt.Value, + SetCurrentInt = value => fsmInt.Value = value, + LastIntValue = fsmInt.Value + }; + + _persistentFsmData.Add(persistentFsmData); + } + } + + /// + /// Checks if a save update should be sent and send it using the encode function to encode the value of the + /// changed variable. + /// + /// The name of the variable that was changed. + /// Function to encode the value of the variable to a byte array. + /// Function to encode the delta value of the variable of the type is applicable. + /// + private void CheckSendSaveUpdate(string name, Func encodeFunc, Func deltaEncodeFunc = null) { + // If we are not connected or the 'permadeathMode' is 2, meaning we have broken/lost Steel Soul + if (!_netClient.IsConnected || PlayerData.instance.GetInt("permadeathMode") == 2) { + return; + } + + if (!SaveDataMapping.PlayerDataVarProperties.TryGetValue(name, out var varProps)) { + Logger.Info($"Not in save data values, not sending save update ({name})"); + return; + } + + if (!varProps.Sync) { + Logger.Info($"Value should not sync, not sending save update ({name})"); + return; + } + + // If we should do the scene host check and the player is not scene host, skip sending + if (!varProps.IgnoreSceneHost && !_entityManager.IsSceneHost) { + Logger.Info($"Not scene host, but required, not sending save update ({name})"); + return; + } + + if (!SaveDataMapping.PlayerDataIndices.TryGetValue(name, out var index)) { + Logger.Info($"Cannot find save data index, not sending save update ({name})"); + return; + } + + Func toUseEncodeFunc; + if (varProps.Additive && deltaEncodeFunc != null) { + toUseEncodeFunc = deltaEncodeFunc; + + Logger.Debug($"Sending \"{name}\" as save update (additive)"); + } else { + toUseEncodeFunc = encodeFunc; + + Logger.Debug($"Sending \"{name}\" as save update"); + } + + _netClient.UpdateManager.SetSaveUpdate( + index, + toUseEncodeFunc.Invoke() + ); + } + + /// + /// Called every unity update. Used to check for changes in the GeoRock/PersistentInt/PersistentBool FSMs. + /// + private void OnUpdatePersistents() { + using var enumerator = _persistentFsmData.GetEnumerator(); + + while (enumerator.MoveNext()) { + var persistentFsmData = enumerator.Current; + if (persistentFsmData == null) { + continue; + } + + if (persistentFsmData.IsInt) { + var value = persistentFsmData.GetCurrentInt.Invoke(); + if (value == persistentFsmData.LastIntValue) { + continue; + } + + persistentFsmData.LastIntValue = value; + + var itemData = persistentFsmData.PersistentItemKey; + + Logger.Info($"Value for {itemData} changed to: {value}"); + + if (!_netClient.IsConnected) { + continue; + } + + if (SaveDataMapping.GeoRockBools.TryGetValue(itemData, out var shouldSync) && shouldSync) { + if (!_entityManager.IsSceneHost) { + Logger.Info( + $"Not scene host, not sending geo rock save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + if (!SaveDataMapping.GeoRockIndices.TryGetValue(itemData, out var index)) { + Logger.Info( + $"Cannot find geo rock save data index, not sending save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + Logger.Info($"Sending geo rock ({itemData.Id}, {itemData.SceneName}) as save update"); + + _netClient.UpdateManager.SetSaveUpdate( + index, + [(byte) value] + ); + } else if ( + SaveDataMapping.PersistentIntVarProperties.TryGetValue(itemData, out var varProps) && + varProps.Sync + ) { + // If we should do the scene host check and the player is not scene host, skip sending + if (!varProps.IgnoreSceneHost && !_entityManager.IsSceneHost) { + Logger.Info( + $"Not scene host, not sending persistent int save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + if (!SaveDataMapping.PersistentIntIndices.TryGetValue(itemData, out var index)) { + Logger.Info( + $"Cannot find persistent int save data index, not sending save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + Logger.Info($"Sending persistent int ({itemData.Id}, {itemData.SceneName}) as save update"); + + _netClient.UpdateManager.SetSaveUpdate( + index, + [(byte) value] + ); + } else { + Logger.Info("Cannot find persistent int/geo rock data bool, not sending save update"); + } + } else { + var value = persistentFsmData.GetCurrentBool.Invoke(); + if (value == persistentFsmData.LastBoolValue) { + continue; + } + + persistentFsmData.LastBoolValue = value; + + var itemData = persistentFsmData.PersistentItemKey; + + Logger.Info($"Value for {itemData} changed to: {value}"); + + if (!_netClient.IsConnected) { + continue; + } + + if (!SaveDataMapping.PersistentBoolVarProperties.TryGetValue(itemData, out var varProps) || + !varProps.Sync) { + Logger.Info( + $"Not in persistent bool save data values or false in sync props, not sending save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + // If we should do the scene host check and the player is not scene host, skip sending + if (!varProps.IgnoreSceneHost && !_entityManager.IsSceneHost) { + Logger.Info( + $"Not scene host, not sending persistent bool save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + if (!SaveDataMapping.PersistentBoolIndices.TryGetValue(itemData, out var index)) { + Logger.Info( + $"Cannot find persistent bool save data index, not sending save update ({itemData.Id}, {itemData.SceneName})"); + continue; + } + + Logger.Info($"Sending persistent bool ({itemData.Id}, {itemData.SceneName}) as save update"); + + _netClient.UpdateManager.SetSaveUpdate( + index, + BitConverter.GetBytes(value) + ); + } + } + } + + /// + /// Called every unity update. Used to check for changes in non-primitive variables in the PlayerData. + /// + private void OnUpdateCompounds() { + void CheckUpdates( + List variableNames, + Dictionary checkDict, + Func newCheckFunc, + Func changeFunc, + Func deltaEncodeFunc = null + ) { + foreach (var varName in variableNames) { + // Get current value from player data based on the variable name + var currentValue = (TVar) typeof(PlayerData).GetField(varName).GetValue(PlayerData.instance); + // Get the value with which to check whether this current value is new or not + // In some cases this is the same value, in others this is a hash of the value + var currentCheckValue = newCheckFunc.Invoke(currentValue); + + // Check if the dictionary contains the last value to check against + if (!checkDict.TryGetValue(varName, out var lastCheckValue)) { + // If not, we put the value in the dictionary and continue + checkDict[varName] = currentCheckValue; + continue; + } + + // Invoke the change function to check whether there is a difference between the current and last value + if (!changeFunc(currentCheckValue, lastCheckValue)) { + continue; + } + + Logger.Debug($"Compound variable ({varName}) changed value"); + + // Since the value changed, we update it in the dictionary + checkDict[varName] = currentCheckValue; + + if (deltaEncodeFunc == null) { + CheckSendSaveUpdate(varName, () => EncodeSaveDataValue(currentValue)); + } else { + var lastValue = _lastPlayerData.GetVariableInternal(varName); + + CheckSendSaveUpdate( + varName, + () => EncodeSaveDataValue(currentValue), + () => deltaEncodeFunc.Invoke(currentValue, lastValue) + ); + + // Also update the current value in the PlayerData instance for last values + // We copy the value, because otherwise it will be updated whenever the list is updated + _lastPlayerData.SetVariableInternal(varName, (TVar) GetCompoundCopy(currentValue)); + } + } + } + + CheckUpdates, int>( + SaveDataMapping.StringListVariables, + _listHashes, + GetListHashCode, + (hash1, hash2) => hash1 != hash2, + (currentValue, lastValue) => { + var currentList = currentValue as List; + var lastList = lastValue as List; + + // TODO: also allow for negative updates, where something is deleted from the list + // this also holds for the other two lambdas below + var deltaList = currentList!.Except(lastList!).ToList(); + + Logger.Debug($"String list var updated, currentList: {string.Join(", ", currentList)}, lastList: {string.Join(", ", lastList)}, deltaList: {string.Join(", ", deltaList)}"); + + return EncodeSaveDataValue(deltaList); + } + ); + + CheckUpdates( + SaveDataMapping.BossSequenceDoorCompletionVariables, + _bsdCompHashes, + bsdComp => bsdComp, + (b1, b2) => + b1.canUnlock != b2.canUnlock || + b1.unlocked != b2.unlocked || + b1.completed != b2.completed || + b1.allBindings != b2.allBindings || + b1.noHits != b2.noHits || + b1.boundNail != b2.boundNail || + b1.boundShell != b2.boundShell || + b1.boundCharms != b2.boundCharms || + b1.boundSoul != b2.boundSoul + ); + + CheckUpdates( + SaveDataMapping.BossStatueCompletionVariables, + _bsCompHashes, + bsComp => bsComp, + (b1, b2) => + b1.hasBeenSeen != b2.hasBeenSeen || + b1.isUnlocked != b2.isUnlocked || + b1.completedTier1 != b2.completedTier1 || + b1.completedTier2 != b2.completedTier2 || + b1.completedTier3 != b2.completedTier3 || + b1.seenTier3Unlock != b2.seenTier3Unlock || + b1.usingAltVersion != b2.usingAltVersion + ); + + CheckUpdates, int>( + SaveDataMapping.VectorListVariables, + _listHashes, + GetListHashCode, + (hash1, hash2) => hash1 != hash2, + (currentValue, lastValue) => { + var currentList = currentValue as List; + var lastList = lastValue as List; + + var deltaList = currentList!.Except(lastList!).ToList(); + + Logger.Debug($"Vector3 list var updated, currentList: {string.Join(", ", currentList)}, lastList: {string.Join(", ", lastList)}, deltaList: {string.Join(", ", deltaList)}"); + + return EncodeSaveDataValue(deltaList); + } + ); + + CheckUpdates, int>( + SaveDataMapping.IntListVariables, + _listHashes, + GetListHashCode, + (hash1, hash2) => hash1 != hash2, + (currentValue, lastValue) => { + var currentList = currentValue as List; + var lastList = lastValue as List; + + var deltaList = currentList!.Except(lastList!).ToList(); + + Logger.Debug($"Integer list var updated, currentList: {string.Join(", ", currentList)}, lastList: {string.Join(", ", lastList)}, deltaList: {string.Join(", ", deltaList)}"); + + return EncodeSaveDataValue(deltaList); + } + ); + } + + /// + /// Callback method for when a save update is received. + /// + /// The save update that was received. + public void UpdateSaveWithData(SaveUpdate saveUpdate) { + Logger.Info($"Received save update for index: {saveUpdate.SaveDataIndex}"); + + var index = saveUpdate.SaveDataIndex; + var value = saveUpdate.Value; + + UpdateSaveWithData(index, value); + } + + /// + /// Set the save data from the given CurrentSave by overriding all values. + /// + /// The save data to set. + public void SetSaveWithData(CurrentSave currentSave) { + if (IsHostingServer) { + Logger.Info("Received current save, but player is hosting, not updating"); + return; + } + + Logger.Info("Received current save, updating..."); + + foreach (var keyValuePair in currentSave.SaveData) { + var index = keyValuePair.Key; + var value = keyValuePair.Value; + + UpdateSaveWithData(index, value); + } + } + + /// + /// Update the local save with the given data (index and encoded value). + /// + /// The index of the save data. + /// A byte array containing the encoded value of the save data. + /// Thrown when the type belonging to the save data cannot be decoded + /// due to a missing implementation. + private void UpdateSaveWithData(ushort index, byte[] encodedValue) { + var pd = PlayerData.instance; + var sceneData = SceneData.instance; + + if (SaveDataMapping.PlayerDataIndices.TryGetValue(index, out var name)) { + if (CheckPlayerSpecificHosting(SaveDataMapping.PlayerDataVarProperties, name)) { + return; + } + + Logger.Info($"Received save update ({index}, {name})"); + + var decodedObject = DecodeSaveDataValue(name, encodedValue); + + if (decodedObject is bool decodedBool) { + _lastPlayerData?.SetBoolInternal(name, decodedBool); + pd.SetBoolInternal(name, decodedBool); + } else if (decodedObject is float decodedFloat) { + _lastPlayerData?.SetFloatInternal(name, decodedFloat); + pd.SetFloatInternal(name, decodedFloat); + } else if (decodedObject is int decodedInt) { + _lastPlayerData?.SetIntInternal(name, decodedInt); + pd.SetIntInternal(name, decodedInt); + } else if (decodedObject is string decodedString) { + _lastPlayerData?.SetStringInternal(name, decodedString); + pd.SetStringInternal(name, decodedString); + } else if (decodedObject is Vector3 decodedVec3) { + _lastPlayerData?.SetVector3Internal(name, decodedVec3); + pd.SetVector3Internal(name, decodedVec3); + } else if (decodedObject is List decodedStringList) { + // First set the new string list hash, so we don't trigger an update and subsequently a feedback loop + _listHashes[name] = GetListHashCode(decodedStringList); + _lastPlayerData?.SetVariableInternal(name, (List) GetCompoundCopy(decodedStringList)); + pd.SetVariableInternal(name, decodedStringList); + } else if (decodedObject is BossSequenceDoor.Completion decodedBsdComp) { + // First set the new bsdComp obj in the dict, so we don't trigger an update and subsequently a + // feedback loop + _bsdCompHashes[name] = decodedBsdComp; + _lastPlayerData?.SetVariableInternal(name, (BossSequenceDoor.Completion) GetCompoundCopy(decodedBsdComp)); + pd.SetVariableInternal(name, decodedBsdComp); + } else if (decodedObject is BossStatue.Completion decodedBsComp) { + // First set the new bsComp obj in the dict, so we don't trigger an update and subsequently a + // feedback loop + _bsCompHashes[name] = decodedBsComp; + _lastPlayerData?.SetVariableInternal(name, (BossStatue.Completion) GetCompoundCopy(decodedBsComp)); + pd.SetVariableInternal(name, decodedBsComp); + } else if (decodedObject is List decodedVec3List) { + // First set the new string list hash, so we don't trigger an update and subsequently a feedback loop + _listHashes[name] = GetListHashCode(decodedVec3List); + _lastPlayerData?.SetVariableInternal(name, (List) GetCompoundCopy(decodedVec3List)); + pd.SetVariableInternal(name, decodedVec3List); + } else if (decodedObject is MapZone decodedMapZone) { + _lastPlayerData?.SetVariableInternal(name, decodedMapZone); + pd.SetVariableInternal(name, decodedMapZone); + } else if (decodedObject is List decodedIntList) { + // First set the new string list hash, so we don't trigger an update and subsequently a feedback loop + _listHashes[name] = GetListHashCode(decodedIntList); + _lastPlayerData?.SetVariableInternal(name, (List) GetCompoundCopy(decodedIntList)); + pd.SetVariableInternal(name, decodedIntList); + } else { + throw new ArgumentException($"Could not decode type: {decodedObject.GetType()}"); + } + + _saveChanges.ApplyPlayerDataSaveChange(name); + } + + if (SaveDataMapping.GeoRockIndices.TryGetValue(index, out var itemData)) { + var value = encodedValue[0]; + + Logger.Info($"Received geo rock save update: {itemData.Id}, {itemData.SceneName}, {value}"); + + foreach (var persistentFsmData in _persistentFsmData) { + var existingItemData = persistentFsmData.PersistentItemKey; + + if (existingItemData.Id == itemData.Id && existingItemData.SceneName == itemData.SceneName) { + persistentFsmData.SetCurrentInt.Invoke(value); + persistentFsmData.LastIntValue = value; + } + } + + sceneData.SaveMyState(new GeoRockData { + id = itemData.Id, + sceneName = itemData.SceneName, + hitsLeft = value + }); + } else if (SaveDataMapping.PersistentBoolIndices.TryGetValue(index, out itemData)) { + if (CheckPlayerSpecificHosting(SaveDataMapping.PersistentBoolVarProperties, itemData)) { + return; + } + + var value = encodedValue[0] == 1; + + Logger.Info($"Received persistent bool save update: {itemData.Id}, {itemData.SceneName}, {value}"); + + foreach (var persistentFsmData in _persistentFsmData) { + var existingItemData = persistentFsmData.PersistentItemKey; + + if (existingItemData.Id == itemData.Id && existingItemData.SceneName == itemData.SceneName) { + Logger.Debug($"Setting last bool value for {existingItemData} to {value}"); + persistentFsmData.SetCurrentBool.Invoke(value); + persistentFsmData.LastBoolValue = value; + } + } + + sceneData.SaveMyState(new PersistentBoolData { + id = itemData.Id, + sceneName = itemData.SceneName, + activated = value + }); + + _saveChanges.ApplyPersistentValueSaveChange(itemData); + } else if (SaveDataMapping.PersistentIntIndices.TryGetValue(index, out itemData)) { + if (CheckPlayerSpecificHosting(SaveDataMapping.PersistentIntVarProperties, itemData)) { + return; + } + + var value = (int) encodedValue[0]; + // Add a special case for the -1 value that some persistent ints might have + // 255 is never used in the byte space, so we use it for compact networking + if (value == 255) { + value = -1; + } + + Logger.Info($"Received persistent int save update: {itemData.Id}, {itemData.SceneName}, {value}"); + + foreach (var persistentFsmData in _persistentFsmData) { + var existingItemData = persistentFsmData.PersistentItemKey; + + if (existingItemData.Id == itemData.Id && existingItemData.SceneName == itemData.SceneName) { + persistentFsmData.SetCurrentInt.Invoke(value); + persistentFsmData.LastIntValue = value; + } + } + + sceneData.SaveMyState(new PersistentIntData { + id = itemData.Id, + sceneName = itemData.SceneName, + value = value + }); + + _saveChanges.ApplyPersistentValueSaveChange(itemData); + } + + // Do the checks for whether the player is hosting and the received save data is player specific and should + // thus be ignored. Returns true if the data should be ignored, false otherwise. + bool CheckPlayerSpecificHosting(Dictionary dict, TKey value) { + if (!IsHostingServer) { + return false; + } + + if (!dict.TryGetValue(value, out var varProps)) { + return true; + } + + if (varProps.SyncType != SaveDataMapping.SyncType.Player) { + return false; + } + + Logger.Info($"Received player specific save update ({index}, {name}), but player is hosting"); + return true; + } + } + + /// + /// Encode a save data value by first recasting HK/Unity internal types to HKMP types and then using the EncodeUtil. + /// + /// The object to encode, which should be part of save data. + /// A byte array containing the encoded data. + private static byte[] EncodeSaveDataValue(object value) { + // First cast HK or Unity internal types to HKMP types, this is to make sure we can use our internal + // EncodeUtil to encode all types. This util is also used on the server side, where (in the case of the + // standalone server) we have no reference of HK or Unity internal types + if (value is Vector3 vector3) { + value = (Math.Vector3) vector3; + } else if (value is MapZone mapZone) { + value = (Serialization.MapZone) mapZone; + } else if (value is BossStatue.Completion bsCompletion) { + value = (BossStatueCompletion) bsCompletion; + } else if (value is BossSequenceDoor.Completion bsdCompletion) { + value = (BossSequenceDoorCompletion) bsdCompletion; + } else if (value is List vector3List) { + value = vector3List.Select(v => (Math.Vector3) v).ToList(); + } + + return EncodeUtil.EncodeSaveDataValue(value); + } + + /// + /// Decode a save data value by first using the EncodeUtil and then recasting HKMP types to HK/Unity internal types. + /// + /// The name of the save data variable. + /// A byte array containing the encoded data. + /// The decoded object. + private static object DecodeSaveDataValue(string name, byte[] encodedValue) { + var decodedValue = EncodeUtil.DecodeSaveDataValue(name, encodedValue); + + // Now we cast HKMP types to HK or Unity internal types, this is to make sure we can use our internal + // EncodeUtil to decode all types. This util is also used on the server side, where (in the case of the + // standalone server) we have no reference of HK or Unity internal types + if (decodedValue is Math.Vector3 vector3) { + decodedValue = (Vector3) vector3; + } else if (decodedValue is Serialization.MapZone mapZone) { + decodedValue = (MapZone) mapZone; + } else if (decodedValue is BossStatueCompletion bsCompletion) { + decodedValue = (BossStatue.Completion) bsCompletion; + } else if (decodedValue is BossSequenceDoorCompletion bsdCompletion) { + decodedValue = (BossSequenceDoor.Completion) bsdCompletion; + } else if (decodedValue is List vector3List) { + decodedValue = vector3List.Select(v => (Vector3) v).ToList(); + } + + return decodedValue; + } + + /// + /// Get the current save data as a dictionary with mapped indices and encoded values. This returns the global save + /// data if the boolean is set. Otherwise, it returns the player save data. + /// + /// A dictionary with mapped indices and byte-encoded values. + public static Dictionary GetCurrentSaveData(bool server) { + var pd = PlayerData.instance; + var sd = SceneData.instance; + + var saveData = new Dictionary(); + + void AddToSaveData( + IEnumerable enumerable, + Func keyFunc, + object syncMapping, + BiLookup indexMapping, + Func valueFunc + ) { + foreach (var collectionValue in enumerable) { + var key = keyFunc.Invoke(collectionValue); + + if (syncMapping is Dictionary boolMapping) { + if (!boolMapping.TryGetValue(key, out var shouldSync) || !shouldSync) { + continue; + } + + // Since all geo rocks are server data, we need to check whether we are actually trying to get + // server data or not and continue appropriately + if (!server) { + continue; + } + } else if (syncMapping is Dictionary syncPropMapping) { + if (!syncPropMapping.TryGetValue(key, out var varProps)) { + continue; + } + + // Skip values that are not supposed to be synced, or ones that have the property that it is + // server data. Since we will not require the hosting player's save data on the server. + if (!varProps.Sync) { + continue; + } + + // Check whether the sync type corresponds with the server parameter. If it is server data, but + // we are trying to get player data, we continue + if ((varProps.SyncType == SaveDataMapping.SyncType.Server) != server) { + continue; + } + } + + if (!indexMapping.TryGetValue(key, out var index)) { + continue; + } + + var value = valueFunc.Invoke(collectionValue); + + saveData.Add(index, EncodeSaveDataValue(value)); + } + } + + AddToSaveData( + typeof(PlayerData).GetFields(), + fieldInfo => fieldInfo.Name, + SaveDataMapping.PlayerDataVarProperties, + SaveDataMapping.PlayerDataIndices, + fieldInfo => fieldInfo.GetValue(pd) + ); + + AddToSaveData( + sd.geoRocks, + geoRock => new PersistentItemKey { + Id = geoRock.id, + SceneName = geoRock.sceneName + }, + SaveDataMapping.GeoRockBools, + SaveDataMapping.GeoRockIndices, + geoRock => geoRock.hitsLeft + ); + + AddToSaveData( + sd.persistentBoolItems, + boolData => new PersistentItemKey { + Id = boolData.id, + SceneName = boolData.sceneName + }, + SaveDataMapping.PersistentBoolVarProperties, + SaveDataMapping.PersistentBoolIndices, + boolData => boolData.activated + ); + + AddToSaveData( + sd.persistentIntItems, + intData => new PersistentItemKey { + Id = intData.id, + SceneName = intData.sceneName + }, + SaveDataMapping.PersistentIntVarProperties, + SaveDataMapping.PersistentIntIndices, + intData => intData.value + ); + + return saveData; + } + + /// + /// Get the hash code of the combined values in a list. + /// + /// The list to calculate the hash code for. + /// 0 if the list is empty, otherwise a hash code matching the specific order of values in the list. + /// + private static int GetListHashCode(List list) { + if (list.Count == 0) { + return 0; + } + + return list + .Select(item => item.GetHashCode()) + .Aggregate((total, nextCode) => total ^ nextCode); + } + + /// + /// Get a copy of the given object for compound objects in the PlayerData, such as string lists, integer lists, + /// completion for boss sequences or boss doors, etc. + /// + /// The object value to get a copy from. + /// The copy of the given value. + /// Thrown when a copy cannot be made, due to the given value being null or + /// of a non-compound or non-PlayerData type. + private static object GetCompoundCopy(object value) { + if (value == null) { + throw new ArgumentException("Cannot get copy of null"); + } + + if (value is List stringListValue) { + return new List(stringListValue); + } + + if (value is List intListValue) { + return new List(intListValue); + } + + if (value is List vecListValue) { + return new List(vecListValue); + } + + if (value is BossSequenceDoor.Completion bsdComp) { + return new BossSequenceDoor.Completion { + canUnlock = bsdComp.canUnlock, + unlocked = bsdComp.unlocked, + completed = bsdComp.completed, + allBindings = bsdComp.allBindings, + noHits = bsdComp.noHits, + boundNail = bsdComp.boundNail, + boundShell = bsdComp.boundShell, + boundCharms = bsdComp.boundCharms, + boundSoul = bsdComp.boundSoul, + viewedBossSceneCompletions = bsdComp.viewedBossSceneCompletions == null + ? [] + : [..bsdComp.viewedBossSceneCompletions] + }; + } + + if (value is BossStatue.Completion bsComp) { + return new BossStatue.Completion { + hasBeenSeen = bsComp.hasBeenSeen, + isUnlocked = bsComp.isUnlocked, + completedTier1 = bsComp.completedTier1, + completedTier2 = bsComp.completedTier2, + completedTier3 = bsComp.completedTier3, + seenTier3Unlock = bsComp.seenTier3Unlock, + usingAltVersion = bsComp.usingAltVersion + }; + } + + throw new ArgumentException($"Cannot get copy of value with type: {value.GetType()}"); + } +} diff --git a/HKMP/Game/Client/Skin/SkinManager.cs b/HKMP/Game/Client/Skin/SkinManager.cs index f1ab2bb4..a49c6486 100644 --- a/HKMP/Game/Client/Skin/SkinManager.cs +++ b/HKMP/Game/Client/Skin/SkinManager.cs @@ -11,7 +11,7 @@ internal class SkinManager { /// /// Dictionary mapping skin IDs to PlayerSkin objects that store all relevant textures. /// - private readonly Dictionary _playerSkins; + private Dictionary _playerSkins; /// /// The fallback skin to use. @@ -20,22 +20,42 @@ internal class SkinManager { public SkinManager() { _playerSkins = new Dictionary(); + } + /// + /// Initialize the skin manager by loading all the skins. + /// + public void Initialize() { new SkinLoader().LoadAllSkins(ref _playerSkins); + } + /// + /// Register hooks for skin-related operations. + /// + public void RegisterHooks() { // Only when the local player object is created can we retrieve the default materials from it, // so we register this on HeroController Start - On.HeroController.Start += (orig, self) => { - orig(self); + CustomHooks.HeroControllerStartAction += HeroControllerOnStart; + } - // If we haven't saved the default skin already - if (_defaultPlayerSkin == null) { - Logger.Debug("Storing default player skin"); - StoreDefaultPlayerSkin(self); - } + /// + /// Deregister hooks for skin-related operations. + /// + public void DeregisterHooks() { + CustomHooks.HeroControllerStartAction -= HeroControllerOnStart; + } + + /// + /// Callback method for when the HeroController is started so we can store the default skin and initialize sprites. + /// + private void HeroControllerOnStart() { + // If we haven't saved the default skin already + if (_defaultPlayerSkin == null) { + Logger.Debug("Storing default player skin"); + StoreDefaultPlayerSkin(HeroController.instance); + } - InitializeSpritesOnLocalPlayer(self.gameObject); - }; + InitializeSpritesOnLocalPlayer(HeroController.instance.gameObject); } /// diff --git a/HKMP/Game/Command/Client/AddonCommand.cs b/HKMP/Game/Command/Client/AddonCommand.cs index 89814b6a..b0bc8cc8 100644 --- a/HKMP/Game/Command/Client/AddonCommand.cs +++ b/HKMP/Game/Command/Client/AddonCommand.cs @@ -42,18 +42,24 @@ public void Execute(string[] arguments) { var action = arguments[1]; if (action == "list") { - var message = "Loaded addons: "; - message += string.Join( - ", ", - _addonManager.GetLoadedAddons().Select(addon => { - var msg = $"{addon.GetName()} {addon.GetVersion()}"; - if (addon is TogglableClientAddon {Disabled: true }) { - msg += " (disabled)"; - } - - return msg; - }) - ); + string message; + var addons = _addonManager.GetLoadedAddons(); + if (addons.Count == 0) { + message = "No addons loaded."; + } else { + message = "Loaded addons: "; + message += string.Join( + ", ", + addons.Select(addon => { + var msg = $"{addon.GetName()} {addon.GetVersion()}"; + if (addon is TogglableClientAddon { Disabled: true }) { + msg += " (disabled)"; + } + + return msg; + }) + ); + } UiManager.InternalChatBox.AddMessage(message); return; @@ -64,7 +70,7 @@ public void Execute(string[] arguments) { return; } - if (_netClient.IsConnected || _netClient.IsConnecting) { + if (_netClient.ConnectionStatus != ClientConnectionStatus.NotConnected) { UiManager.InternalChatBox.AddMessage("Cannot toggle addons while connecting or connected to a server."); return; } diff --git a/HKMP/Game/Command/Client/ConnectCommand.cs b/HKMP/Game/Command/Client/ConnectCommand.cs deleted file mode 100644 index 62eb290e..00000000 --- a/HKMP/Game/Command/Client/ConnectCommand.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Hkmp.Api.Command.Client; -using Hkmp.Game.Client; -using Hkmp.Ui; - -namespace Hkmp.Game.Command.Client; - -/// -/// Command for connecting the local user to a server with a given address, port and username. -/// -internal class ConnectCommand : IClientCommand { - /// - public string Trigger => "/connect"; - - /// - public string[] Aliases => new[] { "/disconnect" }; - - /// - /// The client manager instance. - /// - private readonly ClientManager _clientManager; - - public ConnectCommand(ClientManager clientManager) { - _clientManager = clientManager; - } - - /// - public void Execute(string[] arguments) { - var command = arguments[0]; - if (command == Aliases[0]) { - _clientManager.Disconnect(); - UiManager.InternalChatBox.AddMessage("You are disconnected from the server"); - return; - } - - if (arguments.Length != 4) { - SendUsage(); - return; - } - - var address = arguments[1]; - - var portString = arguments[2]; - var parsedPort = int.TryParse(portString, out var port); - if (!parsedPort || port < 1 || port > 99999) { - UiManager.InternalChatBox.AddMessage("Invalid port!"); - return; - } - - var username = arguments[3]; - - _clientManager.Connect(address, port, username); - UiManager.InternalChatBox.AddMessage($"Trying to connect to {address}:{port} as {username}..."); - } - - /// - /// Sends the command usage to the chat box. - /// - private void SendUsage() { - UiManager.InternalChatBox.AddMessage($"Invalid usage: {Trigger}
"); - } -} diff --git a/HKMP/Game/Command/Client/HostCommand.cs b/HKMP/Game/Command/Client/HostCommand.cs deleted file mode 100644 index 2b50d70b..00000000 --- a/HKMP/Game/Command/Client/HostCommand.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using Hkmp.Api.Command.Client; -using Hkmp.Game.Server; -using Hkmp.Ui; - -namespace Hkmp.Game.Command.Client; - -/// -/// Command for controlling local server hosting. -/// -internal class HostCommand : IClientCommand { - /// - public string Trigger => "/host"; - - /// - public string[] Aliases => Array.Empty(); - - /// - /// The server manager instance. - /// - private readonly ServerManager _serverManager; - - public HostCommand(ServerManager serverManager) { - _serverManager = serverManager; - } - - /// - public void Execute(string[] arguments) { - if (arguments.Length < 2) { - SendUsage(); - return; - } - - var action = arguments[1]; - if (action == "start") { - if (arguments.Length != 3) { - SendUsage(); - return; - } - - var portString = arguments[2]; - var parsedPort = int.TryParse(portString, out var port); - if (!parsedPort || port < 1 || port > 99999) { - UiManager.InternalChatBox.AddMessage("Invalid port!"); - return; - } - - _serverManager.Start(port); - UiManager.InternalChatBox.AddMessage($"Started server on port {port}"); - } else if (action == "stop") { - _serverManager.Stop(); - UiManager.InternalChatBox.AddMessage("Stopped server"); - } else { - SendUsage(); - } - } - - /// - /// Sends the command usage to the chat box. - /// - private void SendUsage() { - UiManager.InternalChatBox.AddMessage($"Invalid usage: {Trigger} [port]"); - } -} diff --git a/HKMP/Game/Command/Server/AnnounceCommand.cs b/HKMP/Game/Command/Server/AnnounceCommand.cs index acefc233..fa9c73b3 100644 --- a/HKMP/Game/Command/Server/AnnounceCommand.cs +++ b/HKMP/Game/Command/Server/AnnounceCommand.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Concurrent; using Hkmp.Api.Command.Server; -using Hkmp.Concurrency; using Hkmp.Game.Server; using Hkmp.Networking.Server; diff --git a/HKMP/Game/Command/Server/CopySaveCommand.cs b/HKMP/Game/Command/Server/CopySaveCommand.cs new file mode 100644 index 00000000..f9112e9c --- /dev/null +++ b/HKMP/Game/Command/Server/CopySaveCommand.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using Hkmp.Api.Command.Server; +using Hkmp.Game.Server; +using Hkmp.Game.Server.Save; +using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Server; +using Hkmp.Util; + +namespace Hkmp.Game.Command.Server; + +/// +/// Command for allowing players to copy player-specific save data from another player. This is used to catch up +/// to another player's progression by transferring the save data. +/// +internal class CopySaveCommand : IServerCommand { + /// + public string Trigger => "/copysave"; + + /// + public string[] Aliases => Array.Empty(); + + /// + public bool AuthorizedOnly => true; + + /// + /// The server manager instance to access players. + /// + private readonly ServerManager _serverManager; + + /// + /// The server save data instance for accessing save data to copy. + /// + private readonly ServerSaveData _serverSaveData; + + public CopySaveCommand(ServerManager serverManager, ServerSaveData serverSaveData) { + _serverManager = serverManager; + _serverSaveData = serverSaveData; + } + + /// + public void Execute(ICommandSender commandSender, string[] args) { + if (args.Length < 3) { + commandSender.SendMessage($"Invalid usage: {Trigger} "); + return; + } + + var fromUsername = args[1]; + if (!CommandUtil.TryGetPlayerByName(_serverManager.Players, fromUsername, out var fromPlayer)) { + commandSender.SendMessage($"Could not find player with name '{fromUsername}'"); + return; + } + + var toUsername = args[2]; + if (!CommandUtil.TryGetPlayerByName(_serverManager.Players, toUsername, out var toPlayer)) { + commandSender.SendMessage($"Could not find player with name '{toUsername}'"); + return; + } + + var toCopyData = new Dictionary(_serverSaveData.PlayerSaveData[fromPlayer.AuthKey]); + + _serverSaveData.PlayerSaveData[toPlayer.AuthKey] = toCopyData; + + _serverManager.DisconnectPlayer(toPlayer.Id, DisconnectReason.Kicked); + + commandSender.SendMessage($"Copied player save file from '{fromUsername}' to '{toUsername}'"); + } +} diff --git a/HKMP/Game/Command/Server/SettingsCommand.cs b/HKMP/Game/Command/Server/SettingsCommand.cs index 55fcf8a2..1ba96563 100644 --- a/HKMP/Game/Command/Server/SettingsCommand.cs +++ b/HKMP/Game/Command/Server/SettingsCommand.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using System.Reflection; using Hkmp.Api.Command.Server; @@ -15,7 +14,7 @@ internal class SettingsCommand : IServerCommand { public string Trigger => "/set"; /// - public string[] Aliases => Array.Empty(); + public string[] Aliases => []; /// public bool AuthorizedOnly => true; diff --git a/HKMP/Game/Command/Server/SkinCommand.cs b/HKMP/Game/Command/Server/SkinCommand.cs new file mode 100644 index 00000000..5f0d8541 --- /dev/null +++ b/HKMP/Game/Command/Server/SkinCommand.cs @@ -0,0 +1,55 @@ +using System; +using Hkmp.Api.Command.Server; +using Hkmp.Game.Server; + +namespace Hkmp.Game.Command.Server; + +/// +/// Command for changing the skin of the player. +/// +internal class SkinCommand : IServerCommand { + /// + public string Trigger => "/skin"; + + /// + public string[] Aliases => Array.Empty(); + + /// + public bool AuthorizedOnly => false; + + /// + /// The server manager instance. + /// + private readonly ServerManager _serverManager; + + public SkinCommand(ServerManager serverManager) { + _serverManager = serverManager; + } + + /// + public void Execute(ICommandSender commandSender, string[] arguments) { + if (commandSender.Type == CommandSenderType.Console) { + commandSender.SendMessage("Console cannot change skins."); + return; + } + + var sender = (PlayerCommandSender) commandSender; + + if (arguments.Length != 2) { + sender.SendMessage($"Usage: {Trigger} "); + return; + } + + var skinIdArg = arguments[1]; + if (!byte.TryParse(skinIdArg, out var skinId)) { + sender.SendMessage($"Unknown skin ID '{skinIdArg}', please provide a value between 0-255"); + return; + } + + if (_serverManager.TryUpdatePlayerSkin(sender.Id, skinId, out var reason)) { + sender.SendMessage($"Skin ID changed to '{skinId}'"); + } else { + sender.SendMessage(reason); + } + } +} diff --git a/HKMP/Game/Command/Server/TeamCommand.cs b/HKMP/Game/Command/Server/TeamCommand.cs new file mode 100644 index 00000000..232b9808 --- /dev/null +++ b/HKMP/Game/Command/Server/TeamCommand.cs @@ -0,0 +1,55 @@ +using System; +using Hkmp.Api.Command.Server; +using Hkmp.Game.Server; + +namespace Hkmp.Game.Command.Server; + +/// +/// Command for changing the team of the player. +/// +internal class TeamCommand : IServerCommand { + /// + public string Trigger => "/team"; + + /// + public string[] Aliases => Array.Empty(); + + /// + public bool AuthorizedOnly => false; + + /// + /// The server manager instance. + /// + private readonly ServerManager _serverManager; + + public TeamCommand(ServerManager serverManager) { + _serverManager = serverManager; + } + + /// + public void Execute(ICommandSender commandSender, string[] arguments) { + if (commandSender.Type == CommandSenderType.Console) { + commandSender.SendMessage("Console cannot change teams."); + return; + } + + var sender = (PlayerCommandSender) commandSender; + + if (arguments.Length != 2) { + sender.SendMessage($"Usage: {Trigger} "); + return; + } + + var teamName = arguments[1]; + if (!Enum.TryParse(teamName, true, out var team)) { + sender.SendMessage($"Unknown team name: '{teamName}'"); + return; + } + + if (_serverManager.TryUpdatePlayerTeam(sender.Id, team, out var reason)) { + sender.SendMessage($"Team changed to '{team}'"); + } else { + sender.SendMessage(reason); + } + } +} diff --git a/HKMP/Game/GameManager.cs b/HKMP/Game/GameManager.cs index 4d53dbf9..bce97ff7 100644 --- a/HKMP/Game/GameManager.cs +++ b/HKMP/Game/GameManager.cs @@ -14,6 +14,19 @@ namespace Hkmp.Game; /// Instantiates all necessary classes to start multiplayer activities. ///
internal class GameManager { + /// + /// The net client instance for the mod. + /// + public readonly NetClient NetClient; + /// + /// The client manager instance for the mod. + /// + public readonly ClientManager ClientManager; + /// + /// The server manager instance for the mod. + /// + public readonly ModServerManager ServerManager; + /// /// Constructs this GameManager instance by instantiating all other necessary classes. /// @@ -27,7 +40,7 @@ public GameManager(ModSettings modSettings) { var packetManager = new PacketManager(); - var netClient = new NetClient(packetManager); + NetClient = new NetClient(packetManager); var netServer = new NetServer(packetManager); var clientServerSettings = new ServerSettings(); @@ -37,26 +50,27 @@ public GameManager(ModSettings modSettings) { var serverServerSettings = modSettings.ServerSettings; var uiManager = new UiManager( - clientServerSettings, modSettings, - netClient + NetClient ); + uiManager.Initialize(); - var serverManager = new ModServerManager( + ServerManager = new ModServerManager( netServer, - serverServerSettings, packetManager, - uiManager + serverServerSettings, + uiManager, + modSettings ); - serverManager.Initialize(); + ServerManager.Initialize(); - new ClientManager( - netClient, - serverManager, + ClientManager = new ClientManager( + NetClient, packetManager, uiManager, clientServerSettings, modSettings ); + ClientManager.Initialize(ServerManager); } } diff --git a/HKMP/Game/Server/ModServerManager.cs b/HKMP/Game/Server/ModServerManager.cs index d1e20679..550dec8d 100644 --- a/HKMP/Game/Server/ModServerManager.cs +++ b/HKMP/Game/Server/ModServerManager.cs @@ -1,4 +1,6 @@ +using Hkmp.Game.Client.Save; using Hkmp.Game.Command.Server; +using Hkmp.Game.Server.Save; using Hkmp.Game.Settings; using Hkmp.Networking.Packet; using Hkmp.Networking.Server; @@ -11,27 +13,107 @@ namespace Hkmp.Game.Server; /// Specialization of that adds handlers for the mod specific things. ///
internal class ModServerManager : ServerManager { + /// + /// The UiManager instance for registering events for starting and stopping a server. + /// + private readonly UiManager _uiManager; + + /// + /// The mod settings instance for retrieving the auth key of the local player to set player save data when + /// hosting a server. + /// + private readonly ModSettings _modSettings; + + /// + /// The settings command. + /// + private readonly SettingsCommand _settingsCommand; + + /// + /// Save data that was loaded from selecting a save file. Will be retroactively applied to a server, if one was + /// requested to be started after selecting a save file. + /// + private ServerSaveData _loadedLocalSaveData; + public ModServerManager( NetServer netServer, - ServerSettings serverSettings, PacketManager packetManager, - UiManager uiManager - ) : base(netServer, serverSettings, packetManager) { + ServerSettings serverSettings, + UiManager uiManager, + ModSettings modSettings + ) : base(netServer, packetManager, serverSettings) { + _uiManager = uiManager; + _modSettings = modSettings; + _settingsCommand = new SettingsCommand(this, InternalServerSettings); + } + + /// + public override void Initialize() { + base.Initialize(); + // Start addon loading once all mods have finished loading ModHooks.FinishedLoadingModsHook += AddonManager.LoadAddons; // Register handlers for UI events - uiManager.ConnectInterface.StartHostButtonPressed += Start; - uiManager.ConnectInterface.StopHostButtonPressed += Stop; + _uiManager.RequestServerStartHostEvent += port => OnRequestServerStartHost(port, _modSettings.FullSynchronisation); + _uiManager.RequestServerStopHostEvent += Stop; // Register application quit handler ModHooks.ApplicationQuitHook += Stop; } + /// + /// Callback method for when the UI requests the server to be started as a host. + /// + /// The port to start the server on. + /// Whether full synchronisation is enabled. + private void OnRequestServerStartHost(int port, bool fullSynchronisation) { + if (fullSynchronisation) { + // Get the global save data from the save manager, which obtains the global save data from the loaded + // save file that the user selected + ServerSaveData.GlobalSaveData = SaveManager.GetCurrentSaveData(true); + + // Then we import the player save data from the (potentially) loaded modded save file from the user selected + // save file + if (_loadedLocalSaveData != null) { + ServerSaveData.PlayerSaveData = _loadedLocalSaveData.PlayerSaveData; + } + + // Lastly, we get the player save data from the save manager, which obtains the player save data from the + // loaded save file that the user selected. We add this data to the server save as the local player + ServerSaveData.PlayerSaveData[_modSettings.AuthKey] = SaveManager.GetCurrentSaveData(false); + } + + Start(port, fullSynchronisation); + } + /// protected override void RegisterCommands() { base.RegisterCommands(); - CommandManager.RegisterCommand(new SettingsCommand(this, InternalServerSettings)); + CommandManager.RegisterCommand(_settingsCommand); + } + + /// + protected override void DeregisterCommands() { + base.DeregisterCommands(); + + CommandManager.DeregisterCommand(_settingsCommand); + } + + /// + /// Callback for when a local save is loaded. + /// + /// The deserialized ModSaveFile instance. + public void OnLoadLocal(ModSaveFile modSaveFile) { + _loadedLocalSaveData = modSaveFile.ToServerSaveData(); + } + + /// + /// Callback for when a local save is saved. + /// + /// The ModSaveFile instance to serialize to file. + public ModSaveFile OnSaveLocal() { + return ModSaveFile.FromServerSaveData(ServerSaveData); } } diff --git a/HKMP/Game/Server/Save/ModSaveFile.cs b/HKMP/Game/Server/Save/ModSaveFile.cs new file mode 100644 index 00000000..89ab3889 --- /dev/null +++ b/HKMP/Game/Server/Save/ModSaveFile.cs @@ -0,0 +1,190 @@ +using System.Collections.Generic; +using Hkmp.Game.Client.Save; +using Hkmp.Util; +using Newtonsoft.Json; + +namespace Hkmp.Game.Server.Save; + +/// +/// Class for serialization and deserialization of save data from a server to the local save file. +/// See for the representation of the same data of the running server. +/// +internal class ModSaveFile { + /// + /// The player specific save data mapped to player's auth keys. + /// + [JsonProperty("playerSaveData")] + public Dictionary PlayerSaveData { get; set; } + + public ModSaveFile() { + PlayerSaveData = new Dictionary(); + } + + /// + /// Convert this class to an encoded ServerSaveData. + /// + /// The converted ServerSaveData instance. + public virtual ServerSaveData ToServerSaveData() { + // Create new instance of server save data, which we return at the end + var serverSaveData = new ServerSaveData(); + + foreach (var authKey in PlayerSaveData.Keys) { + serverSaveData.PlayerSaveData[authKey] = EncodeUtil.ConvertToServerSaveData(PlayerSaveData[authKey]); + } + + return serverSaveData; + } + + /// + /// Get an instance of this class with the decoded data from the given ServerSaveData. + /// + /// The encoded ServerSaveData. + /// An instance of this class. + public static ModSaveFile FromServerSaveData(ServerSaveData serverSaveData) { + // Create new instance of this class, which we return at the end + var modSaveFile = new ModSaveFile(); + + var playerSaveData = serverSaveData.PlayerSaveData; + foreach (var authKey in playerSaveData.Keys) { + var saveData = EncodeUtil.ConvertFromServerSaveData(playerSaveData[authKey]); + // Store the entries in the player save data dictionary of the instance + modSaveFile.PlayerSaveData[authKey] = saveData; + } + + return modSaveFile; + } + + /// + /// Serializable save data that contains PlayerData and SceneData similar to the HK save file. + /// + public class SaveData { + /// + /// PlayerData entries that use a custom serialization. + /// + /// + [JsonProperty("playerData")] + public PlayerDataEntries PlayerDataEntries { get; set; } + + /// + /// SceneData instance that contains geo rocks and persistent items. + /// + [JsonProperty("sceneData")] + public SceneData SceneData { get; set; } + + public SaveData() { + PlayerDataEntries = []; + SceneData = new SceneData(); + } + } + + /// + /// Serializable SceneData class that contains geo rocks, persistent integers, and persistent booleans. + /// + public class SceneData { + /// + /// List of individual geo rocks. + /// + [JsonProperty("geoRocks")] + public List GeoRockData { get; set; } + /// + /// List of persistent booleans. + /// + [JsonProperty("persistentBoolItems")] + public List PersistentBoolData { get; set; } + /// + /// List of persistent integers. + /// + [JsonProperty("persistentIntItems")] + public List PersistentIntData { get; set; } + + public SceneData() { + GeoRockData = []; + PersistentBoolData = []; + PersistentIntData = []; + } + } + + /// + /// Base class for serializable scene data items, such as geo rocks or persistent items. + /// + /// + /// + /// + public class SceneDataItem { + /// + /// The ID of the item. + /// + [JsonProperty("id")] + public string Id { get; set; } + + /// + /// The scene name the item is in. + /// + [JsonProperty("sceneName")] + public string SceneName { get; set; } + + /// + /// Get a persistent item key for this item with the correct ID and scene name. + /// + /// An instance of . + public PersistentItemKey GetKey() { + return new PersistentItemKey { + Id = Id, + SceneName = SceneName + }; + } + } + + /// + /// Serializable geo rock data. + /// + public class GeoRockData : SceneDataItem { + /// + /// The number of hits left for this geo rock. + /// + [JsonProperty("hitsLeft")] + public int HitsLeft { get; set; } + } + + /// + /// Serializable persistent boolean. + /// + public class PersistentBoolData : SceneDataItem { + /// + /// Whether the item was activated. + /// + [JsonProperty("activated")] + public bool Activated { get; set; } + } + + /// + /// Serializable persistent integer. + /// + public class PersistentIntData : SceneDataItem { + /// + /// The value of the item. + /// + [JsonProperty("value")] + public int Value { get; set; } + } + + /// + /// List of with a custom converter to make sure JSON (de)serialization is handled correctly. + /// + [JsonConverter(typeof(PlayerSaveDataConverter))] + public class PlayerDataEntries : List; + + /// + /// A single entry for a mod save file with a name and corresponding value that can be any type. + /// + public class PlayerDataEntry { + /// + /// The name of the PlayerData variable. + /// + public string Name { get; set; } + /// + /// The value of the PlayerData variable as an object. + /// + public object Value { get; set; } + } +} diff --git a/HKMP/Game/Server/Save/PlayerSaveDataConverter.cs b/HKMP/Game/Server/Save/PlayerSaveDataConverter.cs new file mode 100644 index 00000000..065fd81b --- /dev/null +++ b/HKMP/Game/Server/Save/PlayerSaveDataConverter.cs @@ -0,0 +1,86 @@ +using System; +using Hkmp.Game.Client.Save; +using Hkmp.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Hkmp.Game.Server.Save; + +/// +/// JSON converter class to handle converting a list of entries for a mod save file into and from JSON. +/// +public class PlayerSaveDataConverter : JsonConverter { + /// + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { + if (value == null) { + return; + } + + var entries = (ModSaveFile.PlayerDataEntries) value; + + // Create a JSON object as the basis for the list of entries + var jObject = new JObject(); + + // Then for each entry we add the name and value as a property to the object + foreach (var entry in entries) { + // The use of the 'serializer' in the FromObject is important to ensure we allow earlier defined converters + // from acting on nested objects in the value of the entry (such as Unity's Vector3 being handled by + // the modding API's Vector3 converter) + jObject.Add(entry.Name, JToken.FromObject(entry.Value, serializer)); + } + + // Finally, write the JSON object to the writer + jObject.WriteTo(writer); + } + + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { + // We know that our JSON will have a JSON object, so we can read it + var jObject = JObject.Load(reader); + + var entries = new ModSaveFile.PlayerDataEntries(); + + // Loop over all properties of the object, since these are the entries in our ModSaveFile + foreach (var prop in jObject.Properties()) { + // Create the entry with the name only + var entry = new ModSaveFile.PlayerDataEntry { + Name = prop.Name + }; + + // Find the variable properties that correspond to the PlayerData variable name from this JSON property's + // name + if (!SaveDataMapping.Instance.PlayerDataVarProperties.TryGetValue(prop.Name, out var varProps)) { + Logger.Warn($"Could not deserialize ModSaveFile.Entry, because variable '{prop.Name}' has no variable properties, skipping"); + continue; + } + + if (!varProps.Sync) { + Logger.Debug($"Variable properties for '{prop.Name}' indicate no sync, skipping"); + continue; + } + + // From the variable properties, we obtain the type for the value of this JSON property + var typeString = varProps.VarType; + var type = Type.GetType(typeString); + + if (type == null) { + Logger.Warn($"Could not deserialize ModSaveFile.Entry, because var type '{typeString}' could not be found, skipping"); + continue; + } + + // Then we can convert the JSON property's value + // The use of the 'serializer' here is important to ensure we allow earlier defined converters from acting + // on nested objects in the value of the entry (such as Unity's Vector3 being handled by the modding API's + // Vector3 converter) + entry.Value = prop.Value.ToObject(type, serializer); + entries.Add(entry); + } + + return entries; + } + + /// + public override bool CanConvert(Type objectType) { + return objectType == typeof(ModSaveFile.PlayerDataEntry); + } +} diff --git a/HKMP/Game/Server/Save/ServerSaveData.cs b/HKMP/Game/Server/Save/ServerSaveData.cs new file mode 100644 index 00000000..e84ea97b --- /dev/null +++ b/HKMP/Game/Server/Save/ServerSaveData.cs @@ -0,0 +1,136 @@ +using System.Collections.Generic; +using Hkmp.Game.Client.Save; +using Hkmp.Logging; +using Hkmp.Networking.Packet.Data; +using Hkmp.Util; + +namespace Hkmp.Game.Server.Save; + +/// +/// Class that holds save data from a server. This consists of global data relating to the world and individual +/// data specific to each player. This class is only used for storing the save data while the server is running; +/// serialization of this data to the save file is done with . +/// +internal class ServerSaveData { + /// + /// Name of the variable in PlayerData that denotes a Steel Soul save file. + /// + private const string SteelSoulVarName = "permadeathMode"; + /// + /// Name of the variable in PlayerData that denotes a Godseeker save file. + /// + private const string GodseekerVarName = "bossRushMode"; + + /// + /// The file path of the embedded resource file for the Godseeker overrides. + /// + private const string GodseekerFilePath = "Hkmp.Resource.save-data-godseeker.json"; + + /// + /// Save data that is the basis for a Godseeker file and should override player save data when initializing new + /// save data. + /// + private static readonly Dictionary GodseekerOverrides; + + /// + /// The index that corresponds with the Steel Soul variable. + /// + private static readonly ushort SteelSoulIndex; + /// + /// The index that corresponds with the Godseeker variable. + /// + private static readonly ushort GodseekerIndex; + + /// + /// The global save data for the server. E.g. broken walls, open doors, etc. + /// + public Dictionary GlobalSaveData { get; set; } + + /// + /// The player specific save data mapped to player's auth keys. + /// + public Dictionary> PlayerSaveData { get; set; } + + /// + /// Static constructor for initializing the indices for the Steel Soul and Godseeker variables and the Godseeker + /// overrides. + /// + static ServerSaveData() { + if (!SaveDataMapping.Instance.PlayerDataIndices.TryGetValue(SteelSoulVarName, out SteelSoulIndex)) { + Logger.Warn("Could not find index for steel soul variable"); + } + + if (!SaveDataMapping.Instance.PlayerDataIndices.TryGetValue(GodseekerVarName, out GodseekerIndex)) { + Logger.Warn("Could not find index for godseeker variable"); + } + + var deserializedOverrides = FileUtil.LoadObjectFromEmbeddedJson(GodseekerFilePath); + GodseekerOverrides = EncodeUtil.ConvertToServerSaveData(new ModSaveFile.SaveData { + PlayerDataEntries = deserializedOverrides + }); + } + + public ServerSaveData() { + GlobalSaveData = new Dictionary(); + PlayerSaveData = new Dictionary>(); + } + + /// + /// Get save data that contains global save data and player specific save data for the player with the given auth + /// key. + /// + /// The auth key that corresponds to the player for the player specific data. + /// A dictionary mapping save data indices to byte encoded values. + public CurrentSave GetCurrentSaveData(string authKey) { + var currentSave = new CurrentSave(); + + if (!PlayerSaveData.TryGetValue(authKey, out var playerSaveData)) { + if (IsGodseeker()) { + Logger.Debug("Global server data indicates Godseeker mode, adding overrides for new player data"); + // Obtain a new dictionary with the Godseeker overrides + playerSaveData = new Dictionary(GodseekerOverrides); + + // And immediately store it in the player save data dictionary for future use + PlayerSaveData[authKey] = playerSaveData; + } else { + playerSaveData = new Dictionary(); + currentSave.NewForPlayer = true; + + Logger.Debug("No save data for player yet, marking in CurrentSave"); + } + } + + var saveData = new Dictionary(GlobalSaveData); + foreach (var data in playerSaveData) { + saveData[data.Key] = data.Value; + } + + currentSave.SaveData = saveData; + + return currentSave; + } + + /// + /// Whether the global save data in this instance is for Steel Soul mode. + /// + /// True if the save data is for Steel Soul mode, false otherwise. + public bool IsSteelSoul() { + if (!GlobalSaveData.TryGetValue(SteelSoulIndex, out var value)) { + return false; + } + + return value.Length > 0 && value[0] != 0; + } + + /// + /// Whether the global save data in this instance is for Godseeker mode. + /// + /// True if the save data is for Godseeker mode, false otherwise. + private bool IsGodseeker() { + if (!GlobalSaveData.TryGetValue(GodseekerIndex, out var value)) { + return false; + } + + return value.Length > 0 && value[0] == 1; + } +} diff --git a/HKMP/Game/Server/ServerEntityData.cs b/HKMP/Game/Server/ServerEntityData.cs new file mode 100644 index 00000000..24c2b58f --- /dev/null +++ b/HKMP/Game/Server/ServerEntityData.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using Hkmp.Game.Client.Entity; +using Hkmp.Math; +using Hkmp.Networking.Packet.Data; +using JetBrains.Annotations; + +namespace Hkmp.Game.Server; + +/// +/// Class containing all the relevant data managed by the server about an entity. +/// +internal class ServerEntityData { + /// + /// Whether this entity spawned while in a scene already. + /// + public bool Spawned { get; set; } + /// + /// The type of the entity that spawned the new entity. + /// + public EntityType SpawningType { get; set; } + /// + /// The type of the entity that was spawned. + /// + public EntityType SpawnedType { get; set; } + + /// + /// The last position of the entity. + /// + [CanBeNull] + public Vector2 Position { get; set; } + /// + /// The last scale data of the entity. + /// + public EntityUpdate.ScaleData Scale { get; set; } + /// + /// The ID of the last played animation. + /// + public byte? AnimationId { get; set; } + /// + /// The wrap mode of the last played animation. + /// + public byte AnimationWrapMode { get; set; } + /// + /// Whether the entity is active. + /// + public bool? IsActive { get; set; } + + /// + /// Generic data associated with this entity. + /// + public List GenericData { get; } + + /// + /// Host FSM data to keep track of for transferring scene host. + /// + public Dictionary HostFsmData { get; } + + public ServerEntityData() { + Scale = new EntityUpdate.ScaleData(); + GenericData = new List(); + HostFsmData = new Dictionary(); + } +} diff --git a/HKMP/Game/Server/ServerEntityKey.cs b/HKMP/Game/Server/ServerEntityKey.cs new file mode 100644 index 00000000..51db9ad9 --- /dev/null +++ b/HKMP/Game/Server/ServerEntityKey.cs @@ -0,0 +1,46 @@ +using System; + +namespace Hkmp.Game.Server; + +/// +/// A key class that uniquely identifies an entity. +/// +internal class ServerEntityKey : IEquatable { + /// + /// The scene that the entity is in. + /// + public string Scene { get; } + /// + /// The ID of the entity. + /// + public ushort EntityId { get; } + + public ServerEntityKey(string scene, ushort entityId) { + Scene = scene; + EntityId = entityId; + } + + public override bool Equals(object obj) { + if (obj == null || GetType() != obj.GetType()) { + return false; + } + + return Equals((ServerEntityKey) obj); + } + + public bool Equals(ServerEntityKey other) { + if (other == null) { + return false; + } + + return Scene == other.Scene && EntityId == other.EntityId; + } + + public override int GetHashCode() { + unchecked { + var hashCode = Scene != null ? Scene.GetHashCode() : 0; + hashCode = (hashCode * 397) ^ EntityId.GetHashCode(); + return hashCode; + } + } +} diff --git a/HKMP/Game/Server/ServerManager.cs b/HKMP/Game/Server/ServerManager.cs index accf394b..c3b018bf 100644 --- a/HKMP/Game/Server/ServerManager.cs +++ b/HKMP/Game/Server/ServerManager.cs @@ -2,20 +2,25 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Net; using Hkmp.Animation; using Hkmp.Api.Command.Server; using Hkmp.Api.Eventing.ServerEvents; using Hkmp.Api.Server; using Hkmp.Eventing; using Hkmp.Eventing.ServerEvents; +using Hkmp.Game.Client.Entity.Component; +using Hkmp.Game.Client.Save; using Hkmp.Game.Command.Server; using Hkmp.Game.Server.Auth; +using Hkmp.Game.Server.Save; using Hkmp.Game.Settings; using Hkmp.Logging; +using Hkmp.Networking; using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; using Hkmp.Networking.Server; +using Hkmp.Util; namespace Hkmp.Game.Server; @@ -36,16 +41,28 @@ internal abstract class ServerManager : IServerManager { /// private const string AuthorizedFileName = "authorized.json"; + /// + /// The maximum length of a username, for validation purposes in multiple places. + /// + public const int MaxUsernameLength = 20; + /// /// The net server instance. /// private readonly NetServer _netServer; + /// + /// The packet manager instance for register and deregistering packet handlers. + /// + private readonly PacketManager _packetManager; + /// /// Dictionary mapping player IDs to their server player data instances. /// private readonly ConcurrentDictionary _playerData; + private readonly ConcurrentDictionary _entityData; + /// /// The white-list for managing player logins. /// @@ -64,7 +81,7 @@ internal abstract class ServerManager : IServerManager { /// /// The server settings. /// - protected readonly ServerSettings InternalServerSettings; + public readonly ServerSettings InternalServerSettings; /// /// The server command manager instance. @@ -76,6 +93,58 @@ internal abstract class ServerManager : IServerManager { /// protected readonly ServerAddonManager AddonManager; + /// + /// Whether full synchronisation is enabled for the server. + /// + protected bool FullSynchronisation; + + /// + /// The save data for the server. The instance will be created in the constructor and is passed around to other + /// objects. Therefore, it should not change instances. + /// + protected ServerSaveData ServerSaveData; + + #endregion + + #region Internal server manager commands + + /// + /// The list command. + /// + private readonly IServerCommand _listCommand; + /// + /// The whitelist command. + /// + private readonly IServerCommand _whiteListCommand; + /// + /// The authorize command. + /// + private readonly IServerCommand _authorizeCommand; + /// + /// The announce command. + /// + private readonly IServerCommand _announceCommand; + /// + /// The ban command. + /// + private readonly IServerCommand _banCommand; + /// + /// The kick command. + /// + private readonly IServerCommand _kickCommand; + /// + /// The team command. + /// + private readonly IServerCommand _teamCommand; + /// + /// The skin command. + /// + private readonly IServerCommand _skinCommand; + /// + /// The copy save command. + /// + private readonly IServerCommand _copySaveCommand; + #endregion #region IServerManager properties @@ -107,16 +176,18 @@ internal abstract class ServerManager : IServerManager { /// Constructs the server manager. /// /// The net server instance. - /// The server settings. /// The packet manager instance. + /// The server settings. protected ServerManager( NetServer netServer, - ServerSettings serverSettings, - PacketManager packetManager + PacketManager packetManager, + ServerSettings serverSettings ) { _netServer = netServer; + _packetManager = packetManager; InternalServerSettings = serverSettings; _playerData = new ConcurrentDictionary(); + _entityData = new ConcurrentDictionary(); CommandManager = new ServerCommandManager(); var eventAggregator = new EventAggregator(); @@ -124,71 +195,180 @@ PacketManager packetManager var serverApi = new ServerApi(this, CommandManager, _netServer, eventAggregator); AddonManager = new ServerAddonManager(serverApi); + ServerSaveData = new ServerSaveData(); + // Load the lists _whiteList = WhiteList.LoadFromFile(); _authorizedList = AuthKeyList.LoadFromFile(AuthorizedFileName); _banList = BanList.LoadFromFile(); + + _listCommand = new ListCommand(this); + _whiteListCommand = new WhiteListCommand(_whiteList, this); + _authorizeCommand = new AuthorizeCommand(_authorizedList, this); + _announceCommand = new AnnounceCommand(_playerData, _netServer); + _banCommand = new BanCommand(_banList, this); + _kickCommand = new KickCommand(this); + _teamCommand = new TeamCommand(this); + _skinCommand = new SkinCommand(this); + _copySaveCommand = new CopySaveCommand(this, ServerSaveData); + } - // Register packet handlers - packetManager.RegisterServerPacketHandler(ServerPacketId.HelloServer, OnHelloServer); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerEnterScene, - OnClientEnterScene); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerLeaveScene, OnClientLeaveScene); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerUpdate, OnPlayerUpdate); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerMapUpdate, - OnPlayerMapUpdate); - packetManager.RegisterServerPacketHandler(ServerPacketId.EntityUpdate, OnEntityUpdate); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerDisconnect, OnPlayerDisconnect); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerDeath, OnPlayerDeath); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerTeamUpdate, - OnPlayerTeamUpdate); - packetManager.RegisterServerPacketHandler(ServerPacketId.PlayerSkinUpdate, - OnPlayerSkinUpdate); - packetManager.RegisterServerPacketHandler(ServerPacketId.ChatMessage, OnChatMessage); + #region Internal server manager methods + /// + /// Initializes the server manager. + /// + public virtual void Initialize() { // Register a timeout handler _netServer.ClientTimeoutEvent += OnClientTimeout; // Register server shutdown handler _netServer.ShutdownEvent += OnServerShutdown; - // Register a handler for when a client wants to login - _netServer.LoginRequestEvent += OnLoginRequest; + // Register a handler for when a client wants to connect + _netServer.ConnectionRequestEvent += OnConnectionRequest; } - #region Internal server manager methods + /// + /// Register the default server commands. + /// + protected virtual void RegisterCommands() { + CommandManager.RegisterCommand(_listCommand); + CommandManager.RegisterCommand(_whiteListCommand); + CommandManager.RegisterCommand(_authorizeCommand); + CommandManager.RegisterCommand(_announceCommand); + CommandManager.RegisterCommand(_banCommand); + CommandManager.RegisterCommand(_kickCommand); + CommandManager.RegisterCommand(_teamCommand); + CommandManager.RegisterCommand(_skinCommand); + + if (FullSynchronisation) { + CommandManager.RegisterCommand(_copySaveCommand); + } + } - // TODO: move registering packet handler and method hooks in here /// - /// Initializes the server manager. + /// Deregister the default server commands. /// - public void Initialize() { - RegisterCommands(); + protected virtual void DeregisterCommands() { + CommandManager.DeregisterCommand(_listCommand); + CommandManager.DeregisterCommand(_whiteListCommand); + CommandManager.DeregisterCommand(_authorizeCommand); + CommandManager.DeregisterCommand(_announceCommand); + CommandManager.DeregisterCommand(_banCommand); + CommandManager.DeregisterCommand(_kickCommand); + CommandManager.DeregisterCommand(_teamCommand); + CommandManager.DeregisterCommand(_skinCommand); + + if (FullSynchronisation) { + CommandManager.DeregisterCommand(_copySaveCommand); + } } /// - /// Register the default server commands. + /// Register the packet handlers for handling incoming packet data. /// - protected virtual void RegisterCommands() { - CommandManager.RegisterCommand(new ListCommand(this)); - CommandManager.RegisterCommand(new WhiteListCommand(_whiteList, this)); - CommandManager.RegisterCommand(new AuthorizeCommand(_authorizedList, this)); - CommandManager.RegisterCommand(new AnnounceCommand(_playerData, _netServer)); - CommandManager.RegisterCommand(new BanCommand(_banList, this)); - CommandManager.RegisterCommand(new KickCommand(this)); + private void RegisterPacketHandlers() { + Logger.Debug("Registering packet handlers"); + + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerEnterScene, + OnClientEnterScene + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerLeaveScene, + OnClientLeaveScene + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerUpdate, + OnPlayerUpdate + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerMapUpdate, + OnPlayerMapUpdate + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerDisconnect, + OnPlayerDisconnect + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerDeath, + OnPlayerDeath + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.ChatMessage, + OnChatMessage + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.ServerSettings, + OnServerSettingsUpdate + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.PlayerSetting, + OnPlayerSettingUpdate + ); + + if (FullSynchronisation) { + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.EntitySpawn, + OnEntitySpawn + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.EntityUpdate, + OnEntityUpdate + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.ReliableEntityUpdate, + OnReliableEntityUpdate + ); + _packetManager.RegisterServerUpdatePacketHandler( + ServerUpdatePacketId.SaveUpdate, + OnSaveUpdate + ); + } + } + + /// + /// Deregister the packet handlers for handling incoming packet data. + /// + private void DeregisterPacketHandlers() { + Logger.Debug("Deregistering packet handlers"); + + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerEnterScene); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerLeaveScene); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerUpdate); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerMapUpdate); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerDisconnect); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerDeath); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.ChatMessage); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.ServerSettings); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.PlayerSetting); + + if (FullSynchronisation) { + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.EntitySpawn); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.EntityUpdate); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.ReliableEntityUpdate); + _packetManager.DeregisterServerUpdatePacketHandler(ServerUpdatePacketId.SaveUpdate); + } } /// /// Starts a server with the given port. /// /// The port the server should run on. - public void Start(int port) { + /// Whether full synchronisation should be enabled. + public virtual void Start(int port, bool fullSynchronisation) { // Stop existing server if (_netServer.IsStarted) { Logger.Info("Server was running, shutting it down before starting"); _netServer.Stop(); } + FullSynchronisation = fullSynchronisation; + + RegisterCommands(); + RegisterPacketHandlers(); + // Start server again with given port _netServer.Start(port); } @@ -205,8 +385,9 @@ public void Stop() { }); _netServer.Stop(); - } else { - Logger.Info("Could not stop server, it was not started"); + + DeregisterCommands(); + DeregisterPacketHandlers(); } } @@ -229,67 +410,6 @@ public void OnUpdateServerSettings() { _netServer.SetDataForAllClients(updateManager => { updateManager.UpdateServerSettings(InternalServerSettings); }); } - /// - /// Callback method for when HelloServer data is received from a client. - /// - /// The ID of the client. - /// The HelloServer packet data. - private void OnHelloServer(ushort id, HelloServer helloServer) { - Logger.Info($"Received HelloServer data from {id}"); - - // Start by sending the new client the current Server Settings - _netServer.GetUpdateManagerForClient(id)?.UpdateServerSettings(InternalServerSettings); - - if (!_playerData.TryGetValue(id, out var playerData)) { - Logger.Warn($"Could not find player data for {id}"); - return; - } - - // Specifically set the position, scale and animation before current scene so that when we check if current - // scene exists, we have all other data set - playerData.Position = helloServer.Position; - playerData.Scale = helloServer.Scale; - playerData.AnimationId = helloServer.AnimationClipId; - playerData.CurrentScene = helloServer.SceneName; - - var clientInfo = new List<(ushort, string)>(); - - foreach (var idPlayerDataPair in _playerData) { - var otherId = idPlayerDataPair.Key; - if (otherId == id) { - continue; - } - - var otherPd = idPlayerDataPair.Value; - - clientInfo.Add((otherId, otherPd.Username)); - - // If the other player has an active map icon, we also send that to the new player - if (otherPd.HasMapIcon) { - _netServer.GetUpdateManagerForClient(id).UpdatePlayerMapIcon(otherId, true); - if (otherPd.MapPosition != null) { - _netServer.GetUpdateManagerForClient(id).UpdatePlayerMapPosition(otherId, otherPd.MapPosition); - } - } - - // Send to the other players that this client has just connected - _netServer.GetUpdateManagerForClient(otherId)?.AddPlayerConnectData( - id, - playerData.Username - ); - } - - _netServer.GetUpdateManagerForClient(id).SetHelloClientData(clientInfo); - - try { - PlayerConnectEvent?.Invoke(playerData); - } catch (Exception e) { - Logger.Error($"Exception thrown while invoking PlayerConnect event:\n{e}"); - } - - OnClientEnterScene(playerData); - } - /// /// Callback method for when a player enters a scene. /// @@ -333,8 +453,7 @@ private void OnClientEnterScene(ServerPlayerData playerData) { // could be null and result in NREs further down the line if (string.IsNullOrEmpty(playerData.CurrentScene)) { _netServer.GetUpdateManagerForClient(playerData.Id)?.AddPlayerAlreadyInSceneData( - enterSceneList, - true + enterSceneList ); } @@ -379,9 +498,94 @@ private void OnClientEnterScene(ServerPlayerData playerData) { } } + var entitySpawnList = new List(); + var entityUpdateList = new List(); + var reliableEntityUpdateList = new List(); + + if (FullSynchronisation) { + foreach (var keyDataPair in _entityData) { + var entityKey = keyDataPair.Key; + + // Check which entities are actually in the scene that the player is entering + if (!entityKey.Scene.Equals(playerData.CurrentScene)) { + continue; + } + + var entityData = keyDataPair.Value; + if (entityData.Spawned) { + Logger.Info( + $"Sending that entity '{entityKey.EntityId}' has spawned in the scene to '{playerData.Id}'"); + + var entitySpawn = new EntitySpawn { + Id = entityKey.EntityId, + SpawningType = entityData.SpawningType, + SpawnedType = entityData.SpawnedType + }; + + entitySpawnList.Add(entitySpawn); + } + + Logger.Info($"Sending that entity '{entityKey.EntityId}' is already in scene to '{playerData.Id}'"); + + var entityUpdate = new EntityUpdate { + Id = entityKey.EntityId + }; + + if (entityData.Position != null) { + entityUpdate.UpdateTypes.Add(EntityUpdateType.Position); + entityUpdate.Position = entityData.Position; + } + + if (!entityData.Scale.IsEmpty) { + entityUpdate.UpdateTypes.Add(EntityUpdateType.Scale); + entityUpdate.Scale = entityData.Scale; + } + + if (entityData.AnimationId.HasValue) { + entityUpdate.UpdateTypes.Add(EntityUpdateType.Animation); + + entityUpdate.AnimationId = entityData.AnimationId.Value; + entityUpdate.AnimationWrapMode = entityData.AnimationWrapMode; + } + + var reliableEntityUpdate = new ReliableEntityUpdate { + Id = entityKey.EntityId + }; + + if (entityData.IsActive.HasValue) { + reliableEntityUpdate.UpdateTypes.Add(EntityUpdateType.Active); + reliableEntityUpdate.IsActive = entityData.IsActive.Value; + } + + if (entityData.GenericData.Count > 0) { + reliableEntityUpdate.UpdateTypes.Add(EntityUpdateType.Data); + reliableEntityUpdate.GenericData.AddRange(entityData.GenericData); + } + + if (entityData.HostFsmData.Count > 0) { + reliableEntityUpdate.UpdateTypes.Add(EntityUpdateType.HostFsm); + + foreach (var pair in entityData.HostFsmData) { + reliableEntityUpdate.HostFsmData[pair.Key] = pair.Value; + } + } + + entityUpdateList.Add(entityUpdate); + reliableEntityUpdateList.Add(reliableEntityUpdate); + } + + if (!alreadyPlayersInScene) { + Logger.Debug($"No players already in scene, making {playerData.Id} the scene host"); + playerData.IsSceneHost = true; + } + } + _netServer.GetUpdateManagerForClient(playerData.Id)?.AddPlayerAlreadyInSceneData( enterSceneList, - !alreadyPlayersInScene + entitySpawnList, + entityUpdateList, + reliableEntityUpdateList, + FullSynchronisation && !alreadyPlayersInScene ); } @@ -389,39 +593,14 @@ private void OnClientEnterScene(ServerPlayerData playerData) { /// Callback method for when a player leaves a scene. /// /// The ID of the player. - private void OnClientLeaveScene(ushort id) { + /// The leave scene data for the player. + private void OnClientLeaveScene(ushort id, ServerPlayerLeaveScene playerLeaveScene) { if (!_playerData.TryGetValue(id, out var playerData)) { Logger.Warn($"Received LeaveScene data from {id}, but player is not in mapping"); return; } - var sceneName = playerData.CurrentScene; - - if (sceneName.Length == 0) { - Logger.Warn($"Received LeaveScene data from ID {id}, but there was no last scene registered"); - return; - } - - Logger.Info($"Received LeaveScene data from ({id}, {playerData.Username}), last scene: {sceneName}"); - - playerData.CurrentScene = ""; - - foreach (var idPlayerDataPair in _playerData) { - // Skip source player - if (idPlayerDataPair.Key == id) { - continue; - } - - var otherPlayerData = idPlayerDataPair.Value; - - // Send the packet to all clients on the scene that the player left - // to indicate that this client has left their scene - if (otherPlayerData.CurrentScene.Equals(sceneName)) { - Logger.Debug($"Sending leave scene packet to {idPlayerDataPair.Key}"); - - _netServer.GetUpdateManagerForClient(idPlayerDataPair.Key)?.AddPlayerLeaveSceneData(id); - } - } + HandlePlayerLeaveScene(id, false, false, playerLeaveScene.SceneName); try { PlayerLeaveSceneEvent?.Invoke(playerData); @@ -546,6 +725,58 @@ private void OnPlayerMapUpdate(ushort id, PlayerMapUpdate playerMapUpdate) { } } } + + /// + /// Callback method for when an entity spawn is received from a player. + /// + /// The ID of the player. + /// The EntitySpawn packet data. + private void OnEntitySpawn(ushort id, EntitySpawn entitySpawn) { + if (!FullSynchronisation) { + return; + } + + if (!_playerData.TryGetValue(id, out var playerData)) { + Logger.Info($"Received EntitySpawn data, but player with ID {id} is not in mapping"); + return; + } + + // If the player is not the scene host, ignore this data + if (!playerData.IsSceneHost) { + return; + } + + // Create the key for the entity data + var serverEntityKey = new ServerEntityKey( + playerData.CurrentScene, + entitySpawn.Id + ); + + // Check with the created key whether we have an existing entry + if (!_entityData.TryGetValue(serverEntityKey, out var entityData)) { + // If the entry for this entity did not yet exist, we insert a new one + entityData = new ServerEntityData(); + _entityData[serverEntityKey] = entityData; + } + + Logger.Info($"Received EntitySpawn from {id}, with entity {entitySpawn.Id}, {entitySpawn.SpawningType}, {entitySpawn.SpawnedType}"); + + entityData.Spawned = true; + entityData.SpawningType = entitySpawn.SpawningType; + entityData.SpawnedType = entitySpawn.SpawnedType; + + SendDataInSameScene( + id, + playerData.CurrentScene, + otherId => { + _netServer.GetUpdateManagerForClient(otherId)?.SetEntitySpawn( + entitySpawn.Id, + entitySpawn.SpawningType, + entitySpawn.SpawnedType + ); + } + ); + } /// /// Callback method for when an entity update is received from a player. @@ -553,10 +784,27 @@ private void OnPlayerMapUpdate(ushort id, PlayerMapUpdate playerMapUpdate) { /// The ID of the player. /// The EntityUpdate packet data. private void OnEntityUpdate(ushort id, EntityUpdate entityUpdate) { + if (!FullSynchronisation) { + return; + } + if (!_playerData.TryGetValue(id, out var playerData)) { Logger.Warn($"Received EntityUpdate data, but player with ID {id} is not in mapping"); return; } + + // Create the key for the entity data + var serverEntityKey = new ServerEntityKey( + playerData.CurrentScene, + entityUpdate.Id + ); + + // Check with the created key whether we have an existing entry + if (!_entityData.TryGetValue(serverEntityKey, out var entityData)) { + // If the entry for this entity did not yet exist, we insert a new one + entityData = new ServerEntityData(); + _entityData[serverEntityKey] = entityData; + } if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Position)) { SendDataInSameScene( @@ -564,40 +812,148 @@ private void OnEntityUpdate(ushort id, EntityUpdate entityUpdate) { playerData.CurrentScene, otherId => { _netServer.GetUpdateManagerForClient(otherId)?.UpdateEntityPosition( - entityUpdate.EntityType, entityUpdate.Id, entityUpdate.Position ); } ); + + entityData.Position = entityUpdate.Position; + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Scale)) { + SendDataInSameScene( + id, + playerData.CurrentScene, + otherId => { + _netServer.GetUpdateManagerForClient(otherId)?.UpdateEntityScale( + entityUpdate.Id, + entityUpdate.Scale + ); + } + ); + + entityData.Scale.Merge(entityUpdate.Scale); + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Animation)) { + SendDataInSameScene( + id, + playerData.CurrentScene, + otherId => { + _netServer.GetUpdateManagerForClient(otherId)?.UpdateEntityAnimation( + entityUpdate.Id, + entityUpdate.AnimationId, + entityUpdate.AnimationWrapMode + ); + } + ); + + entityData.AnimationId = entityUpdate.AnimationId; + entityData.AnimationWrapMode = entityUpdate.AnimationWrapMode; + } + } + + /// + /// Callback method for when a reliable entity update is received from a player. + /// + /// The ID of the player. + /// The ReliableEntityUpdate packet data. + private void OnReliableEntityUpdate(ushort id, ReliableEntityUpdate entityUpdate) { + if (!FullSynchronisation) { + return; } - if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.State)) { + if (!_playerData.TryGetValue(id, out var playerData)) { + Logger.Warn($"Received ReliableEntityUpdate data, but player with ID {id} is not in mapping"); + return; + } + + // Create the key for the entity data + var serverEntityKey = new ServerEntityKey( + playerData.CurrentScene, + entityUpdate.Id + ); + + // Check with the created key whether we have an existing entry + if (!_entityData.TryGetValue(serverEntityKey, out var entityData)) { + // If the entry for this entity did not yet exist, we insert a new one + entityData = new ServerEntityData(); + _entityData[serverEntityKey] = entityData; + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Active)) { SendDataInSameScene( id, playerData.CurrentScene, otherId => { - _netServer.GetUpdateManagerForClient(otherId)?.UpdateEntityState( - entityUpdate.EntityType, + _netServer.GetUpdateManagerForClient(otherId)?.UpdateEntityIsActive( entityUpdate.Id, - entityUpdate.State + entityUpdate.IsActive ); } ); + + entityData.IsActive = entityUpdate.IsActive; } - if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Variables)) { + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.Data)) { SendDataInSameScene( id, playerData.CurrentScene, otherId => { - _netServer.GetUpdateManagerForClient(otherId)?.UpdateEntityVariables( - entityUpdate.EntityType, + _netServer.GetUpdateManagerForClient(otherId)?.AddEntityData( entityUpdate.Id, - entityUpdate.Variables + entityUpdate.GenericData ); } ); + + void ReplaceExistingDataWithSameType(EntityComponentType type, Packet data) { + var existingData = entityData.GenericData.Find( + d => d.Type == type + ); + if (existingData == null) { + entityData.GenericData.Add(new EntityNetworkData { + Type = type, + Packet = data + }); + } else { + existingData.Packet = data; + } + } + + foreach (var updateData in entityUpdate.GenericData) { + if (updateData.Type > EntityComponentType.Death) { + ReplaceExistingDataWithSameType(updateData.Type, updateData.Packet); + } + } + } + + if (entityUpdate.UpdateTypes.Contains(EntityUpdateType.HostFsm)) { + foreach (var pair in entityUpdate.HostFsmData) { + var fsmIndex = pair.Key; + var data = pair.Value; + + if (!entityData.HostFsmData.TryGetValue(fsmIndex, out var existingData)) { + existingData = new EntityHostFsmData(); + entityData.HostFsmData[fsmIndex] = existingData; + } + + existingData.MergeData(data); + + SendDataInSameScene( + id, + playerData.CurrentScene, + otherId => { + _netServer.GetUpdateManagerForClient(otherId)?.AddEntityHostFsmData( + entityUpdate.Id, + fsmIndex, + data + ); + } + ); + } } } @@ -627,6 +983,101 @@ public void InternalDisconnectPlayer(ushort id, DisconnectReason reason) { ProcessPlayerDisconnect(id); } + /// + /// Handle a player leaving a scene by transition, disconnect or timeout. + /// + /// The ID of the player that left the scene. + /// Whether the player disconnected from the server. + /// Whether the disconnect was due to connection timeout. + /// The name of the scene that the player left (if known from a received packet), or null + /// if no such name is known. + private void HandlePlayerLeaveScene(ushort id, bool disconnected, bool timeout = false, string sceneName = null) { + if (!_playerData.TryGetValue(id, out var playerData)) { + Logger.Warn($"Handling player leave scene (dc: {disconnected}) for ID {id}, but player is not in mapping"); + return; + } + + sceneName ??= playerData.CurrentScene; + + if (!disconnected && sceneName.Length == 0) { + Logger.Warn($"Handling player leave scene for ID {id}, but there was no last scene registered"); + return; + } + + Logger.Info($"Handling player leave scene (dc: {disconnected}) for ID {id}, left scene: {sceneName}"); + + // If the current scene of the player is the one being left, we can set it to an empty string + // It can happen that enter scene data arrives earlier than the leave scene packet, in which case this will + // not be true, and we don't want to set the current scene (which is now already something else) to empty + if (playerData.CurrentScene == sceneName) { + playerData.CurrentScene = ""; + } + + var username = playerData.Username; + + // Keep track of whether the scene that the player has left is now empty + var isSceneNowEmpty = true; + + foreach (var idPlayerDataPair in _playerData) { + if (idPlayerDataPair.Key == id) { + continue; + } + + var otherPlayerData = idPlayerDataPair.Value; + + // Send a packet to all clients in the scene that the player has left their scene + if (otherPlayerData.CurrentScene == sceneName) { + Logger.Info($"Sending leave scene packet to {idPlayerDataPair.Key}"); + + // We have now found at least one player that is still in this scene + isSceneNowEmpty = false; + + var updateManager = _netServer.GetUpdateManagerForClient(idPlayerDataPair.Key); + + // We check if the player is the scene host, which will never be the case if FullSync is disabled + if (playerData.IsSceneHost) { + // If the leaving player was the scene host, we can make this player the new scene host + updateManager.SetSceneHostTransfer(sceneName); + + // Reset the scene host variable in the leaving player, so only a single other player + // becomes the scene host + playerData.IsSceneHost = false; + + // Also set the player data of the new scene host + otherPlayerData.IsSceneHost = true; + + Logger.Info($" {idPlayerDataPair.Key} has become scene host"); + } + + if (disconnected) { + updateManager.AddPlayerDisconnectData(id, username, timeout); + } else { + updateManager.AddPlayerLeaveSceneData(id, sceneName); + } + } + } + + if (FullSynchronisation) { + // In case there were no other players to make scene host, we still need to reset the leaving + // player's status of scene host + playerData.IsSceneHost = false; + + // If the scene is now empty, we can remove all data from stored entities in that scene + if (isSceneNowEmpty) { + foreach (var keyDataPair in _entityData) { + if (keyDataPair.Key.Scene == sceneName) { + _entityData.TryRemove(keyDataPair.Key, out _); + } + } + } + } + + if (disconnected) { + // Now remove the client from the player data mapping + _playerData.TryRemove(id, out _); + } + } + /// /// Process a disconnect for the player with the given ID. /// @@ -658,6 +1109,8 @@ private void ProcessPlayerDisconnect(ushort id, bool timeout = false) { // Now remove the client from the player data mapping _playerData.TryRemove(id, out _); + + HandlePlayerLeaveScene(id, true, timeout); try { PlayerDisconnectEvent?.Invoke(playerData); @@ -678,6 +1131,13 @@ private void OnPlayerDeath(ushort id) { Logger.Info($"Received PlayerDeath data from ({id}, {playerData.Username})"); + if (ServerSaveData.IsSteelSoul()) { + // We are running a Steel Soul save file, so we wipe the player-specific data for the player + ServerSaveData.PlayerSaveData.Remove(playerData.AuthKey); + + Logger.Info(" Wiped player save data (Steel Soul)"); + } + SendDataInSameScene( id, playerData.CurrentScene, @@ -686,63 +1146,103 @@ private void OnPlayerDeath(ushort id) { } /// - /// Callback method for when a player updates their team. + /// Try to update the team for the player with the given ID. /// /// The ID of the player. - /// The ServerPlayerTeamUpdate packet data. - private void OnPlayerTeamUpdate(ushort id, ServerPlayerTeamUpdate teamUpdate) { + /// The team to change the player to. + /// The reason if the team could not be updated, otherwise null. + /// True if the player's team was updated, false otherwise. + public bool TryUpdatePlayerTeam(ushort id, Team team, out string reason) { if (!_playerData.TryGetValue(id, out var playerData)) { Logger.Warn($"Received PlayerTeamUpdate data, but player with ID {id} is not in mapping"); - return; + + reason = "Could not find player"; + return false; } - Logger.Info($"Received PlayerTeamUpdate data from ({id}, {playerData.Username}), new team: {teamUpdate.Team}"); + Logger.Info($"Received PlayerTeamUpdate data from ({id}, {playerData.Username}) for team: {team}"); + + if (!ServerSettings.TeamsEnabled) { + Logger.Info(" Teams are not enabled, won't update team"); + + reason = "Unable to change team"; + return false; + } // Update the team in the player data - playerData.Team = teamUpdate.Team; + playerData.Team = team; // Broadcast the packet to all players except the player we received the update from foreach (var playerId in _playerData.Keys) { if (id == playerId) { + _netServer.GetUpdateManagerForClient(playerId)?.AddPlayerSettingUpdateData(team: team); continue; } - _netServer.GetUpdateManagerForClient(playerId)?.AddPlayerTeamUpdateData( + _netServer.GetUpdateManagerForClient(playerId)?.AddOtherPlayerSettingUpdateData( id, - playerData.Username, - teamUpdate.Team + team: team ); } + + reason = null; + return true; } /// - /// Callback method for when a player updates their skin. + /// Try to update the skin for the player with the given ID. /// /// The ID of the player. - /// The ServerPlayerSkinUpdate packet data. - private void OnPlayerSkinUpdate(ushort id, ServerPlayerSkinUpdate skinUpdate) { + /// The ID of the skin to change the player to. + /// The reason if the skin could not be updated, otherwise null. + /// True if the player's team was updated, false otherwise. + public bool TryUpdatePlayerSkin(ushort id, byte skinId, out string reason) { if (!_playerData.TryGetValue(id, out var playerData)) { Logger.Warn($"Received PlayerSkinUpdate data, but player with ID {id} is not in mapping"); - return; + + reason = "Could not find player"; + return false; } - - if (playerData.SkinId == skinUpdate.SkinId) { - Logger.Debug($"Received PlayerSkinUpdate data from ({id}, {playerData.Username}), but skin was the same"); - return; + + Logger.Info($"Received PlayerSkinUpdate data from ({id}, {playerData.Username}) for skin ID: {skinId}"); + + if (!ServerSettings.AllowSkins) { + Logger.Info(" Skins are not allowed, won't update skin"); + + reason = "Unable to change skin"; + return false; } - Logger.Debug($"Received PlayerSkinUpdate data from ({id}, {playerData.Username}), new skin ID: {skinUpdate.SkinId}"); + if (playerData.SkinId == skinId) { + Logger.Info(" Skins is the same as current, won't update skin"); + + reason = "Skin is already in use"; + return false; + } // Update the skin ID in the player data - playerData.SkinId = skinUpdate.SkinId; - - SendDataInSameScene( - id, - playerData.CurrentScene, - otherId => { - _netServer.GetUpdateManagerForClient(otherId)?.AddPlayerSkinUpdateData(id, playerData.SkinId); + playerData.SkinId = skinId; + + foreach (var idPlayerDataPair in _playerData) { + var otherId = idPlayerDataPair.Key; + + if (otherId == id) { + _netServer.GetUpdateManagerForClient(id)?.AddPlayerSettingUpdateData(skinId: skinId); + continue; } - ); + + var otherPd = idPlayerDataPair.Value; + + // Skip sending skin to players not in the same scene + if (!string.Equals(otherPd.CurrentScene, playerData.CurrentScene)) { + continue; + } + + _netServer.GetUpdateManagerForClient(otherId)?.AddOtherPlayerSettingUpdateData(id, skinId: skinId); + } + + reason = null; + return true; } /// @@ -756,83 +1256,79 @@ private void OnServerShutdown() { /// /// Handle a login request for a client that has invalid addons. /// - /// The update manager for the client. - private void HandleInvalidLoginAddons(ServerUpdateManager updateManager) { - var loginResponse = new LoginResponse { - LoginResponseStatus = LoginResponseStatus.InvalidAddons - }; - loginResponse.AddonData.AddRange(AddonManager.GetNetworkedAddonData()); - - updateManager.SetLoginResponse(loginResponse); + /// The server info instance to send to the client containing the invalid addons + /// result. + private void HandleInvalidLoginAddons(ServerInfo serverInfo) { + serverInfo.ConnectionResult = ServerConnectionResult.InvalidAddons; + serverInfo.AddonData.AddRange(AddonManager.GetNetworkedAddonData()); } /// /// Method for handling a login request for a new client. /// - /// The ID of the client. - /// The IP endpoint of the client. - /// The LoginRequest packet data. - /// The update manager for the client. - /// true if the login request was approved, false otherwise. - private bool OnLoginRequest( - ushort id, - IPEndPoint endPoint, - LoginRequest loginRequest, - ServerUpdateManager updateManager - ) { - Logger.Info($"Received login request from IP: {endPoint.Address}, username: {loginRequest.Username}"); - - if (_banList.IsIpBanned(endPoint.Address.ToString()) || _banList.Contains(loginRequest.AuthKey)) { - updateManager.SetLoginResponse(new LoginResponse { - LoginResponseStatus = LoginResponseStatus.Banned - }); - return false; + /// The net server client that tries to connect. + /// The information from the client. + /// The server info instance to modify based on whether the client should be accepted + /// or not. + private void OnConnectionRequest(NetServerClient netServerClient, ClientInfo clientInfo, ServerInfo serverInfo) { + Logger.Info($"Received connection request from IP: {netServerClient.EndPoint}, username: {clientInfo.Username}"); + + if (_banList.IsIpBanned(netServerClient.EndPoint.Address.ToString()) || _banList.Contains(clientInfo.AuthKey)) { + Logger.Debug(" Client is banned from the server, rejected connection"); + + serverInfo.ConnectionResult = ServerConnectionResult.RejectedOther; + serverInfo.ConnectionRejectedMessage = "Banned from the server"; + return; } if (_whiteList.IsEnabled) { - if (!_whiteList.Contains(loginRequest.AuthKey)) { - if (!_whiteList.IsPreListed(loginRequest.Username)) { - updateManager.SetLoginResponse(new LoginResponse { - LoginResponseStatus = LoginResponseStatus.NotWhiteListed - }); - return false; + if (!_whiteList.Contains(clientInfo.AuthKey)) { + if (!_whiteList.IsPreListed(clientInfo.Username)) { + Logger.Debug(" Client is not whitelisted on the server, rejected connection"); + + serverInfo.ConnectionResult = ServerConnectionResult.RejectedOther; + serverInfo.ConnectionRejectedMessage = "Not whitelisted on the server"; + return; } Logger.Info(" Username was pre-listed, auth key has been added to whitelist"); - _whiteList.Add(loginRequest.AuthKey); - _whiteList.RemovePreList(loginRequest.Username); + _whiteList.Add(clientInfo.AuthKey); + _whiteList.RemovePreList(clientInfo.Username); } } // Check whether the username is valid - if (loginRequest.Username.Length > UsernameMaxLength) { - updateManager.SetLoginResponse(new LoginResponse { - LoginResponseStatus = LoginResponseStatus.InvalidUsername - }); - return false; + if (clientInfo.Username.Length > MaxUsernameLength) { + Logger.Debug(" Client has username that is too long, rejected connection"); + + serverInfo.ConnectionResult = ServerConnectionResult.RejectedOther; + serverInfo.ConnectionRejectedMessage = "Invalid username"; + return; } - foreach (var character in loginRequest.Username) { + foreach (var character in clientInfo.Username) { if (!char.IsLetterOrDigit(character)) { - updateManager.SetLoginResponse(new LoginResponse { - LoginResponseStatus = LoginResponseStatus.InvalidUsername - }); - return false; + Logger.Debug(" Client has invalid characters in username, rejected connection"); + + serverInfo.ConnectionResult = ServerConnectionResult.RejectedOther; + serverInfo.ConnectionRejectedMessage = "Invalid username"; + return; } } // Check whether the username is not already in use foreach (var existingPlayerData in _playerData.Values) { - if (existingPlayerData.Username.ToLower().Equals(loginRequest.Username.ToLower())) { - updateManager.SetLoginResponse(new LoginResponse { - LoginResponseStatus = LoginResponseStatus.InvalidUsername - }); - return false; + if (existingPlayerData.Username.ToLower().Equals(clientInfo.Username.ToLower())) { + Logger.Debug(" Client username is already in use, rejected connection"); + + serverInfo.ConnectionResult = ServerConnectionResult.RejectedOther; + serverInfo.ConnectionRejectedMessage = "Username already in use"; + return; } } - var addonData = loginRequest.AddonData; + var addonData = clientInfo.AddonData; // Construct a string that contains all addons and respective versions by mapping the items in the addon data var addonStringList = string.Join(", ", addonData.Select(addon => $"{addon.Identifier} v{addon.Version}")); @@ -841,8 +1337,10 @@ ServerUpdateManager updateManager // If there is a mismatch between the number of networked addons of the client and the server, // we can immediately invalidate the request if (addonData.Count != AddonManager.GetNetworkedAddonData().Count) { - HandleInvalidLoginAddons(updateManager); - return false; + Logger.Debug(" Client addons are invalid, rejected connection"); + + HandleInvalidLoginAddons(serverInfo); + return; } // Create a byte list denoting the order of the addons on the server @@ -855,10 +1353,12 @@ ServerUpdateManager updateManager addon.Version, out var correspondingServerAddon )) { + Logger.Debug(" Client addons are invalid, rejected connection"); + // There was no corresponding server addon, so we send a login response with an invalid status // and the addon data that is present on the server, so the client knows what is invalid - HandleInvalidLoginAddons(updateManager); - return false; + HandleInvalidLoginAddons(serverInfo); + return; } if (!correspondingServerAddon.Id.HasValue) { @@ -868,25 +1368,59 @@ out var correspondingServerAddon // If the addon is also present on the server, we append the addon order with the correct index addonOrder.Add(correspondingServerAddon.Id.Value); } + + Logger.Debug(" Accepting client connection, preparing server info"); + + // Finally after all the checks, the client is accepted, and we note that in the server info + serverInfo.ConnectionResult = ServerConnectionResult.Accepted; + serverInfo.AddonOrder = addonOrder.ToArray(); - var loginResponse = new LoginResponse { - LoginResponseStatus = LoginResponseStatus.Success, - AddonOrder = addonOrder.ToArray() + serverInfo.ServerSettingsUpdate = new ServerSettingsUpdate { + ServerSettings = InternalServerSettings }; - updateManager.SetLoginResponse(loginResponse); + serverInfo.FullSynchronisation = FullSynchronisation; + + // Construct the player info to send to the new client in the server info + var playerInfo = new List<(ushort, string)>(); + + foreach (var idPlayerDataPair in _playerData) { + var otherId = idPlayerDataPair.Key; + if (otherId == netServerClient.Id) { + continue; + } + + var otherPd = idPlayerDataPair.Value; + + playerInfo.Add((otherId, otherPd.Username)); + + // Send to the other players that this client has just connected + _netServer.GetUpdateManagerForClient(otherId)?.AddPlayerConnectData( + netServerClient.Id, + clientInfo.Username + ); + } + + serverInfo.PlayerInfo = playerInfo; + + // Obtain the save data for the connecting client and add it to the server info + serverInfo.CurrentSave = ServerSaveData.GetCurrentSaveData(clientInfo.AuthKey); // Create new player data and store it var playerData = new ServerPlayerData( - id, - endPoint.Address.ToString(), - loginRequest.Username, - loginRequest.AuthKey, + netServerClient.Id, + netServerClient.EndPoint.ToString(), + clientInfo.Username, + clientInfo.AuthKey, _authorizedList ); - _playerData[id] = playerData; - - return true; + _playerData[netServerClient.Id] = playerData; + + try { + PlayerConnectEvent?.Invoke(playerData); + } catch (Exception e) { + Logger.Error($"Exception thrown while invoking PlayerConnect event:\n{e}"); + } } /// @@ -986,6 +1520,202 @@ private void OnChatMessage(ushort id, ChatMessage chatMessage) { } } + /// + /// Callback method for when a server settings update is received from a player. + /// + /// The ID of the player. + /// The packet data. + private void OnServerSettingsUpdate(ushort id, ServerSettingsUpdate serverSettingsUpdate) { + if (!_playerData.TryGetValue(id, out var playerData)) { + Logger.Debug($"Could not process server settings update from unknown player ID: {id}"); + return; + } + + Logger.Info($"Received server settings update from ({id}, {playerData.Username})"); + + if (!playerData.IsAuthorized) { + Logger.Info(" Player is not authorized"); + + SendMessage(id, "You are not authorized to change server settings"); + _netServer.GetUpdateManagerForClient(id).UpdateServerSettings(InternalServerSettings); + + return; + } + + InternalServerSettings.SetAllProperties(serverSettingsUpdate.ServerSettings); + OnUpdateServerSettings(); + } + + /// + /// Callback method for when a player setting update is received from a player. This can include changes to their + /// team, skin, etc. + /// + /// The ID of the player. + /// The packet data. + private void OnPlayerSettingUpdate(ushort id, ServerPlayerSettingUpdate playerSettingUpdate) { + if (playerSettingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { + if (TryUpdatePlayerTeam(id, playerSettingUpdate.Team, out var reason)) { + SendMessage(id, $"Team changed to '{playerSettingUpdate.Team}'"); + } else { + SendMessage(id, reason); + } + } + + if (playerSettingUpdate.UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { + if (TryUpdatePlayerSkin(id, playerSettingUpdate.SkinId, out var reason)) { + SendMessage(id, $"Skin ID changed to '{playerSettingUpdate.SkinId}'"); + } else { + SendMessage(id, reason); + } + } + } + + /// + /// Callback method for when a save update is received from a player. + /// + /// The ID of the player. + /// The SaveUpdate packet data. + protected virtual void OnSaveUpdate(ushort id, SaveUpdate packet) { + if (!FullSynchronisation) { + return; + } + + if (!_playerData.TryGetValue(id, out var playerData)) { + Logger.Debug($"Could not process save update from unknown player ID: {id}"); + return; + } + + Logger.Info($"Save update from ({id}, {playerData.Username}), index: {packet.SaveDataIndex}"); + + // Find the properties for syncing this save update, based on whether it is a geo rock, player data or + // persistent bool/int item + SaveDataMapping.VarProperties varProps; + string pdVarName = null; + if (SaveDataMapping.Instance.GeoRockIndices.TryGetValue(packet.SaveDataIndex, out var persistentItemData)) { + Logger.Debug($" Found GeoRockData: {persistentItemData.Id}, {persistentItemData.SceneName}"); + + if (!SaveDataMapping.Instance.GeoRockBools.TryGetValue(persistentItemData, out _)) { + return; + } + + varProps = new SaveDataMapping.VarProperties { + Sync = true, + SyncType = SaveDataMapping.SyncType.Server, + IgnoreSceneHost = false + }; + } else if (SaveDataMapping.Instance.PlayerDataIndices.TryGetValue(packet.SaveDataIndex, out pdVarName)) { + Logger.Debug($" Found PlayerData: {pdVarName}"); + + if (!SaveDataMapping.Instance.PlayerDataVarProperties.TryGetValue(pdVarName, out varProps)) { + return; + } + } else if (SaveDataMapping.Instance.PersistentBoolIndices.TryGetValue( + packet.SaveDataIndex, + out persistentItemData) + ) { + Logger.Debug($" Found PersistentBoolData: {persistentItemData.Id}, {persistentItemData.SceneName}"); + + if (!SaveDataMapping.Instance.PersistentBoolVarProperties.TryGetValue(persistentItemData, out varProps)) { + return; + } + } else if (SaveDataMapping.Instance.PersistentIntIndices.TryGetValue( + packet.SaveDataIndex, + out persistentItemData) + ) { + Logger.Debug($" Found PersistentIntData: {persistentItemData.Id}, {persistentItemData.SceneName}"); + + if (!SaveDataMapping.Instance.PersistentIntVarProperties.TryGetValue(persistentItemData, out varProps)) { + return; + } + } else { + Logger.Debug(" Could not find sync props for save update"); + return; + } + + // Check whether this save update requires the player to be scene host and do the check for it + if (!varProps.IgnoreSceneHost && !playerData.IsSceneHost) { + Logger.Debug(" Player is not scene host, but should be for update, not broadcasting"); + return; + } + + if (varProps.SyncType == SaveDataMapping.SyncType.Player) { + Logger.Debug(" SyncType is Player"); + + if (!ServerSaveData.PlayerSaveData.TryGetValue(playerData.AuthKey, out var playerSaveData)) { + Logger.Debug(" No PlayerSaveData for player yet, creating one"); + playerSaveData = new Dictionary(); + ServerSaveData.PlayerSaveData[playerData.AuthKey] = playerSaveData; + } + + Logger.Debug(" Storing player data"); + + playerSaveData[packet.SaveDataIndex] = packet.Value; + } else if (varProps.SyncType == SaveDataMapping.SyncType.Server) { + if (varProps.Additive) { + if (pdVarName == null) { + Logger.Debug(" Cannot decode value, name for variable is null"); + return; + } + + object decodedCurrentValue = null; + var decodedDeltaValue = EncodeUtil.DecodeSaveDataValue(pdVarName, packet.Value); + + if (!ServerSaveData.GlobalSaveData.TryGetValue(packet.SaveDataIndex, out var currentValue)) { + Logger.Debug($"No current value is stored in the global save data for: {pdVarName}"); + + if (varProps.InitialValue != null) { + Logger.Debug($" Taking initial value: {varProps.InitialValue}"); + decodedCurrentValue = varProps.InitialValue; + } else { + Logger.Debug(" No initial value defined, using delta as absolute"); + packet.Value = EncodeUtil.EncodeSaveDataValue(decodedDeltaValue); + } + } else { + decodedCurrentValue = EncodeUtil.DecodeSaveDataValue(pdVarName, currentValue); + } + + if (decodedCurrentValue != null) { + object decodedNewValue; + + if (decodedCurrentValue is int decodedCurrentInt && decodedDeltaValue is int decodedDeltaInt) { + decodedNewValue = decodedCurrentInt + decodedDeltaInt; + } else if (decodedCurrentValue is List decodedCurrentStringList && + decodedDeltaValue is List decodedDeltaStringList) { + + // Loop over the delta list and add only non-duplicates + foreach (var str in decodedDeltaStringList) { + if (!decodedCurrentStringList.Contains(str)) { + decodedCurrentStringList.Add(str); + } + } + + decodedNewValue = decodedCurrentStringList; + } else { + Logger.Debug($" Type of decoded values did not match: {decodedCurrentValue.GetType()}"); + return; + } + + packet.Value = EncodeUtil.EncodeSaveDataValue(decodedNewValue); + } + } + + Logger.Debug(" SyncType is Server, broadcasting save update"); + + ServerSaveData.GlobalSaveData[packet.SaveDataIndex] = packet.Value; + + foreach (var idPlayerDataPair in _playerData) { + var otherId = idPlayerDataPair.Key; + // For additive properties, it might happen (due to race conditions) that the resulting value needs to + // be sent to the sender of this packet as well + if (id == otherId && !varProps.Additive) { + continue; + } + + _netServer.GetUpdateManagerForClient(otherId).SetSaveUpdate(packet.SaveDataIndex, packet.Value); + } + } + } + #endregion #region IServerManager methods diff --git a/HKMP/Game/Server/ServerPlayerData.cs b/HKMP/Game/Server/ServerPlayerData.cs index 94c25592..26cd7f92 100644 --- a/HKMP/Game/Server/ServerPlayerData.cs +++ b/HKMP/Game/Server/ServerPlayerData.cs @@ -44,6 +44,11 @@ internal class ServerPlayerData : IServerPlayer { /// public byte SkinId { get; set; } + + /// + /// Whether this player is the host of their current scene. + /// + public bool IsSceneHost { get; set; } /// /// Reference of the authorized list for checking whether this player is authorized. diff --git a/HKMP/Game/Settings/ModSettings.cs b/HKMP/Game/Settings/ModSettings.cs index fc92a93d..7daa3ab2 100644 --- a/HKMP/Game/Settings/ModSettings.cs +++ b/HKMP/Game/Settings/ModSettings.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; +using Hkmp.Menu; +using Modding.Converters; using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using UnityEngine; namespace Hkmp.Game.Settings; @@ -12,19 +12,13 @@ internal class ModSettings { /// /// The authentication key for the user. /// - public string AuthKey { get; set; } = null; + public string AuthKey { get; set; } /// - /// The key to hide the HKMP UI. + /// The keybinds for HKMP. /// - [JsonConverter(typeof(StringEnumConverter))] - public KeyCode HideUiKey { get; set; } = KeyCode.RightAlt; - - /// - /// The key to open the chat. - /// - [JsonConverter(typeof(StringEnumConverter))] - public KeyCode OpenChatKey { get; set; } = KeyCode.T; + [JsonConverter(typeof(PlayerActionSetConverter))] + public Keybinds Keybinds { get; set; } = new(); /// /// The last used address to join a server. @@ -47,14 +41,16 @@ internal class ModSettings { public bool DisplayPing { get; set; } /// - /// Whether to automatically connect to the server when starting hosting. + /// Set of addon names for addons that are disabled by the user. /// - public bool AutoConnectWhenHosting { get; set; } = true; + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global + public HashSet DisabledAddons { get; set; } = []; /// - /// Set of addon names for addons that are disabled by the user. + /// Whether full synchronisation of bosses, enemies, worlds, and saves is enabled. /// - public HashSet DisabledAddons { get; set; } = new(); + // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global + public bool FullSynchronisation { get; set; } /// /// The last used server settings in a hosted server. diff --git a/HKMP/Game/Settings/ServerSettings.cs b/HKMP/Game/Settings/ServerSettings.cs index 0d4929c6..504c249d 100644 --- a/HKMP/Game/Settings/ServerSettings.cs +++ b/HKMP/Game/Settings/ServerSettings.cs @@ -1,5 +1,7 @@ using System; using Hkmp.Api.Server; +using Hkmp.Menu; + // ReSharper disable UnusedAutoPropertyAccessor.Global // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global // ReSharper disable StringLiteralTypo @@ -10,94 +12,117 @@ namespace Hkmp.Game.Settings; public class ServerSettings : IServerSettings, IEquatable { /// [SettingAlias("pvp")] + [ModMenuSetting("PvP", "Player versus Player damage")] public bool IsPvpEnabled { get; set; } /// [SettingAlias("bodydamage")] + [ModMenuSetting("Body Damage", "If PvP is on, whether player hitboxes do damage")] public bool IsBodyDamageEnabled { get; set; } = true; /// [SettingAlias("globalmapicons")] + [ModMenuSetting("Global Map Icons", "Always show map icons for all players")] public bool AlwaysShowMapIcons { get; set; } /// [SettingAlias("compassicon", "compassicons", "waywardicon", "waywardicons")] + [ModMenuSetting("Wayward Compass Map Icons", "Only show map icons when Wayward Compass is equipped")] public bool OnlyBroadcastMapIconWithWaywardCompass { get; set; } = true; /// [SettingAlias("names")] + [ModMenuSetting("Show Names", "Show names of player above their characters")] public bool DisplayNames { get; set; } = true; /// [SettingAlias("teams")] + [ModMenuSetting("Teams", "Whether players can join teams")] public bool TeamsEnabled { get; set; } /// [SettingAlias("skins")] + [ModMenuSetting("Skins", "Whether players can have skins")] public bool AllowSkins { get; set; } = true; /// [SettingAlias("parries")] + [ModMenuSetting("Parries", "Whether parrying certain player attacks is possible")] public bool AllowParries { get; set; } = true; /// [SettingAlias("naildmg")] + [ModMenuSetting("Nail Damage", "The number of masks of damage that a player's nail swing deals")] public byte NailDamage { get; set; } = 1; /// [SettingAlias("elegydmg")] + [ModMenuSetting("Grubberfly's Elegy Damage", "The number of masks of damage that Grubberfly's Elegy deals")] public byte GrubberflyElegyDamage { get; set; } = 1; /// [SettingAlias("vsdmg", "fireballdamage", "fireballdmg")] + [ModMenuSetting("Vengeful Spirit Damage", "The number of masks of damage that Vengeful Spirit deals")] public byte VengefulSpiritDamage { get; set; } = 1; /// [SettingAlias("shadesouldmg")] + [ModMenuSetting("Shade Soul Damage", "The number of masks of damage that Shade Soul deals")] public byte ShadeSoulDamage { get; set; } = 2; /// [SettingAlias("desolatedivedmg", "ddivedmg")] + [ModMenuSetting("Desolate Dive Damage", "The number of masks of damage that Desolate Dive deals")] public byte DesolateDiveDamage { get; set; } = 1; /// [SettingAlias("descendingdarkdmg", "ddarkdmg")] + [ModMenuSetting("Descending Dark Damage", "The number of masks of damage that Descending Dark deals")] public byte DescendingDarkDamage { get; set; } = 2; /// [SettingAlias("howlingwraithsdamage", "howlingwraithsdmg", "wraithsdmg")] + [ModMenuSetting("Howling Wraiths Damage", "The number of masks of damage that Howling Wraiths deals")] public byte HowlingWraithDamage { get; set; } = 1; /// [SettingAlias("abyssshriekdmg", "shriekdmg")] + [ModMenuSetting("Abyss Shriek Damage", "The number of masks of damage that Abyss Shriek deals")] public byte AbyssShriekDamage { get; set; } = 2; /// [SettingAlias("greatslashdmg")] + [ModMenuSetting("Great Slash Damage", "The number of masks of damage that Great Slash deals")] public byte GreatSlashDamage { get; set; } = 2; /// [SettingAlias("dashslashdmg")] + [ModMenuSetting("Dash Slash Damage", "The number of masks of damage that Dash Slash deals")] public byte DashSlashDamage { get; set; } = 2; /// [SettingAlias("cycloneslashdmg", "cyclonedmg")] + [ModMenuSetting("Cyclone Slash Damage", "The number of masks of damage that Cyclone Slash deals")] public byte CycloneSlashDamage { get; set; } = 1; /// [SettingAlias("sporeshroomdmg")] + [ModMenuSetting("Spore Shroom Damage", "The number of masks of damage that a Spore Shroom cloud deals")] public byte SporeShroomDamage { get; set; } = 1; /// [SettingAlias("sporedungshroomdmg", "dungshroomdmg")] + [ModMenuSetting("Spore-Dung Shroom Damage", "The number of masks of damage that a Spore Shroom cloud with Defender's Crest deals")] public byte SporeDungShroomDamage { get; set; } = 1; /// [SettingAlias("thornsofagonydamage", "thornsofagonydmg", "thornsdamage", "thornsdmg")] + [ModMenuSetting("Thorns of Agongy Damage", "The number of masks of damage that the Thorns of Agony lash deals")] public byte ThornOfAgonyDamage { get; set; } = 1; /// [SettingAlias("sharpshadowdmg")] + [ModMenuSetting("Sharp Shadow Damage", "The number of masks of damage that a Sharp Shadow dash deals")] public byte SharpShadowDamage { get; set; } = 1; /// @@ -116,6 +141,17 @@ public void SetAllProperties(ServerSettings serverSettings) { } } + /// + /// Get a copy of this instance of the server settings. + /// + /// A new instance of the server settings with the same values as this instance. + public ServerSettings GetCopy() { + var serverSettings = new ServerSettings(); + serverSettings.SetAllProperties(this); + + return serverSettings; + } + /// public bool Equals(ServerSettings other) { if (ReferenceEquals(null, other)) { diff --git a/HKMP/HKMP.csproj b/HKMP/HKMP.csproj index 07a84e96..d3638ed4 100644 --- a/HKMP/HKMP.csproj +++ b/HKMP/HKMP.csproj @@ -1,6 +1,6 @@  - - + + {F34118B2-515D-4C33-88E6-9CFEF2AD5A15} @@ -13,12 +13,18 @@ - + - - + + + + + + + + @@ -34,6 +40,18 @@ $(References)\MMHOOK_PlayMaker.dll False + + $(References)\MonoMod.RuntimeDetour.dll + False + + + $(References)\MonoMod.Utils.dll + False + + + $(References)\Mono.Cecil.dll + False + $(References)\Newtonsoft.Json.dll False @@ -82,19 +100,16 @@ $(References)\UnityEngine.UIModule.dll False - - $(References)\MonoMod.Utils.dll - False - + - - + + - + diff --git a/HKMP/HkmpMod.cs b/HKMP/HkmpMod.cs index 45489cbf..64f3910b 100644 --- a/HKMP/HkmpMod.cs +++ b/HKMP/HkmpMod.cs @@ -1,6 +1,8 @@ using System.Collections.Generic; +using Hkmp.Game.Server.Save; using Hkmp.Game.Settings; using Hkmp.Logging; +using Hkmp.Menu; using Hkmp.Util; using Modding; using UnityEngine; @@ -11,7 +13,7 @@ namespace Hkmp; /// /// Mod class for the HKMP mod. /// -internal class HkmpMod : Mod, IGlobalSettings { +internal class HkmpMod : Mod, IGlobalSettings, ILocalSettings, ICustomMenuMod { /// /// Dictionary containing preloaded objects by scene name and object path. /// @@ -22,6 +24,16 @@ internal class HkmpMod : Mod, IGlobalSettings { /// private ModSettings _modSettings = new ModSettings(); + /// + /// The game manager instance. + /// + private Game.GameManager _gameManager; + + /// + /// The HKMP mod menu. + /// + private ModMenu _modMenu; + /// /// Construct the HKMP mod. /// @@ -35,10 +47,10 @@ public override string GetVersion() { /// public override List<(string, string)> GetPreloadNames() { - return new List<(string, string)> { + return [ ("GG_Sly", "Battle Scene/Sly Boss/Cyclone Tink"), ("GG_Sly", "Battle Scene/Sly Boss/S1") - }; + ]; } /// @@ -55,7 +67,15 @@ public override void Initialize(Dictionary(); - var gameManager = new Game.GameManager(_modSettings); + _gameManager = new Game.GameManager(_modSettings); + + _modMenu = new ModMenu( + _modSettings, + _gameManager.ClientManager, + _gameManager.ServerManager, + _gameManager.NetClient + ); + _modMenu.Initialize(); } /// @@ -67,4 +87,22 @@ public void OnLoadGlobal(ModSettings modSettings) { public ModSettings OnSaveGlobal() { return _modSettings; } + + /// + public void OnLoadLocal(ModSaveFile modSaveFile) { + _gameManager?.ServerManager?.OnLoadLocal(modSaveFile); + } + + /// + public ModSaveFile OnSaveLocal() { + return _gameManager.ServerManager.OnSaveLocal(); + } + + /// + public MenuScreen GetMenuScreen(MenuScreen modListMenu, ModToggleDelegates? toggleDelegates) { + return _modMenu.CreateMenu(modListMenu); + } + + /// + public bool ToggleButtonInsideMenu => true; } diff --git a/HKMP/Math/Vector3.cs b/HKMP/Math/Vector3.cs index ba97ebea..c31194d7 100644 --- a/HKMP/Math/Vector3.cs +++ b/HKMP/Math/Vector3.cs @@ -1,22 +1,27 @@ +using Newtonsoft.Json; + namespace Hkmp.Math; /// -/// Class for three dimensional vectors. +/// Class for three-dimensional vectors. /// public class Vector3 { /// /// The X coordinate of this vector. /// + [JsonProperty("x")] public float X { get; set; } /// /// The Y coordinate of this vector. /// + [JsonProperty("y")] public float Y { get; set; } /// /// The Z coordinate of this vector. /// + [JsonProperty("z")] public float Z { get; set; } /// diff --git a/HKMP/Menu/Keybinds.cs b/HKMP/Menu/Keybinds.cs new file mode 100644 index 00000000..7bde366f --- /dev/null +++ b/HKMP/Menu/Keybinds.cs @@ -0,0 +1,18 @@ +using InControl; + +namespace Hkmp.Menu; + +/// +/// Class that stores keybinds for HKMP, to allow them to be (de)serialized to the settings file. +/// +internal class Keybinds : PlayerActionSet { + /// + /// Keybind to open the chat. + /// + public PlayerAction OpenChat { get; } + + public Keybinds() { + OpenChat = CreatePlayerAction("OpenChat"); + OpenChat.AddDefaultBinding(Key.T); + } +} diff --git a/HKMP/Menu/ModMenu.cs b/HKMP/Menu/ModMenu.cs new file mode 100644 index 00000000..c6460a1e --- /dev/null +++ b/HKMP/Menu/ModMenu.cs @@ -0,0 +1,569 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using Hkmp.Game; +using Hkmp.Game.Client; +using Hkmp.Game.Server; +using Hkmp.Game.Settings; +using Hkmp.Networking.Client; +using Hkmp.Util; +using Modding; +using Modding.Menu; +using Modding.Menu.Config; +using UnityEngine; +using UnityEngine.UI; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Menu; + +/// +/// Class for building the HKMP mod menu. +/// +internal class ModMenu { + /// + /// The time in seconds that a modified setting needs to be stopped being modified by the user before being + /// applied. + /// + private const float SettingApplyDelay = 1.5f; + + /// + /// The HKMP mod settings instance. + /// + private readonly ModSettings _modSettings; + + /// + /// The client manager instance. + /// + private readonly ClientManager _clientManager; + + /// + /// The server manager instance. + /// + private readonly ServerManager _serverManager; + + /// + /// The net client instance. + /// + private readonly NetClient _netClient; + + /// + /// List of callbacks that should be fired if the server settings change. + /// + private readonly List> _serverSettingsChangedCallbacks; + + /// + /// The top-level HKMP mod menu. + /// + private MenuScreen _hkmpMenu; + /// + /// The menu containing the client settings. Needs to be a static variable here to allow it to be accessed by + /// lambdas and modified. + /// + private MenuScreen _clientSettingsMenu; + + /// + /// The menu containing the server settings. Needs to be a static variable here to allow it to be accessed by + /// lambdas and modified. + /// + private MenuScreen _serverSettingsMenu; + + /// + /// A local copy of the server settings for modification through the menu that will be used to either network to + /// the server or modify our own hosted servers. + /// + private ServerSettings _localServerSettings; + + /// + /// Coroutine that delays applying new server settings until no more changes are made within a certain time period. + /// + private Coroutine _delayedApplyServerSettingsRoutine; + + /// + /// Coroutine that delays applying the team setting until no more changes are made within a certain time period. + /// + private Coroutine _delayedApplyTeamRoutine; + + /// + /// Coroutine that delays applying the skin setting until no more changes are made within a certain time period. + /// + private Coroutine _delayedApplySkinRoutine; + + /// + /// The horizontal option for changing teams. + /// + private MenuOptionHorizontal _teamHorizontalOption; + + /// + /// The horizontal option for changing skins. + /// + private MenuOptionHorizontal _skinHorizontalOption; + + /// + /// Whether the horizontal option for changing teams is enabled (as in, whether the user can modify their team). + /// + private bool _teamHorizontalOptionEnabled; + + /// + /// Whether the horizontal option for changing skins is enabled (as in, whether the user can modify their skin). + /// + private bool _skinHorizontalOptionEnabled; + + /// + /// Constructs the mod menu class for HKMP. + /// + /// The mod settings for HKMP. + /// The client manager to register a callback for when server settings are updated. + /// + /// The server manager to get the initial server settings and update them if we are + /// not connected to a server. + /// The net client to network changes to the server settings if we are connected to a + /// server. + public ModMenu( + ModSettings modSettings, + ClientManager clientManager, + ServerManager serverManager, + NetClient netClient + ) { + _modSettings = modSettings; + _clientManager = clientManager; + _serverManager = serverManager; + _netClient = netClient; + + _serverSettingsChangedCallbacks = []; + } + + /// + /// Initialize the mod menu by registering hooks. + /// + public void Initialize() { + _netClient.ConnectEvent += _ => { + OnClientConnectionChange(true); + }; + _netClient.DisconnectEvent += () => { + OnClientConnectionChange(false); + }; + _clientManager.TeamChangedEvent += team => { + _teamHorizontalOption?.SetOptionTo((int) team); + }; + _clientManager.SkinChangedEvent += skinId => { + _skinHorizontalOption?.SetOptionTo(skinId); + }; + _clientManager.ServerSettingsChangedEvent += newSettings => { + if (newSettings.TeamsEnabled != _teamHorizontalOptionEnabled) { + ModifyTeamHorizontalOption(newSettings.TeamsEnabled); + } + + if (newSettings.AllowSkins != _skinHorizontalOptionEnabled) { + ModifySkinHorizontalOption(newSettings.AllowSkins); + } + + foreach (var action in _serverSettingsChangedCallbacks) { + action.Invoke(newSettings); + } + }; + } + + /// + /// Create the mod menu for HKMP. This consists of client-side settings (such as HUD elements and keybinds) and + /// server settings. + /// + /// The MenuScreen for the mod list menu to return to. + /// The built HKMP menu screen. + /// Thrown when the menu could not be created due to missing + /// implementation for a type in the server settings. + public MenuScreen CreateMenu(MenuScreen modListMenu) { + var builder = MenuUtils.CreateMenuBuilderWithBackButton("HKMP", modListMenu, out _); + + builder.AddContent( + RegularGridLayout.CreateVerticalLayout(150f), + c => { + c.AddMenuButton("Client Settings", new MenuButtonConfig { + Label = "Client Settings", + SubmitAction = _ => UIManager.instance.UIGoToDynamicMenu(_clientSettingsMenu), + Proceed = true, + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(modListMenu), + Description = new DescriptionInfo { + Text = "Menu for changing the settings of the client" + } + }); + + c.AddMenuButton("Server Settings", new MenuButtonConfig { + Label = "Server Settings", + SubmitAction = _ => UIManager.instance.UIGoToDynamicMenu(_serverSettingsMenu), + Proceed = true, + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(modListMenu), + Description = new DescriptionInfo { + Text = "Menu for changing the settings of the server for authorized players" + } + }); + } + ); + + _hkmpMenu = builder.Build(); + + _clientSettingsMenu = CreateClientSettingsMenu(); + _serverSettingsMenu = CreateServerSettingsMenu(); + + return _hkmpMenu; + } + + /// + /// Create the client settings menu. + /// + /// A for the client settings menu. + private MenuScreen CreateClientSettingsMenu() { + var builder = MenuUtils.CreateMenuBuilderWithBackButton("HKMP Client Settings", _hkmpMenu, out _); + + builder.AddContent( + RegularGridLayout.CreateVerticalLayout(150f), + c => { + c.AddHorizontalOption( + "TeamOption", + new HorizontalOptionConfig { + Options = Enum.GetNames(typeof(Team)), + Label = "Team", + ApplySetting = (_, _) => { }, + RefreshSetting = (s, _) => s.optionList.SetOptionTo((int) _clientManager.Team), + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(_hkmpMenu), + Description = new DescriptionInfo { + Text = "" + } + }, + out _teamHorizontalOption + ); + ModifyTeamHorizontalOption(false); + + var skinOptions = new string[256]; + for (var i = 0; i < 256; i++) { + skinOptions[i] = i.ToString(); + } + + c.AddHorizontalOption( + "SkinOption", + new HorizontalOptionConfig { + Options = skinOptions, + Label = "Skin", + ApplySetting = (_, _) => { }, + RefreshSetting = (s, _) => s.optionList.SetOptionTo(0), + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(_hkmpMenu), + Description = new DescriptionInfo { + Text = "" + } + }, + out _skinHorizontalOption + ); + ModifySkinHorizontalOption(false); + + MenuUtils.AddModMenuContent( + [ + new IMenuMod.MenuEntry { + Name = "Full Synchronisation", + Description = "Synchronise enemies, bosses, world changes, and saves in multiplayer games", + Values = ["Off", "On"], + Saver = index => _modSettings.FullSynchronisation = index == 1, + Loader = () => _modSettings.FullSynchronisation ? 1 : 0 + }, + new IMenuMod.MenuEntry { + Name = "Ping Display", + Description = "HUD element that shows the player's ping in multiplayer games", + Values = ["Off", "On"], + Saver = index => _modSettings.DisplayPing = index == 1, + Loader = () => _modSettings.DisplayPing ? 1 : 0 + } + ], + c, + _hkmpMenu + ); + + c.AddKeybind( + "OpenChatKeybind", + _modSettings.Keybinds.OpenChat, + new KeybindConfig { + Label = "Key to open the chat", + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(_hkmpMenu) + } + ); + } + ); + + return builder.Build(); + } + + /// + /// Create the server settings menu. + /// + /// A for the server settings menu. + /// Thrown when the menu could not be created due to missing + /// implementation for a type in the server settings. + private MenuScreen CreateServerSettingsMenu() { + _serverSettingsChangedCallbacks.Clear(); + + // We keep track of an instance of server settings specifically for this mod menu + // This will initially be a copy of the server manager settings, which come from the mod settings + _localServerSettings = _serverManager.InternalServerSettings.GetCopy(); + + var serverSettingsProps = typeof(ServerSettings).GetProperties(); + + var builder = MenuUtils.CreateMenuBuilderWithBackButton( + "HKMP Server Settings", + _hkmpMenu, + out var serverSettingsBackButton + ); + + builder.AddContent(new NullContentLayout(), nullContentArea => { + nullContentArea.AddScrollPaneContent( + new ScrollbarConfig { + Navigation = new Navigation { + mode = Navigation.Mode.Explicit, + selectOnUp = serverSettingsBackButton, + selectOnDown = serverSettingsBackButton + }, + Position = new AnchoredPosition { + ChildAnchor = new Vector2(0.0f, 1f), + ParentAnchor = new Vector2(1f, 1f), + Offset = new Vector2(-310f, 0.0f) + }, + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(_hkmpMenu) + }, + new RelLength(serverSettingsProps.Length * 150f), + RegularGridLayout.CreateVerticalLayout(150f), + scrollGridContentArea => { + foreach (var propInfo in serverSettingsProps) { + var name = propInfo.Name; + var type = propInfo.PropertyType; + + // Define variables for the eventual creation of an individual setting that depend on the + // type of the setting (for example, the options will be different for a byte than for a bool) + string[] options; + Action saver; + Func loader; + // This action will be invoked whenever the server settings are changed externally, so outside + // the mod menu + Action serverSettingsChangedAction; + + if (type == typeof(bool)) { + options = ["Off", "On"]; + + saver = i => { + ReflectionHelper.SetProperty(_localServerSettings, name, i == 1); + HandleUpdateServerSettings(); + }; + loader = () => ReflectionHelper.GetProperty(_localServerSettings, name) + ? 1 + : 0; + + serverSettingsChangedAction = (newSettings, horizontalOptionToChange) => { + // Get the old and new values and check whether there is a change + var oldValue = ReflectionHelper.GetProperty(_localServerSettings, name); + var newValue = ReflectionHelper.GetProperty(newSettings, name); + + if (oldValue != newValue) { + // Set the mod menu option and update our local server settings instance + horizontalOptionToChange.SetOptionTo(newValue ? 1 : 0); + ReflectionHelper.SetProperty(_localServerSettings, name, newValue); + } + }; + } else if (type == typeof(byte) && name.EndsWith("Damage")) { + // If the field is for the amount of damage for something, we fill the values with 0 through 20 + options = new string[21]; + for (var i = 0; i <= 20; i++) { + options[i] = i.ToString(); + } + + saver = i => { + ReflectionHelper.SetProperty(_localServerSettings, name, (byte) i); + HandleUpdateServerSettings(); + }; + loader = () => ReflectionHelper.GetProperty(_localServerSettings, name); + + serverSettingsChangedAction = (newSettings, horizontalOptionToChange) => { + // Get the old and new values and check whether there is a change + var oldValue = ReflectionHelper.GetProperty(_localServerSettings, name); + var newValue = ReflectionHelper.GetProperty(newSettings, name); + + if (oldValue != newValue) { + // Set the mod menu option and update our local server settings instance + horizontalOptionToChange.SetOptionTo(newValue); + ReflectionHelper.SetProperty(_localServerSettings, name, newValue); + } + }; + } else { + throw new InvalidOperationException( + $"Could not make menu entry for unknown field type: {type}, for field: {name}"); + } + + // Try to obtain the label and description of the setting using the mod menu attribute + var label = name; + DescriptionInfo? descriptionInfo = null; + var menuSettingAttr = propInfo.GetCustomAttribute(); + if (menuSettingAttr != null) { + label = menuSettingAttr.Name; + if (menuSettingAttr.Description != null) { + descriptionInfo = new DescriptionInfo { + Text = menuSettingAttr.Description + }; + } + } + + scrollGridContentArea.AddHorizontalOption( + name, + new HorizontalOptionConfig { + Options = options, + Label = label, + ApplySetting = (_, i) => saver.Invoke(i), + RefreshSetting = (s, _) => s.optionList.SetOptionTo(loader.Invoke()), + CancelAction = _ => UIManager.instance.UIGoToDynamicMenu(_hkmpMenu), + Description = descriptionInfo + }, + out var horizontalOption + ); + horizontalOption.menuSetting.RefreshValueFromGameSettings(); + + _serverSettingsChangedCallbacks.Add(newSettings => { + serverSettingsChangedAction.Invoke(newSettings, horizontalOption); + }); + } + } + ); + }); + + return builder.Build(); + } + + /// + /// Run the given action after a delay (defined in ) and store the delayed execution coroutine in the referenced + /// variable. If this method is called again with the same referenced coroutine before the action is executed, + /// it will be cancelled and a new delayed execution is scheduled. + /// Can be used to apply the changing of certain settings only if the user stops modifying the setting for a + /// certain period of time. + /// + /// The action to execute delayed if this method is not called again within a certain period + /// of time. + /// The coroutine containing the previous delayed execution of the action. This variable + /// will be used to store the new coroutine. + private void RunActionWhenNoChanges(Action action, ref Coroutine coroutine) { + if (coroutine != null) { + MonoBehaviourUtil.Instance.StopCoroutine(coroutine); + } + + coroutine = MonoBehaviourUtil.Instance.StartCoroutine(RunActionWithDelay(action, SettingApplyDelay)); + } + + /// + /// Handles updates to the server settings. Checks whether we are connected to a server and only sends updates + /// if no changes were made to the local server settings in this class for a certain period of time to prevent + /// sending unnecessary updates. + /// + private void HandleUpdateServerSettings() { + if (!_netClient.IsConnected) { + // If the client is not connected to any server, we simply update the server settings of the + // server manager so that when we host a server, it will have the same settings as in this menu + _serverManager.InternalServerSettings.SetAllProperties(_localServerSettings); + return; + } + + RunActionWhenNoChanges(() => { + _netClient.UpdateManager.SetServerSettingsUpdate(_localServerSettings); + }, ref _delayedApplyServerSettingsRoutine); + } + + /// + /// Callback method for the client connection changes, when either connected or disconnected from a server. + /// This will modify the team selection depending on whether team selection should be allowed or not. + /// + /// + private void OnClientConnectionChange(bool connected) { + if (!connected) { + ModifyTeamHorizontalOption(false); + ModifySkinHorizontalOption(false); + return; + } + + if (_localServerSettings.TeamsEnabled) { + ModifyTeamHorizontalOption(true); + } + + if (_localServerSettings.AllowSkins) { + ModifySkinHorizontalOption(true); + } + } + + /// + /// Modify the horizontal option for teams to either allow or disallow team selection. + /// + /// Whether to allow the player to select a team. + private void ModifyTeamHorizontalOption(bool allowTeams) { + string description; + MenuSetting.ApplySetting applySetting; + + if (allowTeams) { + applySetting = (_, i) => { + if (_netClient.IsConnected) { + RunActionWhenNoChanges(() => { + _netClient.UpdateManager.AddPlayerSettingUpdate(team: (Team) i); + }, ref _delayedApplyTeamRoutine); + } + }; + + description = "Select one of several teams"; + } else { + applySetting = (m, _) => { + m.optionList.SetOptionTo(0); + }; + + description = "Team selection is currently disabled"; + } + + _teamHorizontalOption.menuSetting.customApplySetting = applySetting; + + _teamHorizontalOption.gameObject.transform.Find("Description").GetComponent().text = description; + + _teamHorizontalOptionEnabled = allowTeams; + } + + /// + /// Modify the horizontal option for skins to either allow or disallow skin selection. + /// + /// Whether to allow the player to select a skin. + private void ModifySkinHorizontalOption(bool allowSkins) { + string description; + MenuSetting.ApplySetting applySetting; + + if (allowSkins) { + applySetting = (_, i) => { + if (_netClient.IsConnected) { + RunActionWhenNoChanges(() => { + _netClient.UpdateManager.AddPlayerSettingUpdate(skinId: (byte) i); + }, ref _delayedApplySkinRoutine); + } + }; + + description = "Select a skin"; + } else { + applySetting = (m, _) => { + m.optionList.SetOptionTo(0); + }; + + description = "Skin selection is currently disabled"; + } + + _skinHorizontalOption.menuSetting.customApplySetting = applySetting; + + _skinHorizontalOption.gameObject.transform.Find("Description").GetComponent().text = description; + + _skinHorizontalOptionEnabled = allowSkins; + } + + /// + /// Run the given action with the given delay. + /// + /// The action to invoke after the delay. + /// The delay in seconds. + /// The coroutine of the delayed invocation. + private static IEnumerator RunActionWithDelay(Action action, float delay) { + yield return new WaitForSeconds(delay); + + action.Invoke(); + } +} diff --git a/HKMP/Menu/ModMenuSettingAttribute.cs b/HKMP/Menu/ModMenuSettingAttribute.cs new file mode 100644 index 00000000..26a9099f --- /dev/null +++ b/HKMP/Menu/ModMenuSettingAttribute.cs @@ -0,0 +1,38 @@ +using System; + +namespace Hkmp.Menu; + +/// +/// Attribute to define a name and description for entries in the mod menu. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +public class ModMenuSettingAttribute : Attribute { + /// + /// The name that the entry should show as on the mod menu. + /// + public string Name { get; private set; } + + /// + /// The description that the entry should show as on the mod menu. + /// + public string Description { get; private set; } + + /// + /// Constructs the attribute with the given name and no description. + /// + /// The name as a string. + public ModMenuSettingAttribute(string name) { + Name = name; + Description = null; + } + + /// + /// Constructs the attribute with the given name and description. + /// + /// The name as a string. + /// The description as a string. + public ModMenuSettingAttribute(string name, string description) { + Name = name; + Description = description; + } +} diff --git a/HKMP/Networking/Chunk/ChunkReceiver.cs b/HKMP/Networking/Chunk/ChunkReceiver.cs new file mode 100644 index 00000000..e48abf38 --- /dev/null +++ b/HKMP/Networking/Chunk/ChunkReceiver.cs @@ -0,0 +1,180 @@ +using System; +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Chunk; + +/// +/// Class that processes and manages chunks by receiving slices of those chunks and sending acknowledgements for those +/// slices. +/// +internal abstract class ChunkReceiver { + /// + /// Boolean array where each value indicates whether the slice of the same index was received. + /// + private readonly bool[] _received; + /// + /// Byte array that contains (parts of) the chunk data that is received. + /// + private readonly byte[] _chunkData; + + /// + /// Whether we are currently receiving a chunk. If not, receiving a slice containing a chunk ID that is one higher + /// that the last received chunk will start the reception process again. + /// + private bool _isReceiving; + /// + /// The currently (if receiving) or last received (when not receiving) chunk ID. + /// + private byte _chunkId = 255; + /// + /// The size of the chunk that we are currently receiving. Only calculated when the last slice is received, since + /// that is the only slice with a different slice size. + /// + private int _chunkSize; + /// + /// The number of slices that the chunk we are currently receiving contains. Set whenever we receive the first + /// slice in a chunk. + /// + private int _numSlices; + /// + /// The number of slices we have received so far. Used to keep track when all slices are received. + /// + private int _numReceivedSlices; + + /// + /// Event that is called when the entirety of a chunk is received. + /// + public event Action ChunkReceivedEvent; + + /// + /// Construct the chunk receiver by allocating the readonly arrays with their maximally used lengths. + /// + protected ChunkReceiver() { + _received = new bool[ConnectionManager.MaxSlicesPerChunk]; + _chunkData = new byte[ConnectionManager.MaxChunkSize]; + } + + /// + /// Process received slice data by checking whether we have not yet received this slice and adding it to the data + /// array and marking it received. If this is the first slice received in this chunk we note that we are + /// receiving, set the number of slices we expect to receive and increment the currently receiving chunk ID. + /// If this is the last slice in the chunk we invoke the event that an entire chunk is received. + /// + /// The received slice data. + public void ProcessReceivedData(SliceData sliceData) { + //Logger.Debug($"Received slice packet: {sliceData.ChunkId}, {sliceData.SliceId}, {sliceData.NumSlices}"); + + // We check if the received chunk ID is smaller than the current chunk ID accounting for wrapping IDs + if (ConnectionManager.IsWrappingIdSmaller(sliceData.ChunkId, _chunkId)) { + //Logger.Debug("Chunk ID of received slice packet is smaller than currently receiving chunk"); + return; + } + + if (!_isReceiving) { + if (sliceData.ChunkId == (byte) (_chunkId + 1)) { + //Logger.Debug($"Received new chunk with ID: {sliceData.ChunkId}"); + SoftReset(); + + _chunkId += 1; + _isReceiving = true; + _numSlices = sliceData.NumSlices; + } else if (sliceData.ChunkId == _chunkId) { + //Logger.Debug("Already received all slices, resending ack packet"); + SendAckData(); + return; + } else { + //Logger.Debug($"Received old chunk: {_chunkId}, ignoring"); + return; + } + } else { + // If the received number of slices does not match the number slices we are keeping track of, we discard + // the slice altogether as it is likely not correct + if (_numSlices != sliceData.NumSlices) { + //Logger.Debug("Number of slices in slice packet does not correspond with local number of slices"); + return; + } + } + + if (_received[sliceData.SliceId]) { + //Logger.Debug($"Received duplicate slice: {sliceData.SliceId}, ignoring"); + return; + } + + _numReceivedSlices += 1; + _received[sliceData.SliceId] = true; + + // Copy over the data from the received slice into the chunk data array at the correct position + Array.Copy( + sliceData.Data, + 0, + _chunkData, + sliceData.SliceId * ConnectionManager.MaxSliceSize, + sliceData.Data.Length + ); + + SendAckData(); + + // If this is the last slice in the chunk, we can calculate the chunk size + if (sliceData.SliceId == _numSlices - 1) { + _chunkSize = (_numSlices - 1) * ConnectionManager.MaxSliceSize + sliceData.Data.Length; + //Logger.Debug($"Received last slice in chunk, chunk size: {_chunkSize}"); + } + + if (_numReceivedSlices == _numSlices) { + var byteArray = new byte[_chunkSize]; + Array.Copy( + _chunkData, + 0, + byteArray, + 0, + _chunkSize + ); + var packet = new Packet.Packet(byteArray); + + ChunkReceivedEvent?.Invoke(packet); + + _isReceiving = false; + } + } + + /// + /// Reset the chunk receiver so it can be used for a new connection. This will reset most variables to their + /// default values. + /// + public void Reset() { + SoftReset(); + + _isReceiving = false; + _chunkId = 255; + } + + /// + /// Send acknowledgement data containing the boolean array of all slices that have been acknowledged thus far. + /// + private void SendAckData() { + var acked = new bool[_numSlices]; + Array.Copy(_received, acked, _numSlices); + + SetSliceAckData(_chunkId, (ushort) _numSlices, acked); + } + + /// + /// Soft reset the chunk receiver by clearing the array of received slices and setting chunk size, number of + /// slices, and number of received slices to 0. + /// + private void SoftReset() { + Array.Clear(_received, 0, _received.Length); + + _chunkSize = 0; + _numSlices = 0; + _numReceivedSlices = 0; + } + + /// + /// Set the slice ack data in the corresponding update manager for sending. + /// + /// The ID of the chunk for this acknowledgement. + /// The number of slices in this chunk. + /// The boolean array containing acknowledgements of all slices. + protected abstract void SetSliceAckData(byte chunkId, ushort numSlices, bool[] acked); +} diff --git a/HKMP/Networking/Chunk/ChunkSender.cs b/HKMP/Networking/Chunk/ChunkSender.cs new file mode 100644 index 00000000..26c4e7ff --- /dev/null +++ b/HKMP/Networking/Chunk/ChunkSender.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading; +using Hkmp.Logging; +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Chunk; + +/// +/// Class that processes and manages chunks by sending slices of those chunks and receiving acknowledgements for those +/// slices. +/// +internal abstract class ChunkSender { + /// + /// The number of milliseconds to wait between sending slices. + /// + private const int WaitMillisBetweenSlices = 20; + /// + /// The number of milliseconds to wait before re-sending a slice. + /// + private const int WaitMillisResendSlice = 100; + + /// + /// Blocking collection of packets that need to be sent as chunks. + /// + private readonly BlockingCollection _toSendPackets; + + /// + /// Boolean array where each value indicates whether the slice of the same index was acknowledged. + /// + private readonly bool[] _acked; + /// + /// Byte array that contains the chunk data that needs to be sent. + /// + private readonly byte[] _chunkData; + + /// + /// Manual reset event that is used for its wait handle to time when to send the next slice. + /// + private readonly ManualResetEventSlim _sliceWaitHandle; + + /// + /// Whether we are currently sending a chunk. If we are not sending anything, we ignore incoming chunk + /// acknowledgements. + /// + private bool _isSending; + /// + /// The ID of the chunk we are currently sending. + /// + private byte _chunkId; + /// + /// The size of the chunk we are currently sending. + /// + private int _chunkSize; + /// + /// The number of slices of the chunk we are currently sending. + /// + private int _numSlices; + /// + /// The number of acknowledged slices in the currently sending chunk. + /// + private int _numAckedSlices; + /// + /// The ID of the slice we are currently sending. + /// + private int _currentSliceId; + + /// + /// Array of stopwatches that keep track of the elapsed time since we have last sent the slice with the same ID. + /// If this time is smaller than a certain threshold, we do not send the slice again yet. + /// + private readonly Stopwatch[] _sliceStopwatches; + + /// + /// Cancellation token source for cancelling the send task. + /// + private CancellationTokenSource _sendTaskTokenSource; + + /// + /// Event that is called when we finish sending data. This is registered internally when the + /// method is called and we are waiting for the current chunk to finish sending. + /// + private event Action FinishSendingDataEvent; + + /// + /// Construct the chunk sender by initializing the blocking collection and manual reset event, and allocating the + /// arrays to their maximally used length. + /// + protected ChunkSender() { + _toSendPackets = new BlockingCollection(); + + _acked = new bool[ConnectionManager.MaxSlicesPerChunk]; + _chunkData = new byte[ConnectionManager.MaxChunkSize]; + _sliceStopwatches = new Stopwatch[ConnectionManager.MaxSlicesPerChunk]; + + _sliceWaitHandle = new ManualResetEventSlim(); + } + + /// + /// Start the chunk sender by starting the thread that manages the chunk sending. + /// + public void Start() { + _sendTaskTokenSource?.Cancel(); + _sendTaskTokenSource?.Dispose(); + _sendTaskTokenSource = new CancellationTokenSource(); + + new Thread(() => StartSends(_sendTaskTokenSource.Token)).Start(); + } + + /// + /// Stop the chunk sender by cancelling the send task. + /// + public void Stop() { + _sendTaskTokenSource?.Cancel(); + _sendTaskTokenSource?.Dispose(); + _sendTaskTokenSource = null; + } + + /// + /// Finish sending data and call the given callback whenever the data is finished sending. + /// + /// The callback to invoke. + public void FinishSendingData(Action callback) { + // If we aren't currently sending and the queue does not contain any packets to send, we immediately invoke + // the callback and return + if (!_isSending && _toSendPackets.Count == 0) { + callback?.Invoke(); + return; + } + + // Otherwise, we register the event + // We do it like this so we can deregister the event immediately after it is called, so it doesn't trigger + // more than once + Action lambda = null; + lambda = () => { + callback?.Invoke(); + FinishSendingDataEvent -= lambda; + }; + FinishSendingDataEvent += lambda; + } + + /// + /// Enqueue a packet to be sent as a chunk. + /// + /// The packet to send. + public void EnqueuePacket(Packet.Packet packet) { + _toSendPackets.Add(packet); + } + + /// + /// Process received slice acknowledgement data. First does sanity checks to see if we are actually sending a + /// chunk, whether the received chunk ID matches the currently sending chunk ID, and whether the number of slices + /// matches. Then for each of the slice indices in the acknowledgement array, it checks whether this is a newly + /// acknowledged slice and locally marks it as acknowledged. + /// + /// The received slice acknowledgement data. + public void ProcessReceivedData(SliceAckData sliceAckData) { + //Logger.Debug($"Received slice ack packet: {sliceAckData.ChunkId}, {sliceAckData.NumSlices}"); + + if (!_isSending) { + //Logger.Debug("Not sending a chunk, ignoring ack packet"); + return; + } + + if (_chunkId != sliceAckData.ChunkId) { + //Logger.Debug("Chunk ID of received ack packet does not correspond with currently sending chunk"); + return; + } + + if (_numSlices != sliceAckData.NumSlices) { + //Logger.Debug("Number of slices in ack packet does not correspond with local number of slices"); + return; + } + + for (var i = 0; i < _numSlices; i++) { + if (sliceAckData.Acked[i] && !_acked[i]) { + _acked[i] = true; + _numAckedSlices += 1; + + //Logger.Debug($"Received acknowledgement for slice {i}, total acked: {_numAckedSlices}"); + } + } + } + + /// + /// Start the sending process with the given cancellation token. + /// We block on the collection to take a new packet to start sending. Once a packet is taken from the collection, + /// we calculate the chunk size and number of slices that we need to send. Then, wee go over the slices in + /// ascending order and send one with a given delay between each slice. Each slice that is acknowledged already + /// is skipped in the sending order. If we have already sent a given slice less than a certain threshold ago, we + /// also skip sending it. Once all slices have been acknowledged, we go back to blocking on the collection to wait + /// for a new chunk to send. + /// + /// The cancellation token for cancelling the sending process. + private void StartSends(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + if (_toSendPackets.Count == 0) { + FinishSendingDataEvent?.Invoke(); + } + + Packet.Packet packet; + try { + packet = _toSendPackets.Take(cancellationToken); + } catch (OperationCanceledException) { + continue; + } + + _isSending = true; + + //Logger.Debug("Successfully taken new packet from blocking collection, starting networking chunk"); + + Array.Clear(_sliceStopwatches, 0, _sliceStopwatches.Length); + _numAckedSlices = 0; + + var packetBytes = packet.ToArray(); + + _chunkSize = packetBytes.Length; + _numSlices = _chunkSize / ConnectionManager.MaxSliceSize; + if (_chunkSize % ConnectionManager.MaxSliceSize != 0) { + _numSlices += 1; + } + + //Logger.Debug($"ChunkSize: {_chunkSize}, NumSlices: {_numSlices}"); + + // Skip over chunks that exceed the maximum size that our system can handle + if (_chunkSize > ConnectionManager.MaxChunkSize) { + Logger.Error($"Could not send packet that exceeds max chunk size: {_chunkSize}"); + continue; + } + + // Copy the raw bytes from the packet into the chunk data array + Array.Copy(packetBytes, _chunkData, _chunkSize); + + do { + //Logger.Debug($"Sending next slice: {_currentSliceId}"); + SendNextSlice(); + + // Obtain (or create) the stopwatch for the slice and start it + var sliceStopwatch = _sliceStopwatches[_currentSliceId]; + if (sliceStopwatch == null) { + sliceStopwatch = new Stopwatch(); + _sliceStopwatches[_currentSliceId] = sliceStopwatch; + } + + sliceStopwatch.Restart(); + + if (!TryGetNextSliceToSend()) { + //Logger.Debug($"All slices have been acked ({_numAckedSlices}), stopping sending slices"); + break; + } + + long waitMillisNextSlice; + + // Get the stopwatch for this slice, and check whether we have already sent this slice not too long ago + // If so, we wait longer before resending the slice. Otherwise, we default to the normal send rate. + sliceStopwatch = _sliceStopwatches[_currentSliceId]; + if (sliceStopwatch == null) { + waitMillisNextSlice = WaitMillisBetweenSlices; + } else { + waitMillisNextSlice = WaitMillisResendSlice - sliceStopwatch.ElapsedMilliseconds; + if (waitMillisNextSlice < 0) { + waitMillisNextSlice = WaitMillisBetweenSlices; + } + } + + //Logger.Debug($"Waiting on handle for next slice: {waitMillisNextSlice}"); + try { + _sliceWaitHandle.Wait((int) waitMillisNextSlice, cancellationToken); + } catch (OperationCanceledException) { + //Logger.Debug("Wait operation was cancelled, breaking"); + break; + } + } while (!cancellationToken.IsCancellationRequested); + + //Logger.Debug($"Incrementing chunk ID to: {_chunkId + 1}"); + _chunkId += 1; + _isSending = false; + } + + //Logger.Debug("Resetting values of chunk sender"); + + // The loop is over when cancellation is requested, so we reset the variables after it + _isSending = false; + _chunkId = 0; + _chunkSize = 0; + _numSlices = 0; + _numAckedSlices = 0; + _currentSliceId = 0; + + for (var i = 0; i < _sliceStopwatches.Length; i++) { + _sliceStopwatches[i]?.Stop(); + _sliceStopwatches[i] = null; + } + } + + /// + /// Send the next slice, whose ID is . This will figure out the start index of the + /// data in the array and copy the data into a new array for adding to the update packet. + /// + private void SendNextSlice() { + var startIndex = _currentSliceId * ConnectionManager.MaxSliceSize; + + byte[] sliceBytes; + // Figure out if the start index for the next slice would exceed the chunk size. If so, the length of the slice + // is less than the maximum slice size, which we need to calculate + if ((_currentSliceId + 1) * ConnectionManager.MaxSliceSize > _chunkSize) { + var length = _chunkSize - startIndex; + sliceBytes = new byte[length]; + + Array.Copy(_chunkData, startIndex, sliceBytes, 0, length); + } else { + sliceBytes = new byte[ConnectionManager.MaxSliceSize]; + + Array.Copy(_chunkData, startIndex, sliceBytes, 0, sliceBytes.Length); + } + + SetSliceData(_chunkId, (byte) _currentSliceId, (byte) _numSlices, sliceBytes); + } + + /// + /// Try to get the next slice ID that we need to send. We simply iterate in ascending order over slice IDs until + /// we find one that is not yet acknowledged. Each iteration we check whether the number of acknowledged slices + /// equals the number of slices in the chunk, so we don't end up in an infinite loop. + /// + /// True if a next slice could be found, false if all slices are acknowledged. + private bool TryGetNextSliceToSend() { + do { + // We do the check inside the loop to prevent multi-thread issues where another ack is received and + // a non-acked slice cannot be found anywhere, resulting in an infinite while loop + if (_numAckedSlices == _numSlices) { + return false; + } + + _currentSliceId += 1; + if (_currentSliceId >= _numSlices) { + _currentSliceId = 0; + } + } while (_acked[_currentSliceId]); + + return true; + } + + /// + /// Set the slice data in the corresponding update manager for sending. + /// + /// The ID of the chunk for this slice. + /// The ID of the slice. + /// The number of slices in this chunk. + /// The byte array containing the data of the slice. + protected abstract void SetSliceData(byte chunkId, byte sliceId, byte numSlices, byte[] data); +} diff --git a/HKMP/Networking/Chunk/ClientChunkReceiver.cs b/HKMP/Networking/Chunk/ClientChunkReceiver.cs new file mode 100644 index 00000000..77c00d2c --- /dev/null +++ b/HKMP/Networking/Chunk/ClientChunkReceiver.cs @@ -0,0 +1,22 @@ +using Hkmp.Networking.Client; + +namespace Hkmp.Networking.Chunk; + +/// +/// Specialization class of for the client-side chunk receiver. +/// +internal class ClientChunkReceiver : ChunkReceiver { + /// + /// The client update manager instance used for adding slice ack data to the update packet. + /// + private readonly ClientUpdateManager _updateManager; + + public ClientChunkReceiver(ClientUpdateManager updateManager) { + _updateManager = updateManager; + } + + /// + protected override void SetSliceAckData(byte chunkId, ushort numSlices, bool[] acked) { + _updateManager.SetSliceAckData(chunkId, numSlices, acked); + } +} diff --git a/HKMP/Networking/Chunk/ClientChunkSender.cs b/HKMP/Networking/Chunk/ClientChunkSender.cs new file mode 100644 index 00000000..2709f7aa --- /dev/null +++ b/HKMP/Networking/Chunk/ClientChunkSender.cs @@ -0,0 +1,22 @@ +using Hkmp.Networking.Client; + +namespace Hkmp.Networking.Chunk; + +/// +/// Specialization class of for the client-side chunk receiver. +/// +internal class ClientChunkSender : ChunkSender { + /// + /// The client update manager instance used for adding slice data to the update packet. + /// + private readonly ClientUpdateManager _updateManager; + + public ClientChunkSender(ClientUpdateManager updateManager) { + _updateManager = updateManager; + } + + /// + protected override void SetSliceData(byte chunkId, byte sliceId, byte numSlices, byte[] data) { + _updateManager.SetSliceData(chunkId, sliceId, numSlices, data); + } +} diff --git a/HKMP/Networking/Chunk/ServerChunkReceiver.cs b/HKMP/Networking/Chunk/ServerChunkReceiver.cs new file mode 100644 index 00000000..51bf5a32 --- /dev/null +++ b/HKMP/Networking/Chunk/ServerChunkReceiver.cs @@ -0,0 +1,22 @@ +using Hkmp.Networking.Server; + +namespace Hkmp.Networking.Chunk; + +/// +/// Specialization class of for the server-side chunk receiver. +/// +internal class ServerChunkReceiver : ChunkReceiver { + /// + /// The server update manager instance used for adding slice ack data to the update packet. + /// + private readonly ServerUpdateManager _updateManager; + + public ServerChunkReceiver(ServerUpdateManager updateManager) { + _updateManager = updateManager; + } + + /// + protected override void SetSliceAckData(byte chunkId, ushort numSlices, bool[] acked) { + _updateManager.SetSliceAckData(chunkId, numSlices, acked); + } +} diff --git a/HKMP/Networking/Chunk/ServerChunkSender.cs b/HKMP/Networking/Chunk/ServerChunkSender.cs new file mode 100644 index 00000000..e54587c2 --- /dev/null +++ b/HKMP/Networking/Chunk/ServerChunkSender.cs @@ -0,0 +1,22 @@ +using Hkmp.Networking.Server; + +namespace Hkmp.Networking.Chunk; + +/// +/// Specialization class of for the server-side chunk receiver. +/// +internal class ServerChunkSender : ChunkSender { + /// + /// The server update manager instance used for adding slice data to the update packet. + /// + private readonly ServerUpdateManager _updateManager; + + public ServerChunkSender(ServerUpdateManager updateManager) { + _updateManager = updateManager; + } + + /// + protected override void SetSliceData(byte chunkId, byte sliceId, byte numSlices, byte[] data) { + _updateManager.SetSliceData(chunkId, sliceId, numSlices, data); + } +} diff --git a/HKMP/Networking/Client/ClientConnectionManager.cs b/HKMP/Networking/Client/ClientConnectionManager.cs new file mode 100644 index 00000000..6af5524e --- /dev/null +++ b/HKMP/Networking/Client/ClientConnectionManager.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using Hkmp.Logging; +using Hkmp.Networking.Chunk; +using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Connection; +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Client; + +/// +/// Client-side manager for handling the initial connection to the server. +/// +internal class ClientConnectionManager : ConnectionManager { + /// + /// The client-side chunk sender used to handle sending chunks. + /// + private readonly ClientChunkSender _chunkSender; + /// + /// The client-side chunk received used to receive chunks. + /// + private readonly ClientChunkReceiver _chunkReceiver; + + /// + /// Event that is called when server info is received from the server we are trying to connect to. + /// + public event Action ServerInfoReceivedEvent; + + /// + /// Construct the connection manager with the given packet manager and chunk sender, and receiver instances. + /// Will register handlers in the packet manager that relate to the connection. + /// + public ClientConnectionManager( + PacketManager packetManager, + ClientChunkSender chunkSender, + ClientChunkReceiver chunkReceiver + ) : base(packetManager) { + _chunkSender = chunkSender; + _chunkReceiver = chunkReceiver; + + packetManager.RegisterClientConnectionPacketHandler( + ClientConnectionPacketId.ServerInfo, + OnServerInfoReceived + ); + _chunkReceiver.ChunkReceivedEvent += OnChunkReceived; + } + + /// + /// Start establishing the connection to the server with the given information. + /// + /// The username of the player. + /// The authentication key of the player. + /// List of addon data that represents the enabled networked addons that the client uses. + /// + public void StartConnection(string username, string authKey, List addonData) { + Logger.Debug("StartConnection"); + + // Create a connection packet that will be the entire chunk we will be sending + var connectionPacket = new ServerConnectionPacket(); + + // Set the client info data in the connection packet + connectionPacket.SetSendingPacketData(ServerConnectionPacketId.ClientInfo, new ClientInfo { + Username = username, + AuthKey = authKey, + AddonData = addonData + }); + + // Create the raw packet from the connection packet + var packet = new Packet.Packet(); + connectionPacket.CreatePacket(packet); + + // Enqueue the raw packet to be sent using the chunk sender + _chunkSender.EnqueuePacket(packet); + } + + /// + /// Callback method for when server info is received from the server. + /// + /// The server info instance received from the server. + private void OnServerInfoReceived(ServerInfo serverInfo) { + Logger.Debug($"ServerInfo received, connection accepted: {serverInfo.ConnectionResult}"); + + ServerInfoReceivedEvent?.Invoke(serverInfo); + } + + /// + /// Callback method for when a new chunk is received from the server. + /// + /// The raw packet that contains the data from the chunk. + private void OnChunkReceived(Packet.Packet packet) { + // Create the connection packet instance and try to read it + var connectionPacket = new ClientConnectionPacket(); + if (!connectionPacket.ReadPacket(packet)) { + Logger.Debug("Received malformed connection packet chunk from server"); + return; + } + + // Let the packet manager handle the connection packet, which will invoke the relevant data handlers + PacketManager.HandleClientConnectionPacket(connectionPacket); + } +} diff --git a/HKMP/Networking/Client/ClientConnectionStatus.cs b/HKMP/Networking/Client/ClientConnectionStatus.cs new file mode 100644 index 00000000..aaad49e5 --- /dev/null +++ b/HKMP/Networking/Client/ClientConnectionStatus.cs @@ -0,0 +1,19 @@ +namespace Hkmp.Networking.Client; + +/// +/// Enumeration of connection statuses for the client. +/// +internal enum ClientConnectionStatus { + /// + /// Not connected to any server. + /// + NotConnected, + /// + /// Trying to establish a connection to a server. + /// + Connecting, + /// + /// Connected to a server. + /// + Connected +} diff --git a/HKMP/Networking/Client/ClientDatagramTransport.cs b/HKMP/Networking/Client/ClientDatagramTransport.cs new file mode 100644 index 00000000..5998970e --- /dev/null +++ b/HKMP/Networking/Client/ClientDatagramTransport.cs @@ -0,0 +1,34 @@ +using System.Net.Sockets; + +namespace Hkmp.Networking.Client; + +/// +/// Class that implements the DatagramTransport interface from DTLS. This class simply sends and receives data using +/// a UDP socket directly. +/// +internal class ClientDatagramTransport : UdpDatagramTransport { + /// + /// The socket with which to send and over which to receive data. + /// + private readonly Socket _socket; + + public ClientDatagramTransport(Socket socket) { + _socket = socket; + } + + /// + public override int GetReceiveLimit() { + return DtlsClient.MaxPacketSize; + } + + /// + public override int GetSendLimit() { + return DtlsClient.MaxPacketSize; + } + + /// + /// The implementation simply sends the data in the buffer over the network using the socket. + public override void Send(byte[] buf, int off, int len) { + _socket.Send(buf, off, len, SocketFlags.None); + } +} diff --git a/HKMP/Networking/Client/ClientTlsClient.cs b/HKMP/Networking/Client/ClientTlsClient.cs new file mode 100644 index 00000000..37e4bd46 --- /dev/null +++ b/HKMP/Networking/Client/ClientTlsClient.cs @@ -0,0 +1,118 @@ +using System.Text; +using Hkmp.Logging; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Tls; +using Org.BouncyCastle.Tls.Crypto; +using Org.BouncyCastle.Utilities.Encoders; + +namespace Hkmp.Networking.Client; + +/// +/// Client-side TLS client implementation that handles reporting supported cipher suites, provides client +/// authentication and checks server certificate. +/// +/// TlsCrypto instance for handling low-level cryptography. +internal class ClientTlsClient(TlsCrypto crypto) : AbstractTlsClient(crypto) { + /// + /// List of supported cipher suites on the client-side. + /// + private static readonly int[] SupportedCipherSuites = [ + CipherSuite.TLS_AES_128_GCM_SHA256, + CipherSuite.TLS_AES_256_GCM_SHA384, + CipherSuite.TLS_CHACHA20_POLY1305_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + ]; + + /// + protected override ProtocolVersion[] GetSupportedVersions() { + return ProtocolVersion.DTLSv12.Only(); + } + + /// + /// Get the supported cipher suites for this TLS client. + /// + /// An int array representing the cipher suites. + protected override int[] GetSupportedCipherSuites() { + return SupportedCipherSuites; + } + + /// + /// The maximum time the handshake can take in milliseconds before timing out. + /// + /// The integer value of the timeout in milliseconds. + public override int GetHandshakeTimeoutMillis() { + return DtlsClient.DtlsHandshakeTimeoutMillis; + } + + /// + /// + /// Get the authentication implementation for this TLS client that handles providing client credentials and + /// checking server certificates. + /// + /// The TlsAuthentication instance for this TLS client. + public override TlsAuthentication GetAuthentication() { + return new TlsAuthenticationImpl(); + } + + /// + /// Implementation for TLS authentication that handles providing client credentials and checking server + /// certificates. + /// + private class TlsAuthenticationImpl : TlsAuthentication { + /// + /// Notify the TLS client of the server certificate that the server has sent. This method checks whether to + /// trust the server based on this certificate or not. If not, the method will throw an exception which will + /// subsequently abort the connection. + /// In the current implementation, we only log the fingerprints of the certificates in the chain from the + /// server. + /// + /// + /// The server certificate instance. + public void NotifyServerCertificate(TlsServerCertificate serverCertificate) { + if (serverCertificate?.Certificate == null || serverCertificate.Certificate.IsEmpty) { + throw new TlsFatalAlert(AlertDescription.bad_certificate); + } + + var chain = serverCertificate.Certificate.GetCertificateList(); + + Logger.Info("Server certificate fingerprint(s):"); + for (var i = 0; i < chain.Length; i++) { + var entry = X509CertificateStructure.GetInstance(chain[i].GetEncoded()); + Logger.Info($" fingerprint:SHA256 {Fingerprint(entry)} ({entry.Subject})"); + } + } + + /// + /// Get the credentials of the client so the server can verify who we are. Currently, we have no way to + /// provide client-side credentials, so we return null. + /// + /// + public TlsCredentials GetClientCredentials(CertificateRequest certificateRequest) { + // TODO: provide means for a client to have certificate and return it in this method + return null; + } + + /// + /// Return a fingerprint for the given X509 certificate. + /// + /// The 509 certificate to fingerprint. + /// The fingerprint as a string. + private static string Fingerprint(X509CertificateStructure c) { + var der = c.GetEncoded(); + var hash = DigestUtilities.CalculateDigest("SHA256", der); + var hexBytes = Hex.Encode(hash); + var hex = Encoding.ASCII.GetString(hexBytes).ToUpperInvariant(); + + var fp = new StringBuilder(); + var i = 0; + fp.Append(hex.Substring(i, 2)); + while ((i += 2) < hex.Length) { + fp.Append(':'); + fp.Append(hex.Substring(i, 2)); + } + return fp.ToString(); + } + } +} diff --git a/HKMP/Networking/Client/ClientUpdateManager.cs b/HKMP/Networking/Client/ClientUpdateManager.cs index 394c4867..a098b9ff 100644 --- a/HKMP/Networking/Client/ClientUpdateManager.cs +++ b/HKMP/Networking/Client/ClientUpdateManager.cs @@ -1,35 +1,17 @@ -using System; -using System.Collections.Generic; -using System.Net.Sockets; using Hkmp.Animation; using Hkmp.Game; using Hkmp.Game.Client.Entity; +using Hkmp.Game.Settings; using Hkmp.Math; -using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; namespace Hkmp.Networking.Client; /// /// Specialization of for client to server packet sending. /// -internal class ClientUpdateManager : UdpUpdateManager { - /// - /// Construct the update manager with a UDP net client. - /// - /// The UDP socket for the local client. - public ClientUpdateManager(Socket udpSocket) : base(udpSocket) { - } - - /// - protected override void SendPacket(Packet.Packet packet) { - if (!UdpSocket.Connected) { - return; - } - - UdpSocket?.SendAsync(new ArraySegment(packet.ToArray()), SocketFlags.None); - } - +internal class ClientUpdateManager : UdpUpdateManager { /// public override void ResendReliableData(ServerUpdatePacket lostPacket) { lock (Lock) { @@ -43,30 +25,50 @@ public override void ResendReliableData(ServerUpdatePacket lostPacket) { /// The existing or new PlayerUpdate instance. private PlayerUpdate FindOrCreatePlayerUpdate() { if (!CurrentUpdatePacket.TryGetSendingPacketData( - ServerPacketId.PlayerUpdate, + ServerUpdatePacketId.PlayerUpdate, out var packetData)) { packetData = new PlayerUpdate(); - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.PlayerUpdate, packetData); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.PlayerUpdate, packetData); } return (PlayerUpdate) packetData; } /// - /// Set the login request data in the current packet. + /// Set slice data in the current packet. + /// + /// The ID of the chunk the slice belongs to. + /// The ID of the slice within the chunk. + /// The number of slices in the chunk. + /// The raw data in the slice as a byte array. + public void SetSliceData(byte chunkId, byte sliceId, byte numSlices, byte[] data) { + lock (Lock) { + var sliceData = new SliceData { + ChunkId = chunkId, + SliceId = sliceId, + NumSlices = numSlices, + Data = data + }; + + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.Slice, sliceData); + } + } + + /// + /// Set slice acknowledgement data in the current packet. /// - /// The username of the client. - /// The auth key of the client. - /// A list of addon data of the client. - public void SetLoginRequestData(string username, string authKey, List addonData) { + /// The ID of the chunk the slice belongs to. + /// The number of slices in the chunk. + /// A boolean array containing whether a certain slice in the chunk was acknowledged. + public void SetSliceAckData(byte chunkId, ushort numSlices, bool[] acked) { lock (Lock) { - var loginRequest = new LoginRequest { - Username = username, - AuthKey = authKey + var sliceAckData = new SliceAckData { + ChunkId = chunkId, + NumSlices = numSlices, + Acked = acked }; - loginRequest.AddonData.AddRange(addonData); - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.LoginRequest, loginRequest); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.SliceAck, sliceAckData); } } @@ -113,11 +115,11 @@ public void UpdatePlayerMapPosition(Vector2 mapPosition) { public void UpdatePlayerMapIcon(bool hasIcon) { lock (Lock) { if (!CurrentUpdatePacket.TryGetSendingPacketData( - ServerPacketId.PlayerMapUpdate, + ServerUpdatePacketId.PlayerMapUpdate, out var packetData )) { packetData = new PlayerMapUpdate(); - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.PlayerMapUpdate, packetData); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.PlayerMapUpdate, packetData); } ((PlayerMapUpdate) packetData).HasIcon = hasIcon; @@ -146,44 +148,79 @@ public void UpdatePlayerAnimation(AnimationClip clip, int frame = 0, bool[] effe playerUpdate.AnimationInfos.Add(animationInfo); } } + + /// + /// Set entity spawn data for an entity that spawned later in the scene. + /// + /// The ID of the entity. + /// The type of the entity that spawned the new entity. + /// The type of the entity that was spawned. + public void SetEntitySpawn(ushort id, EntityType spawningType, EntityType spawnedType) { + lock (Lock) { + PacketDataCollection entitySpawnCollection; + + // Check if there is an existing data collection or create one if not + if (CurrentUpdatePacket.TryGetSendingPacketData(ServerUpdatePacketId.EntitySpawn, out var packetData)) { + entitySpawnCollection = (PacketDataCollection) packetData; + } else { + entitySpawnCollection = new PacketDataCollection(); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.EntitySpawn, entitySpawnCollection); + } + + entitySpawnCollection.DataInstances.Add(new EntitySpawn { + Id = id, + SpawningType = spawningType, + SpawnedType = spawnedType + }); + } + } /// /// Find an existing or create a new EntityUpdate instance in the current update packet. /// - /// The type of the entity. /// The ID of the entity. + /// The type of the entity update. Either or + /// . /// The existing or new EntityUpdate instance. - private EntityUpdate FindOrCreateEntityUpdate(EntityType entityType, byte entityId) { - EntityUpdate entityUpdate = null; - PacketDataCollection entityUpdateCollection; + private T FindOrCreateEntityUpdate(ushort entityId) where T : BaseEntityUpdate, new() { + var entityUpdate = default(T); + PacketDataCollection entityUpdateCollection; + + var packetId = typeof(T) == typeof(EntityUpdate) + ? ServerUpdatePacketId.EntityUpdate + : ServerUpdatePacketId.ReliableEntityUpdate; // First check whether there actually exists entity data at all if (CurrentUpdatePacket.TryGetSendingPacketData( - ServerPacketId.EntityUpdate, - out var packetData) - ) { + packetId, + out var packetData + )) { // And if there exists data already, try to find a match for the entity type and id - entityUpdateCollection = (PacketDataCollection) packetData; + entityUpdateCollection = (PacketDataCollection) packetData; foreach (var existingPacketData in entityUpdateCollection.DataInstances) { - var existingEntityUpdate = (EntityUpdate) existingPacketData; - if (existingEntityUpdate.EntityType.Equals((byte) entityType) && - existingEntityUpdate.Id == entityId) { + var existingEntityUpdate = (T) existingPacketData; + if (existingEntityUpdate.Id == entityId) { entityUpdate = existingEntityUpdate; break; } } } else { // If no data exists yet, we instantiate the data collection class and put it at the respective key - entityUpdateCollection = new PacketDataCollection(); - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.EntityUpdate, entityUpdateCollection); + entityUpdateCollection = new PacketDataCollection(); + CurrentUpdatePacket.SetSendingPacketData(packetId, entityUpdateCollection); } // If no existing instance was found, create one and add it to the (newly created) collection if (entityUpdate == null) { - entityUpdate = new EntityUpdate { - EntityType = (byte) entityType, - Id = entityId - }; + if (typeof(T) == typeof(EntityUpdate)) { + entityUpdate = (T) (object) new EntityUpdate { + Id = entityId + }; + } else { + entityUpdate = (T) (object) new ReliableEntityUpdate { + Id = entityId + }; + } entityUpdateCollection.DataInstances.Add(entityUpdate); @@ -195,12 +232,11 @@ private EntityUpdate FindOrCreateEntityUpdate(EntityType entityType, byte entity /// /// Update an entity's position in the current packet. /// - /// The entity type. /// The ID of the entity. /// The new position of the entity. - public void UpdateEntityPosition(EntityType entityType, byte entityId, Vector2 position) { + public void UpdateEntityPosition(ushort entityId, Vector2 position) { lock (Lock) { - var entityUpdate = FindOrCreateEntityUpdate(entityType, entityId); + var entityUpdate = FindOrCreateEntityUpdate(entityId); entityUpdate.UpdateTypes.Add(EntityUpdateType.Position); entityUpdate.Position = position; @@ -208,98 +244,89 @@ public void UpdateEntityPosition(EntityType entityType, byte entityId, Vector2 p } /// - /// Update an entity's state in the current packet. + /// Update an entity's scale in the current packet. /// - /// The entity type. /// The ID of the entity. - /// The new state of the entity. - public void UpdateEntityState(EntityType entityType, byte entityId, byte state) { + /// The scale data of the entity. + public void UpdateEntityScale(ushort entityId, EntityUpdate.ScaleData scale) { lock (Lock) { - var entityUpdate = FindOrCreateEntityUpdate(entityType, entityId); + var entityUpdate = FindOrCreateEntityUpdate(entityId); - entityUpdate.UpdateTypes.Add(EntityUpdateType.State); - entityUpdate.State = state; + entityUpdate.UpdateTypes.Add(EntityUpdateType.Scale); + entityUpdate.Scale = scale; } } /// - /// Update an entity's state and variables in the current packet. + /// Update an entity's animation ID in the current packet. /// - /// The entity type. /// The ID of the entity. - /// The new state of the entity. - /// List of entity variables for this update. - public void UpdateEntityStateAndVariables(EntityType entityType, byte entityId, byte state, - List fsmVariables) { + /// The new animation ID of the entity. + /// The wrap mode of the animation of the entity. + public void UpdateEntityAnimation(ushort entityId, byte animationId, byte animationWrapMode) { lock (Lock) { - var entityUpdate = FindOrCreateEntityUpdate(entityType, entityId); - - entityUpdate.UpdateTypes.Add(EntityUpdateType.State); - entityUpdate.State = state; + var entityUpdate = FindOrCreateEntityUpdate(entityId); - entityUpdate.UpdateTypes.Add(EntityUpdateType.Variables); - entityUpdate.Variables.AddRange(fsmVariables); + entityUpdate.UpdateTypes.Add(EntityUpdateType.Animation); + entityUpdate.AnimationId = animationId; + entityUpdate.AnimationWrapMode = animationWrapMode; } } - + /// - /// Set that the player has disconnected in the current packet. + /// Update whether an entity is active or not. /// - public void SetPlayerDisconnect() { + /// The ID of the entity. + /// Whether the entity is active or not. + public void UpdateEntityIsActive(ushort entityId, bool isActive) { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.PlayerDisconnect, new EmptyData()); + var entityUpdate = FindOrCreateEntityUpdate(entityId); + + entityUpdate.UpdateTypes.Add(EntityUpdateType.Active); + entityUpdate.IsActive = isActive; } } - + /// - /// Set a team update in the current packet. + /// Add data to an entity's update in the current packet. /// - /// The new team of the player. - public void SetTeamUpdate(Team team) { + /// The ID of the entity. + /// The entity network data to add. + public void AddEntityData(ushort entityId, EntityNetworkData data) { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData( - ServerPacketId.PlayerTeamUpdate, - new ServerPlayerTeamUpdate { Team = team } - ); + var entityUpdate = FindOrCreateEntityUpdate(entityId); + + entityUpdate.UpdateTypes.Add(EntityUpdateType.Data); + entityUpdate.GenericData.Add(data); } } /// - /// Set a skin update in the current packet. + /// Add host entity FSM data to the current packet. /// - /// The ID of the skin of the player. - public void SetSkinUpdate(byte skinId) { + /// The ID of the entity. + /// The index of the FSM of the entity. + /// The host FSM data to add. + public void AddEntityHostFsmData(ushort entityId, byte fsmIndex, EntityHostFsmData data) { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData( - ServerPacketId.PlayerSkinUpdate, - new ServerPlayerSkinUpdate { SkinId = skinId } - ); + var entityUpdate = FindOrCreateEntityUpdate(entityId); + + entityUpdate.UpdateTypes.Add(EntityUpdateType.HostFsm); + + if (entityUpdate.HostFsmData.TryGetValue(fsmIndex, out var existingData)) { + existingData.MergeData(data); + } else { + entityUpdate.HostFsmData.Add(fsmIndex, data); + } } } /// - /// Set hello server data in the current packet. + /// Set that the player has disconnected in the current packet. /// - /// The name of the current scene of the player. - /// The position of the player. - /// The scale of the player. - /// The animation clip ID of the player. - public void SetHelloServerData( - string sceneName, - Vector2 position, - bool scale, - ushort animationClipId - ) { + public void SetPlayerDisconnect() { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData( - ServerPacketId.HelloServer, - new HelloServer { - SceneName = sceneName, - Position = position, - Scale = scale, - AnimationClipId = animationClipId - } - ); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.PlayerDisconnect, new EmptyData()); } } @@ -318,7 +345,7 @@ ushort animationClipId ) { lock (Lock) { CurrentUpdatePacket.SetSendingPacketData( - ServerPacketId.PlayerEnterScene, + ServerUpdatePacketId.PlayerEnterScene, new ServerPlayerEnterScene { NewSceneName = sceneName, Position = position, @@ -330,11 +357,17 @@ ushort animationClipId } /// - /// Set that the player has left the current scene in the current packet. + /// Set that the player has left the given scene in the current packet. /// - public void SetLeftScene() { + /// The name of the scene that the player left. + public void SetLeftScene(string sceneName) { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.PlayerLeaveScene, new ReliableEmptyData()); + CurrentUpdatePacket.SetSendingPacketData( + ServerUpdatePacketId.PlayerLeaveScene, + new ServerPlayerLeaveScene { + SceneName = sceneName + } + ); } } @@ -343,7 +376,7 @@ public void SetLeftScene() { /// public void SetDeath() { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.PlayerDeath, new ReliableEmptyData()); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.PlayerDeath, new ReliableEmptyData()); } } @@ -353,9 +386,79 @@ public void SetDeath() { /// The string message. public void SetChatMessage(string message) { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData(ServerPacketId.ChatMessage, new ChatMessage { + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.ChatMessage, new ChatMessage { Message = message }); } } + + /// + /// Set save update data. + /// + /// The index of the save data entry. + /// The array of bytes that represents the changed value. + public void SetSaveUpdate(ushort index, byte[] value) { + lock (Lock) { + PacketDataCollection saveUpdateCollection; + + if (CurrentUpdatePacket.TryGetSendingPacketData(ServerUpdatePacketId.SaveUpdate, out var packetData)) { + saveUpdateCollection = (PacketDataCollection) packetData; + } else { + saveUpdateCollection = new PacketDataCollection(); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.SaveUpdate, saveUpdateCollection); + } + + saveUpdateCollection.DataInstances.Add(new SaveUpdate { + SaveDataIndex = index, + Value = value + }); + } + } + + /// + /// Set server settings update. + /// + /// The server settings instance that contains the updated values. + public void SetServerSettingsUpdate(ServerSettings serverSettings) { + lock (Lock) { + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.ServerSettings, new ServerSettingsUpdate { + ServerSettings = serverSettings + }); + } + } + + /// + /// Add a player setting update to the current packet. + /// + /// The team that the player would like to switch to, or null, if the team does not need to be + /// updated. + /// The ID of the skin that the player would like to switch to, or null, if the skin does not + /// need to be updated. + public void AddPlayerSettingUpdate(Team? team = null, byte? skinId = null) { + if (!team.HasValue && !skinId.HasValue) { + return; + } + + lock (Lock) { + if (!CurrentUpdatePacket.TryGetSendingPacketData( + ServerUpdatePacketId.PlayerSetting, + out var packetData + )) { + packetData = new ServerPlayerSettingUpdate(); + CurrentUpdatePacket.SetSendingPacketData(ServerUpdatePacketId.PlayerSetting, packetData); + } + + var playerSettingUpdate = (ServerPlayerSettingUpdate) packetData; + + if (team.HasValue) { + playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Team); + playerSettingUpdate.Team = team.Value; + } + + if (skinId.HasValue) { + playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Skin); + playerSettingUpdate.SkinId = skinId.Value; + } + } + } } diff --git a/HKMP/Networking/Client/ConnectionFailedResult.cs b/HKMP/Networking/Client/ConnectionFailedResult.cs new file mode 100644 index 00000000..64178a38 --- /dev/null +++ b/HKMP/Networking/Client/ConnectionFailedResult.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Client; + +/// +/// Class that encapsulates the result of a failed connection. +/// +internal class ConnectionFailedResult { + /// + /// The reason that the connection failed. + /// + public ConnectionFailedReason Reason { get; init; } +} + +/// +/// Specialization class of for invalid addons. +/// +internal class ConnectionInvalidAddonsResult : ConnectionFailedResult { + /// + /// The list of addon data that the server uses to compare against for clients. + /// + public List AddonData { get; init; } +} + +/// +/// Specialization class of for a generic failed connection with message. +/// +internal class ConnectionFailedMessageResult : ConnectionFailedResult { + /// + /// The string message that describes the reason the connection failed. + /// + public string Message { get; init; } +} + +/// +/// Enumeration of reasons why the connection failed. +/// +internal enum ConnectionFailedReason { + /// + /// The client and server addon do not match. + /// + InvalidAddons, + /// + /// The connection timed out (took too long to establish). + /// + TimedOut, + /// + /// A socket exception occurred while trying to establish the connection. + /// + SocketException, + /// + /// An IO exception occurred while trying to establish the connection. + /// + IOException, + /// + /// The reason is miscellaneous. + /// + Other, +} diff --git a/HKMP/Networking/Client/DtlsClient.cs b/HKMP/Networking/Client/DtlsClient.cs new file mode 100644 index 00000000..87a2dfb4 --- /dev/null +++ b/HKMP/Networking/Client/DtlsClient.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using Hkmp.Logging; +using Org.BouncyCastle.Tls; +using Org.BouncyCastle.Tls.Crypto.Impl.BC; + +namespace Hkmp.Networking.Client; + +/// +/// DTLS implementation for a client-side peer for networking. +/// +internal class DtlsClient { + /// + /// The maximum packet size for sending and receiving DTLS packets. + /// + public const int MaxPacketSize = 1400; + + /// + /// The maximum time the DTLS handshake can take in milliseconds before timing out. + /// + public const int DtlsHandshakeTimeoutMillis = 5000; + + /// + /// The socket instance for the underlying networking. + /// + private Socket _socket; + /// + /// The TLS client for communicating supported cipher suites and handling certificates. + /// + private ClientTlsClient _tlsClient; + /// + /// The client datagram transport that provides networking to the DTLS client. + /// + private ClientDatagramTransport _clientDatagramTransport; + + /// + /// Token source for cancellation tokens for the receive task. + /// + private CancellationTokenSource _receiveTaskTokenSource; + + /// + /// DTLS transport instance from establishing a connection to a server. + /// + public DtlsTransport DtlsTransport { get; private set; } + + /// + /// Event that is called when data is received from the server. + /// + public event Action DataReceivedEvent; + + /// + /// Try to establish a connection to a server with the given address and port. + /// + /// The address of the server. + /// The port of the server. + /// Thrown when the underlying socket fails to connect to the server. + /// Thrown when the DTLS protocol fails to connect to the server. + public void Connect(string address, int port) { + if (_socket != null || + _tlsClient != null || + _clientDatagramTransport != null || + DtlsTransport != null || + _receiveTaskTokenSource != null + ) { + Disconnect(); + } + + _socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + + try { + _socket.Connect(address, port); + } catch (SocketException e) { + Logger.Error($"Socket exception when connecting UDP socket:\n{e}"); + + Disconnect(); + + throw; + } + + var clientProtocol = new DtlsClientProtocol(); + _tlsClient = new ClientTlsClient(new BcTlsCrypto()); + _clientDatagramTransport = new ClientDatagramTransport(_socket); + + // Create the token source, because we need the token for the receive loop + _receiveTaskTokenSource = new CancellationTokenSource(); + var cancellationToken = _receiveTaskTokenSource.Token; + + // Start the socket receive loop, since during the DTLS connection, it needs to receive data + new Thread(() => SocketReceiveLoop(cancellationToken)).Start(); + + try { + DtlsTransport = clientProtocol.Connect(_tlsClient, _clientDatagramTransport); + } catch (TlsTimeoutException) { + Disconnect(); + throw; + } catch (IOException e) { + Logger.Error($"IO exception when connecting DTLS client:\n{e}"); + Disconnect(); + throw; + } + + Logger.Debug($"Successfully connected DTLS client to endpoint: {address}:{port}"); + + new Thread(() => DtlsReceiveLoop(cancellationToken)).Start(); + } + + /// + /// Disconnect the DTLS client from the server. This will cancel, dispose, or close all internal objects to + /// clean up potential previous connection attempts. + /// + public void Disconnect() { + _receiveTaskTokenSource?.Cancel(); + _receiveTaskTokenSource?.Dispose(); + _receiveTaskTokenSource = null; + + DtlsTransport?.Close(); + DtlsTransport = null; + + _clientDatagramTransport?.Dispose(); + _clientDatagramTransport = null; + + _tlsClient?.Cancel(); + _tlsClient = null; + + _socket?.Close(); + _socket = null; + } + + /// + /// Continuously tries to receive data from the socket until cancellation is requested. + /// + /// The cancellation token to cancel the loop. + private void SocketReceiveLoop(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + EndPoint endPoint = new IPEndPoint(IPAddress.Any, 0); + + var numReceived = 0; + var buffer = new byte[MaxPacketSize]; + + try { + numReceived = _socket.ReceiveFrom( + buffer, + SocketFlags.None, + ref endPoint + ); + } catch (SocketException e) when (e.SocketErrorCode == SocketError.Interrupted) { + // SocketError Interrupted happens when the socket is closed during the receive call + // We close the socket when the client disconnects, thus this exception is expected, so we simply break + Logger.Debug("SocketException with error code interrupted"); + break; + } catch (SocketException e) { + Logger.Error($"UDP Socket exception, ErrorCode: {e.ErrorCode}, Socket ErrorCode: {e.SocketErrorCode}, Exception:\n{e}"); + } + + if (cancellationToken.IsCancellationRequested) { + Logger.Debug("Cancellation requested"); + break; + } + + try { + _clientDatagramTransport.ReceivedDataCollection.Add(new UdpDatagramTransport.ReceivedData { + Buffer = buffer, + Length = numReceived + }, cancellationToken); + } catch (OperationCanceledException) { + Logger.Debug("OperationCanceledException"); + break; + } + } + + Logger.Debug("Receive loop cancelled"); + } + + /// + /// Continuously tries to receive data from the DTLS transport until cancellation is requested. + /// + /// The cancellation token to cancel the loop. + private void DtlsReceiveLoop(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested && DtlsTransport != null) { + var buffer = new byte[MaxPacketSize]; + var length = DtlsTransport.Receive(buffer, 0, buffer.Length, 5); + if (length >= 0) { + DataReceivedEvent?.Invoke(buffer, length); + } + } + } +} diff --git a/HKMP/Networking/Client/NetClient.cs b/HKMP/Networking/Client/NetClient.cs index 88c7d010..b4a30882 100644 --- a/HKMP/Networking/Client/NetClient.cs +++ b/HKMP/Networking/Client/NetClient.cs @@ -1,21 +1,20 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net.Sockets; using System.Threading; using Hkmp.Api.Client; using Hkmp.Api.Client.Networking; using Hkmp.Logging; +using Hkmp.Networking.Chunk; using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; using Hkmp.Util; +using Org.BouncyCastle.Tls; namespace Hkmp.Networking.Client; -/// -/// Delegate for receiving a list of packets. -/// -internal delegate void OnReceive(List receivedPackets); - /// /// The networking client that manages the UDP client for sending and receiving data. This only /// manages client side networking, e.g. sending to and receiving from the server. @@ -26,25 +25,20 @@ internal class NetClient : INetClient { /// private readonly PacketManager _packetManager; - /// - /// The underlying UDP net client for networking. - /// - private readonly UdpNetClient _udpNetClient; - /// /// The client update manager for this net client. /// - public ClientUpdateManager UpdateManager { get; private set; } + public ClientUpdateManager UpdateManager { get; } /// /// Event that is called when the client connects to a server. /// - public event Action ConnectEvent; + public event Action ConnectEvent; /// /// Event that is called when the client fails to connect to a server. /// - public event Action ConnectFailedEvent; + public event Action ConnectFailedEvent; /// /// Event that is called when the client disconnects from a server. @@ -57,142 +51,56 @@ internal class NetClient : INetClient { public event Action TimeoutEvent; /// - /// Boolean denoting whether the client is connected to a server. + /// The connection status of the client. /// - public bool IsConnected { get; private set; } - + public ClientConnectionStatus ConnectionStatus { get; private set; } = ClientConnectionStatus.NotConnected; + + /// + public bool IsConnected => ConnectionStatus == ClientConnectionStatus.Connected; + /// - /// Boolean denoting whether the client is currently attempting connection. + /// The DTLS client instance for handling DTLS connections. /// - public bool IsConnecting { get; private set; } + private readonly DtlsClient _dtlsClient; /// - /// Cancellation token source for the task for the update manager. + /// Chunk sender instance for sending large amounts of data. /// - private CancellationTokenSource _updateTaskTokenSource; - + private readonly ClientChunkSender _chunkSender; /// - /// Construct the net client with the given packet manager. + /// Chunk receiver instance for receiving large amounts of data. /// - /// The packet manager instance. - public NetClient(PacketManager packetManager) { - _packetManager = packetManager; - - _udpNetClient = new UdpNetClient(); - - // Register the same function for both TCP and UDP receive callbacks - _udpNetClient.RegisterOnReceive(OnReceiveData); - } + private readonly ClientChunkReceiver _chunkReceiver; /// - /// Callback method for when the client receives a login response from a server connection. + /// The client connection manager responsible for handling sending and receiving connection data. /// - /// The LoginResponse packet data. - private void OnConnect(LoginResponse loginResponse) { - Logger.Debug("Connection to server success"); - - // De-register the connect failed and register the actual timeout handler if we time out - UpdateManager.OnTimeout -= OnConnectTimedOut; - UpdateManager.OnTimeout += () => { ThreadUtil.RunActionOnMainThread(() => { TimeoutEvent?.Invoke(); }); }; - - // Invoke callback if it exists on the main thread of Unity - ThreadUtil.RunActionOnMainThread(() => { ConnectEvent?.Invoke(loginResponse); }); - - IsConnected = true; - IsConnecting = false; - } + private readonly ClientConnectionManager _connectionManager; /// - /// Callback method for when the client connection times out. + /// Byte array containing received data that was not included in a packet object yet. /// - private void OnConnectTimedOut() => OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.TimedOut - }); + private byte[] _leftoverData; /// - /// Callback method for when the client connection fails. + /// Construct the net client with the given packet manager. /// - /// The connection failed result. - private void OnConnectFailed(ConnectFailedResult result) { - Logger.Debug($"Connection to server failed, cause: {result.Type}"); - - UpdateManager?.StopUpdates(); - - IsConnected = false; - IsConnecting = false; - - // Request cancellation for the update task - _updateTaskTokenSource?.Cancel(); + /// The packet manager instance. + public NetClient(PacketManager packetManager) { + _packetManager = packetManager; - // Invoke callback if it exists on the main thread of Unity - ThreadUtil.RunActionOnMainThread(() => { ConnectFailedEvent?.Invoke(result); }); - } + _dtlsClient = new DtlsClient(); - /// - /// Callback method for when the net client receives data. - /// - /// A list of raw received packets. - private void OnReceiveData(List packets) { - foreach (var packet in packets) { - // Create a ClientUpdatePacket from the raw packet instance, - // and read the values into it - var clientUpdatePacket = new ClientUpdatePacket(packet); - if (!clientUpdatePacket.ReadPacket()) { - // If ReadPacket returns false, we received a malformed packet, which we simply ignore for now - continue; - } + UpdateManager = new ClientUpdateManager(); - UpdateManager.OnReceivePacket(clientUpdatePacket); - - // If we are not yet connected we check whether this packet contains a login response, - // so we can finish connecting - if (!IsConnected) { - if (clientUpdatePacket.GetPacketData().TryGetValue( - ClientPacketId.LoginResponse, - out var packetData) - ) { - var loginResponse = (LoginResponse) packetData; - - switch (loginResponse.LoginResponseStatus) { - case LoginResponseStatus.Success: - OnConnect(loginResponse); - break; - case LoginResponseStatus.NotWhiteListed: - OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.NotWhiteListed - }); - return; - case LoginResponseStatus.Banned: - OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.Banned - }); - return; - case LoginResponseStatus.InvalidAddons: - OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.InvalidAddons, - AddonData = loginResponse.AddonData - }); - return; - case LoginResponseStatus.InvalidUsername: - OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.InvalidUsername - }); - return; - default: - OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.Unknown - }); - return; - } - - break; - } - } + _chunkSender = new ClientChunkSender(UpdateManager); + _chunkReceiver = new ClientChunkReceiver(UpdateManager); + _connectionManager = new ClientConnectionManager(_packetManager, _chunkSender, _chunkReceiver); - _packetManager.HandleClientPacket(clientUpdatePacket); - } + _dtlsClient.DataReceivedEvent += OnReceiveData; + _connectionManager.ServerInfoReceivedEvent += OnServerInfoReceived; } - + /// /// Starts establishing a connection with the given host on the given port. /// @@ -208,41 +116,46 @@ public void Connect( string authKey, List addonData ) { - IsConnecting = true; - - try { - _udpNetClient.Connect(address, port); - } catch (SocketException e) { - Logger.Error($"Failed to connect due to SocketException:\n{e}"); - - OnConnectFailed(new ConnectFailedResult { - Type = ConnectFailedResult.FailType.SocketException - }); - return; - } - - UpdateManager = new ClientUpdateManager(_udpNetClient.UdpSocket); - // During the connection process we register the connection failed callback if we time out - UpdateManager.OnTimeout += OnConnectTimedOut; - UpdateManager.StartUpdates(); + Logger.Debug($"Trying to connect NetClient to '{address}:{port}'"); + ConnectionStatus = ClientConnectionStatus.Connecting; - // Start a thread that will process the updates for the update manager - // Also make a cancellation token source so we can cancel the thread on demand - _updateTaskTokenSource = new CancellationTokenSource(); - var cancellationToken = _updateTaskTokenSource.Token; + // Start a new thread for establishing the connection, otherwise Unity will hang new Thread(() => { - while (!cancellationToken.IsCancellationRequested) { - UpdateManager.ProcessUpdate(); - - // TODO: figure out a good way to get rid of the sleep here - // some way to signal when clients should be updated again would suffice - // also see NetServer#StartClientUpdates - Thread.Sleep(5); + try { + _dtlsClient.Connect(address, port); + } catch (TlsTimeoutException) { + Logger.Info("DTLS connection timed out"); + + HandleConnectFailed(new ConnectionFailedResult { + Reason = ConnectionFailedReason.TimedOut + }); + return; + } catch (SocketException e) { + Logger.Error($"Failed to connect due to SocketException:\n{e}"); + + HandleConnectFailed(new ConnectionFailedResult { + Reason = ConnectionFailedReason.SocketException + }); + return; + } catch (IOException e) { + Logger.Error($"Failed to connect due to IOException:\n{e}"); + + HandleConnectFailed(new ConnectionFailedResult { + Reason = ConnectionFailedReason.IOException + }); + return; } - }).Start(); - UpdateManager.SetLoginRequestData(username, authKey, addonData); - Logger.Debug("Sending login request"); + UpdateManager.DtlsTransport = _dtlsClient.DtlsTransport; + // During the connection process we register the connection failed callback if we time out + UpdateManager.TimeoutEvent += OnConnectTimedOut; + + UpdateManager.StartUpdates(); + + Logger.Debug("Starting connection with connection manager"); + _chunkSender.Start(); + _connectionManager.StartConnection(username, authKey, addonData); + }).Start(); } /// @@ -250,21 +163,136 @@ List addonData /// public void Disconnect() { UpdateManager.StopUpdates(); + UpdateManager.TimeoutEvent -= OnConnectTimedOut; + UpdateManager.TimeoutEvent -= OnUpdateTimedOut; + + _chunkSender.Stop(); + _chunkReceiver.Reset(); + + _dtlsClient.Disconnect(); - _udpNetClient.Disconnect(); - - IsConnected = false; - - // Request cancellation for the update task - _updateTaskTokenSource.Cancel(); + ConnectionStatus = ClientConnectionStatus.NotConnected; // Clear all client addon packet handlers, because their IDs become invalid - _packetManager.ClearClientAddonPacketHandlers(); + _packetManager.ClearClientAddonUpdatePacketHandlers(); // Invoke callback if it exists DisconnectEvent?.Invoke(); } + /// + /// Callback method for when the DTLS client receives data. This will update the update manager that we have + /// received data, handle packet creation from raw data, handle login responses, and forward received packets to + /// the packet manager. + /// + /// Byte array containing the received bytes. + /// The number of bytes in the . + private void OnReceiveData(byte[] buffer, int length) { + if (ConnectionStatus == ClientConnectionStatus.NotConnected) { + Logger.Error("Client is not connected to a server, but received data, ignoring"); + return; + } + + var packets = PacketManager.HandleReceivedData(buffer, length, ref _leftoverData); + + foreach (var packet in packets) { + // Create a ClientUpdatePacket from the raw packet instance, + // and read the values into it + var clientUpdatePacket = new ClientUpdatePacket(); + if (!clientUpdatePacket.ReadPacket(packet)) { + // If ReadPacket returns false, we received a malformed packet, which we simply ignore for now + continue; + } + + UpdateManager.OnReceivePacket(clientUpdatePacket); + + // First check for slice or slice ack data and handle it separately by passing it onto either the chunk + // sender or chunk receiver + var packetData = clientUpdatePacket.GetPacketData(); + if (packetData.TryGetValue(ClientUpdatePacketId.Slice, out var sliceData)) { + packetData.Remove(ClientUpdatePacketId.Slice); + _chunkReceiver.ProcessReceivedData((SliceData) sliceData); + } + + if (packetData.TryGetValue(ClientUpdatePacketId.SliceAck, out var sliceAckData)) { + packetData.Remove(ClientUpdatePacketId.SliceAck); + _chunkSender.ProcessReceivedData((SliceAckData) sliceAckData); + } + + // Then, if we are already connected to a server, we let the packet manager handle the rest of the packet + // data + if (ConnectionStatus == ClientConnectionStatus.Connected) { + _packetManager.HandleClientUpdatePacket(clientUpdatePacket); + } + } + } + + private void OnServerInfoReceived(ServerInfo serverInfo) { + if (serverInfo.ConnectionResult == ServerConnectionResult.Accepted) { + Logger.Debug("Connection to server accepted"); + + // De-register the "connect failed" and register the actual timeout handler if we time out + UpdateManager.TimeoutEvent -= OnConnectTimedOut; + UpdateManager.TimeoutEvent += OnUpdateTimedOut; + + // Invoke callback if it exists on the main thread of Unity + ThreadUtil.RunActionOnMainThread(() => { ConnectEvent?.Invoke(serverInfo); }); + + ConnectionStatus = ClientConnectionStatus.Connected; + return; + } + + ConnectionFailedResult result; + + if (serverInfo.ConnectionResult == ServerConnectionResult.InvalidAddons) { + Logger.Debug("Connection to server failed due to invalid addons"); + + result = new ConnectionInvalidAddonsResult { + Reason = ConnectionFailedReason.InvalidAddons, + AddonData = serverInfo.AddonData + }; + } else if (serverInfo.ConnectionResult == ServerConnectionResult.RejectedOther) { + Logger.Debug($"Connection to server failed, message: {serverInfo.ConnectionRejectedMessage}"); + + result = new ConnectionFailedMessageResult { + Reason = ConnectionFailedReason.Other, + Message = serverInfo.ConnectionRejectedMessage + }; + } else { + throw new NotImplementedException("Unknown connection result in server info"); + } + + UpdateManager?.StopUpdates(); + + ConnectionStatus = ClientConnectionStatus.NotConnected; + + // Invoke callback if it exists on the main thread of Unity + ThreadUtil.RunActionOnMainThread(() => { ConnectFailedEvent?.Invoke(result); }); + } + + /// + /// Callback method for when the client connection fails. + /// + private void OnConnectTimedOut() => HandleConnectFailed(new ConnectionFailedResult { + Reason = ConnectionFailedReason.TimedOut + }); + + /// + /// Callback method for when the client times out while connected. + /// + private void OnUpdateTimedOut() { + ThreadUtil.RunActionOnMainThread(() => { TimeoutEvent?.Invoke(); }); + } + + /// + /// Handles a failed connection with the given result. + /// + private void HandleConnectFailed(ConnectionFailedResult result) { + Disconnect(); + + ConnectFailedEvent?.Invoke(result); + } + /// public IClientAddonNetworkSender GetNetworkSender( ClientAddon addon @@ -331,31 +359,3 @@ Func packetInstantiator return addon.NetworkReceiver as IClientAddonNetworkReceiver; } } - -/// -/// Class that stores the result of a failed connection. -/// -internal class ConnectFailedResult { - /// - /// The type of the failed connection. - /// - public FailType Type { get; set; } - - /// - /// If the type for failing is having invalid addons, this field contains the addon data that we should have. - /// - public List AddonData { get; set; } - - /// - /// Enumeration of fail types. - /// - public enum FailType { - NotWhiteListed, - Banned, - InvalidAddons, - InvalidUsername, - TimedOut, - SocketException, - Unknown - } -} diff --git a/HKMP/Networking/Client/UdpNetClient.cs b/HKMP/Networking/Client/UdpNetClient.cs deleted file mode 100644 index 5f94d60c..00000000 --- a/HKMP/Networking/Client/UdpNetClient.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using System.Threading; -using Hkmp.Logging; -using Hkmp.Networking.Packet; - -namespace Hkmp.Networking.Client; - -/// -/// NetClient that uses the UDP protocol. -/// -internal class UdpNetClient { - /// - /// Maximum size of a UDP packet in bytes. - /// - private const int MaxUdpPacketSize = 65527; - - /// - /// The underlying UDP socket. - /// - public Socket UdpSocket; - - /// - /// Delegate called when packets are received. - /// - private OnReceive _onReceive; - - /// - /// Byte array containing received data that was not included in a packet object yet. - /// - private byte[] _leftoverData; - - /// - /// Cancellation token source for the thread of receiving network data. - /// - private CancellationTokenSource _receiveTokenSource; - - /// - /// Register a callback for when packets are received. - /// - /// The delegate that handles the received packets. - public void RegisterOnReceive(OnReceive onReceive) { - _onReceive = onReceive; - } - - /// - /// Connects the UDP socket to the host at the given address and port. - /// - /// The address of the host. - /// The port of the host. - public void Connect(string address, int port) { - UdpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - try { - UdpSocket.Connect(address, port); - } catch (SocketException e) { - Logger.Error($"Socket exception when connecting UDP socket:\n{e}"); - - UdpSocket.Close(); - UdpSocket = null; - - throw; - } - - Logger.Debug($"Starting receiving UDP data on endpoint {UdpSocket.LocalEndPoint}"); - - // Start a thread to receive network data and create a corresponding cancellation token - _receiveTokenSource = new CancellationTokenSource(); - new Thread(() => ReceiveData(_receiveTokenSource.Token)).Start(); - } - - /// - /// Continuously receive network UDP data and queue it for processing. - /// - /// The cancellation token for checking whether this method is requested to cancel. - private void ReceiveData(CancellationToken token) { - EndPoint endPoint = new IPEndPoint(IPAddress.Any, 0); - - while (!token.IsCancellationRequested) { - var buffer = new byte[MaxUdpPacketSize]; - - try { - UdpSocket.ReceiveFrom( - buffer, - SocketFlags.None, - ref endPoint - ); - } catch (SocketException e) { - Logger.Error($"UDP Socket exception:\n{e}"); - } - - var packets = PacketManager.HandleReceivedData(buffer, ref _leftoverData); - - _onReceive?.Invoke(packets); - } - } - - /// - /// Disconnect the UDP client and clean it up. - /// - public void Disconnect() { - // Request cancellation of the receive thread - _receiveTokenSource.Cancel(); - - UdpSocket?.Close(); - UdpSocket = null; - } -} diff --git a/HKMP/Networking/ConnectionManager.cs b/HKMP/Networking/ConnectionManager.cs new file mode 100644 index 00000000..bf857283 --- /dev/null +++ b/HKMP/Networking/ConnectionManager.cs @@ -0,0 +1,48 @@ +using Hkmp.Networking.Packet; + +namespace Hkmp.Networking; + +/// +/// Abstract base class that manages handling the initial connection to a server. +/// +internal abstract class ConnectionManager { + /// + /// The maximum size that a slice can be in bytes. + /// + public const int MaxSliceSize = 1024; + + /// + /// The maximum number of slices in a chunk. + /// + public const int MaxSlicesPerChunk = 256; + + /// + /// The maximum size of a chunk in bytes. + /// + public const int MaxChunkSize = MaxSliceSize * MaxSlicesPerChunk; + + /// + /// The number of milliseconds a connection attempt can maximally take before being timed out. + /// + public const int TimeoutMillis = 60000; + + /// + /// The packet manager instance to register handlers for slice and slice ack data. + /// + protected readonly PacketManager PacketManager; + + protected ConnectionManager(PacketManager packetManager) { + PacketManager = packetManager; + } + + /// + /// Check whether the first ID is smaller than the second ID. Accounts for ID wrap-around, by inverse comparison + /// if differences are larger than half of the ID number space. + /// + /// The first ID as a byte. + /// The second ID as a byte. + /// True if the first ID is smaller than the second ID, false otherwise. + public static bool IsWrappingIdSmaller(byte id1, byte id2) { + return id1 < id2 && id2 - id1 <= 128 || id1 > id2 && id1 - id2 > 128; + } +} diff --git a/HKMP/Networking/Packet/AddonPacketData.cs b/HKMP/Networking/Packet/AddonPacketData.cs index 6d05fbdf..5a97f095 100644 --- a/HKMP/Networking/Packet/AddonPacketData.cs +++ b/HKMP/Networking/Packet/AddonPacketData.cs @@ -20,7 +20,7 @@ internal class AddonPacketData { /// /// Enumerator to go over each packet ID in the packet ID space of this addon. /// - public IEnumerator PacketIdEnumerator { + public IEnumerable PacketIdEnumerable { get { if (_packetIdArray == null) { // Create an array containing all possible IDs for this addon @@ -31,7 +31,7 @@ public IEnumerator PacketIdEnumerator { } // Return a fresh enumerator for the ID space - return ((IEnumerable) _packetIdArray).GetEnumerator(); + return _packetIdArray; } } diff --git a/HKMP/Networking/Packet/BasePacket.cs b/HKMP/Networking/Packet/BasePacket.cs new file mode 100644 index 00000000..3212abcb --- /dev/null +++ b/HKMP/Networking/Packet/BasePacket.cs @@ -0,0 +1,517 @@ +using System; +using System.Collections.Generic; +using Hkmp.Logging; + +namespace Hkmp.Networking.Packet; + +/// +/// Abstract base class for the packets containing structured packet data. +/// +/// The enum type for packet IDs in this packet. +internal abstract class BasePacket where TPacketId : Enum { + // ReSharper disable once StaticMemberInGenericType + /// + /// A dictionary containing addon packet info per addon ID in order to read and convert raw addon + /// packet data into IPacketData instances. + /// + public static Dictionary AddonPacketInfoDict { get; } = new(); + + // TODO: refactor these dictionaries into a class that contains them for readability + /// + /// Normal non-resend packet data. + /// + protected readonly Dictionary NormalPacketData; + + /// + /// Packet data from addons indexed by their ID. + /// + protected readonly Dictionary AddonPacketData; + + /// + /// The combination of normal and resent packet data cached in case it needs to be queried multiple times. + /// + protected Dictionary CachedAllPacketData; + + /// + /// The combination of addon and resent addon data cached in case it needs to be queried multiple times. + /// + protected Dictionary CachedAllAddonData; + + /// + /// Whether the dictionary containing all packet data is cached already or needs to be calculated first. + /// + protected bool IsAllPacketDataCached; + + /// + /// Whether this packet contains data that needs to be reliable. + /// + public bool ContainsReliableData { get; protected set; } + + /// + /// Construct the update packet with the given raw packet instance to read from. + /// + protected BasePacket() { + NormalPacketData = new Dictionary(); + AddonPacketData = new Dictionary(); + } + + /// + /// Write the given dictionary of normal or resent packet data into the given raw packet instance. + /// + /// The packet to write into. + /// Dictionary of packet data to write. + /// true if any of the data written was reliable; otherwise false. + protected bool WritePacketData( + Packet packet, + Dictionary packetData + ) { + var enumValues = (TPacketId[]) Enum.GetValues(typeof(TPacketId)); + var packetIdSize = (byte) enumValues.Length; + + return WritePacketData( + packet, + packetData, + enumValues, + packetIdSize + ); + } + + /// + /// Write the data in the given instance of AddonPacketData into the given raw packet instance. + /// + /// The packet to write into. + /// AddonPacketData instance from which to data should be written. + /// true if any of the data written was reliable; otherwise false. + private bool WriteAddonPacketData( + Packet packet, + AddonPacketData addonPacketData + ) => WritePacketData( + packet, + addonPacketData.PacketData, + addonPacketData.PacketIdEnumerable, + addonPacketData.PacketIdSize + ); + + /// + /// Write the given dictionary of packet data into the given raw packet instance. + /// + /// The packet to write into. + /// The dictionary containing packet data to write in the packet. + /// An enumerator that enumerates over all possible keys in the dictionary. + /// The exact size of the key space. + /// Dictionary key parameter and enumerator parameter. + /// true if any of the data written was reliable; otherwise false. + private bool WritePacketData( + Packet packet, + Dictionary packetData, + IEnumerable keyEnumerable, + byte keySpaceSize + ) { + // Keep track of the bit flag in an unsigned long, which is the largest integer implicit type allowed + ulong idFlag = 0; + // Also keep track of the value of the current bit in an unsigned long + ulong currentTypeValue = 1; + + var keyEnumerator = keyEnumerable.GetEnumerator(); + while (keyEnumerator.MoveNext()) { + var key = keyEnumerator.Current; + + // Update the bit in the flag if the current value is included in the dictionary + if (key != null && packetData.ContainsKey(key)) { + idFlag |= currentTypeValue; + } + + // Always increase the current bit + currentTypeValue *= 2; + } + + // Based on the size of the values space, we cast to the smallest primitive that can hold the flag + // and write it to the packet + if (keySpaceSize <= 8) { + packet.Write((byte) idFlag); + } else if (keySpaceSize <= 16) { + packet.Write((ushort) idFlag); + } else if (keySpaceSize <= 32) { + packet.Write((uint) idFlag); + } else if (keySpaceSize <= 64) { + packet.Write(idFlag); + } + + // Let each individual piece of packet data write themselves into the packet + // and keep track of whether any of them need to be reliable + var containsReliableData = false; + // We loop over the possible IDs in the order from the given array to make it + // consistent between server and client + keyEnumerator.Reset(); + while (keyEnumerator.MoveNext()) { + var key = keyEnumerator.Current; + + if (key != null && packetData.TryGetValue(key, out var iPacketData)) { + iPacketData.WriteData(packet); + + if (iPacketData.IsReliable) { + containsReliableData = true; + } + } + } + + keyEnumerator.Dispose(); + + return containsReliableData; + } + + /// + /// Write the given dictionary containing addon data for all addons in the given packet. + /// + /// The raw packet instance to write into. + /// The dictionary containing all addon data to write. + /// true if any of the data written was reliable; otherwise false. + protected bool WriteAddonDataDict( + Packet packet, + Dictionary addonDataDict + ) { + // Normally, we put the length of the addon packet data as a byte in the packet. + // There should only be a maximum of 255 addons, so the length should fit in a byte. + // But we don't know which addon data is going to get written correctly and which throws + // an exception, so for now we hold off on writing anything yet, but keep track of how + // many instances we are writing + var addonPacketDataCount = (byte) addonDataDict.Count; + + // We also construct a temporary packet that we use to write the progress of all + // addon packet data into. This temp packet we can then later write into the original + // packet as soon as we know the number of successful addon packet data instances we + // have written. + var addonPacketDataPacket = new Packet(); + + // Also keep track of whether we have written reliable data + var containsReliable = false; + + // Add the packet data per addon ID + foreach (var addonPacketDataPair in addonDataDict) { + var addonId = addonPacketDataPair.Key; + var addonPacketData = addonPacketDataPair.Value; + + // Create a new packet to try and write addon packet data into + var addonPacket = new Packet(); + bool addonContainsReliable; + try { + addonContainsReliable = WriteAddonPacketData( + addonPacket, + addonPacketData + ); + } catch (Exception e) { + // If the addon data writing throws an exception, we skip it entirely and since we + // wrote it in a separate packet, it has no impact on the regular packet + Logger.Debug($"Addon with ID {addonId} has thrown an exception while writing addon packet data:\n{e}"); + // We decrease the count of addon packet data's we write, so we know how many are actually in + // final packet + addonPacketDataCount--; + continue; + } + + // Prepend the length of the addon packet data to the addon packet + addonPacket.WriteLength(); + + // Now we add the addon ID to the addon packet data packet and then the contents of the addon packet + addonPacketDataPacket.Write(addonId); + addonPacketDataPacket.Write(addonPacket.ToArray()); + + // Potentially update whether this packet contains reliable data now + containsReliable |= addonContainsReliable; + } + + // Finally write the resulting size and the addon packet data itself in the regular packet + packet.Write(addonPacketDataCount); + packet.Write(addonPacketDataPacket.ToArray()); + + return containsReliable; + } + + /// + /// Read raw data from the given packet into the given packet data dictionary. + /// This method is only for normal and resent packet data, not for addon packet data. + /// + /// The raw packet instance to read from. + /// The dictionary of packet data to write the read data into. + protected void ReadPacketData( + Packet packet, + Dictionary packetData + ) { + // Figure out the size of the packet ID enum + var enumValues = (TPacketId[]) Enum.GetValues(typeof(TPacketId)); + var packetIdSize = (byte) enumValues.Length; + + // Read the byte flag representing which packets are included in this update + // The number of bytes we read is dependent on the size of the enum + ulong dataPacketIdFlag = 0; + if (packetIdSize <= 8) { + dataPacketIdFlag = packet.ReadByte(); + } else if (packetIdSize <= 16) { + dataPacketIdFlag = packet.ReadUShort(); + } else if (packetIdSize <= 32) { + dataPacketIdFlag = packet.ReadUInt(); + } else if (packetIdSize <= 64) { + dataPacketIdFlag = packet.ReadULong(); + } + + // Keep track of value of current bit + ulong currentTypeValue = 1; + + var packetIdValues = Enum.GetValues(typeof(TPacketId)); + foreach (TPacketId packetId in packetIdValues) { + // If this bit was set in our flag, we add the type to the list + if ((dataPacketIdFlag & currentTypeValue) != 0) { + var iPacketData = InstantiatePacketDataFromId(packetId); + iPacketData?.ReadData(packet); + + packetData[packetId] = iPacketData; + } + + // Increase the value of current bit + currentTypeValue *= 2; + } + } + + /// + /// Read raw addon data from the given packet into the given addon data dictionary. + /// + /// The raw packet instance to read from. + /// The size of the packet ID space. + /// A function that instantiate IPacketData implementations given a + /// packet ID in byte form. + /// The dictionary of addon data to write the read data into. + /// Thrown if the given instantiation function returns null. + private void ReadAddonPacketData( + Packet packet, + byte packetIdSize, + Func packetDataInstantiator, + Dictionary packetData + ) { + // Read the byte flag representing which packets are included in this update + // This flag may come in different primitives based on the size of the packet + // ID space + ulong dataPacketIdFlag; + + if (packetIdSize <= 8) { + dataPacketIdFlag = packet.ReadByte(); + } else if (packetIdSize <= 16) { + dataPacketIdFlag = packet.ReadUShort(); + } else if (packetIdSize <= 32) { + dataPacketIdFlag = packet.ReadUInt(); + } else if (packetIdSize <= 64) { + dataPacketIdFlag = packet.ReadULong(); + } else { + // This should never happen, but in case it does, we throw an exception + throw new Exception("Addon packet ID space size is larger than expected"); + } + + // Keep track of value of current bit in the largest integer primitive + ulong currentTypeValue = 1; + + for (byte packetId = 0; packetId < packetIdSize; packetId++) { + // If this bit was set in our flag, we add the type to the list + if ((dataPacketIdFlag & currentTypeValue) != 0) { + IPacketData iPacketData; + + // Wrap this in try catch so we can add information on what happened when the addon submitted + // packet data instantiator throws + try { + iPacketData = packetDataInstantiator.Invoke(packetId); + } catch (Exception e) { + throw new Exception( + $"Packet data instantiator for addon data threw an exception:\n{e}"); + } + + if (iPacketData == null) { + throw new Exception("Addon packet data instantiating method returned null"); + } + + iPacketData.ReadData(packet); + + packetData[packetId] = iPacketData; + } + + // Increase the value of current bit + currentTypeValue *= 2; + } + } + + /// + /// Read all raw addon data from the given packet into the given dictionary containing entries for all addons. + /// + /// The raw packet instance to read from. + /// The dictionary for all addon data to write the read data into. + /// Thrown if any part of reading the data throws. + protected void ReadAddonDataDict( + Packet packet, + Dictionary addonDataDict + ) { + // Read the number of the addon packet data instances from the packet + var numAddonData = packet.ReadByte(); + + while (numAddonData-- > 0) { + var addonId = packet.ReadByte(); + + if (!AddonPacketInfoDict.TryGetValue(addonId, out var addonPacketInfo)) { + // If the addon packet info for this addon could not be found, we need to throw an exception + throw new Exception($"Addon with ID {addonId} has no defined addon packet info"); + } + + // Read the length of the addon packet data for this addon + var addonDataLength = packet.ReadUShort(); + + // Read exactly as many bytes as was indicated by the previously read value + var addonDataBytes = packet.ReadBytes(addonDataLength); + + // Create a new packet object with the given bytes so we can sandbox the reading + var addonPacket = new Packet(addonDataBytes); + + // Create a new instance of AddonPacketData to read packet data into and eventually + // add to this packet instance's dictionary + var addonPacketData = new AddonPacketData(addonPacketInfo.PacketIdSize); + + try { + ReadAddonPacketData( + addonPacket, + addonPacketInfo.PacketIdSize, + addonPacketInfo.PacketDataInstantiator, + addonPacketData.PacketData + ); + } catch (Exception e) { + // If the addon data reading throws an exception, we skip it entirely and since + // we read it into a separate packet, it has no impact on the regular packet + Logger.Debug($"Addon with ID {addonId} has thrown an exception while reading addon packet data:\n{e}"); + continue; + } + + addonDataDict[addonId] = addonPacketData; + } + } + + /// + /// Create a raw packet out of the data contained in this class by writing to the given packet. + /// + /// The packet instance to write the data to. + public virtual void CreatePacket(Packet packet) { + // Write the normal packet data into the packet and keep track of whether this packet contains reliable data + // now + ContainsReliableData = WritePacketData(packet, NormalPacketData); + + // Write the addon packet data into the packet and add to the fact that this packet contains reliable data now + ContainsReliableData |= WriteAddonDataDict(packet, AddonPacketData); + } + + /// + /// Read the raw packet contents into easy to access dictionaries. + /// + /// The packet instance to read the data from. + /// False if the packet cannot be successfully read due to malformed data; otherwise true. + public virtual bool ReadPacket(Packet packet) { + try { + // Read the normal packet data from the packet + ReadPacketData(packet, NormalPacketData); + + // Read the addon packet data + ReadAddonDataDict(packet, AddonPacketData); + } catch (Exception e) { + Logger.Debug($"Exception while reading base packet:\n{e}"); + return false; + } + + return true; + } + + /// + /// Tries to get packet data that is going to be sent with the given packet ID. + /// + /// The packet ID to try and get. + /// Variable to store the retrieved data in. Null if this method returns false. + /// true if the packet data exists and will be stored in the packetData variable; otherwise + /// false. + public bool TryGetSendingPacketData(TPacketId packetId, out IPacketData packetData) { + return NormalPacketData.TryGetValue(packetId, out packetData); + } + + /// + /// Tries to get addon packet data for the addon with the given ID. + /// + /// The ID of the addon to get the data for. + /// An instance of AddonPacketData corresponding to the given ID. + /// Null if this method returns false. + /// true if the addon packet data exists and will be stored in the addonPacketData variable; + /// otherwise false. + public bool TryGetSendingAddonPacketData(byte addonId, out AddonPacketData addonPacketData) { + return AddonPacketData.TryGetValue(addonId, out addonPacketData); + } + + /// + /// Sets the given packetData with the given packet ID for sending. + /// + /// The packet ID to set data for. + /// The packet data to set. + public void SetSendingPacketData(TPacketId packetId, IPacketData packetData) { + NormalPacketData[packetId] = packetData; + } + + /// + /// Sets the given addonPacketData with the given addon ID for sending. + /// + /// The addon ID to set data for. + /// Instance of AddonPacketData to set. + public void SetSendingAddonPacketData(byte addonId, AddonPacketData packetData) { + AddonPacketData[addonId] = packetData; + } + + /// + /// Get all the packet data contained in this packet, normal and resent data (but not addon data). + /// + /// A dictionary containing packet IDs mapped to packet data. + public Dictionary GetPacketData() { + if (!IsAllPacketDataCached) { + CacheAllPacketData(); + } + + return CachedAllPacketData; + } + + /// + /// Get the addon packet data in this packet, normal addon and resent data. + /// + /// A dictionary containing addon IDs mapped to addon packet data. + public Dictionary GetAddonPacketData() { + if (!IsAllPacketDataCached) { + CacheAllPacketData(); + } + + return CachedAllAddonData; + } + + /// + /// Computes all packet data (normal, resent, addon and addon resent data), caches it and sets a boolean + /// indicating that this cache is now available. + /// + protected virtual void CacheAllPacketData() { + // Construct a new dictionary for all the data + CachedAllPacketData = new Dictionary(); + + // Iteratively add the normal packet data + foreach (var packetIdDataPair in NormalPacketData) { + CachedAllPacketData.Add(packetIdDataPair.Key, packetIdDataPair.Value); + } + + // Do the same as above but for addon data + CachedAllAddonData = new Dictionary(); + + // Iteratively add the addon data + foreach (var addonIdDataPair in AddonPacketData) { + CachedAllAddonData.Add(addonIdDataPair.Key, addonIdDataPair.Value); + } + } + + /// + /// Get an instantiation of IPacketData for the given packet ID. + /// + /// The packet ID to get an instance for. + /// A new instance of IPacketData. + protected abstract IPacketData InstantiatePacketDataFromId(TPacketId packetId); +} diff --git a/HKMP/Networking/Packet/Connection/ClientConnectionPacket.cs b/HKMP/Networking/Packet/Connection/ClientConnectionPacket.cs new file mode 100644 index 00000000..c3ed9b90 --- /dev/null +++ b/HKMP/Networking/Packet/Connection/ClientConnectionPacket.cs @@ -0,0 +1,18 @@ +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Packet.Connection; + +/// +/// Packet that contains connection information for server to client communication. +/// +internal class ClientConnectionPacket : BasePacket { + /// + protected override IPacketData InstantiatePacketDataFromId(ClientConnectionPacketId packetId) { + switch (packetId) { + case ClientConnectionPacketId.ServerInfo: + return new ServerInfo(); + default: + return new EmptyData(); + } + } +} diff --git a/HKMP/Networking/Packet/Connection/ClientConnectionPacketId.cs b/HKMP/Networking/Packet/Connection/ClientConnectionPacketId.cs new file mode 100644 index 00000000..f23a4fa0 --- /dev/null +++ b/HKMP/Networking/Packet/Connection/ClientConnectionPacketId.cs @@ -0,0 +1,11 @@ +namespace Hkmp.Networking.Packet.Connection; + +/// +/// Enumeration of packet IDs for connection packet for server to client communication. +/// +internal enum ClientConnectionPacketId { + /// + /// Information about the server meant for the client detailing whether the connection was accepted. + /// + ServerInfo = 0, +} diff --git a/HKMP/Networking/Packet/Connection/ServerConnectionPacket.cs b/HKMP/Networking/Packet/Connection/ServerConnectionPacket.cs new file mode 100644 index 00000000..152c05c2 --- /dev/null +++ b/HKMP/Networking/Packet/Connection/ServerConnectionPacket.cs @@ -0,0 +1,18 @@ +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Packet.Connection; + +/// +/// Packet that contains connection information for client to server communication. +/// +internal class ServerConnectionPacket : BasePacket { + /// + protected override IPacketData InstantiatePacketDataFromId(ServerConnectionPacketId packetId) { + switch (packetId) { + case ServerConnectionPacketId.ClientInfo: + return new ClientInfo(); + default: + return new EmptyData(); + } + } +} diff --git a/HKMP/Networking/Packet/Connection/ServerConnectionPacketId.cs b/HKMP/Networking/Packet/Connection/ServerConnectionPacketId.cs new file mode 100644 index 00000000..f9b8145d --- /dev/null +++ b/HKMP/Networking/Packet/Connection/ServerConnectionPacketId.cs @@ -0,0 +1,11 @@ +namespace Hkmp.Networking.Packet.Connection; + +/// +/// Enumeration of packet IDs for the connection packet for client to server communication. +/// +internal enum ServerConnectionPacketId { + /// + /// Information about the client that the server can use to determine whether to accept the connection. + /// + ClientInfo = 0, +} diff --git a/HKMP/Networking/Packet/Data/LoginRequest.cs b/HKMP/Networking/Packet/Data/ClientInfo.cs similarity index 88% rename from HKMP/Networking/Packet/Data/LoginRequest.cs rename to HKMP/Networking/Packet/Data/ClientInfo.cs index ebe18308..8d040f09 100644 --- a/HKMP/Networking/Packet/Data/LoginRequest.cs +++ b/HKMP/Networking/Packet/Data/ClientInfo.cs @@ -6,14 +6,14 @@ namespace Hkmp.Networking.Packet.Data; /// -/// Packet data for the login request data. +/// Packet data for the client info data. /// -internal class LoginRequest : IPacketData { +internal class ClientInfo : IPacketData { /// - public bool IsReliable => true; + public bool IsReliable => false; /// - public bool DropReliableDataIfNewerExists => true; + public bool DropReliableDataIfNewerExists => false; /// /// The username of the client. @@ -28,20 +28,15 @@ internal class LoginRequest : IPacketData { /// /// A list of addon data of the client. /// - public List AddonData { get; } - - /// - /// Construct the login request. - /// - public LoginRequest() { - AddonData = new List(); - } + public List AddonData { get; set; } /// public void WriteData(IPacket packet) { packet.Write(Username); packet.Write(AuthKey); + AddonData ??= []; + var addonDataLength = (byte) System.Math.Min(byte.MaxValue, AddonData.Count); packet.Write(addonDataLength); @@ -59,6 +54,8 @@ public void ReadData(IPacket packet) { var addonDataLength = packet.ReadByte(); + AddonData = []; + for (var i = 0; i < addonDataLength; i++) { var id = packet.ReadString(); var version = packet.ReadString(); diff --git a/HKMP/Networking/Packet/Data/EntitySpawn.cs b/HKMP/Networking/Packet/Data/EntitySpawn.cs new file mode 100644 index 00000000..ea339895 --- /dev/null +++ b/HKMP/Networking/Packet/Data/EntitySpawn.cs @@ -0,0 +1,43 @@ +using Hkmp.Game.Client.Entity; + +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet data for an entity spawn. Either from entering a scene or from spawning while in a scene. +/// +internal class EntitySpawn : IPacketData { + /// + public bool IsReliable => true; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// The ID of the spawned entity. + /// + public ushort Id { get; set; } + + /// + /// The type of the entity that spawned the new entity. + /// + public EntityType SpawningType { get; set; } + + /// + /// The type of the entity that was spawned. + /// + public EntityType SpawnedType { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write(Id); + packet.Write((ushort) SpawningType); + packet.Write((ushort) SpawnedType); + } + + /// + public void ReadData(IPacket packet) { + Id = packet.ReadUShort(); + SpawningType = (EntityType) packet.ReadUShort(); + SpawnedType = (EntityType) packet.ReadUShort(); + } +} diff --git a/HKMP/Networking/Packet/Data/EntityUpdate.cs b/HKMP/Networking/Packet/Data/EntityUpdate.cs index 316de974..f6b91db4 100644 --- a/HKMP/Networking/Packet/Data/EntityUpdate.cs +++ b/HKMP/Networking/Packet/Data/EntityUpdate.cs @@ -1,28 +1,42 @@ using System; using System.Collections.Generic; +using Hkmp.Game.Client.Entity.Component; +using Hkmp.Logging; using Hkmp.Math; namespace Hkmp.Networking.Packet.Data; /// -/// Packet data for an entity update. +/// Base entity update class for reliable and non-reliable entity update data. /// -internal class EntityUpdate : IPacketData { +internal abstract class BaseEntityUpdate : IPacketData { /// - public bool IsReliable => false; + public abstract bool IsReliable { get; } /// - public bool DropReliableDataIfNewerExists => false; - - /// - /// The type of the entity. - /// - public byte EntityType { get; set; } - + public abstract bool DropReliableDataIfNewerExists { get; } + /// /// The ID of the entity. /// - public byte Id { get; set; } + public ushort Id { get; set; } + + /// + public abstract void WriteData(IPacket packet); + + /// + public abstract void ReadData(IPacket packet); +} + +/// +/// Packet data for the non-reliable part of an entity update. +/// +internal class EntityUpdate : BaseEntityUpdate { + /// + public override bool IsReliable => false; + + /// + public override bool DropReliableDataIfNewerExists => false; /// /// A set containing the types of updates contained in this packet. @@ -35,26 +49,29 @@ internal class EntityUpdate : IPacketData { public Vector2 Position { get; set; } /// - /// The state of the entity. + /// The scale data of the entity. /// - public byte State { get; set; } - + public ScaleData Scale { get; set; } + + /// + /// The ID of the animation of the entity. + /// + public byte AnimationId { get; set; } /// - /// A list of variables for the entity. + /// The wrap mode of the animation. /// - public List Variables { get; } + public byte AnimationWrapMode { get; set; } /// /// Construct the entity update data. /// public EntityUpdate() { UpdateTypes = new HashSet(); - Variables = new List(); + Scale = new ScaleData(); } /// - public void WriteData(IPacket packet) { - packet.Write(EntityType); + public override void WriteData(IPacket packet) { packet.Write(Id); // Construct the byte flag representing update types @@ -80,24 +97,19 @@ public void WriteData(IPacket packet) { packet.Write(Position); } - if (UpdateTypes.Contains(EntityUpdateType.State)) { - packet.Write(State); + if (UpdateTypes.Contains(EntityUpdateType.Scale)) { + Scale.WriteData(packet); } - if (UpdateTypes.Contains(EntityUpdateType.Variables)) { - // First write the number of bytes we are writing - packet.Write((byte) Variables.Count); - - foreach (var b in Variables) { - packet.Write(b); - } + if (UpdateTypes.Contains(EntityUpdateType.Animation)) { + packet.Write(AnimationId); + packet.Write(AnimationWrapMode); } } /// - public void ReadData(IPacket packet) { - EntityType = packet.ReadByte(); - Id = packet.ReadByte(); + public override void ReadData(IPacket packet) { + Id = packet.ReadUShort(); // Read the byte flag representing update types and reconstruct it var updateTypeFlag = packet.ReadByte(); @@ -118,20 +130,657 @@ public void ReadData(IPacket packet) { if (UpdateTypes.Contains(EntityUpdateType.Position)) { Position = packet.ReadVector2(); } + + if (UpdateTypes.Contains(EntityUpdateType.Scale)) { + Scale.ReadData(packet); + } + + if (UpdateTypes.Contains(EntityUpdateType.Animation)) { + AnimationId = packet.ReadByte(); + AnimationWrapMode = packet.ReadByte(); + } + } + + /// + /// Data class containing compact information about an entity's scale, which can be more efficiently networked. + /// + public class ScaleData { + /// + /// Whether this instance originates from a client. This influences how to write certain data. + /// + public bool origin { private get; init; } + + /// + /// Whether the x of the scale is defined. + /// + public bool x { get; set; } + /// + /// Whether the y of the scale is defined. + /// + public bool y { get; set; } + /// + /// Whether the z of the scale is defined. + /// + public bool z { get; set; } + + /// + /// Whether the x of the scale is only flipped from positive to negative or vice versa. + /// + public bool xFlipped { get; set; } + /// + /// Whether the y of the scale is only flipped from positive to negative or vice versa. + /// + public bool yFlipped { get; set; } + /// + /// Whether the z of the scale is only flipped from positive to negative or vice versa. + /// + public bool zFlipped { get; set; } + + /// + /// The float value for the x of the scale. + /// + public float xScale { get; set; } + /// + /// The float value for the y of the scale. + /// + public float yScale { get; set; } + /// + /// The float value for the z of the scale. + /// + public float zScale { get; set; } + + /// + /// Whether the x of the scale is positive if it was only flipped. + /// + public bool xPos { get; private set; } + /// + /// Whether the y of the scale is positive if it was only flipped. + /// + public bool yPos { get; private set; } + /// + /// Whether the z of the scale is positive if it was only flipped. + /// + public bool zPos { get; private set; } + + /// + /// Whether this data instance is empty (no x, y and z defined). + /// + public bool IsEmpty => !x && !y && !z; + + /// + public void WriteData(IPacket packet) { + // Logger.Debug($"ScaleData.WriteData x: {x}, y: {y}, z: {z}, xFlipped: {xFlipped}, yFlipped: {yFlipped}, zFlipped: {zFlipped}, xScale: {xScale}, yScale: {yScale}, zScale: {zScale}"); + + // 0 0 0 0 0 0 0 0 + byte flagByte = 0; + + if (x && !y && !z) { + // Only x defined + // ( 1 0 ) 0 0 0 0 0 0 + flagByte |= 1; + } else if (!x && y && !z) { + // Only y defined + // ( 0 1 ) 0 0 0 0 0 0 + flagByte |= 2; + } else if (x && y && !z) { + // Only x and y defined + // ( 1 1 ) 0 0 0 0 0 0 + flagByte |= 3; + } + + if (xFlipped) { + // 1 x ( 1 ) 0 0 0 0 0 + flagByte |= 4; + + if ((origin && xScale > 0) || (!origin && xPos)) { + // 1 x 1 ( 1 ) 0 0 0 0 + flagByte |= 8; + } + } + + if (yFlipped) { + // 1 x x x ( 1 ) 0 0 0 + flagByte |= 16; + + if ((origin && yScale > 0) || (!origin && yPos)) { + // 1 x x x 1 ( 1 ) 0 0 + flagByte |= 32; + } + } + + if (zFlipped) { + // 1 x x x x x ( 1 ) 0 + flagByte |= 64; + + if ((origin && zScale > 0) || (!origin && zPos)) { + // 1 x x x x x 1 ( 1 ) + flagByte |= 128; + } + } + + // Logger.Debug($" Flag: {flagByte}"); + packet.Write(flagByte); + + if (x && !xFlipped) { + // Logger.Debug($" xScale: {xScale}"); + packet.Write(xScale); + } + + if (y && !yFlipped) { + // Logger.Debug($" yScale: {yScale}"); + packet.Write(yScale); + } + + if (z && !zFlipped) { + // Logger.Debug($" zScale: {zScale}"); + packet.Write(zScale); + } + } + + /// + public void ReadData(IPacket packet) { + var flagByte = packet.ReadByte(); + // Logger.Debug($"ScaleData.ReadData flag: {flagByte}"); + + var firstBit = (flagByte & 1) != 0; + var secondBit = (flagByte & 2) != 0; + + if (firstBit) { + x = true; + } + + if (secondBit) { + y = true; + } + + if (!firstBit && !secondBit) { + x = y = z = true; + } + + if ((flagByte & 4) != 0) { + xFlipped = true; + + if ((flagByte & 8) != 0) { + xPos = true; + } + } + + if ((flagByte & 16) != 0) { + yFlipped = true; + + if ((flagByte & 32) != 0) { + yPos = true; + } + } + + if ((flagByte & 64) != 0) { + zFlipped = true; + + if ((flagByte & 128) != 0) { + zPos = true; + } + } + + if (x && !xFlipped) { + xScale = packet.ReadFloat(); + // Logger.Debug($" xScale: {xScale}"); + } + + if (y && !yFlipped) { + yScale = packet.ReadFloat(); + // Logger.Debug($" yScale: {yScale}"); + } + + if (z && !zFlipped) { + zScale = packet.ReadFloat(); + // Logger.Debug($" zScale: {zScale}"); + } + + // Logger.Debug($" x: {x}, y: {y}, z: {z}, xFlipped: {xFlipped}, yFlipped: {yFlipped}, zFlipped: {zFlipped}, xPos: {xPos}, yPos: {yPos}, zPos: {zPos}"); + } + + /// + /// Merge the given data into this instance. + /// + /// Another instance of ScaleData. + public void Merge(ScaleData data) { + x |= data.x; + y |= data.y; + z |= data.z; + + if (data.x) { + xFlipped = data.xFlipped; + + if (data.xFlipped) { + xPos = data.xPos; + } else { + xScale = data.xScale; + } + } + + if (data.y) { + yFlipped = data.yFlipped; + + if (data.yFlipped) { + yPos = data.yPos; + } else { + yScale = data.yScale; + } + } + + if (data.z) { + zFlipped = data.zFlipped; + + if (data.zFlipped) { + zPos = data.zPos; + } else { + zScale = data.zScale; + } + } + } + + /// + public override string ToString() { + return + $"ScaleData: x: {x}, y: {y}, z: {z}, xFlipped: {xFlipped}, yFlipped: {yFlipped}, zFlipped: {zFlipped}, xPos: {xPos}, yPos: {yPos}, zPos: {zPos}, xScale: {xScale}, yScale: {yScale}, zScale: {zScale}"; + } + } +} + +/// +/// Packet data for the reliable part of an entity update. +/// +internal class ReliableEntityUpdate : BaseEntityUpdate { + /// + public override bool IsReliable => true; + + /// + public override bool DropReliableDataIfNewerExists => false; + + /// + /// A set containing the types of updates contained in this packet. + /// + public HashSet UpdateTypes { get; } + + /// + /// Whether the entity is active or not. + /// + public bool IsActive { get; set; } + + /// + /// List of generic entity network data for entity components and FSM updates. + /// + public List GenericData { get; } + + /// + /// Dictionary of data for a host entity's FSMs. + /// + public Dictionary HostFsmData { get; } + + /// + /// Construct the reliable entity update data. + /// + public ReliableEntityUpdate() { + UpdateTypes = new HashSet(); + GenericData = new List(); + HostFsmData = new Dictionary(); + } + + /// + public override void WriteData(IPacket packet) { + packet.Write(Id); + + // Construct the byte flag representing update types + byte updateTypeFlag = 0; + // Keep track of value of current bit + byte currentTypeValue = 1; + + for (var i = 0; i < Enum.GetNames(typeof(EntityUpdateType)).Length; i++) { + // Cast the current index of the loop to a PlayerUpdateType and check if it is + // contained in the update type list, if so, we add the current bit to the flag + if (UpdateTypes.Contains((EntityUpdateType) i)) { + updateTypeFlag |= currentTypeValue; + } + + currentTypeValue *= 2; + } + + // Write the update type flag + packet.Write(updateTypeFlag); + + if (UpdateTypes.Contains(EntityUpdateType.Active)) { + packet.Write(IsActive); + } + + if (UpdateTypes.Contains(EntityUpdateType.Data)) { + if (GenericData.Count > byte.MaxValue) { + Logger.Error("Length of entity network data instances exceeded max value of byte"); + } + + var length = (byte)System.Math.Min(GenericData.Count, byte.MaxValue); + + packet.Write(length); + for (var i = 0; i < length; i++) { + GenericData[i].WriteData(packet); + } + } + + if (UpdateTypes.Contains(EntityUpdateType.HostFsm)) { + var length = (byte) HostFsmData.Count; + packet.Write(length); + + foreach (var pair in HostFsmData) { + packet.Write(pair.Key); + + pair.Value.WriteData(packet); + } + } + } + + /// + public override void ReadData(IPacket packet) { + Id = packet.ReadUShort(); + + // Read the byte flag representing update types and reconstruct it + var updateTypeFlag = packet.ReadByte(); + // Keep track of value of current bit + var currentTypeValue = 1; - if (UpdateTypes.Contains(EntityUpdateType.State)) { - State = packet.ReadByte(); + for (var i = 0; i < Enum.GetNames(typeof(EntityUpdateType)).Length; i++) { + // If this bit was set in our flag, we add the type to the list + if ((updateTypeFlag & currentTypeValue) != 0) { + UpdateTypes.Add((EntityUpdateType) i); + } + + // Increase the value of current bit + currentTypeValue *= 2; + } + + if (UpdateTypes.Contains(EntityUpdateType.Active)) { + IsActive = packet.ReadBool(); } - if (UpdateTypes.Contains(EntityUpdateType.Variables)) { - // We first read how many bytes are in the array - var numBytes = packet.ReadByte(); + if (UpdateTypes.Contains(EntityUpdateType.Data)) { + var length = packet.ReadByte(); - for (var i = 0; i < numBytes; i++) { - var readByte = packet.ReadByte(); - Variables.Add(readByte); + for (var i = 0; i < length; i++) { + var entityNetworkData = new EntityNetworkData(); + entityNetworkData.ReadData(packet); + + GenericData.Add(entityNetworkData); } } + + if (UpdateTypes.Contains(EntityUpdateType.HostFsm)) { + var length = packet.ReadByte(); + + for (var i = 0; i < length; i++) { + var key = packet.ReadByte(); + + var data = new EntityHostFsmData(); + data.ReadData(packet); + + HostFsmData.Add(key, data); + } + } + } +} + +/// +/// Generic data for a networked entity. +/// +internal class EntityNetworkData { + /// + /// The type of the data. + /// + public EntityComponentType Type { get; set; } + /// + /// Packet instance containing the data for easy reading and writing of data. + /// + public Packet Packet { get; set; } + + public EntityNetworkData() { + Packet = new Packet(); + } + + /// + public void WriteData(IPacket packet) { + packet.Write((ushort) Type); + + var data = Packet.ToArray(); + + if (data.Length > ushort.MaxValue) { + Logger.Error("Length of entity network data exceeded max value of ushort"); + } + + var length = (ushort) System.Math.Min(data.Length, ushort.MaxValue); + + packet.Write(length); + for (var i = 0; i < length; i++) { + packet.Write(data[i]); + } + } + + /// + public void ReadData(IPacket packet) { + Type = (EntityComponentType) packet.ReadUShort(); + + var length = packet.ReadUShort(); + var data = new byte[length]; + + for (var i = 0; i < length; i++) { + data[i] = packet.ReadByte(); + } + + Packet = new Packet(data); + } +} + +/// +/// Class containing data for host FSMs including state and FSM variables. +/// Used to make host transfer easier since all clients receive updates on host FSM details. +/// +internal class EntityHostFsmData { + /// + /// The types of content that is in this data class. + /// + public HashSet Types { get; } + + /// + /// The index of the current (or last) state of the FSM. + /// + public byte CurrentState { get; set; } + + /// + /// Dictionary containing indices of float variables to their respective values. + /// + public Dictionary Floats { get; } + /// + /// Dictionary containing indices of int variables to their respective values. + /// + public Dictionary Ints { get; } + /// + /// Dictionary containing indices of bool variables to their respective values. + /// + public Dictionary Bools { get; } + /// + /// Dictionary containing indices of string variables to their respective values. + /// + public Dictionary Strings { get; } + /// + /// Dictionary containing indices of vector2 variables to their respective values. + /// + public Dictionary Vec2s { get; } + /// + /// Dictionary containing indices of vector3 variables to their respective values. + /// + public Dictionary Vec3s { get; } + + public EntityHostFsmData() { + Types = new HashSet(); + + Floats = new Dictionary(); + Ints = new Dictionary(); + Bools = new Dictionary(); + Strings = new Dictionary(); + Vec2s = new Dictionary(); + Vec3s = new Dictionary(); + } + + /// + /// Merges the data from the given data class into the current one. + /// + /// The other instance. + public void MergeData(EntityHostFsmData otherData) { + if (otherData.Types.Contains(Type.State)) { + Types.Add(Type.State); + + CurrentState = otherData.CurrentState; + } + + if (otherData.Types.Contains(Type.Floats)) { + Types.Add(Type.Floats); + + foreach (var pair in otherData.Floats) { + Floats[pair.Key] = pair.Value; + } + } + + if (otherData.Types.Contains(Type.Ints)) { + Types.Add(Type.Ints); + + foreach (var pair in otherData.Ints) { + Ints[pair.Key] = pair.Value; + } + } + + if (otherData.Types.Contains(Type.Bools)) { + Types.Add(Type.Bools); + + foreach (var pair in otherData.Bools) { + Bools[pair.Key] = pair.Value; + } + } + + if (otherData.Types.Contains(Type.Strings)) { + Types.Add(Type.Strings); + + foreach (var pair in otherData.Strings) { + Strings[pair.Key] = pair.Value; + } + } + + if (otherData.Types.Contains(Type.Vector2s)) { + Types.Add(Type.Vector2s); + + foreach (var pair in otherData.Vec2s) { + Vec2s[pair.Key] = pair.Value; + } + } + + if (otherData.Types.Contains(Type.Vector3s)) { + Types.Add(Type.Vector3s); + + foreach (var pair in otherData.Vec3s) { + Vec3s[pair.Key] = pair.Value; + } + } + } + + /// + public void WriteData(IPacket packet) { + // Construct the byte flag representing update types + byte updateTypeFlag = 0; + // Keep track of value of current bit + byte currentTypeValue = 1; + + for (var i = 0; i < Enum.GetNames(typeof(Type)).Length; i++) { + // Cast the current index of the loop to a PlayerUpdateType and check if it is + // contained in the update type list, if so, we add the current bit to the flag + if (Types.Contains((Type) i)) { + updateTypeFlag |= currentTypeValue; + } + + currentTypeValue *= 2; + } + + // Write the update type flag + packet.Write(updateTypeFlag); + + if (Types.Contains(Type.State)) { + packet.Write(CurrentState); + } + + void WriteVarDict(Type type, Dictionary dict, Action writeValue) { + if (Types.Contains(type)) { + var length = (byte) dict.Count; + packet.Write(length); + + foreach (var pair in dict) { + packet.Write(pair.Key); + writeValue.Invoke(pair.Value); + } + } + } + + WriteVarDict(Type.Floats, Floats, packet.Write); + WriteVarDict(Type.Ints, Ints, packet.Write); + WriteVarDict(Type.Bools, Bools, packet.Write); + WriteVarDict(Type.Strings, Strings, packet.Write); + WriteVarDict(Type.Vector2s, Vec2s, packet.Write); + WriteVarDict(Type.Vector3s, Vec3s, packet.Write); + } + + /// + public void ReadData(IPacket packet) { + // Read the byte flag representing update types and reconstruct it + var updateTypeFlag = packet.ReadByte(); + // Keep track of value of current bit + var currentTypeValue = 1; + + for (var i = 0; i < Enum.GetNames(typeof(Type)).Length; i++) { + // If this bit was set in our flag, we add the type to the list + if ((updateTypeFlag & currentTypeValue) != 0) { + Types.Add((Type) i); + } + + // Increase the value of current bit + currentTypeValue *= 2; + } + + if (Types.Contains(Type.State)) { + CurrentState = packet.ReadByte(); + } + + void ReadVarDict(Type type, Dictionary dict, Func readValue) { + if (Types.Contains(type)) { + var length = packet.ReadByte(); + + for (var i = 0; i < length; i++) { + dict.Add(packet.ReadByte(), readValue.Invoke()); + } + } + } + + ReadVarDict(Type.Floats, Floats, packet.ReadFloat); + ReadVarDict(Type.Ints, Ints, packet.ReadInt); + ReadVarDict(Type.Bools, Bools, packet.ReadBool); + ReadVarDict(Type.Strings, Strings, packet.ReadString); + ReadVarDict(Type.Vector2s, Vec2s, packet.ReadVector2); + ReadVarDict(Type.Vector3s, Vec3s, packet.ReadVector3); + } + + /// + /// Enum for update types of this class. + /// + public enum Type : byte { + State, + Floats, + Ints, + Bools, + Strings, + Vector2s, + Vector3s } } @@ -140,6 +789,9 @@ public void ReadData(IPacket packet) { /// internal enum EntityUpdateType { Position = 0, - State, - Variables, + Scale, + Animation, + Active, + Data, + HostFsm } diff --git a/HKMP/Networking/Packet/Data/HelloClient.cs b/HKMP/Networking/Packet/Data/HelloClient.cs deleted file mode 100644 index d89245f7..00000000 --- a/HKMP/Networking/Packet/Data/HelloClient.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; - -namespace Hkmp.Networking.Packet.Data; - -/// -/// Packet data for the hello client data. -/// -internal class HelloClient : IPacketData { - /// - public bool IsReliable => true; - - /// - public bool DropReliableDataIfNewerExists => true; - - /// - /// List of ID, username pairs for each connected client. - /// - public List<(ushort, string)> ClientInfo { get; set; } - - /// - /// Construct the hello client data. - /// - public HelloClient() { - ClientInfo = new List<(ushort, string)>(); - } - - /// - public void WriteData(IPacket packet) { - packet.Write((ushort) ClientInfo.Count); - - foreach (var (id, username) in ClientInfo) { - packet.Write(id); - packet.Write(username); - } - } - - /// - public void ReadData(IPacket packet) { - var length = packet.ReadUShort(); - - for (var i = 0; i < length; i++) { - ClientInfo.Add(( - packet.ReadUShort(), - packet.ReadString() - )); - } - } -} diff --git a/HKMP/Networking/Packet/Data/HelloServer.cs b/HKMP/Networking/Packet/Data/HelloServer.cs index fe7e4a3e..f3f1bb26 100644 --- a/HKMP/Networking/Packet/Data/HelloServer.cs +++ b/HKMP/Networking/Packet/Data/HelloServer.cs @@ -1,6 +1,4 @@ -using Hkmp.Math; - -namespace Hkmp.Networking.Packet.Data; +namespace Hkmp.Networking.Packet.Data; /// /// Packet data for the hello server data. @@ -13,42 +11,17 @@ internal class HelloServer : IPacketData { public bool DropReliableDataIfNewerExists => true; /// - /// The name of the current scene of the player. - /// - public string SceneName { get; set; } - - /// - /// The position of the player. - /// - public Vector2 Position { get; set; } - - /// - /// The scale of the player. + /// The username of the player. /// - public bool Scale { get; set; } - - /// - /// The animation clip ID of the player. - /// - public ushort AnimationClipId { get; set; } + public string Username { get; set; } /// public void WriteData(IPacket packet) { - packet.Write(SceneName); - - packet.Write(Position); - packet.Write(Scale); - - packet.Write(AnimationClipId); + packet.Write(Username); } /// public void ReadData(IPacket packet) { - SceneName = packet.ReadString(); - - Position = packet.ReadVector2(); - Scale = packet.ReadBool(); - - AnimationClipId = packet.ReadUShort(); + Username = packet.ReadString(); } } diff --git a/HKMP/Networking/Packet/Data/HostTransfer.cs b/HKMP/Networking/Packet/Data/HostTransfer.cs new file mode 100644 index 00000000..7c12fbde --- /dev/null +++ b/HKMP/Networking/Packet/Data/HostTransfer.cs @@ -0,0 +1,27 @@ +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet data for a host transfer. +/// +internal class HostTransfer : IPacketData { + /// + public bool IsReliable => true; + + /// + public bool DropReliableDataIfNewerExists => true; + + /// + /// The name of the scene in which the player becomes the scene host. + /// + public string SceneName { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write(SceneName); + } + + /// + public void ReadData(IPacket packet) { + SceneName = packet.ReadString(); + } +} diff --git a/HKMP/Networking/Packet/Data/PacketDataCollection.cs b/HKMP/Networking/Packet/Data/PacketDataCollection.cs index d691db1e..b1d49114 100644 --- a/HKMP/Networking/Packet/Data/PacketDataCollection.cs +++ b/HKMP/Networking/Packet/Data/PacketDataCollection.cs @@ -1,7 +1,5 @@ namespace Hkmp.Networking.Packet.Data; -// TODO: extend this to allow a larger/customizable number of instances in the list -// It is now limited by the size of a byte /// /// Packet data for a collection of individual packet data instances. /// @@ -9,7 +7,7 @@ namespace Hkmp.Networking.Packet.Data; public class PacketDataCollection : RawPacketDataCollection, IPacketData where T : IPacketData, new() { /// public void WriteData(IPacket packet) { - var length = (byte) System.Math.Min(byte.MaxValue, DataInstances.Count); + var length = (ushort) System.Math.Min(ushort.MaxValue, DataInstances.Count); packet.Write(length); @@ -20,7 +18,7 @@ public void WriteData(IPacket packet) { /// public void ReadData(IPacket packet) { - var length = packet.ReadByte(); + var length = packet.ReadUShort(); for (var i = 0; i < length; i++) { // Create new instance of generic type diff --git a/HKMP/Networking/Packet/Data/PlayerEnterScene.cs b/HKMP/Networking/Packet/Data/PlayerEnterScene.cs index c1839cd1..ee5105b1 100644 --- a/HKMP/Networking/Packet/Data/PlayerEnterScene.cs +++ b/HKMP/Networking/Packet/Data/PlayerEnterScene.cs @@ -87,6 +87,21 @@ internal class ClientPlayerAlreadyInScene : IPacketData { /// public List PlayerEnterSceneList { get; } + /// + /// List of entity spawn instances. + /// + public List EntitySpawnList { get; } + + /// + /// List of entity update instances. + /// + public List EntityUpdateList { get; } + + /// + /// List of entity update instances. + /// + public List ReliableEntityUpdateList { get; } + /// /// Whether the receiving player is scene host. /// @@ -97,25 +112,51 @@ internal class ClientPlayerAlreadyInScene : IPacketData { /// public ClientPlayerAlreadyInScene() { PlayerEnterSceneList = new List(); + EntitySpawnList = new List(); + EntityUpdateList = new List(); + ReliableEntityUpdateList = new List(); } /// public void WriteData(IPacket packet) { - var length = (byte) System.Math.Min(byte.MaxValue, PlayerEnterSceneList.Count); + var length = System.Math.Min(byte.MaxValue, PlayerEnterSceneList.Count); - packet.Write(length); + packet.Write((byte) length); for (var i = 0; i < length; i++) { PlayerEnterSceneList[i].WriteData(packet); } + length = System.Math.Min(byte.MaxValue, EntitySpawnList.Count); + + packet.Write((byte) length); + + for (var i = 0; i < length; i++) { + EntitySpawnList[i].WriteData(packet); + } + + length = System.Math.Min(ushort.MaxValue, EntityUpdateList.Count); + + packet.Write((ushort) length); + + for (var i = 0; i < length; i++) { + EntityUpdateList[i].WriteData(packet); + } + + length = System.Math.Min(ushort.MaxValue, ReliableEntityUpdateList.Count); + + packet.Write((ushort) length); + + for (var i = 0; i < length; i++) { + ReliableEntityUpdateList[i].WriteData(packet); + } + packet.Write(SceneHost); } /// public void ReadData(IPacket packet) { - var length = packet.ReadByte(); - + int length = packet.ReadByte(); for (var i = 0; i < length; i++) { // Create new instance of generic type var instance = new ClientPlayerEnterScene(); @@ -127,6 +168,42 @@ public void ReadData(IPacket packet) { PlayerEnterSceneList.Add(instance); } + length = packet.ReadByte(); + for (var i = 0; i < length; i++) { + // Create new instance of entity update + var instance = new EntitySpawn(); + + // Read the packet data into the instance + instance.ReadData(packet); + + // And add it to our already initialized list + EntitySpawnList.Add(instance); + } + + length = packet.ReadUShort(); + for (var i = 0; i < length; i++) { + // Create new instance of entity update + var instance = new EntityUpdate(); + + // Read the packet data into the instance + instance.ReadData(packet); + + // And add it to our already initialized list + EntityUpdateList.Add(instance); + } + + length = packet.ReadUShort(); + for (var i = 0; i < length; i++) { + // Create new instance of reliable entity update + var instance = new ReliableEntityUpdate(); + + // Read the packet data into the instance + instance.ReadData(packet); + + // And add it to our already initialized list + ReliableEntityUpdateList.Add(instance); + } + SceneHost = packet.ReadBool(); } } diff --git a/HKMP/Networking/Packet/Data/PlayerLeaveScene.cs b/HKMP/Networking/Packet/Data/PlayerLeaveScene.cs new file mode 100644 index 00000000..958cb4f1 --- /dev/null +++ b/HKMP/Networking/Packet/Data/PlayerLeaveScene.cs @@ -0,0 +1,57 @@ +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet data for the client-bound player leave scene data. +/// +internal class ClientPlayerLeaveScene : GenericClientData { + /// + /// The name of the scene that the player left. + /// + public string SceneName { get; set; } + + /// + /// Construct the client player leave scene data. + /// + public ClientPlayerLeaveScene() { + IsReliable = true; + DropReliableDataIfNewerExists = false; + } + + /// + public override void WriteData(IPacket packet) { + packet.Write(Id); + packet.Write(SceneName); + } + + /// + public override void ReadData(IPacket packet) { + Id = packet.ReadUShort(); + SceneName = packet.ReadString(); + } +} + +/// +/// Packet data for the server-bound player left scene data. +/// +internal class ServerPlayerLeaveScene : IPacketData { + /// + public bool IsReliable => true; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// The name of the scene that the player left. + /// + public string SceneName { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write(SceneName); + } + + /// + public void ReadData(IPacket packet) { + SceneName = packet.ReadString(); + } +} diff --git a/HKMP/Networking/Packet/Data/PlayerSettingUpdate.cs b/HKMP/Networking/Packet/Data/PlayerSettingUpdate.cs new file mode 100644 index 00000000..ce73df94 --- /dev/null +++ b/HKMP/Networking/Packet/Data/PlayerSettingUpdate.cs @@ -0,0 +1,137 @@ +using System.Collections.Generic; +using Hkmp.Game; + +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet data for client-bound player setting update. +/// +internal class ClientPlayerSettingUpdate : GenericClientData { + /// + /// Whether the update is for the player receiving this packet. + /// + public bool Self { get; set; } + + /// + /// Set of the types of updates that this packet contains. For example, only a skin update, or a skin and a team + /// update. + /// + public ISet UpdateTypes { get; set; } + + /// + /// The team of the player. + /// + public Team Team { get; set; } + + /// + /// The ID of the skin. + /// + public byte SkinId { get; set; } + + public ClientPlayerSettingUpdate() { + UpdateTypes = new HashSet(); + } + + /// + public override void WriteData(IPacket packet) { + packet.Write(Self); + + if (!Self) { + packet.Write(Id); + } + + packet.WriteBitFlag(UpdateTypes); + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { + packet.Write((byte) Team); + } + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { + packet.Write(SkinId); + } + } + + /// + public override void ReadData(IPacket packet) { + Self = packet.ReadBool(); + + if (!Self) { + Id = packet.ReadUShort(); + } + + UpdateTypes = packet.ReadBitFlag(); + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { + Team = (Team) packet.ReadByte(); + } + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { + SkinId = packet.ReadByte(); + } + } +} + +/// +/// Packet data for server-bound player setting update. +/// +internal class ServerPlayerSettingUpdate : IPacketData { + /// + public bool IsReliable => true; + + /// + public bool DropReliableDataIfNewerExists => true; + + /// + /// Set of the types of updates that this packet contains. For example, only a skin update, or a skin and a team + /// update. + /// + public ISet UpdateTypes { get; set; } + + /// + /// The team of the player. + /// + public Team Team { get; set; } + + /// + /// The ID of the skin. + /// + public byte SkinId { get; set; } + + public ServerPlayerSettingUpdate() { + UpdateTypes = new HashSet(); + } + + /// + public void WriteData(IPacket packet) { + packet.WriteBitFlag(UpdateTypes); + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { + packet.Write((byte) Team); + } + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { + packet.Write(SkinId); + } + } + + /// + public void ReadData(IPacket packet) { + UpdateTypes = packet.ReadBitFlag(); + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Team)) { + Team = (Team) packet.ReadByte(); + } + + if (UpdateTypes.Contains(PlayerSettingUpdateType.Skin)) { + SkinId = packet.ReadByte(); + } + } +} + +/// +/// Enum for the type of player setting update. +/// +internal enum PlayerSettingUpdateType { + Team = 0, + Skin = 1 +} diff --git a/HKMP/Networking/Packet/Data/PlayerSkinUpdate.cs b/HKMP/Networking/Packet/Data/PlayerSkinUpdate.cs deleted file mode 100644 index cb5aa992..00000000 --- a/HKMP/Networking/Packet/Data/PlayerSkinUpdate.cs +++ /dev/null @@ -1,57 +0,0 @@ -namespace Hkmp.Networking.Packet.Data; - -/// -/// Packet data for client-bound player skin update. -/// -internal class ClientPlayerSkinUpdate : GenericClientData { - /// - /// The ID of the skin. - /// - public byte SkinId { get; set; } - - /// - /// Construct the player skin update data. - /// - public ClientPlayerSkinUpdate() { - IsReliable = true; - DropReliableDataIfNewerExists = true; - } - - /// - public override void WriteData(IPacket packet) { - packet.Write(Id); - packet.Write(SkinId); - } - - /// - public override void ReadData(IPacket packet) { - Id = packet.ReadUShort(); - SkinId = packet.ReadByte(); - } -} - -/// -/// Packet data for the server-bound player skin update. -/// -internal class ServerPlayerSkinUpdate : IPacketData { - /// - public bool IsReliable => true; - - /// - public bool DropReliableDataIfNewerExists => true; - - /// - /// The ID of the skin. - /// - public byte SkinId { get; set; } - - /// - public void WriteData(IPacket packet) { - packet.Write(SkinId); - } - - /// - public void ReadData(IPacket packet) { - SkinId = packet.ReadByte(); - } -} diff --git a/HKMP/Networking/Packet/Data/PlayerTeamUpdate.cs b/HKMP/Networking/Packet/Data/PlayerTeamUpdate.cs deleted file mode 100644 index ffd238d4..00000000 --- a/HKMP/Networking/Packet/Data/PlayerTeamUpdate.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Hkmp.Game; - -namespace Hkmp.Networking.Packet.Data; - -/// -/// Packet data of the client-bound player team update. -/// -internal class ClientPlayerTeamUpdate : GenericClientData { - /// - /// The username of the player. - /// - public string Username { get; set; } - - /// - /// The team of the player. - /// - public Team Team { get; set; } - - /// - /// Construct the player team update data. - /// - public ClientPlayerTeamUpdate() { - IsReliable = true; - DropReliableDataIfNewerExists = true; - } - - /// - public override void WriteData(IPacket packet) { - packet.Write(Id); - packet.Write(Username); - packet.Write((byte) Team); - } - - /// - public override void ReadData(IPacket packet) { - Id = packet.ReadUShort(); - Username = packet.ReadString(); - Team = (Team) packet.ReadByte(); - } -} - -/// -/// Packet data for the server-bound player team update. -/// -internal class ServerPlayerTeamUpdate : IPacketData { - /// - public bool IsReliable => true; - - /// - public bool DropReliableDataIfNewerExists => true; - - /// - /// The team of the player. - /// - public Team Team { get; set; } - - /// - public void WriteData(IPacket packet) { - packet.Write((byte) Team); - } - - /// - public void ReadData(IPacket packet) { - Team = (Team) packet.ReadByte(); - } -} diff --git a/HKMP/Networking/Packet/Data/SaveUpdate.cs b/HKMP/Networking/Packet/Data/SaveUpdate.cs new file mode 100644 index 00000000..104c8916 --- /dev/null +++ b/HKMP/Networking/Packet/Data/SaveUpdate.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; + +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet data for when values in the save update. +/// +internal class SaveUpdate : IPacketData { + /// + public bool IsReliable => true; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// The index of the save data entry that got updated. + /// + public ushort SaveDataIndex { get; set; } + + /// + /// The encoded value of the save data in a byte array. + /// + public byte[] Value { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write(SaveDataIndex); + + if (Value.Length > ushort.MaxValue) { + throw new Exception($"Number of bytes exceeds ushort max value: {Value.Length}"); + } + + var length = (ushort) Value.Length; + packet.Write(length); + for (var i = 0; i < length; i++) { + packet.Write(Value[i]); + } + } + + /// + public void ReadData(IPacket packet) { + SaveDataIndex = packet.ReadUShort(); + + var length = packet.ReadUShort(); + Value = new byte[length]; + for (var i = 0; i < length; i++) { + Value[i] = packet.ReadByte(); + } + } +} + +/// +/// Packet data for when an entire save is networked. +/// +internal class CurrentSave : IPacketData { + /// + public bool IsReliable => true; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// Whether this save is new file the player. As in, there is no player-specific save data in the save yet. + /// + public bool NewForPlayer { get; set; } + + /// + /// Dictionary mapping save data indices to encoded values in byte array form. + /// + public Dictionary SaveData { get; set; } + + public CurrentSave() { + SaveData = new Dictionary(); + } + + /// + public void WriteData(IPacket packet) { + packet.Write(NewForPlayer); + + WriteSaveDataDict(SaveData, packet); + } + + /// + public void ReadData(IPacket packet) { + NewForPlayer = packet.ReadBool(); + + SaveData = ReadSaveDataDict(packet); + } + + /// + /// Writes a save data dictionary to the given packet. + /// + /// The dictionary mapping indices to byte encoded values to write. + /// The packet to write the data into. + /// Thrown if the number of keys in the given save data dictionary is too large to + /// be written (> max ushort). + public static void WriteSaveDataDict(Dictionary dataDict, IPacket packet) { + var saveDataKeyCount = dataDict.Keys.Count; + if (saveDataKeyCount > ushort.MaxValue) { + throw new Exception("Number of keys in save data is too large"); + } + + var dataLength = (ushort) saveDataKeyCount; + + packet.Write(dataLength); + + foreach (var keyValuePair in dataDict) { + var saveDataIndex = keyValuePair.Key; + var value = keyValuePair.Value; + + packet.Write(saveDataIndex); + + var length = (ushort) System.Math.Min(value.Length, ushort.MaxValue); + packet.Write(length); + for (var i = 0; i < length; i++) { + packet.Write(value[i]); + } + } + } + + /// + /// Reads a save data dictionary that maps indices to byte encoded values from the given + /// packet. + /// + /// The packet interface to read from. + /// A dictionary mapping save data indices to byte encoded values. + public static Dictionary ReadSaveDataDict(IPacket packet) { + var saveData = new Dictionary(); + var dataLength = packet.ReadUShort(); + + for (var i = 0; i < dataLength; i++) { + var saveDataIndex = packet.ReadUShort(); + + var length = packet.ReadUShort(); + var value = new byte[length]; + for (var j = 0; j < length; j++) { + value[j] = packet.ReadByte(); + } + + saveData.Add(saveDataIndex, value); + } + + return saveData; + } +} diff --git a/HKMP/Networking/Packet/Data/ServerInfo.cs b/HKMP/Networking/Packet/Data/ServerInfo.cs new file mode 100644 index 00000000..f2096353 --- /dev/null +++ b/HKMP/Networking/Packet/Data/ServerInfo.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using Hkmp.Api.Addon; + +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet data for the server info data. +/// +internal class ServerInfo : IPacketData { + /// + public bool IsReliable => false; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// The result of the connection, whether it was accepted. + /// + public ServerConnectionResult ConnectionResult { get; set; } + + /// + /// The message detailing why the connection was rejected if it was. + /// + public string ConnectionRejectedMessage { get; set; } + + /// + /// List of addon data that the server uses. + /// + public List AddonData { get; set; } + + /// + /// The order in which the addons have been assigned IDs. + /// + public byte[] AddonOrder { get; set; } + + /// + /// The server settings for the server. Packaged as a to allow serialization + /// to packet data. + /// + public ServerSettingsUpdate ServerSettingsUpdate { get; set; } + + /// + /// Whether full synchronisation is enabled for the server. + /// + public bool FullSynchronisation { get; set; } + + /// + /// The save data currently used on the server. + /// + public CurrentSave CurrentSave { get; set; } + + /// + /// List of ID, username pairs for each connected client. + /// + public List<(ushort, string)> PlayerInfo { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write((byte) ConnectionResult); + + if (ConnectionResult == ServerConnectionResult.Accepted) { + packet.Write((byte) AddonOrder.Length); + + foreach (var addonOrderByte in AddonOrder) { + packet.Write(addonOrderByte); + } + + ServerSettingsUpdate.WriteData(packet); + + packet.Write(FullSynchronisation); + + CurrentSave.WriteData(packet); + + packet.Write((ushort) PlayerInfo.Count); + + foreach (var (id, username) in PlayerInfo) { + packet.Write(id); + packet.Write(username); + } + + return; + } + + if (ConnectionResult == ServerConnectionResult.InvalidAddons) { + AddonData ??= new List(); + + var addonDataLength = (byte) System.Math.Min(byte.MaxValue, AddonData.Count); + + packet.Write(addonDataLength); + + for (var i = 0; i < addonDataLength; i++) { + packet.Write(AddonData[i].Identifier); + packet.Write(AddonData[i].Version); + } + + return; + } + + packet.Write(ConnectionRejectedMessage); + } + + /// + public void ReadData(IPacket packet) { + ConnectionResult = (ServerConnectionResult) packet.ReadByte(); + + if (ConnectionResult == ServerConnectionResult.Accepted) { + var addonOrderLength = packet.ReadByte(); + AddonOrder = new byte[addonOrderLength]; + + for (var i = 0; i < addonOrderLength; i++) { + AddonOrder[i] = packet.ReadByte(); + } + + ServerSettingsUpdate = new ServerSettingsUpdate(); + ServerSettingsUpdate.ReadData(packet); + + FullSynchronisation = packet.ReadBool(); + + CurrentSave = new CurrentSave(); + CurrentSave.ReadData(packet); + + var length = packet.ReadUShort(); + + PlayerInfo = new List<(ushort, string)>(); + for (var i = 0; i < length; i++) { + PlayerInfo.Add(( + packet.ReadUShort(), + packet.ReadString() + )); + } + + return; + } + + if (ConnectionResult == ServerConnectionResult.InvalidAddons) { + var addonDataLength = packet.ReadByte(); + + AddonData = new List(); + + for (var i = 0; i < addonDataLength; i++) { + var id = packet.ReadString(); + var version = packet.ReadString(); + + if (id.Length > Addon.MaxNameLength || version.Length > Addon.MaxVersionLength) { + throw new ArgumentException("Identifier or version of addon exceeds max length"); + } + + AddonData.Add(new AddonData(id, version)); + } + + return; + } + + ConnectionRejectedMessage = packet.ReadString(); + } +} diff --git a/HKMP/Networking/Packet/Data/ServerSettingsUpdate.cs b/HKMP/Networking/Packet/Data/ServerSettingsUpdate.cs index ded22871..4949855c 100644 --- a/HKMP/Networking/Packet/Data/ServerSettingsUpdate.cs +++ b/HKMP/Networking/Packet/Data/ServerSettingsUpdate.cs @@ -4,7 +4,7 @@ namespace Hkmp.Networking.Packet.Data; /// -/// Packet data for a server settings update. +/// Packet data for both client-bound and server-bound server settings update. /// internal class ServerSettingsUpdate : IPacketData { // TODO: optimize this by only sending the values that actually changed diff --git a/HKMP/Networking/Packet/Data/SliceAckData.cs b/HKMP/Networking/Packet/Data/SliceAckData.cs new file mode 100644 index 00000000..e8644c3f --- /dev/null +++ b/HKMP/Networking/Packet/Data/SliceAckData.cs @@ -0,0 +1,114 @@ +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet for acknowledging a received slice packet for large reliable data transfer during connection. +/// +internal class SliceAckData : IPacketData { + /// + public bool IsReliable => false; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// The ID of the chunk that is being networked. + /// + public byte ChunkId { get; set; } + + /// + /// The total number of slices in this chunk. Encoded as a byte where all values are shifted by -1 to ensure we + /// can encode 256 as a value, since we don't use 0. + /// + public ushort NumSlices { get; set; } + + /// + /// Boolean array containing whether a slice was acked. For writing packets, the length of the array can equal + /// the number of slices. For reading packets, the length of the array will equal the maximum possible number + /// of slices per chunk. + /// + public bool[] Acked { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write(ChunkId); + + var encodedNumSlices = (byte) (NumSlices - 1); + packet.Write(encodedNumSlices); + + // Keep track of current index for writing ack array + var currentIndex = 0; + // Do while loop, since we will always be writing at least a single byte bit flag + do { + packet.Write(CreateAckFlag(currentIndex, currentIndex + 8, Acked)); + // Continue while loop if we need to write another flag, namely when the new starting index is smaller + // than the number of slices + } while ((currentIndex += 8) <= NumSlices); + } + + /// + public void ReadData(IPacket packet) { + ChunkId = packet.ReadByte(); + + var encodedNumSlices = packet.ReadByte(); + NumSlices = (ushort) (encodedNumSlices + 1); + + var acked = new bool[ConnectionManager.MaxSlicesPerChunk]; + + // Keep track of current index for writing to ack array + var currentIndex = 0; + // Do while loop, since we will always be reading at least one byte for the bit flag + do { + var flag = packet.ReadByte(); + ReadAckFlag(flag, currentIndex, currentIndex + 8, ref acked); + // Continue while loop if we need to read another flag, namely when the new starting index is smaller + // than the number of slices + } while ((currentIndex += 8) <= NumSlices); + + Acked = acked; + } + + /// + /// Create a bit flag as a byte from the given boolean array with start and end indices. + /// + /// The (inclusive) start index to start reading from the boolean array. + /// The (exclusive) end index to stop reading from the boolean array. + /// The boolean array to read values from for the flag. + /// The bit flag as a byte. + private static byte CreateAckFlag(int startIndex, int endIndex, bool[] acked) { + byte flag = 0; + byte currentValue = 1; + + for (var i = startIndex; i < endIndex; i++) { + if (acked.Length <= i) { + break; + } + + if (acked[i]) { + flag |= currentValue; + } + + currentValue *= 2; + } + + return flag; + } + + /// + /// Read a bit flag in byte form and put the bits into the given reference boolean array. + /// + /// The bit flag as a byte. + /// The (inclusive) start index to start reading from the boolean array. + /// The (exclusive) end index to stop reading from the boolean array. + /// The boolean array as a reference to write values to from the flag. + private static void ReadAckFlag(byte flag, int startIndex, int endIndex, ref bool[] acked) { + byte currentValue = 1; + + for (var i = startIndex; i < endIndex; i++) { + if ((flag & currentValue) != 0) { + acked[i] = true; + } + + currentValue *= 2; + } + } +} diff --git a/HKMP/Networking/Packet/Data/SliceData.cs b/HKMP/Networking/Packet/Data/SliceData.cs new file mode 100644 index 00000000..216e0780 --- /dev/null +++ b/HKMP/Networking/Packet/Data/SliceData.cs @@ -0,0 +1,80 @@ +using System; + +namespace Hkmp.Networking.Packet.Data; + +/// +/// Packet with raw byte data as a slice of a bigger chunk meant for large reliable data transfer during connection. +/// +internal class SliceData : IPacketData { + /// + public bool IsReliable => false; + + /// + public bool DropReliableDataIfNewerExists => false; + + /// + /// The ID of the chunk that is being networked. + /// + public byte ChunkId { get; set; } + + /// + /// The ID of this slice. + /// + public byte SliceId { get; set; } + + /// + /// The total number of slices in this chunk. It is an unsigned short because we can have 256 slices in a chunk. + /// It is encoded as a byte, where all values are shifted by one since 0 is not used. + /// + public ushort NumSlices { get; set; } + + /// + /// Byte array containing the data of this slice. + /// + public byte[] Data { get; set; } + + /// + public void WriteData(IPacket packet) { + packet.Write(ChunkId); + packet.Write(SliceId); + + // Shift all values by -1 so that we can encode 256 as a number of slices + var encodedNumSlices = (byte) (NumSlices - 1); + packet.Write(encodedNumSlices); + + var length = Data.Length; + if (length > ConnectionManager.MaxSliceSize) { + throw new ArgumentOutOfRangeException(nameof(Data), "Length of data for slice cannot exceed 1024"); + } + + if (SliceId == NumSlices - 1) { + packet.Write((ushort) length); + } + + for (var i = 0; i < length; i++) { + packet.Write(Data[i]); + } + } + + /// + public void ReadData(IPacket packet) { + ChunkId = packet.ReadByte(); + SliceId = packet.ReadByte(); + + // Read the encoded byte and shift it by 1 again + var encodedNumSlices = packet.ReadByte(); + NumSlices = (ushort) (encodedNumSlices + 1); + + ushort length; + if (SliceId == NumSlices - 1) { + length = packet.ReadUShort(); + } else { + length = ConnectionManager.MaxSliceSize; + } + + Data = new byte[length]; + for (var i = 0; i < length; i++) { + Data[i] = packet.ReadByte(); + } + } +} diff --git a/HKMP/Networking/Packet/IPacket.cs b/HKMP/Networking/Packet/IPacket.cs index 131d40a6..4e61818a 100644 --- a/HKMP/Networking/Packet/IPacket.cs +++ b/HKMP/Networking/Packet/IPacket.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Hkmp.Math; namespace Hkmp.Networking.Packet; @@ -96,6 +97,21 @@ public interface IPacket { /// /// The Vector2 value. void Write(Vector2 value); + + /// + /// Write a Vector3 (12 bytes) to the packet. Simply a wrapper for writing the X, Y and Z floats to the packet. + /// + /// The Vector3 value. + void Write(Vector3 value); + + /// + /// Write a bit flag to the packet based on an enum type and a set of that type. Will write either a byte, + /// unsigned short, unsigned int or unsigned long to the packet based on the size of the enum. Also assumes that + /// the enum has underlying int values starting from 0 and incrementing by 1 for each subsequent type. + /// + /// The set containing the values for which a bit in the flag should be set to 1. + /// The enum type that the set also uses. + void WriteBitFlag(ISet set) where TEnum : Enum; #endregion @@ -193,6 +209,21 @@ public interface IPacket { /// /// The Vector2 value. Vector2 ReadVector2(); + + /// + /// Read a Vector3 (12 bytes) from the packet. Simply a wrapper for reading the X, Y and Z floats from the packet. + /// + /// The Vector3 value. + Vector3 ReadVector3(); + + /// + /// Read a bit flag from the packet based on an enum type and a set of that type. Will read either a byte, + /// unsigned short, unsigned int or unsigned long from the packet based on the size of the enum. Also assumes that + /// the enum has underlying int values starting from 0 and incrementing by 1 for each subsequent type. + /// + /// The set containing the enum values where the corresponding bit in the flag was set to 1. + /// The enum type that the set also uses. + ISet ReadBitFlag() where TEnum : Enum; #endregion } diff --git a/HKMP/Networking/Packet/Packet.cs b/HKMP/Networking/Packet/Packet.cs index 41ca5613..0554e1d1 100644 --- a/HKMP/Networking/Packet/Packet.cs +++ b/HKMP/Networking/Packet/Packet.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; using System.Text; -using Hkmp.Logging; using Hkmp.Math; -using Hkmp.Util; namespace Hkmp.Networking.Packet; @@ -192,6 +190,40 @@ public void Write(Vector2 value) { Write(value.X); Write(value.Y); } + + /// + public void Write(Vector3 value) { + Write(value.X); + Write(value.Y); + Write(value.Z); + } + + /// + public void WriteBitFlag(ISet set) where TEnum : Enum { + var enumTypes = Enum.GetValues(typeof(TEnum)); + var enumLength = enumTypes.Length; + + ulong flag = 0; + ulong currentValue = 1; + + for (var i = 0; i < enumLength; i++) { + if (set.Contains((TEnum) enumTypes.GetValue(i))) { + flag |= currentValue; + } + + currentValue *= 2; + } + + if (enumLength <= 8) { + Write((byte) flag); + } else if (enumLength <= 16) { + Write((ushort) flag); + } else if (enumLength <= 32) { + Write((uint) flag); + } else if (enumLength <= 64) { + Write(flag); + } + } #endregion @@ -429,6 +461,42 @@ public Vector2 ReadVector2() { // check whether there are enough bytes left to read and throw exceptions if not return new Vector2(ReadFloat(), ReadFloat()); } + + /// + public Vector3 ReadVector3() { + // Simply construct the Vector3 by reading a float from the packet thrice, which should + // check whether there are enough bytes left to read and throw exceptions if not + return new Vector3(ReadFloat(), ReadFloat(), ReadFloat()); + } + + /// + public ISet ReadBitFlag() where TEnum : Enum { + var enumTypes = Enum.GetValues(typeof(TEnum)); + var enumLength = enumTypes.Length; + + ulong flag = 0; + if (enumLength <= 8) { + flag = ReadByte(); + } else if (enumLength <= 16) { + flag = ReadUShort(); + } else if (enumLength <= 32) { + flag = ReadUInt(); + } else if (enumLength <= 64) { + flag = ReadULong(); + } + + ulong currentValue = 1; + var set = new HashSet(); + foreach (var enumType in enumTypes) { + if ((flag & currentValue) != 0) { + set.Add((TEnum) enumType); + } + + currentValue *= 2; + } + + return set; + } #endregion diff --git a/HKMP/Networking/Packet/PacketId.cs b/HKMP/Networking/Packet/PacketId.cs deleted file mode 100644 index 981f2049..00000000 --- a/HKMP/Networking/Packet/PacketId.cs +++ /dev/null @@ -1,151 +0,0 @@ -namespace Hkmp.Networking.Packet; - -/// -/// Enumeration of packet IDs for server to client communication. -/// -internal enum ClientPacketId { - /// - /// A response to the login request to indicate whether the client is allowed to connect. - /// - LoginResponse = 0, - - /// - /// A response to the HelloServer after a succeeding login. - /// - HelloClient, - - /// - /// Indicating that a client has connected. - /// - PlayerConnect, - - /// - /// Indicating that a client is disconnecting. - /// - PlayerDisconnect, - - /// - /// Indicating the client is (forcefully) disconnected from the server. - /// - ServerClientDisconnect, - - /// - /// Notify that a player has entered the current scene. - /// - PlayerEnterScene, - - /// - /// Notify that a player is already in the scene we just entered. - /// - PlayerAlreadyInScene, - - /// - /// Notify that a player has left the current scene. - /// - PlayerLeaveScene, - - /// - /// Update of realtime player values. - /// - PlayerUpdate, - - /// - /// Update of player map position. - /// - PlayerMapUpdate, - - /// - /// Update of realtime entity values. - /// - EntityUpdate, - - /// - /// Notify that a player has died. - /// - PlayerDeath, - - /// - /// Notify that a player has changed teams. - /// - PlayerTeamUpdate, - - /// - /// Notify that a player has changed skins. - /// - PlayerSkinUpdate, - - /// - /// Notify that the gameplay settings have updated. - /// - ServerSettingsUpdated, - - /// - /// Player sent chat message. - /// - ChatMessage = 15 -} - -/// -/// Enumeration of packet IDs for client to server communication. -/// -public enum ServerPacketId { - /// - /// Login packet that indicates that a new client wants to connect. - /// - LoginRequest = 0, - - /// - /// Initial hello, sent when login succeeds. - /// - HelloServer, - - /// - /// Indicating that a client is disconnecting. - /// - PlayerDisconnect, - - /// - /// Update of realtime player values. - /// - PlayerUpdate, - - /// - /// Update of player map position. - /// - PlayerMapUpdate, - - /// - /// Update of realtime entity values. - /// - EntityUpdate, - - /// - /// Notify that the player has entered a new scene. - /// - PlayerEnterScene, - - /// - /// Notify that the player has left their current scene. - /// - PlayerLeaveScene, - - /// - /// Notify that a player has died. - /// - PlayerDeath, - - /// - /// Notify that a player has changed teams. - /// - PlayerTeamUpdate, - - /// - /// Notify that a player has changed skins. - /// - PlayerSkinUpdate, - - /// - /// Player sent chat message. - /// - ChatMessage = 11 -} diff --git a/HKMP/Networking/Packet/PacketManager.cs b/HKMP/Networking/Packet/PacketManager.cs index d2f42361..6642c811 100644 --- a/HKMP/Networking/Packet/PacketManager.cs +++ b/HKMP/Networking/Packet/PacketManager.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using Hkmp.Logging; +using Hkmp.Networking.Packet.Connection; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; using Hkmp.Util; namespace Hkmp.Networking.Packet; @@ -39,42 +41,68 @@ public delegate void GenericServerPacketHandler(ushort id, TPack /// internal class PacketManager { /// - /// Handlers that deal with data from the server intended for the client. + /// Handlers that deal with update packet data from the server intended for the client. /// - private readonly Dictionary _clientPacketHandlers; + private readonly Dictionary _clientUpdatePacketHandlers; /// - /// Handlers that deal with data from the client intended for the server. + /// Handlers that deal with connection packet data from the server intended for the client. /// - private readonly Dictionary _serverPacketHandlers; + private readonly Dictionary _clientConnectionPacketHandlers; /// - /// Handlers that deal with client addon data from the server intended for the client. + /// Handlers that deal with update packet data from the client intended for the server. /// - private readonly Dictionary> _clientAddonPacketHandlers; + private readonly Dictionary _serverUpdatePacketHandlers; + + /// + /// Handlers that deal with connection packet data from the client intended for the server. + /// + private readonly Dictionary _serverConnectionPacketHandlers; + + /// + /// Handlers that deal with client addon update packet data from the server intended for the client. + /// + private readonly Dictionary> _clientAddonUpdatePacketHandlers; + + /// + /// Handlers that deal with client addon connection packet data from the server intended for the client. + /// + private readonly Dictionary> _clientAddonConnectionPacketHandlers; /// - /// Handlers that deal with server addon data from a client intended for the server. + /// Handlers that deal with server addon update packet data from a client intended for the server. /// - private readonly Dictionary> _serverAddonPacketHandlers; + private readonly Dictionary> _serverAddonUpdatePacketHandlers; + + /// + /// Handlers that deal with server addon connection packet data from a client intended for the server. + /// + private readonly Dictionary> _serverAddonConnectionPacketHandlers; public PacketManager() { - _clientPacketHandlers = new Dictionary(); - _serverPacketHandlers = new Dictionary(); + _clientUpdatePacketHandlers = new Dictionary(); + _clientConnectionPacketHandlers = new Dictionary(); + + _serverUpdatePacketHandlers = new Dictionary(); + _serverConnectionPacketHandlers = new Dictionary(); - _clientAddonPacketHandlers = new Dictionary>(); - _serverAddonPacketHandlers = new Dictionary>(); + _clientAddonUpdatePacketHandlers = new Dictionary>(); + _clientAddonConnectionPacketHandlers = new Dictionary>(); + + _serverAddonUpdatePacketHandlers = new Dictionary>(); + _serverAddonConnectionPacketHandlers = new Dictionary>(); } - #region Client-related packet handling + #region Client-related update packet handling /// /// Handle data received by a client. /// /// The client update packet to handle. - public void HandleClientPacket(ClientUpdatePacket packet) { + public void HandleClientUpdatePacket(ClientUpdatePacket packet) { // Execute corresponding packet handlers for normal packet data - UnpackPacketDataDict(packet.GetPacketData(), ExecuteClientPacketHandler); + UnpackPacketDataDict(packet.GetPacketData(), ExecuteClientUpdatePacketHandler); // Execute corresponding packet handlers for addon packet data of each addon in the packet foreach (var idPacketDataPair in packet.GetAddonPacketData()) { @@ -83,26 +111,26 @@ public void HandleClientPacket(ClientUpdatePacket packet) { UnpackPacketDataDict( packetDataDict, - (packetId, packetData) => ExecuteClientAddonPacketHandler(addonId, packetId, packetData) + (packetId, packetData) => ExecuteClientAddonUpdatePacketHandler(addonId, packetId, packetData) ); } } /// - /// Executes the correct packet handler corresponding to this packet data. + /// Executes the correct packet handler corresponding to this update packet data. /// /// The client packet ID for this data. /// The packet data instance. - private void ExecuteClientPacketHandler(ClientPacketId packetId, IPacketData packetData) { - if (!_clientPacketHandlers.ContainsKey(packetId)) { - Logger.Error($"There is no client packet handler registered for ID: {packetId}"); + private void ExecuteClientUpdatePacketHandler(ClientUpdatePacketId packetId, IPacketData packetData) { + if (!_clientUpdatePacketHandlers.TryGetValue(packetId, out var handler)) { + Logger.Error($"There is no client update packet handler registered for ID: {packetId}"); return; } // Invoke the packet handler for this ID on the Unity main thread ThreadUtil.RunActionOnMainThread(() => { try { - _clientPacketHandlers[packetId].Invoke(packetData); + handler.Invoke(packetData); } catch (Exception e) { Logger.Error($"Exception occured while executing client packet handler for packet ID {packetId}:\n{e}"); } @@ -110,70 +138,160 @@ private void ExecuteClientPacketHandler(ClientPacketId packetId, IPacketData pac } /// - /// Register a packet handler for the given ID. + /// Register an update packet handler for the given ID. /// /// The client packet ID. /// The handler for the data. - private void RegisterClientPacketHandler( - ClientPacketId packetId, + private void RegisterClientUpdatePacketHandler( + ClientUpdatePacketId packetId, ClientPacketHandler handler ) { - if (_clientPacketHandlers.ContainsKey(packetId)) { + if (_clientUpdatePacketHandlers.ContainsKey(packetId)) { Logger.Warn($"Tried to register already existing client packet handler: {packetId}"); return; } - _clientPacketHandlers[packetId] = handler; + _clientUpdatePacketHandlers[packetId] = handler; } /// - /// Register a data-independent packet handler for the given ID. + /// Register a data-independent update packet handler for the given ID. /// /// The client packet ID. /// The handler for the data. - public void RegisterClientPacketHandler( - ClientPacketId packetId, + public void RegisterClientUpdatePacketHandler( + ClientUpdatePacketId packetId, Action handler - ) => RegisterClientPacketHandler(packetId, _ => handler()); + ) => RegisterClientUpdatePacketHandler(packetId, _ => handler()); /// - /// Register a packet handler for the given ID. + /// Register an update packet handler for the given ID. /// /// The client packet ID. /// The handler for the data. /// The type of the packet data passed as parameter to the handler. - public void RegisterClientPacketHandler( - ClientPacketId packetId, + public void RegisterClientUpdatePacketHandler( + ClientUpdatePacketId packetId, GenericClientPacketHandler handler - ) where T : IPacketData => RegisterClientPacketHandler(packetId, iPacket => handler((T) iPacket)); + ) where T : IPacketData => RegisterClientUpdatePacketHandler(packetId, iPacket => handler((T) iPacket)); /// - /// De-register a packet handler for the given ID. + /// De-register an update packet handler for the given ID. /// /// The client packet ID. - public void DeregisterClientPacketHandler(ClientPacketId packetId) { - if (!_clientPacketHandlers.ContainsKey(packetId)) { + public void DeregisterClientUpdatePacketHandler(ClientUpdatePacketId packetId) { + if (!_clientUpdatePacketHandlers.Remove(packetId)) { Logger.Warn($"Tried to remove nonexistent client packet handler: {packetId}"); + } + } + + #endregion + + #region Client-related connection packet handling + + /// + /// Handle connection packet data received by a client. + /// + /// The client connection packet to handle. + public void HandleClientConnectionPacket(ClientConnectionPacket packet) { + // Execute corresponding packet handlers for normal packet data + UnpackPacketDataDict(packet.GetPacketData(), ExecuteClientConnectionPacketHandler); + + // Execute corresponding packet handlers for addon packet data of each addon in the packet + foreach (var idPacketDataPair in packet.GetAddonPacketData()) { + var addonId = idPacketDataPair.Key; + var packetDataDict = idPacketDataPair.Value.PacketData; + + UnpackPacketDataDict( + packetDataDict, + (packetId, packetData) => ExecuteClientAddonConnectionPacketHandler(addonId, packetId, packetData) + ); + } + } + + /// + /// Executes the correct packet handler corresponding to this connection packet data. + /// + /// The client packet ID for this data. + /// The packet data instance. + private void ExecuteClientConnectionPacketHandler(ClientConnectionPacketId packetId, IPacketData packetData) { + if (!_clientConnectionPacketHandlers.TryGetValue(packetId, out var handler)) { + Logger.Error($"There is no client connection packet handler registered for ID: {packetId}"); return; } - _clientPacketHandlers.Remove(packetId); + // Invoke the packet handler for this ID on the Unity main thread + ThreadUtil.RunActionOnMainThread(() => { + try { + handler.Invoke(packetData); + } catch (Exception e) { + Logger.Error($"Exception occured while executing client packet handler for packet ID {packetId}:\n{e}"); + } + }); } - #endregion + /// + /// Register a connection packet handler for the given ID. + /// + /// The client packet ID. + /// The handler for the data. + private void RegisterClientConnectionPacketHandler( + ClientConnectionPacketId packetId, + ClientPacketHandler handler + ) { + if (_clientConnectionPacketHandlers.ContainsKey(packetId)) { + Logger.Warn($"Tried to register already existing client connection packet handler: {packetId}"); + return; + } - #region Server-related packet handling + _clientConnectionPacketHandlers[packetId] = handler; + } /// - /// Handle data received by the server. + /// Register a data-independent connection packet handler for the given ID. + /// + /// The client packet ID. + /// The handler for the data. + public void RegisterClientConnectionPacketHandler( + ClientConnectionPacketId packetId, + Action handler + ) => RegisterClientConnectionPacketHandler(packetId, _ => handler()); + + /// + /// Register a connection packet handler for the given ID. + /// + /// The client packet ID. + /// The handler for the data. + /// The type of the packet data passed as parameter to the handler. + public void RegisterClientConnectionPacketHandler( + ClientConnectionPacketId packetId, + GenericClientPacketHandler handler + ) where T : IPacketData => RegisterClientConnectionPacketHandler(packetId, iPacket => handler((T) iPacket)); + + /// + /// De-register a connection packet handler for the given ID. + /// + /// The client packet ID. + public void DeregisterClientConnectionPacketHandler(ClientConnectionPacketId packetId) { + if (!_clientConnectionPacketHandlers.Remove(packetId)) { + Logger.Warn($"Tried to remove nonexistent client connection packet handler: {packetId}"); + } + } + + #endregion + + #region Server-related update packet handling + + /// + /// Handle update data received by the server. /// /// The ID of the client that sent the packet. /// The server update packet. - public void HandleServerPacket(ushort id, ServerUpdatePacket packet) { + public void HandleServerUpdatePacket(ushort id, ServerUpdatePacket packet) { // Execute corresponding packet handlers UnpackPacketDataDict( packet.GetPacketData(), - (packetId, packetData) => ExecuteServerPacketHandler(id, packetId, packetData) + (packetId, packetData) => ExecuteServerUpdatePacketHandler(id, packetId, packetData) ); // Execute corresponding packet handler for addon packet data of each addon in the packet @@ -183,7 +301,7 @@ public void HandleServerPacket(ushort id, ServerUpdatePacket packet) { UnpackPacketDataDict( packetDataDict, - (packetId, packetData) => ExecuteServerAddonPacketHandler( + (packetId, packetData) => ExecuteServerAddonUpdatePacketHandler( id, addonId, packetId, @@ -194,14 +312,14 @@ public void HandleServerPacket(ushort id, ServerUpdatePacket packet) { } /// - /// Executes the correct packet handler corresponding to this packet data. + /// Executes the correct update packet handler corresponding to this packet data. /// /// The ID of the client that sent the data. /// The server packet ID. /// The packet data instance. - private void ExecuteServerPacketHandler(ushort id, ServerPacketId packetId, IPacketData packetData) { - if (!_serverPacketHandlers.ContainsKey(packetId)) { - Logger.Warn($"There is no server packet handler registered for ID: {packetId}"); + private void ExecuteServerUpdatePacketHandler(ushort id, ServerUpdatePacketId packetId, IPacketData packetData) { + if (!_serverUpdatePacketHandlers.TryGetValue(packetId, out var handler)) { + Logger.Warn($"There is no server update packet handler registered for ID: {packetId}"); return; } @@ -209,82 +327,182 @@ private void ExecuteServerPacketHandler(ushort id, ServerPacketId packetId, IPac // We don't do anything game specific with server packet handler, so there's no need to do it // on the Unity main thread try { - _serverPacketHandlers[packetId].Invoke(id, packetData); + handler.Invoke(id, packetData); } catch (Exception e) { - Logger.Error($"Exception occured while executing server packet handler for packet ID {packetId}:\n{e}"); + Logger.Error($"Exception occured while executing server update packet handler for packet ID {packetId}:\n{e}"); } } /// - /// Register a packet handler for the given ID. + /// Register an update packet handler for the given ID. /// /// The server packet ID. /// The handler for the data. - private void RegisterServerPacketHandler(ServerPacketId packetId, ServerPacketHandler handler) { - if (_serverPacketHandlers.ContainsKey(packetId)) { - Logger.Warn($"Tried to register already existing client packet handler: {packetId}"); + private void RegisterServerUpdatePacketHandler(ServerUpdatePacketId packetId, ServerPacketHandler handler) { + if (_serverUpdatePacketHandlers.ContainsKey(packetId)) { + Logger.Warn($"Tried to register already existing server update packet handler: {packetId}"); return; } - _serverPacketHandlers[packetId] = handler; + _serverUpdatePacketHandlers[packetId] = handler; } /// - /// Register a data-independent packet handler for the given ID. + /// Register a data-independent update packet handler for the given ID. /// /// The server packet ID. /// The handler for the data. - public void RegisterServerPacketHandler( - ServerPacketId packetId, + public void RegisterServerUpdatePacketHandler( + ServerUpdatePacketId packetId, EmptyServerPacketHandler handler - ) => RegisterServerPacketHandler(packetId, (id, _) => handler(id)); + ) => RegisterServerUpdatePacketHandler(packetId, (id, _) => handler(id)); /// - /// Register a packet for the given ID. + /// Register an update packet handler for the given ID. /// /// The server packet ID. /// The handler for the data. /// The type of the packet data passed as parameter to the handler. - public void RegisterServerPacketHandler( - ServerPacketId packetId, + public void RegisterServerUpdatePacketHandler( + ServerUpdatePacketId packetId, GenericServerPacketHandler handler - ) where T : IPacketData => RegisterServerPacketHandler( + ) where T : IPacketData => RegisterServerUpdatePacketHandler( packetId, (id, iPacket) => handler(id, (T) iPacket) ); /// - /// De-register a packet handler for the given ID. + /// De-register an update packet handler for the given ID. + /// + /// The server packet ID. + public void DeregisterServerUpdatePacketHandler(ServerUpdatePacketId packetId) { + if (!_serverUpdatePacketHandlers.Remove(packetId)) { + Logger.Warn($"Tried to remove nonexistent server update packet handler: {packetId}"); + } + } + + #endregion + + #region Server-related connection packet handling + + /// + /// Handle connection data received by the server. + /// + /// The ID of the client that sent the packet. + /// The server connection packet. + public void HandleServerConnectionPacket(ushort id, ServerConnectionPacket packet) { + // Execute corresponding packet handlers + UnpackPacketDataDict( + packet.GetPacketData(), + (packetId, packetData) => ExecuteServerConnectionPacketHandler(id, packetId, packetData) + ); + + // Execute corresponding packet handler for addon packet data of each addon in the packet + foreach (var idPacketDataPair in packet.GetAddonPacketData()) { + var addonId = idPacketDataPair.Key; + var packetDataDict = idPacketDataPair.Value.PacketData; + + UnpackPacketDataDict( + packetDataDict, + (packetId, packetData) => ExecuteServerAddonConnectionPacketHandler( + id, + addonId, + packetId, + packetData + ) + ); + } + } + + /// + /// Executes the correct connection packet handler corresponding to this packet data. + /// + /// The ID of the client that sent the data. + /// The server packet ID. + /// The packet data instance. + private void ExecuteServerConnectionPacketHandler(ushort id, ServerConnectionPacketId packetId, IPacketData packetData) { + if (!_serverConnectionPacketHandlers.TryGetValue(packetId, out var handler)) { + Logger.Warn($"There is no server connection packet handler registered for ID: {packetId}"); + return; + } + + // Invoke the packet handler for this ID directly, in contrast to the client packet handling. + // We don't do anything game specific with server packet handler, so there's no need to do it + // on the Unity main thread + try { + handler.Invoke(id, packetData); + } catch (Exception e) { + Logger.Error($"Exception occured while executing server connection packet handler for packet ID {packetId}:\n{e}"); + } + } + + /// + /// Register a connection packet handler for the given ID. /// /// The server packet ID. - public void DeregisterServerPacketHandler(ServerPacketId packetId) { - if (!_serverPacketHandlers.ContainsKey(packetId)) { - Logger.Warn($"Tried to remove nonexistent server packet handler: {packetId}"); + /// The handler for the data. + private void RegisterServerConnectionPacketHandler(ServerConnectionPacketId packetId, ServerPacketHandler handler) { + if (_serverConnectionPacketHandlers.ContainsKey(packetId)) { + Logger.Warn($"Tried to register already existing client packet handler: {packetId}"); return; } - _serverPacketHandlers.Remove(packetId); + _serverConnectionPacketHandlers[packetId] = handler; } - #endregion + /// + /// Register a data-independent connection packet handler for the given ID. + /// + /// The server packet ID. + /// The handler for the data. + public void RegisterServerConnectionPacketHandler( + ServerConnectionPacketId packetId, + EmptyServerPacketHandler handler + ) => RegisterServerConnectionPacketHandler(packetId, (id, _) => handler(id)); - #region Client-addon-related packet handling + /// + /// Register a connection packet handler for the given ID. + /// + /// The server packet ID. + /// The handler for the data. + /// The type of the packet data passed as parameter to the handler. + public void RegisterServerConnectionPacketHandler( + ServerConnectionPacketId packetId, + GenericServerPacketHandler handler + ) where T : IPacketData => RegisterServerConnectionPacketHandler( + packetId, + (id, iPacket) => handler(id, (T) iPacket) + ); /// - /// Execute the packet handler for the client addon data. + /// De-register a connection packet handler for the given ID. + /// + /// The server packet ID. + public void DeregisterServerConnectionPacketHandler(ServerConnectionPacketId packetId) { + if (!_serverConnectionPacketHandlers.Remove(packetId)) { + Logger.Warn($"Tried to remove nonexistent server connection packet handler: {packetId}"); + } + } + + #endregion + + #region Client-addon-related update packet handling + + /// + /// Execute the packet handler for the client addon data from the update packet. /// /// The ID of the addon. /// The ID of the packet data for the addon. /// The packet data instance. - private void ExecuteClientAddonPacketHandler( + private void ExecuteClientAddonUpdatePacketHandler( byte addonId, byte packetId, IPacketData packetData ) { var addonPacketIdMessage = $"for addon ID {addonId} and packet ID {packetId}"; var noHandlerWarningMessage = - $"There is no client addon packet handler registered {addonPacketIdMessage}"; - if (!_clientAddonPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + $"There is no client addon update packet handler registered {addonPacketIdMessage}"; + if (!_clientAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { Logger.Warn(noHandlerWarningMessage); return; } @@ -305,22 +523,112 @@ IPacketData packetData } /// - /// Register a packet handler for client addon data. + /// Register an update packet handler for client addon data. + /// + /// The ID of the addon. + /// The ID of the packet data for the addon. + /// The handler for the data. + /// Thrown if there is already a handler registered for the + /// given ID. + public void RegisterClientAddonUpdatePacketHandler( + byte addonId, + byte packetId, + ClientPacketHandler handler + ) { + if (!_clientAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + addonPacketHandlers = new Dictionary(); + + _clientAddonUpdatePacketHandlers[addonId] = addonPacketHandlers; + } + + if (addonPacketHandlers.ContainsKey(packetId)) { + throw new InvalidOperationException("There is already an update packet handler for the given ID"); + } + + addonPacketHandlers[packetId] = handler; + } + + /// + /// De-register an update packet handler for client addon data. + /// + /// The ID of the addon. + /// The ID of the packet data for the addon. + /// Thrown if there is no handler registered for the + /// given ID. + public void DeregisterClientAddonUpdatePacketHandler(byte addonId, byte packetId) { + const string invalidOperationExceptionMessage = "Could not remove nonexistent addon update packet handler"; + + if (!_clientAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + throw new InvalidOperationException(invalidOperationExceptionMessage); + } + + if (!addonPacketHandlers.Remove(packetId)) { + throw new InvalidOperationException(invalidOperationExceptionMessage); + } + } + + /// + /// Clear all registered client addon update packet handlers. + /// + public void ClearClientAddonUpdatePacketHandlers() { + _clientAddonUpdatePacketHandlers.Clear(); + } + + #endregion + + #region Client-addon-related connection packet handling + + /// + /// Execute the packet handler for the client addon data from the connection packet. + /// + /// The ID of the addon. + /// The ID of the packet data for the addon. + /// The packet data instance. + private void ExecuteClientAddonConnectionPacketHandler( + byte addonId, + byte packetId, + IPacketData packetData + ) { + var addonPacketIdMessage = $"for addon ID {addonId} and packet ID {packetId}"; + var noHandlerWarningMessage = + $"There is no client addon connection packet handler registered {addonPacketIdMessage}"; + if (!_clientAddonConnectionPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + Logger.Warn(noHandlerWarningMessage); + return; + } + + if (!addonPacketHandlers.TryGetValue(packetId, out var handler)) { + Logger.Warn(noHandlerWarningMessage); + return; + } + + // Invoke the packet handler on the Unity main thread + ThreadUtil.RunActionOnMainThread(() => { + try { + handler.Invoke(packetData); + } catch (Exception e) { + Logger.Error($"Exception occurred while executing client addon connection packet handler {addonPacketIdMessage}:\n{e}"); + } + }); + } + + /// + /// Register a connection packet handler for client addon data. /// /// The ID of the addon. /// The ID of the packet data for the addon. /// The handler for the data. /// Thrown if there is already a handler registered for the /// given ID. - public void RegisterClientAddonPacketHandler( + public void RegisterClientAddonConnectionPacketHandler( byte addonId, byte packetId, ClientPacketHandler handler ) { - if (!_clientAddonPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + if (!_clientAddonConnectionPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { addonPacketHandlers = new Dictionary(); - _clientAddonPacketHandlers[addonId] = addonPacketHandlers; + _clientAddonConnectionPacketHandlers[addonId] = addonPacketHandlers; } if (addonPacketHandlers.ContainsKey(packetId)) { @@ -331,45 +639,43 @@ ClientPacketHandler handler } /// - /// De-register a packet handler for client addon data. + /// De-register a connection packet handler for client addon data. /// /// The ID of the addon. /// The ID of the packet data for the addon. /// Thrown if there is no handler registered for the /// given ID. - public void DeregisterClientAddonPacketHandler(byte addonId, byte packetId) { + public void DeregisterClientAddonConnectionPacketHandler(byte addonId, byte packetId) { const string invalidOperationExceptionMessage = "Could not remove nonexistent addon packet handler"; - if (!_clientAddonPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + if (!_clientAddonConnectionPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { throw new InvalidOperationException(invalidOperationExceptionMessage); } - if (!addonPacketHandlers.ContainsKey(packetId)) { + if (!addonPacketHandlers.Remove(packetId)) { throw new InvalidOperationException(invalidOperationExceptionMessage); } - - addonPacketHandlers.Remove(packetId); } /// - /// Clear all registered client addon packet handlers. + /// Clear all registered client addon connection packet handlers. /// - public void ClearClientAddonPacketHandlers() { - _clientAddonPacketHandlers.Clear(); + public void ClearClientAddonConnectionPacketHandlers() { + _clientAddonConnectionPacketHandlers.Clear(); } #endregion - #region Server-addon-related packet handling + #region Server-addon-related update packet handling /// - /// Execute the packet handler for the server addon data from a client. + /// Execute the packet handler for the server addon data from a client from an update packet. /// /// The ID of the client. /// The ID of the addon. /// The ID of the packet data for the addon. /// The packet data instance. - private void ExecuteServerAddonPacketHandler( + private void ExecuteServerAddonUpdatePacketHandler( ushort id, byte addonId, byte packetId, @@ -377,8 +683,8 @@ IPacketData packetData ) { var addonPacketIdMessage = $"for addon ID {addonId} and packet ID {packetId}"; var noHandlerWarningMessage = - $"There is no server addon packet handler registered {addonPacketIdMessage}"; - if (!_serverAddonPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + $"There is no server addon update packet handler registered {addonPacketIdMessage}"; + if (!_serverAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { Logger.Warn(noHandlerWarningMessage); return; } @@ -394,59 +700,142 @@ IPacketData packetData try { handler.Invoke(id, packetData); } catch (Exception e) { - Logger.Error($"Exception occurred while executing server addon packet handler {addonPacketIdMessage}:\n{e}"); + Logger.Error($"Exception occurred while executing server addon update packet handler {addonPacketIdMessage}:\n{e}"); } } /// - /// Register a packet handler for server addon data. + /// Register an update packet handler for server addon data. /// /// The ID of the addon. /// The ID of the packet data for the addon. /// The handler for the data. /// Thrown if there is already a handler registered for the /// given ID. - public void RegisterServerAddonPacketHandler( + public void RegisterServerAddonUpdatePacketHandler( byte addonId, byte packetId, ServerPacketHandler handler ) { - if (!_serverAddonPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + if (!_serverAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { addonPacketHandlers = new Dictionary(); - _serverAddonPacketHandlers[addonId] = addonPacketHandlers; + _serverAddonUpdatePacketHandlers[addonId] = addonPacketHandlers; } if (addonPacketHandlers.ContainsKey(packetId)) { - throw new InvalidOperationException("There is already a packet handler for the given ID"); + throw new InvalidOperationException("There is already an update packet handler for the given ID"); } addonPacketHandlers[packetId] = handler; } /// - /// De-register a packet handler for server addon data. + /// De-register an update packet handler for server addon data. /// /// The ID of the addon. /// The ID of the packet data for the addon. /// Thrown if there is no handler register for the /// given ID. - public void DeregisterServerAddonPacketHandler(byte addonId, byte packetId) { - const string invalidOperationExceptionMessage = "Could not remove nonexistent addon packet handler"; + public void DeregisterServerAddonUpdatePacketHandler(byte addonId, byte packetId) { + const string invalidOperationExceptionMessage = "Could not remove nonexistent addon update packet handler"; - if (!_serverAddonPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + if (!_serverAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { throw new InvalidOperationException(invalidOperationExceptionMessage); } - if (!addonPacketHandlers.ContainsKey(packetId)) { + if (!addonPacketHandlers.Remove(packetId)) { throw new InvalidOperationException(invalidOperationExceptionMessage); } - - addonPacketHandlers.Remove(packetId); } #endregion + #region Server-addon-related connection packet handling + + /// + /// Execute the packet handler for the server addon data from a client from an connection packet. + /// + /// The ID of the client. + /// The ID of the addon. + /// The ID of the packet data for the addon. + /// The packet data instance. + private void ExecuteServerAddonConnectionPacketHandler( + ushort id, + byte addonId, + byte packetId, + IPacketData packetData + ) { + var addonPacketIdMessage = $"for addon ID {addonId} and packet ID {packetId}"; + var noHandlerWarningMessage = + $"There is no server addon connection packet handler registered {addonPacketIdMessage}"; + if (!_serverAddonUpdatePacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + Logger.Warn(noHandlerWarningMessage); + return; + } + + if (!addonPacketHandlers.TryGetValue(packetId, out var handler)) { + Logger.Warn(noHandlerWarningMessage); + return; + } + + // Invoke the packet handler for this ID directly, in contrast to the client packet handling. + // We don't do anything game specific with server packet handler, so there's no need to do it + // on the Unity main thread + try { + handler.Invoke(id, packetData); + } catch (Exception e) { + Logger.Error($"Exception occurred while executing server addon connection packet handler {addonPacketIdMessage}:\n{e}"); + } + } + + /// + /// Register a connection packet handler for server addon data. + /// + /// The ID of the addon. + /// The ID of the packet data for the addon. + /// The handler for the data. + /// Thrown if there is already a handler registered for the + /// given ID. + public void RegisterServerAddonConnectionPacketHandler( + byte addonId, + byte packetId, + ServerPacketHandler handler + ) { + if (!_serverAddonConnectionPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + addonPacketHandlers = new Dictionary(); + + _serverAddonConnectionPacketHandlers[addonId] = addonPacketHandlers; + } + + if (addonPacketHandlers.ContainsKey(packetId)) { + throw new InvalidOperationException("There is already a connection packet handler for the given ID"); + } + + addonPacketHandlers[packetId] = handler; + } + + /// + /// De-register a connection packet handler for server addon data. + /// + /// The ID of the addon. + /// The ID of the packet data for the addon. + /// Thrown if there is no handler register for the + /// given ID. + public void DeregisterServerAddonConnectionPacketHandler(byte addonId, byte packetId) { + const string invalidOperationExceptionMessage = "Could not remove nonexistent addon connection packet handler"; + + if (!_serverAddonConnectionPacketHandlers.TryGetValue(addonId, out var addonPacketHandlers)) { + throw new InvalidOperationException(invalidOperationExceptionMessage); + } + + if (!addonPacketHandlers.Remove(packetId)) { + throw new InvalidOperationException(invalidOperationExceptionMessage); + } + } + + #endregion + #region Packet handling utilities /// @@ -479,14 +868,23 @@ Action handler /// /// Handle received data and leftover data and store subsequent leftover data again. /// - /// Byte array of received data. + /// Byte array of the buffer containing received data. The entirety of the array does not + /// necessarily contain received bytes. Rather, the first bytes are received data. + /// + /// Number of received bytes in the buffer. /// Reference byte array that should be filled with leftover data. /// A list of packets that were constructed from the received data. - public static List HandleReceivedData(byte[] receivedData, ref byte[] leftoverData) { + public static List HandleReceivedData(byte[] buffer, int numReceived, ref byte[] leftoverData) { + // Make a new byte array exactly the length of number of received bytes and fill it + var receivedData = new byte[numReceived]; + for (var i = 0; i < numReceived; i++) { + receivedData[i] = buffer[i]; + } + var currentData = receivedData; // Check whether we have leftover data from the previous read, and concatenate the two byte arrays - if (leftoverData != null && leftoverData.Length > 0) { + if (leftoverData is { Length: > 0 }) { currentData = new byte[leftoverData.Length + receivedData.Length]; // Copy over the leftover data into the current data array @@ -520,8 +918,7 @@ private static List ByteArrayToPackets(byte[] data, ref byte[] leftover) // The only break from this loop is when there is no new packet to be read do { - // If there is still an int (4 bytes) to read in the data, - // it represents the next packet's length + // If there is still 2 bytes to read in the data, it represents the next packet's length var packetLength = 0; var unreadDataLength = data.Length - readIndex; if (unreadDataLength > 1) { @@ -534,15 +931,14 @@ private static List ByteArrayToPackets(byte[] data, ref byte[] leftover) break; } - // Check whether our given data array actually contains - // the same number of bytes as the packet length + // Check whether our given data array actually contains the same number of bytes as the packet length if (data.Length - readIndex < packetLength) { // There is not enough bytes in the data array to fill the requested packet with // So we put everything, including the packet length ushort (2 bytes) into the leftover byte array leftover = new byte[unreadDataLength]; for (var i = 0; i < unreadDataLength; i++) { - // Make sure to index data 2 bytes earlier, since we incremented - // when we read the packet length ushort + // Make sure to index data 2 bytes earlier, since we incremented when we read the packet + // length ushort leftover[i] = data[readIndex - 2 + i]; } diff --git a/HKMP/Networking/Packet/Update/ClientUpdatePacket.cs b/HKMP/Networking/Packet/Update/ClientUpdatePacket.cs new file mode 100644 index 00000000..f0e7ba0a --- /dev/null +++ b/HKMP/Networking/Packet/Update/ClientUpdatePacket.cs @@ -0,0 +1,54 @@ +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Packet.Update; + +/// +/// Specialization of the update packet for server to client communication. +/// +internal class ClientUpdatePacket : UpdatePacket { + /// + protected override IPacketData InstantiatePacketDataFromId(ClientUpdatePacketId packetId) { + switch (packetId) { + case ClientUpdatePacketId.Slice: + return new SliceData(); + case ClientUpdatePacketId.SliceAck: + return new SliceAckData(); + case ClientUpdatePacketId.ServerClientDisconnect: + return new ServerClientDisconnect(); + case ClientUpdatePacketId.PlayerConnect: + return new PacketDataCollection(); + case ClientUpdatePacketId.PlayerDisconnect: + return new PacketDataCollection(); + case ClientUpdatePacketId.PlayerEnterScene: + return new PacketDataCollection(); + case ClientUpdatePacketId.PlayerAlreadyInScene: + return new ClientPlayerAlreadyInScene(); + case ClientUpdatePacketId.PlayerLeaveScene: + return new PacketDataCollection(); + case ClientUpdatePacketId.PlayerUpdate: + return new PacketDataCollection(); + case ClientUpdatePacketId.PlayerMapUpdate: + return new PacketDataCollection(); + case ClientUpdatePacketId.EntitySpawn: + return new PacketDataCollection(); + case ClientUpdatePacketId.EntityUpdate: + return new PacketDataCollection(); + case ClientUpdatePacketId.ReliableEntityUpdate: + return new PacketDataCollection(); + case ClientUpdatePacketId.SceneHostTransfer: + return new HostTransfer(); + case ClientUpdatePacketId.PlayerDeath: + return new PacketDataCollection(); + case ClientUpdatePacketId.PlayerSetting: + return new PacketDataCollection(); + case ClientUpdatePacketId.ServerSettingsUpdated: + return new ServerSettingsUpdate(); + case ClientUpdatePacketId.ChatMessage: + return new PacketDataCollection(); + case ClientUpdatePacketId.SaveUpdate: + return new PacketDataCollection(); + default: + return new EmptyData(); + } + } +} diff --git a/HKMP/Networking/Packet/Update/ClientUpdatePacketId.cs b/HKMP/Networking/Packet/Update/ClientUpdatePacketId.cs new file mode 100644 index 00000000..b3512994 --- /dev/null +++ b/HKMP/Networking/Packet/Update/ClientUpdatePacketId.cs @@ -0,0 +1,101 @@ +namespace Hkmp.Networking.Packet.Update; + +/// +/// Enumeration of packet IDs for the update packet for server to client communication. +/// +internal enum ClientUpdatePacketId { + /// + /// Indicates slice data from a chunk for large data transfer. + /// + Slice = 0, + + /// + /// Indicates the acknowledgement for a slice from a chunk for large data transfer. + /// + SliceAck = 1, + + /// + /// Indicating that a client has connected. + /// + PlayerConnect = 2, + + /// + /// Indicating that a client is disconnecting. + /// + PlayerDisconnect = 3, + + /// + /// Indicating the client is (forcefully) disconnected from the server. + /// + ServerClientDisconnect = 4, + + /// + /// Notify that a player has entered the current scene. + /// + PlayerEnterScene = 5, + + /// + /// Notify that a player is already in the scene we just entered. + /// + PlayerAlreadyInScene = 6, + + /// + /// Notify that a player has left the current scene. + /// + PlayerLeaveScene = 7, + + /// + /// Update of realtime player values. + /// + PlayerUpdate = 8, + + /// + /// Update of player map position. + /// + PlayerMapUpdate = 9, + + /// + /// Notify that an entity has spawned. + /// + EntitySpawn = 10, + + /// + /// Update of realtime entity values. + /// + EntityUpdate = 11, + + /// + /// Update of realtime reliable entity values. + /// + ReliableEntityUpdate = 12, + + /// + /// Notify that the player becomes scene host of their current scene. + /// + SceneHostTransfer = 13, + + /// + /// Notify that a player has died. + /// + PlayerDeath = 14, + + /// + /// Notify that a player has changed settings (such as team or skin). + /// + PlayerSetting = 15, + + /// + /// Notify that the gameplay settings have updated. + /// + ServerSettingsUpdated = 16, + + /// + /// Player sent chat message. + /// + ChatMessage = 17, + + /// + /// Value in the save file has updated. + /// + SaveUpdate = 18, +} diff --git a/HKMP/Networking/Packet/Update/ServerUpdatePacket.cs b/HKMP/Networking/Packet/Update/ServerUpdatePacket.cs new file mode 100644 index 00000000..d6907cc8 --- /dev/null +++ b/HKMP/Networking/Packet/Update/ServerUpdatePacket.cs @@ -0,0 +1,42 @@ +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Packet.Update; + +/// +/// Specialization of the update packet for client to server communication. +/// +internal class ServerUpdatePacket : UpdatePacket { + /// + protected override IPacketData InstantiatePacketDataFromId(ServerUpdatePacketId packetId) { + switch (packetId) { + case ServerUpdatePacketId.Slice: + return new SliceData(); + case ServerUpdatePacketId.SliceAck: + return new SliceAckData(); + case ServerUpdatePacketId.PlayerUpdate: + return new PlayerUpdate(); + case ServerUpdatePacketId.PlayerMapUpdate: + return new PlayerMapUpdate(); + case ServerUpdatePacketId.EntitySpawn: + return new PacketDataCollection(); + case ServerUpdatePacketId.EntityUpdate: + return new PacketDataCollection(); + case ServerUpdatePacketId.ReliableEntityUpdate: + return new PacketDataCollection(); + case ServerUpdatePacketId.PlayerEnterScene: + return new ServerPlayerEnterScene(); + case ServerUpdatePacketId.PlayerLeaveScene: + return new ServerPlayerLeaveScene(); + case ServerUpdatePacketId.ChatMessage: + return new ChatMessage(); + case ServerUpdatePacketId.SaveUpdate: + return new PacketDataCollection(); + case ServerUpdatePacketId.ServerSettings: + return new ServerSettingsUpdate(); + case ServerUpdatePacketId.PlayerSetting: + return new ServerPlayerSettingUpdate(); + default: + return new EmptyData(); + } + } +} diff --git a/HKMP/Networking/Packet/Update/ServerUpdatePacketId.cs b/HKMP/Networking/Packet/Update/ServerUpdatePacketId.cs new file mode 100644 index 00000000..2db189c0 --- /dev/null +++ b/HKMP/Networking/Packet/Update/ServerUpdatePacketId.cs @@ -0,0 +1,81 @@ +namespace Hkmp.Networking.Packet.Update; + +/// +/// Enumeration of packet IDs for the update packet for client to server communication. +/// +public enum ServerUpdatePacketId { + /// + /// Indicates slice data from a chunk for large data transfer. + /// + Slice = 0, + + /// + /// Indicates the acknowledgement for a slice from a chunk for large data transfer. + /// + SliceAck = 1, + + /// + /// Indicating that a client is disconnecting. + /// + PlayerDisconnect = 2, + + /// + /// Update of realtime player values. + /// + PlayerUpdate = 3, + + /// + /// Update of player map position. + /// + PlayerMapUpdate = 4, + + /// + /// Notify that an entity has spawned. + /// + EntitySpawn = 5, + + /// + /// Update of realtime entity values. + /// + EntityUpdate = 6, + + /// + /// Update of realtime reliable entity values. + /// + ReliableEntityUpdate = 7, + + /// + /// Notify that the player has entered a new scene. + /// + PlayerEnterScene = 8, + + /// + /// Notify that the player has left their current scene. + /// + PlayerLeaveScene = 9, + + /// + /// Notify that a player has died. + /// + PlayerDeath = 10, + + /// + /// Player sent chat message. + /// + ChatMessage = 11, + + /// + /// Value in the save file has updated. + /// + SaveUpdate = 12, + + /// + /// Server settings are updated. + /// + ServerSettings = 13, + + /// + /// Player settings are update for the local player. + /// + PlayerSetting = 14 +} diff --git a/HKMP/Networking/Packet/Update/UpdatePacket.cs b/HKMP/Networking/Packet/Update/UpdatePacket.cs new file mode 100644 index 00000000..b95b5488 --- /dev/null +++ b/HKMP/Networking/Packet/Update/UpdatePacket.cs @@ -0,0 +1,355 @@ +using System; +using System.Collections.Generic; +using Hkmp.Logging; +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Packet.Update; + +/// +/// Abstract base class for the update packet. +/// +/// +internal abstract class UpdatePacket : BasePacket where TPacketId : Enum { + /// + /// The sequence number of this packet. + /// + public ushort Sequence { get; set; } + + /// + /// The acknowledgement number of this packet. + /// + public ushort Ack { get; set; } + + /// + /// An array containing booleans that indicate whether sequence number (Ack - x) is also acknowledged + /// for the x-th value in the array. + /// + public bool[] AckField { get; private set; } + + /// + /// Resend packet data indexed by sequence number it originates from. + /// + protected readonly Dictionary> ResendPacketData; + + /// + /// Resend addon packet data indexed by sequence number it originates from. + /// + protected readonly Dictionary> ResendAddonPacketData; + + protected UpdatePacket() { + AckField = new bool[UdpUpdateManager.AckSize]; + + ResendPacketData = new Dictionary>(); + ResendAddonPacketData = new Dictionary>(); + } + + /// + /// Write header info into the given packet (sequence number, acknowledgement number and ack field). + /// + /// The packet to write the header info into. + private void WriteHeaders(Packet packet) { + packet.Write(Sequence); + packet.Write(Ack); + + ulong ackFieldInt = 0; + ulong currentFieldValue = 1; + for (var i = 0; i < UdpUpdateManager.AckSize; i++) { + if (AckField[i]) { + ackFieldInt |= currentFieldValue; + } + + currentFieldValue *= 2; + } + + packet.Write(ackFieldInt); + } + + /// + /// Read header info from the given packet (sequence number, acknowledgement number and ack field). + /// + /// The packet to read header info from. + private void ReadHeaders(Packet packet) { + Sequence = packet.ReadUShort(); + Ack = packet.ReadUShort(); + + // Initialize the AckField array + AckField = new bool[UdpUpdateManager.AckSize]; + + var ackFieldInt = packet.ReadULong(); + ulong currentFieldValue = 1; + for (var i = 0; i < UdpUpdateManager.AckSize; i++) { + AckField[i] = (ackFieldInt & currentFieldValue) != 0; + + currentFieldValue *= 2; + } + } + + /// + public override void CreatePacket(Packet packet) { + WriteHeaders(packet); + + base.CreatePacket(packet); + + // Put the length of the resend data as an ushort in the packet + var resendLength = (ushort) ResendPacketData.Count; + if (ResendPacketData.Count > ushort.MaxValue) { + resendLength = ushort.MaxValue; + + Logger.Error("Length of resend packet data dictionary does not fit in ushort"); + } + + packet.Write(resendLength); + + // Add each entry of lost data to resend to the packet + foreach (var seqPacketDataPair in ResendPacketData) { + var seq = seqPacketDataPair.Key; + var packetData = seqPacketDataPair.Value; + + // Make sure to not put more resend data in the packet than we specified + if (resendLength-- == 0) { + break; + } + + // First write the sequence number it belongs to + packet.Write(seq); + + // Then write the reliable packet data and note that this packet now contains reliable data + WritePacketData(packet, packetData); + ContainsReliableData = true; + } + + // Put the length of the addon resend data as an ushort in the packet + resendLength = (ushort) ResendAddonPacketData.Count; + if (ResendAddonPacketData.Count > ushort.MaxValue) { + resendLength = ushort.MaxValue; + + Logger.Error("Length of addon resend packet data dictionary does not fit in ushort"); + } + + packet.Write(resendLength); + + // Add each entry of lost addon data to resend to the packet + foreach (var seqAddonDictPair in ResendAddonPacketData) { + var seq = seqAddonDictPair.Key; + var addonDataDict = seqAddonDictPair.Value; + + // Make sure to not put more resend data in the packet than we specified + if (resendLength-- == 0) { + break; + } + + // First write the sequence number it belongs to + packet.Write(seq); + + // Then write the reliable addon data for all addons and note that this packet + // now contains reliable data + WriteAddonDataDict(packet, addonDataDict); + ContainsReliableData = true; + } + + packet.WriteLength(); + } + + /// + public override bool ReadPacket(Packet packet) { + // TODO: maybe get rid of exception catching in packet reading and rely on bool return values for all + // packet reading methods (including Packet class) + try { + ReadHeaders(packet); + } catch (Exception e) { + Logger.Debug($"Exception while reading headers of packet:\n{e}"); + return false; + } + + if (!base.ReadPacket(packet)) { + return false; + } + + try { + // Read the length of the resend data + var resendLength = packet.ReadUShort(); + + while (resendLength-- > 0) { + // Read the sequence number of the packet it was lost from + var seq = packet.ReadUShort(); + + // Create a new dictionary for the packet data and read the data from the packet into it + var packetData = new Dictionary(); + ReadPacketData(packet, packetData); + + // Input the data into the resend dictionary keyed by its sequence number + ResendPacketData[seq] = packetData; + } + + // Read the length of the addon resend data + resendLength = packet.ReadUShort(); + + while (resendLength-- > 0) { + // Read the sequence number of the packet it was lost from + var seq = packet.ReadUShort(); + + // Create a new dictionary for the addon data and read the data from the packet into it + var addonDataDict = new Dictionary(); + ReadAddonDataDict(packet, addonDataDict); + + // Input the dictionary into the resend dictionary keyed by its sequence number + ResendAddonPacketData[seq] = addonDataDict; + } + } catch (Exception e) { + Logger.Debug($"Exception while reading update packet resend data:\n{e}"); + return false; + } + + return true; + } + + /// + /// Set the reliable packet data contained in the lost packet as resend data in this one. + /// + /// The update packet instance that was lost. + public void SetLostReliableData(UpdatePacket lostPacket) { + // Retrieve the lost packet data + var lostPacketData = lostPacket.GetPacketData(); + + // Finally, put the packet data dictionary in the resend dictionary keyed by its sequence number + ResendPacketData[lostPacket.Sequence] = CopyReliableDataDict( + lostPacketData, + t => NormalPacketData.ContainsKey(t) + ); + + // Retrieve the lost addon data + var lostAddonData = lostPacket.GetAddonPacketData(); + // Create a new dictionary of addon data in which we store all reliable data from the lost packet + // for all addons in the dictionary + var toResendAddonData = new Dictionary(); + + foreach (var idLostDataPair in lostAddonData) { + var addonId = idLostDataPair.Key; + var addonPacketData = idLostDataPair.Value; + + // Construct a new AddonPacketData instance that holds the reliable data only + var newAddonPacketData = addonPacketData.GetEmptyCopy(); + newAddonPacketData.PacketData = CopyReliableDataDict( + addonPacketData.PacketData, + rawPacketId => + AddonPacketData.TryGetValue(addonId, out var existingAddonData) + && existingAddonData.PacketData.ContainsKey(rawPacketId)); + + toResendAddonData[addonId] = newAddonPacketData; + } + + // Put the addon data dictionary in the resend dictionary keyed by its sequence number + ResendAddonPacketData[lostPacket.Sequence] = toResendAddonData; + } + + /// + /// Copy all reliable data in the given dictionary of lost packet data into a new dictionary. + /// + /// The dictionary containing all packet data from a lost packet. + /// Function that checks whether for a given key there is newer data + /// available. If it returns true, lost data will be dropped. + /// The key parameter of the dictionaries to copy. + /// A new dictionary containing only the reliable data. + private Dictionary CopyReliableDataDict( + Dictionary lostPacketData, + Func reliabilityCheck + ) { + // Create a new dictionary of packet data in which we store all reliable data + var reliablePacketData = new Dictionary(); + + foreach (var keyDataPair in lostPacketData) { + var key = keyDataPair.Key; + var data = keyDataPair.Value; + + // Check if the packet data is supposed to be reliable + if (!data.IsReliable) { + continue; + } + + // Check whether we can drop it since a newer version of that data already exists + if (data.DropReliableDataIfNewerExists && reliabilityCheck(key)) { + continue; + } + + // Logger.Info($" Resending {data.GetType()} data"); + reliablePacketData[key] = data; + } + + return reliablePacketData; + } + + /// + protected override void CacheAllPacketData() { + base.CacheAllPacketData(); + + void AddResendData( + Dictionary dataDict, + Dictionary cachedData + ) { + foreach (var packetIdDataPair in dataDict) { + // Get the ID and the data itself + var packetId = packetIdDataPair.Key; + var packetData = packetIdDataPair.Value; + + // Check whether for this ID there already exists data + if (cachedData.TryGetValue(packetId, out var existingPacketData)) { + // If the existing data is a PacketDataCollection, we can simply add all the data instance to it + // If not, we simply discard the resent data, since it is older + if (existingPacketData is RawPacketDataCollection existingPacketDataCollection + && packetData is RawPacketDataCollection packetDataCollection) { + existingPacketDataCollection.DataInstances.AddRange(packetDataCollection.DataInstances); + } + } else { + // If no data exists for this ID, we can simply set the resent data for that key + cachedData[packetId] = packetData; + } + } + } + + // Iteratively add the resent packet data, but make sure to merge it with existing data + foreach (var resentPacketData in ResendPacketData.Values) { + AddResendData(resentPacketData, CachedAllPacketData); + } + + // Iteratively add the resent addon data, but make sure to merge it with existing data + foreach (var resentAddonData in ResendAddonPacketData.Values) { + foreach (var addonIdDataPair in resentAddonData) { + var addonId = addonIdDataPair.Key; + var addonPacketData = addonIdDataPair.Value; + + if (CachedAllAddonData.TryGetValue(addonId, out var existingAddonPacketData)) { + AddResendData(addonPacketData.PacketData, existingAddonPacketData.PacketData); + } else { + CachedAllAddonData[addonId] = addonPacketData; + } + } + } + + IsAllPacketDataCached = true; + } + + /// + /// Drops resend data that is duplicate, i.e. that we already received in an earlier packet. + /// + /// A queue containing sequence numbers that were already + /// received. + public void DropDuplicateResendData(Queue receivedSequenceNumbers) { + // For each key in the resend dictionary, we check whether it is contained in the + // queue of sequence numbers that we already received. If so, we remove it from the dictionary + // because it is duplicate data that we already handled + foreach (var resendSequence in new List(ResendPacketData.Keys)) { + if (receivedSequenceNumbers.Contains(resendSequence)) { + // Logger.Info("Dropping resent data due to duplication"); + ResendPacketData.Remove(resendSequence); + } + } + + // Do the same for addon data + foreach (var resendSequence in new List(ResendAddonPacketData.Keys)) { + if (receivedSequenceNumbers.Contains(resendSequence)) { + // Logger.Info("Dropping resent data due to duplication"); + ResendAddonPacketData.Remove(resendSequence); + } + } + } +} diff --git a/HKMP/Networking/Packet/UpdatePacket.cs b/HKMP/Networking/Packet/UpdatePacket.cs deleted file mode 100644 index 91dbd128..00000000 --- a/HKMP/Networking/Packet/UpdatePacket.cs +++ /dev/null @@ -1,919 +0,0 @@ -using System; -using System.Collections.Generic; -using Hkmp.Logging; -using Hkmp.Networking.Packet.Data; - -namespace Hkmp.Networking.Packet; - -/// -/// Abstract base class for the update packet. -/// -/// -internal abstract class UpdatePacket where T : Enum { - // ReSharper disable once StaticMemberInGenericType - /// - /// A dictionary containing addon packet info per addon ID in order to read and convert raw addon - /// packet data into IPacketData instances. - /// - public static Dictionary AddonPacketInfoDict { get; } = - new Dictionary(); - - /// - /// The underlying raw packet instance, only used for reading data out of. - /// - private readonly Packet _packet; - - /// - /// The sequence number of this packet. - /// - public ushort Sequence { get; set; } - - /// - /// The acknowledgement number of this packet. - /// - public ushort Ack { get; set; } - - /// - /// An array containing booleans that indicate whether sequence number (Ack - x) is also acknowledged - /// for the x-th value in the array. - /// - public bool[] AckField { get; private set; } - - // TODO: refactor these dictionaries into a class that contains them for readability - /// - /// Normal non-resend packet data. - /// - private readonly Dictionary _normalPacketData; - - /// - /// Resend packet data indexed by sequence number it originates from. - /// - private readonly Dictionary> _resendPacketData; - - /// - /// Packet data from addons indexed by their ID. - /// - private readonly Dictionary _addonPacketData; - - /// - /// Resend addon packet data indexed by sequence number it originates from. - /// - private readonly Dictionary> _resendAddonPacketData; - - /// - /// The combination of normal and resent packet data cached in case it needs to be queried multiple times. - /// - private Dictionary _cachedAllPacketData; - - /// - /// The combination of addon and resent addon data cached in case it needs to be queried multiple times. - /// - private Dictionary _cachedAllAddonData; - - /// - /// Whether the dictionary containing all packet data is cached already or needs to be calculated first. - /// - private bool _isAllPacketDataCached; - - /// - /// Whether this packet contains data that needs to be reliable. - /// - private bool _containsReliableData; - - /// - /// Construct the update packet with the given raw packet instance to read from. - /// - /// The raw packet instance. - protected UpdatePacket(Packet packet) { - _packet = packet; - - AckField = new bool[UdpUpdateManager.AckSize]; - - _normalPacketData = new Dictionary(); - _resendPacketData = new Dictionary>(); - _addonPacketData = new Dictionary(); - _resendAddonPacketData = new Dictionary>(); - } - - /// - /// Write header info into the given packet (sequence number, acknowledgement number and ack field). - /// - /// The packet to write the header info into. - private void WriteHeaders(Packet packet) { - packet.Write(Sequence); - packet.Write(Ack); - - ulong ackFieldInt = 0; - ulong currentFieldValue = 1; - for (var i = 0; i < UdpUpdateManager.AckSize; i++) { - if (AckField[i]) { - ackFieldInt |= currentFieldValue; - } - - currentFieldValue *= 2; - } - - packet.Write(ackFieldInt); - } - - /// - /// Read header info from the given packet (sequence number, acknowledgement number and ack field). - /// - /// The packet to read header info from. - private void ReadHeaders(Packet packet) { - Sequence = packet.ReadUShort(); - Ack = packet.ReadUShort(); - - // Initialize the AckField array - AckField = new bool[UdpUpdateManager.AckSize]; - - var ackFieldInt = packet.ReadULong(); - ulong currentFieldValue = 1; - for (var i = 0; i < UdpUpdateManager.AckSize; i++) { - AckField[i] = (ackFieldInt & currentFieldValue) != 0; - - currentFieldValue *= 2; - } - } - - /// - /// Write the given dictionary of normal or resent packet data into the given raw packet instance. - /// - /// The packet to write into. - /// Dictionary of packet data to write. - /// true if any of the data written was reliable; otherwise false. - private bool WritePacketData( - Packet packet, - Dictionary packetData - ) { - var enumValues = (T[]) Enum.GetValues(typeof(T)); - var enumerator = ((IEnumerable) enumValues).GetEnumerator(); - var packetIdSize = (byte) enumValues.Length; - - return WritePacketData( - packet, - packetData, - enumerator, - packetIdSize - ); - } - - /// - /// Write the data in the given instance of AddonPacketData into the given raw packet instance. - /// - /// The packet to write into. - /// AddonPacketData instance from which to data should be written. - /// true if any of the data written was reliable; otherwise false. - private bool WriteAddonPacketData( - Packet packet, - AddonPacketData addonPacketData - ) => WritePacketData( - packet, - addonPacketData.PacketData, - addonPacketData.PacketIdEnumerator, - addonPacketData.PacketIdSize - ); - - /// - /// Write the given dictionary of packet data into the given raw packet instance. - /// - /// The packet to write into. - /// The dictionary containing packet data to write in the packet. - /// An enumerator that enumerates over all possible keys in the dictionary. - /// The exact size of the key space. - /// Dictionary key parameter and enumerator parameter. - /// true if any of the data written was reliable; otherwise false. - private bool WritePacketData( - Packet packet, - Dictionary packetData, - IEnumerator keyEnumerator, - byte keySpaceSize - ) { - // Keep track of the bit flag in an unsigned long, which is the largest integer implicit type allowed - ulong idFlag = 0; - // Also keep track of the value of the current bit in an unsigned long - ulong currentTypeValue = 1; - - while (keyEnumerator.MoveNext()) { - var key = keyEnumerator.Current; - - // Update the bit in the flag if the current value is included in the dictionary - if (key != null && packetData.ContainsKey(key)) { - idFlag |= currentTypeValue; - } - - // Always increase the current bit - currentTypeValue *= 2; - } - - // Based on the size of the values space, we cast to the smallest primitive that can hold the flag - // and write it to the packet - if (keySpaceSize <= 8) { - packet.Write((byte) idFlag); - } else if (keySpaceSize <= 16) { - packet.Write((ushort) idFlag); - } else if (keySpaceSize <= 32) { - packet.Write((uint) idFlag); - } else if (keySpaceSize <= 64) { - packet.Write(idFlag); - } - - // Let each individual piece of packet data write themselves into the packet - // and keep track of whether any of them need to be reliable - var containsReliableData = false; - // We loop over the possible IDs in the order from the given array to make it - // consistent between server and client - keyEnumerator.Reset(); - while (keyEnumerator.MoveNext()) { - var key = keyEnumerator.Current; - - if (key != null && packetData.TryGetValue(key, out var iPacketData)) { - iPacketData.WriteData(packet); - - if (iPacketData.IsReliable) { - containsReliableData = true; - } - } - } - - return containsReliableData; - } - - /// - /// Write the given dictionary containing addon data for all addons in the given packet. - /// - /// The raw packet instance to write into. - /// The dictionary containing all addon data to write. - /// true if any of the data written was reliable; otherwise false. - private bool WriteAddonDataDict( - Packet packet, - Dictionary addonDataDict - ) { - // Normally, we put the length of the addon packet data as a byte in the packet. - // There should only be a maximum of 255 addons, so the length should fit in a byte. - // But we don't know which addon data is going to get written correctly and which throws - // an exception, so for now we hold off on writing anything yet, but keep track of how - // many instances we are writing - var addonPacketDataCount = (byte) addonDataDict.Count; - - // We also construct a temporary packet that we use to write the progress of all - // addon packet data into. This temp packet we can then later write into the original - // packet as soon as we know the number of successful addon packet data instances we - // have written. - var addonPacketDataPacket = new Packet(); - - // Also keep track of whether we have written reliable data - var containsReliable = false; - - // Add the packet data per addon ID - foreach (var addonPacketDataPair in addonDataDict) { - var addonId = addonPacketDataPair.Key; - var addonPacketData = addonPacketDataPair.Value; - - // Create a new packet to try and write addon packet data into - var addonPacket = new Packet(); - bool addonContainsReliable; - try { - addonContainsReliable = WriteAddonPacketData( - addonPacket, - addonPacketData - ); - } catch (Exception e) { - // If the addon data writing throws an exception, we skip it entirely and since we - // wrote it in a separate packet, it has no impact on the regular packet - Logger.Debug($"Addon with ID {addonId} has thrown an exception while writing addon packet data:\n{e}"); - // We decrease the count of addon packet datas we write, so we know how many are actually in - // final packet - addonPacketDataCount--; - continue; - } - - // Prepend the length of the addon packet data to the addon packet - addonPacket.WriteLength(); - - // Now we add the addon ID to the addon packet data packet and then the contents of the addon packet - addonPacketDataPacket.Write(addonId); - addonPacketDataPacket.Write(addonPacket.ToArray()); - - // Potentially update whether this packet contains reliable data now - containsReliable |= addonContainsReliable; - } - - // Finally write the resulting size and the addon packet data itself in the regular packet - packet.Write(addonPacketDataCount); - packet.Write(addonPacketDataPacket.ToArray()); - - return containsReliable; - } - - /// - /// Read raw data from the given packet into the given packet data dictionary. - /// This method is only for normal and resent packet data, not for addon packet data. - /// - /// The raw packet instance to read from. - /// The dictionary of packet data to write the read data into. - private void ReadPacketData( - Packet packet, - Dictionary packetData - ) { - // Read the byte flag representing which packets - // are included in this update - var dataPacketIdFlag = packet.ReadUShort(); - // Keep track of value of current bit - var currentTypeValue = 1; - - var packetIdValues = Enum.GetValues(typeof(T)); - foreach (T packetId in packetIdValues) { - // If this bit was set in our flag, we add the type to the list - if ((dataPacketIdFlag & currentTypeValue) != 0) { - var iPacketData = InstantiatePacketDataFromId(packetId); - iPacketData?.ReadData(_packet); - - packetData[packetId] = iPacketData; - } - - // Increase the value of current bit - currentTypeValue *= 2; - } - } - - /// - /// Read raw addon data from the given packet into the given addon data dictionary. - /// - /// The raw packet instance to read from. - /// The size of the packet ID space. - /// A function that instantiate IPacketData implementations given a - /// packet ID in byte form. - /// The dictionary of addon data to write the read data into. - /// Thrown if the given instantiation function returns null. - private void ReadAddonPacketData( - Packet packet, - byte packetIdSize, - Func packetDataInstantiator, - Dictionary packetData - ) { - // Read the byte flag representing which packets are included in this update - // This flag may come in different primitives based on the size of the packet - // ID space - ulong dataPacketIdFlag; - - if (packetIdSize <= 8) { - dataPacketIdFlag = packet.ReadByte(); - } else if (packetIdSize <= 16) { - dataPacketIdFlag = packet.ReadUShort(); - } else if (packetIdSize <= 32) { - dataPacketIdFlag = packet.ReadUInt(); - } else if (packetIdSize <= 64) { - dataPacketIdFlag = packet.ReadULong(); - } else { - // This should never happen, but in case it does, we throw an exception - throw new Exception("Addon packet ID space size is larger than expected"); - } - - // Keep track of value of current bit in the largest integer primitive - ulong currentTypeValue = 1; - - for (byte packetId = 0; packetId < packetIdSize; packetId++) { - // If this bit was set in our flag, we add the type to the list - if ((dataPacketIdFlag & currentTypeValue) != 0) { - IPacketData iPacketData; - - // Wrap this in try catch so we can add information on what happened when the addon submitted - // packet data instantiator throws - try { - iPacketData = packetDataInstantiator.Invoke(packetId); - } catch (Exception e) { - throw new Exception( - $"Packet data instantiator for addon data threw an exception:\n{e}"); - } - - if (iPacketData == null) { - throw new Exception("Addon packet data instantiating method returned null"); - } - - iPacketData.ReadData(packet); - - packetData[packetId] = iPacketData; - } - - // Increase the value of current bit - currentTypeValue *= 2; - } - } - - /// - /// Read all raw addon data from the given packet into the given dictionary containing entries for all addons. - /// - /// The raw packet instance to read from. - /// The dictionary for all addon data to write the read data into. - /// Thrown if the any part of reading the data throws. - private void ReadAddonDataDict( - Packet packet, - Dictionary addonDataDict - ) { - // Read the number of the addon packet data instances from the packet - var numAddonData = packet.ReadByte(); - - while (numAddonData-- > 0) { - var addonId = packet.ReadByte(); - - if (!AddonPacketInfoDict.TryGetValue(addonId, out var addonPacketInfo)) { - // If the addon packet info for this addon could not be found, we need to throw an exception - throw new Exception($"Addon with ID {addonId} has no defined addon packet info"); - } - - // Read the length of the addon packet data for this addon - var addonDataLength = packet.ReadUShort(); - - // Read exactly as many bytes as was indicated by the previously read value - var addonDataBytes = packet.ReadBytes(addonDataLength); - - // Create a new packet object with the given bytes so we can sandbox the reading - var addonPacket = new Packet(addonDataBytes); - - // Create a new instance of AddonPacketData to read packet data into and eventually - // add to this packet instance's dictionary - var addonPacketData = new AddonPacketData(addonPacketInfo.PacketIdSize); - - try { - ReadAddonPacketData( - addonPacket, - addonPacketInfo.PacketIdSize, - addonPacketInfo.PacketDataInstantiator, - addonPacketData.PacketData - ); - } catch (Exception e) { - // If the addon data reading throws an exception, we skip it entirely and since - // we read it into a separate packet, it has no impact on the regular packet - Logger.Debug($"Addon with ID {addonId} has thrown an exception while reading addon packet data:\n{e}"); - continue; - } - - addonDataDict[addonId] = addonPacketData; - } - } - - /// - /// Create a raw packet out of the data contained in this class. - /// - /// A new packet instance containing all data. - public Packet CreatePacket() { - var packet = new Packet(); - - WriteHeaders(packet); - - // Write the normal packet data into the packet and keep track of whether this packet - // contains reliable data now - _containsReliableData = WritePacketData(packet, _normalPacketData); - - // Put the length of the resend data as a ushort in the packet - var resendLength = (ushort) _resendPacketData.Count; - if (_resendPacketData.Count > ushort.MaxValue) { - resendLength = ushort.MaxValue; - - Logger.Error("Length of resend packet data dictionary does not fit in ushort"); - } - - packet.Write(resendLength); - - // Add each entry of lost data to resend to the packet - foreach (var seqPacketDataPair in _resendPacketData) { - var seq = seqPacketDataPair.Key; - var packetData = seqPacketDataPair.Value; - - // Make sure to not put more resend data in the packet than we specified - if (resendLength-- == 0) { - break; - } - - // First write the sequence number it belongs to - packet.Write(seq); - - // Then write the reliable packet data and note that this packet now contains reliable data - WritePacketData(packet, packetData); - _containsReliableData = true; - } - - _containsReliableData |= WriteAddonDataDict(packet, _addonPacketData); - - // Put the length of the addon resend data as a ushort in the packet - resendLength = (ushort) _resendAddonPacketData.Count; - if (_resendAddonPacketData.Count > ushort.MaxValue) { - resendLength = ushort.MaxValue; - - Logger.Error("Length of addon resend packet data dictionary does not fit in ushort"); - } - - packet.Write(resendLength); - - // Add each entry of lost addon data to resend to the packet - foreach (var seqAddonDictPair in _resendAddonPacketData) { - var seq = seqAddonDictPair.Key; - var addonDataDict = seqAddonDictPair.Value; - - // Make sure to not put more resend data in the packet than we specified - if (resendLength-- == 0) { - break; - } - - // First write the sequence number it belongs to - packet.Write(seq); - - // Then write the reliable addon data for all addons and note that this packet - // now contains reliable data - WriteAddonDataDict(packet, addonDataDict); - _containsReliableData = true; - } - - packet.WriteLength(); - - return packet; - } - - /// - /// Read the raw packet contents into easy to access dictionaries. - /// - /// false if the packet cannot be successfully read due to malformed data; otherwise true. - public bool ReadPacket() { - try { - ReadHeaders(_packet); - - // Read the normal packet data from the packet - ReadPacketData(_packet, _normalPacketData); - - // Read the length of the resend data - var resendLength = _packet.ReadUShort(); - - while (resendLength-- > 0) { - // Read the sequence number of the packet it was lost from - var seq = _packet.ReadUShort(); - - // Create a new dictionary for the packet data and read the data from the packet into it - var packetData = new Dictionary(); - ReadPacketData(_packet, packetData); - - // Input the data into the resend dictionary keyed by its sequence number - _resendPacketData[seq] = packetData; - } - - // Read the addon packet data (non-resend) - ReadAddonDataDict(_packet, _addonPacketData); - - // Read the length of the addon resend data - resendLength = _packet.ReadUShort(); - - while (resendLength-- > 0) { - // Read the sequence number of the packet it was lost from - var seq = _packet.ReadUShort(); - - // Create a new dictionary for the addon data and read the data from the packet into it - var addonDataDict = new Dictionary(); - ReadAddonDataDict(_packet, addonDataDict); - - // Input the dictionary into the resend dictionary keyed by its sequence number - _resendAddonPacketData[seq] = addonDataDict; - } - } catch { - return false; - } - - return true; - } - - /// - /// Whether this packet contains data that needs to be reliable. - /// - /// true if the packet contains reliable data; otherwise false. - public bool ContainsReliableData() { - return _containsReliableData; - } - - /// - /// Set the reliable packet data contained in the lost packet as resend data in this one. - /// - /// The update packet instance that was lost. - public void SetLostReliableData(UpdatePacket lostPacket) { - // Retrieve the lost packet data - var lostPacketData = lostPacket.GetPacketData(); - - // Finally, put the packet data dictionary in the resend dictionary keyed by its sequence number - _resendPacketData[lostPacket.Sequence] = CopyReliableDataDict( - lostPacketData, - t => _normalPacketData.ContainsKey(t) - ); - - // Retrieve the lost addon data - var lostAddonData = lostPacket.GetAddonPacketData(); - // Create a new dictionary of addon data in which we store all reliable data from the lost packet - // for all addons in the dictionary - var toResendAddonData = new Dictionary(); - - foreach (var idLostDataPair in lostAddonData) { - var addonId = idLostDataPair.Key; - var addonPacketData = idLostDataPair.Value; - - // Construct a new AddonPacketData instance that holds the reliable data only - var newAddonPacketData = addonPacketData.GetEmptyCopy(); - newAddonPacketData.PacketData = CopyReliableDataDict( - addonPacketData.PacketData, - rawPacketId => - _addonPacketData.TryGetValue(addonId, out var existingAddonData) - && existingAddonData.PacketData.ContainsKey(rawPacketId)); - - toResendAddonData[addonId] = newAddonPacketData; - } - - // Put the addon data dictionary in the resend dictionary keyed by its sequence number - _resendAddonPacketData[lostPacket.Sequence] = toResendAddonData; - } - - /// - /// Copy all reliable data in the given dictionary of lost packet data into a new dictionary. - /// - /// The dictionary containing all packet data from a lost packet. - /// Function that checks whether for a given key there is newer data - /// available. If it returns true, lost data will be dropped. - /// The key parameter of the dictionaries to copy. - /// A new dictionary containing only the reliable data. - private Dictionary CopyReliableDataDict( - Dictionary lostPacketData, - Func reliabilityCheck - ) { - // Create a new dictionary of packet data in which we store all reliable data - var reliablePacketData = new Dictionary(); - - foreach (var keyDataPair in lostPacketData) { - var key = keyDataPair.Key; - var data = keyDataPair.Value; - - // Check if the packet data is supposed to be reliable - if (!data.IsReliable) { - continue; - } - - // Check whether we can drop it since a newer version of that data already exists - if (data.DropReliableDataIfNewerExists && reliabilityCheck(key)) { - continue; - } - - // Logger.Info($" Resending {data.GetType()} data"); - reliablePacketData[key] = data; - } - - return reliablePacketData; - } - - /// - /// Tries to get packet data that is going to be sent with the given packet ID. - /// - /// The packet ID to try and get. - /// Variable to store the retrieved data in. Null if this method returns false. - /// true if the packet data exists and will be stored in the packetData variable; otherwise - /// false. - public bool TryGetSendingPacketData(T packetId, out IPacketData packetData) { - return _normalPacketData.TryGetValue(packetId, out packetData); - } - - /// - /// Tries to get addon packet data for the addon with the given ID. - /// - /// The ID of the addon to get the data for. - /// An instance of AddonPacketData corresponding to the given ID. - /// Null if this method returns false. - /// true if the addon packet data exists and will be stored in the addonPacketData variable; - /// otherwise false. - public bool TryGetSendingAddonPacketData(byte addonId, out AddonPacketData addonPacketData) { - return _addonPacketData.TryGetValue(addonId, out addonPacketData); - } - - /// - /// Sets the given packetData with the given packet ID for sending. - /// - /// The packet ID to set data for. - /// The packet data to set. - public void SetSendingPacketData(T packetId, IPacketData packetData) { - _normalPacketData[packetId] = packetData; - } - - /// - /// Sets the given addonPacketData with the given addon ID for sending. - /// - /// The addon ID to set data for. - /// Instance of AddonPacketData to set. - public void SetSendingAddonPacketData(byte addonId, AddonPacketData packetData) { - _addonPacketData[addonId] = packetData; - } - - /// - /// Get all the packet data contained in this packet, normal and resent data (but not addon data). - /// - /// A dictionary containing packet IDs mapped to packet data. - public Dictionary GetPacketData() { - if (!_isAllPacketDataCached) { - CacheAllPacketData(); - } - - return _cachedAllPacketData; - } - - /// - /// Get the addon packet data in this packet, normal addon and resent data. - /// - /// A dictionary containing addon IDs mapped to addon packet data. - public Dictionary GetAddonPacketData() { - if (!_isAllPacketDataCached) { - CacheAllPacketData(); - } - - return _cachedAllAddonData; - } - - /// - /// Computes all packet data (normal, resent, addon and addon resent data), caches it and sets a boolean - /// indicating that this cache is now available. - /// - private void CacheAllPacketData() { - // Construct a new dictionary for all the data - _cachedAllPacketData = new Dictionary(); - - // Iteratively add the normal packet data - foreach (var packetIdDataPair in _normalPacketData) { - _cachedAllPacketData.Add(packetIdDataPair.Key, packetIdDataPair.Value); - } - - void AddResendData( - Dictionary dataDict, - Dictionary cachedData - ) { - foreach (var packetIdDataPair in dataDict) { - // Get the ID and the data itself - var packetId = packetIdDataPair.Key; - var packetData = packetIdDataPair.Value; - - // Check whether for this ID there already exists data - if (cachedData.TryGetValue(packetId, out var existingPacketData)) { - // If the existing data is a PacketDataCollection, we can simply add all the data instance to it - // If not, we simply discard the resent data, since it is older - if (existingPacketData is RawPacketDataCollection existingPacketDataCollection - && packetData is RawPacketDataCollection packetDataCollection) { - existingPacketDataCollection.DataInstances.AddRange(packetDataCollection.DataInstances); - } - } else { - // If no data exists for this ID, we can simply set the resent data for that key - cachedData[packetId] = packetData; - } - } - } - - // Iteratively add the resent packet data, but make sure to merge it with existing data - foreach (var resentPacketData in _resendPacketData.Values) { - AddResendData(resentPacketData, _cachedAllPacketData); - } - - // Do the same as above but for addon data - _cachedAllAddonData = new Dictionary(); - - // Iteratively add the addon data - foreach (var addonIdDataPair in _addonPacketData) { - _cachedAllAddonData.Add(addonIdDataPair.Key, addonIdDataPair.Value); - } - - // Iteratively add the resent addon data, but make sure to merge it with existing data - foreach (var resentAddonData in _resendAddonPacketData.Values) { - foreach (var addonIdDataPair in resentAddonData) { - var addonId = addonIdDataPair.Key; - var addonPacketData = addonIdDataPair.Value; - - if (_cachedAllAddonData.TryGetValue(addonId, out var existingAddonPacketData)) { - AddResendData(addonPacketData.PacketData, existingAddonPacketData.PacketData); - } else { - _cachedAllAddonData[addonId] = addonPacketData; - } - } - } - - _isAllPacketDataCached = true; - } - - /// - /// Drops resend data that is duplicate, i.e. that we already received in an earlier packet. - /// - /// A queue containing sequence numbers that were already - /// received. - public void DropDuplicateResendData(Queue receivedSequenceNumbers) { - // For each key in the resend dictionary, we check whether it is contained in the - // queue of sequence numbers that we already received. If so, we remove it from the dictionary - // because it is duplicate data that we already handled - foreach (var resendSequence in new List(_resendPacketData.Keys)) { - if (receivedSequenceNumbers.Contains(resendSequence)) { - // Logger.Info("Dropping resent data due to duplication"); - _resendPacketData.Remove(resendSequence); - } - } - - // Do the same for addon data - foreach (var resendSequence in new List(_resendAddonPacketData.Keys)) { - if (receivedSequenceNumbers.Contains(resendSequence)) { - // Logger.Info("Dropping resent data due to duplication"); - _resendAddonPacketData.Remove(resendSequence); - } - } - } - - /// - /// Get an instantiation of IPacketData for the given packet ID. - /// - /// The packet ID to get an instance for. - /// A new instance of IPacketData. - protected abstract IPacketData InstantiatePacketDataFromId(T packetId); -} - -/// -/// Specialization of the update packet for client to server communication. -/// -internal class ServerUpdatePacket : UpdatePacket { - // This constructor is not unused, as it is a constraint for a generic parameter in the UdpUpdateManager. - // ReSharper disable once UnusedMember.Global - public ServerUpdatePacket() : this(null) { - } - - public ServerUpdatePacket(Packet packet) : base(packet) { - } - - /// - protected override IPacketData InstantiatePacketDataFromId(ServerPacketId packetId) { - switch (packetId) { - case ServerPacketId.LoginRequest: - return new LoginRequest(); - case ServerPacketId.HelloServer: - return new HelloServer(); - case ServerPacketId.PlayerUpdate: - return new PlayerUpdate(); - case ServerPacketId.PlayerMapUpdate: - return new PlayerMapUpdate(); - case ServerPacketId.EntityUpdate: - return new PacketDataCollection(); - case ServerPacketId.PlayerEnterScene: - return new ServerPlayerEnterScene(); - case ServerPacketId.PlayerTeamUpdate: - return new ServerPlayerTeamUpdate(); - case ServerPacketId.PlayerSkinUpdate: - return new ServerPlayerSkinUpdate(); - case ServerPacketId.ChatMessage: - return new ChatMessage(); - default: - return new EmptyData(); - } - } -} - -/// -/// Specialization of the update packet for server to client communication. -/// -internal class ClientUpdatePacket : UpdatePacket { - public ClientUpdatePacket() : this(null) { - } - - public ClientUpdatePacket(Packet packet) : base(packet) { - } - - /// - protected override IPacketData InstantiatePacketDataFromId(ClientPacketId packetId) { - switch (packetId) { - case ClientPacketId.LoginResponse: - return new LoginResponse(); - case ClientPacketId.HelloClient: - return new HelloClient(); - case ClientPacketId.ServerClientDisconnect: - return new ServerClientDisconnect(); - case ClientPacketId.PlayerConnect: - return new PacketDataCollection(); - case ClientPacketId.PlayerDisconnect: - return new PacketDataCollection(); - case ClientPacketId.PlayerEnterScene: - return new PacketDataCollection(); - case ClientPacketId.PlayerAlreadyInScene: - return new ClientPlayerAlreadyInScene(); - case ClientPacketId.PlayerLeaveScene: - return new PacketDataCollection(); - case ClientPacketId.PlayerUpdate: - return new PacketDataCollection(); - case ClientPacketId.PlayerMapUpdate: - return new PacketDataCollection(); - case ClientPacketId.EntityUpdate: - return new PacketDataCollection(); - case ClientPacketId.PlayerDeath: - return new PacketDataCollection(); - case ClientPacketId.PlayerTeamUpdate: - return new PacketDataCollection(); - case ClientPacketId.PlayerSkinUpdate: - return new PacketDataCollection(); - case ClientPacketId.ServerSettingsUpdated: - return new ServerSettingsUpdate(); - case ClientPacketId.ChatMessage: - return new PacketDataCollection(); - default: - return new EmptyData(); - } - } -} diff --git a/HKMP/Networking/Server/DtlsServer.cs b/HKMP/Networking/Server/DtlsServer.cs new file mode 100644 index 00000000..c8f3486c --- /dev/null +++ b/HKMP/Networking/Server/DtlsServer.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using Hkmp.Logging; +using Org.BouncyCastle.Tls; +using Org.BouncyCastle.Tls.Crypto.Impl.BC; + +namespace Hkmp.Networking.Server; + +/// +/// DTLS implementation for a server-side peer for networking. +/// +internal class DtlsServer { + /// + /// The maximum packet size for sending and receiving DTLS packets. + /// + public const int MaxPacketSize = 1400; + + /// + /// The DTLS server protocol instance from which to start establishing connections with clients. + /// + private DtlsServerProtocol _serverProtocol; + + /// + /// The TLS client for communicating supported cipher suites and handling certificates. + /// + private ServerTlsServer _tlsServer; + + /// + /// The socket instance for the underlying networking. + /// The server only uses a single socket for all connections given that with UDP, we cannot create more than one + /// on the same listening port. + /// + private Socket _socket; + + /// + /// The server datagram transport that provides networking to the DTLS server. + /// + private ServerDatagramTransport _currentDatagramTransport; + + /// + /// Token source for cancellation tokens for the accept and receive loop tasks. + /// + private CancellationTokenSource _cancellationTokenSource; + + /// + /// Dictionary mapping IP endpoints to DTLS server client instances. This keeps track of individual clients + /// connected to the server and their respective objects. + /// + private readonly Dictionary _dtlsClients; + + /// + /// Event that is called when data is received from the given DTLS server client. + /// + public event Action DataReceivedEvent; + + /// + /// The port that the server is started on. + /// + private int _port; + + public DtlsServer() { + _dtlsClients = new Dictionary(); + } + + /// + /// Start the DTLS server on the given port. + /// + /// The port to start listening on. + public void Start(int port) { + _port = port; + + _serverProtocol = new DtlsServerProtocol(); + _tlsServer = new ServerTlsServer(new BcTlsCrypto()); + + _cancellationTokenSource = new CancellationTokenSource(); + + _socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _socket.Bind(new IPEndPoint(IPAddress.Any, _port)); + + new Thread(() => AcceptLoop(_cancellationTokenSource.Token)).Start(); + new Thread(() => SocketReceiveLoop(_cancellationTokenSource.Token)).Start(); + } + + /// + /// Stop the DTLS server by disconnecting all clients and cancelling all running threads. + /// + public void Stop() { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + + _currentDatagramTransport?.Close(); + + _tlsServer?.Cancel(); + + _socket?.Close(); + _socket = null; + + foreach (var dtlsServerClient in _dtlsClients.Values) { + InternalDisconnectClient(dtlsServerClient); + } + + _dtlsClients.Clear(); + } + + /// + /// Disconnect the client with the given IP endpoint from the server. + /// + /// The IP endpoint of the client. + public void DisconnectClient(IPEndPoint endPoint) { + if (!_dtlsClients.TryGetValue(endPoint, out var dtlsServerClient)) { + Logger.Warn("Could not find DtlsServerClient to disconnect"); + return; + } + + _dtlsClients.Remove(endPoint); + + InternalDisconnectClient(dtlsServerClient); + } + + /// + /// Disconnect the given DTLS server client from the server. This will request cancellation of the "receive loop" + /// for the client and close/cleanup the underlying DTLS objects. + /// + /// + private void InternalDisconnectClient(DtlsServerClient dtlsServerClient) { + dtlsServerClient.ReceiveLoopTokenSource?.Cancel(); + dtlsServerClient.ReceiveLoopTokenSource?.Dispose(); + + dtlsServerClient.DtlsTransport?.Close(); + dtlsServerClient.DatagramTransport.Dispose(); + } + + /// + /// Start a loop that will continuously receive data on the socket for existing and new clients. + /// + /// The cancellation token to cancel the loop. + private void SocketReceiveLoop(CancellationToken cancellationToken) { + while (!cancellationToken.IsCancellationRequested) { + EndPoint endPoint = new IPEndPoint(IPAddress.Any, 0); + + var numReceived = 0; + var buffer = new byte[MaxPacketSize]; + + try { + numReceived = _socket.ReceiveFrom( + buffer, + SocketFlags.None, + ref endPoint + ); + } catch (SocketException e) when (e.SocketErrorCode == SocketError.Interrupted) { + // SocketError Interrupted happens when the socket is closed during the receive call + // We close the socket when the server is stopped, thus this exception is expected, so we simply break + break; + } catch (SocketException e) { + Logger.Error( + $"UDP Socket exception, ErrorCode: {e.ErrorCode}, Socket ErrorCode: {e.SocketErrorCode}, Exception:\n{e}"); + } + + if (cancellationToken.IsCancellationRequested) { + break; + } + + var ipEndPoint = (IPEndPoint) endPoint; + + ServerDatagramTransport serverDatagramTransport; + if (!_dtlsClients.TryGetValue(ipEndPoint, out var dtlsServerClient)) { + Logger.Debug($"Received data on server socket from unknown IP ({ipEndPoint}), length: {numReceived}"); + + serverDatagramTransport = _currentDatagramTransport; + // Set the IP endpoint of the datagram transport instance so it can send data to the correct IP + serverDatagramTransport.IPEndPoint = ipEndPoint; + } else { + serverDatagramTransport = dtlsServerClient.DatagramTransport; + } + + try { + serverDatagramTransport.ReceivedDataCollection.Add(new UdpDatagramTransport.ReceivedData { + Buffer = buffer, + Length = numReceived + }, cancellationToken); + } catch (OperationCanceledException) { + break; + } + } + } + + /// + /// Start a loop that will continuously accept new clients on the DTLS protocol for new incoming connections. + /// + /// The cancellation token to cancel the loop. + private void AcceptLoop(CancellationToken cancellationToken) { + var serverProtocol = _serverProtocol; + ServerDatagramTransport datagramTransport = null; + + while (!cancellationToken.IsCancellationRequested) { + Logger.Debug("Creating new ServerDatagramTransport for handling new connection"); + + datagramTransport = new ServerDatagramTransport(_socket); + _currentDatagramTransport = datagramTransport; + + DtlsTransport dtlsTransport; + try { + dtlsTransport = serverProtocol.Accept(_tlsServer, datagramTransport); + } catch (TlsFatalAlert e) when (e.AlertDescription == AlertDescription.user_canceled) { + break; + } catch (IOException e) { + Logger.Error($"IOException while accepting DTLS connection:\n{e}"); + break; + } + + if (cancellationToken.IsCancellationRequested) { + break; + } + + var endPoint = datagramTransport.IPEndPoint; + + Logger.Debug($"Accepted DTLS connection on socket, endpoint: {endPoint}"); + + if (_dtlsClients.ContainsKey(endPoint)) { + Logger.Error($"DtlsClient with endpoint ({endPoint}) already exists, cannot add"); + continue; + } + + var dtlsServerClient = new DtlsServerClient { + DtlsTransport = dtlsTransport, + DatagramTransport = datagramTransport, + EndPoint = endPoint, + ReceiveLoopTokenSource = new CancellationTokenSource() + }; + + _dtlsClients.Add(endPoint, dtlsServerClient); + + Logger.Debug("Starting receive loop for client"); + new Thread(() => ClientReceiveLoop( + dtlsServerClient, + dtlsServerClient.ReceiveLoopTokenSource.Token + )).Start(); + } + + datagramTransport?.Dispose(); + } + + /// + /// Start a loop for the given DTLS server client that will continuously check whether new data is available + /// on the DTLS transport for that client. Will evoke the in case data is + /// received for that client. + /// + /// The DTLS server client to receive data for. + /// The cancellation token to cancel to loop. + private void ClientReceiveLoop(DtlsServerClient dtlsServerClient, CancellationToken cancellationToken) { + var dtlsTransport = dtlsServerClient.DtlsTransport; + + while (!cancellationToken.IsCancellationRequested) { + var buffer = new byte[dtlsTransport.GetReceiveLimit()]; + + int numReceived; + + try { + numReceived = dtlsTransport.Receive(buffer, 0, dtlsTransport.GetReceiveLimit(), 5); + } catch (TlsFatalAlert alert) { + Logger.Debug($"DtlsServerClient receive call TLS fatal alert: {alert.Message}"); + continue; + } + + if (numReceived <= 0) { + continue; + } + + try { + DataReceivedEvent?.Invoke(dtlsServerClient, buffer, numReceived); + } catch (Exception e) { + Logger.Error($"Error occurred while invoking DataReceivedEvent:\n{e}"); + } + } + } +} diff --git a/HKMP/Networking/Server/DtlsServerClient.cs b/HKMP/Networking/Server/DtlsServerClient.cs new file mode 100644 index 00000000..40456263 --- /dev/null +++ b/HKMP/Networking/Server/DtlsServerClient.cs @@ -0,0 +1,28 @@ +using System.Net; +using System.Threading; +using Org.BouncyCastle.Tls; + +namespace Hkmp.Networking.Server; + +/// +/// Data class containing the related object instances for a DTLS server client. +/// +internal class DtlsServerClient { + /// + /// The DTLS transport instance. + /// + public DtlsTransport DtlsTransport { get; init; } + /// + /// The server datagram transport. + /// + public ServerDatagramTransport DatagramTransport { get; init; } + /// + /// The IP endpoint of the client. + /// + public IPEndPoint EndPoint { get; init; } + + /// + /// The cancellation token source for the "receive loop". + /// + public CancellationTokenSource ReceiveLoopTokenSource { get; init; } +} diff --git a/HKMP/Networking/Server/NetServer.cs b/HKMP/Networking/Server/NetServer.cs index 195cfb6e..7625de60 100644 --- a/HKMP/Networking/Server/NetServer.cs +++ b/HKMP/Networking/Server/NetServer.cs @@ -3,34 +3,21 @@ using System.Collections.Generic; using System.Diagnostics; using System.Net; -using System.Net.Sockets; using System.Threading; using Hkmp.Api.Server; using Hkmp.Api.Server.Networking; using Hkmp.Logging; -using Hkmp.Networking.Client; using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Connection; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; namespace Hkmp.Networking.Server; -/// -/// Delegate for handling login requests. -/// -internal delegate bool LoginRequestHandler( - ushort id, - IPEndPoint ip, - LoginRequest loginRequest, - ServerUpdateManager updateManager -); - /// /// Server that manages connection with clients. /// internal class NetServer : INetServer { - /// - private const int MaxUdpPacketSize = 65527; - /// /// The time to throttle a client after they were rejected connection in milliseconds. /// @@ -40,21 +27,21 @@ internal class NetServer : INetServer { /// The packet manager instance. /// private readonly PacketManager _packetManager; - + /// - /// Object to lock asynchronous access when dealing with clients. + /// Underlying DTLS server instance. /// - private readonly object _clientLock = new object(); + private readonly DtlsServer _dtlsServer; /// - /// Dictionary mapping client IDs to net server clients. + /// Dictionary mapping IP end-points to net server clients. /// - private readonly ConcurrentDictionary _registeredClients; + private readonly ConcurrentDictionary _clientsByEndPoint; /// - /// Dictionary mapping IP end-points to net server clients for all clients. + /// Dictionary mapping client IDs to net server clients. /// - private readonly ConcurrentDictionary _clients; + private readonly ConcurrentDictionary _clientsById; /// /// Dictionary for the IP addresses of clients that have their connection throttled mapped to a stopwatch @@ -64,11 +51,14 @@ internal class NetServer : INetServer { private readonly ConcurrentDictionary _throttledClients; /// - /// The underlying UDP socket. + /// Concurrent queue that contains received data from a client ready for processing. /// - private Socket _udpSocket; - private readonly ConcurrentQueue _receivedQueue; + + /// + /// Wait handle for inter-thread signalling when new data is ready to be processed. + /// + private readonly AutoResetEvent _processingWaitHandle; /// /// Byte array containing leftover data that was not processed as a packet yet. @@ -80,11 +70,6 @@ internal class NetServer : INetServer { /// private CancellationTokenSource _taskTokenSource; - /// - /// Wait handle for inter-thread signalling when new data is ready to be processed. - /// - private ManualResetEventSlim _processingWaitHandle; - /// /// Event that is called when a client times out. /// @@ -95,11 +80,10 @@ internal class NetServer : INetServer { /// public event Action ShutdownEvent; - // TODO: expose to API to allow addons to reject connections /// - /// Event that is called when a new client wants to login. + /// Event that is called when a new client wants to connect. /// - public event LoginRequestHandler LoginRequestEvent; + public event Action ConnectionRequestEvent; /// public bool IsStarted { get; private set; } @@ -107,11 +91,20 @@ internal class NetServer : INetServer { public NetServer(PacketManager packetManager) { _packetManager = packetManager; - _registeredClients = new ConcurrentDictionary(); - _clients = new ConcurrentDictionary(); + _dtlsServer = new DtlsServer(); + + _clientsByEndPoint = new ConcurrentDictionary(); + _clientsById = new ConcurrentDictionary(); _throttledClients = new ConcurrentDictionary(); _receivedQueue = new ConcurrentQueue(); + + _processingWaitHandle = new AutoResetEvent(false); + + _packetManager.RegisterServerConnectionPacketHandler( + ServerConnectionPacketId.ClientInfo, + OnClientInfoReceived + ); } /// @@ -119,16 +112,14 @@ public NetServer(PacketManager packetManager) { /// /// The networking port. public void Start(int port) { + if (IsStarted) { + Stop(); + } + Logger.Info($"Starting NetServer on port {port}"); IsStarted = true; - - // Initialize the UDP socket - _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - - // Bind the socket to the given port and allow incoming packets on any address - _udpSocket.Bind(new IPEndPoint(IPAddress.Any, port)); - - _processingWaitHandle = new ManualResetEventSlim(); + + _dtlsServer.Start(port); // Create a cancellation token source for the tasks that we are creating _taskTokenSource = new CancellationTokenSource(); @@ -136,44 +127,22 @@ public void Start(int port) { // Start a thread for handling the processing of received data new Thread(() => StartProcessing(_taskTokenSource.Token)).Start(); - // Start a thread for sending updates to clients - new Thread(() => StartClientUpdates(_taskTokenSource.Token)).Start(); - - // Start a thread to receive network data from the socket - new Thread(() => ReceiveData(_taskTokenSource.Token)).Start(); + _dtlsServer.DataReceivedEvent += OnDataReceived; } /// - /// Continuously receive network UDP data and queue it for processing. + /// Callback method for when data is received from the DTLS server. Will enqueue the data in the queue. /// - /// The cancellation token for checking whether this method is requested to cancel. - private void ReceiveData(CancellationToken token) { - // Take advantage of pre-pinned memory here using pinned object heap - // var buffer = GC.AllocateArray(65527, true); - // var bufferMem = buffer.AsMemory(); - - EndPoint endPoint = new IPEndPoint(IPAddress.Any, 0); - - while (!token.IsCancellationRequested) { - var buffer = new byte[MaxUdpPacketSize]; - - try { - // This will block until data is available - _udpSocket.ReceiveFrom( - buffer, - SocketFlags.None, - ref endPoint - ); - } catch (SocketException e) { - Logger.Error($"UDP Socket exception:\n{e}"); - } - - _receivedQueue.Enqueue(new ReceivedData { - Data = buffer, - EndPoint = endPoint as IPEndPoint - }); - _processingWaitHandle.Set(); - } + /// The DTLS server client from which the data was received. + /// Byte array for the buffer of data. + /// The number of bytes in the array that can be read. + private void OnDataReceived(DtlsServerClient dtlsServerClient, byte[] buffer, int length) { + _receivedQueue.Enqueue(new ReceivedData { + DtlsServerClient = dtlsServerClient, + Buffer = buffer, + NumReceived = length + }); + _processingWaitHandle.Set(); } /// @@ -181,24 +150,22 @@ ref endPoint /// /// The cancellation token for checking whether this task is requested to cancel. private void StartProcessing(CancellationToken token) { - while (!token.IsCancellationRequested) { - try { - _processingWaitHandle.Wait(token); - } catch (OperationCanceledException) { - return; - } + WaitHandle[] waitHandles = [ _processingWaitHandle, token.WaitHandle ]; - _processingWaitHandle.Reset(); + while (!token.IsCancellationRequested) { + WaitHandle.WaitAny(waitHandles); - while (_receivedQueue.TryDequeue(out var receivedData)) { + while (!token.IsCancellationRequested && _receivedQueue.TryDequeue(out var receivedData)) { var packets = PacketManager.HandleReceivedData( - receivedData.Data, + receivedData.Buffer, + receivedData.NumReceived, ref _leftoverData ); - var endPoint = receivedData.EndPoint; + var dtlsServerClient = receivedData.DtlsServerClient; + var endPoint = dtlsServerClient.EndPoint; - if (!_clients.TryGetValue(endPoint, out var client)) { + if (!_clientsByEndPoint.TryGetValue(endPoint, out var client)) { // If the client is throttled, check their stopwatch for how long still if (_throttledClients.TryGetValue(endPoint.Address, out var clientStopwatch)) { if (clientStopwatch.ElapsedMilliseconds < ThrottleTime) { @@ -216,12 +183,10 @@ ref _leftoverData // We didn't find a client with the given address, so we assume it is a new client // that wants to connect - client = CreateNewClient(endPoint); - - HandlePacketsUnregisteredClient(client, packets); - } else { - HandlePacketsRegisteredClient(client, packets); + client = CreateNewClient(dtlsServerClient); } + + HandleClientPackets(client, packets); } } } @@ -229,33 +194,24 @@ ref _leftoverData /// /// Create a new client and start sending UDP updates and registering the timeout event. /// - /// The endpoint of the new client. + /// The DTLS server client to create the client from. /// A new net server client instance. - private NetServerClient CreateNewClient(IPEndPoint endPoint) { - var netServerClient = new NetServerClient(_udpSocket, endPoint); - netServerClient.UpdateManager.OnTimeout += () => HandleClientTimeout(netServerClient); - netServerClient.UpdateManager.StartUpdates(); + private NetServerClient CreateNewClient(DtlsServerClient dtlsServerClient) { + var netServerClient = new NetServerClient(dtlsServerClient.DtlsTransport, _packetManager, dtlsServerClient.EndPoint); + + netServerClient.ChunkSender.Start(); - _clients.TryAdd(endPoint, netServerClient); + netServerClient.ConnectionManager.ConnectionRequestEvent += OnConnectionRequest; + netServerClient.ConnectionManager.ConnectionTimeoutEvent += () => HandleClientTimeout(netServerClient); + netServerClient.ConnectionManager.StartAcceptingConnection(); - return netServerClient; - } + netServerClient.UpdateManager.TimeoutEvent += () => HandleClientTimeout(netServerClient); + netServerClient.UpdateManager.StartUpdates(); - /// - /// Start updating clients with packets. - /// - /// The cancellation token for checking whether this task is requested to cancel. - private void StartClientUpdates(CancellationToken token) { - while (!token.IsCancellationRequested) { - foreach (var client in _clients.Values) { - client.UpdateManager.ProcessUpdate(); - } + _clientsByEndPoint.TryAdd(dtlsServerClient.EndPoint, netServerClient); + _clientsById.TryAdd(netServerClient.Id, netServerClient); - // TODO: figure out a good way to get rid of the sleep here - // some way to signal when clients should be updated again would suffice - // also see NetClient#Connect - Thread.Sleep(5); - } + return netServerClient; } /// @@ -272,8 +228,9 @@ private void HandleClientTimeout(NetServerClient client) { } client.Disconnect(); - _registeredClients.TryRemove(id, out _); - _clients.TryRemove(client.EndPoint, out _); + _dtlsServer.DisconnectClient(client.EndPoint); + _clientsByEndPoint.TryRemove(client.EndPoint, out _); + _clientsById.TryRemove(id, out _); Logger.Info($"Client {id} timed out"); } @@ -283,37 +240,20 @@ private void HandleClientTimeout(NetServerClient client) { /// /// The registered client. /// The list of packets to handle. - private void HandlePacketsRegisteredClient(NetServerClient client, List packets) { + private void HandleClientPackets(NetServerClient client, List packets) { var id = client.Id; foreach (var packet in packets) { // Create a server update packet from the raw packet instance - var serverUpdatePacket = new ServerUpdatePacket(packet); - if (!serverUpdatePacket.ReadPacket()) { - // If ReadPacket returns false, we received a malformed packet, which we simply ignore for now - continue; - } - - client.UpdateManager.OnReceivePacket(serverUpdatePacket); - - // Let the packet manager handle the received data - _packetManager.HandleServerPacket(id, serverUpdatePacket); - } - } - - /// - /// Handle a list of packets from an unregistered client. - /// - /// The unregistered client. - /// The list of packets to handle. - private void HandlePacketsUnregisteredClient(NetServerClient client, List packets) { - for (var i = 0; i < packets.Count; i++) { - var packet = packets[i]; - - // Create a server update packet from the raw packet instance - var serverUpdatePacket = new ServerUpdatePacket(packet); - if (!serverUpdatePacket.ReadPacket()) { + var serverUpdatePacket = new ServerUpdatePacket(); + if (!serverUpdatePacket.ReadPacket(packet)) { // If ReadPacket returns false, we received a malformed packet + if (client.IsRegistered) { + // Since the client is registered already, we simply ignore the packet + continue; + } + + // If the client is not yet registered, we log the malformed packet, and throttle the client Logger.Debug($"Received malformed packet from client with IP: {client.EndPoint}"); // We throttle the client, because chances are that they are using an outdated version of the @@ -323,76 +263,100 @@ private void HandlePacketsUnregisteredClient(NetServerClient client, List(serverUpdatePacket); + client.UpdateManager.OnReceivePacket(serverUpdatePacket); - if (!serverUpdatePacket.GetPacketData().TryGetValue( - ServerPacketId.LoginRequest, - out var packetData - )) { - continue; + // First process slice or slice ack data if it exists and pass it onto the chunk sender or chunk receiver + var packetData = serverUpdatePacket.GetPacketData(); + if (packetData.TryGetValue(ServerUpdatePacketId.Slice, out var sliceData)) { + packetData.Remove(ServerUpdatePacketId.Slice); + client.ChunkReceiver.ProcessReceivedData((SliceData) sliceData); } - var loginRequest = (LoginRequest) packetData; - - Logger.Info($"Received login request from '{loginRequest.Username}'"); - - // Check if we actually have a login request handler - if (LoginRequestEvent == null) { - Logger.Error("Login request has no handler"); - return; + if (packetData.TryGetValue(ServerUpdatePacketId.SliceAck, out var sliceAckData)) { + packetData.Remove(ServerUpdatePacketId.SliceAck); + client.ChunkSender.ProcessReceivedData((SliceAckData) sliceAckData); } + + // Then, if the client is registered, we let the packet manager handle the rest of the data + if (client.IsRegistered) { + // Let the packet manager handle the received data + _packetManager.HandleServerUpdatePacket(id, serverUpdatePacket); + } + } + } + + /// + /// Callback method for when a connection request is received. + /// + /// The ID of the client. + /// The client info instance containing details about the client. + /// The server info instance that should be modified to reflect whether the client's + /// connection is accepted or not. + private void OnConnectionRequest(ushort clientId, ClientInfo clientInfo, ServerInfo serverInfo) { + if (!_clientsById.TryGetValue(clientId, out var client)) { + Logger.Error($"Connection request for client without known ID: {clientId}"); + serverInfo.ConnectionResult = ServerConnectionResult.RejectedOther; + serverInfo.ConnectionRejectedMessage = "Unknown client"; - // Invoke the handler of the login request and decide what to do with the client based on the result - var allowClient = LoginRequestEvent.Invoke( - client.Id, - client.EndPoint, - loginRequest, - client.UpdateManager - ); + return; + } + + // Invoke the connection request event ourselves first, then check the result + ConnectionRequestEvent?.Invoke(client, clientInfo, serverInfo); - if (allowClient) { - // Logger.Info($"Login request from '{loginRequest.Username}' approved"); - // client.UpdateManager.SetLoginResponseData(LoginResponseStatus.Success); + if (serverInfo.ConnectionResult == ServerConnectionResult.Accepted) { + Logger.Debug($"Connection request for client ID {clientId} was accepted, finishing connection sends, then registering client"); - // Register the client and add them to the dictionary + client.ConnectionManager.FinishConnection(() => { + Logger.Debug("Connection has finished sending data, registering client"); + client.IsRegistered = true; - _registeredClients[client.Id] = client; + client.ConnectionManager.StopAcceptingConnection(); + }); + } else { + Logger.Debug($"Connection request for client ID {clientId} was rejected, finishing connections sends, then throttling connection"); - // Now that the client is registered, we forward the rest of the packets to the other handler - var leftoverPackets = packets.GetRange( - i + 1, - packets.Count - i - 1 - ); + client.ConnectionManager.FinishConnection(() => { + Logger.Debug("Connection has finished sending data, disconnecting client and throttling"); - HandlePacketsRegisteredClient(client, leftoverPackets); - } else { - client.Disconnect(); - _clients.TryRemove(client.EndPoint, out _); + OnClientDisconnect(clientId); - // Throttle the client by adding their IP address without port to the dict _throttledClients[client.EndPoint.Address] = Stopwatch.StartNew(); + }); + } + } - Logger.Debug($"Throttling connection for client with IP: {client.EndPoint.Address}"); - } - - break; + /// + /// Callback method for when client info is received in a connection packet. + /// + /// The ID of the client that sent the client info. + /// The client info instance. + private void OnClientInfoReceived(ushort clientId, ClientInfo clientInfo) { + if (!_clientsById.TryGetValue(clientId, out var client)) { + Logger.Error($"ClientInfo received from client without known ID: {clientId}"); + return; } + + client.ConnectionManager.ProcessClientInfo(clientInfo); } /// /// Stops the server and cleans up everything. /// public void Stop() { + Logger.Info("Stopping NetServer"); + // Clean up existing clients - foreach (var client in _clients.Values) { + foreach (var client in _clientsByEndPoint.Values) { client.Disconnect(); } - _clients.Clear(); - _registeredClients.Clear(); + _clientsByEndPoint.Clear(); + _clientsById.Clear(); _throttledClients.Clear(); - - _udpSocket.Close(); + + _dtlsServer.Stop(); + _dtlsServer.DataReceivedEvent -= OnDataReceived; _leftoverData = null; @@ -400,8 +364,7 @@ public void Stop() { // Request cancellation for the tasks that are still running _taskTokenSource.Cancel(); - - _processingWaitHandle.Dispose(); + _taskTokenSource?.Dispose(); // Invoke the shutdown event to notify all registered parties of the shutdown ShutdownEvent?.Invoke(); @@ -412,14 +375,15 @@ public void Stop() { /// /// The ID of the client. public void OnClientDisconnect(ushort id) { - if (!_registeredClients.TryGetValue(id, out var client)) { + if (!_clientsById.TryGetValue(id, out var client)) { Logger.Warn($"Handling disconnect from ID {id}, but there's no matching client"); return; } client.Disconnect(); - _registeredClients.TryRemove(id, out _); - _clients.TryRemove(client.EndPoint, out _); + _dtlsServer.DisconnectClient(client.EndPoint); + _clientsByEndPoint.TryRemove(client.EndPoint, out _); + _clientsById.TryRemove(id, out _); Logger.Info($"Client {id} disconnected"); } @@ -431,7 +395,7 @@ public void OnClientDisconnect(ushort id) { /// The update manager for the client, or null if there does not exist a client with the /// given ID. public ServerUpdateManager GetUpdateManagerForClient(ushort id) { - if (!_registeredClients.TryGetValue(id, out var netServerClient)) { + if (!_clientsById.TryGetValue(id, out var netServerClient)) { return null; } @@ -443,7 +407,7 @@ public ServerUpdateManager GetUpdateManagerForClient(ushort id) { /// /// The action to execute with each update manager. public void SetDataForAllClients(Action dataAction) { - foreach (var netServerClient in _registeredClients.Values) { + foreach (var netServerClient in _clientsById.Values) { dataAction(netServerClient.UpdateManager); } } @@ -529,12 +493,17 @@ Func packetInstantiator /// internal class ReceivedData { /// - /// Byte array of received data. + /// The DTLS server client that sent this data. /// - public byte[] Data { get; set; } - + public DtlsServerClient DtlsServerClient { get; init; } + + /// + /// Byte array of the buffer containing received data. + /// + public byte[] Buffer { get; init; } + /// - /// The IP end-point of the client from which we received the data. + /// The number of bytes in the buffer that were received. The rest of the buffer is empty. /// - public IPEndPoint EndPoint { get; set; } + public int NumReceived { get; init; } } diff --git a/HKMP/Networking/Server/NetServerClient.cs b/HKMP/Networking/Server/NetServerClient.cs index 3ec9af8b..1b256cbf 100644 --- a/HKMP/Networking/Server/NetServerClient.cs +++ b/HKMP/Networking/Server/NetServerClient.cs @@ -1,6 +1,8 @@ using System.Collections.Concurrent; using System.Net; -using System.Net.Sockets; +using Hkmp.Networking.Chunk; +using Hkmp.Networking.Packet; +using Org.BouncyCastle.Tls; namespace Hkmp.Networking.Server; @@ -28,11 +30,25 @@ internal class NetServerClient { /// Whether the client is registered. /// public bool IsRegistered { get; set; } - + /// /// The update manager for the client. /// public ServerUpdateManager UpdateManager { get; } + + /// + /// The chunk sender instance for sending large amounts of data. + /// + public ServerChunkSender ChunkSender { get; } + /// + /// The chunk receiver instance for receiving large amounts of data. + /// + public ServerChunkReceiver ChunkReceiver { get; } + + /// + /// The connection manager for the client. + /// + public ServerConnectionManager ConnectionManager { get; } /// /// The endpoint of the client. @@ -40,26 +56,21 @@ internal class NetServerClient { public readonly IPEndPoint EndPoint; /// - /// Construct the client with the given UDP socket and endpoint. + /// Construct the client with the given DTLS transport and endpoint. /// - /// The underlying UDP socket. + /// The underlying DTLS transport. + /// The packet manager used on the server. /// The endpoint. - public NetServerClient(Socket udpSocket, IPEndPoint endPoint) { - // Also store endpoint with TCP address and TCP port + public NetServerClient(DtlsTransport dtlsTransport, PacketManager packetManager, IPEndPoint endPoint) { EndPoint = endPoint; Id = GetId(); - UpdateManager = new ServerUpdateManager(udpSocket, EndPoint); - } - - /// - /// Whether this client resides at the given endpoint. - /// - /// The endpoint to test for. - /// true if the address and port of the endpoint match the endpoint of the client; otherwise - /// false. - public bool HasAddress(IPEndPoint endPoint) { - return EndPoint.Address.Equals(endPoint.Address) && EndPoint.Port == endPoint.Port; + UpdateManager = new ServerUpdateManager { + DtlsTransport = dtlsTransport + }; + ChunkSender = new ServerChunkSender(UpdateManager); + ChunkReceiver = new ServerChunkReceiver(UpdateManager); + ConnectionManager = new ServerConnectionManager(packetManager, ChunkSender, ChunkReceiver, Id); } /// @@ -69,6 +80,8 @@ public void Disconnect() { UsedIds.TryRemove(Id, out _); UpdateManager.StopUpdates(); + ChunkSender.Stop(); + ConnectionManager.StopAcceptingConnection(); } /// @@ -81,7 +94,7 @@ private static ushort GetId() { newId = _lastId++; } while (UsedIds.ContainsKey(newId)); - UsedIds[newId] = default; + UsedIds[newId] = 0; return newId; } } diff --git a/HKMP/Networking/Server/ServerConnectionManager.cs b/HKMP/Networking/Server/ServerConnectionManager.cs new file mode 100644 index 00000000..8103d100 --- /dev/null +++ b/HKMP/Networking/Server/ServerConnectionManager.cs @@ -0,0 +1,128 @@ +using System; +using System.Timers; +using Hkmp.Logging; +using Hkmp.Networking.Chunk; +using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Connection; +using Hkmp.Networking.Packet.Data; + +namespace Hkmp.Networking.Server; + +/// +/// Server-side manager for handling the initial connection to a new client. +/// +internal class ServerConnectionManager : ConnectionManager { + /// + /// Server-side chunk sender used to handle sending chunks. + /// + private readonly ServerChunkSender _chunkSender; + /// + /// Server-side chunk received used to receive chunks. + /// + private readonly ServerChunkReceiver _chunkReceiver; + + /// + /// The ID of the client that this class manages. + /// + private readonly ushort _clientId; + + /// + /// Timer that triggers when the connection takes too long and thus times out. + /// + private readonly Timer _timeoutTimer; + + /// + /// Event that is called when the client has sent the client info, and thus we can check the connection request. + /// + public event Action ConnectionRequestEvent; + /// + /// Event that is called when the connection times out. + /// + public event Action ConnectionTimeoutEvent; + + public ServerConnectionManager( + PacketManager packetManager, + ServerChunkSender chunkSender, + ServerChunkReceiver chunkReceiver, + ushort clientId + ) : base(packetManager) { + _chunkSender = chunkSender; + _chunkReceiver = chunkReceiver; + + _clientId = clientId; + + _timeoutTimer = new Timer { + Interval = TimeoutMillis, + AutoReset = false + }; + _timeoutTimer.Elapsed += (_, _) => ConnectionTimeoutEvent?.Invoke(); + + _chunkReceiver.ChunkReceivedEvent += OnChunkReceived; + } + + /// + /// Start accepting connections, which will start the timeout timer. + /// + public void StartAcceptingConnection() { + Logger.Debug("StartAcceptingConnection"); + + _timeoutTimer.Start(); + } + + /// + /// Stop accepting connections, which will stop the timeout timer. + /// + public void StopAcceptingConnection() { + Logger.Debug("StopAcceptingConnection"); + + _timeoutTimer.Stop(); + } + + /// + /// Finish up the connection and execute the given callback when it is finished. + /// + /// The action to execute when the connection is finished. + public void FinishConnection(Action callback) { + _chunkSender.FinishSendingData(callback); + } + + /// + /// Process the given (received) client info. This will invoke the and + /// communicate the resulting server info back to the client. + /// + /// + public void ProcessClientInfo(ClientInfo clientInfo) { + Logger.Debug($"Received client info from client with ID: {_clientId}"); + + var serverInfo = new ServerInfo(); + + try { + ConnectionRequestEvent?.Invoke(_clientId, clientInfo, serverInfo); + } catch (Exception e) { + Logger.Error($"Exception occurred while executing the connection request event:\n{e}"); + } + + var connectionPacket = new ClientConnectionPacket(); + connectionPacket.SetSendingPacketData(ClientConnectionPacketId.ServerInfo, serverInfo); + + var packet = new Packet.Packet(); + connectionPacket.CreatePacket(packet); + + _chunkSender.EnqueuePacket(packet); + } + + /// + /// Callback method for when a chunk is received. Will construct a connection packet and try to read the chunk + /// into it. If successful, will let the packet manager handle the data in it. + /// + /// The raw packet that contains the data from the chunk. + private void OnChunkReceived(Packet.Packet packet) { + var connectionPacket = new ServerConnectionPacket(); + if (!connectionPacket.ReadPacket(packet)) { + Logger.Debug($"Received malformed connection packet chunk from client: {_clientId}"); + return; + } + + PacketManager.HandleServerConnectionPacket(_clientId, connectionPacket); + } +} diff --git a/HKMP/Networking/Server/ServerDatagramTransport.cs b/HKMP/Networking/Server/ServerDatagramTransport.cs new file mode 100644 index 00000000..f58eb09a --- /dev/null +++ b/HKMP/Networking/Server/ServerDatagramTransport.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Net.Sockets; +using Hkmp.Logging; + +namespace Hkmp.Networking.Server; + +/// +/// Class that implements the DatagramTransport interface from DTLS. This class simply sends and receives data based +/// on a blocking collection that is filled by data received by the DTLS server. +/// +internal class ServerDatagramTransport : UdpDatagramTransport { + /// + /// The socket instance solely used to send data. + /// + private readonly Socket _socket; + + /// + /// The IP endpoint for the client that this datagram transport belongs to. + /// + public IPEndPoint IPEndPoint { get; set; } + + public ServerDatagramTransport(Socket socket) { + _socket = socket; + } + + /// + public override int GetReceiveLimit() { + return DtlsServer.MaxPacketSize; + } + + /// + public override int GetSendLimit() { + return DtlsServer.MaxPacketSize; + } + + /// + /// The implementation simply sends the data in the buffer over the network to the IP endpoint in this instance. + public override void Send(byte[] buf, int off, int len) { + if (IPEndPoint == null) { + Logger.Error("Cannot send because transport has no endpoint"); + return; + } + + _socket.SendTo(buf, off, len, SocketFlags.None, IPEndPoint); + } +} diff --git a/HKMP/Networking/Server/ServerTlsServer.cs b/HKMP/Networking/Server/ServerTlsServer.cs new file mode 100644 index 00000000..b31aaa20 --- /dev/null +++ b/HKMP/Networking/Server/ServerTlsServer.cs @@ -0,0 +1,305 @@ +using System; +using System.IO; +using Hkmp.Logging; +using Hkmp.Util; +using Org.BouncyCastle.Asn1.Pkcs; +using Org.BouncyCastle.Asn1.X509; +using Org.BouncyCastle.Asn1.X9; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Generators; +using Org.BouncyCastle.Crypto.Operators; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Math; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Tls; +using Org.BouncyCastle.Tls.Crypto; +using Org.BouncyCastle.Tls.Crypto.Impl.BC; +using Org.BouncyCastle.Utilities.IO.Pem; +using Org.BouncyCastle.X509; +using PemReader = Org.BouncyCastle.OpenSsl.PemReader; +using PemWriter = Org.BouncyCastle.OpenSsl.PemWriter; + +// ReSharper disable InconsistentNaming + +namespace Hkmp.Networking.Server; + +/// +/// Server-side TLS client implementation that handles reporting supported cipher suites and provides the server's +/// certificate. +/// +internal class ServerTlsServer : AbstractTlsServer { + /// + /// List of supported cipher suites on the server-side. + /// + private static readonly int[] SupportedCipherSuites = [ + CipherSuite.TLS_AES_128_GCM_SHA256, + CipherSuite.TLS_AES_256_GCM_SHA384, + CipherSuite.TLS_CHACHA20_POLY1305_SHA256, + CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + CipherSuite.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + ]; + + /// + /// File name of the file that stores the private key. + /// + private const string KeyPairFileName = "key.pem"; + /// + /// File name of the file that stores the certificate. + /// + private const string CertificateFileName = "cert.cer"; + + /// + /// Full file path of the file that stores the private key. + /// + private readonly string KeyPairFilePath; + /// + /// Full file path of the file that stores the certificate. + /// + private readonly string CertificateFilePath; + + /// + /// Asymmetric key pair for the server. Used to create the server certificate. + /// + private readonly AsymmetricCipherKeyPair _keyPair; + /// + /// X509 certificate for the server. Reported to clients to validate they are connecting to the correct server. + /// + private readonly X509Certificate _certificate; + + public ServerTlsServer(TlsCrypto crypto) : base(crypto) { + KeyPairFilePath = Path.Combine(FileUtil.GetCurrentPath(), KeyPairFileName); + CertificateFilePath = Path.Combine(FileUtil.GetCurrentPath(), CertificateFileName); + + _keyPair = LoadOrGenerateECDHKeyPair(); + _certificate = LoadOrGenerateCertificate(); + } + + /// + /// Loads the ECDH key pair from file if it exists, otherwise generates the key pair and stores it to a file. + /// + /// The loaded or generated asymmetric EC key pair. + private AsymmetricCipherKeyPair LoadOrGenerateECDHKeyPair() { + Logger.Info($"LoadOrGenerateECDHKeyPair: {KeyPairFilePath}"); + + if (File.Exists(KeyPairFilePath)) { + Logger.Info("KeyPair file exists, loading..."); + return LoadECDHKeyPair(); + } + + Logger.Info("KeyPair file does not exist, generating and storing..."); + var generatedKeyPair = GenerateECDHKeyPair(GetECBuiltInBinaryDomainParameters()); + WriteObjectAsPemToFile(KeyPairFilePath, generatedKeyPair); + + return generatedKeyPair; + } + + /// + /// Load the ECDH key pair from file. + /// + /// The loaded asymmetric EC key pair, or null if the file could not be found or read. + private AsymmetricCipherKeyPair LoadECDHKeyPair() { + string fileContents; + try { + fileContents = File.ReadAllText(KeyPairFilePath); + } catch (Exception e) { + Logger.Error($"Could not read PEM key file:\n{e}"); + return null; + } + + var stringReader = new StringReader(fileContents); + var pemReader = new PemReader(stringReader); + + try { + return (AsymmetricCipherKeyPair) pemReader.ReadObject(); + } catch (Exception e) { + Logger.Error($"Could not read PEM key file:\n{e}"); + return null; + } finally { + stringReader.Close(); + pemReader.Dispose(); + } + } + + /// + /// Get built-in binary domain parameters for elliptic curve crypto using curve "K-283". + /// + /// The domain parameters corresponding to "K-283". + private static ECDomainParameters GetECBuiltInBinaryDomainParameters() { + var ecParams = ECNamedCurveTable.GetByName("K-283"); + + return new ECDomainParameters(ecParams.Curve, ecParams.G, ecParams.N, ecParams.H, ecParams.GetSeed()); + } + + /// + /// Generate an asymmetric key pair using the given domain parameters. + /// + /// The domain parameters for the generation. + /// The asymmetric key pair. + private static AsymmetricCipherKeyPair GenerateECDHKeyPair(ECDomainParameters ecParams) { + var ecKeyGenParams = new ECKeyGenerationParameters(ecParams, new SecureRandom()); + var ecKeyPairGen = new ECKeyPairGenerator(); + ecKeyPairGen.Init(ecKeyGenParams); + var ecKeyPair = ecKeyPairGen.GenerateKeyPair(); + + return ecKeyPair; + } + + /// + /// Loads the X509 certificate from file if it exists, otherwise generates the certificate and stores it to a file. + /// + /// The loaded or generated certificate. + private X509Certificate LoadOrGenerateCertificate() { + Logger.Info($"LoadOrGenerateCertificate: {CertificateFilePath}"); + + if (File.Exists(CertificateFilePath)) { + Logger.Info("Certificate file exists, loading..."); + return LoadCertificate(); + } + + Logger.Info("Certificate does not exist, generating and storing..."); + var generatedCertificate = GenerateCertificate( + new X509Name("CN=TestCA"), + new X509Name("CN=TestEE"), + _keyPair.Private, + _keyPair.Public + ); + WriteObjectAsPemToFile(CertificateFilePath, generatedCertificate); + + return generatedCertificate; + } + + /// + /// Load the X509 certificate from file. + /// + /// The loaded certificate, or null if the file could not be found or read. + private X509Certificate LoadCertificate() { + string fileContents; + try { + fileContents = File.ReadAllText(CertificateFilePath); + } catch (Exception e) { + Logger.Error($"Could not read certificate file:\n{e}"); + return null; + } + + var stringReader = new StringReader(fileContents); + var pemReader = new PemReader(stringReader); + + try { + return (X509Certificate) pemReader.ReadObject(); + } catch (Exception e) { + Logger.Error($"Could not read certificate file:\n{e}"); + return null; + } finally { + stringReader.Close(); + pemReader.Dispose(); + } + } + + /// + /// Generate a X509 certificate with the given issuer and subject names, and with the given keys. + /// + /// The issuer name. + /// The subject name. + /// The issuer private key. + /// The subject public key. + /// The generated X509 certificate. + private static X509Certificate GenerateCertificate( + X509Name issuer, X509Name subject, + AsymmetricKeyParameter issuerPrivate, + AsymmetricKeyParameter subjectPublic + ) { + ISignatureFactory signatureFactory; + if (issuerPrivate is ECPrivateKeyParameters) { + signatureFactory = new Asn1SignatureFactory( + X9ObjectIdentifiers.ECDsaWithSha256.ToString(), + issuerPrivate); + } else { + signatureFactory = new Asn1SignatureFactory( + PkcsObjectIdentifiers.Sha256WithRsaEncryption.ToString(), + issuerPrivate); + } + + var certGenerator = new X509V3CertificateGenerator(); + certGenerator.SetIssuerDN(issuer); + certGenerator.SetSubjectDN(subject); + certGenerator.SetSerialNumber(BigInteger.ValueOf(1)); + certGenerator.SetNotAfter(DateTime.UtcNow.AddYears(1)); + certGenerator.SetNotBefore(DateTime.UtcNow); + certGenerator.SetPublicKey(subjectPublic); + return certGenerator.Generate(signatureFactory); + } + + /// + /// Write a given object to a file at the given file path. This uses a and thus can + /// only write certain objects: + /// X509Certificate, X509Crl, AsymmetricCipherKeyPair, AsymmetricKeyParameter, + /// IX509AttributeCertificate, Pkcs10CertificationRequest, Asn1.Cms.ContentInfo + /// + /// The full path of the file to write the object to. + /// The object to write to file. + private static void WriteObjectAsPemToFile(string filePath, object obj) { + var stringWriter = new StringWriter(); + var pemWriter = new PemWriter(stringWriter); + + try { + pemWriter.WriteObject(obj); + } catch (PemGenerationException e) { + Logger.Error($"Could not write object to PEM file:\n{e}"); + return; + } + + pemWriter.Writer.Flush(); + + var contents = stringWriter.ToString(); + + stringWriter.Close(); + + try { + File.WriteAllText(filePath, contents); + } catch (Exception e) { + Logger.Error($"Could not write object to PEM file:\n{e}"); + } + } + + /// + protected override ProtocolVersion[] GetSupportedVersions() { + return ProtocolVersion.DTLSv12.Only(); + } + + /// + /// Get the supported cipher suites for this TLS client. + /// + /// An int array representing the cipher suites. + protected override int[] GetSupportedCipherSuites() { + return SupportedCipherSuites; + } + + /// + /// Get the server credentials for sending to clients to authenticate the server. + /// + /// TlsCredentials instance representing the credentials. + /// Thrown when the key exchange algorithm agreed upon by the server and client + /// does not match the expected, and we can thus not send our credentials. + public override TlsCredentials GetCredentials() { + var keyExchangeAlgorithm = TlsUtilities.GetKeyExchangeAlgorithm(m_selectedCipherSuite); + + if (keyExchangeAlgorithm != KeyExchangeAlgorithm.ECDHE_ECDSA) { + throw new TlsFatalAlert(AlertDescription.internal_error); + } + + var bcTlsCrypto = new BcTlsCrypto(new SecureRandom()); + + return new DefaultTlsCredentialedSigner( + new TlsCryptoParameters(m_context), + new BcTlsECDsaSigner( + bcTlsCrypto, + (ECPrivateKeyParameters) _keyPair.Private + ), + new Certificate([new BcTlsCertificate(bcTlsCrypto, _certificate.CertificateStructure)]), + SignatureAndHashAlgorithm.GetInstance( + HashAlgorithm.sha384, + SignatureAlgorithm.ecdsa + ) + ); + } +} diff --git a/HKMP/Networking/Server/ServerUpdateManager.cs b/HKMP/Networking/Server/ServerUpdateManager.cs index cbd14ab2..ff3aade6 100644 --- a/HKMP/Networking/Server/ServerUpdateManager.cs +++ b/HKMP/Networking/Server/ServerUpdateManager.cs @@ -1,38 +1,19 @@ using System; using System.Collections.Generic; -using System.Net; -using System.Net.Sockets; using Hkmp.Game; +using Hkmp.Game.Client.Entity; using Hkmp.Game.Settings; using Hkmp.Math; using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; namespace Hkmp.Networking.Server; /// /// Specialization of for server to client packet sending. /// -internal class ServerUpdateManager : UdpUpdateManager { - /// - /// The endpoint of the client. - /// - private readonly IPEndPoint _endPoint; - - /// - /// Construct the update manager with the given details. - /// - /// The underlying UDP socket for this client. - /// The endpoint of the client. - public ServerUpdateManager(Socket udpSocket, IPEndPoint endPoint) : base(udpSocket) { - _endPoint = endPoint; - } - - /// - protected override void SendPacket(Packet.Packet packet) { - UdpSocket.SendToAsync(new ArraySegment(packet.ToArray()), SocketFlags.None, _endPoint); - } - +internal class ServerUpdateManager : UdpUpdateManager { /// public override void ResendReliableData(ClientUpdatePacket lostPacket) { lock (Lock) { @@ -47,7 +28,29 @@ public override void ResendReliableData(ClientUpdatePacket lostPacket) { /// The ID of the packet data. /// The type of the generic client packet data. /// An instance of the packet data in the packet. - private T FindOrCreatePacketData(ushort id, ClientPacketId packetId) where T : GenericClientData, new() { + private T FindOrCreatePacketData(ushort id, ClientUpdatePacketId packetId) where T : GenericClientData, new() { + return FindOrCreatePacketData( + packetId, + packetData => packetData.Id == id, + () => new T { + Id = id + } + ); + } + + /// + /// Find or create a packet data instance in the current packet that matches a function. + /// + /// The ID of the packet data. + /// The function to match the packet data. + /// The function to construct the packet data if it does not exist. + /// The type of the generic client packet data. + /// An instance of the packet data in the packet. + private T FindOrCreatePacketData( + ClientUpdatePacketId packetId, + Func findFunc, + Func constructFunc + ) where T : IPacketData, new() { PacketDataCollection packetDataCollection; IPacketData packetData = null; @@ -56,8 +59,8 @@ public override void ResendReliableData(ClientUpdatePacket lostPacket) { // And if so, try to find the packet data with the requested client ID packetDataCollection = (PacketDataCollection) iPacketDataAsCollection; - foreach (var existingPacketData in packetDataCollection.DataInstances) { - if (((GenericClientData) existingPacketData).Id == id) { + foreach (T existingPacketData in packetDataCollection.DataInstances) { + if (findFunc(existingPacketData)) { packetData = existingPacketData; break; } @@ -70,36 +73,49 @@ public override void ResendReliableData(ClientUpdatePacket lostPacket) { // If no existing instance was found, create one and add it to the (newly created) collection if (packetData == null) { - packetData = new T { - Id = id - }; + packetData = constructFunc.Invoke(); packetDataCollection.DataInstances.Add(packetData); } return (T) packetData; } - + /// - /// Set login response data in the current packet. + /// Set slice data in the current packet. /// - /// The login response data. - public void SetLoginResponse(LoginResponse loginResponse) { + /// The ID of the chunk the slice belongs to. + /// The ID of the slice within the chunk. + /// The number of slices in the chunk. + /// The raw data in the slice as a byte array. + public void SetSliceData(byte chunkId, byte sliceId, byte numSlices, byte[] data) { lock (Lock) { - CurrentUpdatePacket.SetSendingPacketData(ClientPacketId.LoginResponse, loginResponse); + var sliceData = new SliceData { + ChunkId = chunkId, + SliceId = sliceId, + NumSlices = numSlices, + Data = data + }; + + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.Slice, sliceData); } } /// - /// Set hello client data in the current packet. + /// Set slice acknowledgement data in the current packet. /// - /// The list of pairs of client IDs and usernames. - public void SetHelloClientData(List<(ushort, string)> clientInfo) { + /// The ID of the chunk the slice belongs to. + /// The number of slices in the chunk. + /// A boolean array containing whether a certain slice in the chunk was acknowledged. + public void SetSliceAckData(byte chunkId, ushort numSlices, bool[] acked) { lock (Lock) { - var helloClient = new HelloClient { - ClientInfo = clientInfo + var sliceAckData = new SliceAckData { + ChunkId = chunkId, + NumSlices = numSlices, + Acked = acked }; - CurrentUpdatePacket.SetSendingPacketData(ClientPacketId.HelloClient, helloClient); + + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.SliceAck, sliceAckData); } } @@ -110,7 +126,7 @@ public void SetHelloClientData(List<(ushort, string)> clientInfo) { /// The username of the player connecting. public void AddPlayerConnectData(ushort id, string username) { lock (Lock) { - var playerConnect = FindOrCreatePacketData(id, ClientPacketId.PlayerConnect); + var playerConnect = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerConnect); playerConnect.Id = id; playerConnect.Username = username; } @@ -125,7 +141,7 @@ public void AddPlayerConnectData(ushort id, string username) { public void AddPlayerDisconnectData(ushort id, string username, bool timedOut = false) { lock (Lock) { var playerDisconnect = - FindOrCreatePacketData(id, ClientPacketId.PlayerDisconnect); + FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerDisconnect); playerDisconnect.Id = id; playerDisconnect.Username = username; playerDisconnect.TimedOut = timedOut; @@ -153,7 +169,7 @@ ushort animationClipId ) { lock (Lock) { var playerEnterScene = - FindOrCreatePacketData(id, ClientPacketId.PlayerEnterScene); + FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerEnterScene); playerEnterScene.Id = id; playerEnterScene.Username = username; playerEnterScene.Position = position; @@ -168,10 +184,16 @@ ushort animationClipId /// Add player already in scene data to the current packet. /// /// An enumerable of ClientPlayerEnterScene instances to add. + /// An enumerable of EntitySpawn instances to add. + /// An enumerable of EntityUpdate instances to add. + /// An enumerable of ReliableEntityUpdate instances to add. /// Whether the player is the scene host. public void AddPlayerAlreadyInSceneData( IEnumerable playerEnterSceneList, - bool sceneHost + IEnumerable entitySpawnList = null, + IEnumerable entityUpdateList = null, + IEnumerable reliableEntityUpdateList = null, + bool sceneHost = false ) { lock (Lock) { var alreadyInScene = new ClientPlayerAlreadyInScene { @@ -179,18 +201,32 @@ bool sceneHost }; alreadyInScene.PlayerEnterSceneList.AddRange(playerEnterSceneList); - CurrentUpdatePacket.SetSendingPacketData(ClientPacketId.PlayerAlreadyInScene, alreadyInScene); + if (entitySpawnList != null) { + alreadyInScene.EntitySpawnList.AddRange(entitySpawnList); + } + + if (entityUpdateList != null) { + alreadyInScene.EntityUpdateList.AddRange(entityUpdateList); + } + + if (reliableEntityUpdateList != null) { + alreadyInScene.ReliableEntityUpdateList.AddRange(reliableEntityUpdateList); + } + + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.PlayerAlreadyInScene, alreadyInScene); } } /// /// Add player leave scene data to the current packet. /// - /// The ID of the player. - public void AddPlayerLeaveSceneData(ushort id) { + /// The ID of the player that left the scene. + /// The name of the scene that the player left. + public void AddPlayerLeaveSceneData(ushort id, string sceneName) { lock (Lock) { - var playerLeaveScene = FindOrCreatePacketData(id, ClientPacketId.PlayerLeaveScene); + var playerLeaveScene = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerLeaveScene); playerLeaveScene.Id = id; + playerLeaveScene.SceneName = sceneName; } } @@ -201,7 +237,7 @@ public void AddPlayerLeaveSceneData(ushort id) { /// The position of the player. public void UpdatePlayerPosition(ushort id, Vector2 position) { lock (Lock) { - var playerUpdate = FindOrCreatePacketData(id, ClientPacketId.PlayerUpdate); + var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate); playerUpdate.UpdateTypes.Add(PlayerUpdateType.Position); playerUpdate.Position = position; } @@ -214,7 +250,7 @@ public void UpdatePlayerPosition(ushort id, Vector2 position) { /// The scale of the player. public void UpdatePlayerScale(ushort id, bool scale) { lock (Lock) { - var playerUpdate = FindOrCreatePacketData(id, ClientPacketId.PlayerUpdate); + var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate); playerUpdate.UpdateTypes.Add(PlayerUpdateType.Scale); playerUpdate.Scale = scale; } @@ -227,7 +263,7 @@ public void UpdatePlayerScale(ushort id, bool scale) { /// The map position of the player. public void UpdatePlayerMapPosition(ushort id, Vector2 mapPosition) { lock (Lock) { - var playerUpdate = FindOrCreatePacketData(id, ClientPacketId.PlayerUpdate); + var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate); playerUpdate.UpdateTypes.Add(PlayerUpdateType.MapPosition); playerUpdate.MapPosition = mapPosition; } @@ -240,7 +276,7 @@ public void UpdatePlayerMapPosition(ushort id, Vector2 mapPosition) { /// Whether the player has a map icon. public void UpdatePlayerMapIcon(ushort id, bool hasIcon) { lock (Lock) { - var playerMapUpdate = FindOrCreatePacketData(id, ClientPacketId.PlayerMapUpdate); + var playerMapUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerMapUpdate); playerMapUpdate.HasIcon = hasIcon; } } @@ -254,7 +290,7 @@ public void UpdatePlayerMapIcon(ushort id, bool hasIcon) { /// Boolean array containing effect info. public void UpdatePlayerAnimation(ushort id, ushort clipId, byte frame, bool[] effectInfo) { lock (Lock) { - var playerUpdate = FindOrCreatePacketData(id, ClientPacketId.PlayerUpdate); + var playerUpdate = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerUpdate); playerUpdate.UpdateTypes.Add(PlayerUpdateType.Animation); var animationInfo = new AnimationInfo { @@ -267,42 +303,77 @@ public void UpdatePlayerAnimation(ushort id, ushort clipId, byte frame, bool[] e } } + /// + /// Set entity spawn data for an entity that spawned. + /// + /// The ID of the entity. + /// The type of the entity that spawned the new entity. + /// The type of the entity that was spawned. + public void SetEntitySpawn(ushort id, EntityType spawningType, EntityType spawnedType) { + lock (Lock) { + PacketDataCollection entitySpawnCollection; + + if (CurrentUpdatePacket.TryGetSendingPacketData(ClientUpdatePacketId.EntitySpawn, out var packetData)) { + entitySpawnCollection = (PacketDataCollection) packetData; + } else { + entitySpawnCollection = new PacketDataCollection(); + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.EntitySpawn, entitySpawnCollection); + } + + entitySpawnCollection.DataInstances.Add(new EntitySpawn { + Id = id, + SpawningType = spawningType, + SpawnedType = spawnedType + }); + } + } + /// /// Find or create an entity update instance in the current packet. /// - /// The type of the entity. /// The ID of the entity. + /// The type of the entity update. Either or + /// . /// An instance of the entity update in the packet. - private EntityUpdate FindOrCreateEntityUpdate(byte entityType, byte entityId) { - EntityUpdate entityUpdate = null; - PacketDataCollection entityUpdateCollection; + private T FindOrCreateEntityUpdate(ushort entityId) where T : BaseEntityUpdate, new() { + var entityUpdate = default(T); + PacketDataCollection entityUpdateCollection; + + var packetId = typeof(T) == typeof(EntityUpdate) + ? ClientUpdatePacketId.EntityUpdate + : ClientUpdatePacketId.ReliableEntityUpdate; // First check whether there actually exists entity data at all if (CurrentUpdatePacket.TryGetSendingPacketData( - ClientPacketId.EntityUpdate, + packetId, out var packetData) ) { // And if there exists data already, try to find a match for the entity type and id - entityUpdateCollection = (PacketDataCollection) packetData; + entityUpdateCollection = (PacketDataCollection) packetData; foreach (var existingPacketData in entityUpdateCollection.DataInstances) { - var existingEntityUpdate = (EntityUpdate) existingPacketData; - if (existingEntityUpdate.EntityType.Equals(entityType) && existingEntityUpdate.Id == entityId) { + var existingEntityUpdate = (T) existingPacketData; + if (existingEntityUpdate.Id == entityId) { entityUpdate = existingEntityUpdate; break; } } } else { // If no data exists yet, we instantiate the data collection class and put it at the respective key - entityUpdateCollection = new PacketDataCollection(); - CurrentUpdatePacket.SetSendingPacketData(ClientPacketId.EntityUpdate, entityUpdateCollection); + entityUpdateCollection = new PacketDataCollection(); + CurrentUpdatePacket.SetSendingPacketData(packetId, entityUpdateCollection); } // If no existing instance was found, create one and add it to the (newly created) collection if (entityUpdate == null) { - entityUpdate = new EntityUpdate { - EntityType = entityType, - Id = entityId - }; + if (typeof(T) == typeof(EntityUpdate)) { + entityUpdate = (T) (object) new EntityUpdate { + Id = entityId + }; + } else { + entityUpdate = (T) (object) new ReliableEntityUpdate { + Id = entityId + }; + } entityUpdateCollection.DataInstances.Add(entityUpdate); @@ -314,45 +385,104 @@ private EntityUpdate FindOrCreateEntityUpdate(byte entityType, byte entityId) { /// /// Update an entity's position in the packet. /// - /// The type of the entity. /// The ID of the entity. /// The position of the entity. - public void UpdateEntityPosition(byte entityType, byte entityId, Vector2 position) { + public void UpdateEntityPosition(ushort entityId, Vector2 position) { lock (Lock) { - var entityUpdate = FindOrCreateEntityUpdate(entityType, entityId); + var entityUpdate = FindOrCreateEntityUpdate(entityId); entityUpdate.UpdateTypes.Add(EntityUpdateType.Position); entityUpdate.Position = position; } } + + /// + /// Update an entity's scale in the packet. + /// + /// The ID of the entity. + /// The scale data of the entity. + public void UpdateEntityScale(ushort entityId, EntityUpdate.ScaleData scale) { + lock (Lock) { + var entityUpdate = FindOrCreateEntityUpdate(entityId); + entityUpdate.UpdateTypes.Add(EntityUpdateType.Scale); + entityUpdate.Scale = scale; + } + } + /// - /// Update an entity's state in the packet. + /// Update an entity's animation in the packet. /// - /// The type of the entity. /// The ID of the entity. - /// The state index of the entity. - public void UpdateEntityState(byte entityType, byte entityId, byte stateIndex) { + /// The animation ID of the entity. + /// The wrap mode of the animation of the entity. + public void UpdateEntityAnimation(ushort entityId, byte animationId, byte animationWrapMode) { lock (Lock) { - var entityUpdate = FindOrCreateEntityUpdate(entityType, entityId); + var entityUpdate = FindOrCreateEntityUpdate(entityId); - entityUpdate.UpdateTypes.Add(EntityUpdateType.State); - entityUpdate.State = stateIndex; + entityUpdate.UpdateTypes.Add(EntityUpdateType.Animation); + entityUpdate.AnimationId = animationId; + entityUpdate.AnimationWrapMode = animationWrapMode; } } + + /// + /// Update whether an entity is active or not. + /// + /// The ID of the entity. + /// Whether the entity is active or not. + public void UpdateEntityIsActive(ushort entityId, bool isActive) { + lock (Lock) { + var entityUpdate = FindOrCreateEntityUpdate(entityId); + entityUpdate.UpdateTypes.Add(EntityUpdateType.Active); + entityUpdate.IsActive = isActive; + } + } + /// - /// Update an entity's variables in the packet. + /// Add data to an entity's update in the current packet. /// - /// The type of the entity. /// The ID of the entity. - /// The variables of the entity. - public void UpdateEntityVariables(byte entityType, byte entityId, List fsmVariables) { + /// The list of entity network data to add. + public void AddEntityData(ushort entityId, List data) { lock (Lock) { - var entityUpdate = FindOrCreateEntityUpdate(entityType, entityId); + var entityUpdate = FindOrCreateEntityUpdate(entityId); - entityUpdate.UpdateTypes.Add(EntityUpdateType.Variables); - entityUpdate.Variables.AddRange(fsmVariables); + entityUpdate.UpdateTypes.Add(EntityUpdateType.Data); + entityUpdate.GenericData.AddRange(data); + } + } + + /// + /// Add host entity FSM data to the current packet. + /// + /// The ID of the entity. + /// The index of the FSM of the entity. + /// The host FSM data to add. + public void AddEntityHostFsmData(ushort entityId, byte fsmIndex, EntityHostFsmData data) { + lock (Lock) { + var entityUpdate = FindOrCreateEntityUpdate(entityId); + + entityUpdate.UpdateTypes.Add(EntityUpdateType.HostFsm); + + if (entityUpdate.HostFsmData.TryGetValue(fsmIndex, out var existingData)) { + existingData.MergeData(data); + } else { + entityUpdate.HostFsmData.Add(fsmIndex, data); + } + } + } + + /// + /// Set that the receiving player should become scene host of their current scene. + /// + /// The name of the scene in which the player becomes scene host. + public void SetSceneHostTransfer(string sceneName) { + lock (Lock) { + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.SceneHostTransfer, new HostTransfer { + SceneName = sceneName + }); } } @@ -362,38 +492,71 @@ public void UpdateEntityVariables(byte entityType, byte entityId, List fsm /// The ID of the player. public void AddPlayerDeathData(ushort id) { lock (Lock) { - var playerDeath = FindOrCreatePacketData(id, ClientPacketId.PlayerDeath); + var playerDeath = FindOrCreatePacketData(id, ClientUpdatePacketId.PlayerDeath); playerDeath.Id = id; } } /// - /// Add a player team update to the current packet. + /// Add a player setting update to the current packet for the receiving player. /// - /// The ID of the player. - /// The username of the player. - /// The team of the player. - public void AddPlayerTeamUpdateData(ushort id, string username, Team team) { + /// An optional team, if the player's team changed, or null if no such team was supplied. + /// + /// An optional byte for the ID of the skin, if the player's skin changed, or null if no skin + /// ID was supplied. + public void AddPlayerSettingUpdateData(Team? team = null, byte? skinId = null) { + if (!team.HasValue && !skinId.HasValue) { + return; + } + lock (Lock) { - var playerTeamUpdate = - FindOrCreatePacketData(id, ClientPacketId.PlayerTeamUpdate); - playerTeamUpdate.Id = id; - playerTeamUpdate.Username = username; - playerTeamUpdate.Team = team; + var playerSettingUpdate = FindOrCreatePacketData( + ClientUpdatePacketId.PlayerSetting, + packetData => packetData.Self, + () => new ClientPlayerSettingUpdate { + Self = true + } + ); + + if (team.HasValue) { + playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Team); + playerSettingUpdate.Team = team.Value; + } + + if (skinId.HasValue) { + playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Skin); + playerSettingUpdate.SkinId = skinId.Value; + } } } - + /// - /// Add a player skin update to the current packet. + /// Add a player setting update to the current packet for another player. /// /// The ID of the player. - /// The ID of the skin of the player. - public void AddPlayerSkinUpdateData(ushort id, byte skinId) { + /// An optional team, if the player's team changed, or null if no such team was supplied. + /// + /// An optional byte for the ID of the skin, if the player's skin changed, or null if no such + /// ID was supplied. + public void AddOtherPlayerSettingUpdateData(ushort id, Team? team = null, byte? skinId = null) { lock (Lock) { - var playerSkinUpdate = - FindOrCreatePacketData(id, ClientPacketId.PlayerSkinUpdate); - playerSkinUpdate.Id = id; - playerSkinUpdate.SkinId = skinId; + var playerSettingUpdate = FindOrCreatePacketData( + ClientUpdatePacketId.PlayerSetting, + packetData => packetData.Id == id && !packetData.Self, + () => new ClientPlayerSettingUpdate { + Id = id + } + ); + + if (team.HasValue) { + playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Team); + playerSettingUpdate.Team = team.Value; + } + + if (skinId.HasValue) { + playerSettingUpdate.UpdateTypes.Add(PlayerSettingUpdateType.Skin); + playerSettingUpdate.SkinId = skinId.Value; + } } } @@ -404,7 +567,7 @@ public void AddPlayerSkinUpdateData(ushort id, byte skinId) { public void UpdateServerSettings(ServerSettings serverSettings) { lock (Lock) { CurrentUpdatePacket.SetSendingPacketData( - ClientPacketId.ServerSettingsUpdated, + ClientUpdatePacketId.ServerSettingsUpdated, new ServerSettingsUpdate { ServerSettings = serverSettings } @@ -415,10 +578,11 @@ public void UpdateServerSettings(ServerSettings serverSettings) { /// /// Set that the client is disconnected from the server with the given reason. /// + /// The reason for the disconnect. public void SetDisconnect(DisconnectReason reason) { lock (Lock) { CurrentUpdatePacket.SetSendingPacketData( - ClientPacketId.ServerClientDisconnect, + ClientUpdatePacketId.ServerClientDisconnect, new ServerClientDisconnect { Reason = reason } @@ -434,12 +598,12 @@ public void AddChatMessage(string message) { lock (Lock) { PacketDataCollection packetDataCollection; - if (CurrentUpdatePacket.TryGetSendingPacketData(ClientPacketId.ChatMessage, out var packetData)) { + if (CurrentUpdatePacket.TryGetSendingPacketData(ClientUpdatePacketId.ChatMessage, out var packetData)) { packetDataCollection = (PacketDataCollection) packetData; } else { packetDataCollection = new PacketDataCollection(); - CurrentUpdatePacket.SetSendingPacketData(ClientPacketId.ChatMessage, packetDataCollection); + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.ChatMessage, packetDataCollection); } packetDataCollection.DataInstances.Add(new ChatMessage { @@ -447,4 +611,27 @@ public void AddChatMessage(string message) { }); } } + + /// + /// Set save update data. + /// + /// The index of the save data entry. + /// The array of bytes that represents the changed value. + public void SetSaveUpdate(ushort index, byte[] value) { + lock (Lock) { + PacketDataCollection saveUpdateCollection; + + if (CurrentUpdatePacket.TryGetSendingPacketData(ClientUpdatePacketId.SaveUpdate, out var packetData)) { + saveUpdateCollection = (PacketDataCollection) packetData; + } else { + saveUpdateCollection = new PacketDataCollection(); + CurrentUpdatePacket.SetSendingPacketData(ClientUpdatePacketId.SaveUpdate, saveUpdateCollection); + } + + saveUpdateCollection.DataInstances.Add(new SaveUpdate { + SaveDataIndex = index, + Value = value + }); + } + } } diff --git a/HKMP/Networking/ServerConnectionResult.cs b/HKMP/Networking/ServerConnectionResult.cs new file mode 100644 index 00000000..f20a167f --- /dev/null +++ b/HKMP/Networking/ServerConnectionResult.cs @@ -0,0 +1,19 @@ +namespace Hkmp.Networking; + +/// +/// Enumeration of connection result values. +/// +internal enum ServerConnectionResult { + /// + /// The client was accepted. + /// + Accepted = 0, + /// + /// The client is using different addons to the server. + /// + InvalidAddons, + /// + /// The client was rejected for other reasons. + /// + RejectedOther +} diff --git a/HKMP/Networking/UdpCongestionManager.cs b/HKMP/Networking/UdpCongestionManager.cs index a9f5ff24..6368b36b 100644 --- a/HKMP/Networking/UdpCongestionManager.cs +++ b/HKMP/Networking/UdpCongestionManager.cs @@ -3,6 +3,7 @@ using System.Diagnostics; using Hkmp.Logging; using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Update; namespace Hkmp.Networking; @@ -292,7 +293,7 @@ public void OnSendPacket(ushort sequence, TOutgoing updatePacket) { // Check if this packet contained information that needed to be reliable // and if so, resend the data by adding it to the current packet - if (sentPacket.Packet.ContainsReliableData()) { + if (sentPacket.Packet.ContainsReliableData) { // Logger.Debug( // $"Packet ack of seq: {seqSentPacketPair.Key} with reliable data exceeded maximum RTT, assuming lost, resending data"); diff --git a/HKMP/Networking/UdpDatagramTransport.cs b/HKMP/Networking/UdpDatagramTransport.cs new file mode 100644 index 00000000..4f5ebfa4 --- /dev/null +++ b/HKMP/Networking/UdpDatagramTransport.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using Org.BouncyCastle.Tls; + +namespace Hkmp.Networking; + +/// +/// Abstract base class of the client and server datagram transports for DTLS over UDP. +/// +internal abstract class UdpDatagramTransport : DatagramTransport { + + /// + /// Token source for cancelling the blocking call on the received data collection. + /// + private readonly CancellationTokenSource _cancellationTokenSource; + + /// + /// A thread-safe blocking collection storing received data that is used to handle the "Receive" calls from the + /// DTLS transport. + /// + public BlockingCollection ReceivedDataCollection { get; } + + protected UdpDatagramTransport() { + _cancellationTokenSource = new CancellationTokenSource(); + + ReceivedDataCollection = new BlockingCollection(); + } + + /// + /// This method is called whenever the corresponding DtlsTransport's Receive is called. The implementation + /// obtains data from the blocking collection and store it in the given buffer. If no data is present in the + /// collection within the given , the method returns -1. + /// + /// Byte array to store the received data. + /// The offset at which to begin storing the data. + /// The number of bytes that can be stored in the buffer. + /// The number of milliseconds to wait for data to fill. + /// The number of bytes that were received, or -1 if no bytes were received in the given time. + public int Receive(byte[] buf, int off, int len, int waitMillis) { + if (_cancellationTokenSource.IsCancellationRequested) { + return -1; + } + + bool tryTakeSuccess; + ReceivedData data; + + try { + tryTakeSuccess = ReceivedDataCollection.TryTake(out data, waitMillis, _cancellationTokenSource.Token); + } catch (OperationCanceledException) { + return -1; + } + + if (!tryTakeSuccess) { + return -1; + } + + // If there is more data in the entry we received from the blocking collection than space in the buffer + // from the method, we need to add as much data into the buffer and put the rest back in the collection + if (len < data.Length) { + // Fill the buffer from the method with as much data from the entry as possible + for (var i = off; i < off + len; i++) { + buf[i] = data.Buffer[i - off]; + } + + // Calculate the length of the leftover buffer and instantiate it + var leftoverLength = data.Length - len; + var leftoverBuffer = new byte[leftoverLength]; + + // Fill the leftover buffer with the leftover data from the entry + for (var i = 0; i < leftoverLength; i++) { + leftoverBuffer[i] = data.Buffer[len + i]; + } + + // Add the leftover buffer and its length back to the collection + ReceivedDataCollection.Add(new ReceivedData { + Buffer = leftoverBuffer, + Length = leftoverLength + }); + + return len; + } + + // In this case, the space in the buffer from the method is large enough, so we fill it with all the data + // from the collection entry + for (var i = 0; i < data.Length; i++) { + buf[off + i] = data.Buffer[i]; + } + + return data.Length; + } + + /// + /// The maximum number of bytes to receive in a single call to . + /// + /// The maximum number of bytes that can be received. + public abstract int GetReceiveLimit(); + + /// + /// The maximum number of bytes to send in a single call to . + /// + /// The maximum number of bytes that can be sent. + public abstract int GetSendLimit(); + + /// + /// This method is called whenever the corresponding DtlsTransport's Send is called. + /// + /// Byte array containing the bytes to send. + /// The offset in the buffer at which to start sending bytes. + /// The number of bytes to send. + public abstract void Send(byte[] buf, int off, int len); + + /// + /// Cleanup login for when this transport channel should be closed. + /// + public void Close() { + _cancellationTokenSource?.Cancel(); + } + + /// + /// Dispose of the underlying unmanaged resources. + /// + public void Dispose() { + _cancellationTokenSource?.Dispose(); + ReceivedDataCollection?.Dispose(); + } + + /// + /// Data class containing a buffer and the corresponding length of bytes stored in that buffer. Not necessarily + /// the length of the buffer. + /// + public class ReceivedData { + /// + /// Byte array containing the data. + /// + public byte[] Buffer { get; set; } + /// + /// The number of bytes in the buffer. + /// + public int Length { get; set; } + } +} diff --git a/HKMP/Networking/UdpUpdateManager.cs b/HKMP/Networking/UdpUpdateManager.cs index 0531cbe0..4d53b926 100644 --- a/HKMP/Networking/UdpUpdateManager.cs +++ b/HKMP/Networking/UdpUpdateManager.cs @@ -1,9 +1,12 @@ using System; -using System.Net.Sockets; +using System.Timers; using Hkmp.Concurrency; using Hkmp.Logging; using Hkmp.Networking.Packet; using Hkmp.Networking.Packet.Data; +using Hkmp.Networking.Packet.Update; +using Org.BouncyCastle.Tls; +using Timer = System.Timers.Timer; namespace Hkmp.Networking; @@ -28,26 +31,24 @@ internal abstract class UdpUpdateManager : UdpUpdateManage private const int ConnectionTimeout = 5000; /// - /// The number of sequence numbers to store in the received queue to construct ack fields with and - /// to check against resent data. + /// The MTU (maximum transfer unit) to use to send packets with. If the length of a packet exceeds this, we break + /// it up into smaller packets before sending. This ensures that we control the breaking of packets in most + /// cases and do not rely on smaller network devices for the breaking up as this could impact performance. + /// This size is lower than the limit for DTLS packets, since there is a slight DTLS overhead for packets. /// - private const int ReceiveQueueSize = AckSize; + private const int PacketMtu = 1200; /// - /// The Socket instance to use to send packets. + /// The number of sequence numbers to store in the received queue to construct ack fields with and + /// to check against resent data. /// - protected readonly Socket UdpSocket; + private const int ReceiveQueueSize = AckSize; /// /// The UDP congestion manager instance. /// private readonly UdpCongestionManager _udpCongestionManager; - /// - /// Boolean indicating whether we are allowed to send packets. - /// - private bool _canSendPackets; - /// /// The last sent sequence number. /// @@ -74,14 +75,30 @@ internal abstract class UdpUpdateManager : UdpUpdateManage protected TOutgoing CurrentUpdatePacket; /// - /// Stopwatch to keep track of when to send a new update. + /// Timer for keeping track of when to send an update packet. + /// + private readonly Timer _sendTimer; + + /// + /// Timer for keeping track of the connection timing out. /// - private readonly ConcurrentStopwatch _sendStopwatch; + private readonly Timer _heartBeatTimer; /// - /// Stopwatch to keep track of the heart beat to know when the client times out. + /// The last used send rate for the send timer. Used to check whether the interval of the timers needs to be + /// updated. /// - private readonly ConcurrentStopwatch _heartBeatStopwatch; + private int _lastSendRate; + + /// + /// Whether this update manager is actually updating and sending packets. + /// + private bool _isUpdating; + + /// + /// The Socket instance to use to send packets. + /// + public DtlsTransport DtlsTransport { get; set; } /// /// The current send rate in milliseconds between sending packets. @@ -96,73 +113,61 @@ internal abstract class UdpUpdateManager : UdpUpdateManage /// /// Event that is called when the client times out. /// - public event Action OnTimeout; + public event Action TimeoutEvent; /// /// Construct the update manager with a UDP socket. /// - /// The UDP socket instance. - protected UdpUpdateManager(Socket udpSocket) { - UdpSocket = udpSocket; - + protected UdpUpdateManager() { _udpCongestionManager = new UdpCongestionManager(this); _receivedQueue = new ConcurrentFixedSizeQueue(ReceiveQueueSize); CurrentUpdatePacket = new TOutgoing(); - _sendStopwatch = new ConcurrentStopwatch(); - _heartBeatStopwatch = new ConcurrentStopwatch(); + // Construct the timers with correct intervals and register the Elapsed events + _sendTimer = new Timer { + AutoReset = true, + Interval = CurrentSendRate + }; + _sendTimer.Elapsed += OnSendTimerElapsed; + + _heartBeatTimer = new Timer { + AutoReset = false, + Interval = ConnectionTimeout + }; + _heartBeatTimer.Elapsed += OnHeartBeatTimerElapsed; } /// - /// Start the update manager and allow sending updates. + /// Start the update manager. This will start the send and heartbeat timers, which will respectively trigger + /// sending update packets and trigger on connection timing out. /// public void StartUpdates() { - _canSendPackets = true; + _lastSendRate = CurrentSendRate; + _sendTimer.Start(); + _heartBeatTimer.Start(); - _sendStopwatch.Restart(); - _heartBeatStopwatch.Restart(); + _isUpdating = true; } /// - /// Process an update for this update manager. + /// Stop sending the periodic UDP update packets after sending the current one. /// - public void ProcessUpdate() { - if (!_canSendPackets) { + public void StopUpdates() { + if (!_isUpdating) { return; } - // Check if we can send another update - if (_sendStopwatch.ElapsedMilliseconds > CurrentSendRate) { - CreateAndSendUpdatePacket(); - - _sendStopwatch.Restart(); - } - - // Check heartbeat to make sure the connection is still alive - if (_heartBeatStopwatch.ElapsedMilliseconds > ConnectionTimeout) { - // The stopwatch has surpassed the connection timeout value, so we call the timeout event - OnTimeout?.Invoke(); - - // Stop the stopwatch for now to prevent the callback being executed multiple times - _heartBeatStopwatch.Reset(); - } - } - - /// - /// Stop sending the periodic UDP update packets after sending the current one. - /// - public void StopUpdates() { + _isUpdating = false; + Logger.Debug("Stopping UDP updates, sending last packet"); - + // Send the last packet CreateAndSendUpdatePacket(); - _sendStopwatch.Reset(); - _heartBeatStopwatch.Reset(); - - _canSendPackets = false; + _sendTimer.Stop(); + _heartBeatTimer.Stop(); } /// @@ -188,18 +193,20 @@ public void OnReceivePacket(TIncoming packet) _remoteSequence = sequence; } - _heartBeatStopwatch.Restart(); + // Reset the heart beat timer, as we have received a packet and the connection is alive + _heartBeatTimer.Stop(); + _heartBeatTimer.Start(); } /// /// Create and send the current update packet. /// private void CreateAndSendUpdatePacket() { - if (UdpSocket == null) { + if (DtlsTransport == null) { return; } - Packet.Packet packet; + var packet = new Packet.Packet(); TOutgoing updatePacket; lock (Lock) { @@ -216,7 +223,12 @@ private void CreateAndSendUpdatePacket() { CurrentUpdatePacket.AckField[i] = receivedQueue.Contains(pastSequence); } - packet = CurrentUpdatePacket.CreatePacket(); + try { + CurrentUpdatePacket.CreatePacket(packet); + } catch (Exception e) { + Logger.Error($"An error occurred while trying to create packet:\n{e}"); + return; + } // Reset the packet by creating a new instance, // but keep the original instance for reliability data re-sending @@ -229,9 +241,54 @@ private void CreateAndSendUpdatePacket() { // Increase (and potentially wrap) the current local sequence number _localSequence++; + // Check if the packet exceeds (usual) MTU and break it up if so + if (packet.Length > PacketMtu) { + // Get the original packet's bytes as an array + var byteArray= packet.ToArray(); + + // Keep track of the index in the original array for copying + var index = 0; + // While we have not reached the end of the original array yet with the index + while (index < byteArray.Length) { + // Take the minimum of what's left to copy in the original array and the max MTU + var length = System.Math.Min(byteArray.Length - index, PacketMtu); + // Create a new array that is this calculated length + var newBytes = new byte[length]; + // Copy over the length of bytes starting from index into the new array + Array.Copy(byteArray, index, newBytes, 0, length); + + SendPacket(new Packet.Packet(newBytes)); + + index += length; + } + + return; + } + SendPacket(packet); } + /// + /// Callback method for when the send timer elapses. Will create and send a new update packet and update the + /// timer interval in case the send rate changes. + /// + private void OnSendTimerElapsed(object sender, ElapsedEventArgs elapsedEventArgs) { + CreateAndSendUpdatePacket(); + + if (_lastSendRate != CurrentSendRate) { + _sendTimer.Interval = CurrentSendRate; + _lastSendRate = CurrentSendRate; + } + } + + /// + /// Callback method for when the heart beat timer elapses. Will invoke the timeout event. + /// + private void OnHeartBeatTimerElapsed(object sender, ElapsedEventArgs elapsedEventArgs) { + // The timer has surpassed the connection timeout value, so we call the timeout event + TimeoutEvent?.Invoke(); + } + /// /// Check whether the first given sequence number is greater than the second given sequence number. /// Accounts for sequence number wrap-around, by inverse comparison if differences are larger than half @@ -256,7 +313,11 @@ private bool IsSequenceGreaterThan(ushort sequence1, ushort sequence2) { /// Send the given packet over the corresponding medium. /// /// The raw packet instance. - protected abstract void SendPacket(Packet.Packet packet); + private void SendPacket(Packet.Packet packet) { + var buffer = packet.ToArray(); + + DtlsTransport?.Send(buffer, 0, buffer.Length); + } /// /// Either get or create an AddonPacketData instance for the given addon. diff --git a/HKMP/Resource/action-registry.json b/HKMP/Resource/action-registry.json new file mode 100644 index 00000000..5fa10dbd --- /dev/null +++ b/HKMP/Resource/action-registry.json @@ -0,0 +1,259 @@ +[ + { + "type": "GetPosition", + "update_field": "everyFrame" + }, + { + "type": "GetVelocity2d", + "update_field": "everyFrame" + }, + { + "type": "SetVelocity2d", + "update_field": "everyFrame" + }, + { + "type": "FloatMultiply", + "update_field": "everyFrame" + }, + { + "type": "FloatAdd", + "update_field": "everyFrame" + }, + { + "type": "FloatMultiplyV2", + "update_field": "everyFrame" + }, + { + "type": "SetFloatValue", + "update_field": "everyFrame" + }, + { + "type": "FloatSubtract", + "update_field": "everyFrame" + }, + { + "type": "FloatDivide", + "update_field": "everyFrame" + }, + { + "type": "FloatCompare", + "update_field": "everyFrame" + }, + { + "type": "FloatClamp", + "update_field": "everyFrame" + }, + { + "type": "SetIntValue", + "update_field": "everyFrame" + }, + { + "type": "IntCompare", + "update_field": "everyFrame" + }, + { + "type": "IntAdd", + "update_field": "everyFrame" + }, + { + "type": "IntAddV2", + "update_field": "everyFrame" + }, + { + "type": "IntOperator", + "update_field": "everyFrame" + }, + { + "type": "IntSwitch", + "update_field": "everyFrame" + }, + { + "type": "SetFsmInt", + "update_field": "everyFrame" + }, + { + "type": "GetFsmInt", + "update_field": "everyFrame" + }, + { + "type": "BoolTest", + "update_field": "everyFrame" + }, + { + "type": "SetBoolValue", + "update_field": "everyFrame" + }, + { + "type": "SetFsmBool", + "update_field": "everyFrame" + }, + { + "type": "CheckCollisionSideEnter" + }, + { + "type": "CheckCollisionSide" + }, + { + "type": "SendEventByName", + "update_field": "everyFrame" + }, + { + "type": "RayCast2d", + "update_field": "repeatInterval" + }, + { + "type": "Tk2dWatchAnimationEvents" + }, + { + "type": "Tk2dPlayAnimationWithEvents" + }, + { + "type": "GetDistance", + "update_field": "everyFrame" + }, + { + "type": "CheckTargetDirection", + "update_field": "everyFrame" + }, + { + "type": "BoolTestMulti", + "update_field": "everyFrame" + }, + { + "type": "Wait" + }, + { + "type": "WaitRandom" + }, + { + "type": "ActivateGameObject", + "update_field": "everyFrame" + }, + { + "type": "SetScale", + "update_field": "everyFrame" + }, + { + "type": "GetScale", + "update_field": "everyFrame" + }, + { + "type": "SetParticleEmissionRate", + "update_field": "everyFrame" + }, + { + "type": "SetParticleEmissionSpeed", + "update_field": "everyFrame" + }, + { + "type": "NextFrameEvent" + }, + { + "type": "SetVector2XY", + "update_field": "everyFrame" + }, + { + "type": "SetVector3XYZ", + "update_field": "everyFrame" + }, + { + "type": "SetVector3Value", + "update_field": "everyFrame" + }, + { + "type": "FloatTestToBool", + "update_field": "everyFrame" + }, + { + "type": "FloatInRange", + "update_field": "everyFrame" + }, + { + "type": "AddForce2d", + "update_field": "everyFrame" + }, + { + "type": "AddForce2dV2", + "update_field": "everyFrame" + }, + { + "type": "CheckAlertRange", + "update_field": "everyFrame" + }, + { + "type": "CheckCanSeeHero", + "update_field": "everyFrame" + }, + { + "type": "BoolFlipEveryFrame", + "update_field": "everyFrame" + }, + { + "type": "GetAngleToTarget2D", + "update_field": "everyFrame" + }, + { + "type": "DistanceBetweenPoints2D", + "update_field": "everyFrame" + }, + { + "type": "SetGameObject", + "update_field": "everyFrame" + }, + { + "type": "FaceDirection", + "update_field": "everyFrame" + }, + { + "type": "FaceObject", + "update_field": "everyFrame" + }, + { + "type": "FaceAngle", + "update_field": "everyFrame" + }, + { + "type": "Translate", + "update_field": "everyFrame" + }, + { + "type": "ChaseObjectV2" + }, + { + "type": "DistanceFly" + }, + { + "type": "DistanceFlyV2" + }, + { + "type": "DistanceFlySmooth" + }, + { + "type": "Collision2dEventLayer" + }, + { + "type": "Trigger2dEvent" + }, + { + "type": "IdleBuzz" + }, + { + "type": "IdleBuzzV2" + }, + { + "type": "IdleBuzzV3" + }, + { + "type": "ReceivedDamage" + }, + { + "type": "GetSpeed2d", + "update_field": "everyFrame" + }, + { + "type": "ChaseObjectGround" + }, + { + "type": "FlingObjectsFromGlobalPoolTime" + } +] diff --git a/HKMP/Resource/entity-registry.json b/HKMP/Resource/entity-registry.json new file mode 100644 index 00000000..e5b6e4f1 --- /dev/null +++ b/HKMP/Resource/entity-registry.json @@ -0,0 +1,1508 @@ +[ + { + "base_object_name": "Battle Gate", + "type": "BattleGate", + "fsm_name": "BG Control" + }, + { + "base_object_name": "Dream Gate", + "type": "BattleGate", + "fsm_name": "Control" + }, + { + "base_object_name": "CameraLockArea", + "type": "CameraLockArea" + }, + { + "base_object_name": "Ruins Lift", + "type": "CityElevator", + "fsm_name": "Lift Control" + }, + { + "base_object_name": "Mines Platform", + "type": "CrystalPeakPlatform", + "components": [ + "FlipPlatform" + ] + }, + { + "base_object_name": "F Plat", + "type": "DreamPlatform", + "components": [ + "DreamPlatform" + ] + }, + { + "base_object_name": "dream_plat_", + "type": "DreamPlatform", + "components": [ + "DreamPlatform" + ] + }, + { + "base_object_name": "Crawler", + "type": "Crawlid", + "fsm_name": "Crawler" + }, + { + "base_object_name": "Climber", + "type": "Tiktik" + }, + { + "base_object_name": "Buzzer", + "type": "Vengefly", + "fsm_name": "chaser" + }, + { + "base_object_name": "Zombie Runner", + "type": "WanderingHusk", + "fsm_name": "Zombie Swipe" + }, + { + "base_object_name": "Zombie Barger", + "type": "HuskBully", + "fsm_name": "Zombie Swipe" + }, + { + "base_object_name": "Zombie Hornhead", + "type": "HuskHornhead", + "fsm_name": "Zombie Swipe" + }, + { + "base_object_name": "Fly", + "type": "Gruzzer", + "fsm_name": "Bouncer Control" + }, + { + "base_object_name": "Spitter", + "type": "AspidHunter", + "fsm_name": "spitter" + }, + { + "base_object_name": "Zombie Guard", + "type": "HuskGuard", + "fsm_name": "Zombie Guard" + }, + { + "base_object_name": "Zombie Leaper", + "type": "LeapingHusk", + "fsm_name": "Zombie Leap" + }, + { + "base_object_name": "Hatcher", + "type": "AspidMother", + "fsm_name": "Hatcher" + }, + { + "base_object_name": "Hatcher Baby Spawner", + "type": "AspidHatchling", + "fsm_name": "Control" + }, + { + "base_object_name": "Zombie Shield", + "type": "HuskWarrior", + "fsm_name": "ZombieShieldControl" + }, + { + "base_object_name": "Worm", + "type": "Goam", + "fsm_name": "Worm Control" + }, + { + "base_object_name": "Roller", + "type": "Baldur", + "fsm_name": "Roller" + }, + { + "base_object_name": "Blocker", + "type": "ElderBaldur", + "fsm_name": "Blocker Control" + }, + { + "base_object_name": "False Knight New", + "type": "FalseKnight", + "fsm_name": "FalseyControl", + "components": [ + "Velocity", "Music" + ], + "children": [ + { + "base_object_name": "Head", + "type": "FalseKnightHead", + "fsm_name": "Health Check" + } + ] + }, + { + "base_object_name": "FK Barrel Summon", + "type": "FalseKnightBarrels", + "fsm_name": "summon" + }, + { + "base_object_name": "Normal 1", + "type": "FalseKnightFloor", + "parent_name": "FK Floor" + }, + { + "base_object_name": "Normal 2", + "type": "FalseKnightFloor", + "parent_name": "FK Floor" + }, + { + "base_object_name": "Cracked 1", + "type": "FalseKnightFloor", + "parent_name": "FK Floor" + }, + { + "base_object_name": "Cracked 2", + "type": "FalseKnightFloor", + "parent_name": "FK Floor" + }, + { + "base_object_name": "Broken", + "type": "FalseKnightFloor", + "parent_name": "FK Floor" + }, + { + "base_object_name": "Break Floor", + "type": "FalseKnightFloor", + "parent_name": "FK Floor" + }, + { + "base_object_name": "Giant Fly", + "type": "GruzMother", + "fsm_name": "Big Fly Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Mawlek Body", + "type": "BroodingMawlek", + "fsm_name": "Mawlek Control", + "components": [ + "ZPosition", "GravityScale", "Music" + ], + "children": [ + { + "base_object_name": "Mawlek Arm L", + "type": "BroodingMawlekArm", + "fsm_name": "Mawlek Arm Control" + }, + { + "base_object_name": "Mawlek Arm R", + "type": "BroodingMawlekArm", + "fsm_name": "Mawlek Arm Control" + }, + { + "base_object_name": "Mawlek Head", + "type": "BroodingMawlekHead", + "fsm_name": "Mawlek Head" + }, + { + "base_object_name": "Dummy", + "type": "BroodingMawlekDummy" + } + ] + }, + { + "base_object_name": "Moss Walker", + "type": "Mosscreep", + "fsm_name": "Moss Walker", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Moss Flyer", + "type": "Mossfly", + "fsm_name": "Moss Flyer" + }, + { + "base_object_name": "Mossman_Runner", + "type": "Mosskin", + "fsm_name": "Zombie Swipe" + }, + { + "base_object_name": "Mossman_Shaker", + "type": "VolatileMosskin", + "fsm_name": "Fungus Zombie Attack" + }, + { + "base_object_name": "Plant Trap", + "type": "FoolEater", + "fsm_name": "Plant Trap Control" + }, + { + "base_object_name": "Mosquito", + "type": "Squit", + "fsm_name": "Mozzie", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Fat Fly", + "type": "Obble", + "fsm_name": "Fatty Fly Attack" + }, + { + "base_object_name": "Plant Turret", + "type": "Gulka", + "fsm_name": "Plant Turret", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Acid Walker", + "type": "Durandoo", + "fsm_name": "Acid Walker", + "components": [ + "Velocity" + ] + }, + { + "base_object_name": "Acid Flyer", + "type": "Duranda", + "fsm_name": "Acid Flyer" + }, + { + "base_object_name": "Mega Moss Charger", + "type": "MassiveMossCharger", + "fsm_name": "Mossy Control", + "components": [ + "GravityScale", "Music" + ] + }, + { + "base_object_name": "Moss Charger", + "type": "MossCharger", + "fsm_name": "Mossy Control" + }, + { + "base_object_name": "Moss Knight", + "type": "MossKnight", + "fsm_name": "Moss Knight Control" + }, + { + "base_object_name": "Lazy Flyer", + "type": "Aluba", + "fsm_name": "Lazy Flyer", + "components": [ + "Velocity" + ] + }, + { + "base_object_name": "Giant Buzzer", + "type": "VengeflyKing", + "fsm_name": "Big Buzzer", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Buzzer Summon", + "type": "VengeflySummon", + "components": [ + "EnemySpawner" + ] + }, + { + "base_object_name": "Zap Cloud", + "type": "ChargedLumafly", + "fsm_name": "zap control" + }, + { + "base_object_name": "Hornet Boss", + "type": "Hornet", + "fsm_name": "Control", + "components": [ + "GravityScale", "Rotation", "Music" + ] + }, + { + "base_object_name": "Jellyfish Baby", + "type": "Uoma", + "fsm_name": "Jellyfish Baby" + }, + { + "base_object_name": "Jellyfish", + "type": "Ooma", + "fsm_name": "Jellyfish" + }, + { + "base_object_name": "Corpse Jellyfish", + "type": "OomaCorpse", + "fsm_name": "corpse" + }, + { + "base_object_name": "Lil Jellyfish", + "type": "OomaCore", + "fsm_name": "Lil Jelly", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Mega Jellyfish", + "type": "Uumuu", + "fsm_name": "Mega Jellyfish", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Quirrel Land", + "type": "UumuuQuirrel", + "fsm_name": "Watch" + }, + { + "base_object_name": "Fung Crawler", + "type": "Ambloom" + }, + { + "base_object_name": "Fungoon Baby", + "type": "Fungling", + "fsm_name": "Fungoon baby" + }, + { + "base_object_name": "Fungus Flyer", + "type": "Fungoon", + "fsm_name": "Fungus Flyer" + }, + { + "base_object_name": "Mushroom Turret", + "type": "Sporg", + "fsm_name": "Shroom Turret" + }, + { + "base_object_name": "Spore Bomb", + "type": "SporgSpore", + "fsm_name": "Spore Bomb" + }, + { + "base_object_name": "Zombie Fungus", + "type": "FungifiedHusk", + "fsm_name": "Fungus Zombie Attack" + }, + { + "base_object_name": "Mushroom Baby", + "type": "Shrumeling", + "fsm_name": "Mushroom Mini" + }, + { + "base_object_name": "Mushroom Roller", + "type": "ShrumalWarrior", + "fsm_name": "Mush Roller" + }, + { + "base_object_name": "Mushroom Brawler", + "type": "ShrumalOgre", + "fsm_name": "Shroom Brawler" + }, + { + "base_object_name": "Mantis Flyer Child", + "type": "MantisYouth", + "fsm_name": "Mantis Flyer", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Mantis", + "type": "MantisWarrior", + "fsm_name": "Mantis" + }, + { + "base_object_name": "Mantis Lord Throne", + "type": "MantisThrone", + "fsm_name": "Mantis Throne Sub" + }, + { + "base_object_name": "Mantis Lord Throne", + "type": "MantisThrone", + "fsm_name": "Mantis Throne Main" + }, + { + "base_object_name": "Battle Main", + "type": "MantisLordBattle", + "fsm_name": "Start", + "children": [ + { + "base_object_name": "Mantis Lord", + "type": "MantisLord", + "fsm_name": "Mantis Lord" + } + ], + "components": [ + "ChallengePrompt", "Music" + ] + }, + { + "base_object_name": "Battle Sub", + "type": "MantisLordBattle", + "fsm_name": "Start", + "children": [ + { + "base_object_name": "Mantis Lord S", + "type": "MantisLord", + "fsm_name": "Mantis Lord" + } + ] + }, + { + "base_object_name": "mantis_cage_down", + "type": "MantisLordCage", + "fsm_name": "Cage Control", + "components": [ + "ChildrenActivation" + ] + }, + { + "base_object_name": "mantis_lord_opening_floors", + "type": "MantisLordFloor", + "fsm_name": "Floor Control", + "children": [ + { + "base_object_name": "Floor", + "type": "MantisLordFloor" + } + ] + }, + { + "base_object_name": "Ruins Sentry", + "type": "HuskSentry", + "fsm_name": "Ruins Sentry" + }, + { + "base_object_name": "Ruins Sentry Fat", + "type": "HeavySentry", + "fsm_name": "Ruins Sentry Fat" + }, + { + "base_object_name": "Ruins Flying Sentry", + "type": "WingedSentry", + "fsm_name": "Flying Sentry Nail" + }, + { + "base_object_name": "Ruins Flying Sentry Javelin", + "type": "LanceSentry", + "fsm_name": "Flying Sentry Javelin" + }, + { + "base_object_name": "Mage Blob", + "type": "Mistake", + "fsm_name": "Blob" + }, + { + "base_object_name": "Mage Balloon", + "type": "Folly", + "fsm_name": "Control" + }, + { + "base_object_name": "Mage Balloon Spawner", + "type": "Folly", + "fsm_name": "Control" + }, + { + "base_object_name": "Mage", + "type": "SoulTwister", + "fsm_name": "Mage" + }, + { + "base_object_name": "Mage Orb", + "type": "SoulOrb", + "fsm_name": "Orb Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Mage Knight", + "type": "SoulWarrior", + "fsm_name": "Mage Knight" + }, + { + "base_object_name": "Mage Lord", + "type": "SoulMaster", + "fsm_name": "Mage Lord", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Orb Spinner", + "type": "SoulMasterOrbSpinner", + "fsm_name": "Summon Orbs" + }, + { + "base_object_name": "Mage Lord Phase2", + "type": "SoulMasterPhase2", + "fsm_name": "Mage Lord 2" + }, + { + "base_object_name": "Knight Get Fake Quake", + "type": "SoulMasterFakeQuake", + "fsm_name": "Get Quake" + }, + { + "base_object_name": "mage_window", + "type": "SoulMasterWindow", + "fsm_name": "Break", + "children": [ + { + "base_object_name": "break_ground", + "type": "SoulMasterWindow" + } + ] + }, + { + "base_object_name": "Royal Zombie", + "type": "HuskDandy", + "fsm_name": "Attack" + }, + { + "base_object_name": "Royal Zombie Coward", + "type": "CowardlyHusk", + "fsm_name": "Coward Swipe" + }, + { + "base_object_name": "Royal Zombie Fat", + "type": "GluttonousHusk", + "fsm_name": "Attack" + }, + { + "base_object_name": "Gorgeous Husk", + "type": "GorgeousHusk", + "fsm_name": "Attack" + }, + { + "base_object_name": "Great Shield Zombie", + "type": "GreatHuskSentry", + "fsm_name": "ZombieShieldControl" + }, + { + "base_object_name": "Black Knight", + "type": "WatcherKnight", + "fsm_name": "Black Knight", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Jar Collector", + "type": "TheCollector", + "fsm_name": "Control" + }, + { + "base_object_name": "Spawn Jar", + "type": "CollectorJar", + "components": [ + "SpawnJar" + ] + }, + { + "base_object_name": "Ceiling Dropper", + "type": "Belfly", + "fsm_name": "Ceiling Dropper", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Flip Hopper", + "type": "Pilflip", + "fsm_name": "Attack" + }, + { + "base_object_name": "Inflater", + "type": "Hwurmp", + "fsm_name": "Inflater", + "children": [ + { + "base_object_name": "Enemy Inflater", + "type": "HwurmpChild", + "fsm_name": "Send Inflate Event" + }, + { + "base_object_name": "Bouncer", + "type": "HwurmpChild" + } + ] + }, + { + "base_object_name": "Egg Sac", + "type": "Bluggsac" + }, + { + "base_object_name": "Fluke Fly", + "type": "Flukefey", + "fsm_name": "Fluke Fly", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Flukeman", + "type": "Flukemon", + "fsm_name": "Flukeman" + }, + { + "base_object_name": "Flukeman Bot", + "type": "FlukemonBot", + "fsm_name": "Flukeman Bot" + }, + { + "base_object_name": "Flukeman Top", + "type": "FlukemonTop", + "fsm_name": "Flukeman Top" + }, + { + "base_object_name": "Dung Defender", + "type": "DungDefender", + "fsm_name": "Dung Defender", + "components": [ + "GravityScale", "Music" + ] + }, + { + "base_object_name": "Dung Ball Large", + "type": "LargeDungBall", + "fsm_name": "Ball Control" + }, + { + "base_object_name": "Dung Ball Small", + "type": "SmallDungBall", + "fsm_name": "Ball Control" + }, + { + "base_object_name": "Burrow Effect", + "type": "DungDefenderBurrow", + "fsm_name": "Burrow Effect" + }, + { + "base_object_name": "Corpse Dung Defender", + "type": "DungDefenderCorpse", + "fsm_name": "Corpse" + }, + { + "base_object_name": "Fluke Mother", + "type": "Flukemarm", + "fsm_name": "Fluke Mother", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Zombie Miner", + "type": "HuskMiner", + "fsm_name": "Zombie Miner" + }, + { + "base_object_name": "Zombie Beam Miner Rematch", + "type": "EnragedGuardian", + "fsm_name": "Beam Miner", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Zombie Beam Miner", + "type": "CrystallisedHusk", + "fsm_name": "Beam Miner" + }, + { + "base_object_name": "Mines Crawler", + "type": "Shardmite", + "fsm_name": "Mines Crawler", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Crystal Crawler", + "type": "Glimback", + "fsm_name": "Crawler" + }, + { + "base_object_name": "Crystal Flyer", + "type": "CrystalHunter", + "fsm_name": "Crystal Flyer" + }, + { + "base_object_name": "Crystallised Lazer Bug", + "type": "CrystalCrawler", + "fsm_name": "Laser Bug" + }, + { + "base_object_name": "Laser Turret", + "type": "CrystalTurret", + "fsm_name": "Laser Bug" + }, + { + "base_object_name": "Mega Zombie Beam Miner", + "type": "CrystalGuardian", + "fsm_name": "Beam Miner", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Laser Turret Mega", + "type": "CrystalGuardianLaser", + "fsm_name": "Laser Bug Mega", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Angry Buzzer", + "type": "FuriousVengefly", + "fsm_name": "Control" + }, + { + "base_object_name": "Bursting Bouncer", + "type": "VolatileGruzzer", + "fsm_name": "Bouncer Control" + }, + { + "base_object_name": "Bursting Zombie", + "type": "ViolentHusk", + "fsm_name": "Attack" + }, + { + "base_object_name": "Spitting Zombie", + "type": "SlobberingHusk", + "fsm_name": "Spit" + }, + { + "base_object_name": "Baby Centipede", + "type": "Dirtcarver", + "fsm_name": "Centipede", + "components": [ + "ZPosition" + ] + }, + { + "base_object_name": "Centipede Hatcher", + "type": "CarverHatcher", + "fsm_name": "Centipede Hatcher", + "components": [ + "ZPosition" + ] + }, + { + "base_object_name": "Big Centipede", + "type": "Garpede", + "components": [ + "ZPosition" + ] + }, + { + "base_object_name": "Zombie Spider", + "type": "CorpseCreeper", + "fsm_name": "Chase" + }, + { + "base_object_name": "Tiny Spider", + "type": "Deepling", + "fsm_name": "Spawn", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Spider Mini", + "type": "Deephunter", + "fsm_name": "Spider", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Spider Flyer", + "type": "LittleWeaver", + "fsm_name": "Control" + }, + { + "base_object_name": "Slash Spider", + "type": "StalkingDevout", + "fsm_name": "Slash Spider" + }, + { + "base_object_name": "Mimic Spider", + "type": "Nosk", + "fsm_name": "Mimic Spider", + "components": [ + "GravityScale", "Music" + ] + }, + { + "base_object_name": "Vomit Glob Nosk", + "type": "NoskBlob", + "fsm_name": "Vomit Glob" + }, + { + "base_object_name": "Abyss Crawler", + "type": "ShadowCreeper", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Lesser Mawlek", + "type": "LesserMawlek", + "fsm_name": "Lesser Mawlek" + }, + { + "base_object_name": "Mawlek Turret", + "type": "Mawlurk", + "fsm_name": "Mawlek Turret" + }, + { + "base_object_name": "Parasite Balloon", + "type": "InfectedBalloon", + "fsm_name": "Control" + }, + { + "base_object_name": "Lost Kin", + "type": "LostKin", + "fsm_name": "IK Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Corpse Infected Knight Dream", + "type": "LostKinCorpse", + "fsm_name": "corpse" + }, + { + "base_object_name": "Infected Knight", + "type": "BrokenVessel", + "fsm_name": "IK Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Corpse Infected Knight", + "type": "BrokenVesselCorpse", + "fsm_name": "corpse" + }, + { + "base_object_name": "Blow Fly", + "type": "Boofly", + "fsm_name": "Blow Fly" + }, + { + "base_object_name": "Super Spitter", + "type": "PrimalAspid", + "fsm_name": "spitter" + }, + { + "base_object_name": "Giant Hopper", + "type": "GreatHopper", + "fsm_name": "Hopper" + }, + { + "base_object_name": "Hopper", + "type": "Hopper", + "fsm_name": "Hopper" + }, + { + "base_object_name": "Grub Mimic", + "type": "GrubMimic", + "fsm_name": "Grub Mimic" + }, + { + "base_object_name": "Bee Hatchling", + "type": "Hiveling", + "fsm_name": "Bee" + }, + { + "base_object_name": "Hiveling Spawner", + "type": "Hiveling", + "fsm_name": "Control" + }, + { + "base_object_name": "Bee Stinger", + "type": "HiveSoldier", + "fsm_name": "Bee Stinger", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Big Bee", + "type": "HiveGuardian", + "fsm_name": "Big Bee" + }, + { + "base_object_name": "Zombie Hive", + "type": "HuskHive", + "fsm_name": "Hive Zombie" + }, + { + "base_object_name": "Garden Zombie", + "type": "SpinyHusk", + "fsm_name": "Attack" + }, + { + "base_object_name": "Grass Hopper", + "type": "Loodle", + "fsm_name": "Crazy Hopper", + "children": [ + { + "base_object_name": "Sprite", + "type": "Loodle", + "fsm_name": "Damage Flash", + "components": [ + "Rotation" + ] + } + ] + }, + { + "base_object_name": "Mantis Heavy Flyer", + "type": "MantisPetra", + "fsm_name": "Heavy Flyer", + "components": [ + "ZPosition" + ] + }, + { + "base_object_name": "Shot Mantis", + "type": "MantisPetraScythe", + "fsm_name": "Shot Mantis" + }, + { + "base_object_name": "Dragonfly Summon", + "type": "MantisPetraSummon", + "fsm_name": "summon" + }, + { + "base_object_name": "Mantis Heavy", + "type": "MantisTraitor", + "fsm_name": "Mantis" + }, + { + "base_object_name": "Mantis Traitor Lord", + "type": "TraitorLord", + "fsm_name": "Mantis", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Colosseum Manager", + "type": "ColosseumManager", + "fsm_name": "Battle Control" + }, + { + "base_object_name": "Colosseum Cage Small", + "type": "ColosseumCageSmall", + "fsm_name": "Spawn", + "components": [ + "SpriteRenderer" + ] + }, + { + "base_object_name": "Colosseum Cage Large", + "type": "ColosseumCageLarge", + "fsm_name": "Spawn", + "components": [ + "SpriteRenderer" + ] + }, + { + "base_object_name": "Colosseum Platform", + "type": "ColosseumPlatform", + "fsm_name": "Control", + "components": [ + "SpriteRenderer" + ], + "children": [ + { + "base_object_name": "Platform", + "type": "ColosseumPlatform" + } + ] + }, + { + "base_object_name": "Colosseum Spike", + "type": "ColosseumSpike", + "fsm_name": "Control" + }, + { + "base_object_name": "Colosseum Wall", + "type": "ColosseumWall", + "fsm_name": "Control", + "children": [ + { + "base_object_name": "Wall", + "type": "ColosseumWall" + } + ] + }, + { + "base_object_name": "Colosseum_Armoured_Roller", + "type": "SharpBaldur", + "fsm_name": "Roller" + }, + { + "base_object_name": "Colosseum_Armoured_Mosquito", + "type": "ArmouredSquit", + "fsm_name": "Mozzie2", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Colosseum_Shield_Zombie", + "type": "ShieldedFool", + "fsm_name": "ZombieShieldControl" + }, + { + "base_object_name": "Colosseum_Miner", + "type": "SturdyFool", + "fsm_name": "Zombie Miner" + }, + { + "base_object_name": "Colosseum_Worm", + "type": "HeavyFool", + "fsm_name": "Ruins Sentry Fat" + }, + { + "base_object_name": "Colosseum_Flying_Sentry", + "type": "WingedFool", + "fsm_name": "Flying Sentry Nail" + }, + { + "base_object_name": "Blobble", + "type": "BattleObble", + "fsm_name": "Fatty Fly Attack" + }, + { + "base_object_name": "Mega Fat Bee", + "type": "Oblobble", + "fsm_name": "Fatty Fly Attack", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Electric Mage", + "type": "VoltTwister", + "fsm_name": "Electric Mage" + }, + { + "base_object_name": "Colosseum Cage Zote", + "type": "ColosseumCageZote", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Boss", + "type": "Zote", + "fsm_name": "Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Lancer", + "type": "Tamer", + "fsm_name": "Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Lobster", + "type": "Beast", + "fsm_name": "Control" + }, + { + "base_object_name": "Ghost Warrior Xero", + "type": "Xero", + "fsm_name": "Attacking", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Sword", + "type": "XeroNail", + "fsm_name": "xero_nail", + "components": [ + "Rotation", "ZPosition" + ] + }, + { + "base_object_name": "Ghost Warrior Slug", + "type": "Gorb", + "fsm_name": "Attacking", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Ghost Warrior Hu", + "type": "ElderHu", + "fsm_name": "Attacking", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Ghost Warrior Marmu", + "type": "Marmu", + "fsm_name": "Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Ghost Warrior No Eyes", + "type": "NoEyes", + "fsm_name": "Attacking", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Ghost Warrior Galien", + "type": "Galien", + "fsm_name": "Movement", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Galien Hammer", + "type": "GalienScythe", + "fsm_name": "Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Galien Mini Hammer", + "type": "GalienMiniScythe", + "fsm_name": "FSM" + }, + { + "base_object_name": "Ghost Warrior Markoth", + "type": "Markoth", + "fsm_name": "Attacking", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Markoth Shield", + "type": "MarkothShield", + "fsm_name": "Control" + }, + { + "base_object_name": "White Palace Fly", + "type": "Wingmould", + "fsm_name": "Control" + }, + { + "base_object_name": "Royal Gaurd", + "type": "Kingsmould", + "fsm_name": "Guard" + }, + { + "base_object_name": "Shot Kings Guard", + "type": "KingsmouldBlade", + "fsm_name": "Spin" + }, + { + "base_object_name": "Shade Sibling", + "type": "Sibling", + "fsm_name": "Control" + }, + { + "base_object_name": "Barb Region", + "type": "HornetSentinelSpikes", + "fsm_name": "Spawn Barbs" + }, + { + "base_object_name": "Hornet Barb", + "type": "HornetSentinelSpike", + "fsm_name": "Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Hollow Knight Boss", + "type": "HollowKnight", + "fsm_name": "Control", + "components": [ + "GravityScale", "Music" + ] + }, + { + "base_object_name": "Radiance", + "type": "Radiance", + "fsm_name": "Control", + "components": [ + "Music", + "HazardRespawn" + ] + }, + { + "base_object_name": "Radiant Orb", + "type": "RadianceOrb", + "fsm_name": "Orb Control" + }, + { + "base_object_name": "Radiant Nail Comb", + "type": "RadianceNailComb", + "fsm_name": "Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Radiant Nail", + "type": "RadianceNail", + "fsm_name": "Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Grey Prince", + "type": "GreyPrinceZote", + "fsm_name": "Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Zoteling", + "type": "Zoteling", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Balloon", + "type": "VolatileZoteling", + "fsm_name": "Control" + }, + { + "base_object_name": "White Defender", + "type": "WhiteDefender", + "fsm_name": "Dung Defender", + "components": [ + "GravityScale" + ] + }, + { + "base_object_name": "Flamebearer Spawn", + "type": "GrimmkinSpawner", + "fsm_name": "Spawn Control", + "components": [ + "SpriteRenderer" + ] + }, + { + "base_object_name": "Flamebearer Small", + "type": "GrimmkinNovice", + "fsm_name": "Control" + }, + { + "base_object_name": "Flamebearer Med", + "type": "GrimmkinMaster", + "fsm_name": "Control" + }, + { + "base_object_name": "Flamebearer Large", + "type": "GrimmkinNightmare", + "fsm_name": "Control" + }, + { + "base_object_name": "Grimm Boss", + "type": "Grimm", + "fsm_name": "Control", + "components": [ + "Rotation", "Music" + ] + }, + { + "base_object_name": "Grimm Firebat", + "type": "GrimmBat", + "fsm_name": "Control" + }, + { + "base_object_name": "Nightmare Grimm Boss", + "type": "NightmareKingGrimm", + "fsm_name": "Control", + "components": [ + "Rotation", "Music" + ] + }, + { + "base_object_name": "Nightmare Firebat", + "type": "NightmareKingGrimmBat", + "fsm_name": "Control" + }, + { + "base_object_name": "Grimm Spike Holder", + "type": "GrimmSpikes", + "fsm_name": "Spike Control" + }, + { + "base_object_name": "Flameball Grimmballoon", + "type": "GrimmFireball" + }, + { + "base_object_name": "Hive Knight", + "type": "HiveKnight", + "fsm_name": "Control", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Hive Knight Glob", + "type": "HiveKnightSpike", + "fsm_name": "Control" + }, + { + "base_object_name": "Bee Dropper", + "type": "HiveKnightBee", + "fsm_name": "Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Fat Fluke", + "type": "Flukemunga", + "fsm_name": "Control" + }, + { + "base_object_name": "Pale Lurker", + "type": "PaleLurker", + "fsm_name": "Lurker Control" + }, + { + "base_object_name": "Ghost Battle Revek", + "type": "Revek", + "fsm_name": "Control", + "components": [ + "Rotation" + ] + }, + { + "base_object_name": "Dream Mage Lord", + "type": "SoulTyrant", + "fsm_name": "Mage Lord", + "components": [ + "Music" + ] + }, + { + "base_object_name": "Dream Mage Lord Phase2", + "type": "SoulTyrantPhase2", + "fsm_name": "Mage Lord 2" + }, + { + "base_object_name": "Brothers", + "type": "OroMato", + "fsm_name": "Combo Control" + }, + { + "base_object_name": "Oro", + "type": "Oro", + "fsm_name": "nailmaster" + }, + { + "base_object_name": "Mato", + "type": "Mato", + "fsm_name": "nailmaster" + }, + { + "base_object_name": "Sheo Boss", + "type": "Sheo", + "fsm_name": "nailmaster_sheo" + }, + { + "base_object_name": "Sly Boss", + "type": "Sly", + "fsm_name": "Control", + "components": [ + "GravityScale" + ] + }, + { + "base_object_name": "HK Prime", + "type": "PureVessel", + "fsm_name": "Control" + }, + { + "base_object_name": "HK Prime Blast", + "type": "PureVesselBlast", + "fsm_name": "Control" + }, + { + "base_object_name": "Hornet Nosk", + "type": "WingedNosk", + "fsm_name": "Hornet Nosk" + }, + { + "base_object_name": "Glob Dropper", + "type": "WingedNoskGlobDropper", + "fsm_name": "Dropper" + }, + { + "base_object_name": "Zote Turret", + "type": "TurretZoteling", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Crew Tall", + "type": "LankyZoteling", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Thwomp", + "type": "HeadOfZote", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Fluke", + "type": "FlukeZoteling", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Salubra", + "type": "ZoteCurse", + "fsm_name": "Control" + }, + { + "base_object_name": "Zote Crew Fat", + "type": "HeavyZoteling", + "fsm_name": "Control" + }, + { + "base_object_name": "Absolute Radiance", + "type": "AbsoluteRadiance", + "fsm_name": "Control" + }, + { + "base_object_name": "Radiant Plat", + "type": "RadiancePlatform", + "fsm_name": "radiant_plat" + }, + { + "base_object_name": "Abyss Pit", + "type": "RadianceAbyss", + "fsm_name": "Ascend" + } +] diff --git a/HKMP/Resource/music-data.json b/HKMP/Resource/music-data.json new file mode 100644 index 00000000..6521ee3e --- /dev/null +++ b/HKMP/Resource/music-data.json @@ -0,0 +1,93 @@ +{ + "Item1": [ + { + "Type": "None", + "Name": "None" + }, + { + "Type": "FalseKnight", + "Name": "Boss1" + }, + { + "Type": "Hornet", + "Name": "BossHornet" + }, + { + "Type": "GGHornet", + "Name": "GGHornet" + }, + { + "Type": "MantisLords", + "Name": "BossMantisLords" + }, + { + "Type": "SoulMaster", + "Name": "BossMageLord" + }, + { + "Type": "SoulMaster2", + "Name": "MageLord2" + }, + { + "Type": "GGHeavy", + "Name": "GG Heavy" + }, + { + "Type": "EnemyBattle", + "Name": "EnemyBattle" + }, + { + "Type": "DreamFight", + "Name": "DreamFight" + }, + { + "Type": "Hive", + "Name": "Hive" + }, + { + "Type": "HiveKnight", + "Name": "HiveKnight" + }, + { + "Type": "DungDefender", + "Name": "DungDefender" + }, + { + "Type": "BrokenVessel", + "Name": "BossIK" + }, + { + "Type": "Nosk", + "Name": "MimicSpider" + },{ + "Type": "TheHollowKnight", + "Name": "HKBattle" + }, + { + "Type": "Greenpath", + "Name": "Greenpath" + }, + { + "Type": "Waterways", + "Name": "Waterways" + } + ], + "Item2": [ + { + "Type": "Silent", + "Name": "Silent" + }, + { + "Type": "None", + "Name": "None" + }, + { + "Type": "Off", + "Name": "Off" + }, + { + "Type": "Normal", + "Name": "Normal" + } + ] +} diff --git a/HKMP/Resource/save-data-godseeker.json b/HKMP/Resource/save-data-godseeker.json new file mode 100644 index 00000000..af0351ed --- /dev/null +++ b/HKMP/Resource/save-data-godseeker.json @@ -0,0 +1,122 @@ +{ + "respawnScene": "GG_Entrance_Cutscene", + "respawnMarkerName": "Death Respawn Marker", + "mapZone": 50, + "respawnType": 0, + "maxHealthBase": 9, + "MPReserveMax": 99, + "heartPieceMax": true, + "honedNail": true, + "nailSmithUpgrades": 4, + "hasDash": true, + "hasShadowDash": true, + "hasWalljump": true, + "hasDoubleJump": true, + "hasSuperDash": true, + "hasDreamNail": true, + "hasDreamGate": true, + "dreamOrbs": 1, + "hasNailArt": true, + "hasDashSlash": true, + "hasCyclone": true, + "hasUpwardSlash": true, + "hasSpell": true, + "shadeFireballLevel": 2, + "shadeQuakeLevel": 2, + "shadeScreamLevel": 2, + "fireballLevel": 2, + "quakeLevel": 2, + "screamLevel": 2, + "hasAcidArmour": true, + "hasLantern": true, + "hasCharm": true, + "charmSlots": 11, + "equippedCharm_36": true, + "equippedCharms": [ + 36 + ], + "fragileGreed_unbreakable": true, + "fragileHealth_unbreakable": true, + "fragileStrength_unbreakable": true, + "hasGodfinder": true, + "gotCharm_1": true, + "gotCharm_2": true, + "gotCharm_3": true, + "gotCharm_4": true, + "gotCharm_5": true, + "gotCharm_6": true, + "gotCharm_7": true, + "gotCharm_8": true, + "gotCharm_9": true, + "gotCharm_10": true, + "gotCharm_11": true, + "gotCharm_12": true, + "gotCharm_13": true, + "gotCharm_14": true, + "gotCharm_15": true, + "gotCharm_16": true, + "gotCharm_17": true, + "gotCharm_18": true, + "gotCharm_19": true, + "gotCharm_20": true, + "gotCharm_21": true, + "gotCharm_22": true, + "gotCharm_23": true, + "gotCharm_24": true, + "gotCharm_25": true, + "gotCharm_26": true, + "gotCharm_27": true, + "gotCharm_28": true, + "gotCharm_29": true, + "gotCharm_30": true, + "gotCharm_31": true, + "gotCharm_32": true, + "gotCharm_33": true, + "gotCharm_34": true, + "gotCharm_35": true, + "gotCharm_36": true, + "gotCharm_37": true, + "gotCharm_38": true, + "gotCharm_39": true, + "gotCharm_40": true, + "newCharm_1": true, + "newCharm_2": true, + "newCharm_3": true, + "newCharm_4": true, + "newCharm_5": true, + "newCharm_6": true, + "newCharm_7": true, + "newCharm_8": true, + "newCharm_9": true, + "newCharm_10": true, + "newCharm_11": true, + "newCharm_12": true, + "newCharm_13": true, + "newCharm_14": true, + "newCharm_15": true, + "newCharm_16": true, + "newCharm_17": true, + "newCharm_18": true, + "newCharm_19": true, + "newCharm_20": true, + "newCharm_21": true, + "newCharm_22": true, + "newCharm_23": true, + "newCharm_24": true, + "newCharm_25": true, + "newCharm_26": true, + "newCharm_27": true, + "newCharm_28": true, + "newCharm_29": true, + "newCharm_30": true, + "newCharm_31": true, + "newCharm_32": true, + "newCharm_33": true, + "newCharm_34": true, + "newCharm_35": true, + "newCharm_36": true, + "newCharm_37": true, + "newCharm_38": true, + "newCharm_39": true, + "newCharm_40": true +} diff --git a/HKMP/Resource/save-data.json b/HKMP/Resource/save-data.json new file mode 100644 index 00000000..2615d00e --- /dev/null +++ b/HKMP/Resource/save-data.json @@ -0,0 +1,34451 @@ +{ + "playerData": { + "permadeathMode": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "maxHealthBase": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "heartPieces": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "heartPieceCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "heartPieceMax": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "geo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "soulLimited": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "vesselFragments": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "vesselFragmentCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "MPReserveMax": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "vesselFragmentMax": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "respawnScene": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.String" + }, + "mapZone": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "Hkmp.Serialization.MapZone" + }, + "respawnMarkerName": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.String" + }, + "respawnType": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shadeScene": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.String" + }, + "shadeMapZone": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.String" + }, + "shadePositionX": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Single" + }, + "shadePositionY": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Single" + }, + "shadeHealth": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shadeMP": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shadeFireballLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shadeQuakeLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shadeScreamLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shadeSpecialType": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "dreamgateMapPos": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "Hkmp.Math.Vector3" + }, + "geoPool": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "nailDamage": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "hasSpell": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fireballLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "quakeLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "screamLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "hasNailArt": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasCyclone": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasDashSlash": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasUpwardSlash": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasAllNailArts": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasDreamNail": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasDreamGate": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamNailUpgraded": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamOrbs": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "dreamOrbsSpent": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "dreamGateScene": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.String" + }, + "dreamGateX": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Single" + }, + "dreamGateY": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Single" + }, + "hasDash": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasWalljump": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasSuperDash": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasShadowDash": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasAcidArmour": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasDoubleJump": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasLantern": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasTramPass": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasQuill": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasCityKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasSlykey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gaveSlykey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasWhiteKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "usedWhiteKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasMenderKey": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hasWaterwaysKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasSpaKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasLoveKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasKingsBrand": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasXunFlower": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "ore": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "foundGhostCoin": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "trinket1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "foundTrinket1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "trinket2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "foundTrinket2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "trinket3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "foundTrinket3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "trinket4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "foundTrinket4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "noTrinket1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "noTrinket2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "noTrinket3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "noTrinket4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "soldTrinket1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "soldTrinket2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "soldTrinket3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "soldTrinket4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "simpleKeys": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "rancidEggs": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "notchShroomOgres": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "notchFogCanyon": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotLurkerKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "guardiansDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "lurienDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hegemolDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "monomonDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "maskBrokenLurien": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "maskBrokenHegemol": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "maskBrokenMonomon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "maskToBreak": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "elderbug": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "metElderbug": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugReintro": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugHistory": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "elderbugHistory1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugHistory2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugHistory3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechSly": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechStation": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechEggTemple": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechMapShop": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechBretta": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechJiji": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechMinesLift": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechKingsPass": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechInfectedCrossroads": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugSpeechFinalBossDoor": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugRequestedFlower": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugGaveFlower": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugFirstCall": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metQuirrel": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "quirrelEggTemple": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "quirrelSlugShrine": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "quirrelRuins": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "quirrelMines": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "quirrelLeftStation": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelLeftEggTemple": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelCityEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelCityLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelMinesEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelMinesLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelMantisEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "enteredMantisLordArea": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "visitedDeepnestSpa": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelSpaReady": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelSpaEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelArchiveEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "quirrelEpilogueCompleted": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metRelicDealer": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metRelicDealerShop": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "marmOutside": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "marmOutsideConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "marmConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "marmConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "marmConvo3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "marmConvoNailsmith": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "cornifer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "metCornifer": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corniferIntroduced": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corniferAtHome": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_crossroadsEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_crossroadsLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_greenpathEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_greenpathLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_fogCanyonEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_fogCanyonLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_fungalWastesEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_fungalWastesLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_cityEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_cityLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_waterwaysEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_waterwaysLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_minesEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_minesLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_cliffsEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_cliffsLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_deepnestEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_deepnestLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_deepnestMet1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_deepnestMet2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_outskirtsEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_outskirtsLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_royalGardensEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_royalGardensLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "corn_abyssEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "corn_abyssLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metIselda": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "iseldaCorniferHomeConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "iseldaConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "brettaRescued": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "brettaPosition": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "brettaState": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "brettaSeenBench": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "brettaSeenBed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "brettaSeenBenchDiary": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "brettaSeenBedDiary": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "brettaLeftTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "slyRescued": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metSlyShop": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotSlyCharm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyShellFrag1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyShellFrag2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyShellFrag3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyShellFrag4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyVesselFrag1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyVesselFrag2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slySimpleKey": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyRancidEgg": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyConvoNailArt": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyConvoMapper": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyConvoNailHoned": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jijiDoorUnlocked": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "jijiMet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jijiShadeOffered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jijiShadeCharmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metJinn": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jinnConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jinnConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jinnConvo3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jinnConvoKingBrand": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jinnConvoShadeCharm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jinnEggsSold": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "zote": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "zoteRescuedBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteDead": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteDeathPos": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "zoteSpokenCity": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteLeftCity": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteTrappedDeepnest": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteRescuedDeepnest": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteSpokenColosseum": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zotePrecept": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "zoteTownConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shaman": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "shamanScreamConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "shamanQuakeConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "shamanFireball2Convo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "shamanScream2Convo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "shamanQuake2Convo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metMiner": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "miner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "minerEarly": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "hornetGreenpath": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "hornetFung": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "hornet_f19": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornetFountainEncounter": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hornetCityBridge_ready": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornetCityBridge_completed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornetAbyssEncounter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornetDenEncounter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metMoth": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "ignoredMoth": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gladeDoorOpened": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mothDeparted": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "completedRGDreamPlant": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward5": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward5b": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward6": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward7": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward8": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamReward9": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dreamMothConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bankerAccountPurchased": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metBanker": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bankerBalance": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "bankerDeclined": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bankerTheftCheck": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bankerTheft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "bankerSpaMet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metGiraffe": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metCharmSlug": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraNotch1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraNotch2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraNotch3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraNotch4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraBlessing": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraConvoCombo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraConvoOvercharm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "salubraConvoTruth": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "cultistTransformed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metNailsmith": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailSmithUpgrades": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "honedNail": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailsmithCliff": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailsmithKilled": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailsmithSpared": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailsmithKillSpeech": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailsmithSheo": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "nailsmithConvoArt": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metNailmasterMato": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metNailmasterSheo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metNailmasterOro": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "matoConvoSheo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "matoConvoOro": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "matoConvoSly": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "sheoConvoMato": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "sheoConvoOro": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "sheoConvoSly": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "sheoConvoNailsmith": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "oroConvoSheo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "oroConvoMato": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "oroConvoSly": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hunterRoared": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metHunter": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hunterRewardOffered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "huntersMarkOffered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasHuntersMark": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metLegEater": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "paidLegEater": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "refusedLegEater": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterConvo3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterBrokenConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterDungConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterInfectedCrossroadConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterBoughtConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterGoldConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "legEaterLeft": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "tukMet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "tukEggPrice": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "tukDungEgg": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metEmilitia": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "emilitiaKingsBrandConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metCloth": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "clothEnteredTramRoom": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "savedCloth": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "clothEncounteredQueensGarden": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "clothKilled": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "clothInTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "clothLeftTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "clothGhostSpoken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bigCatHitTail": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatHitTailConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatMeet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatTalk1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatTalk2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatTalk3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatKingsBrandConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bigCatShadeConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "tisoEncounteredTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tisoEncounteredBench": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tisoEncounteredLake": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tisoEncounteredColosseum": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tisoDead": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tisoShieldConvo": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mossCultist": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "maskmakerMet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "maskmakerConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "maskmakerConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "maskmakerUnmasked1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "maskmakerUnmasked2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "maskmakerShadowDash": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "maskmakerKingsBrand": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dungDefenderConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dungDefenderConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dungDefenderConvo3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dungDefenderCharmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dungDefenderIsmaConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "dungDefenderAwoken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dungDefenderLeft": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dungDefenderAwakeConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "midwifeMet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "midwifeConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "midwifeConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metQueen": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenTalk1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenTalk2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenDung1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenDung2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenHornet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenTalkExtra": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotQueenFragment": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenConvo_grimm1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "queenConvo_grimm2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotKingFragment": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metXun": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "xunFailedConvo1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "xunFailedConvo2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "xunFlowerBroken": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "xunFlowerBrokeTimes": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "xunFlowerGiven": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "xunRewardGiven": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "menderState": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "menderSignBroken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "allBelieverTabletsDestroyed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mrMushroomState": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "openedMapperShop": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedSlyShop": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metStag": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagPosition": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "stationsOpened": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "stagConvoTram": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagConvoTiso": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagRemember1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagRemember2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagRemember3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagEggInspected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "stagHopeConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "littleFoolMet": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "ranAway": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "seenColosseumTitle": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "colosseumBronzeOpened": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "colosseumBronzeCompleted": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "colosseumSilverOpened": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "colosseumSilverCompleted": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "colosseumGoldOpened": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "colosseumGoldCompleted": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedTownBuilding": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedCrossroads": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedGreenpath": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedRuins1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedRuins2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedFungalWastes": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedRoyalGardens": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedRestingGrounds": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedDeepnest": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedStagNest": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedHiddenStation": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "charmSlots": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "charmSlotsFilled": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "hasCharm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharms": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[System.Int32]" + }, + "charmBenchMsg": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "charmsOwned": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "canOvercharm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "overcharmed": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_4": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_5": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_5": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_5": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_6": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_6": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_6": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_7": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_7": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_7": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_8": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_8": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_8": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_9": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_9": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_9": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_10": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_10": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_10": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_11": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_11": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_11": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_12": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_12": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_12": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_13": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_13": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_13": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_14": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_14": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_14": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_15": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_15": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_15": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_16": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_16": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_16": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_17": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_17": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_17": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_18": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_18": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_18": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_19": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_19": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_19": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_20": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_20": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_20": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_21": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_21": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_21": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_22": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_22": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_22": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_23": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_23": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_23": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_24": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_24": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_24": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_25": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_25": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_25": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_26": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_26": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_26": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_27": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_27": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_27": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_28": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_28": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_28": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_29": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_29": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_29": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_30": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_30": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_30": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_31": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_31": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_31": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_32": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_32": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_32": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_33": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_33": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_33": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_34": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_34": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_34": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_35": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_35": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_35": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_36": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_36": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_36": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_37": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_37": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_37": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_38": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_38": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_38": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_39": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_39": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_39": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gotCharm_40": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "equippedCharm_40": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newCharm_40": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fragileHealth_unbreakable": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fragileGreed_unbreakable": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fragileStrength_unbreakable": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "royalCharmState": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "hasJournal": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "seenJournalMsg": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "seenHunterMsg": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fillJournal": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "journalEntriesCompleted": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "journalNotesCompleted": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "journalEntriesTotal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "killedCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBouncer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBouncer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBouncer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedClimber": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsClimber": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataClimber": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedWorm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsWorm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataWorm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSpitter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSpitter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSpitter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHatcher": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHatcher": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHatcher": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHatchling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHatchling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHatchling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieRunner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieRunner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieRunner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieHornhead": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieHornhead": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieHornhead": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieLeaper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieLeaper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieLeaper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieBarger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieBarger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieBarger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieShield": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieShield": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieShield": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBigBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBigBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBigBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBigFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBigFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBigFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFalseKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFalseKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFalseKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBlocker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBlocker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBlocker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedPrayerSlug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsPrayerSlug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataPrayerSlug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMenderBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMenderBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMenderBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossmanRunner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossmanRunner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossmanRunner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossmanShaker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossmanShaker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossmanShaker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMosquito": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMosquito": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMosquito": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBlobFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBlobFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBlobFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFungifiedZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFungifiedZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFungifiedZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedPlantShooter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsPlantShooter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataPlantShooter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMegaMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMegaMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMegaMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSnapperTrap": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSnapperTrap": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSnapperTrap": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGrassHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGrassHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGrassHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedAcidFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsAcidFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataAcidFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedAcidWalker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsAcidWalker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataAcidWalker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossKnightFat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossKnightFat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossKnightFat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMossWalker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMossWalker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMossWalker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedInfectedKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsInfectedKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataInfectedKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedLazyFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsLazyFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataLazyFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZapBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZapBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZapBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedJellyfish": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsJellyfish": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataJellyfish": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedJellyCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsJellyCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataJellyCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMegaJellyfish": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMegaJellyfish": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMegaJellyfish": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFungoonBaby": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFungoonBaby": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFungoonBaby": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMushroomTurret": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMushroomTurret": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMushroomTurret": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMantis": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMantis": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMantis": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMushroomRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMushroomRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMushroomRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMushroomBrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMushroomBrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMushroomBrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMushroomBaby": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMushroomBaby": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMushroomBaby": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMantisFlyerChild": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMantisFlyerChild": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMantisFlyerChild": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFungusFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFungusFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFungusFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFungCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFungCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "newDataFungCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMantisLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMantisLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMantisLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBlackKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBlackKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBlackKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedElectricMage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsElectricMage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataElectricMage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMageKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMageKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMageKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedRoyalDandy": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsRoyalDandy": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataRoyalDandy": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedRoyalCoward": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsRoyalCoward": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataRoyalCoward": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedRoyalPlumper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsRoyalPlumper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataRoyalPlumper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlyingSentrySword": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlyingSentrySword": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlyingSentrySword": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlyingSentryJavelin": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlyingSentryJavelin": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlyingSentryJavelin": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSentry": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSentry": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSentry": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSentryFat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSentryFat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSentryFat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMageBlob": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMageBlob": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMageBlob": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGreatShieldZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGreatShieldZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGreatShieldZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedJarCollector": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsJarCollector": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataJarCollector": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMageBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMageBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMageBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMageLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMageLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMageLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGorgeousHusk": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGorgeousHusk": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGorgeousHusk": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlipHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlipHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlipHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlukeman": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlukeman": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlukeman": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedInflater": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsInflater": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataInflater": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlukefly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlukefly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlukefly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlukeMother": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlukeMother": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlukeMother": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedDungDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsDungDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataDungDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedCrystalCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsCrystalCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataCrystalCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedCrystalFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsCrystalFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataCrystalFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedLaserBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsLaserBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataLaserBug": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMegaBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMegaBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMegaBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMinesCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMinesCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMinesCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedAngryBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsAngryBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataAngryBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBurstingBouncer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBurstingBouncer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBurstingBouncer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBurstingZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBurstingZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBurstingZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSpittingZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSpittingZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSpittingZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBabyCentipede": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBabyCentipede": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBabyCentipede": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBigCentipede": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBigCentipede": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBigCentipede": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedCentipedeHatcher": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsCentipedeHatcher": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataCentipedeHatcher": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedLesserMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsLesserMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataLesserMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSlashSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSlashSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSlashSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSpiderCorpse": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSpiderCorpse": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSpiderCorpse": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedShootSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsShootSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataShootSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMiniSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMiniSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMiniSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSpiderFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSpiderFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSpiderFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMimicSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMimicSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMimicSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBeeHatchling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBeeHatchling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBeeHatchling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBeeStinger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBeeStinger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBeeStinger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBigBee": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBigBee": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBigBee": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHiveKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHiveKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHiveKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBlowFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBlowFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBlowFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedCeilingDropper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsCeilingDropper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataCeilingDropper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGiantHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGiantHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGiantHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGrubMimic": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGrubMimic": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGrubMimic": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMawlekTurret": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMawlekTurret": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMawlekTurret": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedOrangeScuttler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsOrangeScuttler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataOrangeScuttler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHealthScuttler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHealthScuttler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHealthScuttler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedPigeon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsPigeon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataPigeon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZombieHive": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZombieHive": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZombieHive": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedDreamGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsDreamGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataDreamGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHornet": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHornet": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHornet": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedAbyssCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsAbyssCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataAbyssCrawler": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSuperSpitter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSuperSpitter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSuperSpitter": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedSibling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsSibling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataSibling": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedPalaceFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsPalaceFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataPalaceFly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedEggSac": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsEggSac": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataEggSac": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMummy": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMummy": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMummy": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedOrangeBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsOrangeBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataOrangeBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedAbyssTendril": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsAbyssTendril": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataAbyssTendril": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHeavyMantis": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHeavyMantis": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHeavyMantis": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedTraitorLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsTraitorLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataTraitorLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedMantisHeavyFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsMantisHeavyFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataMantisHeavyFlyer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGardenZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGardenZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGardenZombie": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedRoyalGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsRoyalGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataRoyalGuard": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedWhiteRoyal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsWhiteRoyal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataWhiteRoyal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedPalaceGrounds": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedOblobble": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsOblobble": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataOblobble": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZote": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZote": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZote": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBlobble": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBlobble": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBlobble": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColMosquito": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColMosquito": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColMosquito": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColRoller": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColFlyingSentry": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColFlyingSentry": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColFlyingSentry": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColShield": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColShield": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColShield": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColWorm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColWorm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColWorm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedColHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsColHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataColHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedLobsterLancer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsLobsterLancer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataLobsterLancer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostAladar": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostAladar": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostAladar": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostXero": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostXero": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostXero": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostHu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostHu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostHu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostMarmu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostMarmu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostMarmu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostNoEyes": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostNoEyes": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostNoEyes": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostMarkoth": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostMarkoth": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostMarkoth": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGhostGalien": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGhostGalien": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGhostGalien": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedWhiteDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsWhiteDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataWhiteDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGreyPrince": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGreyPrince": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGreyPrince": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZotelingBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZotelingBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZotelingBalloon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZotelingHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZotelingHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZotelingHopper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedZotelingBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsZotelingBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataZotelingBuzzer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHollowKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHollowKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHollowKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFinalBoss": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFinalBoss": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFinalBoss": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHunterMark": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHunterMark": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHunterMark": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlameBearerSmall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlameBearerSmall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlameBearerSmall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlameBearerMed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlameBearerMed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlameBearerMed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFlameBearerLarge": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFlameBearerLarge": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFlameBearerLarge": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedNightmareGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsNightmareGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataNightmareGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedBindingSeal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsBindingSeal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataBindingSeal": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedFatFluke": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsFatFluke": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataFatFluke": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedPaleLurker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsPaleLurker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataPaleLurker": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedNailBros": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsNailBros": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataNailBros": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedPaintmaster": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsPaintmaster": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataPaintmaster": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedNailsage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsNailsage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataNailsage": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedHollowKnightPrime": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsHollowKnightPrime": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataHollowKnightPrime": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedGodseekerMask": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsGodseekerMask": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataGodseekerMask": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedVoidIdol_1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsVoidIdol_1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataVoidIdol_1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedVoidIdol_2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsVoidIdol_2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataVoidIdol_2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killedVoidIdol_3": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "killsVoidIdol_3": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "newDataVoidIdol_3": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "grubsCollected": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "grubRewards": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "finalGrubRewardCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fatGrubKing": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "falseKnightDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "falseKnightDreamDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "falseKnightOrbsCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mawlekDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "giantBuzzerDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "giantFlyDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "blocker1Defeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "blocker2Defeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornet1Defeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "collectorDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornetOutskirtsDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mageLordDreamDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mageLordOrbsCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "infectedKnightDreamDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "infectedKnightOrbsCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "whiteDefenderDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whiteDefenderOrbsCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "whiteDefenderDefeats": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "greyPrinceDefeats": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "greyPrinceDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "greyPrinceOrbsCollected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "aladarSlugDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "xeroDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "elderHuDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "mumCaterpillarDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "noEyesDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "markothDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "galienDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "XERO_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "ALADAR_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "HU_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "MUMCAT_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "NOEYES_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "MARKOTH_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "GALIEN_encountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "xeroPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "aladarPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "huPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mumCaterpillarPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "noEyesPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "markothPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "galienPinned": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "visitedCrossroads": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedGreenpath": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedFungus": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedHive": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedCrossroadsInfected": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedRuins": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedMines": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedRoyalGardens": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedFogCanyon": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedDeepnest": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedRestingGrounds": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedWaterways": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedAbyss": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedOutskirts": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedWhitePalace": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedCliffs": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedAbyssLower": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedGodhome": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "visitedMines10": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "scenesVisited": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true + }, + "scenesMapped": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true, + "InitialValue": [ + "Cinematic_Stag_travel", + "Room_Town_Stag_Station", + "Room_Charm_Shop", + "Room_Mender_House", + "Room_mapper", + "Room_nailmaster", + "Room_nailmaster_02", + "Room_nailmaster_03", + "Room_shop", + "Room_nailsmith", + "Room_temple", + "Room_ruinhouse", + "Room_Mansion", + "Room_Tram", + "Room_Tram_RG", + "Room_Bretta", + "Room_Fungus_Shaman", + "Room_Ouiji", + "Room_Jinn", + "Room_Colosseum_01", + "Room_Colosseum_02", + "Room_Colosseum_03", + "Room_Colosseum_Bronze", + "Room_Colosseum_Silver", + "Room_Colosseum_Gold", + "Room_Slug_Shrine", + "Crossroads_ShamanTemple", + "Ruins_House_01", + "Ruins_House_02", + "Ruins_House_03", + "Fungus1_35", + "Fungus1_36", + "Fungus3_archive", + "Fungus3_archive_02", + "Cliffs_03", + "RestingGrounds_07", + "Deepnest_45_v02", + "Deepnest_Spider_Town", + "Room_spider_small", + "Room_Wyrm", + "Abyss_Lighthouse_room", + "Room_Queen", + "White_Palace_01", + "White_Palace_02", + "White_Palace_03_hub", + "White_Palace_04", + "White_Palace_05", + "White_Palace_06", + "White_Palace_07", + "White_Palace_08", + "White_Palace_09", + "White_Palace_11", + "White_Palace_12", + "White_Palace_13", + "White_Palace_14", + "White_Palace_15", + "White_Palace_16", + "Dream_Nailcollection", + "Dream_01_False_Knight", + "Dream_03_Infected_Knight", + "Dream_02_Mage_Lord", + "Dream_Guardian", + "Dream_Guardian_Hegemol", + "Dream_Guardian_Lurien", + "Dream_Guardian_Monomon", + "Cutscene_Boss_Door", + "Dream_Backer_Shrine", + "Dream_Room_Believer_Shrine", + "Dream_Abyss", + "Dream_Final_Boss", + "Room_Final_Boss_Atrium", + "Room_Final_Boss_Core", + "Cinematic_Ending_A", + "Cinematic_Ending_B", + "Cinematic_Ending_C", + "Cinematic_Ending_D", + "Cinematic_Ending_E", + "End_Credits", + "Cinematic_MrMushroom", + "End_Game_Completion", + "PermaDeath", + "PermaDeath_Unlock", + "Deepnest_East_17" + ] + }, + "scenesEncounteredBench": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true + }, + "scenesGrubRescued": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true + }, + "scenesFlameCollected": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true + }, + "scenesEncounteredCocoon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true + }, + "scenesEncounteredDreamPlant": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Collections.Generic.List`1[System.String]", + "Additive": true + }, + "scenesEncounteredDreamPlantC": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[System.String]" + }, + "hasMap": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapDirtmouth": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapCrossroads": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapGreenpath": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapFogCanyon": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapRoyalGardens": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapFungalWastes": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapCity": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapWaterways": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapMines": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapDeepnest": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapCliffs": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapOutskirts": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapRestingGrounds": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "mapAbyss": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPin": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinBench": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinCocoon": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinDreamPlant": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinGuardian": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinBlackEgg": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinShop": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinSpa": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinStag": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinTram": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinGhost": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasPinGrub": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasMarker": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasMarker_r": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasMarker_b": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasMarker_y": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "hasMarker_w": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "spareMarkers_r": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "spareMarkers_b": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "spareMarkers_y": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "spareMarkers_w": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "placedMarkers_r": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[Hkmp.Math.Vector3]" + }, + "placedMarkers_b": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[Hkmp.Math.Vector3]" + }, + "placedMarkers_y": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[Hkmp.Math.Vector3]" + }, + "placedMarkers_w": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[Hkmp.Math.Vector3]" + }, + "openedTramLower": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedTramRestingGrounds": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "tramLowerPosition": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "tramRestingGroundsPosition": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "mineLiftOpened": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "menderDoorOpened": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "vesselFragStagNest": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "shamanPillar": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "crossroadsMawlekWall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "eggTempleVisited": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "crossroadsInfected": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "falseKnightFirstPlop": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "falseKnightWallRepaired": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "falseKnightWallBroken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "falseKnightGhostDeparted": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "spaBugsEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hornheadVinePlat": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "infectedKnightEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "megaMossChargerEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "megaMossChargerDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dreamerScene1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "slugEncounterComplete": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "defeatedDoubleBlockers": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "oneWayArchive": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "defeatedMegaJelly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "summonedMonomon": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "sawWoundedQuirrel": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "encounteredMegaJelly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "defeatedMantisLords": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "encounteredGatekeeper": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "deepnestWall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "queensStationNonDisplay": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "cityBridge1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "cityBridge2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "cityLift1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "cityLift1_isUp": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "liftArrival": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedMageDoor": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedMageDoor_v2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "brokenMageWindow": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "brokenMageWindowGlass": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mageLordEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mageLordEncountered_2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "mageLordDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "ruins1_5_tripleDoor": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedCityGate": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "cityGateClosed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bathHouseOpened": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bathHouseWall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "cityLift2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "cityLift2_isUp": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "city2_sewerDoor": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedLoveDoor": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "watcherChandelier": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "completedQuakeArea": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "kingsStationNonDisplay": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tollBenchCity": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "waterwaysGate": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "defeatedDungDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dungDefenderEncounterReady": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "flukeMotherEncountered": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "flukeMotherDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedWaterwaysManhole": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "waterwaysAcidDrained": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dungDefenderWallBroken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dungDefenderSleeping": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "defeatedMegaBeamMiner": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "defeatedMegaBeamMiner2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "brokeMinersWall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "encounteredMimicSpider": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "steppedBeyondBridge": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "deepnestBridgeCollapsed": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "spiderCapture": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "deepnest26b_switch": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedRestingGrounds02": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "restingGroundsCryptWall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "dreamNailConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gladeGhostsKilled": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "openedGardensStagStation": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "extendedGramophone": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tollBenchQueensGardens": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "blizzardEnded": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "encounteredHornet": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "savedByHornet": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "outskirtsWall": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "abyssGateOpened": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "abyssLighthouse": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "blueVineDoor": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "gotShadeCharm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tollBenchAbyss": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "fountainGeo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "fountainVesselSummoned": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "openedBlackEggPath": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "enteredDreamWorld": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "duskKnightDefeated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whitePalaceOrb_1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whitePalaceOrb_2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whitePalaceOrb_3": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whitePalace05_lever": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whitePalaceMidWarp": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "whitePalaceSecretRoomVisited": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tramOpenedDeepnest": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "tramOpenedCrossroads": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "openedBlackEggDoor": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "unchainedHollowKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "flamesCollected": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32", + "Additive": true + }, + "nightmareLanternAppeared": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nightmareLanternLit": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "troupeInTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "divineInTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "grimmChildLevel": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "elderbugConvoGrimm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyConvoGrimm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "iseldaConvoGrimm": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "midwifeWeaverlingConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "foughtGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "metBrum": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "defeatedNightmareGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "grimmchildAwoken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "gotBrummsFlame": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "brummBrokeBrazier": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "destroyedNightmareLantern": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "gotGrimmNotch": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nymmInTown": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "nymmSpoken": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nymmCharmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nymmFinalConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugNymmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "slyNymmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "iseldaNymmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nymmMissedEggOpen": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugTroupeLeftConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "elderbugBrettaLeft": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "jijiGrimmConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "metDivine": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "divineFinalConvo": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gaveFragileHeart": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gaveFragileGreed": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "gaveFragileStrength": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "divineEatenConvos": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "pooedFragileHeart": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "pooedFragileGreed": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "pooedFragileStrength": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "completionPercentage": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Single" + }, + "unlockedCompletionRate": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "newDatTraitorLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bossDoorStateTier1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossSequenceDoorCompletion" + }, + "bossDoorStateTier2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossSequenceDoorCompletion" + }, + "bossDoorStateTier3": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossSequenceDoorCompletion" + }, + "bossDoorStateTier4": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossSequenceDoorCompletion" + }, + "bossDoorStateTier5": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossSequenceDoorCompletion" + }, + "bossStatueTargetLevel": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "statueStateGruzMother": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateVengefly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateBroodingMawlek": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateFalseKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateFailedChampion": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateHornet1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateHornet2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateMegaMossCharger": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateMantisLords": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateOblobbles": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateGreyPrince": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateBrokenVessel": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateLostKin": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateNosk": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateFlukemarm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateCollector": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateWatcherKnights": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateSoulMaster": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateSoulTyrant": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateGodTamer": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateCrystalGuardian1": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateCrystalGuardian2": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateUumuu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateDungDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateWhiteDefender": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateHiveKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateTraitorLord": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateNightmareGrimm": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateHollowKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateElderHu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateGalien": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateMarkoth": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateMarmu": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateNoEyes": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateXero": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateGorb": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateRadiance": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateSly": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateNailmasters": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateMageKnight": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStatePaintmaster": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateZote": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateNoskHornet": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "statueStateMantisLordsExtra": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "Hkmp.Serialization.BossStatueCompletion" + }, + "godseekerUnlocked": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bossRushMode": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "bossDoorCageUnlocked": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "blueRoomDoorUnlocked": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "blueRoomActivated": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "finalBossDoorUnlocked": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "hasGodfinder": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "unlockedNewBossStatue": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "scaredFlukeHermitEncountered": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "scaredFlukeHermitReturned": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "enteredGGAtrium": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "extraFlowerAppear": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "givenGodseekerFlower": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "givenOroFlower": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "givenWhiteLadyFlower": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "givenEmilitiaFlower": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "unlockedBossScenes": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Collections.Generic.List`1[System.String]" + }, + "queuedGodfinderIcon": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "godseekerSpokenAwake": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "nailsmithCorpseAppeared": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "godseekerWaterwaysSeenState": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Int32" + }, + "godseekerWaterwaysSpoken1": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "godseekerWaterwaysSpoken2": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "godseekerWaterwaysSpoken3": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "bossDoorEntranceTextSeen": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Int32" + }, + "seenDoor4Finale": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "zoteStatueWallBroken": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + }, + "seenGGWastes": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true, + "VarType": "System.Boolean" + }, + "ordealAchieved": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": false, + "VarType": "System.Boolean" + } + }, + "geoRocks": [ + { + "Key": { + "id": "Geo Rock 4", + "sceneName": "Tutorial_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Tutorial_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 3", + "sceneName": "Tutorial_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Tutorial_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 5", + "sceneName": "Tutorial_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (1)", + "sceneName": "Crossroads_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (2)", + "sceneName": "Crossroads_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_12" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (3)", + "sceneName": "Crossroads_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (1)", + "sceneName": "Crossroads_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (2)", + "sceneName": "Crossroads_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_13" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_13" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_42" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_42" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_19" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_21" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (1)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_27" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_01b" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (1)", + "sceneName": "Fungus1_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (2)", + "sceneName": "Fungus1_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (1)", + "sceneName": "Fungus1_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (1)", + "sceneName": "Fungus1_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (1)", + "sceneName": "Fungus1_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 02 (2)", + "sceneName": "Fungus1_21" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 02", + "sceneName": "Fungus1_21" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 02 (1)", + "sceneName": "Fungus1_21" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (1)", + "sceneName": "Fungus1_22" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_22" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 3", + "sceneName": "Crossroads_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_11" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 02", + "sceneName": "Fungus2_11" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01 (1)", + "sceneName": "Fungus2_13" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_13" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 02", + "sceneName": "Fungus2_13" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Fungus2_14" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (2)", + "sceneName": "Fungus2_14" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (3)", + "sceneName": "Fungus2_14" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (1)", + "sceneName": "Fungus2_14" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Fungus2_14" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (1)", + "sceneName": "Fungus2_15" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Fungus2_15" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Fungus2_21" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins1_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins1_05c" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1 (1)", + "sceneName": "Ruins1_05b" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_37" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_05" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins1_32" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (1)", + "sceneName": "Mines_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Mines_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (1)", + "sceneName": "Mines_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_05" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (3)", + "sceneName": "Mines_20" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (4)", + "sceneName": "Mines_20" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (2)", + "sceneName": "Mines_20" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_37" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (1)", + "sceneName": "Mines_37" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1 (1)", + "sceneName": "Waterways_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Waterways_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (1)", + "sceneName": "Abyss_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Abyss_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Abyss_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Waterways_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins2_06" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus3_26" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_52" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Crossroads_52" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins2_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 02 (2)", + "sceneName": "Fungus2_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01 (1)", + "sceneName": "Fungus2_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 02 (1)", + "sceneName": "Fungus2_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Fungus2_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Fungus2_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Fungus2_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Deepnest_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (3)", + "sceneName": "Deepnest_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (4)", + "sceneName": "Deepnest_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Deepnest_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_39" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Deepnest_39" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_39" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (4)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (6)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (7)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (5)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (3)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (1)", + "sceneName": "Cliffs_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Cliffs_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (1)", + "sceneName": "Cliffs_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (2)", + "sceneName": "Cliffs_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (3)", + "sceneName": "Cliffs_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (4)", + "sceneName": "Cliffs_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Fungus1_05" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_19" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Abyss_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Abyss_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Abyss_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Abyss", + "sceneName": "Abyss_18" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Abyss", + "sceneName": "Abyss_19" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Abyss (1)", + "sceneName": "Abyss_19" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_16" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Grave 01", + "sceneName": "RestingGrounds_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Grave 02 (1)", + "sceneName": "RestingGrounds_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Grave 02", + "sceneName": "RestingGrounds_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts", + "sceneName": "Deepnest_East_06" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts (1)", + "sceneName": "Deepnest_East_06" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts", + "sceneName": "Deepnest_East_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Abyss (1)", + "sceneName": "Abyss_06_Core" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Abyss", + "sceneName": "Abyss_06_Core" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_36" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus3_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 02", + "sceneName": "Room_Fungus_Shaman" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1 (2)", + "sceneName": "Fungus1_28" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Fungus1_28" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Deepnest_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Deepnest_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (1)", + "sceneName": "Fungus3_39" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus3_39" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (2)", + "sceneName": "Deepnest_31" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_35" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_35" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_37" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_37" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Deepnest_43" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (1)", + "sceneName": "Deepnest_43" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus3_10" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 02", + "sceneName": "Fungus3_48" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts", + "sceneName": "Deepnest_East_07" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Waterways_04b" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Waterways_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts", + "sceneName": "Deepnest_East_08" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins2_05" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_29" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01", + "sceneName": "Fungus1_12" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 02", + "sceneName": "Fungus1_12" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Green Path 01 (1)", + "sceneName": "Fungus1_12" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive", + "sceneName": "Hive_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive", + "sceneName": "Hive_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive (1)", + "sceneName": "Hive_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive (2)", + "sceneName": "Hive_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_East_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_East_01" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive (2)", + "sceneName": "Hive_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive", + "sceneName": "Hive_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive (1)", + "sceneName": "Hive_03" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive", + "sceneName": "Hive_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Hive (1)", + "sceneName": "Hive_04" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_East_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_East_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (3)", + "sceneName": "Mines_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (1)", + "sceneName": "Mines_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (2)", + "sceneName": "Mines_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine", + "sceneName": "Mines_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Mine (4)", + "sceneName": "Mines_25" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_29" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01 (1)", + "sceneName": "Fungus2_30" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Fungus2_30" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts", + "sceneName": "Deepnest_East_17" + }, + "Value": true + }, + { + "Key": { + "id": "Giant Geo Egg", + "sceneName": "Deepnest_East_17" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest (1)", + "sceneName": "Deepnest_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Deepnest", + "sceneName": "Deepnest_02" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "Ruins_Elevator" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Outskirts", + "sceneName": "GG_Lurker" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2", + "sceneName": "Mines_33" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (2)", + "sceneName": "Mines_33" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 2 (1)", + "sceneName": "Mines_33" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock 1", + "sceneName": "Crossroads_46" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock City 1", + "sceneName": "GG_Pipeway" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 01", + "sceneName": "Room_GG_Shortcut" + }, + "Value": true + }, + { + "Key": { + "id": "Geo Rock Fung 02 (1)", + "sceneName": "Room_GG_Shortcut" + }, + "Value": true + } + ], + "persistentBoolItems": [ + { + "Key": { + "id": "fury charm_remask", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "inverse_remask_right", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Tute 01", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Tute Door 2", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Tute Door 4", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Tute Door 3", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Interact Reminder", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Tute Door 6", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Door", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Tute Door 1", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Tute Door 7", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region (1)", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Tute Door 5", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Initial Fall Impact", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Death Respawn Trigger", + "sceneName": "Town" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mines Lever", + "sceneName": "Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Interact Reminder", + "sceneName": "Town" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Death Respawn Trigger 1", + "sceneName": "Town" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Door Destroyer", + "sceneName": "Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gravedigger NPC", + "sceneName": "Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Crossroads_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Crossroads_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner 1", + "sceneName": "Crossroads_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall_Silhouette", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Tute Door 1", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Break Wall 2", + "sceneName": "Crossroads_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "break_wall_masks", + "sceneName": "Crossroads_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Crossroads_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Crossroads_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Crossroads_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hatcher", + "sceneName": "Crossroads_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Crossroads_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Crossroads_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Wall 2", + "sceneName": "Crossroads_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Toll Gate Switch", + "sceneName": "Crossroads_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "CamLock Destroyer", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gate Switch", + "sceneName": "Room_Town_Stag_Station" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Guard", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger (1)", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Gate Switch", + "sceneName": "Crossroads_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Raising Pillar", + "sceneName": "Crossroads_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (34)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (36)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (32)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Death Respawn Trigger 1", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Bone Gate", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blocker", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (37)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (35)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (38)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (41)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (40)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (39)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reminder Cast (1)", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Prayer Slug", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Prayer Slug (1)", + "sceneName": "Crossroads_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hatcher 1", + "sceneName": "Crossroads_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hatcher", + "sceneName": "Crossroads_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hatcher 2", + "sceneName": "Crossroads_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Shield 1", + "sceneName": "Crossroads_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Shield", + "sceneName": "Crossroads_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Reward 16", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reward 31", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reward 46", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reward 10", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reward 38", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reward 5", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reward 23", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Blocker", + "sceneName": "Crossroads_11_alt" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_11_alt" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Runner", + "sceneName": "Fungus1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker (1)", + "sceneName": "Fungus1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Fungus1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger", + "sceneName": "Fungus1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Fungus1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Fungus1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Fungus1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner (1)", + "sceneName": "Fungus1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker (1)", + "sceneName": "Fungus1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Runner", + "sceneName": "Fungus1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Fungus1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Toll Gate Machine", + "sceneName": "Fungus1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Toll Gate Machine (1)", + "sceneName": "Fungus1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Knight B", + "sceneName": "Fungus1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Fungus1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Fungus1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene v2", + "sceneName": "Fungus1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Knight (1)", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Knight", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (2)", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger (1)", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger (2)", + "sceneName": "Fungus1_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gate Switch", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (1)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (3)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (5)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (1)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (4)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (2)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker (1)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker (2)", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "secret mask 2", + "sceneName": "Fungus1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Fungus1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Camera Locks Boss", + "sceneName": "Fungus1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Reminder Look Down", + "sceneName": "Fungus1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Crossroads_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Crossroads_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (1)", + "sceneName": "Fungus2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (2)", + "sceneName": "Fungus2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (3)", + "sceneName": "Fungus2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Fungus2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller", + "sceneName": "Fungus2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (1)", + "sceneName": "Fungus2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus2_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Fungus B", + "sceneName": "Fungus2_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Fungus A", + "sceneName": "Fungus2_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (2)", + "sceneName": "Fungus2_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (1)", + "sceneName": "Fungus2_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis", + "sceneName": "Fungus2_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (1)", + "sceneName": "Fungus2_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (4)", + "sceneName": "Fungus2_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (3)", + "sceneName": "Fungus2_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (2)", + "sceneName": "Fungus2_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis", + "sceneName": "Fungus2_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever", + "sceneName": "Fungus2_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (2)", + "sceneName": "Fungus2_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Fungus2_14" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mantis Lever (1)", + "sceneName": "Fungus2_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (1)", + "sceneName": "Fungus2_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gate Mantis", + "sceneName": "Fungus2_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (2)", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever (4)", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever (3)", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever (1)", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever (2)", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis (1)", + "sceneName": "Fungus2_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus2_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "secret sound", + "sceneName": "Fungus2_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Fungus2_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Ruins1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Ruins1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Ruins1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Ruins1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Ruins1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Ruins1_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins1_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (1)", + "sceneName": "Ruins1_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Ruins1_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (3)", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (3)", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (2)", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead (1)", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (1)", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (2)", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Ruins1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Ruins1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Ruins1_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Lever 3", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever 2", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin (2)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (7)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (9)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (5)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (4)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (6)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat (5)", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever 1", + "sceneName": "Ruins1_05b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene v2", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle (1)", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin (3)", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (1)", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry FatB", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Ruin Lift", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Toll Machine Bench", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin (1)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker full bot", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "remask_half_mid", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker full mid", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker full top", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "remask_half_bot", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "remask_half_bot (2)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (1)", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Ruins1_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner (1)", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chain Platform", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger 1", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead 1", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner 1", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger (1)", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mask Bottom", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vessel Fragment", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mask Bottom 2", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner (1)", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead 2", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger 3", + "sceneName": "Crossroads_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Crossroads_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Crossroads_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Guard", + "sceneName": "Crossroads_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Crossroads_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead 1", + "sceneName": "Crossroads_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner 1", + "sceneName": "Crossroads_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Crossroads_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene v2", + "sceneName": "Fungus2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Crossroads_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Crossroads_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner 2", + "sceneName": "Crossroads_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Crossroads_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Crossroads_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper 1", + "sceneName": "Crossroads_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat B", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage", + "sceneName": "Ruins1_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Ruins1_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever (1)", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage (1)", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty (1)", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene v2", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty (2)", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty", + "sceneName": "Ruins1_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty (1)", + "sceneName": "Ruins1_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage", + "sceneName": "Ruins1_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage (1)", + "sceneName": "Ruins1_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage (1)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (3)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass (1)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sounder", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass (2)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Light Stand", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage (2)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty (1)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Ruins1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Ruins1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty", + "sceneName": "Ruins1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty (1)", + "sceneName": "Ruins1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Soul Vial", + "sceneName": "Ruins1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass (2)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass (1)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor Glass (4)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Vial Empty (4)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Lever (1)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (1)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass (3)", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "mine_1_quake_floor", + "sceneName": "Mines_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Mines_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Mines_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Miner 1", + "sceneName": "Mines_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (1)", + "sceneName": "Mines_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Mines_29" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Crystal Flyer (1)", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (1)", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (3)", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (2)", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (2)", + "sceneName": "Mines_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1", + "sceneName": "Mines_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (1)", + "sceneName": "Mines_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever", + "sceneName": "Mines_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (2)", + "sceneName": "Mines_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (1)", + "sceneName": "Mines_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (3)", + "sceneName": "Mines_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (2)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (11)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (6)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (12)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (10)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (3)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (9)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (5)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (1)", + "sceneName": "Mines_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Mines_30" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Miner 1", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (4)", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (2)", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (1)", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (3)", + "sceneName": "Mines_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mega Zombie Beam Miner (1)", + "sceneName": "Mines_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Mines_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mines Lever (2)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mines Lever (1)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (7)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (5)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (3)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (4)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (8)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (9)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (6)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (9)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (1)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (3)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (1)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (2)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever", + "sceneName": "Mines_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever New", + "sceneName": "Mines_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Reminder Superdash", + "sceneName": "Mines_31" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Mines_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (2)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (5)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (4)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (6)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever (3)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever (4)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mines Lever New", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystallised Lazer Bug (1)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Miner 1 (3)", + "sceneName": "Mines_37" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Mines_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Ruins1_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (1)", + "sceneName": "Ruins1_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins1_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1", + "sceneName": "Ruins1_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins1_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "mine_1_quake_floor", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (2)", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (1)", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "RestingGrounds_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "RestingGrounds_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "RestingGrounds_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "RestingGrounds_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Grubsong", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Hatcher", + "sceneName": "Crossroads_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Crossroads_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Crossroads_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Brawler", + "sceneName": "Fungus2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever", + "sceneName": "Fungus2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever (1)", + "sceneName": "Fungus2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Fungus B", + "sceneName": "Fungus2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (1)", + "sceneName": "Fungus2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (2)", + "sceneName": "Fungus2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Fungus2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Fungus2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Fungus2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (3)", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (4)", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (1)", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (1)", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (2)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (1)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (3)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (1)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Waterways_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Waterways_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Waterways_Crank_Lever", + "sceneName": "Waterways_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Waterways_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (32)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (34)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (1)", + "sceneName": "Abyss_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (1)", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Acid", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (1)", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Waterways_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (1)", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie (2)", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie (1)", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie (3)", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat (1)", + "sceneName": "Ruins2_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "remask", + "sceneName": "Ruins2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin", + "sceneName": "Ruins2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Ruins2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins2_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins2_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (4)", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (3)", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_25b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_25b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_25b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (4)", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (5)", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (3)", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (3)", + "sceneName": "Fungus3_27" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus3_47" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus3_47" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus3_47" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (3)", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (5)", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (6)", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (4)", + "sceneName": "Fungus3_archive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (5)", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (4)", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (3)", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus3_28" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Fungus Flyer (1)", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Crossroads_52" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Crossroads_52" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Crossroads_52" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie", + "sceneName": "Crossroads_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer (3)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer (5)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer (2)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer (4)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer (1)", + "sceneName": "Crossroads_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (1)", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gorgeous Husk", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (2)", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Ruins_House_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor Glass", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat (1)", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (2)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (4)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever (1)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin (1)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (1)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (3)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat (3)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat (2)", + "sceneName": "Ruins2_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins1_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Scene", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie bottom", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (1)", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "boss_floor_remasker", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Scene", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Control", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Great Shield Zombie", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret sound", + "sceneName": "Ruins2_Watcher_Room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins2_Watcher_Room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Ruins2_Watcher_Room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins2_Watcher_Room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (2)", + "sceneName": "Crossroads_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie (1)", + "sceneName": "Crossroads_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie", + "sceneName": "Crossroads_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie (1)", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie (1)", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie", + "sceneName": "Crossroads_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Fungus A", + "sceneName": "Fungus2_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Lever", + "sceneName": "Fungus2_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus2_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Fungus2_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus2_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Fungus2_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Fungus2_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus2_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Fungus2_20" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mushroom Roller (3)", + "sceneName": "Fungus2_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (1)", + "sceneName": "Fungus2_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller", + "sceneName": "Fungus2_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (2)", + "sceneName": "Fungus2_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller", + "sceneName": "Fungus2_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (1)", + "sceneName": "Fungus2_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Fungus2_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Fungus2_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mantis", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Chest (1)", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Chest (2)", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Chest", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item Charm", + "sceneName": "Fungus2_31" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Fungus2_25" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (1)", + "sceneName": "Fungus2_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Fungus2_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (1)", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask bot left", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (3)", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_16" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_01b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_01b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "Deepnest_01b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_01b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small top", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mimic Spider Fake4", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (3)", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (2)", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (1)", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (3)", + "sceneName": "Deepnest_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp (4)", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp (5)", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp (3)", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp (2)", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp (2)", + "sceneName": "Deepnest_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp", + "sceneName": "Deepnest_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp", + "sceneName": "Deepnest_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp (1)", + "sceneName": "Deepnest_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider", + "sceneName": "Deepnest_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (42)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (36)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (44)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (4)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (40)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (3)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (34)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (6)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (35)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (43)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (2)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (38)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (32)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall (1)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (5)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (39)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (41)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (7)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (1)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (37)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Slash Spider (4)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider (2)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider (3)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall (2)", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask temp", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (1)", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall (1)", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (2)", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (4)", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider", + "sceneName": "Deepnest_41" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (6)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "one way permanent", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (9)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (8)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (2)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (10)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (3)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (7)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker bar", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (11)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (12)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "hack jump secret remask", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (4)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (5)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (4)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (3)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "remask_store_room", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Slash Spider (3)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider (4)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider (1)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Slash Spider (2)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Room_Bretta" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Room_temple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Room_nailmaster" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Room_nailmaster" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Room_nailmaster" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (56)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (58)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (48)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (57)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (59)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (66)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (38)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (63)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (42)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (36)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (41)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (67)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (55)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (47)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (61)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (52)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (53)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall grimm", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (45)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (64)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (39)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (35)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (50)", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Cliffs_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Cliffs_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper (1)", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper (2)", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Cliffs_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost Activator", + "sceneName": "Cliffs_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Cliffs_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost NPC Joni", + "sceneName": "Cliffs_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Grimm_Main_Tent" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Grimm_Main_Tent" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Fungus1_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Knight", + "sceneName": "Fungus1_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "secret sound", + "sceneName": "Fungus1_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall (1)", + "sceneName": "Fungus1_Slug" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_Slug" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Fungus1_Slug" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus1_Slug" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Fungus1_Slug" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_Slug" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vine Platform", + "sceneName": "Fungus1_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_15" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_10" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Moss Charger (1)", + "sceneName": "Fungus1_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger 1 (1)", + "sceneName": "Fungus1_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger 1 (2)", + "sceneName": "Fungus1_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Charger", + "sceneName": "Fungus1_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Runner", + "sceneName": "Fungus1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker (1)", + "sceneName": "Fungus1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform", + "sceneName": "Fungus1_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_14" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus1_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker", + "sceneName": "Fungus1_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner", + "sceneName": "Fungus1_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Fungus1_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus1_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Leaper", + "sceneName": "Fungus1_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus1_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Fungus1_36" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vine Platform (3)", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (2)", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (1)", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Runner", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Runner (1)", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Fungus1_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1 (1)", + "sceneName": "Fungus1_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vine Platform (1)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (4)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (3)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret sounder", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (5)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (2)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vine Platform (6)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker (1)", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mossman_Shaker", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Fungus1_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (2)", + "sceneName": "Crossroads_11_alt" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_11_alt" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Rancid Egg", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost kcin", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost atra", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost wyatt", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost chagax", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost boss", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost hex", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost garro", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost perpetos", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost molten", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost revek", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost NPC 100 nail", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost caspian", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost waldie", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost milly", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost magnus", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost grohac", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost wayner", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "karina", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (32)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost thistlewind", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "RestingGrounds_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin (1)", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry Javelin", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry (1)", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger (1)", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Barger", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "wish_secret sound", + "sceneName": "Abyss_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "wish inverse remask", + "sceneName": "Abyss_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "wish_remask", + "sceneName": "Abyss_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Abyss_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Toll Machine Bench", + "sceneName": "Abyss_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Abyss_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Abyss_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Abyss_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Camera Locks Boss", + "sceneName": "Abyss_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Turret", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Turret Ceiling (1)", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Turret Ceiling", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Turret (1)", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Turret (2)", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Turret (3)", + "sceneName": "Abyss_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Abyss_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene v2", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (1)", + "sceneName": "Mines_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic", + "sceneName": "Mines_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Mines_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic Bottle", + "sceneName": "Mines_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner Rematch", + "sceneName": "Mines_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Mines_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Mines_32" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Mines_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Mines_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Mines_36" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grave Zombie (2)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (3)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall (5)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (6)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall (6)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (3)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall (4)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (5)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (7)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall (3)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (4)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall (8)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small (1)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small (2)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall (2)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall (7)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grave Zombie (4)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grave Zombie", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grave Zombie (1)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "RestingGrounds_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Death Respawn Trigger", + "sceneName": "RestingGrounds_12" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Great Shield Zombie", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gate Switch", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Resting Grounds Slide Floor", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Crossroads_50" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Crossroads_50" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hatcher", + "sceneName": "Crossroads_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Crossroads_22" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Dream_01_False_Knight" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Ruins2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Ruins2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Ruins2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Ruins2_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plank Solid 2", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plank Solid 1", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plank Solid 2 (1)", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (5)", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (1)", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (4)", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (3)", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (2)", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (2)", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (1)", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper (2)", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper (1)", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper", + "sceneName": "Deepnest_East_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Deepnest_East_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Deepnest_East_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (1)", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (2)", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (1)", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hopper Spawn", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (2)", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (3)", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (4)", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (5)", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "remask corridor", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "secret sound", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_East_14b" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_East_18" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (2)", + "sceneName": "Deepnest_East_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (1)", + "sceneName": "Deepnest_East_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall top", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (4)", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (1)", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (3)", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_East_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Hornet Encounter Outskirts", + "sceneName": "Deepnest_East_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Room_Wyrm" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (1)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (2)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (4)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (3)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (3)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (2)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Abyss_06_Core" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Abyss_06_Core" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Abyss_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Soul Vial", + "sceneName": "Abyss_Lighthouse_room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Vial Empty", + "sceneName": "Abyss_Lighthouse_room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Abyss_Lighthouse_room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Abyss_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Abyss_10" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Bursting Bouncer (1)", + "sceneName": "Crossroads_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Reminder Look Down", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Force Hard Landing", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small 1", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mask Bottom", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mawlek Body", + "sceneName": "Crossroads_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Crossroads_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Crossroads_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Crossroads_09" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Jellyfish (3)", + "sceneName": "Fungus3_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (4)", + "sceneName": "Fungus3_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (1)", + "sceneName": "Fungus3_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish (2)", + "sceneName": "Fungus3_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie", + "sceneName": "Fungus3_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Egg Sac", + "sceneName": "Fungus3_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie (1)", + "sceneName": "Fungus3_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie (2)", + "sceneName": "Fungus3_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Fungus3_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus3_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus3_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Room_Fungus_Shaman" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Room_Fungus_Shaman" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Room_Fungus_Shaman" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie (1)", + "sceneName": "Crossroads_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Spitting Zombie", + "sceneName": "Crossroads_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (2)", + "sceneName": "Crossroads_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (2)", + "sceneName": "Crossroads_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blocker 2", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blocker 1", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "mask_01", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall (1)", + "sceneName": "Fungus1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Moss Knight C", + "sceneName": "Fungus1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Relic2", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Fungus Break Floor", + "sceneName": "Deepnest_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mantis Heavy", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy (1)", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "Deepnest_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mimic Spider Fake1", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mimic Spider Fake3", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall (1)", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mimic Spider Fake2", + "sceneName": "Deepnest_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Deepnest_32" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Deepnest_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp (2)", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp (1)", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene v2", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp (1)", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp", + "sceneName": "Deepnest_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Centipede Hatcher (2)", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Centipede Hatcher", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Centipede Hatcher (9)", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Centipede Hatcher (7)", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Centipede Hatcher (4)", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Centipede Hatcher (5)", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever Remade", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "tram_inverse mask", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_26" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "mask tram left front", + "sceneName": "Deepnest_26b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp (2)", + "sceneName": "Deepnest_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hornhead Sp", + "sceneName": "Deepnest_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Runner Sp", + "sceneName": "Deepnest_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Deepnest_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Last Weaver", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_45_v02" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Deepnest_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Deepnest_44" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Deepnest_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_44" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "gramaphone", + "sceneName": "Room_Tram" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "gramaphone (1)", + "sceneName": "Room_Tram" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Abyss_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Abyss_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Abyss_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Abyss_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vessel Fragment", + "sceneName": "Abyss_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_38" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vessel Fragment", + "sceneName": "Deepnest_38" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_38" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_38" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Fungus3_34" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Fungus3_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Fungus3_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Slide Floor", + "sceneName": "Fungus3_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (1)", + "sceneName": "Fungus3_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gate Switch", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (2)", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (1)", + "sceneName": "Fungus3_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Fungus1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost NPC", + "sceneName": "Fungus1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Fungus1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Fungus3_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (1)", + "sceneName": "Fungus3_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (3)", + "sceneName": "Fungus3_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Fungus3_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (2)", + "sceneName": "Fungus3_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_43" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie", + "sceneName": "Deepnest_43" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie (1)", + "sceneName": "Deepnest_43" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Deepnest_43" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Toll Machine Bench", + "sceneName": "Fungus3_50" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "gramaphone", + "sceneName": "Fungus3_50" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie (1)", + "sceneName": "Fungus3_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie", + "sceneName": "Fungus3_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Fungus3_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus3_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus3_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (2)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "secret sound", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (8)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (1)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (5)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (7)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (4)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (10)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (3)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (9)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (11)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (1)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plant Trap (6)", + "sceneName": "Fungus3_48" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "secret sound", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Gate Switch", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (1)", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek (1)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek (2)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek (3)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek (4)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek 1", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek 2", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (3)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek (8)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene Ore", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Abyss_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mantis Heavy", + "sceneName": "Fungus3_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Spawn", + "sceneName": "Fungus3_39" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "ruind_dressing_light_01", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie (2)", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Garden Zombie (1)", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer (1)", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer", + "sceneName": "Fungus3_22" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Fungus3_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Room_Queen" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Room_Queen" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item RoyalCharm", + "sceneName": "Room_Queen" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Cloth Ghost NPC", + "sceneName": "Fungus3_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (36)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (41)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (42)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (38)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (35)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (43)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (34)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (40)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vessel Fragment", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vine Platform", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (39)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vine Platform (2)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (32)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (37)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vine Platform (1)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Acid Walker (4)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker (5)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker (2)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker (3)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker (1)", + "sceneName": "Fungus1_13" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (34)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (30)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (41)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (33)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (48)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (42)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (40)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (29)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (44)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (47)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (31)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (46)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (51)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (45)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (50)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (25)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (36)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (24)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (26)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (27)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (49)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (37)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (32)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (39)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (43)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (35)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (28)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Super Spitter (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (5)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (2)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter (4)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (5)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (7)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (2)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (6)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (4)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Ruins2_11_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar", + "sceneName": "Ruins2_11_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Ruins2_11_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Ruins2_11_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins2_11_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (1)", + "sceneName": "Ruins2_11_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (4)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "inver remask_below second corridor", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "inver remask_below second corridor (1)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "secret sound_grub room", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (3)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (5)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (7)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "inver remask_above boss encounter", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (2)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Jar (8)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item CollectorMap", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Break Jar (6)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "inver remask_above first corridor", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "inver remask_below second corridor (2)", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Ruins2_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Ruins_House_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Ruins_House_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Flukeman (1)", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (1)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (2)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (3)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (16)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (5)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (4)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (13)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (15)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (3)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (4)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Waterways_12" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Waterways_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Waterways_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Waterways_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Waterways_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Ore", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Deepnest_East_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter(Clone)", + "sceneName": "Deepnest_East_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (3)", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Deepnest_East_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (4)", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (5)", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (6)", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (3)", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (2)", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ceiling Dropper (1)", + "sceneName": "Deepnest_East_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Room_Colosseum_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall_Silhouette", + "sceneName": "Room_Colosseum_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Room_Colosseum_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer(Clone)", + "sceneName": "Room_Colosseum_Bronze" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "Room_Colosseum_Bronze" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Room_Colosseum_Bronze" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Royal Zombie 1", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (1)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (2)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (1)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Flying Sentry", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat (1)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (2)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry Fat (1)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward (3)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (1)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Sentry 1 (3)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat (2)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie 1 (2)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Royal Zombie 1", + "sceneName": "Ruins2_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Coward", + "sceneName": "Ruins2_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Royal Zombie Fat", + "sceneName": "Ruins2_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "Ruins2_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Vessel Fragment", + "sceneName": "Ruins2_09" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "secret sound", + "sceneName": "Fungus2_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Fungus2_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_34" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Fungus2_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish 1", + "sceneName": "Fungus3_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish 4", + "sceneName": "Fungus3_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish 3", + "sceneName": "Fungus3_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish", + "sceneName": "Fungus3_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish 6", + "sceneName": "Fungus3_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Jellyfish 2", + "sceneName": "Fungus3_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus3_30" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Health Cocoon", + "sceneName": "Fungus3_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Acid Walker", + "sceneName": "Fungus1_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus1_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask sounder", + "sceneName": "Fungus1_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Abyss_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hive", + "sceneName": "Hive_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar", + "sceneName": "Hive_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Bench", + "sceneName": "Hive_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger", + "sceneName": "Hive_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (1)", + "sceneName": "Hive_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (22)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Bee Stinger (1)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (2)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Bee Stinger (3)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (23)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (21)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Bee Stinger (2)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Hive (1)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hive (2)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hive (3)", + "sceneName": "Hive_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (4)", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (5)", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (5)", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Big Bee (1)", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (6)", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Big Bee", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Hive_03_c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (1)", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (4)", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (2)", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (5)", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly (3)", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Blow Fly", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (7)", + "sceneName": "Hive_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Big Bee (2)", + "sceneName": "Hive_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (8)", + "sceneName": "Hive_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Hive_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Hive_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (10)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hive (4)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (9)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (11)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Hive (6)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Break Wall", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (4)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Big Bee (3)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (3)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (5)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Big Bee (4)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bee Stinger (12)", + "sceneName": "Hive_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar", + "sceneName": "Hive_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (1)", + "sceneName": "Hive_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hive Breakable Pillar (2)", + "sceneName": "Hive_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Hive_05" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Vespa NPC", + "sceneName": "Hive_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Deepnest_East_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Deepnest_East_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Super Spitter(Clone)", + "sceneName": "Room_Colosseum_Silver" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Bouncer(Clone)", + "sceneName": "Room_Colosseum_Silver" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "Room_Colosseum_Silver" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Giant Hopper(Clone)", + "sceneName": "Room_Colosseum_Silver" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Room_Colosseum_Silver" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item Relic3", + "sceneName": "Crossroads_38" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Mines_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Ruins1_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Fungus1_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Tutorial_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Deepnest_East_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grimm Chest", + "sceneName": "Grimm_Main_Tent" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Grimm_Main_Tent" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Royal Gaurd", + "sceneName": "White_Palace_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "White Palace Orb Lever", + "sceneName": "White_Palace_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "White_Palace_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Battle Scene", + "sceneName": "White_Palace_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "WP Lever", + "sceneName": "White_Palace_03_hub" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "WP Lever", + "sceneName": "White_Palace_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "White Palace Orb Lever", + "sceneName": "White_Palace_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "White_Palace_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "White Palace Orb Lever", + "sceneName": "White_Palace_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Ruin Lift", + "sceneName": "White_Palace_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "White_Palace_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "White_Palace_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "White_Palace_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "WP Lever", + "sceneName": "White_Palace_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "White_Palace_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall Waterways", + "sceneName": "White_Palace_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "White_Palace_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item RoyalCharm", + "sceneName": "White_Palace_09" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Abyss_15" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Dream_Final" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Beam Miner (3)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (18)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (20)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (19)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Crystal Flyer (2)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner (1)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner (2)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner (4)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (1)", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner", + "sceneName": "Mines_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner", + "sceneName": "Mines_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Mines_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner (2)", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (2)", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zombie Beam Miner (1)", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer (1)", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Zombie Beam Miner (3)", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Crystal Flyer", + "sceneName": "Mines_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Mines_34" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Mines_34" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Dream Plant Orb (7)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (16)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (1)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (13)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (17)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (15)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (6)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (3)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (5)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (14)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (12)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (8)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (10)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (4)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (9)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (2)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Dream Plant Orb (11)", + "sceneName": "Fungus2_17" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Mushroom Roller", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (1)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (1)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (2)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (3)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Brawler (1)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Brawler", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (2)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Break Floor 1", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fungus Flyer (1)", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Brawler (1)", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Brawler", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (3)", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mushroom Roller (2)", + "sceneName": "Fungus2_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flamebearer Spawn", + "sceneName": "Abyss_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_12" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Hatcher (1)", + "sceneName": "Crossroads_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Heart Piece", + "sceneName": "Room_Mansion" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Super Spitter(Clone)", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (4)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (2)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (5)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor (10)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (6)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (3)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (8)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (9)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (7)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Quake Floor (1)", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Waterways_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Waterways_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Waterways_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Waterways_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Waterways_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Stand", + "sceneName": "Waterways_15" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Waterways_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Waterways_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plank Solid 1 (1)", + "sceneName": "Deepnest_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plank Solid 1 (2)", + "sceneName": "Deepnest_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plank Solid 1", + "sceneName": "Deepnest_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Deepnest_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Deepnest_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "Deepnest_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Grub Mimic 2", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic 1", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic 3", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic Bottle (1)", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic Bottle (2)", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Bottle", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Grub Mimic Bottle", + "sceneName": "Deepnest_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ghost NPC", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (2)", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region (1)", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (3)", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item (1)", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Ghost NPC", + "sceneName": "Ruins_Bathhouse" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Ruins_Bathhouse" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall", + "sceneName": "Ruins_Bathhouse" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall_Silhouette", + "sceneName": "Room_Colosseum_Spectate" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask (1)", + "sceneName": "GG_Lurker" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "GG_Lurker" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "GG_Lurker" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "Room_Colosseum_Gold" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mage (1)", + "sceneName": "Room_Colosseum_Gold" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer(Clone)", + "sceneName": "Room_Colosseum_Gold" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy(Clone)", + "sceneName": "Room_Colosseum_Gold" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Mantis Heavy Flyer(Clone)", + "sceneName": "Room_Colosseum_Gold" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Lesser Mawlek(Clone)", + "sceneName": "Room_Colosseum_Gold" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Poo Strength", + "sceneName": "Grimm_Divine" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Toll Gate Machine (1)", + "sceneName": "Mines_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "One Way Wall", + "sceneName": "Mines_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Toll Gate Machine", + "sceneName": "Mines_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "Mines_33" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "gramaphone", + "sceneName": "Room_Tram_RG" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "gramaphone (1)", + "sceneName": "Room_Tram_RG" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fat Fluke", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Flukeman (1)", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fat Fluke (1)", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Fat Fluke (3)", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Fat Fluke (2)", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Quake Floor", + "sceneName": "GG_Pipeway" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker (1)", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest (1)", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest (3)", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest (4)", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Chest (2)", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item Godfinder", + "sceneName": "GG_Waterways" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "Room_GG_Shortcut" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "Room_GG_Shortcut" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Grate", + "sceneName": "Room_GG_Shortcut" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Atrium" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Zote_Break_wall", + "sceneName": "GG_Workshop" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall_Silhouette", + "sceneName": "GG_Workshop" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Workshop" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Radiance Statue Cage", + "sceneName": "GG_Workshop" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Knight Statue Cage", + "sceneName": "GG_Workshop" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Collapser Small", + "sceneName": "White_Palace_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "White_Palace_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "WP Lever", + "sceneName": "White_Palace_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Sound Region", + "sceneName": "White_Palace_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "White_Palace_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "White_Palace_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Secret Mask", + "sceneName": "GG_Atrium_Roof" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Remasker", + "sceneName": "GG_Atrium_Roof" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Breakable Wall_Silhouette", + "sceneName": "GG_Atrium_Roof" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Atrium_Roof" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "GG Fall Platform", + "sceneName": "GG_Atrium_Roof" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "gg_roof_lever", + "sceneName": "GG_Atrium_Roof" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item", + "sceneName": "GG_False_Knight" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Spa" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Engine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Engine_Prime" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "white_scene_glow", + "sceneName": "GG_Hollow_Knight" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "white_scene_glow (1)", + "sceneName": "GG_Hollow_Knight" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "gg_roof_lever", + "sceneName": "GG_Atrium" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Unn" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Engine_Root" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Col_Glow_Remasker", + "sceneName": "GG_Wyrm" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (46)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (18)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (7)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (23)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (27)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (36)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (41)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (5)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (1)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (10)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_01 (3)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (25)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (6)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (15)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (5)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (40)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (42)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (17)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (9)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (20)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_01 (1)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (6)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (26)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (3)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (16)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (4)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (43)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (21)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (34)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (8)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (10)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (11)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (2)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (3)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (2)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (32)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (28)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (9)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (22)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (4)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (38)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (45)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (13)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_01 (2)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (24)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (1)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (30)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (14)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_01 (4)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (29)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (8)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (31)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (7)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (33)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_02 (11)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (37)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (35)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (19)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (12)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (39)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Plaque_statue_03 (44)", + "sceneName": "Dream_Room_Believer_Shrine" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lever", + "sceneName": "Ruins_House_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker (1)", + "sceneName": "Ruins_House_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Inverse Remasker", + "sceneName": "Ruins_House_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Poo Heart", + "sceneName": "Grimm_Divine" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item HunterMark", + "sceneName": "Fungus1_08" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Angry Buzzer (1)", + "sceneName": "Crossroads_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Angry Buzzer", + "sceneName": "Crossroads_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie (1)", + "sceneName": "Crossroads_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Bursting Zombie", + "sceneName": "Crossroads_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Poo Greed", + "sceneName": "Grimm_Divine" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Health Cocoon (1)", + "sceneName": "GG_Spa" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Waterways_02" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item GG Storms", + "sceneName": "GG_Land_of_Storms" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Deepnest_39" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny Item(Clone)", + "sceneName": "Waterways_04" + }, + "Value": { + "Sync": true, + "SyncType": "Player", + "IgnoreSceneHost": true + } + }, + { + "Key": { + "id": "Shiny", + "sceneName": "Fungus3_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + } + ], + "persistentIntItems": [ + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Crossroads_19" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 2", + "sceneName": "Crossroads_ShamanTemple" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Crossroads_18" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Fungus2_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Fungus2_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 1", + "sceneName": "Ruins1_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 1", + "sceneName": "Ruins1_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 3", + "sceneName": "Ruins1_05c" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 2", + "sceneName": "Ruins1_05b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 1", + "sceneName": "Ruins1_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins1_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 2", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift 1", + "sceneName": "Ruins1_23" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins1_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 1", + "sceneName": "Ruins1_24" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 3", + "sceneName": "Ruins1_32" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Crossroads_45" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Mines_20" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Mines_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Mines_31" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Mines_28" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Mines_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 4", + "sceneName": "RestingGrounds_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Crossroads_35" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Waterways_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Waterways_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift (1)", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins2_01_b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins2_03b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift (1)", + "sceneName": "Ruins2_03" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins2_Watcher_Room" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 1", + "sceneName": "Deepnest_10" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Deepnest_Spider_Town" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Cliffs_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Cliffs_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Cliffs_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Fungus1_30" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Fungus1_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Abyss_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "RestingGrounds_06" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Deepnest_East_16" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Deepnest_East_14" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 5", + "sceneName": "Deepnest_East_11" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Crossroads_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 4", + "sceneName": "Crossroads_36" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 4", + "sceneName": "Deepnest_38" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Fungus3_40" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Fungus3_21" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Deepnest_East_07" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Waterways_04b" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Waterways_08" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift (1)", + "sceneName": "Ruins2_05" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Fungus1_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Deepnest_East_01" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Deepnest_East_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem white", + "sceneName": "White_Palace_02" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem white", + "sceneName": "White_Palace_03_hub" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem white", + "sceneName": "White_Palace_15" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem white", + "sceneName": "White_Palace_04" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem white", + "sceneName": "White_Palace_09" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Mines_25" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_horned", + "sceneName": "Fungus2_29" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Deepnest_East_17" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem mini_two_horned", + "sceneName": "Deepnest_42" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Ruins Lift", + "sceneName": "Ruins_Elevator" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + }, + { + "Key": { + "id": "Soul Totem 3", + "sceneName": "GG_Lurker" + }, + "Value": { + "Sync": true, + "SyncType": "Server" + } + } + ], + "stringListVariables": [ + "scenesVisited", + "scenesMapped", + "scenesEncounteredBench", + "scenesGrubRescued", + "scenesFlameCollected", + "scenesEncounteredCocoon", + "scenesEncounteredDreamPlant", + "scenesEncounteredDreamPlantC", + "unlockedBossScenes" + ], + "bossSequenceDoorCompletionVariables": [ + "bossDoorStateTier1", + "bossDoorStateTier2", + "bossDoorStateTier3", + "bossDoorStateTier4", + "bossDoorStateTier5" + ], + "bossStatueCompletionVariables": [ + "statueStateGruzMother", + "statueStateVengefly", + "statueStateBroodingMawlek", + "statueStateFalseKnight", + "statueStateFailedChampion", + "statueStateHornet1", + "statueStateHornet2", + "statueStateMegaMossCharger", + "statueStateMantisLords", + "statueStateOblobbles", + "statueStateGreyPrince", + "statueStateBrokenVessel", + "statueStateLostKin", + "statueStateNosk", + "statueStateFlukemarm", + "statueStateCollector", + "statueStateWatcherKnights", + "statueStateSoulMaster", + "statueStateSoulTyrant", + "statueStateGodTamer", + "statueStateCrystalGuardian1", + "statueStateCrystalGuardian2", + "statueStateUumuu", + "statueStateDungDefender", + "statueStateWhiteDefender", + "statueStateHiveKnight", + "statueStateTraitorLord", + "statueStateGrimm", + "statueStateNightmareGrimm", + "statueStateHollowKnight", + "statueStateElderHu", + "statueStateGalien", + "statueStateMarkoth", + "statueStateMarmu", + "statueStateNoEyes", + "statueStateXero", + "statueStateGorb", + "statueStateRadiance", + "statueStateSly", + "statueStateNailmasters", + "statueStateMageKnight", + "statueStatePaintmaster", + "statueStateZote", + "statueStateNoskHornet", + "statueStateMantisLordsExtra" + ], + "vectorListVariables": [ + "placedMarkers_r", + "placedMarkers_b", + "placedMarkers_y", + "placedMarkers_w" + ], + "intListVariables": [ + "equippedCharms" + ] +} diff --git a/HKMP/Resource/string-data.json b/HKMP/Resource/string-data.json new file mode 100644 index 00000000..79d1ab1c --- /dev/null +++ b/HKMP/Resource/string-data.json @@ -0,0 +1,656 @@ +[ + "", + "None", + "Pre_Menu_Intro", + "Menu_Title", + "Quit_To_Menu", + "BetaEnd", + "Knight_Pickup", + "Opening_Sequence", + "Tutorial_01", + "Town", + "Cinematic_Stag_travel", + "Room_Town_Stag_Station", + "Room_Charm_Shop", + "Room_Mender_House", + "Room_mapper", + "Room_nailmaster", + "Room_nailmaster_02", + "Room_nailmaster_03", + "Room_nailsmith", + "Room_shop", + "Room_Sly_Storeroom", + "Room_temple", + "Room_ruinhouse", + "Room_Mask_Maker", + "Room_Mansion", + "Room_Tram", + "Room_Tram_RG", + "Room_Bretta", + "Room_Bretta_Basement", + "Room_Fungus_Shaman", + "Room_Ouiji", + "Room_Jinn", + "Room_Colosseum_01", + "Room_Colosseum_02", + "Room_Colosseum_03", + "Room_Colosseum_Bronze", + "Room_Colosseum_Silver", + "Room_Colosseum_Gold", + "Room_Colosseum_Spectate", + "Room_Slug_Shrine", + "Crossroads_01", + "Crossroads_02", + "Crossroads_03", + "Crossroads_04", + "Crossroads_05", + "Crossroads_06", + "Crossroads_07", + "Crossroads_08", + "Crossroads_09", + "Crossroads_10", + "Crossroads_10_preload", + "Crossroads_10_boss", + "Crossroads_10_boss_defeated", + "Crossroads_11_alt", + "Crossroads_12", + "Crossroads_13", + "Crossroads_14", + "Crossroads_15", + "Crossroads_16", + "Crossroads_18", + "Crossroads_19", + "Crossroads_21", + "Crossroads_22", + "Crossroads_25", + "Crossroads_27", + "Crossroads_30", + "Crossroads_31", + "Crossroads_33", + "Crossroads_35", + "Crossroads_36", + "Crossroads_37", + "Crossroads_38", + "Crossroads_39", + "Crossroads_40", + "Crossroads_42", + "Crossroads_43", + "Crossroads_45", + "Crossroads_46", + "Crossroads_46b", + "Crossroads_ShamanTemple", + "Crossroads_47", + "Crossroads_48", + "Crossroads_49", + "Crossroads_49b", + "Crossroads_50", + "Crossroads_52", + "Ruins_House_01", + "Ruins_House_02", + "Ruins_House_03", + "Ruins_Elevator", + "Ruins_Bathhouse", + "Ruins1_01", + "Ruins1_02", + "Ruins1_03", + "Ruins1_04", + "Ruins1_05", + "Ruins1_05b", + "Ruins1_05c", + "Ruins1_06", + "Ruins1_09", + "Ruins1_17", + "Ruins1_18", + "Ruins1_23", + "Ruins1_30", + "Ruins1_24", + "Ruins1_24_boss", + "Ruins1_24_boss_defeated", + "Ruins1_25", + "Ruins1_27", + "Ruins1_28", + "Ruins1_29", + "Ruins1_31", + "Ruins1_31b", + "Ruins1_32", + "Ruins2_01", + "Ruins2_01_b", + "Ruins2_03", + "Ruins2_03b", + "Ruins2_03_boss", + "Ruins2_04", + "Ruins2_05", + "Ruins2_06", + "Ruins2_07", + "Ruins2_08", + "Ruins2_09", + "Ruins2_10", + "Ruins2_10b", + "Ruins2_11", + "Ruins2_11_b", + "Ruins2_11_boss", + "Ruins2_Watcher_Room", + "Fungus1_01", + "Fungus1_01b", + "Fungus1_02", + "Fungus1_03", + "Fungus1_04", + "Fungus1_04_boss", + "Fungus1_05", + "Fungus1_06", + "Fungus1_07", + "Fungus1_08", + "Fungus1_09", + "Fungus1_10", + "Fungus1_11", + "Fungus1_12", + "Fungus1_13", + "Fungus1_14", + "Fungus1_15", + "Fungus1_16_alt", + "Fungus1_17", + "Fungus1_19", + "Fungus1_20_v02", + "Fungus1_21", + "Fungus1_22", + "Fungus1_23", + "Fungus1_24", + "Fungus1_25", + "Fungus1_26", + "Fungus1_28", + "Fungus1_29", + "Fungus1_30", + "Fungus1_31", + "Fungus1_32", + "Fungus1_34", + "Fungus1_35", + "Fungus1_36", + "Fungus1_37", + "Fungus1_Slug", + "Fungus2_01", + "Fungus2_02", + "Fungus2_03", + "Fungus2_04", + "Fungus2_05", + "Fungus2_06", + "Fungus2_07", + "Fungus2_08", + "Fungus2_09", + "Fungus2_10", + "Fungus2_11", + "Fungus2_12", + "Fungus2_13", + "Fungus2_14", + "Fungus2_15", + "Fungus2_15_boss", + "Fungus2_15_boss_defeated", + "Fungus2_17", + "Fungus2_18", + "Fungus2_19", + "Fungus2_20", + "Fungus2_21", + "Fungus2_23", + "Fungus2_25", + "Fungus2_26", + "Fungus2_28", + "Fungus2_29", + "Fungus2_30", + "Fungus2_31", + "Fungus2_32", + "Fungus2_33", + "Fungus2_34", + "Fungus3_01", + "Fungus3_02", + "Fungus3_03", + "Fungus3_04", + "Fungus3_05", + "Fungus3_08", + "Fungus3_10", + "Fungus3_11", + "Fungus3_13", + "Fungus3_21", + "Fungus3_22", + "Fungus3_23", + "Fungus3_23_boss", + "Fungus3_24", + "Fungus3_25", + "Fungus3_25b", + "Fungus3_26", + "Fungus3_27", + "Fungus3_28", + "Fungus3_30", + "Fungus3_34", + "Fungus3_35", + "Fungus3_39", + "Fungus3_40", + "Fungus3_40_boss", + "Fungus3_44", + "Fungus3_47", + "Fungus3_48", + "Fungus3_49", + "Fungus3_50", + "Fungus3_archive", + "Fungus3_archive_02", + "Fungus3_archive_02_boss", + "Cliffs_01", + "Cliffs_02", + "Cliffs_02_boss", + "Cliffs_03", + "Cliffs_04", + "Cliffs_05", + "Cliffs_06", + "RestingGrounds_02", + "RestingGrounds_02_boss", + "RestingGrounds_04", + "RestingGrounds_05", + "RestingGrounds_06", + "RestingGrounds_07", + "RestingGrounds_08", + "RestingGrounds_09", + "RestingGrounds_10", + "RestingGrounds_12", + "RestingGrounds_17", + "Mines_01", + "Mines_02", + "Mines_03", + "Mines_04", + "Mines_05", + "Mines_06", + "Mines_07", + "Mines_10", + "Mines_11", + "Mines_13", + "Mines_16", + "Mines_17", + "Mines_18", + "Mines_18_boss", + "Mines_19", + "Mines_20", + "Mines_23", + "Mines_24", + "Mines_25", + "Mines_28", + "Mines_29", + "Mines_30", + "Mines_31", + "Mines_32", + "Mines_33", + "Mines_34", + "Mines_35", + "Mines_36", + "Mines_37", + "Deepnest_01", + "Deepnest_01b", + "Deepnest_02", + "Deepnest_03", + "Deepnest_09", + "Deepnest_10", + "Deepnest_14", + "Deepnest_16", + "Deepnest_17", + "Deepnest_26", + "Deepnest_26b", + "Deepnest_30", + "Deepnest_31", + "Deepnest_32", + "Deepnest_33", + "Deepnest_34", + "Deepnest_35", + "Deepnest_36", + "Deepnest_37", + "Deepnest_38", + "Deepnest_39", + "Deepnest_40", + "Deepnest_41", + "Deepnest_42", + "Deepnest_43", + "Deepnest_44", + "Deepnest_45_v02", + "Deepnest_Spider_Town", + "Room_spider_small", + "Deepnest_East_01", + "Deepnest_East_02", + "Deepnest_East_03", + "Deepnest_East_04", + "Deepnest_East_06", + "Deepnest_East_07", + "Deepnest_East_08", + "Deepnest_East_09", + "Deepnest_East_10", + "Deepnest_East_11", + "Deepnest_East_12", + "Deepnest_East_13", + "Deepnest_East_14", + "Deepnest_East_14b", + "Deepnest_East_15", + "Deepnest_East_16", + "Deepnest_East_17", + "Deepnest_East_18", + "Deepnest_East_Hornet", + "Deepnest_East_Hornet_boss", + "Room_Wyrm", + "Abyss_01", + "Abyss_02", + "Abyss_03", + "Abyss_03_b", + "Abyss_03_c", + "Abyss_04", + "Abyss_05", + "Abyss_06_Core", + "Abyss_08", + "Abyss_09", + "Abyss_10", + "Abyss_12", + "Abyss_15", + "Abyss_16", + "Abyss_17", + "Abyss_18", + "Abyss_19", + "Abyss_20", + "Abyss_21", + "Abyss_22", + "Abyss_Lighthouse_room", + "Room_Queen", + "Waterways_01", + "Waterways_02", + "Waterways_03", + "Waterways_04", + "Waterways_04b", + "Waterways_05", + "Waterways_05_boss", + "Waterways_06", + "Waterways_07", + "Waterways_08", + "Waterways_09", + "Waterways_12", + "Waterways_12_boss", + "Waterways_13", + "Waterways_14", + "Waterways_15", + "White_Palace_01", + "White_Palace_02", + "White_Palace_03_hub", + "White_Palace_04", + "White_Palace_05", + "White_Palace_06", + "White_Palace_07", + "White_Palace_08", + "White_Palace_09", + "White_Palace_11", + "White_Palace_12", + "White_Palace_13", + "White_Palace_14", + "White_Palace_15", + "White_Palace_16", + "White_Palace_17", + "White_Palace_18", + "White_Palace_19", + "White_Palace_20", + "Hive_01", + "Hive_02", + "Hive_03", + "Hive_03_c", + "Hive_04", + "Hive_05", + "Grimm_Divine", + "Grimm_Main_Tent", + "Grimm_Main_Tent_boss", + "Grimm_Nightmare", + "Dream_Nailcollection", + "Dream_01_False_Knight", + "Dream_02_Mage_Lord", + "Dream_03_Infected_Knight", + "Dream_04_White_Defender", + "Dream_Mighty_Zote", + "Dream_Guardian", + "Dream_Guardian_Hegemol", + "Dream_Guardian_Lurien", + "Dream_Guardian_Monomon", + "Cutscene_Boss_Door", + "Dream_Backer_Shrine", + "Dream_Room_Believer_Shrine", + "Dream_Abyss", + "Dream_Final", + "Dream_Final_Boss", + "Room_Final_Boss_Atrium", + "Room_Final_Boss_Core", + "Cinematic_Ending_A", + "Cinematic_Ending_B", + "Cinematic_Ending_C", + "Cinematic_Ending_D", + "Cinematic_Ending_E", + "End_Credits", + "Cinematic_MrMushroom", + "Menu_Credits", + "End_Game_Completion", + "PermaDeath", + "_test_cocoon_2", + "PermaDeath_Unlock", + "_test_cocoon_1", + "GG_Waterways", + "GG_Atrium", + "GG_Broken_Vessel", + "GG_Brooding_Mawlek", + "GG_Collector", + "GG_Crystal_Guardian", + "GG_Crystal_Guardian_2", + "GG_Dung_Defender", + "GG_Failed_Champion", + "GG_False_Knight", + "GG_Flukemarm", + "GG_Ghost_Galien", + "GG_Ghost_Gorb", + "GG_Ghost_Hu", + "GG_Ghost_Markoth", + "GG_Ghost_Marmu", + "GG_Ghost_No_Eyes", + "GG_Ghost_Xero", + "GG_God_Tamer", + "GG_Grey_Prince_Zote", + "GG_Grimm", + "GG_Grimm_Nightmare", + "GG_Gruz_Mother", + "GG_Hive_Knight", + "GG_Hollow_Knight", + "GG_Hornet_1", + "GG_Hornet_2", + "GG_Lost_Kin", + "GG_Lurker", + "GG_Mantis_Lords", + "GG_Mega_Moss_Charger", + "GG_Nailmasters", + "GG_Nosk", + "GG_Oblobbles", + "GG_Painter", + "GG_Pipeway", + "GG_Radiance", + "GG_Sly", + "GG_Soul_Master", + "GG_Soul_Tyrant", + "GG_Spa", + "GG_Traitor_Lord", + "GG_Unlock", + "GG_Uumuu", + "GG_Vengefly", + "GG_Watcher_Knights", + "GG_White_Defender", + "GG_Workshop", + "Room_GG_Shortcut", + "GG_End_Sequence", + "GG_Atrium_Roof", + "GG_Blue_Room", + "GG_Engine", + "GG_Engine_Prime", + "GG_Engine_Root", + "GG_Mage_Knight", + "GG_Vengefly_V", + "GG_Entrance_Cutscene", + "GG_Mighty_Zote", + "GG_Land_of_Storms", + "GG_Boss_Door_Entrance", + "GG_Gruz_Mother_V", + "GG_Brooding_Mawlek_V", + "GG_Mantis_Lords_V", + "GG_Nosk_Hornet", + "GG_Uumuu_V", + "GG_Ghost_Gorb_V", + "GG_Ghost_Markoth_V", + "GG_Ghost_Marmu_V", + "GG_Ghost_No_Eyes_V", + "GG_Ghost_Xero_V", + "GG_Mage_Knight_V", + "GG_Collector_V", + "GG_Nosk_V", + "GG_Wyrm", + "GG_Unn", + "GG_Door_5_Finale", + "GG_Unlock_Wastes", + "Abyss_06_Core_b", + "Abyss_18_b", + "Cliffs_01_b", + "Cliffs_02_b", + "Cliffs_06_b", + "Crossroads_04_b", + "Crossroads_18_b", + "Crossroads_21_b", + "Crossroads_35_b", + "Deepnest_26_b", + "Deepnest_30_b", + "Deepnest_41_b", + "Deepnest_43_b", + "Deepnest_44_b", + "Deepnest_East_14a", + "Deepnest_East_02_b", + "Deepnest_East_09_b", + "Deepnest_East_Hornet_b", + "Fungus1_09_b", + "Fungus1_14_b", + "Fungus1_28_b", + "Fungus2_14_b", + "Fungus2_14_c", + "Fungus2_29_b", + "Fungus3_22_b", + "Fungus3_23_b", + "Fungus3_48_bot", + "Fungus3_48_left", + "Fungus3_48_top", + "Hive_01_b", + "Hive_03_b", + "Hive_04_b", + "Mines_20_b", + "Mines_28_b", + "RestingGrounds_10_b", + "RestingGrounds_10_c", + "RestingGrounds_10_d", + "Ruins1_18_b", + "Ruins1_31_top", + "Ruins1_31_top_2", + "Ruins2_07_left", + "Ruins2_07_right", + "Waterways_02_b", + "Waterways_04_part_b", + "Intro_Cutscene_Prologue", + "Prologue_Excerpt", + "Intro_Cutscene", + "Dream_NailCollection", + "RestBench", + "BoneBench", + "RestBench (1)", + "RestBench Return", + "WhiteBench", + "Death Respawn Marker", + "Gruz Boss Scene", + "False Knight Boss Scene", + "Mega Moss Charger Boss Scene", + "Hornet 1 Boss Scene", + "Gorb Boss Scene", + "White Defender Boss Scene", + "Dung Defender Boss Scene", + "Brooding Mawlek Boss Scene", + "Nailmaster Oro Mato Boss Scene", + "Xero Boss Scene", + "Crystal Guardian Boss Scene", + "Soul Master Boss Scene", + "Marmu Boss Scene", + "Nosk Boss Scene", + "Flukemarm Boss Scene", + "Broken Vessel Boss Scene", + "Paintmaster Boss Scene", + "Hive Knight Boss Scene", + "Elder Hu Boss Scene", + "Grimm Boss Scene", + "Galien Boss Scene", + "Hornet 2 Boss Scene", + "Nailsage Sly Boss Scene", + "Crystal Guardian 2 Boss Scene", + "Lost Kin Boss Scene", + "Failed Champion Boss Scene", + "Nosk Boss Scene V", + "Traitor Lord Boss Scene", + "No Eyes Boss Scene", + "Markoth Boss Scene", + "Nightmare Grimm Boss Scene", + "Soul Tyrant Boss Scene", + "Hollow Knight Boss Scene", + "Mantis Lords Boss Scene", + "Mantis Lords Boss Scene V", + "Watcher Knights Boss Scene", + "Uumuu Boss Scene", + "Nosk Hornet Boss Scene", + "Radiance Boss Scene", + "Oblobbles Boss Scene", + "God Tamer Boss Scene", + "Collector Boss Scene", + "NONE", + "TEST_AREA", + "KINGS_PASS", + "CLIFFS", + "TOWN", + "CROSSROADS", + "GREEN_PATH", + "ROYAL_GARDENS", + "FOG_CANYON", + "WASTES", + "DEEPNEST", + "HIVE", + "BONE_FOREST", + "PALACE_GROUNDS", + "MINES", + "RESTING_GROUNDS", + "CITY", + "DREAM_WORLD", + "COLOSSEUM", + "ABYSS", + "ROYAL_QUARTER", + "WHITE_PALACE", + "SHAMAN_TEMPLE", + "WATERWAYS", + "QUEENS_STATION", + "OUTSKIRTS", + "KINGS_STATION", + "MAGE_TOWER", + "TRAM_UPPER", + "TRAM_LOWER", + "FINAL_BOSS", + "SOUL_SOCIETY", + "ACID_LAKE", + "NOEYES_TEMPLE", + "MONOMON_ARCHIVE", + "MANTIS_VILLAGE", + "RUINED_TRAMWAY", + "DISTANT_VILLAGE", + "ABYSS_DEEP", + "ISMAS_GROVE", + "WYRMSKIN", + "LURIENS_TOWER", + "LOVE_TOWER", + "GLADE", + "BLUE_LAKE", + "PEAK", + "JONI_GRAVE", + "OVERGROWN_MOUND", + "CRYSTAL_MOUND", + "BEASTS_DEN", + "GODS_GLORY", + "GODSEEKER_WASTE" +] diff --git a/HKMP/Serialization/BossSequenceDoorCompletion.cs b/HKMP/Serialization/BossSequenceDoorCompletion.cs new file mode 100644 index 00000000..6188291c --- /dev/null +++ b/HKMP/Serialization/BossSequenceDoorCompletion.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Hkmp.Serialization; + +/// +/// Class that mirrors BossSequenceDoor.Completion from HK to allow (de)serialization on the server side (including +/// the standalone server). +/// +public class BossSequenceDoorCompletion { + [JsonProperty("canUnlock")] + public bool CanUnlock { get; set; } + [JsonProperty("unlocked")] + public bool Unlocked { get; set; } + [JsonProperty("completed")] + public bool Completed { get; set; } + [JsonProperty("allBindings")] + public bool AllBindings { get; set; } + [JsonProperty("noHits")] + public bool NoHits { get; set; } + [JsonProperty("boundNail")] + public bool BoundNail { get; set; } + [JsonProperty("boundShell")] + public bool BoundShell { get; set; } + [JsonProperty("boundCharms")] + public bool BoundCharms { get; set; } + [JsonProperty("boundSoul")] + public bool BoundSoul { get; set; } + [JsonProperty("viewedBossSceneCompletions")] + public List ViewedBossSceneCompletions { get; set; } + + /// + /// Explicit conversion from the internal type to this type. + /// + /// The internal-typed instance. + /// The converted instance of this type. + public static explicit operator BossSequenceDoorCompletion(BossSequenceDoor.Completion bsdCompletion) { + return new BossSequenceDoorCompletion { + CanUnlock = bsdCompletion.canUnlock, + Unlocked = bsdCompletion.unlocked, + Completed = bsdCompletion.completed, + AllBindings = bsdCompletion.allBindings, + NoHits = bsdCompletion.noHits, + BoundNail = bsdCompletion.boundNail, + BoundShell = bsdCompletion.boundShell, + BoundCharms = bsdCompletion.boundCharms, + BoundSoul = bsdCompletion.boundSoul, + ViewedBossSceneCompletions = bsdCompletion.viewedBossSceneCompletions == null + ? [] + : [..bsdCompletion.viewedBossSceneCompletions] + }; + } + + /// + /// Explicit conversion from this type to the internal type. + /// + /// The instance of this type. + /// The converted instance of the internal type. + public static explicit operator BossSequenceDoor.Completion(BossSequenceDoorCompletion bsdCompletion) { + return new BossSequenceDoor.Completion { + canUnlock = bsdCompletion.CanUnlock, + unlocked = bsdCompletion.Unlocked, + completed = bsdCompletion.Completed, + allBindings = bsdCompletion.AllBindings, + noHits = bsdCompletion.NoHits, + boundNail = bsdCompletion.BoundNail, + boundShell = bsdCompletion.BoundShell, + boundCharms = bsdCompletion.BoundCharms, + boundSoul = bsdCompletion.BoundSoul, + viewedBossSceneCompletions = bsdCompletion.ViewedBossSceneCompletions == null + ? [] + : [..bsdCompletion.ViewedBossSceneCompletions] + }; + } +} diff --git a/HKMP/Serialization/BossStatueCompletion.cs b/HKMP/Serialization/BossStatueCompletion.cs new file mode 100644 index 00000000..6d332183 --- /dev/null +++ b/HKMP/Serialization/BossStatueCompletion.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +namespace Hkmp.Serialization; + +/// +/// Class that mirrors BossStatue.Completion from HK to allow (de)serialization on the server side (including the +/// standalone server. +/// +public class BossStatueCompletion { + [JsonProperty("hasBeenSeen")] + public bool HasBeenSeen { get; set; } + [JsonProperty("isUnlocked")] + public bool IsUnlocked { get; set; } + [JsonProperty("completedTier1")] + public bool CompletedTier1 { get; set; } + [JsonProperty("completedTier2")] + public bool CompletedTier2 { get; set; } + [JsonProperty("completedTier3")] + public bool CompletedTier3 { get; set; } + [JsonProperty("seenTier3Unlock")] + public bool SeenTier3Unlock { get; set; } + [JsonProperty("usingAltVersion")] + public bool UsingAltVersion { get; set; } + + /// + /// Explicit conversion from the internal type to this type. + /// + /// The internal-typed instance. + /// The converted instance of this type. + public static explicit operator BossStatueCompletion(BossStatue.Completion bsCompletion) { + return new BossStatueCompletion { + HasBeenSeen = bsCompletion.hasBeenSeen, + IsUnlocked = bsCompletion.isUnlocked, + CompletedTier1 = bsCompletion.completedTier1, + CompletedTier2 = bsCompletion.completedTier2, + CompletedTier3 = bsCompletion.completedTier3, + SeenTier3Unlock = bsCompletion.seenTier3Unlock, + UsingAltVersion = bsCompletion.usingAltVersion + }; + } + + /// + /// Explicit conversion from this type to the internal type. + /// + /// The instance of this type. + /// The converted instance of the internal type. + public static explicit operator BossStatue.Completion(BossStatueCompletion bsCompletion) { + return new BossStatue.Completion { + hasBeenSeen = bsCompletion.HasBeenSeen, + isUnlocked = bsCompletion.IsUnlocked, + completedTier1 = bsCompletion.CompletedTier1, + completedTier2 = bsCompletion.CompletedTier2, + completedTier3 = bsCompletion.CompletedTier3, + seenTier3Unlock = bsCompletion.SeenTier3Unlock, + usingAltVersion = bsCompletion.UsingAltVersion + }; + } +} diff --git a/HKMP/Serialization/MapZone.cs b/HKMP/Serialization/MapZone.cs new file mode 100644 index 00000000..b5fdad12 --- /dev/null +++ b/HKMP/Serialization/MapZone.cs @@ -0,0 +1,92 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Hkmp.Serialization; + +/// +/// Class that encompasses the MapZone enum from HK to allow (de)serialization on the server side (including the +/// standalone server). +/// +[JsonConverter(typeof(MapZoneConverter))] +public class MapZone { + /// + /// The (raw) byte value that represents the MapZone. + /// + private byte Value { get; set; } + + /// + /// Explicit conversion from the internal type to this type. + /// + /// The internal-typed instance. + /// The converted instance of this type. + public static explicit operator MapZone(GlobalEnums.MapZone mapZone) { + return new MapZone { + Value = (byte) mapZone + }; + } + + /// + /// Explicit conversion from this type to the internal type. + /// + /// The instance of this type. + /// The converted instance of the internal type. + public static explicit operator GlobalEnums.MapZone(MapZone mapZone) { + return (GlobalEnums.MapZone) mapZone.Value; + } + + /// + /// Explicit conversion from a byte to this type. + /// + /// The byte. + /// The converted instance of this type. + public static explicit operator MapZone(byte b) { + return new MapZone { + Value = b + }; + } + + /// + /// Explicit conversion from this type to a byte. + /// + /// The instance of this type. + /// The converted byte. + public static explicit operator byte(MapZone mapZone) { + return mapZone.Value; + } + + /// + /// JSON converter class to handle converting MapZone values into and from JSON. + /// + public class MapZoneConverter : JsonConverter { + /// + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { + if (value == null) { + return; + } + + var mapZone = (MapZone) value; + + var jValue = new JValue(mapZone.Value); + jValue.WriteTo(writer); + } + + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { + var jToken = JToken.Load(reader); + + if (jToken is JValue { Value: long longValue and >= 0 and <= 255 }) { + return new MapZone { + Value = (byte) longValue + }; + } + + throw new JsonSerializationException("Could not read JSON for MapZone"); + } + + /// + public override bool CanConvert(Type objectType) { + return objectType == typeof(MapZone); + } + } +} diff --git a/HKMP/Ui/Chat/ChatBox.cs b/HKMP/Ui/Chat/ChatBox.cs index 90e5d7c5..580f5840 100644 --- a/HKMP/Ui/Chat/ChatBox.cs +++ b/HKMP/Ui/Chat/ChatBox.cs @@ -7,6 +7,8 @@ using Hkmp.Ui.Resources; using Hkmp.Util; using UnityEngine; +using UnityEngine.UI; +using Logger = Hkmp.Logging.Logger; using Object = UnityEngine.Object; namespace Hkmp.Ui.Chat; @@ -169,23 +171,54 @@ private void CheckKeyBinds(ModSettings modSettings) { if (InputHandler.Instance.inputActions.pause.WasPressed) { HideChatInput(); } - } else if (Input.GetKeyDown(modSettings.OpenChatKey)) { + } else if (modSettings.Keybinds.OpenChat.IsPressed) { var gameManager = GameManager.instance; + if (gameManager == null) { + Logger.Debug("Could not open chat, GM is null"); + return; + } + + var gameState = gameManager.gameState; + if (gameState != GameState.PLAYING && gameState != GameState.MAIN_MENU) { + Logger.Debug($"Could not open chat, game state is incorrect: {gameState}"); + return; + } + var uiManager = UIManager.instance; + if (uiManager == null) { + Logger.Debug("Could not open chat, UIM is null"); + return; + } + + var uiState = uiManager.uiState; + if (uiState != UIState.PLAYING && uiState != UIState.MAIN_MENU_HOME) { + Logger.Debug($"Could not open chat, UI state is incorrect: {uiState}"); + return; + } + var heroController = HeroController.instance; - if (gameManager == null - || uiManager == null - || gameManager.gameState != GameState.PLAYING - || uiManager.uiState != UIState.PLAYING - // If the hero is charging their nail and chat opens, it will cause a flashing effect - || (heroController != null && heroController.cState.nailCharging) - // If we are in the inventory, opening the chat has side-effects, such as floating - || IsInventoryOpen() - // If we are in a godhome menu, we will soft-lock opening the chat - || IsGodHomeMenuOpen() - ) { + if (heroController != null && heroController.cState.nailCharging) { + Logger.Debug("Could not open chat, player is charging nail"); + return; + } + + if (gameState == GameState.PLAYING && IsInventoryOpen()) { + Logger.Debug("Could not open chat, inventory is open"); return; } + + if (IsGodHomeMenuOpen()) { + Logger.Debug("Could not open chat, GodHome menu is open"); + return; + } + + foreach (var selectable in Selectable.allSelectablesArray) { + var inputField = selectable.gameObject.GetComponent(); + if (inputField && inputField.isFocused) { + Logger.Debug("Could not open chat, another input field is focused currently"); + return; + } + } _isOpen = true; diff --git a/HKMP/Ui/ConnectInterface.cs b/HKMP/Ui/ConnectInterface.cs index 2f33bdc9..d910f1b8 100644 --- a/HKMP/Ui/ConnectInterface.cs +++ b/HKMP/Ui/ConnectInterface.cs @@ -4,7 +4,6 @@ using Hkmp.Game.Settings; using Hkmp.Networking.Client; using Hkmp.Ui.Component; -using Hkmp.Ui.Resources; using Hkmp.Util; using UnityEngine; using Logger = Hkmp.Logging.Logger; @@ -15,11 +14,6 @@ namespace Hkmp.Ui; /// Class for creating and managing the connect interface. /// internal class ConnectInterface { - /// - /// The address to connect to the local device. - /// - private const string LocalhostAddress = "127.0.0.1"; - /// /// The indent of some text elements. /// @@ -35,21 +29,11 @@ internal class ConnectInterface { /// private const string ConnectingText = "Connecting..."; - /// - /// The text of the connection button while connected. - /// - private const string DisconnectText = "Disconnect"; - /// /// The text of the host button while not hosting. /// private const string StartHostingText = "Start Hosting"; - /// - /// The text of the host button while hosting. - /// - private const string StopHostingText = "Stop Hosting"; - /// /// The time in seconds to hide the feedback text after it appeared. /// @@ -65,10 +49,10 @@ internal class ConnectInterface { /// private readonly ComponentGroup _connectGroup; - /// - /// The component group of the client settings UI. - /// - private readonly ComponentGroup _settingsGroup; + // /// + // /// The component group of the client settings UI. + // /// + // private readonly ComponentGroup _settingsGroup; /// /// The username input component. @@ -110,30 +94,20 @@ internal class ConnectInterface { /// public event Action ConnectButtonPressed; - /// - /// Event that is executed when the disconnect button is pressed. - /// - public event Action DisconnectButtonPressed; - /// /// Event that is executed when the start hosting button is pressed. /// - public event Action StartHostButtonPressed; - - /// - /// The event that is executed when the stop hosting button is pressed. - /// - public event Action StopHostButtonPressed; + public event Action StartHostButtonPressed; public ConnectInterface( ModSettings modSettings, - ComponentGroup connectGroup, - ComponentGroup settingsGroup + ComponentGroup connectGroup + // ComponentGroup settingsGroup ) { _modSettings = modSettings; _connectGroup = connectGroup; - _settingsGroup = settingsGroup; + // _settingsGroup = settingsGroup; CreateConnectUi(); } @@ -154,9 +128,8 @@ public void OnSuccessfulConnect() { // Let the user know that the connection was successful SetFeedbackText(Color.green, "Successfully connected"); - // Reset the connection button with the disconnect text and callback - _connectionButton.SetText(DisconnectText); - _connectionButton.SetOnPress(OnDisconnectButtonPressed); + // Reset the connection button with the disconnect text + _connectionButton.SetText(ConnectText); _connectionButton.SetInteractable(true); } @@ -164,29 +137,25 @@ public void OnSuccessfulConnect() { /// Callback method for when the client fails to connect. /// /// The result of the failed connection. - public void OnFailedConnect(ConnectFailedResult result) { + public void OnFailedConnect(ConnectionFailedResult result) { // Let the user know that the connection failed based on the result - switch (result.Type) { - case ConnectFailedResult.FailType.NotWhiteListed: - SetFeedbackText(Color.red, "Failed to connect:\nNot whitelisted"); - break; - case ConnectFailedResult.FailType.Banned: - SetFeedbackText(Color.red, "Failed to connect:\nBanned from server"); - break; - case ConnectFailedResult.FailType.InvalidAddons: + switch (result.Reason) { + case ConnectionFailedReason.InvalidAddons: SetFeedbackText(Color.red, "Failed to connect:\nInvalid addons"); break; - case ConnectFailedResult.FailType.InvalidUsername: - SetFeedbackText(Color.red, "Failed to connect:\nInvalid username"); - break; - case ConnectFailedResult.FailType.SocketException: + case ConnectionFailedReason.SocketException: + case ConnectionFailedReason.IOException: SetFeedbackText(Color.red, "Failed to connect:\nInternal error"); break; - case ConnectFailedResult.FailType.TimedOut: + case ConnectionFailedReason.TimedOut: SetFeedbackText(Color.red, "Failed to connect:\nConnection timed out"); break; - case ConnectFailedResult.FailType.Unknown: - SetFeedbackText(Color.red, "Failed to connect:\nUnknown reason"); + case ConnectionFailedReason.Other: + var message = ((ConnectionFailedMessageResult) result).Message; + SetFeedbackText(Color.red, $"Failed to connect:\n{message}"); + break; + default: + SetFeedbackText(Color.red, $"Failed to connect:\nUnknown reason"); break; } @@ -201,21 +170,12 @@ public void OnFailedConnect(ConnectFailedResult result) { private void CreateConnectUi() { // Now we can start adding individual components to our UI // Keep track of current x and y of objects we want to place - var x = 1920f - 210f; - var y = 1080f - 100f; + var x = 1920f / 2f; + var y = 1080f - 400f; const float labelHeight = 20f; - const float logoHeight = 74f; - - new ImageComponent( - _connectGroup, - new Vector2(x, y), - new Vector2(240f, logoHeight), - TextureManager.HkmpLogo - ); - - y -= logoHeight / 2f + 20f; + // ReSharper disable once ObjectCreationAsStatement new TextComponent( _connectGroup, new Vector2(x + TextIndentWidth, y), @@ -238,6 +198,7 @@ private void CreateConnectUi() { y -= InputComponent.DefaultHeight + 20f; + // ReSharper disable once ObjectCreationAsStatement new TextComponent( _connectGroup, new Vector2(x + TextIndentWidth, y), @@ -286,18 +247,6 @@ private void CreateConnectUi() { y -= ButtonComponent.DefaultHeight + 8f; - var settingsButton = new ButtonComponent( - _connectGroup, - new Vector2(x, y), - "Settings" - ); - settingsButton.SetOnPress(() => { - _connectGroup.SetActive(false); - _settingsGroup.SetActive(true); - }); - - y -= ButtonComponent.DefaultHeight + 8f; - _feedbackText = new TextComponent( _connectGroup, new Vector2(x, y), @@ -335,14 +284,7 @@ private void OnConnectButtonPressed() { Logger.Debug($"Connect button pressed, address: {address}:{port}"); - var username = _usernameInput.GetInput(); - if (username.Length == 0 || username.Length > ServerManager.UsernameMaxLength) { - if (username.Length > ServerManager.UsernameMaxLength) { - SetFeedbackText(Color.red, "Failed to connect:\nUsername is too long"); - } else if (username.Length == 0) { - SetFeedbackText(Color.red, "Failed to connect:\nYou must enter a username"); - } - + if (!ValidateUsername(out var username)) { return; } @@ -361,21 +303,6 @@ private void OnConnectButtonPressed() { ConnectButtonPressed?.Invoke(address, port, username); } - /// - /// Callback method for when the disconnect button is pressed. - /// - private void OnDisconnectButtonPressed() { - // Disconnect the client - DisconnectButtonPressed?.Invoke(); - - // Let the user know that the connection was successful - SetFeedbackText(Color.green, "Successfully disconnected"); - - _connectionButton.SetText(ConnectText); - _connectionButton.SetOnPress(OnConnectButtonPressed); - _connectionButton.SetInteractable(true); - } - /// /// Callback method for when the start hosting button is pressed. /// @@ -389,40 +316,13 @@ private void OnStartButtonPressed() { return; } - - // Start the server in networkManager - StartHostButtonPressed?.Invoke(port); - - _serverButton.SetText(StopHostingText); - _serverButton.SetOnPress(OnStopButtonPressed); - - // If the setting for automatically connecting when hosting is enabled, - // we connect the client to itself as well - if (_modSettings.AutoConnectWhenHosting) { - _addressInput.SetInput(LocalhostAddress); - - OnConnectButtonPressed(); - - // Let the user know that the server has been started - SetFeedbackText(Color.green, "Successfully connected to hosted server"); - } else { - // Let the user know that the server has been started - SetFeedbackText(Color.green, "Successfully started server"); + + if (!ValidateUsername(out var username)) { + return; } - } - - /// - /// Callback method for when the stop hosting button is pressed. - /// - private void OnStopButtonPressed() { - // Stop the server in networkManager - StopHostButtonPressed?.Invoke(); - - _serverButton.SetText(StartHostingText); - _serverButton.SetOnPress(OnStartButtonPressed); - // Let the user know that the server has been stopped - SetFeedbackText(Color.green, "Successfully stopped server"); + // Start the server in networkManager + StartHostButtonPressed?.Invoke(username, port); } /// @@ -451,4 +351,20 @@ private IEnumerator WaitHideFeedbackText() { _feedbackText.SetActive(false); } + + private bool ValidateUsername(out string username) { + username = _usernameInput.GetInput(); + + if (username.Length == 0) { + SetFeedbackText(Color.red, "Failed to connect:\nYou must enter a username"); + return false; + } + + if (username.Length > ServerManager.UsernameMaxLength) { + SetFeedbackText(Color.red, "Failed to connect:\nUsername is too long"); + return false; + } + + return true; + } } diff --git a/HKMP/Ui/Resources/Images/button_background_active.png b/HKMP/Ui/Resources/Images/button_background_active.png index b3be77c5..bebd6e10 100644 Binary files a/HKMP/Ui/Resources/Images/button_background_active.png and b/HKMP/Ui/Resources/Images/button_background_active.png differ diff --git a/HKMP/Ui/Resources/Images/button_background_disabled.png b/HKMP/Ui/Resources/Images/button_background_disabled.png index 28eaaef6..c41866e5 100644 Binary files a/HKMP/Ui/Resources/Images/button_background_disabled.png and b/HKMP/Ui/Resources/Images/button_background_disabled.png differ diff --git a/HKMP/Ui/Resources/Images/button_background_hover.png b/HKMP/Ui/Resources/Images/button_background_hover.png index 81e789a2..46fc5ce9 100644 Binary files a/HKMP/Ui/Resources/Images/button_background_hover.png and b/HKMP/Ui/Resources/Images/button_background_hover.png differ diff --git a/HKMP/Ui/Resources/Images/button_background_neutral.png b/HKMP/Ui/Resources/Images/button_background_neutral.png index 59cb73ac..0f9b4620 100644 Binary files a/HKMP/Ui/Resources/Images/button_background_neutral.png and b/HKMP/Ui/Resources/Images/button_background_neutral.png differ diff --git a/HKMP/Ui/Resources/Images/input_field_background_active.png b/HKMP/Ui/Resources/Images/input_field_background_active.png index 785ffc14..754e7a11 100644 Binary files a/HKMP/Ui/Resources/Images/input_field_background_active.png and b/HKMP/Ui/Resources/Images/input_field_background_active.png differ diff --git a/HKMP/Ui/Resources/Images/input_field_background_disabled.png b/HKMP/Ui/Resources/Images/input_field_background_disabled.png index 0494959c..3171430c 100644 Binary files a/HKMP/Ui/Resources/Images/input_field_background_disabled.png and b/HKMP/Ui/Resources/Images/input_field_background_disabled.png differ diff --git a/HKMP/Ui/Resources/Images/input_field_background_hover.png b/HKMP/Ui/Resources/Images/input_field_background_hover.png index 988dd37a..f737c4f5 100644 Binary files a/HKMP/Ui/Resources/Images/input_field_background_hover.png and b/HKMP/Ui/Resources/Images/input_field_background_hover.png differ diff --git a/HKMP/Ui/Resources/Images/input_field_background_neutral.png b/HKMP/Ui/Resources/Images/input_field_background_neutral.png index 5c82f14d..846020f6 100644 Binary files a/HKMP/Ui/Resources/Images/input_field_background_neutral.png and b/HKMP/Ui/Resources/Images/input_field_background_neutral.png differ diff --git a/HKMP/Ui/UiManager.cs b/HKMP/Ui/UiManager.cs index 9941af0f..e9458765 100644 --- a/HKMP/Ui/UiManager.cs +++ b/HKMP/Ui/UiManager.cs @@ -1,14 +1,20 @@ -using GlobalEnums; +using System; +using System.Collections; +using System.Collections.Generic; +using GlobalEnums; using Hkmp.Api.Client; using Hkmp.Game.Settings; using Hkmp.Networking.Client; using Hkmp.Ui.Chat; using Hkmp.Util; using Modding; +using Modding.Menu; +using Modding.Menu.Config; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; using Logger = Hkmp.Logging.Logger; +using Object = UnityEngine.Object; namespace Hkmp.Ui; @@ -35,6 +41,29 @@ internal class UiManager : IUiManager { /// The font size of sub text. /// public const int SubTextFontSize = 22; + + /// + /// The address to connect to the local device. + /// + private const string LocalhostAddress = "127.0.0.1"; + + /// + /// Expression for the GameManager instance. + /// + // ReSharper disable once InconsistentNaming + private static GameManager GM => GameManager.instance; + + /// + /// Expression for the UIManager instance. + /// + // ReSharper disable once InconsistentNaming + private static UIManager UM => UIManager.instance; + + /// + /// Expression for the InputHandler instance. + /// + // ReSharper disable once InconsistentNaming + private static InputHandler IH => InputHandler.Instance; /// /// The global GameObject in which all UI is created. @@ -45,37 +74,69 @@ internal class UiManager : IUiManager { /// The chat box instance. /// internal static ChatBox InternalChatBox; + + /// + /// Event that is fired when a server is requested to be hosted from the UI. + /// + public event Action RequestServerStartHostEvent; /// - /// The connect interface. + /// Event that is fired when a server is requested to be stopped. /// - public ConnectInterface ConnectInterface { get; } + public event Action RequestServerStopHostEvent; /// - /// The client settings interface. + /// Event that is fired when a connection is requested with the given username, IP, port and whether it was a + /// connection from hosting. /// - public ClientSettingsInterface SettingsInterface { get; } + public event Action RequestClientConnectEvent; /// - /// The mod settings. + /// Event that is fired when a disconnect is requested. + /// + public event Action RequestClientDisconnectEvent; + + /// + /// The mod settings for HKMP. /// private readonly ModSettings _modSettings; + /// + /// The net client to check if we are connected to a server or not. + /// + private readonly NetClient _netClient; + + /// + /// The connect interface. + /// + private ConnectInterface _connectInterface; + + /// + /// The menu screen for the connection UI. + /// + private MenuScreen _connectMenu; + /// /// The ping interface. /// - private readonly PingInterface _pingInterface; + private PingInterface _pingInterface; + + /// + /// The group that controls the connection UI. + /// + private ComponentGroup _connectGroup; /// - /// Whether the UI is hidden by the key-bind. + /// List of event trigger entries for the original back triggers for the save selection screen. These triggers are + /// stored when they are override to get back to our connection menu. /// - private bool _isUiHiddenByKeyBind; + private List _originalBackTriggers; /// - /// Whether the game is in a state where we normally show the pause menu UI for example in a gameplay - /// scene in the HK pause menu. + /// Callback action to execute when save slot selection is finished. The boolean parameter indicates whether a + /// save slot was selected (true) or the menu was exited through the back button (false). /// - private bool _canShowPauseUi; + private Action _saveSlotSelectedAction; #endregion @@ -86,13 +147,15 @@ internal class UiManager : IUiManager { #endregion - public UiManager( - ServerSettings clientServerSettings, - ModSettings modSettings, - NetClient netClient - ) { + public UiManager(ModSettings modSettings, NetClient netClient) { _modSettings = modSettings; - + _netClient = netClient; + } + + /// + /// Initialize the UI manager by creating UI and register various hooks. + /// + public void Initialize() { // First we create a gameObject that will hold all other objects of the UI UiGameObject = new GameObject(); @@ -114,6 +177,7 @@ NetClient netClient var canvasScaler = UiGameObject.AddComponent(); canvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; canvasScaler.referenceResolution = new Vector2(1920f, 1080f); + canvasScaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand; UiGameObject.AddComponent(); @@ -121,38 +185,25 @@ NetClient netClient var uiGroup = new ComponentGroup(); - var pauseMenuGroup = new ComponentGroup(false, uiGroup); - - var connectGroup = new ComponentGroup(parent: pauseMenuGroup); + _connectGroup = new ComponentGroup(false); - var settingsGroup = new ComponentGroup(parent: pauseMenuGroup); - - ConnectInterface = new ConnectInterface( - modSettings, - connectGroup, - settingsGroup + _connectInterface = new ConnectInterface( + _modSettings, + _connectGroup ); var inGameGroup = new ComponentGroup(parent: uiGroup); var infoBoxGroup = new ComponentGroup(parent: inGameGroup); - InternalChatBox = new ChatBox(infoBoxGroup, modSettings); + InternalChatBox = new ChatBox(infoBoxGroup, _modSettings); var pingGroup = new ComponentGroup(parent: inGameGroup); _pingInterface = new PingInterface( pingGroup, - modSettings, - netClient - ); - - SettingsInterface = new ClientSettingsInterface( - modSettings, - clientServerSettings, - settingsGroup, - connectGroup, - _pingInterface + _modSettings, + _netClient ); // Register callbacks to make sure the UI is hidden and shown at correct times @@ -160,122 +211,401 @@ NetClient netClient orig(self, state); if (state == UIState.PAUSED) { - // Only show UI in gameplay scenes - if (!SceneUtil.IsNonGameplayScene(SceneUtil.GetCurrentSceneName())) { - _canShowPauseUi = true; - - pauseMenuGroup.SetActive(!_isUiHiddenByKeyBind); - } - inGameGroup.SetActive(false); } else { - pauseMenuGroup.SetActive(false); - - _canShowPauseUi = false; - // Only show chat box UI in gameplay scenes if (!SceneUtil.IsNonGameplayScene(SceneUtil.GetCurrentSceneName())) { inGameGroup.SetActive(true); } } }; - UnityEngine.SceneManagement.SceneManager.activeSceneChanged += (oldScene, newScene) => { - if (SceneUtil.IsNonGameplayScene(newScene.name)) { - eventSystem.enabled = false; + UnityEngine.SceneManagement.SceneManager.activeSceneChanged += (_, newScene) => { + var isNonGamePlayScene = SceneUtil.IsNonGameplayScene(newScene.name); + + eventSystem.enabled = !isNonGamePlayScene; + inGameGroup.SetActive(!isNonGamePlayScene); + }; - _canShowPauseUi = false; + ModHooks.LanguageGetHook += (key, sheet, orig) => { + if (key == "StartMultiplayerBtn" && sheet == "MainMenu") { + return "Start Multiplayer"; + } + + if (key == "MODAL_PROGRESS" && sheet == "MainMenu" && _netClient.IsConnected) { + return "You will be disconnected"; + } - pauseMenuGroup.SetActive(false); - inGameGroup.SetActive(false); - } else { - eventSystem.enabled = true; + return orig; + }; + + On.UIManager.UIGoToMainMenu += (orig, self) => { + orig(self); - inGameGroup.SetActive(true); - } + TryAddMultiplayerScreen(); + }; + + TryAddMultiplayerScreen(); + + _connectInterface.StartHostButtonPressed += (username, port) => { + OpenSaveSlotSelection(saveSelected => { + if (!saveSelected) { + return; + } + + RequestServerStartHostEvent?.Invoke(port); + RequestClientConnectEvent?.Invoke(LocalhostAddress, port, username, true); + }); + }; + + _connectInterface.ConnectButtonPressed += (address, port, username) => { + RequestClientConnectEvent?.Invoke(address, port, username, false); + }; + + On.UIManager.ReturnToMainMenu += (orig, self) => { + RequestClientDisconnectEvent?.Invoke(); + RequestServerStopHostEvent?.Invoke(); + + return orig(self); }; - // The game is automatically unpaused when the knight dies, so we need - // to disable the UI menu manually - // TODO: this still gives issues, since it displays the cursor while we are supposed to be unpaused - ModHooks.AfterPlayerDeadHook += () => { pauseMenuGroup.SetActive(false); }; + // Hook to make sure that after game completion cutscenes we do not head to the main menu, but stay hosting/ + // connected to the server. Otherwise, if the host would go to the main menu, every other player would be + // disconnected + On.CutsceneHelper.DoSceneLoad += (orig, self) => { + if (!_netClient.IsConnected) { + orig(self); + return; + } + + var sceneName = self.gameObject.scene.name; + + Logger.Debug($"DoSceneLoad of CutsceneHelper for next scene type: {self.nextSceneType}, scene name: {sceneName}"); - MonoBehaviourUtil.Instance.OnUpdateEvent += () => { CheckKeyBinds(uiGroup); }; + var toMainMenu = self.nextSceneType.Equals(CutsceneHelper.NextScene.MainMenu) + || self.nextSceneType.Equals(CutsceneHelper.NextScene.MainMenuNoSave); + if (self.nextSceneType.Equals(CutsceneHelper.NextScene.PermaDeathUnlock)) { + toMainMenu |= GM.GetStatusRecordInt("RecPermadeathMode") != 0; + } + + if (toMainMenu) { + if (PlayerData.instance.GetInt("permadeathMode") != 0) { + // We are running Steel Soul mode, so we disconnect and go to main menu instead of reloading to + // the last save point + Logger.Debug(" NextSceneType is main menu, disconnecting because of Steel Soul"); + + RequestClientDisconnectEvent?.Invoke(); + RequestServerStopHostEvent?.Invoke(); + + orig(self); + return; + } + + Logger.Debug(" NextSceneType is main menu, transitioning to last save point instead"); + + GameManager.instance.ContinueGame(); + return; + } + + orig(self); + }; } + + /// + /// Enter the game with the current PlayerData from the multiplayer menu. This assumes that the PlayerData + /// instance is populated with values already. + /// + public void EnterGameFromMultiplayerMenu(bool newGame) { + IH.StopUIInput(); - #region Internal UI manager methods + _connectGroup.SetActive(false); + + UM.uiAudioPlayer.PlayStartGame(); + if (MenuStyles.Instance) { + MenuStyles.Instance.StopAudio(); + } + if (newGame) { + Logger.Debug("Entering game from MP menu for new game"); + GM.StartCoroutine(GM.RunStartNewGame()); + } else { + Logger.Debug("Entering game from MP menu for a continued game"); + GM.ContinueGame(); + } + } + /// - /// Callback method for when the client successfully connects. + /// Return to the main menu from in-game. Used whenever the player disconnects from the current server. /// - public void OnSuccessfulConnect() { - ConnectInterface.OnSuccessfulConnect(); - _pingInterface.SetEnabled(true); - SettingsInterface.OnSuccessfulConnect(); + public void ReturnToMainMenuFromGame() { + IH.StopUIInput(); + + UM.StartCoroutine(GM.ReturnToMainMenu( + GameManager.ReturnToMainMenuSaveModes.DontSave, + _ => { + IH.StartUIInput(); + UM.returnMainMenuPrompt.HighlightDefault(); + } + )); } /// - /// Callback method for when the client fails to connect. + /// Open the save slot selection screen (from the multiplayer connect menu) and execute the given callback when + /// a save is selected. /// - /// The result of the failed connection. - public void OnFailedConnect(ConnectFailedResult result) { - ConnectInterface.OnFailedConnect(result); - } + /// The action to execute when save slot selection is finished. The boolean parameter + /// indicates whether a save slot is selected (true) or the back button was pressed (false). Can be null, in which + /// case no callback is executed. + public void OpenSaveSlotSelection(Action callback = null) { + _saveSlotSelectedAction = SaveSlotSelectedCallback; + + On.GameManager.StartNewGame += OnStartNewGame; + On.GameManager.ContinueGame += OnContinueGame; + UM.StartCoroutine(GoToSaveMenu()); + return; + + void SaveSlotSelectedCallback(bool saveSelected) { + callback?.Invoke(saveSelected); + + On.GameManager.StartNewGame -= OnStartNewGame; + On.GameManager.ContinueGame -= OnContinueGame; + } + } + /// - /// Callback method for when the client disconnects. + /// Callback method for when a new game is started. This is used to check when to start a hosted server from + /// the save menu. /// - public void OnClientDisconnect() { - ConnectInterface.OnClientDisconnect(); - _pingInterface.SetEnabled(false); - SettingsInterface.OnDisconnect(); + private void OnStartNewGame(On.GameManager.orig_StartNewGame orig, GameManager self, bool permaDeathMode, bool bossRushMode) { + orig(self, permaDeathMode, bossRushMode); + _saveSlotSelectedAction.Invoke(true); } /// - /// Callback method for when the team setting in the changes. + /// Callback method for when a save file is continued. This is used to check when to start a hosted server from + /// the save menu. /// - public void OnTeamSettingChange() { - SettingsInterface.OnTeamSettingChange(); + private void OnContinueGame(On.GameManager.orig_ContinueGame orig, GameManager self) { + orig(self); + _saveSlotSelectedAction.Invoke(true); } /// - /// Check key-binds to show/hide the UI. + /// Try to add the "Start Multiplayer" button and multiplayer menu screen to the main menu. Will not add the button + /// or screen if they already exist. /// - /// The component group for the entire UI. - private void CheckKeyBinds(ComponentGroup uiGroup) { - if (Input.GetKeyDown((KeyCode) _modSettings.HideUiKey)) { - // Only allow UI toggling within the pause menu, otherwise the chat input might interfere - if (_canShowPauseUi) { - _isUiHiddenByKeyBind = !_isUiHiddenByKeyBind; + private void TryAddMultiplayerScreen() { + var btnParent = UM.mainMenuButtons.gameObject; + if (!btnParent) { + return; + } - Logger.Debug($"UI is now {(_isUiHiddenByKeyBind ? "hidden" : "shown")}"); + var startMultiBtn = btnParent.FindGameObjectInChildren("StartMultiplayerButton"); + if (startMultiBtn) { + FixMultiplayerButtonNavigation(startMultiBtn); + + return; + } - uiGroup.SetActive(!_isUiHiddenByKeyBind); + var startGameBtn = UM.mainMenuButtons.startButton.gameObject; + if (!startGameBtn) { + return; + } + + startMultiBtn = Object.Instantiate(startGameBtn, btnParent.transform); + if (!startMultiBtn) { + return; + } + + startMultiBtn.name = "StartMultiplayerButton"; + startMultiBtn.transform.SetSiblingIndex(1); + + var autoLocalize = startMultiBtn.GetComponent(); + autoLocalize.textKey = "StartMultiplayerBtn"; + autoLocalize.RefreshTextFromLocalization(); + + var eventTrigger = startMultiBtn.GetComponent(); + eventTrigger.triggers.Clear(); + + ChangeBtnTriggers(eventTrigger, () => UM.StartCoroutine(GoToMultiplayerMenu())); + + // Fix navigation now and when the IH can accept input again + FixMultiplayerButtonNavigation(startMultiBtn); + + UM.StartCoroutine(WaitForInput()); + IEnumerator WaitForInput() { + yield return new WaitUntil(() => IH.acceptingInput); + + FixMultiplayerButtonNavigation(startMultiBtn); + } + + _connectMenu = MenuUtils.CreateMenuBuilder("HKMP").AddControls( + new SingleContentLayout( + new AnchoredPosition( + new Vector2(0.5f, 0.5f), + new Vector2(0.5f, 0.5f), + new Vector2(0.0f, -64f) + ) + ), + c => { + Action returnAction = _ => { + UM.StartCoroutine(UM.HideCurrentMenu()); + UM.UIGoToMainMenu(); + + _connectGroup.SetActive(false); + }; + c.AddMenuButton("BackButton", new MenuButtonConfig { + Label = Language.Language.Get("NAV_BACK", "MainMenu"), + CancelAction = returnAction, + SubmitAction = returnAction, + Proceed = true + }, out _); } + ).Build(); + } + + /// + /// Fix the navigation for the multiplayer button that is added to the main menu. + /// + /// The game object for the multiplayer button. + private void FixMultiplayerButtonNavigation(GameObject multiBtnObject) { + // Fix navigation for buttons + var startMultiBtnMenuBtn = multiBtnObject.GetComponent(); + if (startMultiBtnMenuBtn) { + var nav = UM.mainMenuButtons.startButton.navigation; + nav.selectOnDown = startMultiBtnMenuBtn; + UM.mainMenuButtons.startButton.navigation = nav; + + nav = UM.mainMenuButtons.optionsButton.navigation; + nav.selectOnUp = startMultiBtnMenuBtn; + UM.mainMenuButtons.optionsButton.navigation = nav; + + nav = startMultiBtnMenuBtn.navigation; + nav.selectOnUp = UM.mainMenuButtons.startButton; + startMultiBtnMenuBtn.navigation = nav; } } + /// + /// Coroutine to go to the multiplayer menu of the main menu. + /// + private IEnumerator GoToMultiplayerMenu() { + IH.StopUIInput(); + + if (UM.menuState == MainMenuState.MAIN_MENU) { + UM.StartCoroutine(ReflectionHelper.CallMethod(UM, "FadeOutSprite", UM.gameTitle)); + UM.subtitleFSM.SendEvent("FADE OUT"); + yield return UM.StartCoroutine(UM.FadeOutCanvasGroup(UM.mainMenuScreen)); + } else if (UM.menuState == MainMenuState.SAVE_PROFILES) { + yield return UM.StartCoroutine(UM.HideSaveProfileMenu()); + } + + IH.StartUIInput(); + + yield return UM.StartCoroutine(UM.ShowMenu(_connectMenu)); + + UM.currentDynamicMenu = _connectMenu; + UM.menuState = MainMenuState.DYNAMIC_MENU; + + _connectGroup.SetActive(true); + } + + /// + /// Coroutine to go to the saves menu from the multiplayer menu. Used whenever the user selects to host a server. + /// + private IEnumerator GoToSaveMenu() { + _connectGroup.SetActive(false); + + yield return UM.HideCurrentMenu(); + yield return UM.GoToProfileMenu(); + + var saveProfilesBackBtn = UM.saveProfileControls.gameObject.FindGameObjectInChildren("BackButton"); + if (!saveProfilesBackBtn) { + Logger.Info("saveProfilesBackBtn is null"); + yield break; + } + + var eventTrigger = saveProfilesBackBtn.GetComponent(); + _originalBackTriggers = eventTrigger.triggers; + + eventTrigger.triggers = []; + ChangeBtnTriggers(eventTrigger, () => { + On.GameManager.StartNewGame -= OnStartNewGame; + On.GameManager.ContinueGame -= OnContinueGame; + + _saveSlotSelectedAction.Invoke(false); + + UM.StartCoroutine(GoToMultiplayerMenu()); + + eventTrigger.triggers = _originalBackTriggers; + }); + } + + /// + /// Change the triggers on a button with the given event trigger. + /// + /// The event trigger of the button to change. + /// The action that should be executed whenever the button is triggered. + private void ChangeBtnTriggers(EventTrigger eventTrigger, Action action) { + var entry = new EventTrigger.Entry { + eventID = EventTriggerType.Submit + }; + entry.callback.AddListener(_ => action.Invoke()); + eventTrigger.triggers.Add(entry); + + var entry2 = new EventTrigger.Entry { + eventID = EventTriggerType.PointerClick + }; + entry2.callback.AddListener(_ => action.Invoke()); + eventTrigger.triggers.Add(entry2); + } + + #region Internal UI manager methods + + /// + /// Callback method for when the client successfully connects. + /// + public void OnSuccessfulConnect() { + _connectInterface.OnSuccessfulConnect(); + _pingInterface.SetEnabled(true); + } + + /// + /// Callback method for when the client fails to connect. + /// + /// The result of the failed connection. + public void OnFailedConnect(ConnectionFailedResult result) { + _connectInterface.OnFailedConnect(result); + } + + /// + /// Callback method for when the client disconnects. + /// + public void OnClientDisconnect() { + _connectInterface.OnClientDisconnect(); + _pingInterface.SetEnabled(false); + } + #endregion #region IUiManager methods /// public void DisableTeamSelection() { - SettingsInterface.OnAddonSetTeamSelection(false); } /// public void EnableTeamSelection() { - SettingsInterface.OnAddonSetTeamSelection(true); } /// public void DisableSkinSelection() { - SettingsInterface.OnAddonSetSkinSelection(false); + // SettingsInterface.OnAddonSetSkinSelection(false); } /// public void EnableSkinSelection() { - SettingsInterface.OnAddonSetSkinSelection(true); + // SettingsInterface.OnAddonSetSkinSelection(true); } #endregion diff --git a/HKMP/Util/EncodeUtil.cs b/HKMP/Util/EncodeUtil.cs new file mode 100644 index 00000000..a487f508 --- /dev/null +++ b/HKMP/Util/EncodeUtil.cs @@ -0,0 +1,528 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Hkmp.Collection; +using Hkmp.Game.Client.Save; +using Hkmp.Game.Server.Save; +using Hkmp.Math; +using Hkmp.Serialization; +using Logger = Hkmp.Logging.Logger; + +namespace Hkmp.Util; + +/// +/// Static class to help with encoding/decoding values to/from bytes. +/// +public static class EncodeUtil { + /// + /// The file path of the embedded resource file for string data. + /// + private const string StringDataFilePath = "Hkmp.Resource.string-data.json"; + + /// + /// Bi-directional lookup that maps strings (for encoding) to their indices. + /// + private static readonly BiLookup StringIndices; + + /// + /// Static construct to load the scene indices. + /// + static EncodeUtil() { + StringIndices = new BiLookup(); + + var strings = FileUtil.LoadObjectFromEmbeddedJson>(StringDataFilePath); + ushort index = 0; + foreach (var str in strings) { + StringIndices.Add(str, index++); + } + } + + /// + /// Get a single byte for the given array of booleans where each bit represents a boolean from the array. + /// + /// An array of booleans of at most length 8. + /// A byte representing the booleans. + public static byte GetByte(bool[] bits) { + byte result = 0; + for (var i = 0; i < bits.Length; i++) { + if (bits[i]) { + result |= (byte) (1 << i); + } + } + + return result; + } + + /// + /// Get a boolean array representing the given byte where each boolean is a bit from the byte. + /// + /// A byte that contains boolean for each bit. + /// An boolean array of length 8. + public static bool[] GetBoolsFromByte(byte b) { + var result = new bool[8]; + for (var i = 0; i < result.Length; i++) { + result[i] = (b & (1 << i)) > 0; + } + + return result; + } + + /// + /// Try to get the string index corresponding to the given string for encoding/decoding purposes. + /// + /// The string. + /// The index of the string or default if the string could not be found. + /// true if there is a corresponding index for the given string, false otherwise. + public static bool TryGetStringIndex(string sceneName, out ushort index) { + return StringIndices.TryGetValue(sceneName, out index); + } + + /// + /// Try to get the string corresponding to the given string index for encoding/decoding purposes. + /// + /// The string. + /// The string or default if the string index could not be found. + /// true if there is a corresponding string for the given index, false otherwise. + public static bool TryGetStringName(ushort index, out string sceneName) { + return StringIndices.TryGetValue(index, out sceneName); + } + + /// + /// Encode a given value into a byte array in the context of save data. + /// + /// The value to encode. + /// A byte array containing the encoded value. + /// Thrown when the given value is out of range to be encoded. + /// + /// Thrown when the given value has a type that cannot be encoded due to + /// missing implementation. + public static byte[] EncodeSaveDataValue(object value) { + if (value is bool bValue) { + return [(byte) (bValue ? 1 : 0)]; + } + + if (value is float fValue) { + return BitConverter.GetBytes(fValue); + } + + if (value is int iValue) { + return BitConverter.GetBytes(iValue); + } + + if (value is string sValue) { + return EncodeString(sValue); + } + + if (value is Vector3 vecValue) { + return EncodeVector3(vecValue); + } + + if (value is List listValue) { + if (listValue.Count > ushort.MaxValue) { + throw new ArgumentOutOfRangeException($"Could not encode string list length: {listValue.Count}"); + } + + var length = (ushort) listValue.Count; + + IEnumerable byteArray = BitConverter.GetBytes(length); + + for (var i = 0; i < length; i++) { + var encoded = EncodeString(listValue[i]); + + byteArray = byteArray.Concat(encoded); + } + + return byteArray.ToArray(); + } + + if (value is BossSequenceDoorCompletion bsdCompValue) { + // For now we only encode the bools of completion struct + var firstBools = new[] { + bsdCompValue.CanUnlock, bsdCompValue.Unlocked, bsdCompValue.Completed, bsdCompValue.AllBindings, + bsdCompValue.NoHits, bsdCompValue.BoundNail, bsdCompValue.BoundShell, bsdCompValue.BoundCharms + }; + + var byte1 = GetByte(firstBools); + + var byte2 = (byte) (bsdCompValue.BoundSoul ? 1 : 0); + + return [byte1, byte2]; + } + + if (value is BossStatueCompletion bsCompValue) { + var bools = new[] { + bsCompValue.HasBeenSeen, bsCompValue.IsUnlocked, bsCompValue.CompletedTier1, bsCompValue.CompletedTier2, + bsCompValue.CompletedTier3, bsCompValue.SeenTier3Unlock, bsCompValue.UsingAltVersion + }; + + return [GetByte(bools)]; + } + + if (value is List vecListValue) { + if (vecListValue.Count > ushort.MaxValue) { + throw new ArgumentOutOfRangeException($"Could not encode vector list length: {vecListValue.Count}"); + } + + var length = (ushort) vecListValue.Count; + + IEnumerable byteArray = BitConverter.GetBytes(length); + + for (var i = 0; i < length; i++) { + var encoded = EncodeVector3(vecListValue[i]); + + byteArray = byteArray.Concat(encoded); + } + + return byteArray.ToArray(); + } + + if (value is MapZone mapZone) { + return [(byte) mapZone]; + } + + if (value is List intListValue) { + if (intListValue.Count > byte.MaxValue) { + throw new ArgumentOutOfRangeException($"Could not encode int list length: {intListValue.Count}"); + } + + var length = (byte) intListValue.Count; + + // Create a byte array for the encoded result that has the size of the length of the int list plus one + // for the length itself + var byteArray = new byte[length + 1]; + byteArray[0] = length; + + for (var i = 0; i < length; i++) { + byteArray[i + 1] = (byte) intListValue[i]; + } + + return byteArray; + } + + throw new ArgumentException($"No encoding implementation for type: {value.GetType()}"); + + // To preserve network bandwidth, we encode known strings into indices, since there is a limited number of + // strings in the save data + byte[] EncodeString(string stringValue) { + if (!TryGetStringIndex(stringValue, out var index)) { + Logger.Warn($"Could not encode string value: {stringValue}"); + throw new Exception($"Could not encode string value: {stringValue}"); + } + + return BitConverter.GetBytes(index); + } + + byte[] EncodeVector3(Vector3 vec3Value) { + return BitConverter.GetBytes(vec3Value.X) + .Concat(BitConverter.GetBytes(vec3Value.Y)) + .Concat(BitConverter.GetBytes(vec3Value.Z)) + .ToArray(); + } + } + + /// + /// Decode a given save data value from its name and encoded byte array. This only supports values from PlayerData. + /// + /// The variable name from PlayerData. + /// The encoded value as a byte array. + /// The decoded object. + /// Thrown when the given name does not correspond with a PlayerData + /// value that can be decoded, because its variable properties do not exist. + /// Thrown when the length of the given byte array does not match + /// the value that should be decoded from it. + /// Thrown when the value can not be decoded for another reason. + public static object DecodeSaveDataValue(string name, byte[] encodedValue) { + if (!SaveDataMapping.Instance.PlayerDataVarProperties.TryGetValue(name, out var varProps)) { + throw new InvalidOperationException($"Could not decode save data value with name: \"{name}\", missing variable properties"); + } + + var type = varProps.VarType; + if (type == "System.Boolean") { + if (encodedValue.Length != 1) { + throw new ArgumentOutOfRangeException($"Encoded value has incorrect value length for bool: {encodedValue.Length}"); + } + + return encodedValue[0] == 1; + } + + if (type == "System.Single") { + if (encodedValue.Length != 4) { + throw new ArgumentOutOfRangeException($"Encoded value has incorrect value length for float: {encodedValue.Length}"); + } + + return BitConverter.ToSingle(encodedValue, 0); + } + + if (type == "System.Int32") { + if (encodedValue.Length != 4) { + throw new ArgumentOutOfRangeException($"Encoded value has incorrect value length for int: {encodedValue.Length}"); + } + + return BitConverter.ToInt32(encodedValue, 0); + } + + if (type == "System.String") { + return DecodeString(encodedValue, 0); + } + + if (type == "Hkmp.Math.Vector3") { + if (encodedValue.Length != 12) { + throw new ArgumentOutOfRangeException($"Encoded value has incorrect value length for Vector3: {encodedValue.Length}"); + } + + return new Vector3( + BitConverter.ToSingle(encodedValue, 0), + BitConverter.ToSingle(encodedValue, 4), + BitConverter.ToSingle(encodedValue, 8) + ); + } + + if (type == "System.Collections.Generic.List`1[System.String]") { + var length = BitConverter.ToUInt16(encodedValue, 0); + + var list = new List(); + for (var i = 0; i < length; i++) { + var sceneIndex = BitConverter.ToUInt16(encodedValue, 2 + i * 2); + + if (!TryGetStringName(sceneIndex, out var sceneName)) { + throw new ArgumentException($"Could not decode string in list from save update: {sceneIndex}"); + } + + list.Add(sceneName); + } + + return list; + } + + if (type == "Hkmp.Serialization.BossSequenceDoorCompletion") { + var byte1 = encodedValue[0]; + var byte2 = encodedValue[1]; + + var bools = GetBoolsFromByte(byte1); + + return new BossSequenceDoorCompletion { + CanUnlock = bools[0], + Unlocked = bools[1], + Completed = bools[2], + AllBindings = bools[3], + NoHits = bools[4], + BoundNail = bools[5], + BoundShell = bools[6], + BoundCharms = bools[7], + BoundSoul = byte2 == 1 + }; + } + + if (type == "Hkmp.Serialization.BossStatueCompletion") { + var bools = GetBoolsFromByte(encodedValue[0]); + + return new BossStatueCompletion { + HasBeenSeen = bools[0], + IsUnlocked = bools[1], + CompletedTier1 = bools[2], + CompletedTier2 = bools[3], + CompletedTier3 = bools[4], + SeenTier3Unlock = bools[5], + UsingAltVersion = bools[6] + }; + } + + if (type == "System.Collections.Generic.List`1[Hkmp.Math.Vector3]") { + var length = BitConverter.ToUInt16(encodedValue, 0); + + var list = new List(); + for (var i = 0; i < length; i++) { + // Decode the floats of the vector with offset indices 2, 6, and 10 because we already read 2 + // bytes as the length. The index is multiplied by 12 as this is the length of a single float + var value = new Vector3( + BitConverter.ToSingle(encodedValue, 2 + i * 12), + BitConverter.ToSingle(encodedValue, 6 + i * 12), + BitConverter.ToSingle(encodedValue, 10 + i * 12) + ); + + list.Add(value); + } + + return list; + } + + if (type == "Hkmp.Serialization.MapZone") { + if (encodedValue.Length != 1) { + throw new ArgumentOutOfRangeException($"Encoded value has incorrect value length for MapZone: {encodedValue.Length}"); + } + + return (MapZone) encodedValue[0]; + } + + if (type == "System.Collections.Generic.List`1[System.Int32]") { + var length = encodedValue[0]; + + var list = new List(); + for (var i = 0; i < length; i++) { + list.Add(encodedValue[i + 1]); + } + + return list; + } + + throw new ArgumentException($"Could not decode type: {type}"); + + // Decode a string from the given byte array and start index in that array + string DecodeString(byte[] encoded, int startIndex) { + var sceneIndex = BitConverter.ToUInt16(encoded, startIndex); + + if (!TryGetStringName(sceneIndex, out var value)) { + throw new ArgumentException($"Could not decode string from save update: {encodedValue}"); + } + + return value; + } + } + + /// + /// Convert the given to a dictionary of raw indices and byte arrays. + /// + /// The save data to convert. + /// A dictionary of raw values. + internal static Dictionary ConvertToServerSaveData(ModSaveFile.SaveData saveData) { + // Create new dictionary for this player's specific save data + var encodedSaveData = new Dictionary(); + + // Loop through the entries in the player's save data + foreach (var entry in saveData.PlayerDataEntries) { + var name = entry.Name; + var decodedObject = entry.Value; + + //Logger.Debug($"Encoding entry: {name}, {decodedObject}"); + + CheckEncodeAddData(name, SaveDataMapping.Instance.PlayerDataIndices, decodedObject); + } + + var sceneData = saveData.SceneData; + foreach (var geoRockData in sceneData.GeoRockData) { + CheckEncodeAddData(geoRockData.GetKey(), SaveDataMapping.Instance.GeoRockIndices, geoRockData.HitsLeft); + } + + foreach (var persistentBoolData in sceneData.PersistentBoolData) { + CheckEncodeAddData( + persistentBoolData.GetKey(), + SaveDataMapping.Instance.PersistentBoolIndices, + persistentBoolData.Activated + ); + } + + foreach (var persistentIntData in sceneData.PersistentIntData) { + CheckEncodeAddData( + persistentIntData.GetKey(), + SaveDataMapping.Instance.PersistentIntIndices, + persistentIntData.Value + ); + } + + return encodedSaveData; + + void CheckEncodeAddData(TKey key, BiLookup lookup, object decodedObject) { + if (!lookup.TryGetValue(key, out var index)) { + Logger.Warn($"Could not find index for data for key: {key}"); + return; + } + + // Try to encode the value into our byte array representation + byte[] encodedValue; + try { + encodedValue = EncodeSaveDataValue(decodedObject); + } catch (Exception e) { + Logger.Warn($"Could not encode save data value of type: {decodedObject.GetType()}, exception:\n{e}"); + return; + } + + // Finally, store the value in the dictionary we created for the player + encodedSaveData[index] = encodedValue; + } + } + + /// + /// Convert the given dictionary of raw indices and byte arrays to . + /// + /// The dictionary containing raw values. + /// An instance of save data with the converted values. + internal static ModSaveFile.SaveData ConvertFromServerSaveData(Dictionary encodedSaveData) { + var saveData = new ModSaveFile.SaveData(); + + foreach (var index in encodedSaveData.Keys) { + var encodedValue = encodedSaveData[index]; + + if (CheckDecodeAddData( + index, + SaveDataMapping.Instance.PlayerDataIndices, + pdName => DecodeSaveDataValue(pdName, encodedValue), + (pdName, decodedObj) => saveData.PlayerDataEntries.Add(new ModSaveFile.PlayerDataEntry { + Name = pdName, + Value = decodedObj + }) + )) { + continue; + } + + if (CheckDecodeAddData( + index, + SaveDataMapping.Instance.GeoRockIndices, + _ => encodedValue[0], + (persistentItemData, decodedObj) => saveData.SceneData.GeoRockData.Add(new ModSaveFile.GeoRockData { + Id = persistentItemData.Id, + SceneName = persistentItemData.SceneName, + HitsLeft = decodedObj + }) + )) { + continue; + } + + if (CheckDecodeAddData( + index, + SaveDataMapping.Instance.PersistentBoolIndices, + _ => encodedValue[0] == 1, + (persistentItemData, decodedObj) => saveData.SceneData.PersistentBoolData.Add(new ModSaveFile.PersistentBoolData { + Id = persistentItemData.Id, + SceneName = persistentItemData.SceneName, + Activated = decodedObj + }) + )) { + continue; + } + + if (!CheckDecodeAddData( + index, + SaveDataMapping.Instance.PersistentIntIndices, + _ => (int) encodedValue[0], + (persistentItemData, decodedObj) => saveData.SceneData.PersistentIntData.Add( + new ModSaveFile.PersistentIntData { + Id = persistentItemData.Id, + SceneName = persistentItemData.SceneName, + Value = decodedObj + }) + )) { + Logger.Warn($"Could not decode/find key for index: {index}"); + } + } + + return saveData; + + bool CheckDecodeAddData(ushort index, BiLookup lookup, Func decodeFunc, Action addAction) { + if (!lookup.TryGetValue(index, out var key)) { + return false; + } + + TDecoded decodedObj; + try { + decodedObj = decodeFunc.Invoke(key); + } catch (Exception e) { + Logger.Warn($"Could not decode save data value with key: {key}, exception:\n{e}"); + return false; + } + + addAction.Invoke(key, decodedObj); + + return true; + } + } +} diff --git a/HKMP/Util/FileUtil.cs b/HKMP/Util/FileUtil.cs index 439492c1..7be7bc30 100644 --- a/HKMP/Util/FileUtil.cs +++ b/HKMP/Util/FileUtil.cs @@ -26,6 +26,30 @@ public static T LoadObjectFromJsonFile(string filePath) { return default; } } + + /// + /// Load an object from an embedded JSON file at the given path. + /// + /// The path of the embedded file. + /// The type of the object to load. + /// An instance of the loaded object, or the default value if it could not be loaded. + public static T LoadObjectFromEmbeddedJson(string path) { + try { + var resourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(path); + if (resourceStream == null) { + Logger.Warn($"Could not get resource stream for path: {path}"); + return default; + } + + using var streamReader = new StreamReader(resourceStream); + var fileString = streamReader.ReadToEnd(); + + return JsonConvert.DeserializeObject(fileString); + } catch (Exception e) { + Logger.Warn($"Could not read embedded resource at path: {path}, exception: {e.GetType()}, {e.Message}"); + return default; + } + } /// /// Write an object to a JSON file at the given path. diff --git a/HKMP/Util/FsmUtilExt.cs b/HKMP/Util/FsmUtilExt.cs index d0ea16b6..e97cc985 100644 --- a/HKMP/Util/FsmUtilExt.cs +++ b/HKMP/Util/FsmUtilExt.cs @@ -75,7 +75,7 @@ public static FsmState GetState(this PlayMakerFSM fsm, string stateName) { /// The name of the state. /// The FSM action to insert. /// The index at which to insert the action. - public static void InsertAction(PlayMakerFSM fsm, string stateName, FsmStateAction action, int index) { + public static void InsertAction(this PlayMakerFSM fsm, string stateName, FsmStateAction action, int index) { foreach (FsmState t in fsm.FsmStates) { if (t.Name != stateName) continue; List actions = t.Actions.ToList(); @@ -119,6 +119,30 @@ public static void RemoveAction(this PlayMakerFSM fsm, string stateName, int ind state.Actions = actions; } + + /// + /// Removes the first action in the given state of the given type from the FSM. + /// + /// The FSM. + /// The name of the state with the action to remove. + /// The type of the action to remove. + public static void RemoveFirstAction(this PlayMakerFSM fsm, string stateName) { + var state = fsm.GetState(stateName); + + var skipped = false; + state.Actions = state.Actions.Where(a => { + if (skipped) { + return true; + } + + if (a.GetType() == typeof(T)) { + skipped = true; + return false; + } + + return true; + }).ToArray(); + } } /// diff --git a/HKMP/Util/GameObjectExtensions.cs b/HKMP/Util/GameObjectExtensions.cs deleted file mode 100644 index c983b551..00000000 --- a/HKMP/Util/GameObjectExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -using UnityEngine; - -namespace Hkmp.Util; - -/// -/// Class for GameObject extensions. -/// -internal static class GameObjectExtensions { - /// - /// Find a GameObject with the given name in the children of the given GameObject. - /// - /// The GameObject to search in. - /// The name of the GameObject to search for. - /// The GameObject if found, null otherwise. - public static GameObject FindGameObjectInChildren( - this GameObject gameObject, - string name - ) { - if (gameObject == null) { - return null; - } - - foreach (var componentsInChild in gameObject.GetComponentsInChildren(true)) { - if (componentsInChild.name == name) { - return componentsInChild.gameObject; - } - } - - return null; - } -} diff --git a/HKMP/Util/GameObjectUtil.cs b/HKMP/Util/GameObjectUtil.cs new file mode 100644 index 00000000..116365e7 --- /dev/null +++ b/HKMP/Util/GameObjectUtil.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Hkmp.Util; + +/// +/// Class for GameObject utility methods and extensions. +/// +internal static class GameObjectUtil { + /// + /// Find a GameObject with the given name in the children of the given GameObject. + /// + /// The GameObject to search in. + /// The name of the GameObject to search for. + /// The GameObject if found, null otherwise. + public static GameObject FindGameObjectInChildren( + this GameObject gameObject, + string name + ) { + if (gameObject == null) { + return null; + } + + foreach (var componentsInChild in gameObject.GetComponentsInChildren(true)) { + if (componentsInChild.name == name) { + return componentsInChild.gameObject; + } + } + + return null; + } + + /// + /// Get a list of the children of the given GameObject. + /// + /// The GameObject to get the children for. + /// A list of the children of the GameObject. + public static List GetChildren(this GameObject gameObject) { + var children = new List(); + for (var i = 0; i < gameObject.transform.childCount; i++) { + children.Add(gameObject.transform.GetChild(i).gameObject); + } + + return children; + } + + /// + /// Find an inactive GameObject with the given name. + /// + /// The name of the GameObject. + /// The GameObject is it exists, null otherwise. + public static GameObject FindInactiveGameObject(string name) { + var transforms = Resources.FindObjectsOfTypeAll(); + foreach (var transform in transforms) { + if (transform.hideFlags == HideFlags.None) { + if (transform.name == name) { + return transform.gameObject; + } + } + } + + return null; + } +} diff --git a/HKMP/Util/ThreadUtil.cs b/HKMP/Util/ThreadUtil.cs index 98d74b95..2e6edae8 100644 --- a/HKMP/Util/ThreadUtil.cs +++ b/HKMP/Util/ThreadUtil.cs @@ -38,12 +38,15 @@ public static void RunActionOnMainThread(Action action) { } public void Update() { - lock (Lock) { - foreach (var action in ActionsToRun) { - action.Invoke(); - } + List actions; + lock (Lock) { + actions = new List(ActionsToRun); ActionsToRun.Clear(); } + + foreach (var action in actions) { + action.Invoke(); + } } } diff --git a/HKMP/Version.cs b/HKMP/Version.cs index e94b307e..bc55aff8 100644 --- a/HKMP/Version.cs +++ b/HKMP/Version.cs @@ -7,5 +7,5 @@ internal static class Version { /// /// The version as a string. /// - public const string String = "2.4.3"; + public const string String = "3.0.0"; } diff --git a/HKMPServer/Command/ConsoleInputManager.cs b/HKMPServer/Command/ConsoleInputManager.cs index 66df62d5..fc1977bd 100644 --- a/HKMPServer/Command/ConsoleInputManager.cs +++ b/HKMPServer/Command/ConsoleInputManager.cs @@ -1,6 +1,5 @@ using System; using System.Threading; -using System.Threading.Tasks; namespace HkmpServer.Command { /// diff --git a/HKMPServer/ConfigManager.cs b/HKMPServer/ConfigManager.cs index 44f432d4..0aba4c2f 100644 --- a/HKMPServer/ConfigManager.cs +++ b/HKMPServer/ConfigManager.cs @@ -13,31 +13,70 @@ internal static class ConfigManager { private const string ServerSettingsFileName = "serversettings.json"; /// - /// Try to load stored in the default location. If not such file can be found it - /// will return a fresh instance. - /// - /// Will be set to true if the file already exists, false if a new instance - /// has been generated. - /// An instance of . - public static ServerSettings LoadServerSettings(out bool existed) { - var serverSettingsFilePath = Path.Combine(FileUtil.GetCurrentPath(), ServerSettingsFileName); + /// The file name of the console settings file. + /// + private const string ConsoleSettingsFileName = "consolesettings.json"; + + /// + /// Try to load stored in the file with the given name. If not such file can be found it will + /// return a fresh instance. + /// + /// An instance of . + /// The name of the file that contains the settings. + /// The type of the settings class. + /// True if the file already exists, false if a new instance of has been created. + /// + private static bool LoadSettings(out T settings, string fileName) where T : new() { + var serverSettingsFilePath = Path.Combine(FileUtil.GetCurrentPath(), fileName); if (File.Exists(serverSettingsFilePath)) { - existed = true; - return FileUtil.LoadObjectFromJsonFile(serverSettingsFilePath); + settings = FileUtil.LoadObjectFromJsonFile(serverSettingsFilePath); + return true; } - existed = false; - return new ServerSettings(); + settings = new T(); + return false; } /// - /// Will save the given instance of to the default file location. + /// Will save the given instance of to file with the given name. /// - /// - public static void SaveServerSettings(ServerSettings serverSettings) { - var serverSettingsFilePath = Path.Combine(FileUtil.GetCurrentPath(), ServerSettingsFileName); + /// The instance of that should be saved to file. + /// The name of the file where the settings should be saved. + /// The type of the settings class. + private static void SaveSettings(T serverSettings, string fileName) { + var serverSettingsFilePath = Path.Combine(FileUtil.GetCurrentPath(), fileName); FileUtil.WriteObjectToJsonFile(serverSettings, serverSettingsFilePath); } + + /// + /// Load the server settings from the default location. + /// + /// An instance of . + /// True if the settings existed, false otherwise. + public static bool LoadServerSettings(out ServerSettings serverSettings) => + LoadSettings(out serverSettings, ServerSettingsFileName); + + /// + /// Save the server settings to the default location. + /// + /// The to save. + public static void SaveServerSettings(ServerSettings serverSettings) => + SaveSettings(serverSettings, ServerSettingsFileName); + + /// + /// Load the console settings from the default location. + /// + /// An instance of . + /// True if the settings existed, false otherwise. + public static bool LoadConsoleSettings(out ConsoleSettings consoleSettings) => + LoadSettings(out consoleSettings, ConsoleSettingsFileName); + + /// + /// Save the console settings to the default location. + /// + /// The to save. + public static void SaveConsoleSettings(ConsoleSettings consoleSettings) => + SaveSettings(consoleSettings, ConsoleSettingsFileName); } } diff --git a/HKMPServer/ConsoleSaveFile.cs b/HKMPServer/ConsoleSaveFile.cs new file mode 100644 index 00000000..ef0dc462 --- /dev/null +++ b/HKMPServer/ConsoleSaveFile.cs @@ -0,0 +1,52 @@ +using Hkmp.Game.Server.Save; +using Hkmp.Util; +using Newtonsoft.Json; + +namespace HkmpServer { + /// + /// Class for serialization and deserialization of save data from a standalone server to the local save file. + /// See for the representation of the same data of the running server. + /// + internal class ConsoleSaveFile : ModSaveFile { + /// + /// The global save data for the server. E.g. broken walls, open doors, etc. + /// + [JsonProperty("global_save_data")] + public SaveData GlobalSaveData { get; set; } + + public ConsoleSaveFile() { + GlobalSaveData = new SaveData(); + } + + /// + public override ServerSaveData ToServerSaveData() { + // Create new instance of server save data, which we return at the end + var serverSaveData = new ServerSaveData { + GlobalSaveData = EncodeUtil.ConvertToServerSaveData(GlobalSaveData) + }; + + foreach (var authKey in PlayerSaveData.Keys) { + serverSaveData.PlayerSaveData[authKey] = EncodeUtil.ConvertToServerSaveData(PlayerSaveData[authKey]); + } + + return serverSaveData; + } + + /// + public new static ConsoleSaveFile FromServerSaveData(ServerSaveData serverSaveData) { + // Create new instance of this class, which we return at the end + var consoleSaveFile = new ConsoleSaveFile { + GlobalSaveData = EncodeUtil.ConvertFromServerSaveData(serverSaveData.GlobalSaveData) + }; + + var playerSaveData = serverSaveData.PlayerSaveData; + foreach (var authKey in playerSaveData.Keys) { + var entries = EncodeUtil.ConvertFromServerSaveData(playerSaveData[authKey]); + // Store the entries in the player save data dictionary of the instance + consoleSaveFile.PlayerSaveData[authKey] = entries; + } + + return consoleSaveFile; + } + } +} diff --git a/HKMPServer/ConsoleServerManager.cs b/HKMPServer/ConsoleServerManager.cs index 6bd6c6ee..2065129e 100644 --- a/HKMPServer/ConsoleServerManager.cs +++ b/HKMPServer/ConsoleServerManager.cs @@ -1,10 +1,17 @@ using System; +using System.IO; +using System.Reflection; +using Hkmp.Api.Command.Server; using Hkmp.Game.Server; +using Hkmp.Game.Server.Save; using Hkmp.Game.Settings; +using Hkmp.Logging; using Hkmp.Networking.Packet; +using Hkmp.Networking.Packet.Data; using Hkmp.Networking.Server; using HkmpServer.Command; using HkmpServer.Logging; +using Newtonsoft.Json; namespace HkmpServer { /// @@ -12,17 +19,47 @@ namespace HkmpServer { /// internal class ConsoleServerManager : ServerManager { /// - /// The logger class for logging to console. + /// Name of the file used to store save data. /// - private readonly ConsoleLogger _consoleLogger; - + private const string SaveFileName = "save.json"; + + /// + /// The exit command for exiting the server. + /// + private readonly IServerCommand _exitCommand; + /// + /// The console settings command for changing console settings. + /// + private readonly IServerCommand _consoleSettingsCommand; + /// + /// The log command for changing log levels. + /// + private readonly IServerCommand _logCommand; + + /// + /// Lock object for asynchronous access to the save file. + /// + private readonly object _saveFileLock = new object(); + + /// + /// The absolute file path of the save file. + /// + private string _saveFilePath; + public ConsoleServerManager( NetServer netServer, - ServerSettings serverSettings, PacketManager packetManager, + ServerSettings serverSettings, ConsoleLogger consoleLogger - ) : base(netServer, serverSettings, packetManager) { - _consoleLogger = consoleLogger; + ) : base(netServer, packetManager, serverSettings) { + _exitCommand = new ExitCommand(this); + _consoleSettingsCommand = new ConsoleSettingsCommand(this, InternalServerSettings); + _logCommand = new LogCommand(consoleLogger); + } + + /// + public override void Initialize() { + base.Initialize(); // Start loading addons AddonManager.LoadAddons(); @@ -37,13 +74,111 @@ ConsoleLogger consoleLogger }; } + /// + public override void Start(int port, bool fullSynchronisation) { + base.Start(port, fullSynchronisation); + + InitializeSaveFile(); + } + /// protected override void RegisterCommands() { base.RegisterCommands(); - CommandManager.RegisterCommand(new ExitCommand(this)); - CommandManager.RegisterCommand(new ConsoleSettingsCommand(this, InternalServerSettings)); - CommandManager.RegisterCommand(new LogCommand(_consoleLogger)); + CommandManager.RegisterCommand(_exitCommand); + CommandManager.RegisterCommand(_consoleSettingsCommand); + CommandManager.RegisterCommand(_logCommand); + } + + /// + protected override void DeregisterCommands() { + base.DeregisterCommands(); + + CommandManager.DeregisterCommand(_exitCommand); + CommandManager.DeregisterCommand(_consoleSettingsCommand); + CommandManager.DeregisterCommand(_logCommand); + } + + /// + protected override void OnSaveUpdate(ushort id, SaveUpdate packet) { + base.OnSaveUpdate(id, packet); + + // After the server manager has processed the save update, we write the current save data to file + WriteToSaveFile(ServerSaveData); + } + + /// + /// Initialize the save file by either reading it from disk or creating a new one and writing it to disk. + /// + /// Thrown when the directory of the assembly could not be found. + private void InitializeSaveFile() { + // We first try to get the entry assembly in case the executing assembly was + // embedded in the standalone server + var assembly = Assembly.GetEntryAssembly(); + if (assembly == null) { + // If the entry assembly doesn't exist, we fall back on the executing assembly + assembly = Assembly.GetExecutingAssembly(); + } + + var currentPath = Path.GetDirectoryName(assembly.Location); + if (currentPath == null) { + throw new Exception("Could not get directory of assembly for save file"); + } + + lock (_saveFileLock) { + _saveFilePath = Path.Combine(currentPath, SaveFileName); + + // If the file exists, simply read it into the current save data for the server + // Otherwise, create an empty dictionary for save data and save it to file + if (File.Exists(_saveFilePath) && TryReadSaveFile(out var saveData)) { + ServerSaveData = saveData; + } else { + ServerSaveData = new ServerSaveData(); + + WriteToSaveFile(ServerSaveData); + } + } + } + + /// + /// Try to read the save data in the save file into a server save data instance. + /// + /// The server save data instance if it was read, otherwise null. + /// true if the save file could be read, false otherwise. + private bool TryReadSaveFile(out ServerSaveData serverSaveData) { + lock (_saveFileLock) { + // Read the JSON text from the file + var saveFileText = File.ReadAllText(_saveFilePath); + + try { + var consoleSaveFile = JsonConvert.DeserializeObject(saveFileText); + + serverSaveData = consoleSaveFile.ToServerSaveData(); + return true; + } catch (Exception e) { + Logger.Error($"Could not read the JSON from save file:\n{e}"); + } + + serverSaveData = null; + return false; + } + } + + /// + /// Write the save data from the server to the save file. + /// + /// The save data from the server to write to file. + private void WriteToSaveFile(ServerSaveData serverSaveData) { + lock (_saveFileLock) { + try { + var consoleSaveFile = ConsoleSaveFile.FromServerSaveData(serverSaveData); + var saveFileText = JsonConvert.SerializeObject(consoleSaveFile, Formatting.Indented); + + File.WriteAllText(_saveFilePath, saveFileText); + } catch (Exception e) { + Logger.Error($"Exception occurred while serializing/writing to save file:\n{e}"); + } + } } } } diff --git a/HKMPServer/ConsoleSettings.cs b/HKMPServer/ConsoleSettings.cs new file mode 100644 index 00000000..17d14549 --- /dev/null +++ b/HKMPServer/ConsoleSettings.cs @@ -0,0 +1,17 @@ +namespace HkmpServer { + /// + /// Class that houses settings for the console program specifically. Settings that should be known upon starting + /// the console program specifically. + /// + internal class ConsoleSettings { + /// + /// The port that the console program should run on. + /// + public int Port { get; set; } = 26950; + + /// + /// Whether full synchronisation of bosses, enemies, worlds, and saves is enabled. + /// + public bool FullSynchronisation { get; set; } = true; + } +} diff --git a/HKMPServer/HKMPServer.csproj b/HKMPServer/HKMPServer.csproj index e897ec53..afb6a97b 100644 --- a/HKMPServer/HKMPServer.csproj +++ b/HKMPServer/HKMPServer.csproj @@ -1,4 +1,7 @@  + + + {5AB0E450-3F37-4715-916F-CF2EC62D398B} HkmpServer @@ -7,25 +10,13 @@ exe - - - Lib\HKMP.dll - - - Lib\HKMP.pdb - - - - {f34118b2-515d-4c33-88e6-9cfef2ad5a15} HKMP - false - Lib\Newtonsoft.Json.dll - False + $(References)\Newtonsoft.Json.dll diff --git a/HKMPServer/HkmpServer.cs b/HKMPServer/HkmpServer.cs index 20d42fd6..6b3606e9 100644 --- a/HKMPServer/HkmpServer.cs +++ b/HKMPServer/HkmpServer.cs @@ -21,35 +21,47 @@ public void Initialize(string[] args) { Logger.AddLogger(consoleLogger); Logger.AddLogger(new RollingFileLogger()); - if (args.Length < 1) { - Logger.Info("Please provide a port in the arguments"); - return; - } + var hasPortArg = false; + ushort port = 0; - var portArg = args[0]; + if (args.Length > 0) { + if (string.IsNullOrEmpty(args[0]) || !ushort.TryParse(args[0], out port)) { + Logger.Info("Invalid port, should be an integer between 0 and 65535"); + return; + } - if (string.IsNullOrEmpty(portArg) || !ushort.TryParse(portArg, out var port)) { - Logger.Info("Invalid port, should be an integer between 0 and 65535"); - return; + hasPortArg = true; } - var serverSettings = ConfigManager.LoadServerSettings(out var existed); - if (!existed) { + if (!ConfigManager.LoadServerSettings(out var serverSettings)) { ConfigManager.SaveServerSettings(serverSettings); } - StartServer(port, serverSettings, consoleInputManager, consoleLogger); + // Load the console settings and note whether they existed or not + var consoleSettingsExisted = ConfigManager.LoadConsoleSettings(out var consoleSettings); + // If the user supplied a port on the arguments to the program, we override the loaded settings with + // the port + if (hasPortArg) { + consoleSettings.Port = port; + } + + // If the settings did not yet exist, we now save the settings possibly with the argument provided port + if (!consoleSettingsExisted) { + ConfigManager.SaveConsoleSettings(consoleSettings); + } + + StartServer(consoleSettings, serverSettings, consoleInputManager, consoleLogger); } /// /// Will start the server with the given port and server settings. /// - /// The port of the server. + /// The console settings for the program. /// The server settings for the server. /// The input manager for command-line input. /// The logging class for logging to console. private void StartServer( - ushort port, + ConsoleSettings consoleSettings, ServerSettings serverSettings, ConsoleInputManager consoleInputManager, ConsoleLogger consoleLogger @@ -60,9 +72,9 @@ ConsoleLogger consoleLogger var netServer = new NetServer(packetManager); - var serverManager = new ConsoleServerManager(netServer, serverSettings, packetManager, consoleLogger); + var serverManager = new ConsoleServerManager(netServer, packetManager, serverSettings, consoleLogger); serverManager.Initialize(); - serverManager.Start((int)port); + serverManager.Start(consoleSettings.Port, consoleSettings.FullSynchronisation); // TODO: make an event in ServerManager that we can register for so we know when the server shuts down consoleInputManager.ConsoleInputEvent += input => { diff --git a/HKMPServer/Launcher.cs b/HKMPServer/Launcher.cs index 6d8f7b35..6496f819 100644 --- a/HKMPServer/Launcher.cs +++ b/HKMPServer/Launcher.cs @@ -1,8 +1,3 @@ -using System; -using System.IO; -using System.Reflection; -using System.Threading.Tasks; - namespace HkmpServer { /// /// Launcher class with the entry point for the program. Primarily here to make sure embedded assemblies @@ -14,62 +9,7 @@ internal static class Launcher { /// /// Command line arguments for the server. public static void Main(string[] args) { - // Register event listeners for when assemblies are trying to get resolved - AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += ResolveAssembly; - AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly; - new HkmpServer().Initialize(args); } - - /// - /// Callback for assembly resolve event. Will try to find and load an embedded assembly for the assembly - /// that is trying to get resolved. - /// - /// The sender of the event. - /// The event arguments. - /// The resolved assembly, or null if no such assembly could be found. - private static Assembly ResolveAssembly(object sender, ResolveEventArgs eventArgs) { - var assemblyName = eventArgs.Name.Split(',')[0]; - var currentAssembly = Assembly.GetExecutingAssembly(); - - // Try to find the assembly as an embedded resource - var assemblyStream = currentAssembly.GetManifestResourceStream($"HkmpServer.Lib.{assemblyName}.dll"); - if (assemblyStream == null) { - return null; - } - - var assemblyMemoryStream = new MemoryStream(); - assemblyStream.CopyTo(assemblyMemoryStream); - - Console.WriteLine($"Found resource for resolving assembly: {assemblyName}"); - - // Exception message for when assembly loading fails - const string assemblyLoadingExceptionMsg = "Exception occurred while loading assembly: "; - - // Try to get the PDB for the assembly if it exists - var symbolStream = currentAssembly.GetManifestResourceStream($"HkmpServer.Lib.{assemblyName}.pdb"); - if (symbolStream != null) { - Console.WriteLine(" Found PDB for assembly"); - - var symbolMemoryStream = new MemoryStream(); - symbolStream.CopyTo(symbolMemoryStream); - - // Load the assembly with the PDB - try { - return Assembly.Load(assemblyMemoryStream.ToArray(), symbolMemoryStream.ToArray()); - } catch (BadImageFormatException ex) { - Console.WriteLine(assemblyLoadingExceptionMsg + ex.Message); - } - } - - // Load the assembly without the PDB - try { - return Assembly.Load(assemblyMemoryStream.ToArray()); - } catch (BadImageFormatException ex) { - Console.WriteLine(assemblyLoadingExceptionMsg + ex.Message); - } - - return null; - } } } diff --git a/HKMPServer/LocalBuildProperties_example.props b/HKMPServer/LocalBuildProperties_example.props new file mode 100644 index 00000000..09c369f1 --- /dev/null +++ b/HKMPServer/LocalBuildProperties_example.props @@ -0,0 +1,6 @@ + + + + .\lib + +