From 6994c500c8f97e3915794a63dd656b656e8f8e9a Mon Sep 17 00:00:00 2001 From: CloudeaSoft Date: Mon, 19 Oct 2026 09:58:53 +0800 Subject: [PATCH 1/6] chore(yggdrasil): remove unused PurpleBomb VFX import --- .../Projectiles/Enemies/PurpleBombCaterpillarDeath.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Modules/Yggdrasil/YggdrasilTown/Projectiles/Enemies/PurpleBombCaterpillarDeath.cs b/Sources/Modules/Yggdrasil/YggdrasilTown/Projectiles/Enemies/PurpleBombCaterpillarDeath.cs index f5e2fd85a..46a50c327 100644 --- a/Sources/Modules/Yggdrasil/YggdrasilTown/Projectiles/Enemies/PurpleBombCaterpillarDeath.cs +++ b/Sources/Modules/Yggdrasil/YggdrasilTown/Projectiles/Enemies/PurpleBombCaterpillarDeath.cs @@ -1,6 +1,5 @@ using Everglow.Yggdrasil.YggdrasilTown.VFXs; using Everglow.Yggdrasil.YggdrasilTown.VFXs.NPCEffects; -using static Everglow.Yggdrasil.YggdrasilTown.VFXs.RandomNPC.VFXPerson; namespace Everglow.Yggdrasil.YggdrasilTown.Projectiles.Enemies; From e23fb49adbf7ec3b6f57ab0eb68c676660d505cc Mon Sep 17 00:00:00 2001 From: CloudeaSoft Date: Mon, 19 Oct 2026 09:59:04 +0800 Subject: [PATCH 2/6] refactor(yggdrasil): remove legacy town crowd implementation --- .../Items/Tools/Developer/VFXPersonItem.cs | 28 -- .../YggdrasilTown/VFXs/RandomNPC/VFXPerson.cs | 306 ------------------ .../RandomNPC/YggdrasilTownPersonManager.cs | 65 ---- .../YggdrasilTownCentralSystem.cs | 2 - 4 files changed, 401 deletions(-) delete mode 100644 Sources/Modules/Yggdrasil/YggdrasilTown/Items/Tools/Developer/VFXPersonItem.cs delete mode 100644 Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/VFXPerson.cs delete mode 100644 Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/YggdrasilTownPersonManager.cs diff --git a/Sources/Modules/Yggdrasil/YggdrasilTown/Items/Tools/Developer/VFXPersonItem.cs b/Sources/Modules/Yggdrasil/YggdrasilTown/Items/Tools/Developer/VFXPersonItem.cs deleted file mode 100644 index 9271419a6..000000000 --- a/Sources/Modules/Yggdrasil/YggdrasilTown/Items/Tools/Developer/VFXPersonItem.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Everglow.Yggdrasil.YggdrasilTown.VFXs.RandomNPC; - -namespace Everglow.Yggdrasil.YggdrasilTown.Items.Tools.Developer; - -public class VFXPersonItem : ModItem -{ - public override string LocalizationCategory => Everglow.Commons.Utilities.LocalizationUtils.Categories.Placeables; - - public override void SetDefaults() - { - Item.width = 16; - Item.height = 16; - Item.useTurn = true; - Item.useAnimation = 4; - Item.useTime = 4; - Item.autoReuse = false; - Item.useStyle = ItemUseStyleID.Swing; - } - - public override bool CanUseItem(Player player) - { - for (int k = 0; k < 10; k++) - { - YggdrasilTownPersonManager.AddRandomPerson(); - } - return false; - } -} diff --git a/Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/VFXPerson.cs b/Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/VFXPerson.cs deleted file mode 100644 index 3c456f0c3..000000000 --- a/Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/VFXPerson.cs +++ /dev/null @@ -1,306 +0,0 @@ -using Everglow.Yggdrasil.YggdrasilTown.Items.Tools.Developer; - -namespace Everglow.Yggdrasil.YggdrasilTown.VFXs.RandomNPC; - -[Pipeline(typeof(WCSPipeline))] -public class VFXPerson : Visual -{ - public override CodeLayer DrawLayer => CodeLayer.PostDrawDusts; - - public enum MoveState - { - Stand, - Walk, - Jump, - } - - public Vector2 Position; - - public Vector2 Velocity; - - public float Timer; - - public float MaxTime; - - public int State; - - public int Direction; - - public int Frame; - - public int HairStyle; - - public int Sex; - - public int ShoeStyle; - - public bool Blocked; - - public Color EyeColor; - - public Color SkinColor; - - public Color HairColor; - - public Color ClothColor; - - public Color ShoeColor; - - public override void OnSpawn() - { - } - - public override void Update() - { - CheckEliminate(); - UpdateFrame(); - AI(); - - Timer++; - if (Timer > MaxTime) - { - Active = false; - } - } - - public void AI() - { - if (TileUtils.PlatformCollision(Position + new Vector2(-8, 0), 16, 48)) - { - if (!Blocked) - { - Velocity.Y *= 0; - for (int t = 0; t < 16; t++) - { - if (!TileUtils.PlatformCollision(Position + new Vector2(-8, -1), 16, 48)) - { - break; - } - Position.Y -= 1; - if (t == 15) - { - Position.Y += 16; - Velocity.X *= -1; - Direction *= -1; - Blocked = true; - } - } - } - else - { - Velocity.Y *= 0; - for (int t = 0; t < 32; t++) - { - if (!TileUtils.PlatformCollision(Position + new Vector2(-8, -1), 16, 48)) - { - break; - } - Position.Y -= 16; - if (t == 31) - { - Position.Y += 16 * 32; - } - } - for (int t = 0; t < 16; t++) - { - if (!TileUtils.PlatformCollision(Position + new Vector2(-8, -1), 16, 48)) - { - break; - } - Position.Y += 16; - if (t == 15) - { - Position.Y -= 16 * 16; - Active = false; - return; - } - } - Blocked = false; - } - } - else - { - Blocked = false; - Velocity.Y += 0.25f; - if (Velocity.Y > 8) - { - Velocity.Y = 8; - } - } - if (State == (int)MoveState.Walk) - { - Velocity.X = 1.5f * Direction; - if (Main.rand.NextBool(240)) - { - State = (int)MoveState.Stand; - } - } - if (State == (int)MoveState.Stand) - { - Velocity.X = 0; - if (Main.rand.NextBool(240)) - { - State = (int)MoveState.Walk; - if (Main.rand.NextBool()) - { - Direction *= -1; - } - } - } - Position += Velocity; - } - - public void CheckEliminate() - { - if (Main.mouseMiddle && Main.mouseMiddleRelease && Main.LocalPlayer.HeldItem.type == ModContent.ItemType()) - { - Active = false; - return; - } - } - - public void UpdateFrame() - { - if (Timer % 3 == 0) - { - Frame++; - switch (State) - { - case (int)MoveState.Stand: - { - Frame = 0; - break; - } - case (int)MoveState.Walk: - { - if (Frame >= 20) - { - Frame = 6; - } - break; - } - } - } - } - - public override void Draw() - { - // Eye and Head - SpriteEffects flip = Direction == 1 ? SpriteEffects.None : SpriteEffects.FlipHorizontally; - Texture2D head = Terraria.GameContent.TextureAssets.Players[0, 0].Value; - Rectangle headFrame = new Rectangle(0, Frame * 56, 40, 56); - Ins.Batch.Draw(head, Position + new Vector2(0, 52), headFrame, Lighting.GetColor(Position.ToTileCoordinates(), SkinColor), 0, new Vector2(headFrame.Width * 0.5f, headFrame.Height), 1f, flip); - Texture2D sclera = Terraria.GameContent.TextureAssets.Players[0, 1].Value; - Ins.Batch.Draw(sclera, Position + new Vector2(0, 52), headFrame, Lighting.GetColor(Position.ToTileCoordinates(), Color.White), 0, new Vector2(headFrame.Width * 0.5f, headFrame.Height), 1f, flip); - Texture2D pupil = Terraria.GameContent.TextureAssets.Players[0, 2].Value; - Ins.Batch.Draw(pupil, Position + new Vector2(0, 52), headFrame, Lighting.GetColor(Position.ToTileCoordinates(), EyeColor), 0, new Vector2(headFrame.Width * 0.5f, headFrame.Height), 1f, flip); - - float bodyOffset = 0; - if (Frame is >= 14 and <= 16 || Frame is >= 7 and <= 9) - { - bodyOffset = -2; - } - - // Chest - Texture2D chest = Terraria.GameContent.TextureAssets.Players[0, 3].Value; - Rectangle chestFrame = new Rectangle(0, 0, 40, 56); - Ins.Batch.Draw(chest, Position + new Vector2(0, 24 + bodyOffset), chestFrame, Lighting.GetColor(Position.ToTileCoordinates(), SkinColor), 0, chestFrame.Size() * 0.5f, 1f, flip); - - // Armx and hands - Texture2D arm = Terraria.GameContent.TextureAssets.Players[0, 4].Value; - Texture2D hand = Terraria.GameContent.TextureAssets.Players[0, 5].Value; - Rectangle armFrame = new Rectangle(120, 56, 40, 56); - int offsetFrame = Frame + 2; - offsetFrame -= 6; - offsetFrame %= 14; - offsetFrame += 6; - if (offsetFrame is 6 or 7 or 19) - { - armFrame.X = 160; - } - if (offsetFrame is 8 or 9 or 17 or 18) - { - armFrame.X = 120; - } - if (offsetFrame is 10 or 11 or 15 or 16) - { - armFrame.X = 200; - } - if (offsetFrame is 12 or 13 or 14) - { - armFrame.X = 240; - } - if (State == (int)MoveState.Stand) - { - armFrame = new Rectangle(80, 0, 40, 56); - } - Rectangle armFrame_back = armFrame; - armFrame_back.Y += 112; - Rectangle handFrame = armFrame; - Rectangle handFrame_back = armFrame; - handFrame_back.Y += 112; - Ins.Batch.Draw(hand, Position + new Vector2(0, 24 + bodyOffset), handFrame_back, Lighting.GetColor(Position.ToTileCoordinates(), SkinColor), 0, handFrame_back.Size() * 0.5f, 1f, flip); - Ins.Batch.Draw(arm, Position + new Vector2(0, 24 + bodyOffset), armFrame_back, Lighting.GetColor(Position.ToTileCoordinates(), SkinColor), 0, armFrame_back.Size() * 0.5f, 1f, flip); - - // Clothes - Texture2D innerCloth = Terraria.GameContent.TextureAssets.Players[0, 6].Value; - Texture2D sleeve = Terraria.GameContent.TextureAssets.Players[0, 8].Value; - Rectangle innerClothFrame = chestFrame; - if (Sex == 1) - { - innerClothFrame.Y += 112; - } - Ins.Batch.Draw(innerCloth, Position + new Vector2(0, 24 + bodyOffset), innerClothFrame, Lighting.GetColor(Position.ToTileCoordinates(), ClothColor), 0, innerClothFrame.Size() * 0.5f, 1f, flip); - Ins.Batch.Draw(sleeve, Position + new Vector2(0, 24 + bodyOffset), armFrame_back, Lighting.GetColor(Position.ToTileCoordinates(), ClothColor), 0, armFrame_back.Size() * 0.5f, 1f, flip); - Ins.Batch.Draw(hand, Position + new Vector2(0, 24 + bodyOffset), handFrame, Lighting.GetColor(Position.ToTileCoordinates(), SkinColor), 0, handFrame.Size() * 0.5f, 1f, flip); - Ins.Batch.Draw(arm, Position + new Vector2(0, 24 + bodyOffset), armFrame, Lighting.GetColor(Position.ToTileCoordinates(), SkinColor), 0, armFrame.Size() * 0.5f, 1f, flip); - Ins.Batch.Draw(sleeve, Position + new Vector2(0, 24 + bodyOffset), armFrame, Lighting.GetColor(Position.ToTileCoordinates(), ClothColor), 0, armFrame.Size() * 0.5f, 1f, flip); - - // Foot - Texture2D foot = Terraria.GameContent.TextureAssets.Players[0, 10].Value; - Rectangle footFrame = new Rectangle(0, Frame * 56, 40, 56); - Ins.Batch.Draw(foot, Position + new Vector2(0, -4), footFrame, Lighting.GetColor(Position.ToTileCoordinates(), ShoeStyle % 2 == 0 ? ShoeColor : SkinColor), 0, new Vector2(footFrame.Width * 0.5f, 0), 1f, flip); - - // Shoes - Texture2D shoe = Terraria.GameContent.TextureAssets.Players[0, 11].Value; - switch (ShoeStyle) - { - case 0: - shoe = Terraria.GameContent.TextureAssets.Players[0, 11].Value; - break; - case 1: - shoe = Terraria.GameContent.TextureAssets.Players[0, 12].Value; - break; - case 2: - shoe = Terraria.GameContent.TextureAssets.Players[1, 11].Value; - break; - case 3: - shoe = Terraria.GameContent.TextureAssets.Players[1, 12].Value; - break; - case 4: - shoe = Terraria.GameContent.TextureAssets.Players[2, 11].Value; - break; - case 5: - shoe = Terraria.GameContent.TextureAssets.Players[2, 12].Value; - break; - case 6: - shoe = Terraria.GameContent.TextureAssets.Players[3, 11].Value; - break; - case 7: - shoe = Terraria.GameContent.TextureAssets.Players[3, 12].Value; - break; - } - - Ins.Batch.Draw(shoe, Position + new Vector2(0, -4), footFrame, Lighting.GetColor(Position.ToTileCoordinates(), ShoeColor), 0, new Vector2(footFrame.Width * 0.5f, 0), 1f, flip); - - // Hair - Main.instance.LoadHair(HairStyle); - Texture2D hair = Terraria.GameContent.TextureAssets.PlayerHair[HairStyle].Value; - Rectangle hairFrame = new Rectangle(0, (Frame - 6) * 56, 40, 56); - if (Frame < 6) - { - hairFrame.Y = 0; - } - Ins.Batch.Draw(hair, Position + new Vector2(0, 24), hairFrame, Lighting.GetColor(Position.ToTileCoordinates(), HairColor), 0, hairFrame.Size() * 0.5f, 1f, flip); - } -} diff --git a/Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/YggdrasilTownPersonManager.cs b/Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/YggdrasilTownPersonManager.cs deleted file mode 100644 index 3fd7a2ee0..000000000 --- a/Sources/Modules/Yggdrasil/YggdrasilTown/VFXs/RandomNPC/YggdrasilTownPersonManager.cs +++ /dev/null @@ -1,65 +0,0 @@ -namespace Everglow.Yggdrasil.YggdrasilTown.VFXs.RandomNPC; - -public class YggdrasilTownPersonManager -{ - public static List People = new List(); - - public static void AddRandomPerson() - { - VFXPerson person = new VFXPerson() - { - Active = true, - Visible = true, - Position = Main.MouseWorld, - Velocity = Vector2.Zero, - Timer = 0, - MaxTime = 3000000, - State = (int)VFXPerson.MoveState.Walk, - SkinColor = ChooseSkinColor(), - HairColor = new Vector3(Main.rand.NextFloat(360), MathF.Pow(Main.rand.NextFloat(), 0.25f), MathF.Pow(Main.rand.NextFloat(), 0.5f)).HSVToRGB_Color(1f), - ClothColor = new Vector3(Main.rand.NextFloat(360), MathF.Pow(Main.rand.NextFloat(), 2f), MathF.Pow(Main.rand.NextFloat(), 1.2f)).HSVToRGB_Color(1f), - ShoeColor = new Vector3(Main.rand.NextFloat(-30, 60), MathF.Pow(Main.rand.NextFloat(), 2f), MathF.Pow(Main.rand.NextFloat(), 2.5f)).HSVToRGB_Color(1f), - EyeColor = new Vector3(Main.rand.NextFloat(360), MathF.Pow(Main.rand.NextFloat(), 0.5f), Main.rand.NextFloat()).HSVToRGB_Color(1f), - HairStyle = Main.rand.Next(165), - Direction = Main.rand.NextBool() ? -1 : 1, - Sex = Main.rand.NextBool() ? 0 : 1, - ShoeStyle = Main.rand.Next(8), - }; - Ins.VFXManager.Add(person); - People.Add(person); - } - - public static Color ChooseSkinColor() - { - switch (Main.rand.Next(7)) - { - case 0: - return new Color(209, 160, 156); - case 1: - return new Color(255, 221, 221); - case 2: - return new Color(255, 221, 193); - case 3: - return new Color(206, 135, 111); - case 4: - return new Color(104, 66, 75); - case 5: - return new Color(232, 205, 176); - case 6: - return new Color(232, 202, 194); - } - - return new Color(209, 160, 156); - } - - public static void Update() - { - for (int k = People.Count - 1; k >= 0; k--) - { - if (!People[k].Active) - { - People.RemoveAt(k); - } - } - } -} diff --git a/Sources/Modules/Yggdrasil/YggdrasilTown/YggdrasilTownCentralSystem.cs b/Sources/Modules/Yggdrasil/YggdrasilTown/YggdrasilTownCentralSystem.cs index 76557ff13..481743143 100644 --- a/Sources/Modules/Yggdrasil/YggdrasilTown/YggdrasilTownCentralSystem.cs +++ b/Sources/Modules/Yggdrasil/YggdrasilTown/YggdrasilTownCentralSystem.cs @@ -5,7 +5,6 @@ using Everglow.Yggdrasil.YggdrasilTown.Projectiles.Miscs.PlayerArena; using Everglow.Yggdrasil.YggdrasilTown.Tiles; using Everglow.Yggdrasil.YggdrasilTown.Tiles.Union; -using Everglow.Yggdrasil.YggdrasilTown.VFXs.RandomNPC; using SubworldLibrary; using Terraria.DataStructures; using Terraria.GameContent; @@ -119,7 +118,6 @@ public override void PostUpdateNPCs() RoadSignPost_ToArenaVFX.BuildArenaGen(); ResetedArena = true; } - YggdrasilTownPersonManager.Update(); base.PostUpdateNPCs(); } From e8fb97797398be9fdf26e24970341edb0e349c22 Mon Sep 17 00:00:00 2001 From: CloudeaSoft Date: Mon, 19 Oct 2026 11:41:44 +0800 Subject: [PATCH 3/6] feat(yggdrasil): implement lightweight town crowds --- .../Modules/Yggdrasil/CrowdActivityTests.cs | 184 ++ .../Modules/Yggdrasil/CrowdAvoidanceTests.cs | 49 + .../Modules/Yggdrasil/CrowdConfigTests.cs | 46 + .../Modules/Yggdrasil/CrowdLifeRulesTests.cs | 74 + .../Modules/Yggdrasil/CrowdNavigationTests.cs | 211 +++ .../Modules/Yggdrasil/CrowdRetirementTests.cs | 164 ++ .../Modules/Yggdrasil/CrowdRulesTests.cs | 134 ++ .../Modules/Yggdrasil/CrowdSittingTests.cs | 47 + .../Yggdrasil/CrowdTownPlanningTests.cs | 1654 +++++++++++++++++ .../Modules/Yggdrasil/CrowdTrainStateTests.cs | 28 + .../en-US/Mods.Everglow.Configs.hjson | 19 + .../zh-Hans/Mods.Everglow.Configs.hjson | 19 + .../Activities/CrowdActivityRules.cs | 49 + .../TownCrowd/Activities/CrowdAvoidance.cs | 44 + .../TownCrowd/Activities/CrowdConversation.cs | 116 ++ .../TownCrowd/Activities/CrowdLifeRules.cs | 77 + .../TownCrowd/Activities/CrowdPlaces.cs | 314 ++++ .../TownCrowd/Activities/CrowdRetirement.cs | 102 + .../TownCrowd/Activities/CrowdSeats.cs | 79 + .../TownCrowd/Activities/CrowdThreats.cs | 173 ++ .../TownCrowd/Activities/CrowdTrainState.cs | 68 + .../TownCrowd/Configuration/CrowdConfig.cs | 24 + .../TownCrowd/Configuration/CrowdSettings.cs | 44 + .../Mechanics/TownCrowd/CrowdAgent.cs | 1074 +++++++++++ .../Mechanics/TownCrowd/Movement/CrowdBody.cs | 64 + .../TownCrowd/Movement/CrowdNavigation.cs | 230 +++ .../TownCrowd/Movement/CrowdProgress.cs | 41 + .../TownCrowd/Movement/CrowdRules.cs | 42 + .../TownCrowd/Movement/CrowdTerrain.cs | 218 +++ .../TownCrowd/Presentation/CrowdAppearance.cs | 270 +++ .../TownCrowd/Presentation/CrowdNames.cs | 15 + .../Mechanics/TownCrowd/TownCrowdSystem.cs | 484 +++++ .../CustomTiles/YggdrasilTown_NormalMetro.cs | 11 +- 33 files changed, 6163 insertions(+), 5 deletions(-) create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdActivityTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdConfigTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdNavigationTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs create mode 100644 Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTrainStateTests.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdActivityRules.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdAvoidance.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdConversation.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdSeats.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdTrainState.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdConfig.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdBody.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdNavigation.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdProgress.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdRules.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdAppearance.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdNames.cs create mode 100644 Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdActivityTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdActivityTests.cs new file mode 100644 index 000000000..1f0a1feea --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdActivityTests.cs @@ -0,0 +1,184 @@ +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Microsoft.Xna.Framework; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdActivityTests +{ + [TestMethod] + public void SittingIsAnActivityAndConversationHasItsOwnLongerDuration() + { + Assert.IsTrue(Enum.TryParse("Sit", out var sitting)); + Assert.IsFalse(CrowdActivityRules.IsExit(sitting)); + Assert.AreEqual(8 * 60, CrowdSettings.ChatMinTicks); + Assert.AreEqual(15 * 60, CrowdSettings.ChatMaxTicks); + } + + [TestMethod] + public void RepeatedlyBlockedReturnsCanRetireOffscreenWithoutInterruptingAPlannedExit() + { + var retire = typeof(CrowdActivityRules).GetMethod("CanRetireAfterFailedDeparture"); + Assert.IsNotNull(retire); + bool Check(int age, bool visible, int failures) => (bool)retire.Invoke(null, [age, visible, failures])!; + Assert.IsTrue(Check(12000, false, 3)); + Assert.IsFalse(Check(12000, true, 3)); + Assert.IsFalse(Check(12000, false, 0)); + Assert.IsFalse(Check(12000, false, 2)); + Assert.IsFalse(Check(6000, false, 3)); + } + + [TestMethod] + public void ReturnIntentPersistsAndVisitorsHaveDifferentStayDurations() + { + var decision = typeof(CrowdActivityRules).GetMethod("ShouldDepart"); + Assert.IsNotNull(decision); + bool Decide(bool returning, int age, int visits, int seed) => (bool)decision.Invoke(null, [returning, age, visits, seed])!; + Assert.IsTrue(Decide(true, 100, 0, 42)); + Assert.IsFalse(Decide(false, 300, 0, 42)); + Assert.IsFalse(Decide(false, 300, 5, 42)); + Assert.IsTrue(Decide(false, 10801, 0, 42)); + Assert.IsTrue(Decide(false, 3000, 5, 42)); + Assert.IsTrue(Enumerable.Range(0, 6000).Select(seed => Decide(false, 6000, 0, seed)).Distinct().Count() == 2); + } + + [TestMethod] + public void ReturningVisitorsCanReuseTheirArrivalPylonAndPreferItToADoor() + { + float pylon = CrowdActivityRules.Weight(CrowdActivity.LeavePylon, true, true, 0, 2, 300, 1); + float door = CrowdActivityRules.Weight(CrowdActivity.EnterBuilding, true, false, 0, 2, 300, 1); + Assert.IsTrue(pylon > door, "Returning home must remain possible even when this pylon was the last sightseeing stop."); + Assert.AreEqual(0f, CrowdActivityRules.Weight(CrowdActivity.LeavePylon, true, true, 2, 2, 300, 1)); + Assert.AreEqual(0f, CrowdActivityRules.Weight(CrowdActivity.LeavePylon, false, false, 0, 2, 300, 1)); + } + + [TestMethod] + public void PylonDepartureWaitsBeforeFadingAndDoorDepartureKeepsItsShortFade() + { + var opacity = typeof(CrowdActivityRules).GetMethod("DepartureOpacity"); + Assert.IsNotNull(opacity); + float At(CrowdActivity activity, int tick) => (float)opacity.Invoke(null, [activity, tick])!; + Assert.AreEqual(1f, At(CrowdActivity.LeavePylon, 0)); + Assert.AreEqual(1f, At(CrowdActivity.LeavePylon, 59)); + Assert.AreEqual(0.5f, At(CrowdActivity.LeavePylon, 75), 0.001f); + Assert.AreEqual(0f, At(CrowdActivity.LeavePylon, 90)); + Assert.AreEqual(0f, At(CrowdActivity.LeavePylon, 120)); + Assert.AreEqual(0.5f, At(CrowdActivity.EnterBuilding, 30), 0.001f); + } + + [TestMethod] + public void CrowdNamePoolIsVariedAndStableForTheSameIdentity() + { + var names = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdNames"); + Assert.IsNotNull(names); + var key = names.GetMethod("KeyFor"); + Assert.IsNotNull(key); + string Key(int seed) => (string)key.Invoke(null, [seed])!; + var keys = Enumerable.Range(0, 256).Select(Key).ToArray(); + Assert.IsTrue(keys.Distinct().Count() >= 24); + Assert.IsTrue(keys.All(value => value.StartsWith("Mods.Everglow.Travelers.Names."))); + Assert.AreEqual(Key(931), Key(931)); + Assert.IsFalse(string.IsNullOrWhiteSpace(Key(int.MinValue))); + } + + [TestMethod] + public void LifetimeOnlyReclaimsTravelersOutsideView() + { + Assert.IsFalse(CrowdActivityRules.CanRetire(10801, true)); + Assert.IsFalse(CrowdActivityRules.CanRetire(900, false)); + Assert.IsTrue(CrowdActivityRules.CanRetire(10801, false)); + } + + [TestMethod] + public void TrainLoadOnlyCountsSuccessfulCreationAndResetsOnceAfterOffscreenDeparture() + { + var load = new CrowdTrainLoad(); + load.Observe(false, false, false); + Assert.AreEqual(0, load.Loaded); + load.RecordLoaded(); + load.Observe(false, false, true); + Assert.AreEqual(1, load.Loaded); + load.Observe(true, true, true); + load.Observe(false, true, false); + Assert.AreEqual(1, load.Loaded); + load.Observe(false, false, true); + Assert.AreEqual(1, load.Loaded); + load.Observe(false, false, false); + Assert.AreEqual(0, load.Loaded); + load.RecordLoaded(); + load.Observe(false, false, false); + Assert.AreEqual(1, load.Loaded); + } + + [TestMethod] + public void TrainSlotsAreDistinctAndSmallLoadsCanReachAnExitDuringOneStop() + { + var offsets = Enumerable.Range(0, 12).Select(index => CrowdTrainLoad.SeatOffset(index, 1696)).ToArray(); + Assert.AreEqual(12, offsets.Distinct().Count()); + Assert.IsTrue(offsets.All(x => x >= 16 && x <= 1680)); + Assert.IsTrue(CrowdActivityRules.CanFinishTrainWalk(true, 30, 600, (int)((offsets[0] + 64) / 1.2f))); + Assert.AreEqual(1696 - offsets[0], offsets[1]); + } + + [TestMethod] + public void StreetSpawnRequiresTownRoadOutsideViewAndReachableActivity() + { + var town = new Rectangle(0, 0, 2000, 1000); + var view = new Rectangle(500, 0, 1000, 1000); + Assert.IsTrue(CrowdActivityRules.CanSpawnOnStreet(new Vector2(200, 500), town, view)); + Assert.IsFalse(CrowdActivityRules.CanSpawnOnStreet(new Vector2(1000, 500), town, view)); + Assert.IsFalse(CrowdActivityRules.CanSpawnOnStreet(new Vector2(-200, 500), town, view)); + } + + [TestMethod] + public void TownActivitiesDoNotRequireDepartureIntent() + { + Assert.IsTrue(CrowdActivityRules.Weight(CrowdActivity.Browse, false, false, 0, 2, 300, 1) > 0); + Assert.AreEqual(0f, CrowdActivityRules.Weight(CrowdActivity.BoardTrain, false, false, 0, 2, 300, 1)); + Assert.AreEqual(0f, CrowdActivityRules.Weight(CrowdActivity.Browse, false, true, 0, 2, 300, 1)); + Assert.AreEqual(0f, CrowdActivityRules.Weight(CrowdActivity.Browse, false, false, 2, 2, 300, 1)); + Assert.IsTrue(CrowdActivityRules.Weight(CrowdActivity.BoardTrain, true, false, 0, 2, 300, 1) > 0); + } + + [TestMethod] + public void ArrivalBatchOccursOncePerStopAndCanRepeatAtNextStop() + { + var batch = new CrowdStopBatch(); + Assert.IsFalse(batch.TryBegin(true, 12, 600)); + Assert.IsTrue(batch.TryBegin(true, 30, 600)); + Assert.IsFalse(batch.TryBegin(true, 31, 600)); + Assert.IsFalse(batch.TryBegin(true, 400, 600)); + Assert.IsFalse(batch.TryBegin(false, 600, 600)); + Assert.IsTrue(batch.TryBegin(true, 30, 600)); + } + + [TestMethod] + public void LateArrivalCannotStartDisembarking() + { + Assert.IsFalse(new CrowdStopBatch().TryBegin(true, 520, 600)); + Assert.IsTrue(CrowdActivityRules.CanFinishTrainWalk(true, 30, 600, 400)); + Assert.IsFalse(CrowdActivityRules.CanFinishTrainWalk(true, 300, 600, 400)); + Assert.IsFalse(CrowdActivityRules.CanFinishTrainWalk(false, 30, 600, 10)); + } + + [TestMethod] + public void TrainDispatchRetriesFailuresButSpacesPassengersAndCapsSuccessfulBatch() + { + var batch = new CrowdStopBatch(); + batch.TryBegin(true, 30, 600); + Assert.IsTrue(batch.TryDispatch(2, 3, out int first)); + Assert.AreEqual(-1, first); + batch.TryBegin(true, 31, 600); + Assert.IsFalse(batch.TryDispatch(2, 3, out _)); + batch.TryBegin(true, 60, 600); + Assert.IsTrue(batch.TryDispatch(2, 3, out int second)); + Assert.AreEqual(1, second); + batch.RecordArrival(); + batch.TryBegin(true, 90, 600); + Assert.IsFalse(batch.TryDispatch(2, 1, out _)); + Assert.IsTrue(batch.TryDispatch(2, 3, out _)); + batch.RecordArrival(); + batch.TryBegin(true, 120, 600); + Assert.IsFalse(batch.TryDispatch(2, 3, out _)); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs new file mode 100644 index 000000000..564a36b28 --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs @@ -0,0 +1,49 @@ +using Everglow.Yggdrasil.Mechanics.TownCrowd; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdAvoidanceTests +{ + private static Type StateType + { + get + { + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAvoidance"); + Assert.IsNotNull(type); + return type; + } + } + + [TestMethod] + public void RecoveryNeedsThreeContinuousSafeSeconds() + { + var type = StateType; + var state = Activator.CreateInstance(type)!; + bool Tick(bool danger, bool safe) => (bool)type.GetMethod("Update")!.Invoke(state, [danger, safe, true, 300])!; + bool Alarmed() => (bool)type.GetProperty("Alarmed")!.GetValue(state)!; + Tick(true, false); + Assert.IsTrue(Alarmed()); + for (int i = 0; i < 179; i++) { Tick(false, true); } + Assert.IsTrue(Alarmed()); + Tick(false, false); + for (int i = 0; i < 179; i++) { Tick(false, true); } + Assert.IsTrue(Alarmed()); + Tick(false, true); + Assert.IsFalse(Alarmed()); + } + + [TestMethod] + public void ReturningOnscreenResetsRetirementAndSafeOffscreenStillRetires() + { + var type = StateType; + var state = Activator.CreateInstance(type)!; + bool Tick(bool danger, bool safe, bool visible) => (bool)type.GetMethod("Update")!.Invoke(state, [danger, safe, visible, 300])!; + Tick(true, false, true); + for (int i = 0; i < 299; i++) { Assert.IsFalse(Tick(false, true, false)); } + Assert.IsFalse(Tick(false, false, true)); + Tick(true, false, true); + for (int i = 0; i < 299; i++) { Assert.IsFalse(Tick(false, true, false)); } + Assert.IsTrue(Tick(false, true, false)); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdConfigTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdConfigTests.cs new file mode 100644 index 000000000..aeff006a0 --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdConfigTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Terraria.ModLoader.Config; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdConfigTests +{ + [TestMethod] + public void DensityScalesStreetAndTrainPopulationTogether() + { + var levels = new[] { CrowdDensity.Sparse, CrowdDensity.Standard, CrowdDensity.Busy }.Select(CrowdSettings.ForDensity).ToArray(); + for (int i = 1; i < levels.Length; i++) + { + Assert.IsTrue(levels[i].PopulationLimit > levels[i - 1].PopulationLimit); + Assert.IsTrue(levels[i].SpawnIntervalTicks < levels[i - 1].SpawnIntervalTicks); + Assert.IsTrue(levels[i].InitialFill > levels[i - 1].InitialFill); + Assert.IsTrue(levels[i].TrainInitialPassengers > levels[i - 1].TrainInitialPassengers); + Assert.IsTrue(levels[i].TrainPassengers > levels[i - 1].TrainPassengers); + } + } + + [TestMethod] + public void ConfigRoundTripKeepsPreferencesAndIgnoresRemovedTuning() + { + var config = Newtonsoft.Json.JsonConvert.DeserializeObject("{\"Enabled\":false,\"Density\":2,\"ChatChancePercent\":0,\"ReturnPercent\":100}")!; + var loaded = Newtonsoft.Json.JsonConvert.DeserializeObject(Newtonsoft.Json.JsonConvert.SerializeObject(config))!; + Assert.IsFalse(loaded.Enabled); + Assert.AreEqual(CrowdDensity.Busy, loaded.Density); + Assert.AreEqual(CrowdDensity.Standard, new CrowdConfig().Density); + Assert.AreSame(CrowdSettings.ForDensity(CrowdDensity.Standard), CrowdSettings.ForDensity((CrowdDensity)999)); + } + + [TestMethod] + public void PlayerSettingsOnlyExposeEnableAndDensityOnTheClient() + { + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdConfig"); + Assert.IsNotNull(type, "Crowd settings must be available in the normal mod configuration menu."); + var config = (ModConfig)Activator.CreateInstance(type)!; + Assert.AreEqual(ConfigScope.ClientSide, config.Mode); + CollectionAssert.AreEquivalent(new[] { "Enabled", "Density" }, type.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Select(field => field.Name).ToArray()); + Assert.AreEqual(true, type.GetField("Enabled")!.GetValue(config)); + Assert.AreEqual("Standard", type.GetField("Density")!.GetValue(config)!.ToString()); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs new file mode 100644 index 000000000..1c436aa5f --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Microsoft.Xna.Framework; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdLifeRulesTests +{ + private static Type Rules + { + get + { + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdLifeRules"); + Assert.IsNotNull(type); + return type; + } + } + + [TestMethod] + public void InitialIntentIsIndependentOfStreetOrPylonAppearance() + { + var choose = Rules.GetMethod("ChooseIntent")!; + Assert.AreEqual("ReturnHome", choose.Invoke(null, [false, false, 0, 100, 0])!.ToString()); + Assert.AreEqual("VisitTown", choose.Invoke(null, [true, false, 0, 100, 0])!.ToString()); + Assert.AreEqual("EnterGuild", choose.Invoke(null, [true, false, 0, 0, 100])!.ToString()); + Assert.AreEqual("EnterGuild", choose.Invoke(null, [false, false, 0, 0, 100])!.ToString()); + Assert.AreEqual("VisitTown", choose.Invoke(null, [false, true, 0, 0, 100])!.ToString()); + } + + [TestMethod] + public void GuildTriggerRequiresWholeBodyInsideOpening() + { + var contains = Rules.GetMethod("InsideGuildOpening")!; + Assert.IsTrue((bool)contains.Invoke(null, [new Point(10, 20), new Vector2(216, 448)])!); + Assert.IsFalse((bool)contains.Invoke(null, [new Point(10, 20), new Vector2(160, 448)])!); + Assert.IsFalse((bool)contains.Invoke(null, [new Point(10, 20), new Vector2(216, 480)])!); + } + + [TestMethod] + public void ZoomedCameraIncludesExactlyItsWorldSpaceExtent() + { + var camera = CrowdLifeRules.CameraBounds(new Vector2(1000, 2000), new Vector2(800, 600), + Matrix.CreateTranslation(-400, -300, 0) * Matrix.CreateScale(2, 2, 1) * Matrix.CreateTranslation(400, 300, 0)); + Assert.AreEqual(new Rectangle(1200, 2150, 400, 300), camera); + var regions = new CrowdRegions(); + Assert.IsTrue(regions.Observe(camera, 0).Length > 0); + Assert.AreEqual(0, regions.Observe(camera, 1).Length); + Assert.IsTrue(regions.Observe(new Rectangle(20000, 20000, 400, 300), 2).Length > 0); + Assert.AreEqual(0, regions.Observe(camera, 3).Length); + } + + [TestMethod] + public void RegionExposureRemembersShortRevisitsAndBoundsMemory() + { + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdRegions"); + Assert.IsNotNull(type); + var regions = Activator.CreateInstance(type)!; + var observe = type.GetMethod("Observe")!; + Rectangle[] Observe(Rectangle view, long clock) => (Rectangle[])observe.Invoke(regions, [view, clock])!; + var first = new Rectangle(20, 20, 100, 100); + Assert.AreEqual(1, Observe(first, 0).Length); + Assert.AreEqual(0, Observe(first, 1).Length); + Assert.AreEqual(1, Observe(new Rectangle(800, 20, 100, 100), 2).Length); + Assert.AreEqual(0, Observe(first, 3).Length); + for (int i = 0; i < 400; i++) + { + Observe(new Rectangle(i * 768, 400, 100, 100), i + 4); + } + Assert.IsTrue((int)type.GetProperty("Count")!.GetValue(regions)! <= 128); + type.GetMethod("Clear")!.Invoke(regions, null); + Assert.AreEqual(1, Observe(first, 500).Length); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdNavigationTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdNavigationTests.cs new file mode 100644 index 000000000..9c221fbc3 --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdNavigationTests.cs @@ -0,0 +1,211 @@ +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Microsoft.Xna.Framework; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdNavigationTests +{ + [TestMethod] + public void RoutePreferencesDoNotTurnFlatWalkingIntoRepeatedJumps() + { + for (int seed = 1; seed <= 16; seed++) + { + bool Clear(Point p) => p.X >= 0 && p.X <= 32 && p.Y >= 0 && p.Y <= 6; + var path = CrowdNavigation.FindPath(new(0, 6), new(30, 6), p => Clear(p) && p.Y == 6, Clear, routeSeed: seed); + Assert.AreEqual(31, path.Count, "A flat clear road should remain walking, even with personal route preferences."); + } + } + + [TestMethod] + public void FindPath_PlatformCanDropToFirstSafeFloorWithoutWalkingToItsEdge() + { + bool Clear(Point p) => p.X >= 0 && p.X <= 12 && p.Y >= 0 && p.Y <= 6; + bool Stand(Point p) => Clear(p) && (p.Y == 3 || p.Y == 6); + var path = CrowdNavigation.FindPath(new(8, 3), new(8, 6), Stand, Clear, canDropThrough: p => p.Y == 3); + CollectionAssert.AreEqual(new[] { new Point(8, 3), new Point(8, 6) }, path); + } + + [TestMethod] + public void FindPath_PlatformDropRequiresSafeFirstLandingAndBoundedHeight() + { + bool Clear(Point p) => p.X == 4 && p.Y >= 0 && p.Y <= 10; + bool Stand(Point p) => Clear(p) && (p.Y == 3 || p.Y == 7); + Assert.AreEqual(0, CrowdNavigation.FindPath(new(4, 3), new(4, 7), Stand, Clear, canDropThrough: _ => true).Count); + Assert.AreEqual(0, CrowdNavigation.FindPath(new(4, 3), new(4, 6), p => p.Y is 3 or 6, + p => Clear(p) && p.Y != 5, canDropThrough: _ => true).Count); + } + + [TestMethod] + public void FindPath_TravelersChooseDifferentSafePlatformExits() + { + bool Clear(Point p) => p.X >= 0 && p.X <= 20 && p.Y >= 0 && p.Y <= 6; + bool Stand(Point p) => Clear(p) && (p.Y == 3 || p.Y == 6); + var exits = new HashSet(); + for (int seed = 1; seed <= 16; seed++) + { + var path = CrowdNavigation.FindPath(new(18, 3), new(2, 6), Stand, Clear, + canDropThrough: p => p.Y == 3, routeSeed: seed); + Assert.IsTrue(path.Count > 0); + exits.Add(path.First(p => p.Y == 6).X); + Assert.IsTrue(path.All(Stand)); + } + Assert.IsTrue(exits.Count >= 3, "Different travelers should not always use the same platform exit."); + } + + [TestMethod] + public void FindPath_StackedPlatforms_WalksOffUpperEdgeBeforeDescending() + { + var start = new Point(4, 4); + var goal = new Point(3, 6); + bool Clear(Point point) => point.X >= 0 && point.X <= 8 && point.Y >= 0 && point.Y <= 6; + bool Stand(Point point) => Clear(point) && (point.Y == 6 || (point.Y == 4 && point.X is 3 or 4)); + var path = CrowdNavigation.FindPath(start, goal, Stand, Clear); + Assert.IsTrue(path.Count > 2, "Cannot walk directly through the upper platform to the floor beneath it."); + Assert.AreEqual(goal, path[^1]); + Assert.IsTrue(path.Any(point => point.Y == 6 && point.X is not (3 or 4)), "Descend outside the upper platform first."); + } + + [TestMethod] + public void FindPath_JumpCannotLandBelowAnInterceptingPlatform() + { + var start = new Point(1, 4); + var goal = new Point(4, 6); + bool Clear(Point point) => point.X >= 0 && point.X <= 8 && point.Y >= 0 && point.Y <= 6; + bool Stand(Point point) => Clear(point) && (point == start || point == goal || point == new Point(4, 4)); + var path = CrowdNavigation.FindPath(start, goal, Stand, Clear); + Assert.AreEqual(0, path.Count, "The descending body hits the upper platform before the requested landing."); + } + + [TestMethod] + public void FindPath_FlatGround_IncludesBothEndpoints() + { + var path = Find(new(1, 9), new(8, 9), (x, y) => y >= 10); + Assert.AreEqual(new Point(1, 9), path[0]); + Assert.AreEqual(new Point(8, 9), path[^1]); + Assert.IsTrue(path.All(p => p.Y == 9)); + } + + [TestMethod] + public void FindPath_UnsupportedStart_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 8), new(8, 9), (x, y) => y >= 10).Count); + } + + [TestMethod] + public void FindPath_UnsupportedGoal_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 9), new(8, 8), (x, y) => y >= 10).Count); + } + + [TestMethod] + public void FindPath_SameSupportedPoint_ReturnsOnePoint() + { + CollectionAssert.AreEqual(new[] { new Point(1, 9) }, Find(new(1, 9), new(1, 9), (x, y) => y >= 10)); + } + + [TestMethod] + public void FindPath_FullHeightWall_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 9), new(8, 9), (x, y) => y >= 10 || x == 4).Count); + } + + [TestMethod] + public void FindPath_TwoTileGap_JumpsToSupportedLanding() + { + var path = Find(new(1, 9), new(4, 9), (x, y) => y >= 10 && (x <= 1 || x >= 4)); + CollectionAssert.AreEqual(new[] { new Point(1, 9), new Point(4, 9) }, path); + } + + [TestMethod] + public void FindPath_FourTileHorizontalJump_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 9), new(5, 9), (x, y) => y >= 10 && (x <= 1 || x >= 5)).Count); + } + + [TestMethod] + public void FindPath_OneTileStep_ClimbsOntoIt() + { + var path = Find(new(1, 9), new(5, 8), (x, y) => y >= (x >= 3 ? 9 : 10)); + Assert.AreEqual(new Point(5, 8), path[^1]); + } + + [TestMethod] + public void FindPath_ThreeTileRise_CanJumpOntoIt() + { + var path = Find(new(1, 9), new(4, 6), (x, y) => (x <= 1 && y >= 10) || (x >= 4 && y >= 7)); + Assert.AreEqual(new Point(4, 6), path[^1]); + } + + [TestMethod] + public void FindPath_FourTileRise_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 9), new(4, 5), (x, y) => (x <= 1 && y >= 10) || (x >= 4 && y >= 6)).Count); + } + + [TestMethod] + public void FindPath_LowCeilingOverGap_BlocksJump() + { + Assert.AreEqual(0, Find(new(1, 9), new(4, 9), (x, y) => y == 6 || (y >= 10 && (x <= 1 || x >= 4))).Count); + } + + [TestMethod] + public void FindPath_LowCeilingOverFlatGround_AllowsWalking() + { + var path = Find(new(1, 9), new(4, 9), (x, y) => y == 6 || y >= 10); + Assert.AreEqual(new Point(4, 9), path[^1]); + } + + [TestMethod] + public void FindPath_LowCeilingTouchedByActualJump_RejectsRoute() + { + // The real -6.1 velocity / 0.3 gravity trajectory rises almost four + // cells; checking an idealized three-cell arch misses this ceiling. + Assert.AreEqual(0, Find(new(1, 9), new(4, 9), (x, y) => y == 3 || (y >= 10 && (x <= 1 || x >= 4))).Count); + } + + [TestMethod] + public void FindPath_SolidTileInsideJumpArc_BlocksJump() + { + var start = new Point(1, 9); + var goal = new Point(4, 9); + // Only the two ledges support travelers; an obstacle within the body + // sweep must reject the jump even though both endpoints are clear. + var path = CrowdNavigation.FindPath(start, goal, p => p == start || p == goal, + p => !(p.X == 2 && p.Y >= 7 && p.Y <= 9)); + Assert.AreEqual(0, path.Count); + } + + [TestMethod] + public void FindPath_ThreeTileDrop_CanReachGround() + { + var path = Find(new(1, 9), new(2, 12), (x, y) => y >= (x <= 1 ? 10 : 13)); + Assert.AreEqual(new Point(2, 12), path[^1]); + } + + [TestMethod] + public void FindPath_FourTileDrop_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 9), new(2, 13), (x, y) => y >= (x <= 1 ? 10 : 14)).Count); + } + + [TestMethod] + public void FindPath_ExhaustedBudget_ReturnsEmpty() + { + Assert.AreEqual(0, Find(new(1, 9), new(8, 9), (x, y) => y >= 10, 1).Count); + } + + [TestMethod] + public void FindPath_ZeroBudget_DoesNotQueryWorld() + { + var path = CrowdNavigation.FindPath(new(1, 9), new(1, 9), _ => throw new AssertFailedException("No search budget."), _ => throw new AssertFailedException("No search budget."), 0); + Assert.AreEqual(0, path.Count); + } + + private static List Find(Point start, Point goal, Func solid, int maxVisited = 6000) + { + bool Clear(Point p) => p.X >= 0 && p.X < 12 && p.Y >= 2 && p.Y < 16 && !solid(p.X, p.Y) && !solid(p.X, p.Y - 1) && !solid(p.X, p.Y - 2); + bool Stand(Point p) => Clear(p) && solid(p.X, p.Y + 1); + return CrowdNavigation.FindPath(start, goal, Stand, Clear, maxVisited); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs new file mode 100644 index 000000000..f7c37e14d --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs @@ -0,0 +1,164 @@ +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Microsoft.Xna.Framework; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdRetirementTests +{ + private static void Fade(CrowdRetirement retirement, int ticks) + { + for (int tick = 0; tick < ticks; tick++) { retirement.Update(); } + } + + [TestMethod] + public void ThreatUsesSustainedAvoidanceRecoveryRatherThanOneMissingDetection() + { + var avoidance = new CrowdAvoidance(); + var retirement = new CrowdRetirement(); + for (int tick = 0; tick < 600; tick++) + { + avoidance.Update(tick == 0, false, true, 300); + retirement.ObserveThreat(avoidance.Alarmed); + if (tick < 599) { Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); } + } + Assert.AreEqual(CrowdRetirementReason.Threat, retirement.Reasons); + for (int tick = 0; tick < 179; tick++) { avoidance.Update(false, true, true, 300); } + retirement.ObserveThreat(avoidance.Alarmed); + Assert.AreEqual(CrowdRetirementReason.Threat, retirement.Reasons); + avoidance.Update(false, true, true, 300); + retirement.ObserveThreat(avoidance.Alarmed); + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + } + + [TestMethod] + public void OverlappingReasonsShareFadeAndOnlyRecoverAfterLastReasonClears() + { + var retirement = new CrowdRetirement(); + retirement.SetReason(CrowdRetirementReason.Threat, true); + Fade(retirement, 60); + Assert.AreEqual(0.5f, retirement.Opacity, 0.0001f); + retirement.SetReason(CrowdRetirementReason.Blocked, true); + retirement.SetReason(CrowdRetirementReason.Threat, false); + Fade(retirement, 30); + Assert.IsTrue(retirement.Opacity < 0.2f, "Adding or replacing reasons must not restart the two-second fade."); + float low = retirement.Opacity; + retirement.SetReason(CrowdRetirementReason.Blocked, false); + Fade(retirement, 15); + Assert.AreEqual((low + 1) / 2, retirement.Opacity, 0.0001f); + Fade(retirement, 15); + Assert.AreEqual(1f, retirement.Opacity); + } + + [TestMethod] + public void RelapseFadesFromCurrentOpacityAndCompletionIsTerminal() + { + var retirement = new CrowdRetirement(); + retirement.SetReason(CrowdRetirementReason.Departure, true); + Fade(retirement, 90); + retirement.SetReason(CrowdRetirementReason.Departure, false); + Fade(retirement, 10); + float before = retirement.Opacity; + retirement.SetReason(CrowdRetirementReason.Threat, true); + Assert.AreEqual(before, retirement.Opacity); + Fade(retirement, 60); + Assert.AreEqual(before / 2, retirement.Opacity, 0.0001f); + Fade(retirement, 59); + Assert.IsFalse(retirement.Complete); + Fade(retirement, 1); + Assert.IsTrue(retirement.Complete); + retirement.SetReason(CrowdRetirementReason.Threat, false); + Fade(retirement, 30); + Assert.AreEqual(0f, retirement.Opacity); + } + + [TestMethod] + public void BlockedMovementNeedsTimeAndActualRecoveriesAndResetsAfterProgress() + { + var retirement = new CrowdRetirement(); + for (int tick = 0; tick < 900; tick++) { retirement.ObserveMovement(true, false, false); } + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + for (int attempt = 0; attempt < 3; attempt++) { retirement.RecordMovementRecovery(); } + retirement.ObserveMovement(true, false, false); + Assert.AreEqual(CrowdRetirementReason.Blocked, retirement.Reasons); + retirement.ObserveMovement(true, false, true); + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + for (int tick = 0; tick < 1800; tick++) { retirement.ObserveMovement(true, false, false); } + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons, "Earlier recovery attempts must not carry into a new stall."); + } + + [TestMethod] + public void RestAndBudgetWaitsDoNotAccumulateMovementOrDepartureTime() + { + var retirement = new CrowdRetirement(); + for (int attempt = 0; attempt < 3; attempt++) + { + retirement.RecordMovementRecovery(); + retirement.RecordDepartureFailure(); + } + for (int tick = 0; tick < 3600; tick++) + { + retirement.ObserveMovement(false, false, false); + retirement.ObserveMovement(true, true, false); + retirement.ObserveDeparture(true, true, false); + } + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + Assert.AreEqual(0, retirement.MovementTicks); + } + + [TestMethod] + public void DepartureRequiresFailuresAndActualExitProgressClearsAllHistory() + { + var retirement = new CrowdRetirement(); + for (int tick = 0; tick < 1800; tick++) { retirement.ObserveDeparture(true, false, false); } + for (int attempt = 0; attempt < 2; attempt++) { retirement.RecordDepartureFailure(); } + retirement.ObserveDeparture(true, false, false); + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + retirement.RecordDepartureFailure(); + retirement.ObserveDeparture(true, false, false); + Assert.AreEqual(CrowdRetirementReason.Departure, retirement.Reasons); + retirement.ObserveDeparture(true, false, true); + Assert.AreEqual(0, retirement.DepartureFailures); + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + for (int tick = 0; tick < 6000; tick++) { retirement.ObserveDeparture(true, false, tick % 30 == 0); } + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + } + + [TestMethod] + public void TinyMotionAndRepeatedTraversalOfSameNodesAreNotProgress() + { + var progress = new CrowdProgress(); + progress.Observe(Vector2.Zero, new Vector2(100, 0), false); + for (int tick = 0; tick < 900; tick++) + { + Assert.IsFalse(progress.Observe(new Vector2(tick % 2 == 0 ? 7 : -7, 0), new Vector2(100, 0), true)); + } + Assert.IsTrue(progress.Observe(new Vector2(16, 0), new Vector2(100, 0), true)); + for (int tick = 0; tick < 900; tick++) + { + Assert.IsFalse(progress.Observe(new Vector2(tick % 2 == 0 ? 0 : 16, 0), new Vector2(tick % 2 == 0 ? -100 : 100, 0), true)); + } + } + + [TestMethod] + public void RealTravelAndDetourWaypointsCountAsProgress() + { + var progress = new CrowdProgress(); + progress.Observe(Vector2.Zero, new Vector2(10000, 0), false); + for (int node = 1; node < 200; node++) + { + Assert.IsTrue(progress.Observe(new Vector2(node * 16, 0), new Vector2(10000, 0), false)); + } + Assert.IsTrue(progress.Observe(new Vector2(199 * 16, 16), Vector2.Zero, true), "Advancing along a checked detour need not reduce direct distance to the final destination."); + } + + [TestMethod] + public void ActivityAndRetirementOpacityMultiplyWithoutChangingActivityOpacity() + { + var appearance = new CrowdAppearance { Opacity = 0.4f, RetirementOpacity = 0.5f }; + Assert.AreEqual(0.2f, appearance.EffectiveOpacity, 0.0001f); + appearance.RetirementOpacity = 1; + Assert.AreEqual(0.4f, appearance.Opacity); + Assert.AreEqual(0.4f, appearance.EffectiveOpacity); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs new file mode 100644 index 000000000..dac306f86 --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs @@ -0,0 +1,134 @@ +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Everglow.Commons.CustomTiles.Core; +using Microsoft.Xna.Framework; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdRulesTests +{ + [TestMethod] + public void SolidTopFurnitureOnlySupportsItsTopFrame() + { + Assert.IsTrue(CrowdRules.IsSolidFloor(false, true, 0)); + Assert.IsFalse(CrowdRules.IsSolidFloor(false, true, 18)); + Assert.IsFalse(CrowdRules.IsSolidFloor(true, true, 36)); + Assert.IsTrue(CrowdRules.IsSolidFloor(true, false, 18)); + Assert.IsFalse(CrowdRules.IsSolidFloor(false, false, 0)); + } + + [TestMethod] + [DataRow(-0.5f)] + [DataRow(0.5f)] + public void FractionalTrainStopHasWalkableBoardingRouteUnderLowCeiling(float offset) + { + var train = new BoxEntity { Position = new Vector2(32 + offset, 48), Size = new Vector2(128, 16) }; + Vector2 Feet(Point p) => new(p.X * 16 + 8, p.Y * 16 + 16); + bool Clear(Point p) + { + var feet = Feet(p); + return p.X >= 0 && p.X <= 12 && feet.Y >= 48 && feet.Y <= 64 + && !(feet.Y > train.Position.Y && feet.X + 8 > train.Box.Left && feet.X - 8 < train.Box.Right); + } + bool Stand(Point p) => Clear(p) && (Feet(p).Y == 64 || CrowdRules.SurfaceSupports(Feet(p), 16, train.Position, train.Size, true)); + foreach (int startX in new[] { 0, 12 }) + { + var path = CrowdNavigation.FindPath(new(startX, 3), new(4, 2), Stand, Clear); + Assert.IsTrue(path.Count > 0, "A fractionally aligned 16px train step must not require a high jump."); + for (int i = 1; i < path.Count; i++) + { + Assert.IsTrue(CrowdNavigation.CanWalkBetween(path[i - 1], path[i], Clear, Stand)); + } + } + } + + [TestMethod] + [DataRow(512f)] + [DataRow(331200f)] + public void CrowdStepsOntoCustomTrainFloorAndReachesDoor(float floorY) + { + var train = new BoxEntity { Position = new Vector2(32, floorY - 16), Size = new Vector2(128, 16) }; + var body = new BoxImpl { Position = new Vector2(0, floorY - 48), Size = new Vector2(16, 48), Gravity = 1 }; + var door = new Vector2(72, train.Position.Y); + for (int tick = 0; tick < 100; tick++) + { + var feet = body.Position + new Vector2(8, 48); + body.Velocity = new Vector2(Math.Clamp(door.X - feet.X, -1.5f, 1.5f), 0.3f); + if (CrowdRules.TryStepHeight(feet, 16, body.Velocity.X, train.Position, train.Size, out float rise)) + { + body.Position -= new Vector2(0, rise); + } + Vector2 stride = body.Velocity; + stride.Y = Math.Min(stride.Y, floorY - body.Position.Y - 48); + if (train.Collision(body, stride, out var collision)) + { + stride = collision.Stride; + } + body.Position += stride; + } + Assert.IsTrue(CrowdRules.CanBoard(true, true, Vector2.Zero, 100, 600, + body.Position + new Vector2(8, 48), door), "The traveler must physically reach the deck, not stop against its side."); + Assert.AreEqual(train.Position.Y, body.Position.Y + 48, 0.1f); + } + + [TestMethod] + public void CustomFloorStepRejectsHighWallsAndDistantSurfaces() + { + Assert.IsFalse(CrowdRules.TryStepHeight(new Vector2(24, 64), 16, 1.5f, new Vector2(32, 32), new Vector2(128, 16), out _)); + Assert.IsFalse(CrowdRules.TryStepHeight(new Vector2(8, 64), 16, 1.5f, new Vector2(32, 48), new Vector2(128, 16), out _)); + } + + [TestMethod] + public void GridLandingRejectsHalfBlocksAndSlopesWithDifferentSurfaceHeight() + { + Assert.IsTrue(CrowdRules.SupportsGridLanding(true, true, false, 0)); + Assert.IsFalse(CrowdRules.SupportsGridLanding(true, true, true, 0)); + Assert.IsFalse(CrowdRules.SupportsGridLanding(true, true, false, 1)); + Assert.IsFalse(CrowdRules.SupportsGridLanding(false, true, false, 0)); + Assert.IsFalse(CrowdRules.SupportsGridLanding(true, false, false, 0)); + } + + [TestMethod] + public void WaypointsRequireLandingAtThePlannedTakeoffPosition() + { + Assert.IsTrue(CrowdRules.ReachedWaypoint(Vector2.Zero, Vector2.Zero, true)); + Assert.IsFalse(CrowdRules.ReachedWaypoint(new Vector2(9, 0), Vector2.Zero, true)); + Assert.IsFalse(CrowdRules.ReachedWaypoint(new Vector2(0, 9), Vector2.Zero, true)); + Assert.IsFalse(CrowdRules.ReachedWaypoint(Vector2.Zero, Vector2.Zero, false)); + } + + [TestMethod] + public void BoardingRequiresStoppedActiveTrainAndNearbyDoor() + { + Assert.IsTrue(CrowdRules.CanBoard(true, true, Vector2.Zero, 60, 600, new Vector2(12, 4), Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(false, true, Vector2.Zero, 60, 600, Vector2.Zero, Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(true, false, Vector2.Zero, 60, 600, Vector2.Zero, Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(true, true, Vector2.UnitX, 60, 600, Vector2.Zero, Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(true, true, Vector2.Zero, 60, 600, new Vector2(100, 0), Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(true, true, Vector2.Zero, 60, 600, new Vector2(0, 80), Vector2.Zero)); + } + + [TestMethod] + public void BoardingWindowExcludesArrivalAndDeparture() + { + Assert.IsFalse(CrowdRules.CanBoard(true, true, Vector2.Zero, 0, 600, Vector2.Zero, Vector2.Zero)); + Assert.IsTrue(CrowdRules.CanBoard(true, true, Vector2.Zero, 30, 600, Vector2.Zero, Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(true, true, Vector2.Zero, 510, 600, Vector2.Zero, Vector2.Zero)); + } + + [TestMethod] + public void BoardingRejectsNonFinitePositionOrSpeed() + { + Assert.IsFalse(CrowdRules.CanBoard(true, true, new Vector2(float.NaN, 0), 60, 600, Vector2.Zero, Vector2.Zero)); + Assert.IsFalse(CrowdRules.CanBoard(true, true, Vector2.Zero, 60, 600, new Vector2(float.NaN, 0), Vector2.Zero)); + } + + [TestMethod] + public void ExperimentsRequireSinglePlayerAndYggdrasil() + { + Assert.IsTrue(CrowdRules.CanRun(0, true)); + Assert.IsFalse(CrowdRules.CanRun(1, true)); + Assert.IsFalse(CrowdRules.CanRun(2, true)); + Assert.IsFalse(CrowdRules.CanRun(0, false)); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs new file mode 100644 index 000000000..b5f9af1e8 --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Microsoft.Xna.Framework; +using Terraria; +using Terraria.DataStructures; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +[DoNotParallelize] +public class CrowdSittingTests +{ + [TestInitialize] + public void Initialize() => Program.SavePath = string.Empty; + + [TestMethod] + [DataRow(-1, -4f)] + [DataRow(1, -4f)] + [DataRow(1, 0f)] + public void LightweightLegSlicesMatchInstalledTmlDrawSittingLegs(int direction, float seatOffset) + { + var method = typeof(CrowdAppearance).GetMethod("SittingLegStrip", BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(method); + var player = new Player { width = 16, height = 48, direction = direction, legFrame = new Rectangle(0, 0, 40, 56) }; + var position = new Vector2(100, 200); + var draw = new PlayerDrawSet + { + drawPlayer = player, + Position = position - new Vector2(8, 0) + Main.screenPosition, + legVect = new Vector2(20, 28), + seatYOffset = seatOffset, + DrawDataCache = [], + }; + object?[] native = [draw, null, Color.White, 0, false, null]; + typeof(Terraria.DataStructures.PlayerDrawLayers).GetMethod("DrawSittingLegs", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, native); + var expected = ((PlayerDrawSet)native[0]!).DrawDataCache; + Assert.AreEqual(3, expected.Count); + for (int index = 0; index < 3; index++) + { + object?[] args = [position, direction, 2 - index, seatOffset, null, null]; + method.Invoke(null, args); + Assert.AreEqual(expected[index].sourceRect, (Rectangle)args[4]!); + Assert.AreEqual(expected[index].position - expected[index].origin, + (Vector2)args[5]! - new Vector2(20, 0)); + } + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs new file mode 100644 index 000000000..e81a30ffe --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs @@ -0,0 +1,1654 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Everglow.Commons.CustomTiles; +using Everglow.Commons.CustomTiles.Core; +using Everglow.Yggdrasil.YggdrasilTown.Biomes; +using Everglow.Yggdrasil.YggdrasilTown.CustomTiles; +using Everglow.Yggdrasil.Mechanics.TownCrowd; +using Everglow.Yggdrasil.YggdrasilTown.Tiles.OriginPylon; +using Microsoft.Xna.Framework; +using Terraria; +using Terraria.ID; +using Terraria.ModLoader; +using Terraria.Utilities; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +[DoNotParallelize] +public class CrowdTownPlanningTests +{ + private sealed class CrowdChanceRandom(int roll) : UnifiedRandom(42) + { + public override int Next(int maxValue) => maxValue == 100 ? roll : base.Next(maxValue); + } + + private readonly List restore = []; + + private static CrowdRetirement RetirementOf(object traveler) => (CrowdRetirement)traveler.GetType() + .GetField("retirement", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)!; + + [TestMethod] + public void SightseeingBudgetWaitPausesRetirementButKeepsActualExitFailures() + { + EnableJourneys(); + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); + type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Planning")); + typeof(TownCrowdSystem).GetField("planningBudget", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, 0); + for (int attempt = 0; attempt < 3; attempt++) + { + type.GetMethod("ChooseNextActivity", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, null); + } + Assert.AreEqual(3, RetirementOf(traveler).DepartureFailures, "This plaza has no return pylon; each rejected return is a real failure."); + Assert.AreEqual(true, type.GetField("waitingForBudget", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); + Assert.IsTrue((int)type.GetField("planDelay", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)! < 5); + for (int tick = 0; tick < 3600; tick++) + { + type.GetMethod("UpdateRetirement", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, null); + } + Assert.AreEqual(CrowdRetirementReason.None, RetirementOf(traveler).Reasons); + } + + [TestMethod] + public void EscapeProgressDoesNotEraseAnInterruptedExitFailure() + { + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + var body = (Entity)type.GetProperty("Body")!.GetValue(traveler)!; + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); + type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Fleeing")); + type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, + Activator.CreateInstance(PlaceType, CrowdActivity.LeavePylon, new Point(110, 63), "exit", new Vector2(1768, 1024), null, 0)!); + type.GetField("movementPending", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, true); + type.GetField("movementTarget", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, new Vector2(1200, 1024)); + var retirement = RetirementOf(traveler); + for (int i = 0; i < 3; i++) { retirement.RecordDepartureFailure(); } + for (int tick = 0; tick < 1800; tick++) { retirement.ObserveDeparture(true, false, false); } + var observe = type.GetMethod("UpdateRetirement", BindingFlags.Instance | BindingFlags.NonPublic)!; + observe.Invoke(traveler, null); + body.position.X -= 20; + observe.Invoke(traveler, null); + Assert.AreEqual(0, retirement.MovementTicks, "Escape movement is legitimate physical progress."); + Assert.AreEqual(3, retirement.DepartureFailures, "Escaping along a different route is not progress on the interrupted exit."); + Assert.IsTrue(retirement.Reasons.HasFlag(CrowdRetirementReason.Departure)); + } + + [TestMethod] + [DataRow("Activity", false)] + [DataRow("Conversing", false)] + [DataRow("Waiting", false)] + [DataRow("Riding", false)] + [DataRow("Planning", true)] + public void NormalRestsAndBudgetWaitsNeverAccumulateRetirementTime(string stage, bool budgetWait) + { + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, stage)); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); + type.GetField("waitingForBudget", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, budgetWait); + type.GetField("movementPending", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, true); + var retirement = RetirementOf(traveler); + for (int i = 0; i < 3; i++) + { + retirement.RecordMovementRecovery(); + retirement.RecordDepartureFailure(); + } + for (int tick = 0; tick < 3600; tick++) + { + type.GetMethod("UpdateRetirement", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, null); + } + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + Assert.AreEqual(1f, retirement.Opacity); + } + + [TestMethod] + public void PhysicalProgressRestoresOpacityWithoutChangingRouteStageOrVelocity() + { + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + var body = (Entity)type.GetProperty("Body")!.GetValue(traveler)!; + body.velocity = new Vector2(1.5f, 0); + type.GetField("movementPending", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, true); + type.GetField("movementTarget", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, new Vector2(1800, 1024)); + var retirement = RetirementOf(traveler); + for (int i = 0; i < 3; i++) { retirement.RecordMovementRecovery(); } + for (int tick = 0; tick < 900; tick++) { retirement.ObserveMovement(true, false, false); } + for (int tick = 0; tick < 60; tick++) { retirement.Update(); } + var route = type.GetField("route", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler); + var observe = type.GetMethod("UpdateRetirement", BindingFlags.Instance | BindingFlags.NonPublic)!; + observe.Invoke(traveler, null); + float low = retirement.Opacity; + body.position.X += 20; + observe.Invoke(traveler, null); + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + Assert.IsTrue(retirement.Opacity > low && retirement.Opacity < 1); + for (int tick = 0; tick < 29; tick++) { observe.Invoke(traveler, null); } + Assert.AreEqual(1f, retirement.Opacity); + Assert.AreSame(route, type.GetField("route", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); + Assert.AreEqual("Walking", type.GetProperty("Stage")!.GetValue(traveler)!.ToString()); + Assert.AreEqual("original", type.GetProperty("DestinationKey")!.GetValue(traveler)); + Assert.AreEqual(new Vector2(1.5f, 0), body.velocity); + } + + [TestMethod] + public void ReplanningWithoutPhysicalProgressEventuallyRetiresBlockedWalker() + { + EnableJourneys(); + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + var body = (Entity)type.GetProperty("Body")!.GetValue(traveler)!; + bool beganFading = false; + for (int tick = 0; tick < 22 * 60; tick++) + { + // Simulate an obstruction unknown to the tile route planner. + body.Bottom = new Vector2(1608, 1024); + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Update")!.Invoke(traveler, null); + beganFading |= RetirementOf(traveler).Reasons.HasFlag(CrowdRetirementReason.Blocked); + } + Assert.IsTrue(beganFading); + Assert.AreEqual(false, type.GetProperty("Active")!.GetValue(traveler)); + } + + [TestMethod] + public void EnclosedReturnerRetiresAfterRealFailedDepartureAttempts() + { + EnableJourneys(); + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); + type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Planning")); + for (int y = 0; y < 64; y++) + { + foreach (int x in new[] { 98, 102 }) + { + var tile = Main.tile[x, y]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + } + bool beganFading = false; + for (int tick = 0; tick < 33 * 60; tick++) + { + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Update")!.Invoke(traveler, null); + beganFading |= RetirementOf(traveler).Reasons.HasFlag(CrowdRetirementReason.Departure); + } + Assert.IsTrue(beganFading); + Assert.AreEqual(false, type.GetProperty("Active")!.GetValue(traveler)); + } + + [TestMethod] + public void FadeKeepsConversationUntilCompletionThenCleansBothPartnersAndBubbleOnce() + { + EnableJourneys(); + ResetThreats(); + var first = CreateWalkingActor(new Vector2(1608, 1024)); + var second = CreateWalkingActor(new Vector2(1656, 1024)); + var type = first.GetType(); + var clear = ConversationType.GetMethod("Clear", BindingFlags.Static | BindingFlags.NonPublic)!; + clear.Invoke(null, null); + restore.Add(() => clear.Invoke(null, null)); + Assert.IsTrue((bool)ConversationType.GetMethod("BeginPeers", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, [first, second, 600])!); + var bubbles = (Dictionary)typeof(Terraria.GameContent.UI.EmoteBubble) + .GetField("byID", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var oldBubbles = bubbles.Keys.ToHashSet(); + restore.Add(() => { foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) { bubbles.Remove(id); } }); + var retirement = RetirementOf(first); + for (int i = 0; i < 3; i++) { retirement.RecordDepartureFailure(); } + for (int tick = 0; tick < 1800; tick++) { retirement.ObserveDeparture(true, false, false); } + type.GetProperty("Intent")!.SetValue(first, CrowdIntent.ReturnHome); + var view = (CrowdAppearance)type.GetField("view", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(first)!; + view.UpdateController = _ => { }; + view.DrawInForeground = () => true; + for (int tick = 0; tick < 119; tick++) + { + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Update")!.Invoke(first, null); + Assert.AreEqual("Conversing", type.GetProperty("Stage")!.GetValue(first)!.ToString()); + Assert.AreEqual("Conversing", type.GetProperty("Stage")!.GetValue(second)!.ToString()); + } + Assert.IsTrue(view.RetirementOpacity > 0 && view.RetirementOpacity < 0.01f); + Assert.IsTrue(bubbles.Values.Any(bubble => !oldBubbles.Contains(bubble.WhoAmI) && bubble.lifeTime > 0)); + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Update")!.Invoke(first, null); + Assert.AreEqual(false, type.GetProperty("Active")!.GetValue(first)); + Assert.AreEqual("Walking", type.GetProperty("Stage")!.GetValue(second)!.ToString()); + Assert.IsFalse(bubbles.Values.Any(bubble => !oldBubbles.Contains(bubble.WhoAmI) && bubble.lifeTime > 0)); + Assert.IsFalse(view.Active || view.Visible); + Assert.IsNull(view.UpdateController); + Assert.IsNull(view.DrawInForeground); + Assert.IsNull(type.GetProperty("DestinationKey")!.GetValue(first)); + Assert.IsFalse((bool)type.GetMethod("ReservesStandingPoint", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(first, [new Point(110, 63)])!); + long peerCooldown = (long)type.GetField("nextConversation", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(second)!; + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Kill")!.Invoke(first, null); + type.GetMethod("Update")!.Invoke(first, null); + Assert.AreEqual(peerCooldown, type.GetField("nextConversation", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(second)); + } + + [TestMethod] + public void PausedSimulationDoesNotAdvanceFadeOrBehavior() + { + EnableJourneys(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + var retirement = RetirementOf(traveler); + retirement.SetReason(CrowdRetirementReason.Threat, true); + for (int tick = 0; tick < 60; tick++) { retirement.Update(); } + bool oldPaused = Main.gamePaused; + restore.Add(() => Main.gamePaused = oldPaused); + Main.gamePaused = true; + for (int tick = 0; tick < 300; tick++) { type.GetMethod("Update")!.Invoke(traveler, null); } + Assert.AreEqual(0.5f, retirement.Opacity, 0.0001f); + Assert.AreEqual(0, type.GetField("age", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); + } + + [TestMethod] + public void TrappedVisibleTravelerRetiresAfterSustainedThreat() + { + EnableJourneys(); + ResetThreats(); + AddEnemy(new Vector2(1800, 1024)); + CaptureThreats(1); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + var body = (Entity)type.GetProperty("Body")!.GetValue(traveler)!; + for (int tick = 0; tick < 12 * 60 + 2; tick++) + { + body.Bottom = new Vector2(1608, 1024); + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Update")!.Invoke(traveler, null); + } + Assert.AreEqual(false, type.GetProperty("Active")!.GetValue(traveler), "A continuously threatened traveler trapped on screen must eventually retire."); + } + + [TestMethod] + public void DensityChangeKeepsExistingActivitiesAndDisablingCleansUpTravelers() + { + EnableJourneys(); + ResetThreats(); + var settings = typeof(TownCrowdSystem).GetProperty(nameof(TownCrowdSystem.Settings))!; + var oldSettings = settings.GetValue(null); + restore.Add(() => settings.SetValue(null, oldSettings)); + var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var previous = travelers.Cast().ToArray(); + travelers.Clear(); + restore.Add(() => { travelers.Clear(); foreach (var person in previous) { travelers.Add(person); } }); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + travelers.Add(traveler); + var apply = typeof(TownCrowdSystem).GetMethod("UpdateConfiguration", BindingFlags.Static | BindingFlags.NonPublic)!; + foreach (var density in new[] { CrowdDensity.Busy, CrowdDensity.Sparse }) + { + apply.Invoke(null, [new CrowdConfig { Density = density }]); + Assert.AreSame(CrowdSettings.ForDensity(density), TownCrowdSystem.Settings); + Assert.AreSame(traveler, travelers[0]); + Assert.AreEqual("Walking", traveler.GetType().GetProperty("Stage")!.GetValue(traveler)!.ToString()); + Assert.AreEqual("original", traveler.GetType().GetProperty("DestinationKey")!.GetValue(traveler)); + } + apply.Invoke(null, [new CrowdConfig { Enabled = false }]); + Assert.IsFalse(TownCrowdSystem.IsRunning); + Assert.AreEqual(0, travelers.Count); + Assert.AreEqual(false, traveler.GetType().GetProperty("Active")!.GetValue(traveler)); + } + + [TestMethod] + [DataRow(false, NetmodeID.MultiplayerClient)] + [DataRow(true, NetmodeID.Server)] + public void ClientConfigurationCannotEnableMultiplayerCrowds(bool server, int netMode) + { + EnableJourneys(); + Main.dedServ = server; + Main.netMode = netMode; + var apply = typeof(TownCrowdSystem).GetMethod("UpdateConfiguration", BindingFlags.Static | BindingFlags.NonPublic)!; + apply.Invoke(null, [new CrowdConfig()]); + Assert.IsFalse(TownCrowdSystem.IsRunning); + } + private static Type ConversationType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdConversation")!; + private static Type PlacesType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdPlaces")!; + private static Type PlaceType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdPlace")!; + private static Type ThreatsType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdThreats")!; + + private void ResetThreats() + { + ThreatsType.GetMethod("Clear")!.Invoke(null, null); + restore.Add(() => ThreatsType.GetMethod("Clear")!.Invoke(null, null)); + } + + private static void CaptureThreats(long clock) + { + typeof(TownCrowdSystem).GetField("clock", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, clock); + ThreatsType.GetMethod("Update")!.Invoke(null, [clock]); + } + + private static bool SpawnSafe(Vector2 point) => (bool)ThreatsType.GetMethod("SpawnAllowed")!.Invoke(null, [point])!; + + private NPC AddEnemy(Vector2 feet, bool boss = false) + { + var npc = Main.npc[0]; + npc.active = true; + npc.friendly = false; + npc.damage = 20; + npc.life = 100; + npc.width = 20; + npc.height = 40; + npc.type = NPCID.Zombie; + npc.boss = boss; + npc.Bottom = feet; + return npc; + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, false)] + [DataRow(true, true)] + public void ArenaResidentsOnlyBlockSpawnsDuringActualFight(bool started, bool failed) + { + ResetThreats(); + var town = (Everglow.Yggdrasil.YggdrasilTown.NPCs.TownNPCs.InnKeeper)RuntimeHelpers.GetUninitializedObject( + typeof(Everglow.Yggdrasil.YggdrasilTown.NPCs.TownNPCs.InnKeeper)); + town.ArenaFighting = true; + town.StartedFight = started; + town.Fail = failed; + var npc = AddEnemy(new Vector2(1608, 1024), true); + npc.friendly = true; + typeof(NPC).GetProperty(nameof(NPC.ModNPC))!.SetValue(npc, town); + CaptureThreats(100); + Assert.AreEqual(!started || failed, SpawnSafe(npc.Bottom)); + Assert.IsTrue(SpawnSafe(npc.Bottom + new Vector2(6000, 0)), "Combat restrictions are local."); + } + + [TestMethod] + public void TownEventBlocksLocalArrivalsThenRecoversWithoutBurst() + { + ResetThreats(); + var instance = typeof(ContentInstance).GetProperty("Instance")!; + var old = instance.GetValue(null); + restore.Add(() => instance.SetValue(null, old)); + var invasion = new Everglow.Yggdrasil.YggdrasilTown.Events.DrunkenMinerInvasion(); + instance.SetValue(null, invasion); + var active = typeof(Everglow.Commons.Mechanics.Events.ModEvent).GetProperty("Active")!; + active.SetValue(invasion, true); + var area = (Rectangle)PlacesType.GetProperty("TownBounds")!.GetValue(null)!; + Vector2 inside = area.Center.ToVector2(); + Vector2 outside = new(area.Right + 1000, area.Bottom + 1000); + Assert.IsFalse(SpawnSafe(inside), "An event activated after the snapshot must block births immediately."); + CaptureThreats(100); + Assert.IsFalse(SpawnSafe(inside)); + Assert.IsTrue(SpawnSafe(outside)); + active.SetValue(invasion, false); + CaptureThreats(106); + Assert.IsFalse(SpawnSafe(inside)); + CaptureThreats(400); + Assert.IsTrue(SpawnSafe(inside)); + ThreatsType.GetMethod("RecordArrival")!.Invoke(null, [inside]); + Assert.IsFalse(SpawnSafe(inside), "Several spawn paths in one frame must share the recovery throttle."); + CaptureThreats(580); + Assert.IsTrue(SpawnSafe(inside)); + } + + [TestMethod] + public void EscapeRouteMovesAwayWithoutCrossingTheMonster() + { + ResetThreats(); + AddEnemy(new Vector2(1768, 1024)); + CaptureThreats(100); + new TownCrowdSystem().PreUpdateNPCs(); + object?[] args = [new Point(100, 63), 1, 42, null, 0]; + Assert.IsTrue((bool)typeof(TownCrowdSystem).GetMethod("TryEscape", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, args)!); + var path = (List)args[3]!; + Assert.IsTrue(path[^1].X < 100); + Assert.IsTrue(path.All(point => point.X <= 100 && point.Y == 63)); + Assert.IsFalse((bool)ThreatsType.GetMethod("EscapeAllowed")!.Invoke(null, [new Vector2(1608, 1024), new Vector2(1768, 1024)])!); + } + + [TestMethod] + public void AllBirthSourcesRejectBossAreaBeforeAllocatingGraphics() + { + ResetThreats(); + AddEnemy(new Vector2(1608, 1024), true); + CaptureThreats(100); + var oldMode = TownCrowdSystem.IsRunning; + restore.Add(() => typeof(TownCrowdSystem).GetProperty("IsRunning")!.SetValue(null, oldMode)); + typeof(TownCrowdSystem).GetProperty("IsRunning")!.SetValue(null, true); + var arrival = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdArrival")!; + var spawn = typeof(TownCrowdSystem).GetMethod("TrySpawnFrom", BindingFlags.Static | BindingFlags.NonPublic)!; + foreach (string source in new[] { "Street", "Building", "Pylon", "Existing" }) + { + Assert.IsFalse((bool)spawn.Invoke(null, [new Point(100, 63), Enum.Parse(arrival, source), null])!, source); + } + var create = typeof(TownCrowdSystem).GetMethod("Create", BindingFlags.Static | BindingFlags.NonPublic)!; + Assert.IsFalse((bool)create.Invoke(null, [new List { new(100, 63) }, null, Enum.Parse(arrival, "Train"), null, + new YggdrasilTown_NormalMetro(), new Vector2(1608, 1024), CrowdIntent.VisitTown])!); + } + + [TestMethod] + public void EscapeCanUseShortPlatformBeforeItsSafeEdge() + { + ResetThreats(); + for (int x = 5; x < 94; x++) + { + var tile = Main.tile[x, 64]; + tile.HasTile = false; + } + AddEnemy(new Vector2(1768, 1024)); + CaptureThreats(100); + new TownCrowdSystem().PreUpdateNPCs(); + object?[] args = [new Point(100, 63), -1, 42, null, 0]; + Assert.IsTrue((bool)typeof(TownCrowdSystem).GetMethod("TryEscape", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, args)!); + var path = (List)args[3]!; + Assert.IsTrue(path[^1].X is >= 94 and < 100); + Assert.IsTrue(path.All(point => point.X >= 94 && point.Y == 63)); + } + + [TestMethod] + public void FleeingCanRecenterItsFeetBeforePlanningAtALedge() + { + ResetThreats(); + var floor = Main.tile[100, 64]; + floor.HasTile = false; + AddEnemy(new Vector2(1448, 1024)); + CaptureThreats(100); + var body = new NPC { width = 16, height = 48 }; + body.Bottom = new Vector2(1608.003f, 1024); + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string name, object value) => type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, body); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Fleeing")); + Set("route", new List()); + Set("speed", 1.5f); + Set("escapeDirection", 1); + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("Flee", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, [true]); + Assert.IsTrue(body.velocity.X > 0, "A partially supported start must recenter on its supported neighbor before pathfinding."); + } + + private void EnableJourneys() + { + var oldPlayers = Main.player; + restore.Add(() => Main.player = oldPlayers); + Main.player = []; + var current = typeof(SubworldLibrary.SubworldSystem).GetField("current", BindingFlags.Static | BindingFlags.NonPublic)!; + var oldWorld = current.GetValue(null); + var oldMode = TownCrowdSystem.IsRunning; + int oldNetMode = Main.netMode; + bool oldServer = Main.dedServ; + restore.Add(() => + { + current.SetValue(null, oldWorld); + typeof(TownCrowdSystem).GetProperty("IsRunning")!.SetValue(null, oldMode); + Main.netMode = oldNetMode; + Main.dedServ = oldServer; + }); + current.SetValue(null, new Everglow.Yggdrasil.YggdrasilWorld()); + typeof(TownCrowdSystem).GetProperty("IsRunning")!.SetValue(null, true); + Main.netMode = NetmodeID.SinglePlayer; + Main.dedServ = false; + Main.screenPosition = new Vector2(1000, 500); + Main.screenWidth = 1200; + Main.screenHeight = 900; + } + + private static void SetJourneyBody(object journey, Entity body) + { + var actorType = journey.GetType().Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdBody")!; + var actor = Activator.CreateInstance(actorType, true)!; + actorType.GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(actor, body); + journey.GetType().GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, actor); + journey.GetType().GetField("avoidance", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, new CrowdAvoidance()); + journey.GetType().GetField("retirement", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, new CrowdRetirement()); + journey.GetType().GetField("movementProgress", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, new CrowdProgress()); + journey.GetType().GetField("view", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, new CrowdAppearance { Active = true, Visible = true }); + } + + private static object CreateWalkingActor(Vector2 feet) + { + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string name, object value) => type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + var actorType = type.Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdBody")!; + var actor = Activator.CreateInstance(actorType, true)!; + var body = (Entity)actorType.GetProperty("Body")!.GetValue(actor)!; + body.Bottom = feet; + SetJourneyBody(journey, body); + Set("k__BackingField", actor); + Set("k__BackingField", true); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Walking")); + Set("view", new CrowdAppearance { Active = true, Visible = true }); + Set("route", new List { new(100, 63), new(110, 63) }); + Set("waypoint", 1); + Set("destination", Activator.CreateInstance(PlaceType, CrowdActivity.Scenery, new Point(110, 63), "original", new Vector2(1768, 1024), null, 0)!); + Set("recentPlaces", new Queue()); + Set("speed", 1.5f); + Set("pauseInterval", 400); + Set("routeSeed", 42); + return journey; + } + + [TestMethod] + [DataRow(-1)] + [DataRow(1)] + public void EvacuationWalksOffSolidWoodPlatformWithoutRecenterOscillation(int direction) + { + ResetThreats(); + EnableJourneys(); + bool solid = Main.tileSolid[TileID.WoodBlock]; + restore.Add(() => Main.tileSolid[TileID.WoodBlock] = solid); + Main.tileSolid[TileID.WoodBlock] = true; + for (int x = 94; x <= 106; x++) + { + var tile = Main.tile[x, 62]; + tile.HasTile = true; + tile.TileType = TileID.WoodBlock; + } + var journey = CreateWalkingActor(new Vector2(1608, 992)); + var body = (Entity)journey.GetType().GetProperty("Body")!.GetValue(journey)!; + AddEnemy(new Vector2(1608 - direction * 200, 992)); + for (int tick = 100; tick < 340; tick++) + { + CaptureThreats(tick); + new TownCrowdSystem().PreUpdateNPCs(); + journey.GetType().GetMethod("Update")!.Invoke(journey, null); + } + Assert.AreEqual(1024f, body.Bottom.Y, 0.1f, "Must descend past the platform edge onto the ground one air tile below."); + Assert.IsTrue((body.Bottom.X - 1608) * direction > 160, "Must continue escaping instead of alternately recentering and stepping off."); + } + + [TestMethod] + [DataRow("horizontal")] + [DataRow("vertical")] + [DataRow("wall")] + [DataRow("danger")] + public void TravelersConverseInPlaceAndResumeTheirOriginalRoutes(string arrangement) + { + ResetThreats(); + EnableJourneys(); + var clear = ConversationType.GetMethod("Clear", BindingFlags.Static | BindingFlags.NonPublic)!; + clear.Invoke(null, null); + restore.Add(() => clear.Invoke(null, null)); + var first = CreateWalkingActor(new Vector2(1608, 1024)); + Vector2 secondFeet = arrangement == "vertical" ? new Vector2(1608, 960) : new Vector2(1656, 1024); + if (arrangement == "vertical") + { + bool solid = Main.tileSolid[TileID.Platforms], top = Main.tileSolidTop[TileID.Platforms]; + restore.Add(() => { Main.tileSolid[TileID.Platforms] = solid; Main.tileSolidTop[TileID.Platforms] = top; }); + Main.tileSolid[TileID.Platforms] = Main.tileSolidTop[TileID.Platforms] = true; + var tile = Main.tile[100, 60]; + tile.HasTile = true; + tile.TileType = TileID.Platforms; + } + if (arrangement == "wall") + { + for (int y = 58; y <= 63; y++) + { + var tile = Main.tile[102, y]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + } + var second = CreateWalkingActor(secondFeet); + if (arrangement == "danger") + { + AddEnemy(new Vector2(1768, 1024)); + } + CaptureThreats(100); + var begin = ConversationType.GetMethod("BeginPeers", BindingFlags.Static | BindingFlags.NonPublic); + Assert.IsNotNull(begin, "Lightweight peers need a shared conversation activity."); + bool started = (bool)begin.Invoke(null, [first, second, 600])!; + Assert.AreEqual(arrangement is "horizontal" or "vertical", started); + if (!started) { return; } + var type = first.GetType(); + Assert.AreEqual("Conversing", type.GetProperty("Stage")!.GetValue(first)!.ToString()); + Assert.AreEqual("Conversing", type.GetProperty("Stage")!.GetValue(second)!.ToString()); + Assert.IsFalse((bool)begin.Invoke(null, [first, second, 600])!, "A traveler cannot join two simultaneous conversations."); + var bubbles = (Dictionary)typeof(Terraria.GameContent.UI.EmoteBubble) + .GetField("byID", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var oldBubbles = bubbles.Keys.ToHashSet(); + restore.Add(() => { foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) { bubbles.Remove(id); } }); + var continueChat = ConversationType.GetMethod("ContinueNearby", BindingFlags.Static | BindingFlags.NonPublic)!; + for (int turn = 0; turn < 2; turn++) + { + CaptureThreats(100 + turn * 120); + Assert.IsTrue((bool)continueChat.Invoke(null, [first])!); + var bubble = bubbles.Values.Single(bubble => !oldBubbles.Contains(bubble.WhoAmI) && bubble.lifeTime > 0); + Assert.AreSame(type.GetProperty("Body")!.GetValue(turn == 0 ? first : second), bubble.anchor.entity); + } + CaptureThreats(700); + type.GetMethod("Update")!.Invoke(first, null); + foreach (var person in new[] { first, second }) + { + Assert.AreEqual("Walking", type.GetProperty("Stage")!.GetValue(person)!.ToString()); + Assert.AreEqual("original", type.GetProperty("DestinationKey")!.GetValue(person)); + } + Assert.IsFalse((bool)begin.Invoke(null, [first, second, 600])!, "Cooldown must prevent immediate repeat conversations."); + } + + [TestMethod] + [DataRow(99, false)] + [DataRow(0, true)] + public void WalkingOpportunityUsesTunedChanceAndDangerInterruptsBothPeers(int roll, bool converses) + { + ResetThreats(); + EnableJourneys(); + var clear = ConversationType.GetMethod("Clear", BindingFlags.Static | BindingFlags.NonPublic)!; + clear.Invoke(null, null); + restore.Add(() => clear.Invoke(null, null)); + var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var previous = travelers.Cast().ToArray(); + travelers.Clear(); + restore.Add(() => { travelers.Clear(); foreach (var person in previous) { travelers.Add(person); } }); + var first = CreateWalkingActor(new Vector2(1608, 1024)); + var second = CreateWalkingActor(new Vector2(1656, 1024)); + travelers.Add(first); + travelers.Add(second); + var bubbles = (Dictionary)typeof(Terraria.GameContent.UI.EmoteBubble).GetField("byID", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var oldBubbles = bubbles.Keys.ToHashSet(); + restore.Add(() => { foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) { bubbles.Remove(id); } }); + Main.rand = new CrowdChanceRandom(roll); + CaptureThreats(77); // PreUpdateNPCs advances to the 120-tick opportunity for seed 42. + new TownCrowdSystem().PreUpdateNPCs(); + var type = first.GetType(); + type.GetMethod("Update")!.Invoke(first, null); + Assert.AreEqual(converses, type.GetProperty("Stage")!.GetValue(first)!.ToString() == "Conversing"); + if (!converses) { return; } + AddEnemy(new Vector2(1800, 1024)); + CaptureThreats(84); + type.GetMethod("Update")!.Invoke(first, null); + Assert.AreEqual("Fleeing", type.GetProperty("Stage")!.GetValue(first)!.ToString()); + Assert.AreEqual("Walking", type.GetProperty("Stage")!.GetValue(second)!.ToString()); + Assert.IsFalse(bubbles.Values.Any(bubble => !oldBubbles.Contains(bubble.WhoAmI) && bubble.lifeTime > 0)); + } + + [TestMethod] + [DataRow(-1, 0)] + [DataRow(1, 0)] + [DataRow(1, 27)] + public void ChairVisualAnchorMatchesInstalledPlayerSittingHelper(int direction, int style) + { + bool old = TileID.Sets.CanBeSatOnForPlayers[TileID.Chairs]; + restore.Add(() => TileID.Sets.CanBeSatOnForPlayers[TileID.Chairs] = old); + TileID.Sets.CanBeSatOnForPlayers[TileID.Chairs] = true; + for (int y = 62; y <= 63; y++) + { + var tile = Main.tile[100, y]; + tile.HasTile = true; + tile.TileType = TileID.Chairs; + tile.TileFrameX = (short)(direction == -1 ? 0 : 18); + tile.TileFrameY = (short)(style * 40 + (y - 62) * 18); + } + var player = new Player { width = 16, height = 48, direction = direction }; + player.Bottom = new Vector2(1608, 1024); + Assert.IsTrue(Terraria.GameContent.PlayerSittingHelper.GetSittingTargetInfo(player, 100, 63, out _, out var target, out var visual, out _)); + player.sitting.isSitting = true; + player.sitting.offsetForSeat = visual; + player.sitting.GetSittingOffsetInfo(player, out var posOffset, out float seatOffset); + var seats = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdSeats")!; + object?[] args = [player, new Point(100, 63), null]; + var actual = (Vector2)seats.GetMethod("VisualOffset")!.Invoke(null, args)!; + Assert.AreEqual(target - player.Bottom + posOffset, actual); + Assert.AreEqual(seatOffset, (float)args[2]!); + } + + [TestMethod] + public void SeatedActorActuallyWalksAwayAndRecoversAfterContinuousSafety() + { + ResetThreats(); + EnableJourneys(); + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string name, object value) => type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + var actorType = type.Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdBody")!; + var actor = Activator.CreateInstance(actorType, true)!; + var body = (Entity)actorType.GetProperty("Body")!.GetValue(actor)!; + body.Bottom = new Vector2(1608, 1024); + SetJourneyBody(journey, body); + Set("k__BackingField", actor); + Set("k__BackingField", true); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Activity")); + Set("destination", Activator.CreateInstance(PlaceType, CrowdActivity.Sit, new Point(100, 63), "seat", body.Bottom, null, 0)!); + Set("view", new CrowdAppearance { Active = true, Visible = true }); + Set("route", new List()); + Set("recentPlaces", new Queue()); + Set("speed", 1.5f); + Set("pauseInterval", 400); + Set("routeSeed", 42); + Assert.IsTrue((bool)type.GetProperty("IsSitting", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)!); + var enemy = AddEnemy(new Vector2(1768, 1024)); + var update = type.GetMethod("Update")!; + for (int tick = 100; tick < 220; tick++) + { + CaptureThreats(tick); + new TownCrowdSystem().PreUpdateNPCs(); + update.Invoke(journey, null); + Assert.IsTrue(body.Bottom.X <= 1608 && Math.Abs(body.Bottom.Y - 1024) < 0.1f); + } + Assert.IsTrue(body.Bottom.X < 1500, "The evacuation state must move the actual lightweight body, not only plan a route."); + Assert.AreEqual("Fleeing", type.GetProperty("Stage")!.GetValue(journey)!.ToString()); + Assert.IsFalse((bool)type.GetProperty("IsSitting", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)!); + enemy.active = false; + CaptureThreats(226); + // Freeze new planning so recovery can be observed without selecting another activity. + typeof(TownCrowdSystem).GetField("planningBudget", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, 0); + for (int i = 0; i < 179; i++) + { + update.Invoke(journey, null); + } + Assert.AreEqual("Fleeing", type.GetProperty("Stage")!.GetValue(journey)!.ToString()); + update.Invoke(journey, null); + Assert.AreEqual("Planning", type.GetProperty("Stage")!.GetValue(journey)!.ToString()); + Assert.IsTrue((bool)type.GetProperty("Active")!.GetValue(journey)!); + } + + [TestMethod] + [DataRow("Riding")] + [DataRow("Alighting")] + public void ThreatenedPassengerStaysOnDeckAndFollowsMovingTrain(string stage) + { + ResetThreats(); + EnableJourneys(); + AddEnemy(new Vector2(1768, 1024), true); + CaptureThreats(100); + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string name, object value) => type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + var body = new NPC { width = 16, height = 48 }; + body.Bottom = new Vector2(1608, 1008); + var train = new YggdrasilTown_NormalMetro { Active = true, Position = new Vector2(1200, 1008), Size = new Vector2(1696, 16), State = (int)YggdrasilTown_NormalMetro.BehaviorType.Move }; + SetJourneyBody(journey, body); + Set("k__BackingField", true); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, stage)); + Set("train", train); + Set("aboardOffset", body.position - train.Position); + Set("view", new CrowdAppearance()); + var update = type.GetMethod("Update")!; + update.Invoke(journey, null); + Assert.AreEqual("Riding", type.GetProperty("Stage")!.GetValue(journey)!.ToString()); + Vector2 before = body.position; + train.Position += new Vector2(100, 0); + update.Invoke(journey, null); + Assert.AreEqual(before + new Vector2(100, 0), body.position); + Assert.AreEqual(Vector2.Zero, body.velocity); + } + + [TestInitialize] + public void Initialize() + { + Program.SavePath = string.Empty; + var oldTiles = Main.tile; + int oldWidth = Main.maxTilesX; + int oldHeight = Main.maxTilesY; + var oldCenter = YggdrasilTownBiome.BiomeCenter; + var oldNpcs = Main.npc; + var oldProjectiles = Main.projectile; + var oldRandom = Main.rand; + var oldScreen = Main.screenPosition; + int oldScreenWidth = Main.screenWidth; + int oldScreenHeight = Main.screenHeight; + bool solid = Main.tileSolid[TileID.Stone]; + restore.Add(() => + { + Main.tile = oldTiles; + Main.maxTilesX = oldWidth; + Main.maxTilesY = oldHeight; + Main.npc = oldNpcs; + Main.projectile = oldProjectiles; + Main.rand = oldRandom; + Main.screenPosition = oldScreen; + Main.screenWidth = oldScreenWidth; + Main.screenHeight = oldScreenHeight; + Main.tileSolid[TileID.Stone] = solid; + YggdrasilTownBiome.BiomeCenter = oldCenter; + }); + Main.maxTilesX = 256; + Main.maxTilesY = 128; + Main.tile = (Tilemap)typeof(Tilemap).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, [typeof(ushort), typeof(ushort)], null)!.Invoke([(ushort)256, (ushort)128]); + Main.npc = Enumerable.Range(0, Main.maxNPCs).Select(_ => new NPC()).ToArray(); + Main.projectile = []; + var invasionInstance = typeof(ContentInstance).GetProperty("Instance")!; + var oldInvasion = invasionInstance.GetValue(null); + restore.Add(() => invasionInstance.SetValue(null, oldInvasion)); + invasionInstance.SetValue(null, new Everglow.Yggdrasil.YggdrasilTown.Events.DrunkenMinerInvasion()); + Main.rand = new UnifiedRandom(42); + Main.tileSolid[TileID.Stone] = true; + YggdrasilTownBiome.BiomeCenter = new Vector2(257, 191) * 16; + for (int x = 5; x < 251; x++) + { + var tile = Main.tile[x, 64]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + var manager = new ColliderManager(); + typeof(ColliderManager).GetField("rigidbodies", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(manager, new List()); + var instance = typeof(ContentInstance).GetProperty(nameof(ContentInstance.Instance))!; + var oldManager = instance.GetValue(null); + restore.Add(() => instance.SetValue(null, oldManager)); + instance.SetValue(null, manager); + // Use a fresh catalog without registering content or loading graphics. + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + foreach (string name in new[] { "doors", "pylons", "stations" }) + { + var list = (System.Collections.IList)PlacesType.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!; + var snapshot = list.Cast().ToArray(); + list.Clear(); + restore.Add(() => { list.Clear(); foreach (var entry in snapshot) { list.Add(entry); } }); + } + foreach (string name in new[] { "clock", "planningBudget" }) + { + var field = typeof(TownCrowdSystem).GetField(name, BindingFlags.Static | BindingFlags.NonPublic)!; + var previous = field.GetValue(null); + restore.Add(() => field.SetValue(null, previous)); + } + } + + [TestCleanup] + public void Cleanup() + { + foreach (var action in restore.AsEnumerable().Reverse()) + { + action(); + } + restore.Clear(); + } + + private static bool Plan(out List? path, Point? source = null, string? exclude = null, bool departing = false) + { + new TownCrowdSystem().PreUpdateNPCs(); + object?[] args = [source ?? new Point(100, 63), null, departing, null, null, exclude, CrowdIntent.VisitTown]; + bool success = (bool)typeof(TownCrowdSystem).GetMethod("TryPlan", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, args)!; + path = args[4] as List; + return success; + } + + [TestMethod] + [DataRow("deep")] + [DataRow("lava")] + [DataRow("partial")] + [DataRow("ascending")] + [DataRow("air")] + public void PlatformRecoveryRejectsUnsafeOrUnrelatedDrops(string condition) + { + bool oldSolid = Main.tileSolid[TileID.Platforms]; + bool oldTop = Main.tileSolidTop[TileID.Platforms]; + restore.Add(() => { Main.tileSolid[TileID.Platforms] = oldSolid; Main.tileSolidTop[TileID.Platforms] = oldTop; }); + Main.tileSolid[TileID.Platforms] = Main.tileSolidTop[TileID.Platforms] = true; + var slope = Main.tile[100, 62]; + slope.HasTile = condition != "air"; + slope.TileType = TileID.Platforms; + slope.Slope = SlopeType.SlopeDownLeft; + var npc = new NPC { width = 16, height = 48 }; + npc.Bottom = new Vector2(condition == "partial" ? 1610 : 1608, 992); + if (condition == "deep") + { + var floor = Main.tile[100, 64]; + floor.HasTile = false; + } + if (condition == "partial") + { + var floor = Main.tile[101, 64]; + floor.HasTile = false; + } + if (condition == "lava") + { + var lava = Main.tile[100, 63]; + lava.LiquidType = LiquidID.Lava; + lava.LiquidAmount = 255; + } + if (condition == "ascending") + { + npc.velocity.Y = -1; + } + var terrain = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdTerrain")!; + object?[] args = [npc, 0f]; + Assert.IsFalse((bool)terrain.GetMethod("TryPlatformRecovery")!.Invoke(null, args)!); + } + + [TestMethod] + [DataRow(1040f, 1024f)] + [DataRow(1008f, 1024f)] + [DataRow(1008f, 1012f)] + public void RecoveryEndsWhenLandingChangesOrBodyPassesTarget(float targetY, float feetY) + { + var current = typeof(SubworldLibrary.SubworldSystem).GetField("current", BindingFlags.Static | BindingFlags.NonPublic)!; + var oldWorld = current.GetValue(null); + var oldMode = TownCrowdSystem.IsRunning; + int oldNetMode = Main.netMode; + bool oldServer = Main.dedServ; + restore.Add(() => + { + current.SetValue(null, oldWorld); + typeof(TownCrowdSystem).GetProperty("IsRunning")!.SetValue(null, oldMode); + Main.netMode = oldNetMode; + Main.dedServ = oldServer; + }); + current.SetValue(null, new Everglow.Yggdrasil.YggdrasilWorld()); + typeof(TownCrowdSystem).GetProperty("IsRunning")!.SetValue(null, true); + Main.netMode = NetmodeID.SinglePlayer; + Main.dedServ = false; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string name, object value) => type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + var npc = new NPC { width = 16, height = 48 }; + npc.Bottom = new Vector2(1608, feetY); + SetJourneyBody(journey, npc); + Set("k__BackingField", true); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Planning")); + Set("recoveringPlatform", true); + Set("dropping", true); + Set("dropSourceY", targetY); + Set("recentPlaces", new Queue()); + // Force a deferred plan so this tests the recovery transition, not destination selection. + typeof(TownCrowdSystem).GetField("planningBudget", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, 0); + type.GetMethod("Update")!.Invoke(journey, null); + Assert.IsFalse((bool)type.GetField("recoveringPlatform", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)!); + Assert.IsFalse((bool)type.GetProperty("FallThroughPlatforms")!.GetValue(journey)!); + } + + [TestMethod] + [DataRow(-1)] + [DataRow(1)] + public void GroundedLedgeRecoveryReturnsToAdjacentSupportedCell(int direction) + { + var floor = Main.tile[100, 64]; + floor.HasTile = false; + var npc = new NPC { width = 16, height = 48 }; + npc.Bottom = new Vector2(1608 + direction * 0.003f, 1024); + var terrain = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdTerrain")!; + Assert.IsTrue((bool)terrain.GetMethod("Grounded")!.Invoke(null, [npc])!); + var recover = terrain.GetMethod("TryStandingRecovery"); + Assert.IsNotNull(recover); + object?[] args = [npc, 0f]; + Assert.IsTrue((bool)recover.Invoke(null, args)!); + Assert.AreEqual(1608f + direction * 16, args[1]); + for (int i = 0; i < 16; i++) + { + npc.velocity = new Vector2(Math.Clamp((float)args[1]! - npc.Bottom.X, -1.5f, 1.5f), CrowdNavigation.Gravity); + npc.velocity = Collision.TileCollision(npc.position, npc.velocity, npc.width, npc.height); + npc.position += npc.velocity; + } + Assert.AreEqual(1024f, npc.Bottom.Y); + Assert.AreEqual((float)args[1]!, npc.Bottom.X); + } + + [TestMethod] + [DataRow("air")] + [DataRow("deep")] + [DataRow("hazard")] + public void StandingRecoveryRejectsUnsupportedOrUnsafeNudges(string condition) + { + for (int x = 99; x <= 101; x++) + { + var floor = Main.tile[x, 64]; + floor.HasTile = x == 99 && condition != "deep"; + } + var npc = new NPC { width = 16, height = 48 }; + npc.Bottom = new Vector2(1607.997f, condition == "air" ? 1020 : 1024); + if (condition == "hazard") + { + var lava = Main.tile[99, 63]; + lava.LiquidType = LiquidID.Lava; + lava.LiquidAmount = 255; + } + var terrain = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdTerrain")!; + object?[] args = [npc, 0f]; + Assert.IsFalse((bool)terrain.GetMethod("TryStandingRecovery")!.Invoke(null, args)!); + } + + [TestMethod] + public void PlannerAvoidsAStandingPositionReservedByAnotherActivity() + { + Main.rand = new UnifiedRandom(42); + Assert.IsTrue(Plan(out var firstPath)); + var cell = firstPath![^1]; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var resident = RuntimeHelpers.GetUninitializedObject(type); + type.GetProperty("Active")!.SetValue(resident, true); + type.GetProperty("Stage")!.SetValue(resident, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Activity")); + type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(resident, + Activator.CreateInstance(PlaceType, CrowdActivity.Scenery, cell, "different-activity", cell.ToVector2() * 16, null, 0)); + var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + travelers.Add(resident); + restore.Add(() => travelers.Remove(resident)); + Main.rand = new UnifiedRandom(42); + Assert.IsTrue(Plan(out var nextPath)); + Assert.IsTrue(Math.Abs(nextPath![^1].X - cell.X) >= 2 || Math.Abs(nextPath[^1].Y - cell.Y) >= 3, + "Distinct activity keys must not allow two people to stand on the same ground coordinate."); + } + + [TestMethod] + public void SharedActivityUsesDistinctSupportedStandingPositions() + { + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var method = PlacesType.GetMethod("TryStandingPlace")!; + Assert.IsNotNull(method, "The activity catalog needs safe standing-space selection."); + var place = Activator.CreateInstance(PlaceType, CrowdActivity.Scenery, new Point(100, 63), "shared", new Vector2(1608, 1024), null, 0)!; + var reserved = new List { new(100, 63) }; + bool Occupied(Point cell) => reserved.Any(other => Math.Abs(other.X - cell.X) < 2 && Math.Abs(other.Y - cell.Y) < 3); + for (int i = 0; i < 2; i++) + { + object?[] args = [place, (Func)Occupied, null]; + Assert.IsTrue((bool)method.Invoke(catalog, args)!); + var cell = (Point)PlaceType.GetProperty("Cell")!.GetValue(args[2])!; + Assert.IsFalse(Occupied(cell)); + Assert.AreEqual(63, cell.Y); + Assert.AreEqual("shared", PlaceType.GetProperty("Key")!.GetValue(args[2])); + reserved.Add(cell); + } + } + + [TestMethod] + public void StandingAlternativesStayOnTheSameSideOfAnInterveningWall() + { + for (int y = 56; y < 64; y++) + { + var tile = Main.tile[99, y]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var place = Activator.CreateInstance(PlaceType, CrowdActivity.Scenery, new Point(100, 63), "wall-side", new Vector2(1608, 1024), null, 0)!; + object?[] args = [place, (Func)(cell => cell == new Point(100, 63)), null]; + Assert.IsTrue((bool)PlacesType.GetMethod("TryStandingPlace")!.Invoke(catalog, args)!); + Assert.AreEqual(new Point(102, 63), PlaceType.GetProperty("Cell")!.GetValue(args[2]), + "The free point behind a wall must not hide a reachable point beside the original activity."); + } + + [TestMethod] + public void StandingAlternativesCannotUseMissingFloorOrMoveAnExit() + { + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var method = PlacesType.GetMethod("TryStandingPlace")!; + Assert.IsNotNull(method); + foreach (var activity in new[] { CrowdActivity.EnterBuilding, CrowdActivity.LeavePylon }) + { + var exit = Activator.CreateInstance(PlaceType, activity, new Point(100, 63), "fixed", new Vector2(1608, 1024), null, 0)!; + object?[] exitArgs = [exit, (Func)(cell => cell == new Point(100, 63)), null]; + Assert.IsFalse((bool)method.Invoke(catalog, exitArgs)!, "A free neighboring floor cannot move an entrance animation."); + } + for (int x = 94; x <= 106; x++) + { + if (x != 100) + { + var tile = Main.tile[x, 64]; + tile.HasTile = false; + } + } + var place = Activator.CreateInstance(PlaceType, CrowdActivity.Scenery, new Point(100, 63), "fixed", new Vector2(1608, 1024), null, 0)!; + object?[] args = [place, (Func)(cell => cell == new Point(100, 63)), null]; + Assert.IsFalse((bool)method.Invoke(catalog, args)!); + } + + [TestMethod] + [DataRow(-1)] + [DataRow(1)] + public void WaitingPositionsExpandAwayFromTheTrain(int side) + { + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var method = PlacesType.GetMethod("TryStandingPlace")!; + Assert.IsNotNull(method); + var place = Activator.CreateInstance(PlaceType, CrowdActivity.BoardTrain, new Point(100, 63), "station", new Vector2(1608 - side * 912, 1040), null, 0)!; + object?[] args = [place, (Func)(cell => cell == new Point(100, 63)), null]; + Assert.IsTrue((bool)method.Invoke(catalog, args)!); + var cell = (Point)PlaceType.GetProperty("Cell")!.GetValue(args[2])!; + Assert.IsTrue((cell.X - 100) * side >= 2, "Waiting space must not expand inward onto the train deck."); + } + + [TestMethod] + public void ActualTownPlannerWorksWithoutStationAndChoosesBothDirections() + { + bool left = false; + bool right = false; + for (int seed = 0; seed < 24; seed++) + { + Main.rand = new UnifiedRandom(seed); + if (Plan(out var path)) + { + Assert.IsNotNull(path); + Assert.IsTrue(path.Count > 1); + Assert.IsTrue(path.All(point => point.Y == 63)); + left |= path[^1].X < 100; + right |= path[^1].X > 100; + } + } + Assert.IsTrue(left && right, "Town activities must be reachable in both directions without any station or pylon."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void OrdinaryActivityCannotOccupyTrainDeck(bool moving) + { + var train = new YggdrasilTown_NormalMetro + { + Position = new Vector2(1200, 1008), Size = new Vector2(600, 16), Active = true, + State = (int)(moving ? YggdrasilTown_NormalMetro.BehaviorType.Move : YggdrasilTown_NormalMetro.BehaviorType.Stop), + Velocity = moving ? Vector2.UnitX : Vector2.Zero, + }; + ((List)typeof(ColliderManager).GetField("rigidbodies", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(ColliderManager.Instance)!).Add(train); + var terrain = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdTerrain")!; + var stand = terrain.GetMethod("CanStand")!; + Assert.IsFalse((bool)stand.Invoke(null, [new Point(100, 63), null])!, "Normal activities must not overlap the solid deck even when it is not a walkable support."); + if (!moving) + { + Assert.IsTrue((bool)stand.Invoke(null, [new Point(100, 62), train])!); + } + } + + [TestMethod] + [DataRow(1440)] + [DataRow(1342)] + public void CrowdOnTrainRemainsVisibleIncludingAtDoor(int x) + { + Main.screenPosition = new Vector2(400, 0); + Main.screenWidth = 2400; + Main.screenHeight = 1400; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + var body = new NPC { width = 16, height = 48, position = new Vector2(x, 960) }; + var train = new YggdrasilTown_NormalMetro { Position = new Vector2(1200, 1008), Size = new Vector2(600, 16), Active = true }; + var view = new CrowdAppearance { RetirementOpacity = 0.4f }; + SetJourneyBody(journey, body); + type.GetField("train", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, train); + type.GetField("view", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, view); + type.GetField("aboardOffset", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, body.position - train.Position); + type.GetField("stageTime", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, 60); + type.GetMethod("Ride", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, null); + Assert.AreEqual(1f, view.Opacity, "Passengers must remain visible throughout their ride, including at a door."); + Assert.AreEqual(0.4f, view.EffectiveOpacity, "Riding must preserve the independent retirement multiplier."); + } + + [TestMethod] + [DataRow("Riding", 1400, 960, true)] + [DataRow("Boarding", 1400, 960, true)] + [DataRow("Alighting", 1400, 960, true)] + [DataRow("Boarding", 1170, 960, false)] + [DataRow("Alighting", 1810, 960, false)] + [DataRow("Boarding", 1400, 800, false)] + [DataRow("Alighting", 1400, 1040, false)] + [DataRow("Walking", 1400, 960, false)] + public void PassengerUsesTrainInteriorLayerOnlyWhileInsideItsCarriage(string stage, int x, int y, bool inside) + { + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + var body = new NPC { width = 16, height = 48, position = new Vector2(x, y) }; + var train = new YggdrasilTown_NormalMetro { Position = new Vector2(1200, 1008), Size = new Vector2(600, 16), Active = true }; + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, body); + Set("k__BackingField", true); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, stage)); + Set("train", train); + var interior = type.GetProperty("InteriorTrain", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(interior, "Passengers need a carriage-owned draw layer instead of the late foreground VFX layer."); + foreach (float shellAlpha in new[] { 0f, 0.5f, 1f }) + { + train.ShellAlpha = shellAlpha; + Assert.AreEqual(inside ? train : null, interior.GetValue(journey), "The shell must occlude passengers through draw order, not passenger opacity."); + } + train.Active = false; + Assert.IsNull(interior.GetValue(journey)); + train.Active = true; + Set("k__BackingField", false); + Assert.IsNull(interior.GetValue(journey)); + } + + [TestMethod] + public void ExitRequiresTravelerToActuallyReachEndpoint() + { + var tile = Main.tile[40, 60]; + tile.HasTile = true; + tile.TileType = TileID.Rope; + var place = Activator.CreateInstance(PlaceType, CrowdActivity.LeavePylon, new Point(42, 63), "pylon", new Vector2(680, 1024), new Point(40, 60), (int)TileID.Rope)!; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + var body = new NPC { width = 16, height = 48, position = new Vector2(1000, 976) }; + SetJourneyBody(journey, body); + type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, place); + type.GetMethod("ReachDestination", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, null); + Assert.AreEqual("Planning", type.GetProperty("Stage")!.GetValue(journey)!.ToString(), "A displaced traveler cannot fade out at an endpoint it has not reached."); + } + + [TestMethod] + public void LightweightNameHoverRespectsVisibilityTrainShellAndActualDrawPosition() + { + var assembly = typeof(TownCrowdSystem).Assembly; + var type = assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + var actor = Activator.CreateInstance(assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdBody")!, true)!; + var body = (Entity)actor.GetType().GetProperty("Body")!.GetValue(actor)!; + body.position = new Vector2(1400, 960); + var view = new CrowdAppearance { Position = new Vector2(1408, 960), Opacity = 1, Active = true, Visible = true }; + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, body); + Set("k__BackingField", actor); + Set("k__BackingField", true); + Set("view", view); + var hover = type.GetMethod("CanShowNameAt", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(hover); + bool At(Vector2 point) => (bool)hover.Invoke(journey, [point])!; + Assert.IsTrue(At(new Vector2(1408, 980))); + Assert.IsFalse(At(new Vector2(1500, 980))); + view.Opacity = 0.05f; + Assert.IsFalse(At(new Vector2(1408, 980))); + view.Opacity = 1; + var train = new YggdrasilTown_NormalMetro { Position = new Vector2(1200, 1008), Size = new Vector2(600, 16), Active = true, ShellAlpha = 1 }; + Set("train", train); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Riding")); + Assert.IsFalse(At(new Vector2(1408, 980)), "An opaque train must not reveal passengers through a name tooltip."); + train.ShellAlpha = 0; + Assert.IsTrue(At(new Vector2(1408, 980))); + view.Visible = false; + Assert.IsFalse(At(new Vector2(1408, 980))); + } + + [TestMethod] + [DataRow("valid")] + [DataRow("destroyed")] + [DataRow("displaced")] + [DataRow("airborne")] + public void PylonReturnCompletesOnlyAtAnIntactReachedExit(string scenario) + { + bool oldServer = Main.dedServ; + restore.Add(() => Main.dedServ = oldServer); + Main.dedServ = true; // Exercise the real departure state without graphics or particle creation. + var tile = Main.tile[40, 60]; + tile.HasTile = scenario != "destroyed"; + tile.TileType = TileID.Rope; + var place = Activator.CreateInstance(PlaceType, CrowdActivity.LeavePylon, new Point(42, 63), "pylon", + new Vector2(680, 1024), new Point(40, 60), (int)TileID.Rope)!; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + var body = new NPC { active = true, width = 16, height = 48, position = new Vector2(scenario == "displaced" ? 900 : 672, 976) }; + var view = new CrowdAppearance { RetirementOpacity = 0.4f }; + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, body); + Set("k__BackingField", true); + Set("k__BackingField", Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Leaving")); + Set("destination", place); + Set("view", view); + Set("stageTime", 75); + var leave = type.GetMethod("Leave", BindingFlags.Instance | BindingFlags.NonPublic)!; + leave.Invoke(journey, [scenario != "airborne"]); + Assert.IsTrue(body.active); + Assert.AreEqual(scenario == "valid" ? 0.5f : 1f, view.Opacity, 0.001f); + Assert.AreEqual(scenario == "valid" ? 0.2f : 0.4f, view.EffectiveOpacity, 0.001f); + if (scenario == "valid") + { + Set("stageTime", 90); + leave.Invoke(journey, [true]); + Assert.IsFalse((bool)type.GetProperty("Active")!.GetValue(journey)!); + Assert.IsFalse(view.Active); + } + else + { + Assert.AreEqual("Planning", type.GetProperty("Stage")!.GetValue(journey)!.ToString()); + } + } + + [TestMethod] + public void IncomingPassengerSurvivesOffscreenAndTracksFractionalTrainMovement() + { + Main.screenPosition = Vector2.Zero; + Main.screenWidth = 800; + Main.screenHeight = 600; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + var body = new NPC { active = true, width = 16, height = 48 }; + var train = new YggdrasilTown_NormalMetro { Active = true, Position = new Vector2(2000.5f, 1008), Size = new Vector2(1696, 16) }; + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, body); + Set("k__BackingField", true); + Set("train", train); + Set("view", new CrowdAppearance()); + Set("aboardOffset", new Vector2(100, -48)); + var ride = type.GetMethod("Ride", BindingFlags.Instance | BindingFlags.NonPublic)!; + for (int i = 0; i < 200; i++) + { + train.Position += new Vector2(0.25f, 0); + ride.Invoke(journey, null); + } + Assert.IsTrue((bool)type.GetProperty("Active")!.GetValue(journey)!); + Assert.AreEqual(train.Position + new Vector2(100, -48), body.position); + train.State = (int)YggdrasilTown_NormalMetro.BehaviorType.Stop; + ride.Invoke(journey, null); + train.State = (int)YggdrasilTown_NormalMetro.BehaviorType.Move; + for (int i = 0; i < 180; i++) + { + ride.Invoke(journey, null); + } + Assert.IsFalse((bool)type.GetProperty("Active")!.GetValue(journey)!); + } + + [TestMethod] + public void LightweightPassengerDoesNotApplyTrainDisplacementTwiceAfterRiding() + { + var assembly = typeof(TownCrowdSystem).Assembly; + var type = assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var actorType = assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdBody")!; + var actor = Activator.CreateInstance(actorType, true)!; + var body = (Entity)actorType.GetProperty("Body")!.GetValue(actor)!; + body.position = new Vector2(1342, 960); + var train = new YggdrasilTown_NormalMetro { Active = true, Position = new Vector2(1200, 1008), Size = new Vector2(1696, 16) }; + actorType.GetField("ground", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(actor, train); + actorType.GetField("groundPosition", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(actor, train.Position); + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, body); + Set("k__BackingField", actor); + Set("train", train); + Set("view", new CrowdAppearance()); + type.GetMethod("AttachToTrain", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, null); + train.Position += new Vector2(400, 0); + type.GetMethod("Ride", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, null); + Vector2 aboard = body.position; + actorType.GetMethod("Move")!.Invoke(actor, [false]); + Assert.AreEqual(aboard, body.position, "The first detached movement must not replay the train's entire journey."); + } + + [TestMethod] + public void DepartureCanReachBeyondWideViewportInsteadOfWaitingForever() + { + Main.screenPosition = new Vector2(408, 0); + Main.screenWidth = 2400; + Main.screenHeight = 1400; + bool found = false; + for (int i = 0; i < 8; i++) + { + if (Plan(out var path, departing: true)) + { + found = true; + Assert.IsNotNull(path); + int x = path[^1].X * 16 + 8; + Assert.IsTrue(x < 248 || x > 2968); + } + } + Assert.IsTrue(found); + } + + [TestMethod] + public void EnclosedTravelerDoesNotPlanThroughWallsToReachActivity() + { + foreach (int x in new[] { 97, 103 }) + { + for (int y = 5; y < 64; y++) + { + var tile = Main.tile[x, y]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + } + Assert.IsFalse(Plan(out _)); + } + + [TestMethod] + public void EntranceCannotRemainValidAfterItsApproachFloorIsRemoved() + { + var catalog = Activator.CreateInstance(PlacesType, true)!; + var origin = new Point(40, 60); + ((Dictionary)PlacesType.GetField("doorSizes", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!).Add(TileID.Rope, new Point(5, 4)); + for (int x = 0; x < 5; x++) + { + for (int y = 0; y < 4; y++) + { + var tile = Main.tile[40 + x, 60 + y]; + tile.HasTile = true; + tile.TileType = TileID.Rope; + tile.TileFrameX = (short)(x * 18); + tile.TileFrameY = (short)(y * 18); + } + } + var place = Activator.CreateInstance(PlaceType, CrowdActivity.EnterBuilding, new Point(42, 63), "door", new Vector2(680, 1024), origin, (int)TileID.Rope)!; + var valid = PlacesType.GetMethod("Valid", BindingFlags.Instance | BindingFlags.Public)!; + Assert.IsTrue((bool)valid.Invoke(catalog, [place])!); + var floor = Main.tile[42, 64]; + floor.HasTile = false; + Assert.IsFalse((bool)valid.Invoke(catalog, [place])!, "A surviving door tile cannot authorize fading out above a removed floor."); + floor.HasTile = true; + var doorPart = Main.tile[44, 63]; + doorPart.HasTile = false; + Assert.IsFalse((bool)valid.Invoke(catalog, [place])!, "An incomplete multi-tile door must be rejected."); + } + + [TestMethod] + public void OnlyGiantOriginPylonIsCataloguedAndSmallPylonsCannotSeedOrOfferActivities() + { + YggdrasilTownBiome.BiomeCenter += new Vector2(10000, 0); + var instance = typeof(ContentInstance).GetProperty("Instance")!; + var previous = instance.GetValue(null); + restore.Add(() => instance.SetValue(null, previous)); + var giant = new OriginPylon(); + typeof(ModTile).GetProperty("Type")!.SetValue(giant, (ushort)TileID.Rope); + instance.SetValue(null, giant); + var catalog = Activator.CreateInstance(PlacesType, true)!; + var inspect = PlacesType.GetMethod("Inspect", BindingFlags.Instance | BindingFlags.NonPublic)!; + var list = (System.Collections.IList)PlacesType.GetField("pylons", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!; + var small = Main.tile[100, 59]; + small.HasTile = true; + small.TileType = TileID.TeleportationPylon; + inspect.Invoke(catalog, [100, 59]); + Assert.AreEqual(0, list.Count); + var markerType = PlacesType.GetNestedType("CrowdLandmark", BindingFlags.NonPublic)!; + list.Add(Activator.CreateInstance(markerType, new Point(100, 59), (int)TileID.TeleportationPylon, 1, 1)!); + var entrances = (System.Collections.IEnumerable)PlacesType.GetMethod("Entrances")!.Invoke(catalog, [new Vector2(1608, 1024), true])!; + Assert.AreEqual(0, entrances.Cast().Count(), "Even a stale small-pylon entry must not generate an arrival or return target."); + Assert.IsFalse((bool)PlacesType.GetMethod("CanSeed")!.Invoke(catalog, [new Vector2(1608, 1024)])!); + list.Clear(); + for (int x = 100; x < 108; x++) + { + for (int y = 50; y < 62; y++) + { + var tile = Main.tile[x, y]; + tile.HasTile = true; + tile.TileType = TileID.Rope; + tile.TileFrameX = (short)((x - 100) * 18); + tile.TileFrameY = (short)((y - 50) * 18); + } + } + inspect.Invoke(catalog, [100, 50]); + Assert.AreEqual(1, list.Count); + Assert.AreEqual(1, entrances.Cast().Count()); + Assert.IsTrue((bool)PlacesType.GetMethod("CanSeed")!.Invoke(catalog, [new Vector2(1672, 1024)])!); + } + + [TestMethod] + public void NativeResidentsDoNotBecomeConversationPartners() + { + ResetThreats(); + EnableJourneys(); + var npc = Main.npc[0]; + npc.active = npc.friendly = npc.townNPC = true; + npc.width = 18; + npc.height = 40; + npc.Bottom = new Vector2(1640, 1024); + npc.aiStyle = NPCAIStyleID.Passive; + var journey = CreateWalkingActor(new Vector2(1608, 1024)); + Assert.IsFalse(typeof(GlobalNPC).IsAssignableFrom(ConversationType)); + Assert.IsFalse((bool)ConversationType.GetMethod("TryNearby", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, [journey])!); + Assert.AreEqual("Walking", journey.GetType().GetProperty("Stage")!.GetValue(journey)!.ToString()); + } + + [TestMethod] + public void PylonPlazaOffersLocalActivityBeforeDepartingForTown() + { + YggdrasilTownBiome.BiomeCenter += new Vector2(10000, 0); + var pylonInstance = typeof(ContentInstance).GetProperty(nameof(ContentInstance.Instance))!; + var previous = pylonInstance.GetValue(null); + restore.Add(() => pylonInstance.SetValue(null, previous)); + var pylon = new OriginPylon(); + typeof(ModTile).GetProperty(nameof(ModTile.Type))!.SetValue(pylon, (ushort)TileID.Rope); + pylonInstance.SetValue(null, pylon); + var origin = new Point(100, 50); + var tile = Main.tile[100, 50]; + tile.HasTile = true; + tile.TileType = TileID.Rope; + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var list = (System.Collections.IList)PlacesType.GetField("pylons", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!; + var markerType = PlacesType.GetNestedType("CrowdLandmark", BindingFlags.NonPublic)!; + list.Add(Activator.CreateInstance(markerType, origin, (int)TileID.Rope, 1, 1)!); + bool found = false; + for (int i = 0; i < 8; i++) + { + found |= Plan(out _, new Point(104, 63), $"tile:{origin}"); + } + Assert.IsTrue(found, "A pylon arrival must have a safe plaza activity even when the town is elsewhere."); + } + + [TestMethod] + public void ChairUsesBottomAnchorAndYieldsToPlayerAndResident() + { + var dataField = typeof(Terraria.ObjectData.TileObjectData).GetField("_data", BindingFlags.Static | BindingFlags.NonPublic)!; + var oldData = dataField.GetValue(null); + bool oldSeating = TileID.Sets.CanBeSatOnForNPCs[TileID.Chairs]; + var oldPlayers = Main.player; + restore.Add(() => { dataField.SetValue(null, oldData); TileID.Sets.CanBeSatOnForNPCs[TileID.Chairs] = oldSeating; Main.player = oldPlayers; }); + var data = new Terraria.ObjectData.TileObjectData(); + typeof(Terraria.ObjectData.TileObjectData).GetMethod("SetupBaseObject", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(data, null); + data.Width = 1; + data.Height = 2; + data.CoordinateHeights = [16, 18]; + data.CoordinateWidth = 16; + data.CoordinatePadding = 2; + data.CoordinatePaddingFix = new Terraria.DataStructures.Point16(0, 2); + var datas = Enumerable.Repeat(null!, TileID.Count).ToList(); + datas[TileID.Chairs] = data; + dataField.SetValue(null, datas); + TileID.Sets.CanBeSatOnForNPCs[TileID.Chairs] = true; + Main.player = [new Player { active = true }]; + for (int y = 62; y <= 63; y++) + { + var tile = Main.tile[100, y]; + tile.HasTile = true; + tile.TileType = TileID.Chairs; + tile.TileFrameY = (short)((y - 62) * 18); + } + var seats = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdSeats")!; + object?[] args = [new Point(100, 62), default(Point)]; + Assert.IsTrue((bool)seats.GetMethod("TryFind")!.Invoke(null, args)!); + Assert.AreEqual(new Point(100, 63), args[1]); + bool Available() => (bool)seats.GetMethod("Available")!.Invoke(null, [new Point(100, 63)])!; + Assert.IsTrue(Available()); + Main.player[0].Bottom = new Vector2(1608, 1008); + Main.player[0].sitting.isSitting = true; + Assert.IsFalse(Available()); + Main.player[0].sitting.isSitting = false; + Main.npc[0].active = Main.npc[0].townNPC = true; + Main.npc[0].Bottom = new Vector2(1608, 1024); + Assert.IsFalse(Available()); + Main.npc[0].active = false; + var top = Main.tile[100, 62]; + top.HasTile = false; + Assert.IsFalse(Available(), "A missing chair half must cancel sitting."); + } + + [TestMethod] + public void NewReturnerCannotFallBackToAnUnrelatedSightseeingRoute() + { + Main.rand = new CrowdChanceRandom(0); + var mode = typeof(TownCrowdSystem).GetField("k__BackingField", BindingFlags.Static | BindingFlags.NonPublic)!; + var oldMode = mode.GetValue(null); + restore.Add(() => mode.SetValue(null, oldMode)); + mode.SetValue(null, true); + new TownCrowdSystem().PreUpdateNPCs(); + var arrivalType = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdArrival")!; + var spawn = typeof(TownCrowdSystem).GetMethod("TrySpawnFrom", BindingFlags.Static | BindingFlags.NonPublic)!; + Assert.IsFalse((bool)spawn.Invoke(null, [new Point(100, 63), Enum.Parse(arrivalType, "Existing"), null])!, + "No pylons exist in this fixture: creating a returner must fail before allocating a body or a VFX."); + } + + [TestMethod] + public void ExhaustedPlanningBudgetDoesNotCountAsFailedDeparture() + { + Main.screenPosition = new Vector2(800, 600); + Main.screenWidth = 800; + Main.screenHeight = 600; + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, new NPC { width = 16, height = 48, position = new Vector2(1600, 976) }); + Set("recentPlaces", new Queue()); + Set("departureRequested", true); + typeof(TownCrowdSystem).GetField("planningBudget", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, 0); + type.GetMethod("ChooseNextActivity", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, null); + Assert.AreEqual(0, ((CrowdRetirement)type.GetField("retirement", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)!).DepartureFailures); + Assert.IsTrue((int)type.GetField("planDelay", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)! < 5); + } + + [TestMethod] + public void GuildUseSharesDoorCooldownAndDoesNotConsumeItOnRejectedAttempt() + { + var cooldowns = (Dictionary)typeof(TownCrowdSystem).GetField("EntranceCooldowns", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + const string key = "test:guild-cooldown"; + var enter = typeof(TownCrowdSystem).GetMethod("TryEnterGuild", BindingFlags.Static | BindingFlags.NonPublic)!; + var clock = typeof(TownCrowdSystem).GetField("clock", BindingFlags.Static | BindingFlags.NonPublic)!; + try + { + clock.SetValue(null, 1L); + Assert.IsTrue((bool)enter.Invoke(null, [key])!); + clock.SetValue(null, 2L); + Assert.IsFalse((bool)enter.Invoke(null, [key])!); + Assert.AreEqual(1L, cooldowns[key]); + clock.SetValue(null, 1L + CrowdSettings.DoorCooldownTicks); + Assert.IsTrue((bool)enter.Invoke(null, [key])!); + } + finally + { + cooldowns.Remove(key); + } + } + + [TestMethod] + public void GuildEntranceUsesSceneOpeningAndRejectsBlockedOrMissingAnchor() + { + var catalog = Activator.CreateInstance(PlacesType, true)!; + PlacesType.GetField("guildType", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(catalog, (int)TileID.Rope); + ((Dictionary)PlacesType.GetField("doorSizes", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!).Add(TileID.Rope, new Point(1, 1)); + var origin = new Point(40, 56); + var tile = Main.tile[40, 56]; + tile.HasTile = true; + tile.TileType = TileID.Rope; + PlacesType.GetMethod("DiscoverNear")!.Invoke(catalog, [new Rectangle(600, 850, 200, 200)]); + var entrances = ((System.Collections.IEnumerable)PlacesType.GetMethod("Entrances")!.Invoke(catalog, [new Vector2(700, 1024), false])!).Cast().ToArray(); + Assert.AreEqual(1, entrances.Length); + var place = entrances[0]; + Assert.AreEqual(CrowdActivity.EnterGuild, PlaceType.GetProperty("Activity")!.GetValue(place)); + Assert.AreEqual(new Point(43, 63), PlaceType.GetProperty("Cell")!.GetValue(place)); + var valid = PlacesType.GetMethod("Valid", BindingFlags.Instance | BindingFlags.Public)!; + Assert.IsTrue((bool)valid.Invoke(catalog, [place])!); + var wall = Main.tile[43, 62]; + wall.HasTile = true; + wall.TileType = TileID.Stone; + Assert.IsFalse((bool)valid.Invoke(catalog, [place])!); + wall.HasTile = false; + tile.HasTile = false; + Assert.IsFalse((bool)valid.Invoke(catalog, [place])!); + } + + [TestMethod] + public void SightseeingFallbackDoesNotEraseRepeatedlyFailedReturns() + { + Main.screenPosition = Vector2.Zero; + Main.screenWidth = 4096; + Main.screenHeight = 2048; // Every safe street point is visible, and this plaza has no entrance. + var type = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdAgent")!; + var journey = RuntimeHelpers.GetUninitializedObject(type); + void Set(string field, object value) => type.GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(journey, value); + SetJourneyBody(journey, new NPC { width = 16, height = 48, position = new Vector2(1600, 976) }); + Set("recentPlaces", new Queue()); + Set("departureRequested", true); + Set("age", 12000); + for (int i = 0; i < 3; i++) + { + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("ChooseNextActivity", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(journey, null); + Assert.AreEqual("Walking", type.GetProperty("Stage")!.GetValue(journey)!.ToString()); + } + Assert.AreEqual(3, ((CrowdRetirement)type.GetField("retirement", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)!).DepartureFailures); + Assert.IsTrue((bool)type.GetField("departureRequested", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(journey)!); + } +} diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTrainStateTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTrainStateTests.cs new file mode 100644 index 000000000..dc850e1bf --- /dev/null +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTrainStateTests.cs @@ -0,0 +1,28 @@ +using Everglow.Yggdrasil.Mechanics.TownCrowd; + +namespace Everglow.UnitTests.Modules.Yggdrasil; + +[TestClass] +public class CrowdTrainStateTests +{ + [TestMethod] + public void TwoTrainPassengersCanUseSeparatePlanningFrames() + { + var batch = new CrowdStopBatch(); + Assert.IsTrue(batch.TryBegin(true, 30, 600)); + Assert.IsTrue(batch.TryDispatch(2, 3, out int first)); + Assert.AreEqual(-1, first); + batch.RecordArrival(); + batch.TryBegin(true, 60, 600); + Assert.IsFalse(batch.TryDispatch(2, 1, out _)); + Assert.IsFalse(batch.TryDispatch(1, 3, out _)); + Assert.IsTrue(batch.TryDispatch(2, 3, out int second)); + Assert.AreEqual(1, second); + batch.RecordArrival(); + batch.TryBegin(true, 90, 600); + Assert.IsFalse(batch.TryDispatch(2, 3, out _)); + batch.TryBegin(false, 600, 600); + Assert.IsTrue(batch.TryBegin(true, 30, 600)); + Assert.IsTrue(batch.TryDispatch(1, 3, out _)); + } +} diff --git a/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson b/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson index dd1ef08da..efdbd9ca9 100644 --- a/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson +++ b/Sources/Everglow/Localization/en-US/Mods.Everglow.Configs.hjson @@ -1,3 +1,22 @@ +CrowdConfig: { + DisplayName: Town Crowds + Enabled: { + Label: Town travelers + Tooltip: "Adds travelers who walk, linger, and converse in Yggdrasil Town. Only active in single-player Yggdrasil. Disabling removes existing travelers." + } + Density: { + Label: Crowd density + Tooltip: "Adjusts the number of travelers on streets and trains. Higher density may increase performance cost. Lower density takes effect gradually as travelers leave." + } +} + +CrowdDensity: { + Tooltip: Controls how busy the town feels. + Sparse.Label: Sparse + Standard.Label: Standard + Busy.Label: Busy +} + MirrorReflectionConfig: { DisplayName: Mirror Reflections (Client) Enabled: { diff --git a/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson index ba8a02efc..dfc11fd4b 100644 --- a/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson +++ b/Sources/Everglow/Localization/zh-Hans/Mods.Everglow.Configs.hjson @@ -1,3 +1,22 @@ +CrowdConfig: { + DisplayName: 城镇人群 + Enabled: { + Label: 城镇路人 + Tooltip: "在天穹镇中添加往来、停留和交谈的路人。仅在单人模式的天穹树中生效。关闭后清理现有路人。" + } + Density: { + Label: 人群密度 + Tooltip: "调整街道和列车中的路人数量。较高密度可能增加性能开销。降低密度后,人数会随路人离开而逐渐减少。" + } +} + +CrowdDensity: { + Tooltip: 调整城镇的热闹程度。 + Sparse.Label: 稀疏 + Standard.Label: 标准 + Busy.Label: 繁忙 +} + MirrorReflectionConfig: { DisplayName: 镜面反射(客户端) Enabled: { diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdActivityRules.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdActivityRules.cs new file mode 100644 index 000000000..ec1c1f9fd --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdActivityRules.cs @@ -0,0 +1,49 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +public enum CrowdActivity +{ + Browse, + Socialize, + Scenery, + BoardTrain, + EnterBuilding, + LeavePylon, + LeaveStreet, + EnterGuild, + Sit, +} + +public static class CrowdActivityRules +{ + public static bool ShouldDepart(bool requested, int age, int completedActivities, int seed) => requested + || age >= 5400 + (uint)seed % 5401 + || (age >= 1800 && completedActivities >= 3 + (uint)seed % 3); + + public static float DepartureOpacity(CrowdActivity activity, int timer) => activity == CrowdActivity.LeavePylon + ? Math.Clamp(1 - (timer - 60) / 30f, 0, 1) + : Math.Clamp(1 - timer / 60f, 0, 1); + + public static bool CanRetire(int age, bool visible) => age > 10800 && !visible; + + public static bool CanRetireAfterFailedDeparture(int age, bool visible, int failedAttempts) => + failedAttempts >= 3 && CanRetire(age, visible); + + public static bool CanSpawnOnStreet(Vector2 feet, Rectangle town, Rectangle view) => + town.Contains(feet.ToPoint()) && !view.Contains(feet.ToPoint()); + + public static bool IsExit(CrowdActivity activity) => activity is CrowdActivity.BoardTrain or CrowdActivity.EnterBuilding + or CrowdActivity.LeavePylon or CrowdActivity.LeaveStreet or CrowdActivity.EnterGuild; + + public static float Weight(CrowdActivity activity, bool departing, bool recent, int occupied, int capacity, float distance, float preference) + { + bool returningThroughPylon = departing && activity == CrowdActivity.LeavePylon; + if ((recent && !returningThroughPylon) || occupied >= capacity || IsExit(activity) != departing) + { + return 0; + } + return (returningThroughPylon ? 8 : 1) * preference / (1 + distance / 480) / (1 + occupied * 2); + } + + public static bool CanFinishTrainWalk(bool stopped, int timer, int duration, int travelTicks) => + stopped && timer >= 30 && travelTicks >= 0 && travelTicks + 90 < duration - timer; +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdAvoidance.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdAvoidance.cs new file mode 100644 index 000000000..ea8d7aa4f --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdAvoidance.cs @@ -0,0 +1,44 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Separate safety recovery from continuous offscreen retirement. +public sealed class CrowdAvoidance +{ + private int safeTicks; + private int offscreenTicks; + private bool retiring; + + public bool Alarmed { get; private set; } + + public bool PendingRetirement => retiring; + + public bool Update(bool threatened, bool safe, bool visible, int retirementTicks) + { + if (threatened) + { + Alarmed = true; + retiring = true; + safeTicks = 0; + } + else if (Alarmed) + { + safeTicks = safe ? safeTicks + 1 : 0; + if (safeTicks >= 180) + { + Alarmed = false; + } + } + if (visible) + { + offscreenTicks = 0; + if (!Alarmed) + { + retiring = false; + } + } + else if (retiring) + { + offscreenTicks++; + } + return retiring && offscreenTicks >= retirementTicks; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdConversation.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdConversation.cs new file mode 100644 index 000000000..1931e6f32 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdConversation.cs @@ -0,0 +1,116 @@ +using Terraria.GameContent.UI; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Ambient conversation between lightweight travelers, without NPC hooks or slots. +internal static class CrowdConversation +{ + internal const int OpportunityInterval = 120; + internal const int CooldownTicks = 600; + private const int BubbleDuration = 100; + private const int BubbleInterval = 120; + + private sealed class CrowdConversationSession(CrowdAgent visitor, CrowdAgent peer, long until) + { + public readonly CrowdAgent Visitor = visitor; + public readonly CrowdAgent Peer = peer; + public readonly long Until = until; + public int Bubble = -1; + public int Turn; + public long NextBubble; + } + + private static readonly Dictionary Visitors = []; + private static readonly int[] Topics = [EmoteID.WeatherSunny, EmoteID.ItemSoup, EmoteID.ItemFishingRod, EmoteID.ItemSword]; + + internal static bool InRange(Entity first, Entity second) => Vector2.DistanceSquared(first.Center, second.Center) + <= MathF.Pow(CrowdSettings.ChatRangeTiles * 16, 2) + && Collision.CanHitLine(first.Center, 1, 1, second.Center, 1, 1); + + private static bool Safe(Entity body) => !CrowdThreats.EnemyNear(body.Center) && !CrowdThreats.Dangerous(body.Bottom); + + internal static bool BeginPeers(CrowdAgent visitor, CrowdAgent peer, int duration) + { + if (ReferenceEquals(visitor, peer) || !visitor.CanConverse || !peer.CanConverse + || Visitors.ContainsKey(visitor) || Visitors.ContainsKey(peer) || !InRange(visitor.Body, peer.Body) + || !Safe(visitor.Body) || !Safe(peer.Body)) + { + return false; + } + visitor.PauseForConversation(); + peer.PauseForConversation(); + var session = new CrowdConversationSession(visitor, peer, TownCrowdSystem.Clock + duration); + Visitors.Add(visitor, session); + Visitors.Add(peer, session); + return true; + } + + internal static bool TryNearby(CrowdAgent visitor) + { + if (!visitor.CanConverse || !Safe(visitor.Body)) + { + return false; + } + int duration = Main.rand.Next(CrowdSettings.ChatMinTicks, CrowdSettings.ChatMaxTicks + 1); + return TownCrowdSystem.ConversationPeers(visitor).Any(peer => BeginPeers(visitor, peer, duration)); + } + + internal static bool ContinueNearby(CrowdAgent visitor) + { + if (!Visitors.TryGetValue(visitor, out var session)) + { + return false; + } + if (!Valid(session)) + { + End(session.Visitor, TownCrowdSystem.Clock >= session.Until); + return false; + } + visitor = session.Visitor; + visitor.FaceConversation(session.Peer.Body); + session.Peer.FaceConversation(visitor.Body); + if (TownCrowdSystem.Clock >= session.NextBubble) + { + ClearBubble(session); + Entity speaker = session.Turn++ % 2 == 0 ? visitor.Body : session.Peer.Body; + int topic = Topics[(int)(((uint)visitor.RouteSeed + (uint)((session.Turn - 1) / 2)) % (uint)Topics.Length)]; + session.Bubble = EmoteBubble.NewBubble(topic, new WorldUIAnchor(speaker), BubbleDuration); + session.NextBubble = TownCrowdSystem.Clock + BubbleInterval; + } + return true; + } + + private static bool Valid(CrowdConversationSession session) => TownCrowdSystem.Enabled && session.Visitor.Active && session.Peer.Active + && session.Visitor.Stage == CrowdStage.Conversing && session.Peer.Stage == CrowdStage.Conversing + && !session.Visitor.IsEvacuating && !session.Peer.IsEvacuating && TownCrowdSystem.Clock < session.Until + && InRange(session.Visitor.Body, session.Peer.Body) && Safe(session.Peer.Body) && Safe(session.Visitor.Body); + + internal static void End(CrowdAgent visitor, bool completed = false) + { + if (Visitors.Remove(visitor, out var session)) + { + ClearBubble(session); + Visitors.Remove(session.Visitor); + Visitors.Remove(session.Peer); + session.Visitor.ResumeAfterConversation(completed); + session.Peer.ResumeAfterConversation(completed); + } + } + + private static void ClearBubble(CrowdConversationSession session) + { + if (session.Bubble >= 0 && EmoteBubble.GetExistingEmoteBubble(session.Bubble) is { } bubble) + { + bubble.lifeTime = 0; + } + session.Bubble = -1; + } + + internal static void Clear() + { + foreach (var visitor in Visitors.Keys.ToArray()) + { + End(visitor); + } + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs new file mode 100644 index 000000000..2cd6b913e --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs @@ -0,0 +1,77 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +public enum CrowdIntent +{ + VisitTown, + ReturnHome, + EnterGuild, +} + +public static class CrowdLifeRules +{ + // Newly teleported arrivals may visit the guild, but never immediately return home. + public static CrowdIntent ChooseIntent(bool arrivingThroughPylon, bool leavingGuild, int roll, int returnPercent, int guildPercent) + { + if (arrivingThroughPylon) + { + return roll < guildPercent ? CrowdIntent.EnterGuild : CrowdIntent.VisitTown; + } + if (roll < returnPercent) + { + return CrowdIntent.ReturnHome; + } + return !leavingGuild && roll < returnPercent + guildPercent ? CrowdIntent.EnterGuild : CrowdIntent.VisitTown; + } + + public static bool InsideGuildOpening(Point origin, Vector2 feet) => feet.X >= origin.X * 16 + 16 + && feet.X <= origin.X * 16 + 96 && feet.Y >= origin.Y * 16 + 64 && feet.Y <= origin.Y * 16 + 128; + + public static Rectangle CameraBounds(Vector2 screenPosition, Vector2 screenSize, Matrix transform) + { + var inverse = Matrix.Invert(transform); + var top = Vector2.Transform(Vector2.Zero, inverse) + screenPosition; + var bottom = Vector2.Transform(screenSize, inverse) + screenPosition; + return new Rectangle((int)MathF.Floor(top.X), (int)MathF.Floor(top.Y), + (int)MathF.Ceiling(bottom.X - top.X), (int)MathF.Ceiling(bottom.Y - top.Y)); + } +} + +/// Bounded exposure history. A full cache skips seeding rather than refilling recently seen space. +public sealed class CrowdRegions +{ + private readonly Dictionary seen = []; + + public int Count => seen.Count; + + public void Clear() => seen.Clear(); + + public Rectangle[] Observe(Rectangle view, long clock) + { + var fresh = new List(); + for (int x = (int)MathF.Floor(view.Left / 768f); x <= (int)MathF.Floor((view.Right - 1) / 768f); x++) + { + for (int y = (int)MathF.Floor(view.Top / 384f); y <= (int)MathF.Floor((view.Bottom - 1) / 384f); y++) + { + var key = new Point(x, y); + if (!seen.ContainsKey(key)) + { + if (seen.Count >= 128) + { + var stale = seen.Where(pair => clock - pair.Value > 3600 + && !view.Intersects(Bounds(pair.Key))).OrderBy(pair => pair.Value).Take(1).ToArray(); + if (stale.Length == 0) + { + continue; + } + seen.Remove(stale[0].Key); + } + fresh.Add(Bounds(key)); + } + seen[key] = clock; + } + } + return fresh.ToArray(); + } + + private static Rectangle Bounds(Point key) => new(key.X * 768, key.Y * 384, 768, 384); +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs new file mode 100644 index 000000000..3285326e3 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs @@ -0,0 +1,314 @@ +using Everglow.Yggdrasil.Common.Tiles; +using Everglow.Yggdrasil.YggdrasilTown; +using Everglow.Yggdrasil.YggdrasilTown.Kitchen.Tiles; +using Everglow.Yggdrasil.YggdrasilTown.Tiles; +using Everglow.Yggdrasil.YggdrasilTown.Tiles.OriginPylon; +using Everglow.Yggdrasil.YggdrasilTown.Tiles.Union; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +internal enum CrowdArrival +{ + Street, + Building, + Pylon, + Train, + Existing, +} + +internal sealed record CrowdPlace(CrowdActivity Activity, Point Cell, string Key, Vector2 Focus, + Point? TileOrigin = null, int TileType = 0) +{ + public int Capacity => Activity is CrowdActivity.BoardTrain or CrowdActivity.Scenery ? 3 + : Activity == CrowdActivity.Sit ? 1 : 2; +} + +/// Explicit public entrances and dynamic, reachable street activities. No player room transitions. +internal sealed class CrowdPlaces +{ + private sealed record CrowdLandmark(Point Origin, int Type, int Width, int Height); + + private readonly List doors = []; + private readonly List pylons = []; + private readonly List stations = []; + private readonly Dictionary doorSizes = []; + private readonly HashSet stationTypes = []; + private int scanX = 10; + private int scanY; + private int cooldown; + private int guildType = -1; + + public static Rectangle TownBounds + { + get + { + var town = YggdrasilTownCentralSystem.TownArea; + return new Rectangle(town.X * 16, town.Y * 16, town.Width * 16, town.Height * 16); + } + } + + public static Rectangle ViewBounds + { + get + { + var view = new Rectangle((int)Main.screenPosition.X, (int)Main.screenPosition.Y, Main.screenWidth, Main.screenHeight); + view.Inflate(160, 160); + return view; + } + } + + public void Initialize() + { + doors.Clear(); + pylons.Clear(); + stations.Clear(); + doorSizes.Clear(); + doorSizes.Add(ModContent.TileType(), new Point(5, 5)); + doorSizes.Add(ModContent.TileType(), new Point(5, 4)); + guildType = ModContent.TileType(); + doorSizes.Add(guildType, new Point(1, 1)); + stationTypes.Clear(); + stationTypes.UnionWith([ModContent.TileType(), ModContent.TileType()]); + scanX = 10; + scanY = Math.Max(10, Main.maxTilesY - 2000); + cooldown = 0; + } + + public void Update() + { + if (cooldown-- > 0) + { + return; + } + for (int examined = 0; examined < 8192; examined++) + { + if (scanY >= Main.maxTilesY - 10) + { + scanY = Math.Max(10, Main.maxTilesY - 2000); + scanX = 10; + cooldown = 1800; + doors.RemoveAll(marker => !Valid(marker)); + pylons.RemoveAll(marker => !Valid(marker)); + stations.RemoveAll(marker => !Valid(marker)); + break; + } + Inspect(scanX, scanY); + if (++scanX >= Main.maxTilesX - 10) + { + scanX = 10; + scanY++; + } + } + } + + public void DiscoverNear(Rectangle view) + { + // Initial local life cannot wait for the background sweep to reach a newly visited plaza. + // At most 128 × 96 tiles, once per new camera region, independent of world size. + int centerX = view.Center.X / 16; + int centerY = view.Center.Y / 16; + for (int x = Math.Max(5, centerX - 64); x < Math.Min(Main.maxTilesX - 5, centerX + 64); x++) + { + for (int y = Math.Max(5, centerY - 48); y < Math.Min(Main.maxTilesY - 5, centerY + 48); y++) + { + Inspect(x, y); + } + } + } + + private void Inspect(int x, int y) + { + var tile = Main.tile[x, y]; + if (!tile.HasUnactuatedTile || tile.TileFrameX != 0 || tile.TileFrameY != 0) + { + return; + } + var point = new Point(x, y); + if (doorSizes.TryGetValue(tile.TileType, out var size)) + { + Add(doors, new CrowdLandmark(point, tile.TileType, size.X, size.Y)); + } + if (tile.TileType == ModContent.TileType()) + { + Add(pylons, new CrowdLandmark(point, tile.TileType, 8, 12)); + } + if (stationTypes.Contains(tile.TileType)) + { + Add(stations, new CrowdLandmark(point, tile.TileType, 1, 1)); + } + } + + private static void Add(List list, CrowdLandmark marker) + { + if (list.Count < 128 && !list.Contains(marker)) + { + list.Add(marker); + } + } + + private static bool Valid(CrowdLandmark marker) + { + for (int x = 0; x < marker.Width; x++) + { + for (int y = 0; y < marker.Height; y++) + { + if (!WorldGen.InWorld(marker.Origin.X + x, marker.Origin.Y + y, 5)) + { + return false; + } + var tile = Main.tile[marker.Origin.X + x, marker.Origin.Y + y]; + if (!tile.HasUnactuatedTile || tile.TileType != marker.Type || tile.TileFrameX != x * 18 || tile.TileFrameY != y * 18) + { + return false; + } + } + } + return true; + } + + public bool Valid(CrowdPlace place) + { + if (!CrowdTerrain.CanStand(place.Cell)) + { + return false; + } + if (place.Activity == CrowdActivity.Sit) + { + return CrowdSeats.Available(place.Cell); + } + if (place.TileOrigin is Point origin) + { + if (place.Activity is CrowdActivity.Browse or CrowdActivity.EnterBuilding or CrowdActivity.EnterGuild && !doorSizes.ContainsKey(place.TileType)) + { + return false; + } + if ((place.Activity == CrowdActivity.EnterGuild && place.TileType != guildType) + || (place.TileType == guildType && !CrowdLifeRules.InsideGuildOpening(origin, CrowdTerrain.Feet(place.Cell)))) + { + return false; + } + var size = doorSizes.GetValueOrDefault(place.TileType, new Point(1, 1)); + return Valid(new CrowdLandmark(origin, place.TileType, size.X, size.Y)); + } + return place.Activity != CrowdActivity.LeaveStreet || !ViewBounds.Contains(CrowdTerrain.Feet(place.Cell).ToPoint()); + } + + public IEnumerable Entrances(Vector2 near, bool pylon) + { + foreach (var marker in (pylon ? pylons : doors).Where(marker => (!pylon || marker.Type == ModContent.TileType()) && Valid(marker) + && Vector2.DistanceSquared(marker.Origin.ToWorldCoordinates(), near) < 2400 * 2400)) + { + bool guild = marker.Type == guildType; + Vector2 focus = guild ? new Vector2(marker.Origin.X * 16 + 56, marker.Origin.Y * 16 + 128) + : marker.Origin.ToWorldCoordinates() + (pylon + ? new Vector2(64, 192) + : new Vector2(marker.Width * 8 - 8, marker.Height * 16 - 8)); + if (CrowdTerrain.TryGround(focus, out var cell, pylon ? 5 : 2, pylon ? 48 : 2, upward: 3) + && (pylon || Math.Abs(CrowdTerrain.Feet(cell).Y - focus.Y) <= 24) + && (!guild || CrowdLifeRules.InsideGuildOpening(marker.Origin, CrowdTerrain.Feet(cell)))) + { + yield return new CrowdPlace(pylon ? CrowdActivity.LeavePylon : guild ? CrowdActivity.EnterGuild : CrowdActivity.EnterBuilding, + cell, $"tile:{marker.Origin}", focus, marker.Origin, marker.Type); + } + } + } + + public bool CanSeed(Vector2 feet) => TownBounds.Contains(feet.ToPoint()) || Entrances(feet, true).Any(pylon => + Math.Abs(feet.X - CrowdTerrain.Feet(pylon.Cell).X) <= 640 && Math.Abs(feet.Y - CrowdTerrain.Feet(pylon.Cell).Y) <= 96); + + public bool TryStandingPlace(CrowdPlace place, Func occupied, out CrowdPlace standingPlace) + { + standingPlace = place; + if (!Valid(place)) + { + return false; + } + if (!occupied(place.Cell)) + { + return true; + } + // Entrance animations must remain at their actual entrance, not fade through a nearby wall. + if (place.Activity == CrowdActivity.Sit || (CrowdActivityRules.IsExit(place.Activity) && place.Activity != CrowdActivity.BoardTrain)) + { + return false; + } + int outward = CrowdTerrain.Feet(place.Cell).X < place.Focus.X ? -1 : 1; + for (int distance = 2; distance <= 4; distance += 2) + { + for (int side = -1; side <= 1; side += 2) + { + if (place.Activity == CrowdActivity.BoardTrain && side != outward) + { + continue; + } + var cell = new Point(place.Cell.X + distance * side, place.Cell.Y); + var alternative = place with { Cell = cell }; + // Keep the group on one continuous floor; a free point beyond a wall or gap is a different place. + bool connected = Enumerable.Range(1, distance).All(offset => + CrowdTerrain.CanStand(new Point(place.Cell.X + offset * side, place.Cell.Y))); + if (connected && !occupied(cell) && Valid(alternative)) + { + standingPlace = alternative; + return true; + } + } + } + return false; + } + + public IEnumerable Candidates(Vector2 from, bool departing) + { + foreach (var entrance in Entrances(from, false)) + { + yield return departing ? entrance : entrance with { Activity = CrowdActivity.Browse }; + } + var plazaEntrances = Entrances(from, true).ToList(); + foreach (var pylon in plazaEntrances) + { + yield return departing ? pylon : pylon with { Activity = CrowdActivity.Scenery }; + } + if (departing) + { + foreach (var station in stations.Where(Valid)) + { + var focus = new Vector2((station.Origin.X + 2) * 16, (station.Origin.Y + 5) * 16); + if (Vector2.DistanceSquared(from, focus) < 2800 * 2800 && TryStation(focus, from.X, out var cell)) + { + yield return new CrowdPlace(CrowdActivity.BoardTrain, cell, $"station:{station.Origin}", focus, station.Origin, station.Type); + } + } + } + else + { + foreach (var seat in CrowdSeats.Nearby(from)) + { + yield return seat; + } + } + for (int i = 0; i < 8; i++) + { + Vector2 near = from + new Vector2(Main.rand.Next(8, 42) * 16 * (Main.rand.NextBool() ? 1 : -1), Main.rand.Next(-8, 9) * 16); + if (departing) + { + var view = ViewBounds; + near.X = Main.rand.NextBool() ? view.Left - Main.rand.Next(48, 192) : view.Right + Main.rand.Next(48, 192); + } + if (CrowdTerrain.TryGround(near, out var cell, 4, 12) + && (TownBounds.Contains(CrowdTerrain.Feet(cell).ToPoint()) || (!departing && plazaEntrances.Any(plaza => + Math.Abs(CrowdTerrain.Feet(cell).Y - CrowdTerrain.Feet(plaza.Cell).Y) <= 96 + && Math.Abs(CrowdTerrain.Feet(cell).X - CrowdTerrain.Feet(plaza.Cell).X) <= 640))) + && (!departing || !ViewBounds.Contains(CrowdTerrain.Feet(cell).ToPoint()))) + { + yield return new CrowdPlace(departing ? CrowdActivity.LeaveStreet : CrowdActivity.Scenery, + cell, $"street:{cell.X / 8}:{cell.Y / 4}", CrowdTerrain.Feet(cell)); + } + } + } + + public static bool TryStation(Vector2 station, float fromX, out Point cell) + { + float side = fromX < station.X ? -1 : 1; + return CrowdTerrain.TryGround(station + new Vector2(side * 912, -16), out cell, 6, 0, upward: CrowdNavigation.MaxDropTiles) + && Math.Abs(CrowdTerrain.Feet(cell).X - station.X) >= 864; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs new file mode 100644 index 000000000..53c02afd4 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs @@ -0,0 +1,102 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +[Flags] +public enum CrowdRetirementReason +{ + None = 0, + Threat = 1, + Blocked = 2, + Departure = 4, +} + +/// Simulation-time retirement observations and one reversible visual fade. Never drives behavior. +public sealed class CrowdRetirement +{ + private int threatTicks; + private int movementTicks; + private int departureTicks; + private int movementRecoveries; + private bool fading; + private int transitionTicks; + private float transitionStart = 1; + + public CrowdRetirementReason Reasons { get; private set; } + public float Opacity { get; private set; } = 1; + public bool Complete => Opacity <= 0; + public int DepartureFailures { get; private set; } + public int MovementTicks => movementTicks; + + public void ObserveThreat(bool alarmed) + { + threatTicks = alarmed ? Math.Min(threatTicks + 1, CrowdSettings.ThreatRetirementTicks) : 0; + SetReason(CrowdRetirementReason.Threat, threatTicks >= CrowdSettings.ThreatRetirementTicks); + } + + public void RecordMovementRecovery() => movementRecoveries = Math.Min(movementRecoveries + 1, CrowdSettings.RetirementAttempts); + + public void RecordDepartureFailure() => DepartureFailures = Math.Min(DepartureFailures + 1, CrowdSettings.RetirementAttempts); + + public void ObserveMovement(bool required, bool suspended, bool progressed) + { + if (progressed) + { + ResolveMovement(); + } + else if (required && !suspended) + { + movementTicks = Math.Min(movementTicks + 1, CrowdSettings.MovementRetirementTicks); + } + SetReason(CrowdRetirementReason.Blocked, movementTicks >= CrowdSettings.MovementRetirementTicks + && movementRecoveries >= CrowdSettings.RetirementAttempts); + } + + public void ObserveDeparture(bool intended, bool suspended, bool progressed) + { + if (!intended || progressed) + { + ResolveDeparture(); + } + else if (!suspended) + { + departureTicks = Math.Min(departureTicks + 1, CrowdSettings.DepartureRetirementTicks); + } + SetReason(CrowdRetirementReason.Departure, departureTicks >= CrowdSettings.DepartureRetirementTicks + && DepartureFailures >= CrowdSettings.RetirementAttempts); + } + + public void ResolveMovement() + { + movementTicks = movementRecoveries = 0; + SetReason(CrowdRetirementReason.Blocked, false); + } + + public void ResolveDeparture() + { + departureTicks = DepartureFailures = 0; + SetReason(CrowdRetirementReason.Departure, false); + } + + public void SetReason(CrowdRetirementReason reason, bool active) + { + Reasons = active ? Reasons | reason : Reasons & ~reason; + } + + /// Advance once per simulation tick, after all reasons have been observed. + public void Update() + { + if (Complete) + { + return; + } + bool shouldFade = Reasons != CrowdRetirementReason.None; + if (shouldFade != fading) + { + fading = shouldFade; + transitionStart = Opacity; + transitionTicks = 0; + } + int duration = fading ? CrowdSettings.RetirementFadeTicks : CrowdSettings.RetirementRestoreTicks; + transitionTicks = Math.Min(transitionTicks + 1, duration); + Opacity = MathHelper.SmoothStep(transitionStart, fading ? 0 : 1, transitionTicks / (float)duration); + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdSeats.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdSeats.cs new file mode 100644 index 000000000..80c3c5291 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdSeats.cs @@ -0,0 +1,79 @@ +using Terraria.DataStructures; +using Terraria.ObjectData; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Single-place chairs only; a floor route remains authoritative even while the view is seated. +internal static class CrowdSeats +{ + public static bool TryFind(Point tilePoint, out Point floor) + { + floor = default; + if (!WorldGen.InWorld(tilePoint.X, tilePoint.Y, 5)) + { + return false; + } + var tile = Main.tile[tilePoint]; + if (!tile.HasUnactuatedTile || !TileID.Sets.CanBeSatOnForNPCs[tile.TileType]) + { + return false; + } + var data = TileObjectData.GetTileData(tile); + if (data == null || data.Width != 1 || data.Height != 2) + { + return false; + } + floor = new Point(tilePoint.X, tilePoint.Y + (tile.TileFrameY % data.CoordinateFullHeight == 0 ? 1 : 0)); + var upper = Main.tile[floor.X, floor.Y - 1]; + var lower = Main.tile[floor]; + return upper.HasUnactuatedTile && lower.HasUnactuatedTile && upper.TileType == tile.TileType && lower.TileType == tile.TileType + && upper.TileFrameX == lower.TileFrameX && lower.TileFrameY - upper.TileFrameY == data.CoordinateHeights[0] + data.CoordinatePadding + && CrowdTerrain.CanStand(floor); + } + + public static bool Available(Point floor) => TryFind(floor, out var actual) && actual == floor + && !Main.player.Any(player => player.active && player.sitting.isSitting + && Math.Abs(player.Bottom.X - CrowdTerrain.Feet(floor).X) < 24 && Math.Abs(player.Bottom.Y - CrowdTerrain.Feet(floor).Y) < 40) + && !Main.npc.Any(npc => npc.active && npc.townNPC + && Math.Abs(npc.Bottom.X - CrowdTerrain.Feet(floor).X) < 24 && Math.Abs(npc.Bottom.Y - CrowdTerrain.Feet(floor).Y) < 40); + + private static TileRestingInfo SittingInfo(Entity body, Point floor) + { + var tile = Main.tile[floor]; + // Match PlayerSittingHelper's chair defaults; mod chairs may replace the anchor and visual offsets. + var visual = new Vector2(0, tile.TileType == TileID.Chairs && tile.TileFrameY / 40 == 27 ? 4 : 0); + var info = new TileRestingInfo(body, new Point(floor.X, floor.Y - 1), visual, tile.TileFrameX == 0 ? -1 : 1, 6, Vector2.Zero, default); + TileLoader.ModifySittingTargetInfo(floor.X, floor.Y, tile.TileType, ref info); + return info; + } + + public static int Direction(Entity body, Point floor) => SittingInfo(body, floor).TargetDirection < 0 ? -1 : 1; + + public static Vector2 VisualOffset(Entity body, Point floor, out float seatYOffset) + { + var info = SittingInfo(body, floor); + seatYOffset = -4 + (int)info.VisualOffset.Y; + return info.AnchorTilePosition.ToWorldCoordinates(8, 16) + new Vector2(info.TargetDirection * info.DirectionOffset, 0) + + info.FinalOffset + info.VisualOffset * new Vector2(info.TargetDirection, 1) - body.Bottom; + } + + public static IEnumerable Nearby(Vector2 from) + { + var center = CrowdTerrain.Cell(from); + var found = new HashSet(); + for (int x = center.X - 28; x <= center.X + 28; x++) + { + for (int y = center.Y - 8; y <= center.Y + 8; y++) + { + if (TryFind(new Point(x, y), out var floor) && found.Add(floor) && Available(floor)) + { + yield return new CrowdPlace(CrowdActivity.Sit, floor, $"chair:{floor}", CrowdTerrain.Feet(floor)); + if (found.Count >= 6) + { + yield break; + } + } + } + } + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs new file mode 100644 index 000000000..d73083f3d --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs @@ -0,0 +1,173 @@ +using Everglow.Yggdrasil.YggdrasilTown.Events; +using Everglow.Yggdrasil.YggdrasilTown.NPCs.TownNPCs; +using Terraria.GameContent.Events; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Shared local danger snapshot, plus bounded post-battle spawn throttling. +internal static class CrowdThreats +{ + private sealed record CrowdThreat(Vector2 Center, float Radius, bool IgnoresWalls); + private sealed class CrowdRecovery(long tick) + { + public long LastDanger = tick; + public long NextArrival = tick + 300; + } + + private static readonly List Sources = []; + private static readonly List Areas = []; + private static readonly Dictionary Recoveries = []; + private static long nextScan; + + internal static bool FightingBoss(NPC npc) => npc.active && npc.boss && npc.life > 0 + && (npc.ModNPC is TownNPC_LiveInYggdrasil town + ? town.ArenaFighting && town.StartedFight && !town.Fail : !npc.friendly); + + internal static bool Enemy(NPC npc) => npc.active && !npc.friendly && npc.life > 0 && npc.damage > 0 + && !npc.townNPC && npc.type != NPCID.TargetDummy && !npc.CountsAsACritter; + + internal static bool EnemyNear(Vector2 center) => Main.npc.Any(npc => Enemy(npc) + && Vector2.DistanceSquared(npc.Center, center) < 480 * 480); + + public static void Update(long clock) + { + if (clock < nextScan) + { + return; + } + nextScan = clock + 6; + Sources.Clear(); + Areas.Clear(); + foreach (var stale in Recoveries.Where(pair => clock - pair.Value.LastDanger >= 600).Select(pair => pair.Key).ToArray()) + { + Recoveries.Remove(stale); + } + foreach (var npc in Main.npc) + { + bool boss = FightingBoss(npc); + if (boss || Enemy(npc)) + { + Sources.Add(new CrowdThreat(npc.Center, (boss ? 2000 : 480) + Math.Max(npc.width, npc.height) * 0.5f, boss)); + if (boss) + { + RememberBattle(new Rectangle((int)npc.Center.X - 2000, (int)npc.Center.Y - 2000, 4000, 4000), clock); + } + } + } + foreach (var projectile in Main.projectile) + { + if (projectile.active && projectile.hostile && projectile.damage > 0) + { + Sources.Add(new CrowdThreat(projectile.Center, 160, false)); + } + } + Areas.AddRange(EventAreas()); + foreach (var area in Areas) + { + RememberBattle(area, clock); + } + } + + private static IEnumerable EventAreas() + { + if (ModContent.GetInstance().Active) + { + yield return CrowdPlaces.TownBounds; + } + int surface = Math.Max(0, (int)(Main.worldSurface * 16)); + if (Main.bloodMoon || Main.eclipse || Main.pumpkinMoon || Main.snowMoon) + { + yield return new Rectangle(0, 0, Main.maxTilesX * 16, surface + 320); + } + if (Main.invasionType > 0 && Main.invasionSize > 0) + { + yield return new Rectangle((int)(Main.invasionX * 16) - 3200, 0, 6400, surface + 800); + } + if (DD2Event.Ongoing) + { + foreach (var crystal in Main.npc.Where(npc => npc.active && npc.type == NPCID.DD2EterniaCrystal)) + { + yield return new Rectangle((int)crystal.Center.X - 2400, (int)crystal.Center.Y - 1200, 4800, 2400); + } + } + } + + private static void RememberBattle(Rectangle area, long clock) + { + // Quantized footprints remember a moving boss's recent battlefield without unbounded history. + int left = (int)MathF.Floor(area.Left / 128f) * 128; + int top = (int)MathF.Floor(area.Top / 128f) * 128; + area = new Rectangle(left, top, (int)MathF.Ceiling(area.Right / 128f) * 128 - left, + (int)MathF.Ceiling(area.Bottom / 128f) * 128 - top); + if (!Recoveries.TryGetValue(area, out var recovery)) + { + if (Recoveries.Count >= 128) + { + Recoveries.Remove(Recoveries.MinBy(pair => pair.Value.LastDanger).Key); + } + Recoveries.Add(area, recovery = new CrowdRecovery(clock)); + } + recovery.LastDanger = clock; + recovery.NextArrival = clock + 300; + } + + public static bool Dangerous(Vector2 feet) => Areas.Any(area => area.Contains(feet.ToPoint())) || Sources.Any(source => + Vector2.DistanceSquared(feet, source.Center) <= source.Radius * source.Radius + && (source.IgnoresWalls || Collision.CanHitLine(feet - new Vector2(0, 24), 1, 1, source.Center, 1, 1))); + + public static float Clearance(Vector2 point) + { + float clearance = float.PositiveInfinity; + foreach (var source in Sources) + { + clearance = Math.Min(clearance, Vector2.Distance(point, source.Center) - source.Radius); + } + foreach (var area in Areas) + { + float distance = AreaClearance(area, point); + clearance = Math.Min(clearance, distance); + } + return clearance; + } + + private static float AreaClearance(Rectangle area, Vector2 point) => area.Contains(point.ToPoint()) + ? -Math.Min(Math.Min(point.X - area.Left, area.Right - point.X), Math.Min(point.Y - area.Top, area.Bottom - point.Y)) + : Vector2.Distance(point, Vector2.Clamp(point, area.TopLeft(), area.BottomRight())); + + public static bool EscapeAllowed(Vector2 from, Vector2 point) => Sources.All(source => + Vector2.Distance(point, source.Center) - source.Radius >= Math.Min(0, Vector2.Distance(from, source.Center) - source.Radius) - 16) + && Areas.All(area => AreaClearance(area, point) >= Math.Min(0, AreaClearance(area, from)) - 16); + + public static int AwayDirection(Vector2 from, int fallback) + { + var nearest = Sources.MinBy(source => Vector2.Distance(from, source.Center) - source.Radius); + return nearest != null && Math.Abs(from.X - nearest.Center.X) > 8 ? Math.Sign(from.X - nearest.Center.X) : fallback; + } + + public static bool SpawnAllowed(Vector2 point) + { + // Also check live major events: activation can occur after the current tick's shared snapshot. + if (Dangerous(point) || EventAreas().Any(area => area.Contains(point.ToPoint())) + || Main.npc.Any(npc => FightingBoss(npc) && Vector2.DistanceSquared(point, npc.Center) <= 2000 * 2000)) + { + return false; + } + return !Recoveries.Any(pair => pair.Key.Contains(point.ToPoint()) && TownCrowdSystem.Clock < pair.Value.NextArrival); + } + + public static void RecordArrival(Vector2 point) + { + foreach (var pair in Recoveries.Where(pair => pair.Key.Contains(point.ToPoint()))) + { + pair.Value.NextArrival = TownCrowdSystem.Clock + 180; + } + } + + public static void Clear() + { + Sources.Clear(); + Areas.Clear(); + Recoveries.Clear(); + nextScan = 0; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdTrainState.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdTrainState.cs new file mode 100644 index 000000000..a0f336b6e --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdTrainState.cs @@ -0,0 +1,68 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +public sealed class CrowdTrainLoad +{ + private bool departedStop; + + public int Loaded { get; private set; } + + // Stable slots alternate from the two exits inward, independent of the live load setting. + public static float SeatOffset(int index, float width) => index % 2 == 0 ? 96 + index / 2 * 128 : width - 96 - index / 2 * 128; + + public void RecordLoaded() => Loaded++; + + public void Observe(bool stopped, bool visible, bool hasRiders) + { + if (stopped) + { + departedStop = true; + } + else if (departedStop && !visible && !hasRiders) + { + Loaded = 0; + departedStop = false; + } + } +} + +public sealed class CrowdStopBatch +{ + private int attemptedSides; + private int arrivals; + private int nextAttemptTimer; + + public bool TryDispatch(int passengerLimit, int planningBudget, out int side) + { + side = 0; + if (previousTimer < nextAttemptTimer || !attempted || attemptedSides >= 12 || arrivals >= passengerLimit || planningBudget < 2) + { + return false; + } + side = attemptedSides++ % 2 == 0 ? -1 : 1; + nextAttemptTimer = previousTimer + 30; + return true; + } + + public void RecordArrival() => arrivals++; + + private bool attempted; + private int previousTimer; + + public bool TryBegin(bool stopped, int timer, int duration) + { + if (!stopped || timer < previousTimer) + { + attempted = false; + attemptedSides = 0; + arrivals = 0; + nextAttemptTimer = 0; + } + previousTimer = timer; + if (!stopped || attempted || timer < 30 || timer >= duration - 90) + { + return false; + } + attempted = true; + return true; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdConfig.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdConfig.cs new file mode 100644 index 000000000..0bf16d0c9 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdConfig.cs @@ -0,0 +1,24 @@ +using System.ComponentModel; +using Terraria.ModLoader.Config; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +public enum CrowdDensity +{ + Sparse, + Standard, + Busy, +} + +/// Local presentation preferences, shared by all characters on this client. +public class CrowdConfig : ModConfig +{ + public override ConfigScope Mode => ConfigScope.ClientSide; + + [DefaultValue(true)] + public bool Enabled = true; + + [DefaultValue(CrowdDensity.Standard)] + [DrawTicks] + public CrowdDensity Density = CrowdDensity.Standard; +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs new file mode 100644 index 000000000..0d7e9cf50 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs @@ -0,0 +1,44 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Density budgets and shared behavior tuning; only density is player configurable. +public sealed record CrowdSettings(int PopulationLimit, int SpawnIntervalTicks, int InitialFill, + int TrainInitialPassengers, int TrainPassengers) +{ + public const int DoorCooldownTicks = 10 * 60; + public const int PylonCooldownTicks = 60 * 60; + public const int ActivityMinTicks = 3 * 60; + public const int ActivityMaxTicks = 9 * 60; + public const int ChatMinTicks = 8 * 60; + public const int ChatMaxTicks = 15 * 60; + public const int OffscreenTicks = 5 * 60; + public const int ThreatRetirementTicks = 10 * 60; + public const int MovementRetirementTicks = 15 * 60; + public const int DepartureRetirementTicks = 30 * 60; + public const int RetirementAttempts = 3; + public const int RetirementFadeTicks = 2 * 60; + public const int RetirementRestoreTicks = 30; + public const int RecoveryAttemptTicks = 2 * 60; + public const float MovementProgressDistance = 16; + public const int ChatRangeTiles = 4; + public const int ChatChancePercent = 40; + public const int ReturnPercent = 30; + public const int GuildPercent = 30; + public const int StreetWeight = 8; + public const int BuildingWeight = 3; + public const int PylonWeight = 1; + public const int TotalSourceWeight = StreetWeight + BuildingWeight + PylonWeight; + + private static readonly CrowdSettings Sparse = new(24, 240, 1, 3, 1); + private static readonly CrowdSettings Standard = new(48, 60, 2, 6, 2); + private static readonly CrowdSettings Busy = new(96, 30, 3, 9, 3); + + public static CrowdSettings ForDensity(CrowdDensity density) => density switch + { + CrowdDensity.Sparse => Sparse, + CrowdDensity.Busy => Busy, + _ => Standard, + }; + + /// Street = 0, building = 1, pylon = 2. + public static int ChooseSource(int roll) => roll < StreetWeight ? 0 : roll < StreetWeight + BuildingWeight ? 1 : 2; +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs new file mode 100644 index 000000000..b26ed7c7c --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs @@ -0,0 +1,1074 @@ +using Everglow.Commons.CustomTiles; +using Everglow.Yggdrasil.YggdrasilTown.CustomTiles; +using Terraria.Localization; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +internal enum CrowdStage +{ + Arriving, + Planning, + Walking, + Activity, + Waiting, + Boarding, + Alighting, + Riding, + Leaving, + Fleeing, + Conversing, +} + +/// A lightweight resident's intent, safe route and visual presentation. +internal sealed class CrowdAgent +{ + private readonly CrowdAppearance view; + private readonly CrowdArrival arrival; + private readonly int routeSeed; + private readonly float speed = Main.rand.NextFloat(1.2f, 1.7f); + private readonly int pauseInterval = Main.rand.Next(300, 600); + private readonly int arrivalDelay = Main.rand.Next(20, 70); + private readonly Queue recentPlaces = []; + private readonly CrowdAvoidance avoidance = new(); + private readonly CrowdRetirement retirement = new(); + private readonly CrowdProgress movementProgress = new(); + private Vector2 movementTarget; + private bool movementPending; + private bool advancedWaypoint; + private bool waitingForBudget; + private YggdrasilTown_NormalMetro progressTrain; + + private CrowdPlace destination; + private int stageTime; + private int age; + private int completedActivities; + private int activityDuration; + private int planDelay; + private bool departureRequested; + + private List route; + private int waypoint = 1; + private int yieldTime; + private int pauseTime; + private float stepSpeed = 1; + private float stepOffset; + private bool jumping; + private Vector2 jumpTarget; + private bool dropping; + private float dropSourceY; + private bool recoveringPlatform; + + private YggdrasilTown_NormalMetro train; + private Vector2 aboardOffset; + private Vector2 boardingDoorOffset; + private int movingTime; + private bool visitedStop; + private bool mayAlight; + + private int escapeDirection; + private int escapeAttempts; + private CrowdStage interruptedStage; + private int interruptedStageTime; + private long nextConversation; + private Vector2 previousViewPosition; + private float animationDistance; + + public CrowdAgent(CrowdBody actor, List path, CrowdPlace destination, + CrowdArrival arrival, CrowdPlace entrance, int seed, YggdrasilTown_NormalMetro arrivingTrain = null, Vector2? aboardFeet = null) + { + Actor = actor; + route = path; + this.destination = destination; + this.arrival = arrival; + train = arrivingTrain; + if (entrance != null) + { + recentPlaces.Enqueue(entrance.Key); + } + routeSeed = seed; + Body.Bottom = CrowdTerrain.Feet(path[0]); + if (arrival == CrowdArrival.Existing) + { + Stage = CrowdStage.Walking; + } + if (aboardFeet.HasValue) + { + Body.Bottom = aboardFeet.Value; + aboardOffset = Body.position - train.Position; + Stage = CrowdStage.Riding; + mayAlight = true; + visitedStop = CrowdTerrain.TrainStopped(train); + } + previousViewPosition = Body.position; + view = CrowdAppearance.Create(Body.position); + view.Opacity = aboardFeet.HasValue || arrival is CrowdArrival.Street or CrowdArrival.Existing ? 1 : 0; + view.UpdateController = UpdateView; + view.DrawInForeground = () => InteriorTrain == null; + UpdateView(view); + Ins.VFXManager.Add(view); + } + + public Entity Body => Actor.Body; + + public CrowdBody Actor { get; } + + public CrowdIntent Intent { get; internal set; } + + internal bool IsSitting => !IsEvacuating && Stage == CrowdStage.Activity && CurrentActivity == CrowdActivity.Sit; + + internal bool IsEvacuating => avoidance.Alarmed || Stage == CrowdStage.Fleeing; + + internal CrowdActivity? CurrentActivity => Stage == CrowdStage.Conversing ? CrowdActivity.Socialize : destination?.Activity; + + internal bool CanConverse => Active && !IsEvacuating && InteriorTrain == null && !jumping && !dropping + && TownCrowdSystem.Clock >= nextConversation && CrowdTerrain.Grounded(Body) + && (Stage is CrowdStage.Planning or CrowdStage.Walking + || (Stage == CrowdStage.Activity && destination?.Activity is CrowdActivity.Scenery or CrowdActivity.Browse)); + + internal void PauseForConversation() + { + interruptedStage = Stage; + interruptedStageTime = stageTime; + Stage = CrowdStage.Conversing; + stageTime = 0; + Body.velocity.X = 0; + } + + internal void FaceConversation(Entity partner) + { + if (Math.Abs(partner.Center.X - Body.Center.X) > 4) + { + Direction = Math.Sign(partner.Center.X - Body.Center.X); + } + } + + internal void ResumeAfterConversation(bool completed) + { + nextConversation = TownCrowdSystem.Clock + CrowdConversation.CooldownTicks; + if (Stage != CrowdStage.Conversing) + { + return; + } + Stage = interruptedStage; + stageTime = interruptedStageTime; + if (completed) + { + completedActivities++; + } + } + + public string DisplayName => Language.GetTextValue(CrowdNames.KeyFor(routeSeed)); + + internal bool CanShowNameAt(Vector2 worldMouse) => !IsEvacuating && Active && view.Active && view.Visible + && view.EffectiveOpacity > 0.25f && (InteriorTrain == null || InteriorTrain.ShellAlpha <= 0.1f) + && new Rectangle((int)view.Position.X - 16, (int)view.Position.Y - 4, 32, 56).Contains(worldMouse.ToPoint()); + + public bool Active { get; private set; } = true; + + public CrowdStage Stage { get; private set; } + + public int Direction { get; private set; } = 1; + + public bool IsAttached => Stage == CrowdStage.Riding; + + // Keep the same owner during boarding/alighting, but only once physically inside. + internal YggdrasilTown_NormalMetro InteriorTrain => Active && train?.Active == true + && (IsAttached || WalkingTrain != null) + && Body.Center.X >= train.Position.X && Body.Center.X < train.Position.X + train.Size.X + && Body.Center.Y >= train.Position.Y - 146 && Body.Center.Y < train.Position.Y ? train : null; + + internal void DrawInTrain(SpriteBatch spriteBatch) + { + if (view.Active && view.Visible) + { + view.DrawAppearance(spriteBatch); + } + } + + internal bool CanAlight(YggdrasilTown_NormalMetro candidate) => Active && IsAttached && mayAlight && ReferenceEquals(train, candidate); + + internal bool Aboard(YggdrasilTown_NormalMetro candidate) => Active && IsAttached && ReferenceEquals(train, candidate); + + internal float WalkSpeed => speed; + + internal void BeginAlighting(List path, CrowdPlace landing) + { + Actor.ReleaseSupport(); + destination = landing; + SetRoute(path); + mayAlight = false; + Body.velocity = Vector2.Zero; + ChangeStage(CrowdStage.Alighting); + } + + public int RouteSeed => routeSeed; + + public string DestinationKey => Stage is CrowdStage.Planning or CrowdStage.Riding or CrowdStage.Fleeing ? null : destination?.Key; + + internal bool ReservesStandingPoint(Point cell) => Active && DestinationKey != null + && Math.Abs(destination.Cell.X - cell.X) < 2 && Math.Abs(destination.Cell.Y - cell.Y) < 3; + + public bool RecentlyVisited(string key) => recentPlaces.Contains(key); + + public float Preference(CrowdActivity activity) => 0.5f + (uint)(routeSeed ^ ((int)activity * 7919)) % 7 * 0.3f; + + private YggdrasilTown_NormalMetro WalkingTrain => Stage is CrowdStage.Boarding or CrowdStage.Alighting ? train : null; + + public bool FallThroughPlatforms => dropping && (recoveringPlatform + ? Body.Bottom.Y + Math.Max(Body.velocity.Y, 0) < dropSourceY + : CrowdRules.FallThroughSourcePlatform(Body.Bottom.Y, dropSourceY)); + + public void Kill() + { + if (!Active) + { + return; + } + CrowdConversation.End(this); + Active = false; + Actor.ReleaseSupport(); + train = null; + destination = null; + view.Active = false; + view.Visible = false; + view.UpdateController = null; + view.DrawInForeground = null; + } + + public void Update() + { + if (!Active) + { + return; + } + if (!TownCrowdSystem.Enabled) + { + Kill(); + return; + } + if (Main.gamePaused) + { + return; + } + advancedWaypoint = false; + UpdateBehavior(); + if (Active) + { + UpdateRetirement(); + } + } + + private bool WantsToLeave => departureRequested || Intent != CrowdIntent.VisitTown; + + private bool FollowingExit => Stage is CrowdStage.Walking or CrowdStage.Boarding or CrowdStage.Leaving or CrowdStage.Waiting + && destination != null && CrowdActivityRules.IsExit(destination.Activity); + + private void UpdateRetirement() + { + bool resting = Stage is CrowdStage.Activity or CrowdStage.Conversing or CrowdStage.Waiting or CrowdStage.Riding or CrowdStage.Arriving; + bool suspended = resting || waitingForBudget || (Stage == CrowdStage.Walking && pauseTime > 0); + bool moving = Stage is CrowdStage.Walking or CrowdStage.Boarding or CrowdStage.Alighting or CrowdStage.Fleeing + || (Stage == CrowdStage.Planning && movementPending) || recoveringPlatform; + var reference = WalkingTrain; + if (!ReferenceEquals(reference, progressTrain)) + { + // Train motion is not walking progress; changing coordinate spaces is not progress either. + movementProgress.Reset(); + progressTrain = reference; + } + var offset = reference?.Position ?? Vector2.Zero; + bool progressed = moving && !suspended && movementPending + && movementProgress.Observe(Body.Bottom - offset, movementTarget - offset, advancedWaypoint); + retirement.ObserveThreat(avoidance.Alarmed); + retirement.ObserveMovement(moving, suspended, progressed); + retirement.ObserveDeparture(WantsToLeave, suspended, progressed && FollowingExit); + retirement.Update(); + view.RetirementOpacity = retirement.Opacity; + if (retirement.Complete) + { + Kill(); + } + } + + private void ResolveMovement() + { + retirement.ResolveMovement(); + movementPending = false; + waitingForBudget = false; + movementProgress.Reset(); + } + + private void RecoverToward(Vector2 target) + { + movementPending = true; + movementTarget = target; + waitingForBudget = false; + if (age % CrowdSettings.RecoveryAttemptTicks == 0) + { + retirement.RecordMovementRecovery(); + } + } + + private void UpdateBehavior() + { + age++; + stageTime++; + if (IsAttached) + { + Ride(); + return; + } + if (!UpdateSafety(out bool grounded)) + { + return; + } + Body.velocity.X = 0; + if (grounded && CanConverse && (TownCrowdSystem.Clock + (uint)routeSeed) % CrowdConversation.OpportunityInterval == 0) + { + TryConversation(); + } + if (UpdatePlatformRecovery(grounded)) + { + return; + } + UpdateStage(grounded); + if (Active && !IsAttached) + { + MoveBody(grounded); + } + } + + private bool UpdateSafety(out bool grounded) + { + grounded = false; + bool threatened = CrowdThreats.Dangerous(Body.Bottom); + if (threatened && WalkingTrain != null && CrowdTerrain.OnTrain(Body, train)) + { + // Remain aboard rather than jump from a moving deck or alight into a fight. + AttachToTrain(); + return false; + } + if (avoidance.Update(threatened, CrowdThreats.Clearance(Body.Bottom) > 320, TownCrowdSystem.Visible(Body), + CrowdSettings.OffscreenTicks)) + { + Kill(); + return false; + } + // Let a planned return finish outside the camera too. Reclaim only stranded + // offscreen visitors after a long unsuccessful planning period. + if (!avoidance.PendingRetirement && ((Stage == CrowdStage.Planning && stageTime > 1800 + && CrowdActivityRules.CanRetire(age, TownCrowdSystem.Visible(Body))) + || CrowdActivityRules.CanRetireAfterFailedDeparture(age, TownCrowdSystem.Visible(Body), retirement.DepartureFailures))) + { + Kill(); + return false; + } + grounded = CrowdTerrain.Grounded(Body); + if (avoidance.Alarmed) + { + CrowdConversation.End(this); + if (Stage != CrowdStage.Fleeing && grounded) + { + escapeDirection = CrowdThreats.AwayDirection(Body.Bottom, Direction == 0 ? 1 : Direction); + escapeAttempts = 0; + train = null; + view.Opacity = 1; + SetRoute([]); + planDelay = 0; + ChangeStage(CrowdStage.Fleeing); + } + } + else if (Stage == CrowdStage.Fleeing && grounded) + { + SetRoute([]); + planDelay = 0; + ChangeStage(CrowdStage.Planning); + } + return true; + } + + private bool UpdatePlatformRecovery(bool grounded) + { + if (Stage == CrowdStage.Planning && !recoveringPlatform && CrowdTerrain.TryPlatformRecovery(Body, out float landingY)) + { + recoveringPlatform = true; + dropping = true; + dropSourceY = landingY; + } + if (recoveringPlatform) + { + RecoverToward(new Vector2(Body.Bottom.X, dropSourceY)); + // A changed floor or an intervening moving surface must not trap the recovery state. + if (grounded || Body.Bottom.Y > dropSourceY + 2) + { + recoveringPlatform = false; + dropping = false; + planDelay = 0; + } + else + { + Body.velocity.Y = Math.Min(Body.velocity.Y + CrowdNavigation.Gravity, 8); + Actor.Move(FallThroughPlatforms); + return true; + } + } + return false; + } + + private void UpdateStage(bool grounded) + { + if (grounded && stageTime % 60 == 0 && Stage is CrowdStage.Walking or CrowdStage.Activity or CrowdStage.Waiting + && !TownCrowdSystem.ValidPlace(destination)) + { + ChangeStage(CrowdStage.Planning); + } + switch (Stage) + { + case CrowdStage.Conversing: + Converse(grounded); + break; + case CrowdStage.Fleeing: + Flee(grounded); + break; + case CrowdStage.Arriving: + Arrive(); + break; + case CrowdStage.Planning: + Plan(grounded); + break; + case CrowdStage.Walking: + if (MoveAlongRoute(grounded)) + { + ReachDestination(); + } + break; + case CrowdStage.Activity: + Stay(grounded); + break; + case CrowdStage.Waiting: + WaitForTrain(); + break; + case CrowdStage.Boarding: + Board(grounded); + break; + case CrowdStage.Alighting: + Alight(grounded); + break; + case CrowdStage.Leaving: + Leave(grounded); + break; + } + } + + private void Arrive() + { + view.Opacity = arrival == CrowdArrival.Street ? 1 : MathHelper.Clamp(stageTime / 30f, 0, 1); + if (arrival == CrowdArrival.Pylon && stageTime <= 35) + { + Dust.NewDust(Body.position, Body.width, Body.height, DustID.MagicMirror, 0, -1.5f, 100); + Lighting.AddLight(Body.Center, new Vector3(0.35f, 0.65f, 0.9f) * (1 - stageTime / 36f)); + } + if (stageTime >= (arrival == CrowdArrival.Pylon ? 90 : 30) + arrivalDelay) + { + ChangeStage(CrowdStage.Walking); + } + } + + private void Plan(bool grounded) + { + if (grounded && CrowdTerrain.TryStandingRecovery(Body, out float standingX)) + { + RecoverToward(new Vector2(standingX, Body.Bottom.Y)); + Direction = Math.Sign(standingX - Body.Bottom.X); + Body.velocity.X = MathHelper.Clamp(standingX - Body.Bottom.X, -speed, speed); + } + else if (grounded && --planDelay <= 0) + { + ChooseNextActivity(); + } + } + + private void Stay(bool grounded) + { + if (stageTime >= activityDuration) + { + completedActivities++; + Remember(destination.Key); + ChangeStage(CrowdStage.Planning); + return; + } + Direction = destination.Focus.X >= Body.Center.X ? 1 : -1; + if (destination.Activity == CrowdActivity.Sit) + { + if (!grounded || !CrowdSeats.Available(destination.Cell) || CrowdThreats.EnemyNear(Body.Center)) + { + Remember(destination.Key); + ChangeStage(CrowdStage.Planning); + return; + } + Direction = CrowdSeats.Direction(Body, destination.Cell); + } + } + + private void Alight(bool grounded) + { + if (!CrowdTerrain.TrainStopped(train) || train.StopTimer >= train.StopTimeMax - 30) + { + RecoverFromTrain(); + } + else if (MoveAlongRoute(grounded)) + { + train = null; + ChangeStage(CrowdStage.Planning); + } + } + + private void Converse(bool grounded) + { + if (!grounded) + { + CrowdConversation.End(this); + } + else if (!CrowdConversation.ContinueNearby(this) && Stage == CrowdStage.Conversing) + { + ResumeAfterConversation(false); + } + } + + private void MoveBody(bool grounded) + { + Body.velocity.Y = Math.Min(Body.velocity.Y + CrowdNavigation.Gravity, 8); + if (grounded && !dropping) + { + Collision.StepUp(ref Body.position, ref Body.velocity, Body.width, Body.height, ref stepSpeed, ref stepOffset); + CrowdTerrain.StepOntoSurface(Body, WalkingTrain); + } + stepOffset *= 0.8f; + Actor.Move(FallThroughPlatforms); + } + + private bool TryConversation() => Main.rand.Next(100) < CrowdSettings.ChatChancePercent + && CrowdConversation.TryNearby(this); + + private void Flee(bool grounded) + { + // A checked route may deliberately walk off support onto a lower safe floor. + // Recentring during that step pulls the body back onto the ledge every other tick. + if ((route.Count <= 1 || waypoint >= route.Count) && grounded && CrowdTerrain.TryStandingRecovery(Body, out float standingX) + && CrowdThreats.EscapeAllowed(Body.Bottom, new Vector2(standingX, Body.Bottom.Y))) + { + RecoverToward(new Vector2(standingX, Body.Bottom.Y)); + Direction = Math.Sign(standingX - Body.Bottom.X); + Body.velocity.X = MathHelper.Clamp(standingX - Body.Bottom.X, -speed, speed); + return; + } + if (!grounded && jumping) + { + WalkToward(jumpTarget, false); + return; + } + if (route.Count > 1 && waypoint < route.Count) + { + if (grounded && !CrowdThreats.EscapeAllowed(Body.Bottom, CrowdTerrain.Feet(route[waypoint]))) + { + SetRoute([]); + planDelay = 0; + } + else if (MoveAlongRoute(grounded)) + { + SetRoute([]); + planDelay = 30; + } + return; + } + if (grounded && --planDelay <= 0) + { + int budgetBefore = TownCrowdSystem.PlanningBudget; + bool planned = TownCrowdSystem.TryEscape(CrowdTerrain.Cell(Body.Bottom), escapeDirection, routeSeed, out var path, escapeAttempts); + waitingForBudget = path == null && TownCrowdSystem.PlanningBudget == budgetBefore; + if (!waitingForBudget) + { + escapeAttempts++; + retirement.RecordMovementRecovery(); + } + planDelay = path == null ? 5 : 60; + if (planned) + { + SetRoute(path); + escapeDirection = Math.Sign(path[^1].X - path[0].X); + } + } + } + + private void Remember(string key) + { + recentPlaces.Enqueue(key); + while (recentPlaces.Count > 2) + { + recentPlaces.Dequeue(); + } + } + + private void ChooseNextActivity() + { + if (TryConversation()) + { + return; + } + planDelay = Main.rand.Next(90, 180); + departureRequested = CrowdActivityRules.ShouldDepart(departureRequested, age, completedActivities, routeSeed); + if (departureRequested && Intent == CrowdIntent.EnterGuild) + { + Intent = CrowdIntent.ReturnHome; + } + bool departing = departureRequested || Intent == CrowdIntent.ReturnHome; + var start = CrowdTerrain.Cell(Body.Bottom); + CrowdPlace next; + List path; + int budgetBefore = TownCrowdSystem.PlanningBudget; + bool planned = Intent == CrowdIntent.VisitTown + ? TownCrowdSystem.TryPlan(start, this, departing, out next, out path) + : TownCrowdSystem.PlanIntent(start, this, Intent, out next, out path); + if (!planned && path == null) + { + // An empty budget is not failure. Routes actually tried before exhaustion still count. + planDelay = Main.rand.Next(1, 5); + waitingForBudget = TownCrowdSystem.PlanningBudget == budgetBefore; + if (!waitingForBudget) + { + if (movementPending) { retirement.RecordMovementRecovery(); } + if (WantsToLeave) { retirement.RecordDepartureFailure(); } + } + return; + } + waitingForBudget = false; + if (movementPending) + { + retirement.RecordMovementRecovery(); + } + if (WantsToLeave && !planned) + { + // Finding a route alone cannot erase failures: the exit must actually make progress. + retirement.RecordDepartureFailure(); + } + if (!planned && (departing || Intent != CrowdIntent.VisitTown)) + { + // An unavailable exit is not a reason to freeze in view. Continue visiting and retry later. + int fallbackBudget = TownCrowdSystem.PlanningBudget; + planned = TownCrowdSystem.TryPlan(CrowdTerrain.Cell(Body.Bottom), this, false, out next, out path); + if (!planned && path == null) + { + waitingForBudget = TownCrowdSystem.PlanningBudget == fallbackBudget; + planDelay = Main.rand.Next(1, 5); + return; + } + } + if (planned) + { + destination = next; + SetRoute(path); + ChangeStage(CrowdStage.Walking); + } + else if (stageTime > 1800) + { + // A small disconnected plaza may have only two activities. Retry after a long pause. + recentPlaces.Clear(); + } + } + + private void Leave(bool grounded) + { + if (!TownCrowdSystem.ValidPlace(destination) + || !CrowdRules.ReachedWaypoint(Body.Bottom, CrowdTerrain.Feet(destination.Cell), grounded)) + { + if (WantsToLeave) + { + retirement.RecordDepartureFailure(); + } + view.Opacity = 1; + ChangeStage(CrowdStage.Planning); + return; + } + bool pylon = destination.Activity == CrowdActivity.LeavePylon; + if (destination.Activity == CrowdActivity.EnterGuild) + { + if (TownCrowdSystem.TryEnterGuild(destination.Key)) + { + Kill(); + } + return; + } + Direction = destination.Focus.X >= Body.Center.X ? 1 : -1; + view.Opacity = CrowdActivityRules.DepartureOpacity(destination.Activity, stageTime); + if (pylon && !Main.dedServ) + { + float charge = MathHelper.Clamp(stageTime / 60f, 0, 1) * view.Opacity; + Lighting.AddLight(Body.Center, new Vector3(0.35f, 0.65f, 0.9f) * charge); + if (stageTime % 3 == 0) + { + Dust.NewDust(Body.position, Body.width, Body.height, DustID.MagicMirror, 0, -1.5f, 100); + } + if (stageTime == 60) + { + for (int i = 0; i < 12; i++) + { + Dust.NewDust(Body.position, Body.width, Body.height, DustID.MagicMirror, 0, -2, 100); + } + } + } + if (stageTime >= (pylon ? 90 : 60)) + { + Kill(); + } + } + + private void ReachDestination() + { + if (!TownCrowdSystem.ValidPlace(destination) + || !CrowdRules.ReachedWaypoint(Body.Bottom, CrowdTerrain.Feet(destination.Cell), CrowdTerrain.Grounded(Body))) + { + if (WantsToLeave && FollowingExit) + { + retirement.RecordDepartureFailure(); + } + ChangeStage(CrowdStage.Planning); + return; + } + ResolveMovement(); + if (FollowingExit) + { + retirement.ResolveDeparture(); + } + if (destination.Activity == CrowdActivity.BoardTrain) + { + ChangeStage(CrowdStage.Waiting); + } + else if (destination.Activity == CrowdActivity.LeaveStreet) + { + if (!TownCrowdSystem.Visible(Body)) + { + Kill(); + } + else + { + ChangeStage(CrowdStage.Planning); + } + } + else if (CrowdActivityRules.IsExit(destination.Activity)) + { + ChangeStage(CrowdStage.Leaving); + } + else + { + activityDuration = destination.Activity switch + { + CrowdActivity.Sit => Main.rand.Next(12 * 60, 24 * 60 + 1), + _ => Main.rand.Next(CrowdSettings.ActivityMinTicks, CrowdSettings.ActivityMaxTicks + 1), + }; + ChangeStage(CrowdStage.Activity); + } + } + + private void RecoverFromTrain() + { + if (CrowdTerrain.OnTrain(Body, train)) + { + AttachToTrain(); + } + else + { + train = null; + ChangeStage(CrowdStage.Planning); + } + } + + private bool MoveAlongRoute(bool grounded) + { + movementPending = true; + if (route.Count > 0) + { + movementTarget = CrowdTerrain.Feet(route[Math.Min(waypoint, route.Count - 1)]); + } + var surfaceTrain = WalkingTrain; + bool Clear(Point cell) => CrowdTerrain.IsClear(cell) + && (Stage != CrowdStage.Fleeing || CrowdThreats.EscapeAllowed(Body.Bottom, CrowdTerrain.Feet(cell))); + bool Stand(Point cell) => CrowdTerrain.CanStand(cell, surfaceTrain); + bool Drop(Point cell) => CrowdTerrain.CanDropThrough(cell, surfaceTrain); + while (waypoint < route.Count && CrowdRules.ReachedWaypoint(Body.Bottom, CrowdTerrain.Feet(route[waypoint]), grounded)) + { + waypoint++; + advancedWaypoint = true; + dropping = false; + } + if (waypoint >= route.Count) + { + return true; + } + if (grounded && !Stand(route[waypoint])) + { + // Terrain may change after planning. Do not step off an edge towards a missing landing. + Replan(surfaceTrain); + return false; + } + if (Stage == CrowdStage.Walking && grounded && !dropping) + { + if (stageTime % pauseInterval == 0) + { + pauseTime = Main.rand.Next(20, 65); + } + if (pauseTime > 0) + { + pauseTime--; + return false; + } + } + bool drop = CrowdNavigation.CanDropBetween(route[waypoint - 1], route[waypoint], Clear, Stand, Drop); + if (drop && grounded) + { + dropping = true; + dropSourceY = CrowdTerrain.Feet(route[waypoint - 1]).Y; + } + bool needsJump = !drop && !CrowdNavigation.CanWalkBetween(route[waypoint - 1], route[waypoint], Clear, Stand, Drop); + if (grounded && needsJump && !CrowdNavigation.CanJumpBetween(route[waypoint - 1], route[waypoint], Clear, Stand)) + { + Replan(surfaceTrain); + return false; + } + WalkToward(CrowdTerrain.Feet(route[waypoint]), grounded, needsJump); + if (age % CrowdSettings.RecoveryAttemptTicks == 0 && retirement.MovementTicks >= CrowdSettings.RecoveryAttemptTicks) + { + Replan(surfaceTrain); + } + return false; + } + + private void Replan(YggdrasilTown_NormalMetro surfaceTrain) + { + Body.velocity.X = 0; + if (Stage == CrowdStage.Fleeing) + { + SetRoute([]); + planDelay = 0; + return; + } + var path = TownCrowdSystem.FindRoute(CrowdTerrain.Cell(Body.Bottom), route[^1], routeSeed, surfaceTrain); + if (path == null) + { + waitingForBudget = true; + return; + } + waitingForBudget = false; + retirement.RecordMovementRecovery(); + if (path.Count == 0) + { + if (WantsToLeave && FollowingExit) + { + retirement.RecordDepartureFailure(); + } + RecoverFromTrain(); + return; + } + SetRoute(path); + } + + private void SetRoute(List path) + { + route = path; + waypoint = 1; + dropping = false; + jumping = false; + waitingForBudget = false; + } + + private void WalkToward(Vector2 target, bool grounded, bool needsJump = false) + { + waitingForBudget = false; + movementPending = true; + movementTarget = target; + if (jumping && !grounded) + { + // Keep the same ballistic motion that navigation checked; do not yield in midair. + Body.velocity.X = MathHelper.Clamp(jumpTarget.X - Body.Bottom.X, -CrowdNavigation.JumpSpeed, CrowdNavigation.JumpSpeed); + return; + } + jumping = false; + float distance = target.X - Body.Bottom.X; + if (Math.Abs(distance) > 2) + { + Direction = Math.Sign(distance); + } + bool occupied = TownCrowdSystem.ObstacleAhead(this, Direction); + yieldTime = occupied ? yieldTime + 1 : 0; + if (occupied && yieldTime < 60) + { + return; + } + float stride = Stage == CrowdStage.Fleeing ? speed * 1.25f : speed; + Body.velocity.X = MathHelper.Clamp(distance, -stride, stride) * (occupied ? 0.65f : 1f); + if (grounded && needsJump) + { + jumping = true; + jumpTarget = target; + Body.velocity.X = MathHelper.Clamp(distance, -CrowdNavigation.JumpSpeed, CrowdNavigation.JumpSpeed); + Body.velocity.Y = CrowdNavigation.JumpVelocity; + } + } + + private void WaitForTrain() + { + if (stageTime > 3600) + { + Remember(destination.Key); + ChangeStage(CrowdStage.Planning); + return; + } + if (stageTime % 30 != 0) + { + return; + } + if (Vector2.DistanceSquared(Main.LocalPlayer.Center, Body.Center) < 160 * 160) + { + Direction = Main.LocalPlayer.Center.X >= Body.Center.X ? 1 : -1; + } + foreach (var candidate in ColliderManager.Instance.OfType()) + { + if (!CrowdTerrain.TrainStopped(candidate) || Math.Abs(candidate.StationX - destination.Focus.X) >= 24 + || Math.Abs(candidate.Box.Top - (destination.Focus.Y - 16)) > 4 || candidate.StopTimer < 30) + { + continue; + } + Vector2 door = new(candidate.Position.X + candidate.Size.X * (Body.Bottom.X < candidate.Box.Center.X ? 0.25f : 0.75f), candidate.Box.Top); + var path = TownCrowdSystem.FindRoute(CrowdTerrain.Cell(Body.Bottom), CrowdTerrain.Cell(door), routeSeed, candidate); + if (path == null || path.Count == 0 || !CrowdActivityRules.CanFinishTrainWalk(true, candidate.StopTimer, + candidate.StopTimeMax, EstimateTravelTicks(path, speed))) + { + continue; + } + train = candidate; + boardingDoorOffset = door - candidate.Position; + SetRoute(path); + ChangeStage(CrowdStage.Boarding); + break; + } + } + + internal static int EstimateTravelTicks(List path, float speed) + { + float ticks = 0; + for (int i = 1; i < path.Count; i++) + { + int dx = Math.Abs(path[i].X - path[i - 1].X); + int dy = Math.Abs(path[i].Y - path[i - 1].Y); + float fallTicks = MathF.Sqrt(2 * dy * 16 / CrowdNavigation.Gravity); + ticks += Math.Max(dx * 16 / speed, fallTicks) + (dx > 1 || path[i].Y < path[i - 1].Y - 1 ? 20 : 0); + } + return (int)Math.Ceiling(ticks); + } + + private void Board(bool grounded) + { + if (!CrowdTerrain.TrainStopped(train) + || train.StopTimer >= train.StopTimeMax - 90) + { + if (CrowdTerrain.OnTrain(Body, train)) + { + // A departing train is already carrying this person; never jump back off its deck. + AttachToTrain(); + return; + } + train = null; + ChangeStage(CrowdStage.Planning); + return; + } + MoveAlongRoute(grounded); + if (Stage == CrowdStage.Boarding && CrowdTerrain.OnTrain(Body, train) + && CrowdRules.CanBoard(train.Active, true, train.Velocity, train.StopTimer, train.StopTimeMax, Body.Bottom, train.Position + boardingDoorOffset)) + { + AttachToTrain(); + } + } + + private void AttachToTrain() + { + ResolveMovement(); + retirement.ResolveDeparture(); + Actor.ReleaseSupport(); + Body.velocity = Vector2.Zero; + aboardOffset = Body.position - train.Position; + visitedStop = true; + mayAlight = false; + ChangeStage(CrowdStage.Riding); + } + + private void Ride() + { + if (train?.Active != true) + { + Kill(); + return; + } + Body.position = train.Position + aboardOffset; + // A passenger who boarded while fleeing still uses the existing sustained-safe recovery. + if (avoidance.Alarmed && avoidance.Update(CrowdThreats.Dangerous(Body.Bottom), CrowdThreats.Clearance(Body.Bottom) > 320, + TownCrowdSystem.Visible(Body), CrowdSettings.OffscreenTicks)) + { + Kill(); + return; + } + Body.velocity = Vector2.Zero; + view.Opacity = 1; + visitedStop |= CrowdTerrain.TrainStopped(train); + if (train.State == (int)YggdrasilTown_NormalMetro.BehaviorType.Move) + { + mayAlight = true; + if (visitedStop) + { + movingTime++; + } + } + else + { + movingTime = 0; + } + if (movingTime >= 180 && !TownCrowdSystem.Visible(Body)) + { + Kill(); + } + } + + private void ChangeStage(CrowdStage stage) + { + Stage = stage; + stageTime = 0; + dropping = false; + } + + private void UpdateView(CrowdAppearance person) + { + // CustomTile trains update after NPCs. Follow their final position for drawing too. + if (IsAttached && train?.Active == true) + { + Body.position = train.Position + aboardOffset; + } + float traveled = Math.Abs(Body.position.X - previousViewPosition.X); + previousViewPosition = Body.position; + animationDistance += traveled; + person.Active = Active && TownCrowdSystem.Enabled; + person.Position = Body.position + new Vector2(Body.width / 2f, 0); + person.Direction = Direction; + Body.direction = Direction; + person.Sitting = IsSitting; + if (person.Sitting) + { + person.Position += CrowdSeats.VisualOffset(Body, destination.Cell, out person.SeatYOffset); + } + person.State = traveled > 0.1f && !IsAttached ? (int)CrowdAppearance.CrowdMoveState.Walk : (int)CrowdAppearance.CrowdMoveState.Stand; + person.Frame = person.State == (int)CrowdAppearance.CrowdMoveState.Walk ? 6 + (int)(animationDistance / 4) % 14 : 0; + if (Math.Abs(Body.velocity.Y) > 0.5f && !IsAttached) + { + person.State = (int)CrowdAppearance.CrowdMoveState.Jump; + person.Frame = 5; + } + person.Visible = person.Active && person.EffectiveOpacity > 0.01f; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdBody.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdBody.cs new file mode 100644 index 000000000..68ceef933 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdBody.cs @@ -0,0 +1,64 @@ +using Everglow.Commons.CustomTiles; +using Everglow.Commons.CustomTiles.Abstracts; +using Everglow.Commons.CustomTiles.Core; +using Everglow.Commons.Physics.DataStructures; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// A lightweight body; it occupies no slot in Main.npc. +internal sealed class CrowdBody : IBox +{ + private RigidEntity ground; + private Vector2 groundPosition; + + public Entity Body { get; } = new CrowdEntity { width = 16, height = 48 }; + + public AABB Box => new(Position, Size); + + public float Gravity => 1; + + public Vector2 Position { get => Body.position; set => Body.position = value; } + + public Vector2 Size => Body.Size; + + public Vector2 Velocity { get => Body.velocity; set => Body.velocity = value; } + + public bool Ignore(RigidEntity entity) => !entity.Active; + + public void ReleaseSupport() => ground = null; + + private sealed class CrowdEntity : Entity + { + } + + public void Move(bool fallThrough = false) + { + Vector2 carry = ground?.Active == true ? ground.Position - groundPosition : Vector2.Zero; + ground = null; + bool previousHook = ColliderManager.EnableHook; + Vector2 stride; + try + { + // Resolve vanilla terrain once, then let the CustomTile manager resolve its bodies. + ColliderManager.EnableHook = false; + stride = Collision.TileCollision(Position, Velocity + carry, Body.width, Body.height, fallThrough); + } + finally + { + ColliderManager.EnableHook = previousHook; + } + if (Math.Abs(stride.Y - Velocity.Y - carry.Y) > 0.01f) + { + Body.velocity.Y = 0; + } + foreach (var collision in ColliderManager.Instance.Move(this, stride)) + { + if (collision.Normal.Y > 0.7f) + { + ground = collision.Collider; + groundPosition = ground.Position; + Body.velocity.Y = 0; + } + } + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdNavigation.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdNavigation.cs new file mode 100644 index 000000000..6bc275346 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdNavigation.cs @@ -0,0 +1,230 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +public static class CrowdNavigation +{ + public const float JumpSpeed = 2f; + public const float JumpVelocity = -6.1f; + public const float Gravity = 0.3f; + public const int MaxDropTiles = 3; + + /// + /// Finds supported landing cells, including both endpoints. A cell's feet are at + /// (X * 16 + 8, Y * 16 + 16); isClear checks the complete 16 by 48 body. + /// Delegates must reject cells outside their world or search region. + /// + public static List FindPath(Point start, Point goal, Func canStand, Func isClear, int maxVisited = 6000, + Func canDropThrough = null, int routeSeed = 0) + { + if (maxVisited <= 0) + { + return []; + } + var clearance = new Dictionary(); + var support = new Dictionary(); + bool Clear(Point point) + { + if (!clearance.TryGetValue(point, out bool result)) + { + result = isClear(point); + clearance.Add(point, result); + } + return result; + } + bool Stand(Point point) + { + if (!support.TryGetValue(point, out bool result)) + { + result = Clear(point) && canStand(point); + support.Add(point, result); + } + return result; + } + if (!Stand(start) || !Stand(goal)) + { + return []; + } + + var frontier = new PriorityQueue(); + var cost = new Dictionary { [start] = 0 }; + var previous = new Dictionary(); + var visited = new HashSet(); + frontier.Enqueue(start, Distance(start, goal) * 10); + while (visited.Count < maxVisited && frontier.TryDequeue(out var current, out _)) + { + if (!visited.Add(current)) + { + continue; + } + if (current == goal) + { + var path = new List { current }; + while (previous.TryGetValue(current, out var parent)) + { + current = parent; + path.Add(current); + } + path.Reverse(); + return path; + } + for (int dx = -3; dx <= 3; dx++) + { + for (int dy = -3; dy <= 3; dy++) + { + var next = new Point(current.X + dx, current.Y + dy); + if (visited.Contains(next) || !Stand(next)) + { + continue; + } + bool walk = CanWalkBetween(current, next, Clear, Stand, canDropThrough); + bool drop = CanDropBetween(current, next, Clear, Stand, canDropThrough); + if (!walk && !drop && (dx == 0 || !CanJumpBetween(current, next, Clear, Stand))) + { + continue; + } + int distance = Math.Abs(dx) + Math.Abs(dy); + int nextCost = cost[current] + distance * (10 + Preference(next, routeSeed)) + (walk || drop ? 0 : 80); + if (cost.TryGetValue(next, out int oldCost) && oldCost <= nextCost) + { + continue; + } + cost[next] = nextCost; + previous[next] = current; + frontier.Enqueue(next, nextCost + Distance(next, goal) * 10); + } + } + } + return []; + } + + private static int Distance(Point from, Point to) => Math.Abs(from.X - to.X) + Math.Abs(from.Y - to.Y); + + private static int Preference(Point point, int seed) + { + if (seed == 0) + { + return 0; + } + // Stable per traveler and road section: variation never changes edge validity. + uint hash = unchecked((uint)(seed * 73856093 ^ (point.X / 4) * 19349663 ^ point.Y * 83492791)); + hash ^= hash >> 13; + return (int)(hash % 13); + } + + public static bool CanDropBetween(Point from, Point to, Func isClear, + Func canStand, Func canDropThrough) + { + if (from.X != to.X || to.Y <= from.Y || to.Y - from.Y > MaxDropTiles + || canDropThrough?.Invoke(from) != true || !canStand(to)) + { + return false; + } + for (int y = from.Y + 1; y <= to.Y; y++) + { + var cell = new Point(from.X, y); + if (!isClear(cell) || (y < to.Y && canStand(cell))) + { + return false; + } + } + return true; + } + + /// Checks a supported edge's walking clearance; callers verify support separately. + public static bool CanWalkBetween(Point from, Point to, Func isClear, Func canStand = null, + Func isPlatform = null) + { + if (Math.Abs(to.X - from.X) != 1 || to.Y < from.Y - 1 || to.Y > from.Y + MaxDropTiles || !isClear(from) || !isClear(to)) + { + return false; + } + if (to.Y < from.Y) + { + if (isPlatform?.Invoke(to) == true) + { + // StepUp does not lift these bodies onto one-way platforms. Use the checked jump instead. + return false; + } + // Lift before moving onto a one-cell step, rather than cutting its corner. + return isClear(new Point(from.X, to.Y)) && isClear(to); + } + // Move off the edge first, then check every cell of the bounded drop. + for (int y = from.Y; y <= to.Y; y++) + { + if (!isClear(new Point(to.X, y))) + { + return false; + } + // A one-way platform is clear to the body, but still catches a descending traveler. + if (y < to.Y && canStand?.Invoke(new Point(to.X, y)) == true) + { + return false; + } + } + return true; + } + + public static bool CanJumpBetween(Point from, Point to, Func isClear, Func canStand) + { + if (Math.Abs(to.X - from.X) > 3 || Math.Abs(to.Y - from.Y) > MaxDropTiles || !canStand(to)) + { + return false; + } + // Match the controller: gravity precedes movement and horizontal motion + // stops at the landing column while the traveler waits to descend. + float x = 0; + float y = 0; + float targetX = (to.X - from.X) * 16; + float targetY = (to.Y - from.Y) * 16; + float velocityY = JumpVelocity; + for (int tick = 0; tick < 80; tick++) + { + float oldX = x; + float oldY = y; + x += Math.Clamp(targetX - x, -JumpSpeed, JumpSpeed); + velocityY += Gravity; + y += velocityY; + bool lands = velocityY > 0 && x == targetX && oldY <= targetY && y >= targetY; + if (lands) + { + // Terrain collision stops the final frame at the floor. Including + // the overshoot would reject the supporting ground itself. + y = targetY; + } + if (velocityY > 0) + { + for (int column = (int)Math.Floor(Math.Min(oldX, x) / 16); column <= (int)Math.Ceiling(Math.Max(oldX, x) / 16); column++) + { + for (int level = (int)Math.Ceiling(oldY / 16); level <= (int)Math.Floor(y / 16); level++) + { + var landing = new Point(from.X + column, from.Y + level); + if (canStand(landing) && !(lands && landing.Y == to.Y)) + { + return false; + } + } + } + } + // Enclose the complete per-frame body sweep, including both columns + // occupied between tile centers, so thin obstacles cannot be skipped. + for (int cellX = from.X + (int)Math.Floor(Math.Min(oldX, x) / 16); cellX <= from.X + (int)Math.Ceiling(Math.Max(oldX, x) / 16); cellX++) + { + for (int cellY = from.Y + (int)Math.Floor(Math.Min(oldY, y) / 16); cellY <= from.Y + (int)Math.Ceiling(Math.Max(oldY, y) / 16); cellY++) + { + if (!isClear(new Point(cellX, cellY))) + { + return false; + } + } + } + if (lands) + { + return true; + } + if (velocityY > 0 && y > targetY) + { + return false; + } + } + return false; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdProgress.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdProgress.cs new file mode 100644 index 000000000..63127a776 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdProgress.cs @@ -0,0 +1,41 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Physical route progress, independent of replanning and waypoint index resets. +public sealed class CrowdProgress +{ + private const int HistoryLimit = 64; + private readonly Queue history = []; + private Vector2 anchor; + private bool initialized; + + public void Reset() + { + history.Clear(); + initialized = false; + } + + public bool Observe(Vector2 position, Vector2 target, bool advancedWaypoint) + { + var cell = new Point((int)MathF.Floor(position.X / 16), (int)MathF.Floor(position.Y / 16)); + if (!initialized) + { + initialized = true; + anchor = position; + history.Enqueue(cell); + return false; + } + float minimum = CrowdSettings.MovementProgressDistance; + if (Vector2.DistanceSquared(anchor, position) < minimum * minimum || history.Contains(cell) + || (!advancedWaypoint && Vector2.Distance(anchor, target) - Vector2.Distance(position, target) < minimum)) + { + return false; + } + anchor = position; + history.Enqueue(cell); + if (history.Count > HistoryLimit) + { + history.Dequeue(); + } + return true; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdRules.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdRules.cs new file mode 100644 index 000000000..6b580160d --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdRules.cs @@ -0,0 +1,42 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Movement and support rules for lightweight travelers. +public static class CrowdRules +{ + public static bool SupportsGridLanding(bool active, bool solidOrPlatform, bool halfBlock, int slope) => + active && solidOrPlatform && !halfBlock && slope == 0; + + public static bool ReachedWaypoint(Vector2 feet, Vector2 waypoint, bool grounded) => grounded + && Math.Abs(feet.X - waypoint.X) <= 0.5f && Math.Abs(feet.Y - waypoint.Y) <= 2; + + public static bool CanRun(int netMode, bool inYggdrasil) => netMode == 0 && inYggdrasil; + + public static bool TryStepHeight(Vector2 feet, float width, float strideX, Vector2 surface, Vector2 size, out float rise) + { + rise = feet.Y - surface.Y; + if (!(rise > 0 && rise <= 16.1f)) + { + return false; + } + float left = feet.X - width / 2; + float right = feet.X + width / 2; + return (strideX > 0 && right <= surface.X + 0.1f && right + strideX > surface.X) + || (strideX < 0 && left >= surface.X + size.X - 0.1f && left + strideX < surface.X + size.X); + } + + public static bool FallThroughSourcePlatform(float feetY, float sourceY) => feetY < sourceY + 4; + + public static bool SurfaceSupports(Vector2 feet, float width, Vector2 surface, Vector2 size, bool allowEdgeOverlap) => + Math.Abs(feet.Y - surface.Y) <= 2 && (allowEdgeOverlap + ? feet.X + width / 2 > surface.X && feet.X - width / 2 < surface.X + size.X + : feet.X >= surface.X + width / 2 && feet.X <= surface.X + size.X - width / 2); + + public static bool IsSolidFloor(bool solid, bool solidTop, int frameY) => solidTop ? frameY == 0 : solid; + + public static bool CanBoard(bool active, bool stopped, Vector2 velocity, int stopTimer, int stopDuration, Vector2 feet, Vector2 door) + { + return active && stopped && velocity.LengthSquared() <= 0.0025f + && stopTimer >= 30 && stopTimer < stopDuration - 90 + && Math.Abs(feet.X - door.X) <= 20 && Math.Abs(feet.Y - door.Y) <= 20; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs new file mode 100644 index 000000000..28d037888 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs @@ -0,0 +1,218 @@ +using Everglow.Commons.CustomTiles; +using Everglow.Commons.CustomTiles.Core; +using Everglow.Commons.Physics.DataStructures; +using Everglow.Yggdrasil.YggdrasilTown.CustomTiles; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +internal static class CrowdTerrain +{ + public static Vector2 Feet(Point cell) => new(cell.X * 16 + 8, cell.Y * 16 + 16); + + public static Point Cell(Vector2 feet) => new((int)MathF.Floor(feet.X / 16), (int)MathF.Floor((feet.Y - 1) / 16)); + + public static bool IsClear(Point cell) + { + if (!WorldGen.InWorld(cell.X, cell.Y, 5)) + { + return false; + } + for (int y = cell.Y - 2; y <= cell.Y; y++) + { + var tile = Main.tile[cell.X, y]; + if (tile.HasUnactuatedTile && Main.tileSolid[tile.TileType] && !Main.tileSolidTop[tile.TileType]) + { + return false; + } + if (Hazard(tile) || tile.LiquidAmount >= 128 || (tile.LiquidAmount > 0 && tile.LiquidType == LiquidID.Lava)) + { + return false; + } + } + var box = new AABB(Feet(cell) - new Vector2(8, 48), new Vector2(16, 47)); + // A moving deck is still an obstacle, even when it cannot be a planned support. + return !ColliderManager.Instance.OfType().Any(tile => tile.Active && tile.Intersect(box)); + } + + public static bool CanStand(Point cell, YggdrasilTown_NormalMetro boardingTrain = null) + { + if (!IsClear(cell)) + { + return false; + } + var tile = Main.tile[cell.X, cell.Y + 1]; + if (Hazard(tile) || (tile.LiquidAmount > 0 && tile.LiquidType == LiquidID.Lava)) + { + return false; + } + // Grid nodes require an exact floor height; half blocks and slopes need a richer surface model. + if (CrowdRules.SupportsGridLanding(tile.HasUnactuatedTile, + CrowdRules.IsSolidFloor(Main.tileSolid[tile.TileType], Main.tileSolidTop[tile.TileType], tile.TileFrameY), tile.IsHalfBlock, (int)tile.Slope)) + { + return true; + } + var feet = Feet(cell); + return Surfaces(boardingTrain).Any(box => CrowdRules.SurfaceSupports(feet, 16, box.Position, box.Size, + ReferenceEquals(box, boardingTrain))); + } + + public static bool CanDropThrough(Point cell, YggdrasilTown_NormalMetro boardingTrain = null) + { + if (!CanStand(cell, boardingTrain)) + { + return false; + } + var tile = Main.tile[cell.X, cell.Y + 1]; + var feet = Feet(cell); + return tile.HasUnactuatedTile && Main.tileSolidTop[tile.TileType] && tile.TileFrameY == 0 + && !Surfaces(boardingTrain).Any(box => feet.X + 8 > box.Box.Left && feet.X - 8 < box.Box.Right + && Math.Abs(box.Box.Top - feet.Y) <= 2); + } + + public static bool TrainStopped(YggdrasilTown_NormalMetro train) => train?.Active == true + && train.State == (int)YggdrasilTown_NormalMetro.BehaviorType.Stop && train.Velocity.LengthSquared() <= 0.0025f; + + private static IEnumerable Surfaces(YggdrasilTown_NormalMetro boardingTrain) => + ColliderManager.Instance.OfType().Where(box => box.Active && box.Velocity == Vector2.Zero + && (box is not YggdrasilTown_NormalMetro || (ReferenceEquals(box, boardingTrain) && TrainStopped(boardingTrain)))); + + private static bool Hazard(Tile tile) => tile.HasUnactuatedTile + && (TileID.Sets.TouchDamageImmediate[tile.TileType] > 0 || TileID.Sets.TouchDamageHot[tile.TileType] + || TileID.Sets.TouchDamageBleeding[tile.TileType]); + + public static List FindRoute(Point start, Point goal, int seed, YggdrasilTown_NormalMetro boardingTrain = null, + Func allowed = null) + { + bool Clear(Point point) => point.X >= Math.Min(start.X, goal.X) - 32 && point.X <= Math.Max(start.X, goal.X) + 32 + && point.Y >= Math.Min(start.Y, goal.Y) - 12 && point.Y <= Math.Max(start.Y, goal.Y) + 12 + && IsClear(point) && (allowed?.Invoke(point) ?? true); + return CrowdNavigation.FindPath(start, goal, point => CanStand(point, boardingTrain), Clear, 3000, + point => CanDropThrough(point, boardingTrain), boardingTrain == null ? seed : 0); + } + + public static bool Grounded(Entity body) => Math.Abs(body.velocity.Y) < 0.05f + && (Collision.TileCollision(body.position, new Vector2(0, 2), body.width, body.height).Y < 1.9f + || ColliderManager.Instance.OfType().Any(box => box.Active && body.Right.X > box.Box.Left + && body.Left.X < box.Box.Right && Math.Abs(body.Bottom.Y - box.Box.Top) <= 2)); + + public static bool TryPlatformRecovery(Entity body, out float landingY) + { + landingY = 0; + var origin = Cell(body.Bottom); + if (Math.Abs(body.velocity.Y) >= 0.05f || CanStand(origin)) + { + return false; + } + int left = (int)MathF.Floor(body.Left.X / 16); + int right = (int)MathF.Floor((body.Right.X - 0.01f) / 16); + if (!WorldGen.InWorld(left, origin.Y - 2, 5) || !WorldGen.InWorld(right, origin.Y + CrowdNavigation.MaxDropTiles + 1, 5)) + { + return false; + } + bool slope = false; + for (int x = left; x <= right; x++) + { + for (int y = origin.Y; y <= origin.Y + 1; y++) + { + var tile = Main.tile[x, y]; + slope |= tile.HasUnactuatedTile && Main.tileSolidTop[tile.TileType] && tile.Slope != SlopeType.Solid; + } + } + if (!slope) + { + return false; + } + for (int y = origin.Y; y <= origin.Y + CrowdNavigation.MaxDropTiles; y++) + { + bool safeFloor = true; + for (int x = left; x <= right; x++) + { + if (!IsClear(new Point(x, y))) + { + return false; + } + var floor = Main.tile[x, y + 1]; + safeFloor &= floor.HasUnactuatedTile + && CrowdRules.IsSolidFloor(Main.tileSolid[floor.TileType], Main.tileSolidTop[floor.TileType], floor.TileFrameY) + && floor.Slope == SlopeType.Solid && !floor.IsHalfBlock && !Hazard(floor) && floor.LiquidAmount == 0; + } + float targetY = (y + 1) * 16; + if (safeFloor && targetY > body.Bottom.Y && targetY - body.Bottom.Y <= CrowdNavigation.MaxDropTiles * 16) + { + // Slope collision can hold bodies above a platform that grid navigation cannot use. + // Fall-through ends before the next collision step reaches this first safe floor. + landingY = targetY; + return true; + } + } + return false; + } + + public static bool TryStandingRecovery(Entity body, out float targetX) + { + targetX = 0; + var origin = Cell(body.Bottom); + if (!Grounded(body) || CanStand(origin) || !IsClear(origin) || Math.Abs(Feet(origin).Y - body.Bottom.Y) > 2) + { + return false; + } + for (int direction = -1; direction <= 1; direction += 2) + { + var candidate = new Point(origin.X + direction, origin.Y); + var feet = Feet(candidate); + // Native collision can rest the edge of a body on the adjacent cell. Move back + // over that support without crossing a gap or changing the standing height. + if (feet.X + 8 > body.Left.X && feet.X - 8 < body.Right.X && CanStand(candidate)) + { + targetX = feet.X; + return true; + } + } + return false; + } + + public static bool OnTrain(Entity body, YggdrasilTown_NormalMetro train) => train?.Active == true + && body.Left.X >= train.Box.Left && body.Right.X <= train.Box.Right && Math.Abs(body.Bottom.Y - train.Box.Top) <= 2; + + public static void StepOntoSurface(Entity body, YggdrasilTown_NormalMetro boardingTrain) + { + foreach (var surface in Surfaces(boardingTrain)) + { + if (!CrowdRules.TryStepHeight(body.Bottom, body.width, body.velocity.X, surface.Position, surface.Size, out float rise)) + { + continue; + } + var raised = body.position - new Vector2(0, rise); + var next = raised + new Vector2(body.velocity.X, 0); + // Check both ends of the short step before raising the body, as vanilla StepUp does. + if (!Collision.SolidCollision(raised, body.width, body.height - 1) + && !Collision.SolidCollision(next, body.width, body.height - 1)) + { + body.position = raised; + body.velocity.Y = 0; + } + } + } + + public static bool TryGround(Vector2 near, out Point cell, int horizontal = 12, int vertical = 24, + YggdrasilTown_NormalMetro boardingTrain = null, int upward = 4) + { + var origin = Cell(near); + for (int distance = 0; distance <= horizontal; distance++) + { + for (int dy = -upward; dy <= vertical; dy++) + { + for (int sign = -1; sign <= 1; sign += 2) + { + cell = new Point(origin.X + distance * sign, origin.Y + dy); + if (CanStand(cell, boardingTrain)) + { + return true; + } + } + } + } + cell = default; + return false; + } +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdAppearance.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdAppearance.cs new file mode 100644 index 000000000..830362845 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdAppearance.cs @@ -0,0 +1,270 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +[Pipeline(typeof(WCSPipeline))] +public class CrowdAppearance : Visual +{ + public override CodeLayer DrawLayer => CodeLayer.PostDrawDusts; + + public enum CrowdMoveState + { + Stand, + Walk, + Jump, + } + + public Vector2 Position; + + public int State; + + public int Direction; + + public int Frame; + + public int HairStyle; + + public int Sex; + + public int ShoeStyle; + + public Color EyeColor; + + public Color SkinColor; + + public Color HairColor; + + public Color ClothColor; + + public Color ShoeColor; + + /// Updates presentation from the owning traveler. + public Action UpdateController; + + /// Allows an owning world object to draw this person behind its foreground. + public Func DrawInForeground; + + public float Opacity = 1f; + + public float RetirementOpacity = 1f; + + public float EffectiveOpacity => Opacity * RetirementOpacity; + + public bool Sitting; + + public float SeatYOffset = -4; + + // PlayerDrawLayers.DrawSittingLegs, ordinary clothing branch. Position uses our 48-pixel body. + internal static void SittingLegStrip(Vector2 position, int direction, int strip, float seatOffset, out Rectangle frame, out Vector2 target) + { + frame = new Rectangle(0, 42 + strip * 2, 40, strip == 2 ? 10 : 2); + target = position + new Vector2((2 + strip * 2) * direction, 38 + seatOffset); + } + + /// Creates an appearance without registering or simulating a person. + internal static CrowdAppearance Create(Vector2 position) + { + CrowdAppearance person = new CrowdAppearance() + { + Active = true, + Visible = true, + Position = position, + State = (int)CrowdAppearance.CrowdMoveState.Walk, + SkinColor = ChooseSkinColor(), + HairColor = new Vector3(Main.rand.NextFloat(360), MathF.Pow(Main.rand.NextFloat(), 0.25f), MathF.Pow(Main.rand.NextFloat(), 0.5f)).HSVToRGB_Color(1f), + ClothColor = new Vector3(Main.rand.NextFloat(360), MathF.Pow(Main.rand.NextFloat(), 2f), MathF.Pow(Main.rand.NextFloat(), 1.2f)).HSVToRGB_Color(1f), + ShoeColor = new Vector3(Main.rand.NextFloat(-30, 60), MathF.Pow(Main.rand.NextFloat(), 2f), MathF.Pow(Main.rand.NextFloat(), 2.5f)).HSVToRGB_Color(1f), + EyeColor = new Vector3(Main.rand.NextFloat(360), MathF.Pow(Main.rand.NextFloat(), 0.5f), Main.rand.NextFloat()).HSVToRGB_Color(1f), + HairStyle = Main.rand.Next(165), + Direction = Main.rand.NextBool() ? -1 : 1, + Sex = Main.rand.NextBool() ? 0 : 1, + ShoeStyle = Main.rand.Next(8), + }; + return person; + } + + private static Color ChooseSkinColor() + { + switch (Main.rand.Next(7)) + { + case 0: + return new Color(209, 160, 156); + case 1: + return new Color(255, 221, 221); + case 2: + return new Color(255, 221, 193); + case 3: + return new Color(206, 135, 111); + case 4: + return new Color(104, 66, 75); + case 5: + return new Color(232, 205, 176); + case 6: + return new Color(232, 202, 194); + } + + return new Color(209, 160, 156); + } + + public override void Update() => UpdateController?.Invoke(this); + + public override void Draw() + { + if (DrawInForeground?.Invoke() != false) + { + DrawAppearance(null); + } + } + + internal void DrawAppearance(SpriteBatch spriteBatch) + { + void DrawPart(Texture2D texture, Vector2 position, Rectangle frame, Color color, float rotation, Vector2 origin, float scale, SpriteEffects effects, bool upperBody = true) + { + if (Sitting && upperBody) + { + position.Y += SeatYOffset; + } + if (spriteBatch == null) + { + Ins.Batch.Draw(texture, position, frame, color, rotation, origin, scale, effects); + } + else + { + spriteBatch.Draw(texture, position - Main.screenPosition, frame, color, rotation, origin, scale, effects, 0); + } + } + + // Eye and Head + SpriteEffects flip = Direction == 1 ? SpriteEffects.None : SpriteEffects.FlipHorizontally; + Texture2D head = Terraria.GameContent.TextureAssets.Players[0, 0].Value; + Rectangle headFrame = new Rectangle(0, Frame * 56, 40, 56); + DrawPart(head, Position + new Vector2(0, 52), headFrame, AppearanceColor(SkinColor), 0, new Vector2(headFrame.Width * 0.5f, headFrame.Height), 1f, flip); + Texture2D sclera = Terraria.GameContent.TextureAssets.Players[0, 1].Value; + DrawPart(sclera, Position + new Vector2(0, 52), headFrame, AppearanceColor(Color.White), 0, new Vector2(headFrame.Width * 0.5f, headFrame.Height), 1f, flip); + Texture2D pupil = Terraria.GameContent.TextureAssets.Players[0, 2].Value; + DrawPart(pupil, Position + new Vector2(0, 52), headFrame, AppearanceColor(EyeColor), 0, new Vector2(headFrame.Width * 0.5f, headFrame.Height), 1f, flip); + + float bodyOffset = 0; + if (Frame is >= 14 and <= 16 || Frame is >= 7 and <= 9) + { + bodyOffset = -2; + } + + // Chest + Texture2D chest = Terraria.GameContent.TextureAssets.Players[0, 3].Value; + Rectangle chestFrame = new Rectangle(0, 0, 40, 56); + DrawPart(chest, Position + new Vector2(0, 24 + bodyOffset), chestFrame, AppearanceColor(SkinColor), 0, chestFrame.Size() * 0.5f, 1f, flip); + + // Armx and hands + Texture2D arm = Terraria.GameContent.TextureAssets.Players[0, 4].Value; + Texture2D hand = Terraria.GameContent.TextureAssets.Players[0, 5].Value; + Rectangle armFrame = new Rectangle(120, 56, 40, 56); + int offsetFrame = Frame + 2; + offsetFrame -= 6; + offsetFrame %= 14; + offsetFrame += 6; + if (offsetFrame is 6 or 7 or 19) + { + armFrame.X = 160; + } + if (offsetFrame is 8 or 9 or 17 or 18) + { + armFrame.X = 120; + } + if (offsetFrame is 10 or 11 or 15 or 16) + { + armFrame.X = 200; + } + if (offsetFrame is 12 or 13 or 14) + { + armFrame.X = 240; + } + if (State == (int)CrowdMoveState.Stand) + { + armFrame = new Rectangle(80, 0, 40, 56); + } + Rectangle armFrame_back = armFrame; + armFrame_back.Y += 112; + Rectangle handFrame = armFrame; + Rectangle handFrame_back = armFrame; + handFrame_back.Y += 112; + DrawPart(hand, Position + new Vector2(0, 24 + bodyOffset), handFrame_back, AppearanceColor(SkinColor), 0, handFrame_back.Size() * 0.5f, 1f, flip); + DrawPart(arm, Position + new Vector2(0, 24 + bodyOffset), armFrame_back, AppearanceColor(SkinColor), 0, armFrame_back.Size() * 0.5f, 1f, flip); + + // Clothes + Texture2D innerCloth = Terraria.GameContent.TextureAssets.Players[0, 6].Value; + Texture2D sleeve = Terraria.GameContent.TextureAssets.Players[0, 8].Value; + Rectangle innerClothFrame = chestFrame; + if (Sex == 1) + { + innerClothFrame.Y += 112; + } + DrawPart(innerCloth, Position + new Vector2(0, 24 + bodyOffset), innerClothFrame, AppearanceColor(ClothColor), 0, innerClothFrame.Size() * 0.5f, 1f, flip); + DrawPart(sleeve, Position + new Vector2(0, 24 + bodyOffset), armFrame_back, AppearanceColor(ClothColor), 0, armFrame_back.Size() * 0.5f, 1f, flip); + DrawPart(hand, Position + new Vector2(0, 24 + bodyOffset), handFrame, AppearanceColor(SkinColor), 0, handFrame.Size() * 0.5f, 1f, flip); + DrawPart(arm, Position + new Vector2(0, 24 + bodyOffset), armFrame, AppearanceColor(SkinColor), 0, armFrame.Size() * 0.5f, 1f, flip); + DrawPart(sleeve, Position + new Vector2(0, 24 + bodyOffset), armFrame, AppearanceColor(ClothColor), 0, armFrame.Size() * 0.5f, 1f, flip); + + // Foot + Texture2D foot = Terraria.GameContent.TextureAssets.Players[0, 10].Value; + Rectangle footFrame = new Rectangle(0, Frame * 56, 40, 56); + void DrawLegs(Texture2D texture, Color color) + { + if (!Sitting) + { + DrawPart(texture, Position + new Vector2(0, -4), footFrame, color, 0, new Vector2(20, 0), 1, flip, false); + return; + } + // Same strip construction as vanilla sitting legs: bent thighs, then a lower leg. + for (int strip = 2; strip >= 0; strip--) + { + SittingLegStrip(Position, Direction, strip, SeatYOffset, out var frame, out var target); + DrawPart(texture, target, frame, + color, 0, new Vector2(20, 0), 1, flip, false); + } + } + DrawLegs(foot, AppearanceColor(ShoeStyle % 2 == 0 ? ShoeColor : SkinColor)); + + // Shoes + Texture2D shoe = Terraria.GameContent.TextureAssets.Players[0, 11].Value; + switch (ShoeStyle) + { + case 0: + shoe = Terraria.GameContent.TextureAssets.Players[0, 11].Value; + break; + case 1: + shoe = Terraria.GameContent.TextureAssets.Players[0, 12].Value; + break; + case 2: + shoe = Terraria.GameContent.TextureAssets.Players[1, 11].Value; + break; + case 3: + shoe = Terraria.GameContent.TextureAssets.Players[1, 12].Value; + break; + case 4: + shoe = Terraria.GameContent.TextureAssets.Players[2, 11].Value; + break; + case 5: + shoe = Terraria.GameContent.TextureAssets.Players[2, 12].Value; + break; + case 6: + shoe = Terraria.GameContent.TextureAssets.Players[3, 11].Value; + break; + case 7: + shoe = Terraria.GameContent.TextureAssets.Players[3, 12].Value; + break; + } + + DrawLegs(shoe, AppearanceColor(ShoeColor)); + + // Hair + Main.instance.LoadHair(HairStyle); + Texture2D hair = Terraria.GameContent.TextureAssets.PlayerHair[HairStyle].Value; + Rectangle hairFrame = new Rectangle(0, (Frame - 6) * 56, 40, 56); + if (Frame < 6) + { + hairFrame.Y = 0; + } + DrawPart(hair, Position + new Vector2(0, 24), hairFrame, AppearanceColor(HairColor), 0, hairFrame.Size() * 0.5f, 1f, flip); + } + + private Color AppearanceColor(Color color) => Lighting.GetColor(Position.ToTileCoordinates(), color) * EffectiveOpacity; +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdNames.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdNames.cs new file mode 100644 index 000000000..d4513da99 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Presentation/CrowdNames.cs @@ -0,0 +1,15 @@ +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// One stable localized identity per randomly seeded journey. +public static class CrowdNames +{ + private static readonly string[] Pool = + [ + "Alden", "Mira", "Rowan", "Lyra", "Orin", "Elara", "Finn", "Nia", + "Silas", "Iris", "Theo", "Luna", "Arlo", "Cora", "Jasper", "Vera", + "Emil", "Faye", "Noel", "Sora", "Ren", "Yuna", "Lian", "Mei", + "Kai", "Asha", "Tarin", "Selene", "Bram", "Tessa", "Eira", "Ravi", + ]; + + public static string KeyFor(int seed) => $"Mods.Everglow.Travelers.Names.{Pool[(uint)seed % Pool.Length]}"; +} diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs new file mode 100644 index 000000000..f2faa5507 --- /dev/null +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs @@ -0,0 +1,484 @@ +using Everglow.Commons.CustomTiles; +using Everglow.Yggdrasil.YggdrasilTown.CustomTiles; +using Terraria.GameInput; + +namespace Everglow.Yggdrasil.Mechanics.TownCrowd; + +/// Client-configured town life simulated without native NPC slots. +public class TownCrowdSystem : ModSystem +{ + private static readonly List Travelers = []; + private static readonly CrowdPlaces Places = new(); + private static readonly Dictionary Stops = []; + private static readonly Dictionary TrainLoads = []; + private static readonly Dictionary EntranceCooldowns = []; + private static readonly CrowdRegions Regions = new(); + private static Rectangle previousView; + private static int spawnTimer; + private static int planningBudget; + private static long clock; + + public static bool IsRunning { get; private set; } + + public static CrowdSettings Settings { get; private set; } = CrowdSettings.ForDensity(CrowdDensity.Standard); + + internal static long Clock => clock; + + internal static int PlanningBudget => planningBudget; + + internal static IEnumerable ConversationPeers(CrowdAgent visitor) => Travelers + .Where(peer => peer != visitor && peer.CanConverse && CrowdConversation.InRange(visitor.Body, peer.Body)) + .OrderBy(peer => Vector2.DistanceSquared(visitor.Body.Center, peer.Body.Center)); + + internal static void DrawTrainPassengers(YggdrasilTown_NormalMetro train, SpriteBatch spriteBatch) + { + foreach (var person in Travelers) + { + if (ReferenceEquals(person.InteriorTrain, train)) + { + person.DrawInTrain(spriteBatch); + } + } + } + + public static bool Enabled => IsRunning && !Main.dedServ + && CrowdRules.CanRun(Main.netMode, YggdrasilWorld.InYggdrasil); + + private static void UpdateConfiguration(CrowdConfig config) + { + if (Main.dedServ || !config.Enabled || !CrowdRules.CanRun(Main.netMode, YggdrasilWorld.InYggdrasil)) + { + if (IsRunning || Travelers.Count != 0) + { + Clear(); + } + return; + } + Settings = CrowdSettings.ForDensity(config.Density); + if (!IsRunning) + { + Places.Initialize(); + // Enabling while looking at a street must not materialize its initial population. + previousView = CurrentView; + Regions.Observe(previousView, clock); + IsRunning = true; + } + } + + public override void OnWorldLoad() => Clear(); + + public override void OnWorldUnload() => Clear(); + + public override void Unload() => Clear(); + + public override void PostDrawInterface(SpriteBatch spriteBatch) + { + if (!Enabled || Main.gameMenu || !Main.hasFocus || Main.mapFullscreen || Main.playerInventory + || Main.blockInput || Main.LocalPlayer.dead || Main.LocalPlayer.mouseInterface) + { + return; + } + var mouse = Vector2.Transform(new Vector2(PlayerInput.MouseInfo.X, PlayerInput.MouseInfo.Y), + Matrix.Invert(Main.GameViewMatrix.TransformationMatrix)) + Main.screenPosition; + for (int i = Travelers.Count - 1; i >= 0; i--) + { + if (Travelers[i].CanShowNameAt(mouse)) + { + Main.instance.MouseText(Travelers[i].DisplayName); + break; + } + } + } + + public override void PreUpdateNPCs() + { + planningBudget = 3; + clock++; + } + + private static Rectangle CurrentView => CrowdLifeRules.CameraBounds(Main.screenPosition, + new Vector2(Main.screenWidth, Main.screenHeight), Main.GameViewMatrix.TransformationMatrix); + + public override void PostDrawTiles() + { + if (!Enabled || Main.gameMenu) + { + return; + } + // Observe the final camera before people draw (PostDrawDusts), including teleportation and zoom. + var view = CurrentView; + var exposed = previousView; + exposed.Inflate(32, 56); + previousView = view; + var fresh = Regions.Observe(view, clock); + if (fresh.Length == 0 || Settings.InitialFill == 0 || !HasCapacity) + { + return; + } + Places.DiscoverNear(view); + foreach (var region in fresh.OrderBy(region => Vector2.DistanceSquared(region.Center.ToVector2(), Main.LocalPlayer.Center))) + { + int existing = Travelers.Count(person => person.Active && region.Contains(person.Body.Bottom.ToPoint())); + for (int count = existing; count < Settings.InitialFill && HasCapacity && planningBudget > 0; count++) + { + for (int attempt = 0; attempt < 8 && planningBudget > 0; attempt++) + { + var near = new Vector2(Main.rand.Next(region.Left, region.Right), Main.rand.Next(region.Top, region.Bottom)); + if (CrowdTerrain.TryGround(near, out var source, 3, 24) + && !exposed.Contains(CrowdTerrain.Feet(source).ToPoint()) + && region.Contains(CrowdTerrain.Feet(source).ToPoint()) && Places.CanSeed(CrowdTerrain.Feet(source)) + && TrySpawnFrom(source, CrowdArrival.Existing, null)) + { + break; + } + } + } + } + } + + public override void PostUpdateNPCs() + { + UpdateConfiguration(Main.dedServ ? null : ModContent.GetInstance()); + if (!Enabled) + { + return; + } + CrowdThreats.Update(clock); + foreach (var person in Travelers) + { + person.Update(); + } + Travelers.RemoveAll(person => !person.Active); + Places.Update(); + ReceiveTrainPassengers(); + if (++spawnTimer >= Settings.SpawnIntervalTicks) + { + spawnTimer = 0; + TrySpawn(); + } + } + + private static bool HasCapacity => Travelers.Count < Settings.PopulationLimit; + + internal static bool Visible(Entity body) => CrowdPlaces.ViewBounds.Intersects(body.Hitbox); + + internal static bool ValidPlace(CrowdPlace place) => Places.Valid(place); + + internal static List FindRoute(Point start, Point goal, int seed, YggdrasilTown_NormalMetro train = null) + { + if (planningBudget <= 0) + { + return null; + } + planningBudget--; + return CrowdTerrain.FindRoute(start, goal, seed, train, + point => !CrowdThreats.Dangerous(CrowdTerrain.Feet(point))); + } + + internal static bool TryPlan(Point start, CrowdAgent traveler, bool departing, out CrowdPlace destination, + out List path, string exclude = null, CrowdIntent intent = CrowdIntent.VisitTown) + { + destination = null; + path = []; + Vector2 from = CrowdTerrain.Feet(start); + bool Occupied(Point cell) => Travelers.Any(other => other != traveler && other.ReservesStandingPoint(cell)); + var candidates = Places.Candidates(from, departing) + .Where(place => CrowdThreats.SpawnAllowed(CrowdTerrain.Feet(place.Cell))) + .Where(place => intent switch + { + CrowdIntent.ReturnHome => place.Activity == CrowdActivity.LeavePylon, + CrowdIntent.EnterGuild => place.Activity == CrowdActivity.EnterGuild, + _ => true, + }) + .Where(place => place.Activity != CrowdActivity.EnterGuild || EntranceReady(place.Key)) + .Select(place => Places.TryStandingPlace(place, Occupied, out var standingPlace) ? standingPlace : null) + .Where(place => place != null && place.Key != exclude) + .Select(place => (Place: place, Weight: CrowdActivityRules.Weight(place.Activity, departing, + traveler?.RecentlyVisited(place.Key) ?? false, + Travelers.Count(other => other != traveler && other.Active && other.DestinationKey == place.Key), + place.Capacity, Vector2.Distance(from, CrowdTerrain.Feet(place.Cell)), traveler?.Preference(place.Activity) ?? 1))) + .Where(candidate => candidate.Weight > 0 && (departing || candidate.Place.Activity == CrowdActivity.Sit + || Vector2.DistanceSquared(from, CrowdTerrain.Feet(candidate.Place.Cell)) >= 96 * 96)) + .Select(candidate => (candidate.Place, Order: -Math.Log(Math.Max(0.001, Main.rand.NextDouble())) / candidate.Weight)) + .OrderBy(candidate => candidate.Order).Take(6); + foreach (var candidate in candidates) + { + path = FindRoute(start, candidate.Place.Cell, traveler?.RouteSeed ?? Main.rand.Next(1, int.MaxValue)); + if (path == null) + { + return false; + } + if (path.Count > 0) + { + destination = candidate.Place; + return true; + } + } + return false; + } + + internal static bool PlanIntent(Point start, CrowdAgent traveler, CrowdIntent intent, + out CrowdPlace destination, out List path, string exclude = null) + => TryPlan(start, traveler, intent != CrowdIntent.VisitTown, out destination, out path, exclude, intent); + + internal static bool TryEscape(Point start, int preferredDirection, int seed, out List path, int attempt = 0) + { + path = []; + Vector2 from = CrowdTerrain.Feet(start); + float minimum = Math.Min(0, CrowdThreats.Clearance(from)) - 16; + var candidates = new List(); + foreach (int distance in new[] { 12, 24, 40, 64, 2, 4, 8 }) + { + foreach (int direction in new[] { preferredDirection, -preferredDirection }) + { + if (CrowdTerrain.TryGround(from + new Vector2(direction * distance * 16, 0), out var cell, 2, 12) + && CrowdThreats.Clearance(CrowdTerrain.Feet(cell)) >= minimum + 16 + && CrowdThreats.EscapeAllowed(from, CrowdTerrain.Feet(cell))) + { + candidates.Add(cell); + } + } + } + float Score(Point cell) + { + Vector2 feet = CrowdTerrain.Feet(cell); + return Math.Min(4096, CrowdThreats.Clearance(feet)) - Vector2.Distance(feet, from) * 0.2f + + (CrowdPlaces.ViewBounds.Contains(feet.ToPoint()) ? 0 : 240) + + (Math.Sign(cell.X - start.X) == preferredDirection ? 64 : 0); + } + var ordered = candidates.Distinct().OrderByDescending(Score).ToArray(); + for (int index = 0; index < ordered.Length; index++) + { + var cell = ordered[(index + (int)((uint)attempt % (uint)ordered.Length)) % ordered.Length]; + if (planningBudget <= 0) + { + path = null; + return false; + } + planningBudget--; + path = CrowdTerrain.FindRoute(start, cell, seed, allowed: point => CrowdThreats.EscapeAllowed(from, CrowdTerrain.Feet(point))); + if (path.Count > 1) + { + return true; + } + } + return false; + } + + internal static bool EntranceReady(string key) => !EntranceCooldowns.TryGetValue(key, out long last) + || clock - last >= CrowdSettings.DoorCooldownTicks; + + internal static bool TryEnterGuild(string key) + { + if (!EntranceReady(key)) + { + return false; + } + EntranceCooldowns[key] = clock; + return true; + } + + private static bool TrySpawn() + { + if (!Enabled || !HasCapacity) + { + return false; + } + Point source; + CrowdArrival arrival = CrowdArrival.Street; + CrowdPlace entrance = null; + // Pylons have their own rare attempt, never a fallback for failed street spawns. + int sourceKind = CrowdSettings.ChooseSource(Main.rand.Next(CrowdSettings.TotalSourceWeight)); + if (sourceKind > 0) + { + arrival = (CrowdArrival)sourceKind; + int cooldown = sourceKind == 2 ? CrowdSettings.PylonCooldownTicks : CrowdSettings.DoorCooldownTicks; + var entrances = Places.Entrances(Main.LocalPlayer.Center, sourceKind == 2) + .Where(place => !EntranceCooldowns.TryGetValue(place.Key, out long lastSpawn) || clock - lastSpawn >= cooldown).ToList(); + if (entrances.Count == 0) + { + return false; + } + entrance = entrances[Main.rand.Next(entrances.Count)]; + source = entrance.Cell; + } + else + { + var view = CrowdPlaces.ViewBounds; + float x = Main.rand.NextBool() ? view.Left - Main.rand.Next(32, 240) : view.Right + Main.rand.Next(32, 240); + var near = new Vector2(x, Main.LocalPlayer.Bottom.Y + Main.rand.Next(-12, 13) * 16); + if (!CrowdTerrain.TryGround(near, out source, 8, 24) + || !CrowdActivityRules.CanSpawnOnStreet(CrowdTerrain.Feet(source), CrowdPlaces.TownBounds, view)) + { + return false; + } + } + return TrySpawnFrom(source, arrival, entrance); + } + + private static bool TrySpawnFrom(Point source, CrowdArrival arrival, CrowdPlace entrance) + { + if (!CrowdThreats.SpawnAllowed(CrowdTerrain.Feet(source)) + || Travelers.Any(person => Vector2.DistanceSquared(person.Body.Bottom, CrowdTerrain.Feet(source)) < 64 * 64)) + { + return false; + } + var intent = CrowdLifeRules.ChooseIntent(arrival == CrowdArrival.Pylon, + entrance?.Activity == CrowdActivity.EnterGuild, Main.rand.Next(100), + CrowdSettings.ReturnPercent, CrowdSettings.GuildPercent); + bool planned = PlanIntent(source, null, intent, out var destination, out var path, entrance?.Key); + if (!planned || !Create(path, destination, arrival, entrance, null, intent: intent)) + { + return false; + } + if (entrance != null) + { + EntranceCooldowns[entrance.Key] = clock; + } + return true; + } + + private static bool Create(List path, CrowdPlace destination, CrowdArrival arrival, + CrowdPlace entrance, YggdrasilTown_NormalMetro train, Vector2? aboardFeet = null, CrowdIntent intent = CrowdIntent.VisitTown) + { + Vector2 feet = aboardFeet ?? CrowdTerrain.Feet(path[0]); + if (!HasCapacity || !CrowdThreats.SpawnAllowed(feet)) + { + return false; + } + int seed = Main.rand.Next(1, int.MaxValue); + var actor = new CrowdBody(); + Travelers.Add(new CrowdAgent(actor, path, destination, arrival, entrance, seed, train, aboardFeet) { Intent = intent }); + CrowdThreats.RecordArrival(feet); + return true; + } + + private static void ReceiveTrainPassengers() + { + foreach (var stale in TrainLoads.Keys.Where(train => !train.Active).ToArray()) + { + TrainLoads.Remove(stale); + } + foreach (var stale in Stops.Keys.Where(train => !train.Active).ToArray()) + { + Stops.Remove(stale); + } + foreach (var train in ColliderManager.Instance.OfType().Where(train => train.Active)) + { + if (!TrainLoads.TryGetValue(train, out var load)) + { + TrainLoads.Add(train, load = new CrowdTrainLoad()); + } + var trainView = new Rectangle((int)train.Position.X - 172, (int)train.Position.Y - 146, (int)train.Size.X + 344, 162); + load.Observe(CrowdTerrain.TrainStopped(train), CrowdPlaces.ViewBounds.Intersects(trainView), Travelers.Any(person => person.Aboard(train))); + if (Vector2.DistanceSquared(train.Box.Center, Main.LocalPlayer.Center) > 3000 * 3000) + { + continue; + } + PopulateTrain(train, load); + DispatchTrainPassengers(train); + } + } + + private static void PopulateTrain(YggdrasilTown_NormalMetro train, CrowdTrainLoad load) + { + int count = Settings.TrainInitialPassengers; + for (int slot = 0; slot < 12 && load.Loaded < count && HasCapacity; slot++) + { + Vector2 feet = train.Position + new Vector2(CrowdTrainLoad.SeatOffset(slot, train.Size.X), 0); + if (Travelers.Any(person => person.Active && Math.Abs(person.Body.Bottom.X - feet.X) < 32 + && Math.Abs(person.Body.Bottom.Y - feet.Y) < 48)) + { + continue; + } + if (!Create([CrowdTerrain.Cell(feet)], null, CrowdArrival.Train, null, train, feet)) + { + break; + } + load.RecordLoaded(); + } + } + + private static void DispatchTrainPassengers(YggdrasilTown_NormalMetro train) + { + if (!Stops.TryGetValue(train, out var batch)) + { + Stops.Add(train, batch = new CrowdStopBatch()); + } + if (!CrowdTerrain.TrainStopped(train)) + { + batch.TryBegin(false, train.StopTimer, train.StopTimeMax); + return; + } + batch.TryBegin(true, train.StopTimer, train.StopTimeMax); + if (!batch.TryDispatch(Settings.TrainPassengers, planningBudget, out int side)) + { + return; + } + // Reuse passengers already aboard. A full town does not block their disembarkation. + var passenger = Travelers.Where(person => person.CanAlight(train)) + .OrderBy(person => side < 0 ? person.Body.Bottom.X : -person.Body.Bottom.X).FirstOrDefault(); + if (passenger == null) + { + return; + } + var station = new Vector2(train.StationX, train.Box.Top + 16); + if (!CrowdPlaces.TryStation(station, station.X + side, out var landing)) + { + return; + } + if (!CrowdThreats.SpawnAllowed(CrowdTerrain.Feet(landing))) + { + return; + } + var path = FindRoute(CrowdTerrain.Cell(passenger.Body.Bottom), landing, passenger.RouteSeed, train); + if (path == null || path.Count == 0) + { + return; + } + if (!CrowdActivityRules.CanFinishTrainWalk(true, train.StopTimer, + train.StopTimeMax, CrowdAgent.EstimateTravelTicks(path, passenger.WalkSpeed))) + { + return; + } + if (!TryPlan(landing, null, false, out _, out _)) + { + return; + } + var destination = new CrowdPlace(CrowdActivity.Scenery, landing, $"alight:{landing}", CrowdTerrain.Feet(landing)); + passenger.BeginAlighting(path, destination); + CrowdThreats.RecordArrival(CrowdTerrain.Feet(landing)); + batch.RecordArrival(); + } + + internal static bool ObstacleAhead(CrowdAgent traveler, int direction) + { + bool Ahead(Entity other) => !ReferenceEquals(other, traveler.Body) + && (other.Center.X - traveler.Body.Center.X) * direction > 0 + && (other.Center.X - traveler.Body.Center.X) * direction < 28 + && Math.Abs(other.Bottom.Y - traveler.Body.Bottom.Y) < 24; + return Main.player.Any(player => player.active && !player.dead && Ahead(player)) + || Main.npc.Any(npc => npc.active && Ahead(npc)) + || Travelers.Any(other => other.Active && !other.IsAttached && Ahead(other.Body)); + } + + private static void Clear() + { + CrowdConversation.Clear(); + CrowdThreats.Clear(); + foreach (var person in Travelers) + { + person.Kill(); + } + Travelers.Clear(); + Stops.Clear(); + TrainLoads.Clear(); + EntranceCooldowns.Clear(); + Regions.Clear(); + previousView = Rectangle.Empty; + IsRunning = false; + spawnTimer = 0; + planningBudget = 0; + clock = 0; + } +} diff --git a/Sources/Modules/Yggdrasil/YggdrasilTown/CustomTiles/YggdrasilTown_NormalMetro.cs b/Sources/Modules/Yggdrasil/YggdrasilTown/CustomTiles/YggdrasilTown_NormalMetro.cs index 7a6e7563d..c5c575d2a 100644 --- a/Sources/Modules/Yggdrasil/YggdrasilTown/CustomTiles/YggdrasilTown_NormalMetro.cs +++ b/Sources/Modules/Yggdrasil/YggdrasilTown/CustomTiles/YggdrasilTown_NormalMetro.cs @@ -2,6 +2,7 @@ using Everglow.Commons.CustomTiles.Core; using Everglow.Yggdrasil.YggdrasilTown.Items.Tools.Developer; using Everglow.Yggdrasil.YggdrasilTown.Tiles; +using Everglow.Yggdrasil.Mechanics.TownCrowd; namespace Everglow.Yggdrasil.YggdrasilTown.CustomTiles; @@ -192,6 +193,9 @@ public bool SearchStation() public override void Draw() { + // Grid primitives draw immediately. Flush sprites in order too, then restore + // the CustomTile manager's batch so passenger sprites cannot escape the shell. + using var drawScope = (Main.spriteBatch.GetState().Value with { SortMode = SpriteSortMode.Immediate }).BeginScope(Main.spriteBatch); Texture2D metro = ModAsset.YggdrasilTown_NormalMetro.Value; var frame = new Rectangle(174, 165, 848, 160); var glow_frame = new Rectangle(174, 495, 848, 160); @@ -200,16 +204,13 @@ public override void Draw() var pos0 = new Vector2((Box.Center.X + Box.Left) * 0.5f, Box.Bottom); TileUtils.VertexDraw_Grid(pos0 - Main.screenPosition, frame, new Vector2(frame.Width / 2f, frame.Height), metro, Main.spriteBatch, 0); Main.spriteBatch.Draw(metro, pos0 - Main.screenPosition, glow_frame, new Color(1f, 1f, 1f, 0) * (1 - ShellAlpha), 0, new Vector2(frame.Width / 2f, frame.Height), 1, SpriteEffects.None, 0); - if (ShellAlpha > 0) - { - TileUtils.VertexDraw_Grid(pos0 - Main.screenPosition, outShellFrame, new Vector2(frame.Width / 2f, frame.Height), metro, Main.spriteBatch, 0, ShellAlpha); - } - var pos1 = new Vector2((Box.Center.X + Box.Right) * 0.5f, Box.Bottom); TileUtils.VertexDraw_Grid(pos1 - Main.screenPosition, frame, new Vector2(frame.Width / 2f, frame.Height), metro, Main.spriteBatch, 0); Main.spriteBatch.Draw(metro, pos1 - Main.screenPosition, glow_frame, new Color(1f, 1f, 1f, 0) * (1 - ShellAlpha), 0, new Vector2(frame.Width / 2f, frame.Height), 1, SpriteEffects.None, 0); + TownCrowdSystem.DrawTrainPassengers(this, Main.spriteBatch); if (ShellAlpha > 0) { + TileUtils.VertexDraw_Grid(pos0 - Main.screenPosition, outShellFrame, new Vector2(frame.Width / 2f, frame.Height), metro, Main.spriteBatch, 0, ShellAlpha); TileUtils.VertexDraw_Grid(pos1 - Main.screenPosition, outShellFrame, new Vector2(frame.Width / 2f, frame.Height), metro, Main.spriteBatch, 0, ShellAlpha); } From 567e7ccb0994e1e587e79acc02b9d70819a8e65b Mon Sep 17 00:00:00 2001 From: CloudeaSoft Date: Mon, 19 Oct 2026 12:24:57 +0800 Subject: [PATCH 4/6] style(yggdrasil): fix TownCrowd StyleCop violations --- .../Mechanics/TownCrowd/Activities/CrowdPlaces.cs | 6 ++++-- .../Mechanics/TownCrowd/Activities/CrowdRetirement.cs | 4 ++++ .../Mechanics/TownCrowd/Activities/CrowdThreats.cs | 1 + .../Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs | 10 ++++++++-- .../Mechanics/TownCrowd/Movement/CrowdTerrain.cs | 3 ++- .../Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs | 9 ++++++--- 6 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs index 3285326e3..29b1ed26b 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs @@ -207,7 +207,8 @@ public IEnumerable Entrances(Vector2 near, bool pylon) && (pylon || Math.Abs(CrowdTerrain.Feet(cell).Y - focus.Y) <= 24) && (!guild || CrowdLifeRules.InsideGuildOpening(marker.Origin, CrowdTerrain.Feet(cell)))) { - yield return new CrowdPlace(pylon ? CrowdActivity.LeavePylon : guild ? CrowdActivity.EnterGuild : CrowdActivity.EnterBuilding, + yield return new CrowdPlace( + pylon ? CrowdActivity.LeavePylon : guild ? CrowdActivity.EnterGuild : CrowdActivity.EnterBuilding, cell, $"tile:{marker.Origin}", focus, marker.Origin, marker.Type); } } @@ -299,7 +300,8 @@ public IEnumerable Candidates(Vector2 from, bool departing) && Math.Abs(CrowdTerrain.Feet(cell).X - CrowdTerrain.Feet(plaza.Cell).X) <= 640))) && (!departing || !ViewBounds.Contains(CrowdTerrain.Feet(cell).ToPoint()))) { - yield return new CrowdPlace(departing ? CrowdActivity.LeaveStreet : CrowdActivity.Scenery, + yield return new CrowdPlace( + departing ? CrowdActivity.LeaveStreet : CrowdActivity.Scenery, cell, $"street:{cell.X / 8}:{cell.Y / 4}", CrowdTerrain.Feet(cell)); } } diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs index 53c02afd4..bce5ecbaa 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdRetirement.cs @@ -21,9 +21,13 @@ public sealed class CrowdRetirement private float transitionStart = 1; public CrowdRetirementReason Reasons { get; private set; } + public float Opacity { get; private set; } = 1; + public bool Complete => Opacity <= 0; + public int DepartureFailures { get; private set; } + public int MovementTicks => movementTicks; public void ObserveThreat(bool alarmed) diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs index d73083f3d..32e79ccf3 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdThreats.cs @@ -8,6 +8,7 @@ namespace Everglow.Yggdrasil.Mechanics.TownCrowd; internal static class CrowdThreats { private sealed record CrowdThreat(Vector2 Center, float Radius, bool IgnoresWalls); + private sealed class CrowdRecovery(long tick) { public long LastDanger = tick; diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs index b26ed7c7c..9d9fca39c 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs @@ -636,8 +636,14 @@ private void ChooseNextActivity() waitingForBudget = TownCrowdSystem.PlanningBudget == budgetBefore; if (!waitingForBudget) { - if (movementPending) { retirement.RecordMovementRecovery(); } - if (WantsToLeave) { retirement.RecordDepartureFailure(); } + if (movementPending) + { + retirement.RecordMovementRecovery(); + } + if (WantsToLeave) + { + retirement.RecordDepartureFailure(); + } } return; } diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs index 28d037888..abfbbed8a 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Movement/CrowdTerrain.cs @@ -46,7 +46,8 @@ public static bool CanStand(Point cell, YggdrasilTown_NormalMetro boardingTrain return false; } // Grid nodes require an exact floor height; half blocks and slopes need a richer surface model. - if (CrowdRules.SupportsGridLanding(tile.HasUnactuatedTile, + if (CrowdRules.SupportsGridLanding( + tile.HasUnactuatedTile, CrowdRules.IsSolidFloor(Main.tileSolid[tile.TileType], Main.tileSolidTop[tile.TileType], tile.TileFrameY), tile.IsHalfBlock, (int)tile.Slope)) { return true; diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs index f2faa5507..2125ba218 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs @@ -78,7 +78,8 @@ public override void PostDrawInterface(SpriteBatch spriteBatch) { return; } - var mouse = Vector2.Transform(new Vector2(PlayerInput.MouseInfo.X, PlayerInput.MouseInfo.Y), + var mouse = Vector2.Transform( + new Vector2(PlayerInput.MouseInfo.X, PlayerInput.MouseInfo.Y), Matrix.Invert(Main.GameViewMatrix.TransformationMatrix)) + Main.screenPosition; for (int i = Travelers.Count - 1; i >= 0; i--) { @@ -96,7 +97,8 @@ public override void PreUpdateNPCs() clock++; } - private static Rectangle CurrentView => CrowdLifeRules.CameraBounds(Main.screenPosition, + private static Rectangle CurrentView => CrowdLifeRules.CameraBounds( + Main.screenPosition, new Vector2(Main.screenWidth, Main.screenHeight), Main.GameViewMatrix.TransformationMatrix); public override void PostDrawTiles() @@ -323,7 +325,8 @@ private static bool TrySpawnFrom(Point source, CrowdArrival arrival, CrowdPlace { return false; } - var intent = CrowdLifeRules.ChooseIntent(arrival == CrowdArrival.Pylon, + var intent = CrowdLifeRules.ChooseIntent( + arrival == CrowdArrival.Pylon, entrance?.Activity == CrowdActivity.EnterGuild, Main.rand.Next(100), CrowdSettings.ReturnPercent, CrowdSettings.GuildPercent); bool planned = PlanIntent(source, null, intent, out var destination, out var path, entrance?.Key); From 0a28cc3662c37628dcfe154a47a5a236b0cbe3db Mon Sep 17 00:00:00 2001 From: CloudeaSoft Date: Mon, 19 Oct 2026 13:25:30 +0800 Subject: [PATCH 5/6] fix(yggdrasil): isolate crowd tests and resolve style diagnostics --- .../Modules/Yggdrasil/CrowdAvoidanceTests.cs | 20 +- .../Modules/Yggdrasil/CrowdRetirementTests.cs | 45 ++++- .../Modules/Yggdrasil/CrowdRulesTests.cs | 3 +- .../Modules/Yggdrasil/CrowdSittingTests.cs | 3 +- .../Yggdrasil/CrowdTownPlanningTests.cs | 187 +++++++++++++++--- 5 files changed, 214 insertions(+), 44 deletions(-) diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs index 564a36b28..a05f0d93b 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdAvoidanceTests.cs @@ -24,10 +24,16 @@ public void RecoveryNeedsThreeContinuousSafeSeconds() bool Alarmed() => (bool)type.GetProperty("Alarmed")!.GetValue(state)!; Tick(true, false); Assert.IsTrue(Alarmed()); - for (int i = 0; i < 179; i++) { Tick(false, true); } + for (int i = 0; i < 179; i++) + { + Tick(false, true); + } Assert.IsTrue(Alarmed()); Tick(false, false); - for (int i = 0; i < 179; i++) { Tick(false, true); } + for (int i = 0; i < 179; i++) + { + Tick(false, true); + } Assert.IsTrue(Alarmed()); Tick(false, true); Assert.IsFalse(Alarmed()); @@ -40,10 +46,16 @@ public void ReturningOnscreenResetsRetirementAndSafeOffscreenStillRetires() var state = Activator.CreateInstance(type)!; bool Tick(bool danger, bool safe, bool visible) => (bool)type.GetMethod("Update")!.Invoke(state, [danger, safe, visible, 300])!; Tick(true, false, true); - for (int i = 0; i < 299; i++) { Assert.IsFalse(Tick(false, true, false)); } + for (int i = 0; i < 299; i++) + { + Assert.IsFalse(Tick(false, true, false)); + } Assert.IsFalse(Tick(false, false, true)); Tick(true, false, true); - for (int i = 0; i < 299; i++) { Assert.IsFalse(Tick(false, true, false)); } + for (int i = 0; i < 299; i++) + { + Assert.IsFalse(Tick(false, true, false)); + } Assert.IsTrue(Tick(false, true, false)); } } diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs index f7c37e14d..ed04b6c12 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRetirementTests.cs @@ -8,7 +8,10 @@ public class CrowdRetirementTests { private static void Fade(CrowdRetirement retirement, int ticks) { - for (int tick = 0; tick < ticks; tick++) { retirement.Update(); } + for (int tick = 0; tick < ticks; tick++) + { + retirement.Update(); + } } [TestMethod] @@ -20,10 +23,16 @@ public void ThreatUsesSustainedAvoidanceRecoveryRatherThanOneMissingDetection() { avoidance.Update(tick == 0, false, true, 300); retirement.ObserveThreat(avoidance.Alarmed); - if (tick < 599) { Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); } + if (tick < 599) + { + Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); + } } Assert.AreEqual(CrowdRetirementReason.Threat, retirement.Reasons); - for (int tick = 0; tick < 179; tick++) { avoidance.Update(false, true, true, 300); } + for (int tick = 0; tick < 179; tick++) + { + avoidance.Update(false, true, true, 300); + } retirement.ObserveThreat(avoidance.Alarmed); Assert.AreEqual(CrowdRetirementReason.Threat, retirement.Reasons); avoidance.Update(false, true, true, 300); @@ -76,14 +85,23 @@ public void RelapseFadesFromCurrentOpacityAndCompletionIsTerminal() public void BlockedMovementNeedsTimeAndActualRecoveriesAndResetsAfterProgress() { var retirement = new CrowdRetirement(); - for (int tick = 0; tick < 900; tick++) { retirement.ObserveMovement(true, false, false); } + for (int tick = 0; tick < 900; tick++) + { + retirement.ObserveMovement(true, false, false); + } Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); - for (int attempt = 0; attempt < 3; attempt++) { retirement.RecordMovementRecovery(); } + for (int attempt = 0; attempt < 3; attempt++) + { + retirement.RecordMovementRecovery(); + } retirement.ObserveMovement(true, false, false); Assert.AreEqual(CrowdRetirementReason.Blocked, retirement.Reasons); retirement.ObserveMovement(true, false, true); Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); - for (int tick = 0; tick < 1800; tick++) { retirement.ObserveMovement(true, false, false); } + for (int tick = 0; tick < 1800; tick++) + { + retirement.ObserveMovement(true, false, false); + } Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons, "Earlier recovery attempts must not carry into a new stall."); } @@ -110,8 +128,14 @@ public void RestAndBudgetWaitsDoNotAccumulateMovementOrDepartureTime() public void DepartureRequiresFailuresAndActualExitProgressClearsAllHistory() { var retirement = new CrowdRetirement(); - for (int tick = 0; tick < 1800; tick++) { retirement.ObserveDeparture(true, false, false); } - for (int attempt = 0; attempt < 2; attempt++) { retirement.RecordDepartureFailure(); } + for (int tick = 0; tick < 1800; tick++) + { + retirement.ObserveDeparture(true, false, false); + } + for (int attempt = 0; attempt < 2; attempt++) + { + retirement.RecordDepartureFailure(); + } retirement.ObserveDeparture(true, false, false); Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); retirement.RecordDepartureFailure(); @@ -120,7 +144,10 @@ public void DepartureRequiresFailuresAndActualExitProgressClearsAllHistory() retirement.ObserveDeparture(true, false, true); Assert.AreEqual(0, retirement.DepartureFailures); Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); - for (int tick = 0; tick < 6000; tick++) { retirement.ObserveDeparture(true, false, tick % 30 == 0); } + for (int tick = 0; tick < 6000; tick++) + { + retirement.ObserveDeparture(true, false, tick % 30 == 0); + } Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); } diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs index dac306f86..80baad422 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdRulesTests.cs @@ -66,7 +66,8 @@ public void CrowdStepsOntoCustomTrainFloorAndReachesDoor(float floorY) } body.Position += stride; } - Assert.IsTrue(CrowdRules.CanBoard(true, true, Vector2.Zero, 100, 600, + Assert.IsTrue( + CrowdRules.CanBoard(true, true, Vector2.Zero, 100, 600, body.Position + new Vector2(8, 48), door), "The traveler must physically reach the deck, not stop against its side."); Assert.AreEqual(train.Position.Y, body.Position.Y + 48, 0.1f); } diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs index b5f9af1e8..5beef9ffa 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdSittingTests.cs @@ -40,7 +40,8 @@ public void LightweightLegSlicesMatchInstalledTmlDrawSittingLegs(int direction, object?[] args = [position, direction, 2 - index, seatOffset, null, null]; method.Invoke(null, args); Assert.AreEqual(expected[index].sourceRect, (Rectangle)args[4]!); - Assert.AreEqual(expected[index].position - expected[index].origin, + Assert.AreEqual( + expected[index].position - expected[index].origin, (Vector2)args[5]! - new Vector2(20, 0)); } } diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs index e81a30ffe..2e51dc265 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs @@ -60,13 +60,20 @@ public void EscapeProgressDoesNotEraseAnInterruptedExitFailure() var body = (Entity)type.GetProperty("Body")!.GetValue(traveler)!; type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Fleeing")); - type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, + type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue( + traveler, Activator.CreateInstance(PlaceType, CrowdActivity.LeavePylon, new Point(110, 63), "exit", new Vector2(1768, 1024), null, 0)!); type.GetField("movementPending", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, true); type.GetField("movementTarget", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, new Vector2(1200, 1024)); var retirement = RetirementOf(traveler); - for (int i = 0; i < 3; i++) { retirement.RecordDepartureFailure(); } - for (int tick = 0; tick < 1800; tick++) { retirement.ObserveDeparture(true, false, false); } + for (int i = 0; i < 3; i++) + { + retirement.RecordDepartureFailure(); + } + for (int tick = 0; tick < 1800; tick++) + { + retirement.ObserveDeparture(true, false, false); + } var observe = type.GetMethod("UpdateRetirement", BindingFlags.Instance | BindingFlags.NonPublic)!; observe.Invoke(traveler, null); body.position.X -= 20; @@ -114,9 +121,18 @@ public void PhysicalProgressRestoresOpacityWithoutChangingRouteStageOrVelocity() type.GetField("movementPending", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, true); type.GetField("movementTarget", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, new Vector2(1800, 1024)); var retirement = RetirementOf(traveler); - for (int i = 0; i < 3; i++) { retirement.RecordMovementRecovery(); } - for (int tick = 0; tick < 900; tick++) { retirement.ObserveMovement(true, false, false); } - for (int tick = 0; tick < 60; tick++) { retirement.Update(); } + for (int i = 0; i < 3; i++) + { + retirement.RecordMovementRecovery(); + } + for (int tick = 0; tick < 900; tick++) + { + retirement.ObserveMovement(true, false, false); + } + for (int tick = 0; tick < 60; tick++) + { + retirement.Update(); + } var route = type.GetField("route", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler); var observe = type.GetMethod("UpdateRetirement", BindingFlags.Instance | BindingFlags.NonPublic)!; observe.Invoke(traveler, null); @@ -125,7 +141,10 @@ public void PhysicalProgressRestoresOpacityWithoutChangingRouteStageOrVelocity() observe.Invoke(traveler, null); Assert.AreEqual(CrowdRetirementReason.None, retirement.Reasons); Assert.IsTrue(retirement.Opacity > low && retirement.Opacity < 1); - for (int tick = 0; tick < 29; tick++) { observe.Invoke(traveler, null); } + for (int tick = 0; tick < 29; tick++) + { + observe.Invoke(traveler, null); + } Assert.AreEqual(1f, retirement.Opacity); Assert.AreSame(route, type.GetField("route", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); Assert.AreEqual("Walking", type.GetProperty("Stage")!.GetValue(traveler)!.ToString()); @@ -198,10 +217,22 @@ public void FadeKeepsConversationUntilCompletionThenCleansBothPartnersAndBubbleO var bubbles = (Dictionary)typeof(Terraria.GameContent.UI.EmoteBubble) .GetField("byID", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; var oldBubbles = bubbles.Keys.ToHashSet(); - restore.Add(() => { foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) { bubbles.Remove(id); } }); + restore.Add(() => + { + foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) + { + bubbles.Remove(id); + } + }); var retirement = RetirementOf(first); - for (int i = 0; i < 3; i++) { retirement.RecordDepartureFailure(); } - for (int tick = 0; tick < 1800; tick++) { retirement.ObserveDeparture(true, false, false); } + for (int i = 0; i < 3; i++) + { + retirement.RecordDepartureFailure(); + } + for (int tick = 0; tick < 1800; tick++) + { + retirement.ObserveDeparture(true, false, false); + } type.GetProperty("Intent")!.SetValue(first, CrowdIntent.ReturnHome); var view = (CrowdAppearance)type.GetField("view", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(first)!; view.UpdateController = _ => { }; @@ -240,11 +271,17 @@ public void PausedSimulationDoesNotAdvanceFadeOrBehavior() var type = traveler.GetType(); var retirement = RetirementOf(traveler); retirement.SetReason(CrowdRetirementReason.Threat, true); - for (int tick = 0; tick < 60; tick++) { retirement.Update(); } + for (int tick = 0; tick < 60; tick++) + { + retirement.Update(); + } bool oldPaused = Main.gamePaused; restore.Add(() => Main.gamePaused = oldPaused); Main.gamePaused = true; - for (int tick = 0; tick < 300; tick++) { type.GetMethod("Update")!.Invoke(traveler, null); } + for (int tick = 0; tick < 300; tick++) + { + type.GetMethod("Update")!.Invoke(traveler, null); + } Assert.AreEqual(0.5f, retirement.Opacity, 0.0001f); Assert.AreEqual(0, type.GetField("age", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); } @@ -279,7 +316,14 @@ public void DensityChangeKeepsExistingActivitiesAndDisablingCleansUpTravelers() var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; var previous = travelers.Cast().ToArray(); travelers.Clear(); - restore.Add(() => { travelers.Clear(); foreach (var person in previous) { travelers.Add(person); } }); + restore.Add(() => + { + travelers.Clear(); + foreach (var person in previous) + { + travelers.Add(person); + } + }); var traveler = CreateWalkingActor(new Vector2(1608, 1024)); travelers.Add(traveler); var apply = typeof(TownCrowdSystem).GetMethod("UpdateConfiguration", BindingFlags.Static | BindingFlags.NonPublic)!; @@ -309,9 +353,13 @@ public void ClientConfigurationCannotEnableMultiplayerCrowds(bool server, int ne apply.Invoke(null, [new CrowdConfig()]); Assert.IsFalse(TownCrowdSystem.IsRunning); } + private static Type ConversationType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdConversation")!; + private static Type PlacesType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdPlaces")!; + private static Type PlaceType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdPlace")!; + private static Type ThreatsType => typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdThreats")!; private void ResetThreats() @@ -577,7 +625,11 @@ public void TravelersConverseInPlaceAndResumeTheirOriginalRoutes(string arrangem if (arrangement == "vertical") { bool solid = Main.tileSolid[TileID.Platforms], top = Main.tileSolidTop[TileID.Platforms]; - restore.Add(() => { Main.tileSolid[TileID.Platforms] = solid; Main.tileSolidTop[TileID.Platforms] = top; }); + restore.Add(() => + { + Main.tileSolid[TileID.Platforms] = solid; + Main.tileSolidTop[TileID.Platforms] = top; + }); Main.tileSolid[TileID.Platforms] = Main.tileSolidTop[TileID.Platforms] = true; var tile = Main.tile[100, 60]; tile.HasTile = true; @@ -602,7 +654,10 @@ public void TravelersConverseInPlaceAndResumeTheirOriginalRoutes(string arrangem Assert.IsNotNull(begin, "Lightweight peers need a shared conversation activity."); bool started = (bool)begin.Invoke(null, [first, second, 600])!; Assert.AreEqual(arrangement is "horizontal" or "vertical", started); - if (!started) { return; } + if (!started) + { + return; + } var type = first.GetType(); Assert.AreEqual("Conversing", type.GetProperty("Stage")!.GetValue(first)!.ToString()); Assert.AreEqual("Conversing", type.GetProperty("Stage")!.GetValue(second)!.ToString()); @@ -610,7 +665,13 @@ public void TravelersConverseInPlaceAndResumeTheirOriginalRoutes(string arrangem var bubbles = (Dictionary)typeof(Terraria.GameContent.UI.EmoteBubble) .GetField("byID", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; var oldBubbles = bubbles.Keys.ToHashSet(); - restore.Add(() => { foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) { bubbles.Remove(id); } }); + restore.Add(() => + { + foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) + { + bubbles.Remove(id); + } + }); var continueChat = ConversationType.GetMethod("ContinueNearby", BindingFlags.Static | BindingFlags.NonPublic)!; for (int turn = 0; turn < 2; turn++) { @@ -642,21 +703,37 @@ public void WalkingOpportunityUsesTunedChanceAndDangerInterruptsBothPeers(int ro var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; var previous = travelers.Cast().ToArray(); travelers.Clear(); - restore.Add(() => { travelers.Clear(); foreach (var person in previous) { travelers.Add(person); } }); + restore.Add(() => + { + travelers.Clear(); + foreach (var person in previous) + { + travelers.Add(person); + } + }); var first = CreateWalkingActor(new Vector2(1608, 1024)); var second = CreateWalkingActor(new Vector2(1656, 1024)); travelers.Add(first); travelers.Add(second); var bubbles = (Dictionary)typeof(Terraria.GameContent.UI.EmoteBubble).GetField("byID", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; var oldBubbles = bubbles.Keys.ToHashSet(); - restore.Add(() => { foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) { bubbles.Remove(id); } }); + restore.Add(() => + { + foreach (int id in bubbles.Keys.Where(id => !oldBubbles.Contains(id)).ToArray()) + { + bubbles.Remove(id); + } + }); Main.rand = new CrowdChanceRandom(roll); CaptureThreats(77); // PreUpdateNPCs advances to the 120-tick opportunity for seed 42. new TownCrowdSystem().PreUpdateNPCs(); var type = first.GetType(); type.GetMethod("Update")!.Invoke(first, null); Assert.AreEqual(converses, type.GetProperty("Stage")!.GetValue(first)!.ToString() == "Conversing"); - if (!converses) { return; } + if (!converses) + { + return; + } AddEnemy(new Vector2(1800, 1024)); CaptureThreats(84); type.GetMethod("Update")!.Invoke(first, null); @@ -780,7 +857,7 @@ public void ThreatenedPassengerStaysOnDeckAndFollowsMovingTrain(string stage) public void Initialize() { Program.SavePath = string.Empty; - var oldTiles = Main.tile; + originalTileData = Main.tile.GetData(); int oldWidth = Main.maxTilesX; int oldHeight = Main.maxTilesY; var oldCenter = YggdrasilTownBiome.BiomeCenter; @@ -793,7 +870,6 @@ public void Initialize() bool solid = Main.tileSolid[TileID.Stone]; restore.Add(() => { - Main.tile = oldTiles; Main.maxTilesX = oldWidth; Main.maxTilesY = oldHeight; Main.npc = oldNpcs; @@ -807,8 +883,12 @@ public void Initialize() }); Main.maxTilesX = 256; Main.maxTilesY = 128; - Main.tile = (Tilemap)typeof(Tilemap).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, - null, [typeof(ushort), typeof(ushort)], null)!.Invoke([(ushort)256, (ushort)128]); + // Tilemap construction resizes global TileData arrays. Borrow and restore only our test area. + PreserveTiles(); + PreserveTiles(); + PreserveTiles(); + PreserveTiles(); + PreserveTiles(); Main.npc = Enumerable.Range(0, Main.maxNPCs).Select(_ => new NPC()).ToArray(); Main.projectile = []; var invasionInstance = typeof(ContentInstance).GetProperty("Instance")!; @@ -837,7 +917,14 @@ public void Initialize() var list = (System.Collections.IList)PlacesType.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!; var snapshot = list.Cast().ToArray(); list.Clear(); - restore.Add(() => { list.Clear(); foreach (var entry in snapshot) { list.Add(entry); } }); + restore.Add(() => + { + list.Clear(); + foreach (var entry in snapshot) + { + list.Add(entry); + } + }); } foreach (string name in new[] { "clock", "planningBudget" }) { @@ -847,6 +934,36 @@ public void Initialize() } } + private TileTypeData[] originalTileData = null!; + + private void PreserveTiles() + where T : unmanaged, ITileData + { + int width = Main.maxTilesX; + int height = Main.maxTilesY; + int stride = Main.tile.Height; + var data = Main.tile.GetData(); + var snapshot = new T[width * height]; + for (int x = 0; x < width; x++) + { + Array.Copy(data, x * stride, snapshot, x * height, height); + Array.Clear(data, x * stride, height); + } + restore.Add(() => + { + for (int x = 0; x < width; x++) + { + Array.Copy(snapshot, x * height, data, x * stride, height); + } + }); + } + + [TestMethod] + public void FixturePreservesSharedTileStorage() + { + Assert.AreSame(originalTileData, Main.tile.GetData()); + } + [TestCleanup] public void Cleanup() { @@ -876,7 +993,11 @@ public void PlatformRecoveryRejectsUnsafeOrUnrelatedDrops(string condition) { bool oldSolid = Main.tileSolid[TileID.Platforms]; bool oldTop = Main.tileSolidTop[TileID.Platforms]; - restore.Add(() => { Main.tileSolid[TileID.Platforms] = oldSolid; Main.tileSolidTop[TileID.Platforms] = oldTop; }); + restore.Add(() => + { + Main.tileSolid[TileID.Platforms] = oldSolid; + Main.tileSolidTop[TileID.Platforms] = oldTop; + }); Main.tileSolid[TileID.Platforms] = Main.tileSolidTop[TileID.Platforms] = true; var slope = Main.tile[100, 62]; slope.HasTile = condition != "air"; @@ -1010,14 +1131,16 @@ public void PlannerAvoidsAStandingPositionReservedByAnotherActivity() var resident = RuntimeHelpers.GetUninitializedObject(type); type.GetProperty("Active")!.SetValue(resident, true); type.GetProperty("Stage")!.SetValue(resident, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Activity")); - type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(resident, + type.GetField("destination", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue( + resident, Activator.CreateInstance(PlaceType, CrowdActivity.Scenery, cell, "different-activity", cell.ToVector2() * 16, null, 0)); var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; travelers.Add(resident); restore.Add(() => travelers.Remove(resident)); Main.rand = new UnifiedRandom(42); Assert.IsTrue(Plan(out var nextPath)); - Assert.IsTrue(Math.Abs(nextPath![^1].X - cell.X) >= 2 || Math.Abs(nextPath[^1].Y - cell.Y) >= 3, + Assert.IsTrue( + Math.Abs(nextPath![^1].X - cell.X) >= 2 || Math.Abs(nextPath[^1].Y - cell.Y) >= 3, "Distinct activity keys must not allow two people to stand on the same ground coordinate."); } @@ -1506,7 +1629,12 @@ public void ChairUsesBottomAnchorAndYieldsToPlayerAndResident() var oldData = dataField.GetValue(null); bool oldSeating = TileID.Sets.CanBeSatOnForNPCs[TileID.Chairs]; var oldPlayers = Main.player; - restore.Add(() => { dataField.SetValue(null, oldData); TileID.Sets.CanBeSatOnForNPCs[TileID.Chairs] = oldSeating; Main.player = oldPlayers; }); + restore.Add(() => + { + dataField.SetValue(null, oldData); + TileID.Sets.CanBeSatOnForNPCs[TileID.Chairs] = oldSeating; + Main.player = oldPlayers; + }); var data = new Terraria.ObjectData.TileObjectData(); typeof(Terraria.ObjectData.TileObjectData).GetMethod("SetupBaseObject", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(data, null); data.Width = 1; @@ -1557,7 +1685,8 @@ public void NewReturnerCannotFallBackToAnUnrelatedSightseeingRoute() new TownCrowdSystem().PreUpdateNPCs(); var arrivalType = typeof(TownCrowdSystem).Assembly.GetType("Everglow.Yggdrasil.Mechanics.TownCrowd.CrowdArrival")!; var spawn = typeof(TownCrowdSystem).GetMethod("TrySpawnFrom", BindingFlags.Static | BindingFlags.NonPublic)!; - Assert.IsFalse((bool)spawn.Invoke(null, [new Point(100, 63), Enum.Parse(arrivalType, "Existing"), null])!, + Assert.IsFalse( + (bool)spawn.Invoke(null, [new Point(100, 63), Enum.Parse(arrivalType, "Existing"), null])!, "No pylons exist in this fixture: creating a returner must fail before allocating a body or a VFX."); } From 72194bab63406da1102f248654bc22e0930f07db Mon Sep 17 00:00:00 2001 From: CloudeaSoft Date: Mon, 19 Oct 2026 22:19:55 +0800 Subject: [PATCH 6/6] fix(yggdrasil): keep crowd travelers focused on transit goals --- .../Modules/Yggdrasil/CrowdLifeRulesTests.cs | 4 +- .../Yggdrasil/CrowdTownPlanningTests.cs | 173 +++++++++++++++++- .../TownCrowd/Activities/CrowdLifeRules.cs | 5 +- .../TownCrowd/Activities/CrowdPlaces.cs | 15 ++ .../TownCrowd/Configuration/CrowdSettings.cs | 3 + .../Mechanics/TownCrowd/CrowdAgent.cs | 17 +- .../Mechanics/TownCrowd/TownCrowdSystem.cs | 7 +- 7 files changed, 214 insertions(+), 10 deletions(-) diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs index 1c436aa5f..fb47d8e43 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdLifeRulesTests.cs @@ -22,8 +22,8 @@ public void InitialIntentIsIndependentOfStreetOrPylonAppearance() { var choose = Rules.GetMethod("ChooseIntent")!; Assert.AreEqual("ReturnHome", choose.Invoke(null, [false, false, 0, 100, 0])!.ToString()); - Assert.AreEqual("VisitTown", choose.Invoke(null, [true, false, 0, 100, 0])!.ToString()); - Assert.AreEqual("EnterGuild", choose.Invoke(null, [true, false, 0, 0, 100])!.ToString()); + Assert.AreEqual("TakeTrain", choose.Invoke(null, [true, false, 0, 100, 0])!.ToString()); + Assert.AreEqual("TakeTrain", choose.Invoke(null, [true, false, 0, 0, 100])!.ToString()); Assert.AreEqual("EnterGuild", choose.Invoke(null, [false, false, 0, 0, 100])!.ToString()); Assert.AreEqual("VisitTown", choose.Invoke(null, [false, true, 0, 0, 100])!.ToString()); } diff --git a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs index 2e51dc265..fd149c257 100644 --- a/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs +++ b/Sources/Everglow.UnitTests/Modules/Yggdrasil/CrowdTownPlanningTests.cs @@ -29,7 +29,143 @@ private static CrowdRetirement RetirementOf(object traveler) => (CrowdRetirement .GetField("retirement", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)!; [TestMethod] - public void SightseeingBudgetWaitPausesRetirementButKeepsActualExitFailures() + public void PylonStationPassengersStartACommittedReturn() + { + EnableJourneys(); + ResetThreats(); + AddTransitLandmark("pylons", new Point(20, 50), TileID.Rope); + AddTransitLandmark("stations", new Point(101, 59), TileID.Signs); + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + ((System.Collections.IList)PlacesType.GetField("stations", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!).Clear(); + for (int x = 5; x < 50; x++) + { + var tile = Main.tile[x, 63]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + var train = new YggdrasilTown_NormalMetro + { + Active = true, + Position = new Vector2(800, 1008), + Size = new Vector2(1696, 16), + StationX = 1648, + State = (int)YggdrasilTown_NormalMetro.BehaviorType.Stop, + StopTimer = 30, + }; + ((List)typeof(ColliderManager).GetField("rigidbodies", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(ColliderManager.Instance)!).Add(train); + var passenger = CreateWalkingActor(new Vector2(896, 1008)); + var type = passenger.GetType(); + type.GetProperty("Stage")!.SetValue(passenger, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Riding")); + type.GetField("train", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(passenger, train); + type.GetField("mayAlight", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(passenger, true); + var travelers = (System.Collections.IList)typeof(TownCrowdSystem).GetField("Travelers", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + travelers.Add(passenger); + restore.Add(() => travelers.Remove(passenger)); + var stops = (System.Collections.IDictionary)typeof(TownCrowdSystem).GetField("Stops", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + restore.Add(() => stops.Remove(train)); + new TownCrowdSystem().PreUpdateNPCs(); + typeof(TownCrowdSystem).GetMethod("DispatchTrainPassengers", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, [train]); + Assert.AreEqual("Alighting", type.GetProperty("Stage")!.GetValue(passenger)!.ToString()); + Assert.AreEqual(CrowdIntent.ReturnHome, type.GetProperty("Intent")!.GetValue(passenger)); + } + + [TestMethod] + public void PylonArrivalCanPlanTrainAndRetryTheSameStation() + { + ResetThreats(); + AddTransitLandmark("stations", new Point(101, 59), TileID.Signs); + for (int x = 5; x < 50; x++) + { + var tile = Main.tile[x, 63]; + tile.HasTile = true; + tile.TileType = TileID.Stone; + } + var traveler = CreateWalkingActor(new Vector2(600, 1008)); + var type = traveler.GetType(); + var intent = Enum.Parse(typeof(CrowdIntent), "TakeTrain"); + type.GetProperty("Intent")!.SetValue(traveler, intent); + var recent = (Queue)type.GetField("recentPlaces", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)!; + recent.Enqueue($"station:{new Point(101, 59)}"); + new TownCrowdSystem().PreUpdateNPCs(); + object?[] args = [new Point(37, 62), traveler, intent, null, null, null]; + Assert.IsTrue((bool)typeof(TownCrowdSystem).GetMethod("PlanIntent", BindingFlags.Static | BindingFlags.NonPublic)!.Invoke(null, args)!); + Assert.AreEqual(CrowdActivity.BoardTrain, PlaceType.GetProperty("Activity")!.GetValue(args[3])); + } + + [TestMethod] + public void TransitPauseIsBriefAndCannotRepeatAfterReplanning() + { + EnableJourneys(); + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.TakeTrain); + type.GetField("routeSeed", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, 1); + var path = Enumerable.Range(100, 11).Select(x => new Point(x, 63)).ToList(); + type.GetMethod("SetRoute", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, [path]); + type.GetField("stageTime", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, 120); + var move = type.GetMethod("MoveAlongRoute", BindingFlags.Instance | BindingFlags.NonPublic)!; + var pause = type.GetField("pauseTime", BindingFlags.Instance | BindingFlags.NonPublic)!; + move.Invoke(traveler, [true]); + int duration = (int)pause.GetValue(traveler)!; + Assert.IsTrue(duration >= 119 && duration <= 239); + for (int tick = 0; tick < duration; tick++) + { + move.Invoke(traveler, [true]); + } + type.GetMethod("SetRoute", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, [path]); + move.Invoke(traveler, [true]); + Assert.AreEqual(0, pause.GetValue(traveler)); + Assert.IsTrue(((Entity)type.GetProperty("Body")!.GetValue(traveler)!).velocity.X > 0); + } + + [TestMethod] + public void TransitDetoursAreRareAndConversationConsumesTheOneOpportunity() + { + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); + var canChat = type.GetProperty("CanConverse", BindingFlags.Instance | BindingFlags.NonPublic)!; + var seed = type.GetField("routeSeed", BindingFlags.Instance | BindingFlags.NonPublic)!; + int eligible = 0; + for (int value = 0; value < 100; value++) + { + seed.SetValue(traveler, value); + if ((bool)canChat.GetValue(traveler)!) + { + eligible++; + } + } + Assert.AreEqual(5, eligible); + seed.SetValue(traveler, 1); + type.GetMethod("PauseForConversation", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, null); + type.GetMethod("ResumeAfterConversation", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, [true]); + typeof(TownCrowdSystem).GetField("clock", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, 10000L); + Assert.AreEqual(false, canChat.GetValue(traveler)); + Assert.AreEqual(CrowdIntent.ReturnHome, type.GetProperty("Intent")!.GetValue(traveler)); + } + + private void AddTransitLandmark(string collection, Point origin, ushort tileType) + where T : ModTile, new() + { + var instance = typeof(ContentInstance).GetProperty("Instance")!; + var previous = instance.GetValue(null); + restore.Add(() => instance.SetValue(null, previous)); + var content = new T(); + typeof(ModTile).GetProperty("Type")!.SetValue(content, tileType); + instance.SetValue(null, content); + var tile = Main.tile[origin]; + tile.HasTile = true; + tile.TileType = tileType; + var catalog = typeof(TownCrowdSystem).GetField("Places", BindingFlags.Static | BindingFlags.NonPublic)!.GetValue(null)!; + var markers = (System.Collections.IList)PlacesType.GetField(collection, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(catalog)!; + markers.Add(Activator.CreateInstance(PlacesType.GetNestedType("CrowdLandmark", BindingFlags.NonPublic)!, origin, (int)tileType, 1, 1)!); + } + + [TestMethod] + public void ReturningTravelerDoesNotReplaceMissingPylonWithSightseeing() { EnableJourneys(); ResetThreats(); @@ -37,12 +173,45 @@ public void SightseeingBudgetWaitPausesRetirementButKeepsActualExitFailures() var type = traveler.GetType(); type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Planning")); + for (int attempt = 0; attempt < 8; attempt++) + { + new TownCrowdSystem().PreUpdateNPCs(); + type.GetMethod("ChooseNextActivity", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, null); + Assert.AreEqual("Planning", type.GetProperty("Stage")!.GetValue(traveler)!.ToString()); + } + Assert.AreEqual(3, RetirementOf(traveler).DepartureFailures); + } + + [TestMethod] + public void ReturningTravelerKeepsWalkingInsteadOfPausingOrStartingConversation() + { + EnableJourneys(); + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.ReturnHome); + type.GetField("stageTime", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, 400); + type.GetField("route", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(traveler, Enumerable.Range(100, 11).Select(x => new Point(x, 63)).ToList()); + Assert.AreEqual(false, type.GetProperty("CanConverse", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); + type.GetMethod("MoveAlongRoute", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, [true]); + Assert.IsTrue(((Entity)type.GetProperty("Body")!.GetValue(traveler)!).velocity.X > 0); + } + + [TestMethod] + public void GuildFallbackBudgetWaitPausesRetirementButKeepsActualExitFailures() + { + EnableJourneys(); + ResetThreats(); + var traveler = CreateWalkingActor(new Vector2(1608, 1024)); + var type = traveler.GetType(); + type.GetProperty("Intent")!.SetValue(traveler, CrowdIntent.EnterGuild); + type.GetProperty("Stage")!.SetValue(traveler, Enum.Parse(type.GetProperty("Stage")!.PropertyType, "Planning")); typeof(TownCrowdSystem).GetField("planningBudget", BindingFlags.Static | BindingFlags.NonPublic)!.SetValue(null, 0); for (int attempt = 0; attempt < 3; attempt++) { type.GetMethod("ChooseNextActivity", BindingFlags.Instance | BindingFlags.NonPublic)!.Invoke(traveler, null); } - Assert.AreEqual(3, RetirementOf(traveler).DepartureFailures, "This plaza has no return pylon; each rejected return is a real failure."); + Assert.AreEqual(3, RetirementOf(traveler).DepartureFailures, "This plaza has no guild entrance; each rejected visit is a real failure."); Assert.AreEqual(true, type.GetField("waitingForBudget", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)); Assert.IsTrue((int)type.GetField("planDelay", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(traveler)! < 5); for (int tick = 0; tick < 3600; tick++) diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs index 2cd6b913e..403e56d26 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdLifeRules.cs @@ -5,16 +5,17 @@ public enum CrowdIntent VisitTown, ReturnHome, EnterGuild, + TakeTrain, } public static class CrowdLifeRules { - // Newly teleported arrivals may visit the guild, but never immediately return home. + // Pylon arrivals travel to town first; local activities begin after disembarking. public static CrowdIntent ChooseIntent(bool arrivingThroughPylon, bool leavingGuild, int roll, int returnPercent, int guildPercent) { if (arrivingThroughPylon) { - return roll < guildPercent ? CrowdIntent.EnterGuild : CrowdIntent.VisitTown; + return CrowdIntent.TakeTrain; } if (roll < returnPercent) { diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs index 29b1ed26b..5f48ceb1d 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Activities/CrowdPlaces.cs @@ -307,6 +307,21 @@ public IEnumerable Candidates(Vector2 from, bool departing) } } + public static CrowdIntent ArrivalIntent(Vector2 station) + { + // The train already knows its stop. Do not depend on the background landmark scan reaching it first. + var origin = new Point((int)MathF.Round(station.X / 16) - 2, (int)MathF.Round(station.Y / 16) - 5); + if (WorldGen.InWorld(origin.X, origin.Y)) + { + var tile = Main.tile[origin]; + if (tile.HasUnactuatedTile && tile.TileType == ModContent.TileType()) + { + return CrowdIntent.ReturnHome; + } + } + return CrowdIntent.VisitTown; + } + public static bool TryStation(Vector2 station, float fromX, out Point cell) { float side = fromX < station.X ? -1 : 1; diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs index 0d7e9cf50..43e923e75 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/Configuration/CrowdSettings.cs @@ -21,6 +21,9 @@ public sealed record CrowdSettings(int PopulationLimit, int SpawnIntervalTicks, public const float MovementProgressDistance = 16; public const int ChatRangeTiles = 4; public const int ChatChancePercent = 40; + public const int TransitDetourPercent = 5; + public const int TransitPauseMinTicks = 2 * 60; + public const int TransitPauseMaxTicks = 4 * 60; public const int ReturnPercent = 30; public const int GuildPercent = 30; public const int StreetWeight = 8; diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs index 9d9fca39c..5bbfea0fd 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/CrowdAgent.cs @@ -70,6 +70,7 @@ internal sealed class CrowdAgent private CrowdStage interruptedStage; private int interruptedStageTime; private long nextConversation; + private bool transitDetourUsed; private Vector2 previousViewPosition; private float animationDistance; @@ -121,12 +122,14 @@ public CrowdAgent(CrowdBody actor, List path, CrowdPlace destination, internal CrowdActivity? CurrentActivity => Stage == CrowdStage.Conversing ? CrowdActivity.Socialize : destination?.Activity; internal bool CanConverse => Active && !IsEvacuating && InteriorTrain == null && !jumping && !dropping + && (!HasTransitIntent || TransitDetourAvailable) && TownCrowdSystem.Clock >= nextConversation && CrowdTerrain.Grounded(Body) && (Stage is CrowdStage.Planning or CrowdStage.Walking || (Stage == CrowdStage.Activity && destination?.Activity is CrowdActivity.Scenery or CrowdActivity.Browse)); internal void PauseForConversation() { + transitDetourUsed |= HasTransitIntent; interruptedStage = Stage; interruptedStageTime = stageTime; Stage = CrowdStage.Conversing; @@ -193,6 +196,7 @@ internal void DrawInTrain(SpriteBatch spriteBatch) internal void BeginAlighting(List path, CrowdPlace landing) { + transitDetourUsed = false; Actor.ReleaseSupport(); destination = landing; SetRoute(path); @@ -260,6 +264,10 @@ public void Update() private bool WantsToLeave => departureRequested || Intent != CrowdIntent.VisitTown; + private bool HasTransitIntent => Intent is CrowdIntent.ReturnHome or CrowdIntent.TakeTrain; + + private bool TransitDetourAvailable => !transitDetourUsed && (uint)routeSeed % 100 < CrowdSettings.TransitDetourPercent; + private bool FollowingExit => Stage is CrowdStage.Walking or CrowdStage.Boarding or CrowdStage.Leaving or CrowdStage.Waiting && destination != null && CrowdActivityRules.IsExit(destination.Activity); @@ -657,7 +665,7 @@ private void ChooseNextActivity() // Finding a route alone cannot erase failures: the exit must actually make progress. retirement.RecordDepartureFailure(); } - if (!planned && (departing || Intent != CrowdIntent.VisitTown)) + if (!planned && !HasTransitIntent && (departing || Intent != CrowdIntent.VisitTown)) { // An unavailable exit is not a reason to freeze in view. Continue visiting and retry later. int fallbackBudget = TownCrowdSystem.PlanningBudget; @@ -818,7 +826,12 @@ bool Clear(Point cell) => CrowdTerrain.IsClear(cell) } if (Stage == CrowdStage.Walking && grounded && !dropping) { - if (stageTime % pauseInterval == 0) + if (HasTransitIntent && TransitDetourAvailable && stageTime >= 120) + { + transitDetourUsed = true; + pauseTime = Main.rand.Next(CrowdSettings.TransitPauseMinTicks, CrowdSettings.TransitPauseMaxTicks + 1); + } + else if (!HasTransitIntent && stageTime % pauseInterval == 0) { pauseTime = Main.rand.Next(20, 65); } diff --git a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs index 2125ba218..69086b139 100644 --- a/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs +++ b/Sources/Modules/Yggdrasil/Mechanics/TownCrowd/TownCrowdSystem.cs @@ -190,13 +190,14 @@ internal static bool TryPlan(Point start, CrowdAgent traveler, bool departing, o { CrowdIntent.ReturnHome => place.Activity == CrowdActivity.LeavePylon, CrowdIntent.EnterGuild => place.Activity == CrowdActivity.EnterGuild, + CrowdIntent.TakeTrain => place.Activity == CrowdActivity.BoardTrain, _ => true, }) .Where(place => place.Activity != CrowdActivity.EnterGuild || EntranceReady(place.Key)) .Select(place => Places.TryStandingPlace(place, Occupied, out var standingPlace) ? standingPlace : null) .Where(place => place != null && place.Key != exclude) .Select(place => (Place: place, Weight: CrowdActivityRules.Weight(place.Activity, departing, - traveler?.RecentlyVisited(place.Key) ?? false, + intent != CrowdIntent.TakeTrain && (traveler?.RecentlyVisited(place.Key) ?? false), Travelers.Count(other => other != traveler && other.Active && other.DestinationKey == place.Key), place.Capacity, Vector2.Distance(from, CrowdTerrain.Feet(place.Cell)), traveler?.Preference(place.Activity) ?? 1))) .Where(candidate => candidate.Weight > 0 && (departing || candidate.Place.Activity == CrowdActivity.Sit @@ -444,11 +445,13 @@ private static void DispatchTrainPassengers(YggdrasilTown_NormalMetro train) { return; } - if (!TryPlan(landing, null, false, out _, out _)) + var intent = CrowdPlaces.ArrivalIntent(station); + if (!PlanIntent(landing, passenger, intent, out _, out _)) { return; } var destination = new CrowdPlace(CrowdActivity.Scenery, landing, $"alight:{landing}", CrowdTerrain.Feet(landing)); + passenger.Intent = intent; passenger.BeginAlighting(path, destination); CrowdThreats.RecordArrival(CrowdTerrain.Feet(landing)); batch.RecordArrival();