diff --git a/.github/workflows/buildAndTest.yml b/.github/workflows/buildAndTest.yml index 6121cce..2a8965a 100644 --- a/.github/workflows/buildAndTest.yml +++ b/.github/workflows/buildAndTest.yml @@ -6,7 +6,7 @@ on: pull_request: branches: [ "main" ] jobs: - build: + buildAndTest: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -18,5 +18,41 @@ jobs: run: dotnet restore - name: Build run: dotnet build --no-restore - - name: Test - run: dotnet test --no-build --verbosity normal + - name: Test (with Coverage) + # Collect coverage using the built-in data collector (coverlet.collector must be referenced by each test project) + run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" + # Install ReportGenerator to turn Cobertura XML into HTML + text summary + - name: Install ReportGenerator tool + run: | + dotnet tool install --global dotnet-reportgenerator-globaltool + echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + # Generate HTML and Text summary reports + - name: Generate coverage report + run: | + reportgenerator \ + -reports:"**/TestResults/**/coverage.cobertura.xml" \ + -targetdir:"coveragereport" \ + -reporttypes:"Html;TextSummary" + + # Print the summary to the job log + - name: Print coverage summary + run: cat coveragereport/Summary.txt + + # Always upload the nice HTML report for inspection (even if threshold fails) + - name: Upload coverage report artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coveragereport + path: coveragereport + + # Fail the build if Line coverage < 50% + - name: Enforce minimum coverage (50% lines) + run: | + LINE_COV=$(grep -Po 'Line coverage:\s*\K[0-9.]+(?=%)' coveragereport/Summary.txt || echo 0) + echo "Detected line coverage: ${LINE_COV}%" + THRESHOLD=50 + awk -v a="$LINE_COV" -v b="$THRESHOLD" 'BEGIN { + printf("Minimum required: %s%%\n", b); + exit (a>=b)?0:1 + }' diff --git a/.gitignore b/.gitignore index ce89292..b4efcd1 100644 --- a/.gitignore +++ b/.gitignore @@ -416,3 +416,4 @@ FodyWeavers.xsd *.msix *.msm *.msp +/Roguelike.Core.Tests/coveragereport diff --git a/README.md b/README.md index ef934cd..a25d708 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,54 @@ # oneheim -A detailled roguelike in console + +A detailled roguelike in console. + +Explore the region : + +![image](screenshots/exploring.png) + +Discuss with NPCs : + +![image](screenshots/dialogue.png) + +Fights various enemies : + +![image](screenshots/combat.png) + +## How to run unit tests and show code coverage + +### 1. Run tests and collect data + +```powershell +cd Roguelike.Core.Tests +dotnet test --collect:"XPlat Code Coverage" +``` + +Coverage results will be saved under: `TestResults//coverage.cobertura.xml` + +### 2. Use ReportGenerator to create an HTML report + +```powershell +reportgenerator -reports:TestResults/**/coverage.cobertura.xml -targetdir:coveragereport +``` + +### 3. Open the report in your browser + +The report will be generated in `coveragereport/index.html` + +#### Windows + +```powershell +start coveragereport/index.html +``` + +#### macOS + +```bash +open coveragereport/index.html +``` + +#### Linux + +```bash +xdg-open coveragereport/index.html +``` diff --git a/Roguelike.Core.Tests/Fakes/FakeInventoryUI.cs b/Roguelike.Core.Tests/Fakes/FakeInventoryUI.cs index bf0e45b..123ac7f 100644 --- a/Roguelike.Core.Tests/Fakes/FakeInventoryUI.cs +++ b/Roguelike.Core.Tests/Fakes/FakeInventoryUI.cs @@ -7,10 +7,9 @@ namespace Roguelike.Core.Tests.Fakes; public sealed class FakeInventoryUI : IInventoryUI { - public int PromptDropIndex(Player player, Item newItem, GameSettings settings) - { - return 0; - } - public void Show(object player) { } + + private readonly int _dropIndex; + public FakeInventoryUI(int dropIndex) => _dropIndex = dropIndex; + public int PromptDropIndex(Player player, Item newItem, GameSettings settings) => _dropIndex; } diff --git a/Roguelike.Core.Tests/Fakes/FakeTreasurePicker.cs b/Roguelike.Core.Tests/Fakes/FakeTreasurePicker.cs index fde2c83..91dce68 100644 --- a/Roguelike.Core.Tests/Fakes/FakeTreasurePicker.cs +++ b/Roguelike.Core.Tests/Fakes/FakeTreasurePicker.cs @@ -4,8 +4,16 @@ namespace Roguelike.Core.Tests.Fakes; public sealed class FakeTreasurePicker : ITreasurePicker { + private readonly int _toReturn; + public TreasurePickerContext? LastContext { get; private set; } + public IReadOnlyList? LastViews { get; private set; } + + public FakeTreasurePicker(int toReturn) => _toReturn = toReturn; + public int Pick(TreasurePickerContext context, IReadOnlyList options) { - return 0; + LastContext = context; + LastViews = options; + return _toReturn; } } diff --git a/Roguelike.Core.Tests/Game/Characters/Enemies/EnemyTypeHelperTests.cs b/Roguelike.Core.Tests/Game/Characters/Enemies/EnemyTypeHelperTests.cs new file mode 100644 index 0000000..ca90363 --- /dev/null +++ b/Roguelike.Core.Tests/Game/Characters/Enemies/EnemyTypeHelperTests.cs @@ -0,0 +1,168 @@ +using Roguelike.Core.Game.Characters.Enemies; + +namespace Roguelike.Core.Tests.Game.Characters.Enemies; + +[TestClass] +public class EnemyTypeHelperTests +{ + [TestMethod] + public void GetRandomEnemyTypesBag_InvalidSize_Throws() + { + int enumCount = Enum.GetNames().Length; + + Assert.ThrowsException(() => + EnemyTypeHelper.GetRandomEnemyTypesBag(-1)); + + Assert.ThrowsException(() => + EnemyTypeHelper.GetRandomEnemyTypesBag(0)); + + Assert.ThrowsException(() => + EnemyTypeHelper.GetRandomEnemyTypesBag(enumCount)); + + Assert.ThrowsException(() => + EnemyTypeHelper.GetRandomEnemyTypesBag(enumCount + 1)); + } + + [TestMethod] + public void GetRandomEnemyTypesBag_ValidSize_ReturnsThatManyDistinctValidTypes() + { + int enumCount = Enum.GetNames().Length; + // valid size is any 1..(enumCount-1) + int size = Math.Max(1, enumCount - 2); + + var result = EnemyTypeHelper.GetRandomEnemyTypesBag(size); + + Assert.IsNotNull(result); + Assert.AreEqual(size, result.Count, "Returned bag does not match requested size."); + + // With current implementation, previously-picked types are excluded, + // so all items should be distinct. + Assert.AreEqual(size, result.Distinct().Count(), "Bag should not contain duplicates with current exclusion logic."); + + // All must be known/valid types present in the weights dictionary (and not Unknown). + var validTypes = EnemyTypeHelper.Weights.Keys.ToHashSet(); + foreach (var t in result) + { + Assert.AreNotEqual(EnemyType.Unknown, t, "Bag must not contain EnemyType.Unknown."); + Assert.IsTrue(validTypes.Contains(t), $"Bag contains an invalid EnemyType: {t}"); + } + } + + [TestMethod] + public void GetRandomEnemyTypesBag_NoImmediateDuplicates_AndNoUnknown() + { + int enumCount = Enum.GetNames().Length; + int size = Math.Max(2, enumCount - 1); // take the max valid size to stress the path + + var bag = EnemyTypeHelper.GetRandomEnemyTypesBag(size); + + // No immediate duplicates + for (int i = 1; i < bag.Count; i++) + { + Assert.AreNotEqual(bag[i - 1], bag[i], $"Immediate duplicate at indices {i - 1} and {i}"); + } + + // No Unknown + Assert.IsFalse(bag.Contains(EnemyType.Unknown), "Bag must not contain EnemyType.Unknown."); + } + + [TestMethod] + public void GetEnemyIdsByType_ReturnsExpectedLists() + { + // Undead + var undead = EnemyTypeHelper.GetEnemyIdsByType(EnemyType.Undead); + CollectionAssert.AreEquivalent( + new List + { + EnemyId.LeglessZombie, + EnemyId.Skeleton, + EnemyId.Zombie, + EnemyId.ArmoredZombie, + EnemyId.PlagueGhoul, + EnemyId.Revenant + }, + undead, + "Undead enemy IDs mismatch." + ); + + // Wild + var wild = EnemyTypeHelper.GetEnemyIdsByType(EnemyType.Wild); + CollectionAssert.AreEquivalent( + new List + { + EnemyId.WildLittleBear, + EnemyId.WildBear, + EnemyId.Wolf, + EnemyId.AlphaWolf, + EnemyId.GiantSpider, + EnemyId.Werewolf + }, + wild, + "Wild enemy IDs mismatch." + ); + + // Outlaws + var outlaws = EnemyTypeHelper.GetEnemyIdsByType(EnemyType.Outlaws); + CollectionAssert.AreEquivalent( + new List + { + EnemyId.Drunkard, + EnemyId.Pickpocket, + EnemyId.Brigand, + EnemyId.Mercenary, + EnemyId.WatchtowerArcher, + EnemyId.Assassin + }, + outlaws, + "Outlaws enemy IDs mismatch." + ); + + // Cultist + var cultist = EnemyTypeHelper.GetEnemyIdsByType(EnemyType.Cultist); + CollectionAssert.AreEquivalent( + new List + { + EnemyId.Novice, + EnemyId.Acolyte, + EnemyId.Cultist, + EnemyId.Zealot, + EnemyId.Priest, + EnemyId.Champion + }, + cultist, + "Cultist enemy IDs mismatch." + ); + + // Demon + var demon = EnemyTypeHelper.GetEnemyIdsByType(EnemyType.Demon); + CollectionAssert.AreEquivalent( + new List + { + EnemyId.Imp, + EnemyId.DemonSlave, + EnemyId.Hellhound, + EnemyId.Overseer, + EnemyId.HellObelisk, + EnemyId.DoomReaper + }, + demon, + "Demon enemy IDs mismatch." + ); + + // Default / unknown + var none = EnemyTypeHelper.GetEnemyIdsByType((EnemyType)999); + Assert.IsNotNull(none); + Assert.AreEqual(0, none.Count, "Unknown enemy type should return an empty list."); + } + + [TestMethod] + public void Weights_Definition_IsConsistent() + { + // Ensure that all weighted types are valid enum values (not Unknown) and weights are positive. + foreach (var kv in EnemyTypeHelper.Weights) + { + Assert.AreNotEqual(EnemyType.Unknown, kv.Key, "Weights should not include Unknown."); + Assert.IsTrue(kv.Value > 0, $"Weight for {kv.Key} should be positive."); + } + } +} diff --git a/Roguelike.Core.Tests/Game/Collectables/Items/ItemFactoryTests.cs b/Roguelike.Core.Tests/Game/Collectables/Items/ItemFactoryTests.cs new file mode 100644 index 0000000..ac0fcc4 --- /dev/null +++ b/Roguelike.Core.Tests/Game/Collectables/Items/ItemFactoryTests.cs @@ -0,0 +1,90 @@ +using Roguelike.Core.Game.Collectables.Items; + +namespace Roguelike.Core.Tests.Game.Collectables.Items +{ + [TestClass] + public class ItemFactoryTests + { + // Covers every switch arm with simple, data-driven cases. + [DataTestMethod] + [DynamicData(nameof(GetItemCases), DynamicDataSourceType.Method)] + public void CreateItem_KnownIds_ReturnsExpectedBasics(ItemId id, int expectedValue, int expectedInc) + { + // Act + var item = ItemFactory.CreateItem(id); + + // Assert + Assert.IsNotNull(item, "Factory returned null."); + Assert.AreEqual(id, item.Id, "Id should flow through unchanged."); + Assert.AreEqual(expectedValue, item.Value, $"Unexpected Value for {id}."); + Assert.AreEqual(expectedInc, item.UpgradableIncrementValue, $"Unexpected UpgradableIncrementValue for {id}."); + + // Don’t assert the exact localized strings; just ensure they’re present. + Assert.IsFalse(string.IsNullOrWhiteSpace(item.Name), "Name should be populated from i18n Messages."); + Assert.IsFalse(string.IsNullOrWhiteSpace(item.Effect), "Effect should be populated from i18n Messages."); + } + + // Targeted rarity checks for the items that explicitly set Rarity in the factory. + [DataTestMethod] + [DataRow(ItemId.TalismanOfTheLastBreath, ItemRarity.Rare)] + [DataRow(ItemId.HawkEye, ItemRarity.Uncommon)] + [DataRow(ItemId.FidelityCard, ItemRarity.Common)] + public void CreateItem_SpecificRarities_AreSet(ItemId id, ItemRarity expectedRarity) + { + var item = ItemFactory.CreateItem(id); + Assert.AreEqual(expectedRarity, item.Rarity, $"Unexpected rarity for {id}."); + } + + // Ensures the default branch is covered. + [TestMethod] + public void CreateItem_UnknownId_ThrowsArgumentException() + { + var unknown = (ItemId)999_999; + Assert.ThrowsException(() => ItemFactory.CreateItem(unknown)); + } + + // ---- Test data mapping (easy to maintain in one place) ---- + // Only asserts numbers (Value & UpgradableIncrementValue) so tests don't depend on resource text. + public static IEnumerable GetItemCases() + { + yield return Row(ItemId.DaggerLifeSteal, 1, 1); + yield return Row(ItemId.CapeOfInvisibility, 1, 1); + yield return Row(ItemId.GlassesOfClairvoyance, 1, 1); + yield return Row(ItemId.BootsOfEchoStep, 10, 5); + yield return Row(ItemId.TalismanOfTheLastBreath, 1, 0); + yield return Row(ItemId.ThornBreastplate, 1, 1); + yield return Row(ItemId.FeathersOfHope, 1, 1); + yield return Row(ItemId.RoyalGuardGauntlet, 10, 3); + yield return Row(ItemId.RoyalGuardShield, 10, 3); + yield return Row(ItemId.BerserkerNecklace, 30, 15); + yield return Row(ItemId.PaladinNecklace, 50, 10); + yield return Row(ItemId.HolyBible, 20, 10); + yield return Row(ItemId.SacredCrucifix, 20, 10); + yield return Row(ItemId.RingOfEndurance, 1, 1); + yield return Row(ItemId.BladeOfHeroes, 20, 10); + yield return Row(ItemId.ShieldOfChampion, 20, 10); + yield return Row(ItemId.FluteOfHunter, 20, 10); + yield return Row(ItemId.EngravedFangs, 20, 10); + yield return Row(ItemId.EnchantedPouch, 10, 10); + yield return Row(ItemId.SealOfWisdom, 10, 10); + yield return Row(ItemId.ProspectorKey, 4, 2); + yield return Row(ItemId.HawkEye, 40, 20); + yield return Row(ItemId.FidelityCard, 10, 10); + yield return Row(ItemId.TrollMushroom, 125, 25); + yield return Row(ItemId.OldGiantWoodenClub, 3, 1); + yield return Row(ItemId.LuckyMillorLeftHand, 10, 5); + yield return Row(ItemId.GrolMokbarRing, 5, 5); + yield return Row(ItemId.TalismanOfPeace, 4, 4); + yield return Row(ItemId.SealOfLivingFlesh, 2, 2); + yield return Row(ItemId.StopWatch, 15, -1); + yield return Row(ItemId.SauerkrautEffigy, 20, 10); + yield return Row(ItemId.ButchersThornChaplet, 20, 10); + yield return Row(ItemId.NordheimWatcherLantern, 20, 10); + yield return Row(ItemId.ArbalestOfTheKingsValley, 20, 10); + yield return Row(ItemId.LightningAmulet, 20, 10); + yield return Row(ItemId.HolyWater, 20, 10); + } + + private static object[] Row(ItemId id, int value, int inc) => new object[] { id, value, inc }; + } +} diff --git a/Roguelike.Core.Tests/Game/Collectables/TreasureSelectorTests.cs b/Roguelike.Core.Tests/Game/Collectables/TreasureSelectorTests.cs new file mode 100644 index 0000000..1afbd64 --- /dev/null +++ b/Roguelike.Core.Tests/Game/Collectables/TreasureSelectorTests.cs @@ -0,0 +1,256 @@ +using Roguelike.Core.Configuration; +using Roguelike.Core.Game.Characters.Players; +using Roguelike.Core.Game.Collectables; +using Roguelike.Core.Game.Collectables.Items; +using Roguelike.Core.Tests.Fakes; + +namespace Roguelike.Core.Tests.Game.Collectables; + +[TestClass] +public class TreasureSelectorTests +{ + [TestMethod] + public void BuildOptionViews_Item_UsesNextRarityAndFormatsDescription() + { + var baseItem = ItemFactory.CreateItem(ItemId.HolyWater); + var existing = new Item + { + Id = baseItem.Id, + Name = baseItem.Name, + Effect = baseItem.Effect, + Value = baseItem.Value, + UpgradableIncrementValue = baseItem.UpgradableIncrementValue, + Rarity = ItemRarity.Common + }; + + var p = NewPlayer(items: new[] { existing }); + var choices = new[] + { + new Treasure { Type = BonusType.Item, Value = (int)ItemId.HolyWater } + }; + + var views = TreasureSelector.BuildOptionViews(choices, p); + + Assert.AreEqual(1, views.Count); + Assert.AreEqual(BonusType.Item, views[0].Treasure.Type); + // When item already owned & upgradable, rarity shown to user is next step: + Assert.AreEqual(ItemRarity.Uncommon, views[0].Rarity); + // Don’t pin to exact i18n text; just ensure it mentions the item name. + StringAssert.Contains(views[0].Description, baseItem.Name); + } + + [TestMethod] + public void BuildOptionViews_VisionAndStats_MapToExpectedRarities() + { + var p = NewPlayer(maxLife: 200); + + var choices = new[] + { + new Treasure { Type = BonusType.Vision, Value = 1 }, // Common + new Treasure { Type = BonusType.Vision, Value = 2 }, // Uncommon + new Treasure { Type = BonusType.Vision, Value = 3 }, // Rare + new Treasure { Type = BonusType.Strength, Value = 1 }, // Broken + new Treasure { Type = BonusType.Speed, Value = 2 }, // Common + new Treasure { Type = BonusType.Armor, Value = 4 }, // Rare + }; + + var views = TreasureSelector.BuildOptionViews(choices, p); + + Assert.AreEqual(ItemRarity.Common, views[0].Rarity); + Assert.AreEqual(ItemRarity.Uncommon, views[1].Rarity); + Assert.AreEqual(ItemRarity.Rare, views[2].Rarity); + Assert.AreEqual(ItemRarity.Broken, views[3].Rarity); + Assert.AreEqual(ItemRarity.Common, views[4].Rarity); + Assert.AreEqual(ItemRarity.Rare, views[5].Rarity); + } + + [TestMethod] + public void BuildOptionViews_MaxHp_RarityBasedOnRatio() + { + var p = NewPlayer(maxLife: 100); + var choices = new[] + { + new Treasure { Type = BonusType.MaxLifePoint, Value = 4 }, + new Treasure { Type = BonusType.MaxLifePoint, Value = 7 }, + new Treasure { Type = BonusType.MaxLifePoint, Value = 14 }, + new Treasure { Type = BonusType.MaxLifePoint, Value = 17 }, + new Treasure { Type = BonusType.MaxLifePoint, Value = 20 }, + }; + + var views = TreasureSelector.BuildOptionViews(choices, p) + .Select(v => v.Rarity).ToList(); + + CollectionAssert.AreEqual( + new List { + ItemRarity.Broken, ItemRarity.Common, ItemRarity.Uncommon, ItemRarity.Rare, ItemRarity.Epic + }, views); + } + + + [TestMethod] + public void GenerateBonusChoices_AddsDominantStatFocus_AndRespectsFilters() + { + // Dominant Strength (>10 and greater than others) should force a stat focus entry. + var p = NewPlayer(life: 20, maxLife: 100, strength: 15, armor: 5, speed: 4, vision: 9, steps: 0); + var settings = new GameSettings(); + + var choices = TreasureSelector.GenerateBonusChoices(p, settings); + + Assert.IsTrue(choices.Count > 0, "Should produce at least one choice."); + Assert.IsTrue(choices.Any(t => t.Type == BonusType.Strength), + "Dominant Strength should lead to a Strength-focused bonus being added."); + // Vision is high; generator may remove Vision from candidate types. Not asserting its absence (random), + // but we at least ensure there are no more than 3 options as per spec. + Assert.IsTrue(choices.Count <= 3); + } + + + [TestMethod] + public void ChooseWithPicker_OutOfRangeIndex_FallsBackToZero_AndReturnsAChoice() + { + var p = NewPlayer(life: 1, maxLife: 100, strength: 12, armor: 1, speed: 1, vision: 0); + var settings = new GameSettings(); + var picker = new FakeTreasurePicker(toReturn: 999); // invalid index + + var chosen = TreasureSelector.ChooseWithPicker(p, settings, picker); + + Assert.IsTrue(Enum.IsDefined(typeof(BonusType), chosen.Type), + "Returned treasure should be valid even when picker returns an out-of-range index."); + Assert.IsNotNull(picker.LastContext); + Assert.IsNotNull(picker.LastViews); + // Title comes from i18n; just ensure it's non-empty. + Assert.IsFalse(string.IsNullOrWhiteSpace(picker.LastContext!.Title)); + Assert.IsTrue(picker.LastViews!.Count >= 1 && picker.LastViews!.Count <= 3); + } + + [TestMethod] + public void ApplyBonus_Item_New_AddsToInventory_AndMayAffectPlayerStats() + { + var p = NewPlayer(vision: 0, items: Array.Empty()); + var settings = new GameSettings(); + + // Pick an item with a special stat side-effect (GlassesOfClairvoyance sets Vision to item.Value if higher) + var itemId = ItemId.GlassesOfClairvoyance; + var msg = TreasureSelector.ApplyBonus( + new Treasure { Type = BonusType.Item, Value = (int)itemId }, p, settings, ui: new FakeInventoryUI(0)); + + Assert.IsTrue(p.Inventory.Any(i => i.Id == itemId)); + // Vision should be at least the item's value after picking it. + var picked = p.Inventory.First(i => i.Id == itemId); + Assert.IsTrue(p.Vision >= picked.Value); + // Message contains item name (don’t assert full i18n) + StringAssert.Contains(msg, picked.Name); + } + + [TestMethod] + public void ApplyBonus_Item_ExistingUpgradable_IncreasesValueAndRarity() + { + var baseItem = ItemFactory.CreateItem(ItemId.EnchantedPouch); + var existing = new Item + { + Id = baseItem.Id, + Name = baseItem.Name, + Effect = baseItem.Effect, + Value = baseItem.Value, + UpgradableIncrementValue = baseItem.UpgradableIncrementValue, // > 0 (upgradable) + Rarity = ItemRarity.Common + }; + + var p = NewPlayer(items: new[] { existing }); + var settings = new GameSettings(); + + var msg = TreasureSelector.ApplyBonus( + new Treasure { Type = BonusType.Item, Value = (int)existing.Id }, p, settings, ui: new FakeInventoryUI(0)); + + var upgraded = p.Inventory.First(i => i.Id == existing.Id); + Assert.AreEqual(baseItem.Value + baseItem.UpgradableIncrementValue, upgraded.Value); + Assert.AreEqual(ItemRarity.Common + 1, upgraded.Rarity); + StringAssert.Contains(msg, baseItem.Name); + } + + [TestMethod] + public void ApplyBonus_Item_ExistingNotUpgradable_ReturnsAlreadyOwnedMessage() + { + var baseItem = ItemFactory.CreateItem(ItemId.TalismanOfTheLastBreath); + Assert.AreEqual(0, baseItem.UpgradableIncrementValue, "Test expects a non-upgradable item."); + + var existing = new Item + { + Id = baseItem.Id, + Name = baseItem.Name, + Effect = baseItem.Effect, + Value = baseItem.Value, + UpgradableIncrementValue = 0, + Rarity = baseItem.Rarity + }; + + var p = NewPlayer(items: new[] { existing }); + var settings = new GameSettings(); + + var msg = TreasureSelector.ApplyBonus( + new Treasure { Type = BonusType.Item, Value = (int)existing.Id }, p, settings, ui: new FakeInventoryUI(0)); + + // Inventory unchanged, message indicates already owned non-upgradable + Assert.AreEqual(1, p.Inventory.Count); + StringAssert.Contains(msg, baseItem.Name); + } + + [TestMethod] + public void ApplyBonus_Item_FullInventory_DropReplacesItem() + { + var settings = new GameSettings(); + + // 3 items already (full) + var i1 = ItemFactory.CreateItem(ItemId.HolyBible); + var i2 = ItemFactory.CreateItem(ItemId.SacredCrucifix); + var i3 = ItemFactory.CreateItem(ItemId.RingOfEndurance); + var p = NewPlayer(items: new[] { i1, i2, i3 }); + + var incoming = ItemId.BootsOfEchoStep; + + // UI chooses to drop index 0 + var msg = TreasureSelector.ApplyBonus( + new Treasure { Type = BonusType.Item, Value = (int)incoming }, p, settings, ui: new FakeInventoryUI(0)); + + Assert.AreEqual(3, p.Inventory.Count); + Assert.IsFalse(p.Inventory.Any(i => i.Id == i1.Id), "Index 0 should be dropped."); + Assert.IsTrue(p.Inventory.Any(i => i.Id == incoming), "New item should be added."); + } + + [TestMethod] + public void ApplyBonus_Item_FullInventory_KeepCurrent() + { + var settings = new GameSettings(); + + var i1 = ItemFactory.CreateItem(ItemId.HolyBible); + var i2 = ItemFactory.CreateItem(ItemId.SacredCrucifix); + var i3 = ItemFactory.CreateItem(ItemId.RingOfEndurance); + var p = NewPlayer(items: new[] { i1, i2, i3 }); + + var incoming = ItemId.HawkEye; + + // UI returns an out-of-range drop index -> keep current inventory + var msg = TreasureSelector.ApplyBonus( + new Treasure { Type = BonusType.Item, Value = (int)incoming }, p, settings, ui: new FakeInventoryUI(dropIndex: 99)); + + Assert.AreEqual(3, p.Inventory.Count); + Assert.IsFalse(p.Inventory.Any(i => i.Id == incoming)); + } + + private static Player NewPlayer( + int life = 50, int maxLife = 100, int strength = 1, int armor = 1, int speed = 1, int vision = 0, int steps = 0, + IEnumerable? items = null) + { + return new Player + { + LifePoint = life, + MaxLifePoint = maxLife, + Strength = strength, + Armor = armor, + Speed = speed, + Vision = vision, + Steps = steps, + Inventory = items?.ToList() ?? new List() + }; + } +} diff --git a/Roguelike.Core.Tests/Game/GameLoop/GameEngineTests.cs b/Roguelike.Core.Tests/Game/GameLoop/GameEngineTests.cs index bfc7a79..1e40fbe 100644 --- a/Roguelike.Core.Tests/Game/GameLoop/GameEngineTests.cs +++ b/Roguelike.Core.Tests/Game/GameLoop/GameEngineTests.cs @@ -23,8 +23,8 @@ private static GameEngine CreateEngine(out FakeRenderer renderer, out FakeClock clock, new FakeCombatRenderer(), new FakeDialogueRenderer(), - new FakeTreasurePicker(), - new FakeInventoryUI()); + new FakeTreasurePicker(0), + new FakeInventoryUI(0)); return engine; } diff --git a/Roguelike.Core.Tests/Game/Levels/EnenyBagsTests.cs b/Roguelike.Core.Tests/Game/Levels/EnenyBagsTests.cs new file mode 100644 index 0000000..9965e15 --- /dev/null +++ b/Roguelike.Core.Tests/Game/Levels/EnenyBagsTests.cs @@ -0,0 +1,114 @@ +using Roguelike.Core.Game.Characters.Enemies; +using Roguelike.Core.Game.Levels; + +namespace Roguelike.Core.Tests.Game.Levels; + +[TestClass] +public class EnemyBagsTests +{ + [DataTestMethod] + [DataRow(0)] + [DataRow(16)] + [DataRow(int.MinValue)] + [DataRow(int.MaxValue)] + public void GetByLevelAndType_InvalidLevel_Throws(int level) + { + Assert.ThrowsException(() => + EnemyBags.GetByLevelAndType(level, new List())); + } + + [TestMethod] + public void GetByLevelAndType_EmptyTypes_ReturnsEmpty() + { + var result = EnemyBags.GetByLevelAndType(1, new List()); + Assert.AreEqual(0, result.Count); + } + + [DataTestMethod] + [DynamicData(nameof(AllLevelIndices), DynamicDataSourceType.Method)] + public void GetByLevelAndType_AllTypes_ReturnsExactLevelBag(int level) + { + var allTypes = Enum.GetValues(typeof(EnemyType)).Cast().ToList(); + + var expected = EnemyBags.Levels[level - 1]; + var actual = EnemyBags.GetByLevelAndType(level, allTypes); + + AssertDictionariesEqual(expected, actual, $"Level {level} bag mismatch."); + } + + // Uses a few individual types (and a combo) to ensure filtering works and counts match. + [DataTestMethod] + [DynamicData(nameof(SomeTypeSelections), DynamicDataSourceType.Method)] + public void GetByLevelAndType_FiltersByType_IntersectionOnly(int level, List selectedTypes) + { + // Build expected via the same helper (keeps tests resilient to future mapping changes). + var allowedIds = selectedTypes + .SelectMany(EnemyTypeHelper.GetEnemyIdsByType) + .ToHashSet(); + + var levelBag = EnemyBags.Levels[level - 1]; + + var expected = levelBag + .Where(kv => allowedIds.Contains(kv.Key)) + .ToDictionary(kv => kv.Key, kv => kv.Value); + + var actual = EnemyBags.GetByLevelAndType(level, selectedTypes); + + AssertDictionariesEqual(expected, actual, $"Level {level} filtering mismatch."); + } + + private static IEnumerable AllLevelIndices() + { + // levels are 1..15 + for (int i = 1; i <= 15; i++) + yield return new object[] { i }; + } + + private static IEnumerable SomeTypeSelections() + { + // Pick up to 3 concrete EnemyType values if the enum has that many. + var all = Enum.GetValues(typeof(EnemyType)).Cast().ToList(); + + if (all.Count == 0) + { + // If the enum is empty (unlikely), still run a no-op case to keep coverage stable. + yield return new object[] { 1, new List() }; + yield break; + } + + // Single type (first) + yield return new object[] { 1, new List { all[0] } }; + + // If available: second single type at a different level + if (all.Count >= 2) + yield return new object[] { 8, new List { all[1] } }; + + // If available: third single type at another level + if (all.Count >= 3) + yield return new object[] { 12, new List { all[2] } }; + + // A combo of the first few types to test union behavior + var take = Math.Min(3, all.Count); + yield return new object[] { 9, all.Take(take).ToList() }; + } + + private static void AssertDictionariesEqual( + IReadOnlyDictionary expected, + IReadOnlyDictionary actual, + string? message = null) + { + Assert.AreEqual(expected.Count, actual.Count, $"Count differs. {message}"); + + foreach (var (id, count) in expected) + { + Assert.IsTrue(actual.ContainsKey(id), $"Missing id: {id}. {message}"); + Assert.AreEqual(count, actual[id], $"Count mismatch for {id}. {message}"); + } + + // Ensure no extra keys slipped in + foreach (var id in actual.Keys) + { + Assert.IsTrue(expected.ContainsKey(id), $"Unexpected id: {id}. {message}"); + } + } +} diff --git a/Roguelike.Core.Tests/Game/Systems/Logics/WaveAndFogSystemTests.cs b/Roguelike.Core.Tests/Game/Systems/Logics/WaveAndFogSystemTests.cs index 02d01dd..7bdd525 100644 --- a/Roguelike.Core.Tests/Game/Systems/Logics/WaveAndFogSystemTests.cs +++ b/Roguelike.Core.Tests/Game/Systems/Logics/WaveAndFogSystemTests.cs @@ -35,8 +35,8 @@ private static (WaveAndFogSystem sys, TurnContext ctx, LevelManager level, Playe settings, new FakeCombatRenderer(), new FakeDialogueRenderer(), - new FakeTreasurePicker(), - new FakeInventoryUI()); + new FakeTreasurePicker(0), + new FakeInventoryUI(0)); var ctx = new TurnContext(level, settings, diff); var sys = new WaveAndFogSystem(playerController); diff --git a/Roguelike.Core.Tests/Roguelike.Core.Tests.csproj b/Roguelike.Core.Tests/Roguelike.Core.Tests.csproj index 99b5259..f22f18a 100644 --- a/Roguelike.Core.Tests/Roguelike.Core.Tests.csproj +++ b/Roguelike.Core.Tests/Roguelike.Core.Tests.csproj @@ -8,8 +8,24 @@ true + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + diff --git a/Roguelike.Core/Game/Characters/Enemies/EnemyManager.cs b/Roguelike.Core/Game/Characters/Enemies/EnemyManager.cs index 230ebb0..86ce97f 100644 --- a/Roguelike.Core/Game/Characters/Enemies/EnemyManager.cs +++ b/Roguelike.Core/Game/Characters/Enemies/EnemyManager.cs @@ -1,6 +1,5 @@ using Roguelike.Core.Game.Abstractions; using Roguelike.Core.Game.Characters.Moves; -using Roguelike.Core.Game.Characters.Players; using Roguelike.Core.Game.Collectables.Items; using Roguelike.Core.Game.Combat; using Roguelike.Core.Game.Levels; diff --git a/Roguelike.Core/Game/Characters/Enemies/EnemyType.cs b/Roguelike.Core/Game/Characters/Enemies/EnemyType.cs index f009bf8..abd4576 100644 --- a/Roguelike.Core/Game/Characters/Enemies/EnemyType.cs +++ b/Roguelike.Core/Game/Characters/Enemies/EnemyType.cs @@ -1,8 +1,13 @@ namespace Roguelike.Core.Game.Characters.Enemies; +/// +/// Represents the various types of enemies encountered in the game. +/// +/// This enumeration categorizes enemies into distinct types, which can be used to determine their +/// behavior, strengths, weaknesses, or other gameplay mechanics. public enum EnemyType { - Unknown, + Unknown, // Default value, should not be used in practice. Undead, Wild, Outlaws, diff --git a/Roguelike.Core/Game/Characters/Enemies/EnemyTypeHelper.cs b/Roguelike.Core/Game/Characters/Enemies/EnemyTypeHelper.cs index 85fb8a8..1c8637f 100644 --- a/Roguelike.Core/Game/Characters/Enemies/EnemyTypeHelper.cs +++ b/Roguelike.Core/Game/Characters/Enemies/EnemyTypeHelper.cs @@ -25,8 +25,9 @@ public static class EnemyTypeHelper /// name="size"/> is less than or equal to 0, an empty list is returned. public static List GetRandomEnemyTypesBag(int size) { - if (size <= 0) return new List(); - + if (size <= 0 || size >= Enum.GetNames().Length) + throw new ArgumentOutOfRangeException($"size out of range in GetRandomEnemyTypesBag. Value={size}"); + // On construit une "liste pondérée" avec répétitions virtuelles var weightedList = new List(); foreach (var kv in Weights) @@ -116,6 +117,8 @@ public static List GetEnemyIdsByType(EnemyType type) private static EnemyType GetWeightedRandom(List weightedList, List excluded) { var filtered = weightedList.Where(t => !excluded.Contains(t)).ToList(); + if (filtered.Count == 0) return EnemyType.Unknown; + int index = _random.Next(filtered.Count); return filtered[index]; } diff --git a/Roguelike.Core/Game/Collectables/TreasureSelector.cs b/Roguelike.Core/Game/Collectables/TreasureSelector.cs index c0fcdab..7587fa5 100644 --- a/Roguelike.Core/Game/Collectables/TreasureSelector.cs +++ b/Roguelike.Core/Game/Collectables/TreasureSelector.cs @@ -1,7 +1,7 @@ namespace Roguelike.Core.Game.Collectables; using Roguelike.Core.Configuration; -using Roguelike.Core.Extensions; +using Roguelike.Core.Utils; using Roguelike.Core.Game.Abstractions; using Roguelike.Core.Game.Characters.Players; using Roguelike.Core.Game.Collectables.Items; diff --git a/Roguelike.Core/Extensions/RandomExtension.cs b/Roguelike.Core/Utils/RandomExtension.cs similarity index 82% rename from Roguelike.Core/Extensions/RandomExtension.cs rename to Roguelike.Core/Utils/RandomExtension.cs index 7ca718f..988c6e5 100644 --- a/Roguelike.Core/Extensions/RandomExtension.cs +++ b/Roguelike.Core/Utils/RandomExtension.cs @@ -1,5 +1,8 @@ -namespace Roguelike.Core.Extensions; +namespace Roguelike.Core.Utils; +/// +/// Add extension methods to the Random class. +/// public static class RandomExtensions { public static T NextWeighted(this Random random, Dictionary weights) @@ -18,7 +21,7 @@ public static T NextWeighted(this Random random, Dictionary weights) return kvp.Key; } - // Ne devrait jamais arriver + // Should never reach here throw new InvalidOperationException("Erreur lors du tirage pondéré"); } } diff --git a/Screenshots/combat.png b/Screenshots/combat.png new file mode 100644 index 0000000..ffa5849 Binary files /dev/null and b/Screenshots/combat.png differ diff --git a/Screenshots/dialogue.png b/Screenshots/dialogue.png new file mode 100644 index 0000000..be4916a Binary files /dev/null and b/Screenshots/dialogue.png differ diff --git a/Screenshots/exploring.png b/Screenshots/exploring.png new file mode 100644 index 0000000..add6b99 Binary files /dev/null and b/Screenshots/exploring.png differ