From a428ef5f5dbc28e1af86dc831c9995ff79bb1791 Mon Sep 17 00:00:00 2001 From: initsu Date: Wed, 29 Jul 2026 00:06:39 +0200 Subject: [PATCH 1/5] Fix boss HP bar divisors for vanilla HP --- RandomizerCore/Hyrule.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index 2ed56125f..6f112ad13 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -3849,11 +3849,7 @@ private void ApplyAsmPatches(RandomizerProperties props, Assembler engine, Rando rom.FixBigBubbleSplit(engine, randomizedStats); StatTracking(props, engine); AddCredits(engine); - - if (props.ShuffleBossHP != EnemyLifeOption.VANILLA) - { - rom.SetBossHpBarDivisors(engine, randomizedStats); - } + rom.SetBossHpBarDivisors(engine, randomizedStats); if (props.DripperEnemyOption != DripperEnemyOption.ONLY_BOTS) { From 9cb27d17022a18710dca9eb204b22b7e88cdd1a8 Mon Sep 17 00:00:00 2001 From: initsu Date: Tue, 28 Jul 2026 13:56:27 +0200 Subject: [PATCH 2/5] Fix Valley of Death generation bugs --- RandomizerCore/Overworld/EastHyrule.cs | 19 ++++++++++++++++++- RandomizerCore/Overworld/World.cs | 24 +++++++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/RandomizerCore/Overworld/EastHyrule.cs b/RandomizerCore/Overworld/EastHyrule.cs index 3da2276b0..a762965bd 100644 --- a/RandomizerCore/Overworld/EastHyrule.cs +++ b/RandomizerCore/Overworld/EastHyrule.cs @@ -1662,12 +1662,29 @@ private void DrawMountains(bool useRiverDevil) } } - protected override bool IsReserved(IntVector2 pos) + /// If this tile is reserved, in the context of a river expanding to + /// this tile. Most of these are probably logically impossible. + /// The point is to stop a river opening up VoD. + protected override bool IsReserved(IntVector2 pos, Terrain terrain) { + switch (terrain) + { + case Terrain.TOWN: + case Terrain.CAVE: + case Terrain.PALACE: + case Terrain.BRIDGE: + case Terrain.LAVA: + case Terrain.MOUNTAIN: + case Terrain.ROCK: + case Terrain.RIVER_DEVIL: + return true; + } + if ((locationAtGP.Pos - pos).Abs().MinComponent() < 4) { return true; } + return false; } diff --git a/RandomizerCore/Overworld/World.cs b/RandomizerCore/Overworld/World.cs index 50ed779b7..0d3faa1af 100644 --- a/RandomizerCore/Overworld/World.cs +++ b/RandomizerCore/Overworld/World.cs @@ -2190,7 +2190,7 @@ IntVector2 AdjustAndPaint(IntVector2 pos, IntVector2 delta, IntVector2 sideDir) int maxAdj = (sideDir.X != 0 && pos.X == MapColumns - 2) || (sideDir.Y != 0 && pos.Y == MapRows - 2) ? 0 : 1; int adjust = RNG.Next(minAdj, maxAdj + 1); IntVector2 adjusted = pos + adjust * sideDir; - if (WithinMapBounds(adjusted) && !IsReserved(adjusted)) + if (WithinMapBounds(adjusted) && !IsReserved(adjusted, map[adjusted])) { map[adjusted] = water; } @@ -2536,7 +2536,7 @@ protected bool VerticalCave(int caveDirection, int centerX, int centerY, Locatio } // subset of walkable terrains that we would expect to be next to a trap tile - protected static readonly FrozenSet TRAP_PATH_VALID_TERRAIN = [Terrain.DESERT, Terrain.GRASS, Terrain.ROAD, Terrain.LAVA, Terrain.NONE]; + protected static readonly FrozenSet TRAP_PATH_VALID_TERRAIN = [Terrain.DESERT, Terrain.GRASS, Terrain.ROAD, Terrain.LAVA]; protected static readonly FrozenSet TRAP_PATH_BLOCKING_TERRAIN = [Terrain.MOUNTAIN, Terrain.WATER]; /// @@ -2610,8 +2610,22 @@ public void PlaceCaveBlocker(Location cave, IntVector2 dir, Terrain blockerTerra cave.Pos = newCavePos; } - protected virtual bool IsReserved(IntVector2 pos) + /// If this tile is reserved, in the context of a river expanding to + /// this tile. Most of these are probably logically impossible. + /// The point is to stop a river opening up VoD. + /// (This method is overridden in EastHyrule) + protected virtual bool IsReserved(IntVector2 pos, Terrain terrain) { + switch (terrain) + { + case Terrain.TOWN: + case Terrain.CAVE: + case Terrain.PALACE: + case Terrain.BRIDGE: + case Terrain.ROCK: + return true; + } + return false; } @@ -2628,8 +2642,8 @@ protected virtual bool IsReserved(IntVector2 pos) /// public IntVector2? ValidTrapTilePosition(IntVector2 pos) { - bool isPassable(IntVector2 pos) => TRAP_PATH_VALID_TERRAIN.Contains(map[pos.Y, pos.X]); - bool isBlocking(IntVector2 pos) => TRAP_PATH_BLOCKING_TERRAIN.Contains(map[pos.Y, pos.X]); + bool isPassable(IntVector2 pos) => TRAP_PATH_VALID_TERRAIN.Contains(map[pos]); + bool isBlocking(IntVector2 pos) => TRAP_PATH_BLOCKING_TERRAIN.Contains(map[pos]); if (!WithinMapBounds(pos, 1)) { return null; } if (!isPassable(pos)) { return null; } From 83503618f642d258ebe3358c7286d6614ef14cff Mon Sep 17 00:00:00 2001 From: initsu Date: Thu, 16 Jul 2026 11:09:15 +0200 Subject: [PATCH 3/5] Remove Vanilla-Weighted from GP styles - It doesn't do much differently from Random Walk in GP (just very slightly more vertical.) --- RandomizerCore/EnumTypes.cs | 22 ++++++++++++++++++++-- RandomizerCore/RandomizerConfiguration.cs | 6 +++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/RandomizerCore/EnumTypes.cs b/RandomizerCore/EnumTypes.cs index 1e079e5c8..8f616734f 100644 --- a/RandomizerCore/EnumTypes.cs +++ b/RandomizerCore/EnumTypes.cs @@ -274,6 +274,24 @@ public static bool IsCoordinateBased(this PalaceStyle style) _ => false }; } + public static bool NormalPalaceStyle(this PalaceStyle style) + { + return style switch + { + PalaceStyle.RANDOM => false, + _ => true + }; +} + public static bool IsGpStyle(this PalaceStyle style) + { + return style switch + { + PalaceStyle.VANILLA_WEIGHTED => false, + PalaceStyle.RANDOM_ALL => false, + PalaceStyle.RANDOM_PER_PALACE => false, + _ => true + }; + } } public enum PalaceDropStyle @@ -976,9 +994,9 @@ public static class Enums public static IEnumerable PalaceLengthOptionList { get; } = ToDescriptions(); public static IEnumerable PalaceItemRoomCountOptions { get; } = ToDescriptions(); public static IEnumerable NormalPalaceStyleList { get; } - = ToDescriptions(i => i != PalaceStyle.RANDOM); + = ToDescriptions(i => i.NormalPalaceStyle()); public static IEnumerable GpPalaceStyleList { get; } - = ToDescriptions(i => i != PalaceStyle.RANDOM_PER_PALACE && i != PalaceStyle.RANDOM_ALL); + = ToDescriptions(i => i.IsGpStyle()); public static IEnumerable BossRoomsExitTypeList { get; } = ToDescriptions(); public static IEnumerable PalaceDropStyleList { get; } = ToDescriptions(); diff --git a/RandomizerCore/RandomizerConfiguration.cs b/RandomizerCore/RandomizerConfiguration.cs index 225398a0b..129809ac0 100644 --- a/RandomizerCore/RandomizerConfiguration.cs +++ b/RandomizerCore/RandomizerConfiguration.cs @@ -931,7 +931,7 @@ public RandomizerProperties Export(Random r, bool includeDifficulty = true) if(GpStyle.IsMetastyle()) { Debug.Assert(GpStyle == PalaceStyle.RANDOM); - allowedPalaceStyles = Enums.GetShufflableList(); + allowedPalaceStyles = [.. Enums.GetShufflableList().Where(i => i.IsGpStyle())]; if (!randomStylesAllowVanilla) { allowedPalaceStyles.RemoveAll(i => i.UsesVanillaRoomPool()); @@ -946,8 +946,8 @@ public RandomizerProperties Export(Random r, bool includeDifficulty = true) if (NormalPalaceStyle.IsMetastyle()) { - Debug.Assert(NormalPalaceStyle != PalaceStyle.RANDOM); - allowedPalaceStyles = Enums.GetShufflableList(); + Debug.Assert(NormalPalaceStyle == PalaceStyle.RANDOM_PER_PALACE || NormalPalaceStyle == PalaceStyle.RANDOM_ALL); + allowedPalaceStyles = [.. Enums.GetShufflableList().Where(i => i.NormalPalaceStyle())]; if (!randomStylesAllowVanilla) { allowedPalaceStyles.RemoveAll(i => i.UsesVanillaRoomPool()); From b42c2ba1548ebb209548c33c23dcbd154e5c9e8d Mon Sep 17 00:00:00 2001 From: initsu Date: Mon, 20 Jul 2026 15:02:36 +0200 Subject: [PATCH 4/5] Add RoomPool tests and fix errors found --- RandomizerCore/Overworld/Climate.cs | 2 +- RandomizerCore/Sidescroll/PalaceRooms.cs | 133 ++++-- RandomizerCore/Sidescroll/RoomPool.cs | 85 +++- RandomizerCore/WeightedRandom.cs | 8 +- Tests/RoomPoolTests.cs | 544 +++++++++++++++++++++++ 5 files changed, 713 insertions(+), 59 deletions(-) create mode 100644 Tests/RoomPoolTests.cs diff --git a/RandomizerCore/Overworld/Climate.cs b/RandomizerCore/Overworld/Climate.cs index 97d6da016..0a142e5d6 100644 --- a/RandomizerCore/Overworld/Climate.cs +++ b/RandomizerCore/Overworld/Climate.cs @@ -66,7 +66,7 @@ public IEnumerable RandomTerrains(IEnumerable filter) public void DisallowTerrain(Terrain terrain) { - weightedSampler = weightedSampler.Subtract(terrain); + weightedSampler = weightedSampler.Subtract(terrain)!; } public Climate Clone() diff --git a/RandomizerCore/Sidescroll/PalaceRooms.cs b/RandomizerCore/Sidescroll/PalaceRooms.cs index 10f32d2cc..5fee52593 100644 --- a/RandomizerCore/Sidescroll/PalaceRooms.cs +++ b/RandomizerCore/Sidescroll/PalaceRooms.cs @@ -11,35 +11,58 @@ public partial class PalaceRooms { private readonly Dictionary> roomsByGroup = new(); public IReadOnlyList RoomsByGroup(RoomGroup group) => roomsByGroup.GetValueOrDefault(group, []); + private readonly Dictionary> roomsByTag = new(); + public IReadOnlyList RoomsByTag(string tag) => roomsByTag.GetValueOrDefault(tag, []); private readonly Dictionary roomsByName = new(); public static readonly string roomsMD5 = "JCa3OsnJhIe/fZ5yrx/+mA=="; public PalaceRooms(string palaceJson, bool doValidation) + : this(JsonSerializer.Deserialize(palaceJson, RoomSerializationContext.Default.ListRoom)!) { var hash = MD5Hash.ComputeHash(Encoding.UTF8.GetBytes(RemoveNewLines().Replace(palaceJson, ""))); if (doValidation && roomsMD5 != Convert.ToBase64String(hash)) { throw new Exception("Invalid PalaceRooms.json"); } + } - var rooms = JsonSerializer.Deserialize(palaceJson, RoomSerializationContext.Default.ListRoom)!; + public PalaceRooms(IEnumerable rooms) + { foreach (var room in rooms) { if (room.Enabled) { - if (!roomsByGroup.TryGetValue(room.Group, out var value)) + if (!roomsByGroup.TryGetValue(room.Group, out var groupList)) { - value = []; - roomsByGroup.Add(room.Group, value); + groupList = []; + roomsByGroup.Add(room.Group, groupList); } + groupList.Add(room); - value.Add(room); + if (room.Tags != null) + { + foreach (var tag in room.Tags) + { + AddRoomToTagList(room, tag); + } + } } + roomsByName[room.Name] = room; } } + private void AddRoomToTagList(Room room, string tag) + { + if (!roomsByTag.TryGetValue(tag, out var tagList)) + { + tagList = []; + roomsByTag.Add(tag, tagList); + } + tagList.Add(room); + } + public IEnumerable VanillaPalaceRoomsByPalaceNumber(int palaceNum) { int mapMin, mapMax; @@ -75,9 +98,8 @@ public IEnumerable VanillaPalaceRoomsByPalaceNumber(int palaceNum) mapMax = 54; break; default: - throw new ArgumentException("Invalid palace number: " + palaceNum); + throw new ArgumentException("Invalid palace number: " + palaceNum); } - var roomgroup = roomsByGroup[RoomGroup.VANILLA]; @@ -90,14 +112,9 @@ public IEnumerable VanillaPalaceRoomsByPalaceNumber(int palaceNum) ); } - public IEnumerable ThunderBirdRooms(RoomGroup group) - { - var roomgroup = roomsByGroup[group]; - return roomgroup.Where(i => i.IsThunderBirdRoom); - } - public Room VanillaBossRoom(int palaceNum) { + /* var map = palaceNum switch { 1 => 13, @@ -109,7 +126,9 @@ public Room VanillaBossRoom(int palaceNum) 7 => 54, _ => throw new ArgumentException("Invalid palace number: " + palaceNum) }; - return roomsByGroup[RoomGroup.VANILLA].First(i => i.IsBossRoom && map == i.Map); + */ + // might be null for test pool + return roomsByGroup[RoomGroup.VANILLA].FirstOrDefault(i => i.IsBossRoom && i.PalaceNumber == palaceNum)!; } public Room VanillaItemRoom(int palaceNum) @@ -130,72 +149,114 @@ public Room VanillaItemRoom(int palaceNum) public IEnumerable ItemRoomsByDirection(RoomGroup group, Direction direction) { - if(direction == Direction.NONE) + return ItemRoomsByDirection(roomsByGroup.GetValueOrDefault(group, []), direction); + } + + public IEnumerable ItemRoomsByDirection(IReadOnlyList rooms, Direction direction) + { + if (direction == Direction.NONE) { throw new ArgumentException("Invalid Direction.NONE in ItemRoomsByDirection"); } - var rooms = roomsByGroup; return direction switch { - //case Direction.HORIZONTAL_PASSTHROUGH: - // return rooms[group].Where(i => i.HasItem && i.HasLeftExit() && i.HasRightExit()); - //case Direction.VERTICAL_PASSTHROUGH: - // return rooms[group].Where(i => i.HasItem && i.HasUpExit() && i.HasDownExit()); - Direction.NORTH => rooms[group].Where(i => i.HasItem && i.HasUpExit), - Direction.SOUTH => rooms[group].Where(i => i.HasItem && i.HasDownExit), - Direction.WEST => rooms[group].Where(i => i.HasItem && i.HasLeftExit), - Direction.EAST => rooms[group].Where(i => i.HasItem && i.HasRightExit), + Direction.NORTH => rooms.Where(i => i.HasItem && i.HasUpExit), + Direction.SOUTH => rooms.Where(i => i.HasItem && i.HasDownExit), + Direction.WEST => rooms.Where(i => i.HasItem && i.HasLeftExit), + Direction.EAST => rooms.Where(i => i.HasItem && i.HasRightExit), _ => throw new ImpossibleException("Invalid direction in ItemRoomsByDirection") }; } public IEnumerable ItemRoomsByShape(RoomGroup group, RoomExitType shape) { - return roomsByGroup[group].Where(i => i.HasItem && i.CategorizeExits() == shape); + return ItemRoomsByShape(roomsByGroup.GetValueOrDefault(group, []), shape); + } + + public IEnumerable ItemRoomsByShape(IReadOnlyList rooms, RoomExitType shape) + { + return rooms.Where(i => i.HasItem && i.CategorizeExits() == shape); } public IEnumerable ItemRooms(RoomGroup group) { - return roomsByGroup[group].Where(i => i.HasItem); + return ItemRooms(roomsByGroup.GetValueOrDefault(group, [])); + } + + public IEnumerable ItemRooms(IReadOnlyList rooms) + { + return rooms.Where(i => i.HasItem); } public IEnumerable NormalPalaceRoomsByGroup(RoomGroup group) { - var roomgroup = roomsByGroup[group]; - return roomgroup.Where(i => (i.PalaceNumber ?? 1) != 7 + return NormalPalaceRoomsInList(roomsByGroup.GetValueOrDefault(group, [])); + } + + public IEnumerable NormalPalaceRoomsInList(IReadOnlyList rooms) + { + return rooms.Where(i => (i.PalaceNumber ?? 1) != 7 && i is { IsThunderBirdRoom: false, HasItem: false, IsBossRoom: false, IsEntrance: false }); } public IEnumerable GpRoomsByGroup(RoomGroup group) { - var roomgroup = roomsByGroup[group]; - return roomgroup.Where(i => (i.PalaceNumber ?? 1) == 7 + return GpRoomsInList(roomsByGroup.GetValueOrDefault(group, [])); + } + + public IEnumerable GpRoomsInList(IReadOnlyList rooms) + { + return rooms.Where(i => (i.PalaceNumber ?? 1) == 7 && i is { IsThunderBirdRoom: false, HasItem: false, IsBossRoom: false, IsEntrance: false }); } public IEnumerable Entrances(RoomGroup group) { - var roomgroup = roomsByGroup[group]; - return roomgroup.Where(i => i.IsEntrance); + return Entrances(roomsByGroup.GetValueOrDefault(group, [])); + } + + public IEnumerable Entrances(IReadOnlyList rooms) + { + return rooms.Where(i => i.IsEntrance); + } + + public IEnumerable ThunderBirdRooms(RoomGroup group) + { + return ThunderBirdRooms(roomsByGroup.GetValueOrDefault(group, [])); + } + + public IEnumerable ThunderBirdRooms(IReadOnlyList rooms) + { + return rooms.Where(i => i.IsThunderBirdRoom); } public IEnumerable BossRooms(RoomGroup group, int? palaceNum = null) { - var roomgroup = roomsByGroup[group]; - return roomgroup.Where(i => i.IsBossRoom && (palaceNum == null || palaceNum == i.PalaceNumber)); + return BossRooms(roomsByGroup.GetValueOrDefault(group, []), palaceNum); } + + public IEnumerable BossRooms(IReadOnlyList rooms, int? palaceNum = null) + { + return rooms.Where(i => i.IsBossRoom && (palaceNum == null || palaceNum == i.PalaceNumber)); + } + public Room GetRoomByName(string name) { return roomsByName[name]; } public Dictionary LinkedRooms(RoomGroup group, int? palaceNum = null) + { + return LinkedRooms(roomsByGroup.GetValueOrDefault(group, [])); + } + + public Dictionary LinkedRooms(IReadOnlyList rooms, int? palaceNum = null) { Dictionary linkedRooms = []; - foreach (Room room in roomsByGroup[group]) + foreach (Room room in rooms) { - if(room.Enabled && room.LinkedRoomName != null) + if (room.Enabled && room.LinkedRoomName != null) { linkedRooms.Add(room.LinkedRoomName, GetRoomByName(room.LinkedRoomName)); linkedRooms.Add(room.Name, GetRoomByName(room.Name)); diff --git a/RandomizerCore/Sidescroll/RoomPool.cs b/RandomizerCore/Sidescroll/RoomPool.cs index 813e697c3..2d69172d3 100644 --- a/RandomizerCore/Sidescroll/RoomPool.cs +++ b/RandomizerCore/Sidescroll/RoomPool.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -28,7 +28,7 @@ protected RoomPool() { } public RoomPool(PalaceRooms palaceRooms, int palaceNumber, RandomizerProperties props) { var allRooms = GatherRoomsFromProps(palaceRooms, palaceNumber, props); - ApplyPropertyExclusions(props); + ApplyPropertyExclusions(allRooms, props); GatherLinkedRooms(allRooms, palaceRooms); SplitRooms(allRooms, palaceNumber); FinalizePool(palaceRooms, palaceNumber, props); @@ -38,8 +38,8 @@ static List GatherRoomsFromProps(PalaceRooms palaceRooms, int palaceNumber { var roomSet = new List(); - //4.4 GP room pool is too shallow to create proper palaces from right now, so if you pick 4.4 only, - //GP also has vanilla rooms added. + //4.4 GP room pool is too shallow to create proper palaces from right now, so if you pick 4.4 only, + //GP also has vanilla rooms added. bool allowVanilla = props.AllowVanillaRooms || (palaceNumber == 7 && !props.AllowVanillaRooms && !props.AllowV4Rooms && props.AllowV5_0Rooms); @@ -61,23 +61,36 @@ static List GatherRoomsFromProps(PalaceRooms palaceRooms, int palaceNumber return roomSet.ToList(); } - private static void AddGroup(List roomSet, PalaceRooms palaceRooms, RoomGroup group) + private static void AddGroup(List roomList, PalaceRooms palaceRooms, RoomGroup group) { + // a room can only belong to one group and groups are added first, so we don't need to check contains foreach (var room in palaceRooms.RoomsByGroup(group)) { - roomSet.Add(room); + roomList.Add(room); } } - private void ApplyPropertyExclusions(RandomizerProperties props) + private static void AddTag(List roomList, PalaceRooms palaceRooms, string tag) + { + foreach (var room in palaceRooms.RoomsByTag(tag)) + { + if (!roomList.Contains(room, ReferenceEqualityComparer.Instance)) // rooms added by tag might overlap with groups + { + roomList.Add(room); + } + } + } + + private void ApplyPropertyExclusions(List allRooms, RandomizerProperties props) { if (props.RemoveLongDeadEnds) { - RemoveRooms(r => r.HasTag("LongDeadEnd")); + allRooms.RemoveAll(r => r.HasTag("LongDeadEnd")); } + if (!props.IncludeExpertRooms) { - RemoveRooms(r => r.HasTag("Expert")); + allRooms.RemoveAll(r => r.HasTag("Expert")); } } @@ -99,7 +112,7 @@ private void SplitRooms(List allRooms, int palaceNumber) ItemRooms.Add(room); } else if (room.IsBossRoom) - { + { BossRooms.Add(room); } else if (room.IsThunderBirdRoom) @@ -188,6 +201,10 @@ void FinalizePool(PalaceRooms palaceRooms, int palaceNumber, RandomizerPropertie } var exitTypes = ItemRooms.Select(r => r.CategorizeExits()).Distinct(); + if (palaceNumber != 7 && exitTypes.Count() < 1) + { + throw new UserFacingException("Incomplete Room Pool", "Room pool has no item rooms."); + } foreach (var shape in exitTypes) { var shapeRooms = ItemRooms.Where(r => r.CategorizeExits() == shape).ToList(); @@ -220,6 +237,7 @@ void FinalizePool(PalaceRooms palaceRooms, int palaceNumber, RandomizerPropertie VanillaBossRoom = palaceRooms.VanillaBossRoom(palaceNumber); if (BossRooms.Count == 0) { + if (VanillaBossRoom == null) { throw new Exception("No boss room in pool!"); } BossRooms.Add(VanillaBossRoom); } @@ -249,7 +267,7 @@ public IEnumerable GetItemRoomShapes() public List GetItemRoomsForShape(RoomExitType itemRoomExitType) { - return ItemRoomsByShape[itemRoomExitType]; + return ItemRoomsByShape.GetValueOrDefault(itemRoomExitType, []); } public void RemoveDuplicates(RandomizerProperties props, Room roomThatWasUsed) @@ -290,22 +308,36 @@ public void RemoveRooms(Predicate removalCondition) Entrances.RemoveAll(room => RoomMatchesIncludingLinked(room, removalCondition)); BossRooms.RemoveAll(room => RoomMatchesIncludingLinked(room, removalCondition)); TbirdRooms.RemoveAll(room => RoomMatchesIncludingLinked(room, removalCondition)); - ItemRooms.RemoveAll(room => RoomMatchesIncludingLinked(room, removalCondition)); RemoveFromItemRooms(removalCondition); } void RemoveFromItemRooms(Room room) { ItemRooms.Remove(room); + + var directionsToRemove = new List(); foreach (Direction direction in ItemRoomsByDirection.Keys) { var originalTable = ItemRoomsByDirection[direction]; - var newTable = (TableWeightedRandom)originalTable.Subtract(room); + var newTable = (TableWeightedRandom?)originalTable.Subtract(room); if (newTable != originalTable) { - ItemRoomsByDirection[direction] = newTable; + if (newTable == null) + { + directionsToRemove.Add(direction); + } + else + { + ItemRoomsByDirection[direction] = newTable; + } } } + + foreach (var dir in directionsToRemove) + { + ItemRoomsByDirection.Remove(dir); + } + foreach (var key in ItemRoomsByShape.Keys) { ItemRoomsByShape[key].Remove(room); @@ -314,6 +346,9 @@ void RemoveFromItemRooms(Room room) void RemoveFromItemRooms(Predicate removalCondition) { + ItemRooms.RemoveAll(room => RoomMatchesIncludingLinked(room, removalCondition)); + + var directionsToRemove = new List(); foreach (Direction direction in ItemRoomsByDirection.Keys) { var originalTable = ItemRoomsByDirection[direction]; @@ -322,15 +357,27 @@ void RemoveFromItemRooms(Predicate removalCondition) { if (RoomMatchesIncludingLinked(room, removalCondition)) { - newTable = (TableWeightedRandom)newTable.Subtract(room); + newTable = (TableWeightedRandom?)newTable?.Subtract(room); } } if (newTable != originalTable) { - ItemRoomsByDirection[direction] = newTable; + if (newTable == null) + { + directionsToRemove.Add(direction); + } + else + { + ItemRoomsByDirection[direction] = newTable; + } } } + foreach (var dir in directionsToRemove) + { + ItemRoomsByDirection.Remove(dir); + } + foreach (var key in ItemRoomsByShape.Keys) { ItemRoomsByShape[key].RemoveAll(room => RoomMatchesIncludingLinked(room, removalCondition)); @@ -359,10 +406,10 @@ private bool RoomMatchesIncludingLinked(Room room, Predicate match) public Dictionary> CategorizeNormalRoomExits(bool linkRooms = false) { Dictionary> categorizedRooms = new Dictionary>(NormalRooms.Count); - foreach(Room room in NormalRooms) + foreach (Room room in NormalRooms) { var type = GetMergedExitType(room); - if(!categorizedRooms.TryGetValue(type, out List? value)) + if (!categorizedRooms.TryGetValue(type, out List? value)) { value = new List(NormalRooms.Count); categorizedRooms[type] = value; @@ -372,7 +419,7 @@ public Dictionary> CategorizeNormalRoomExits(bool linkR return categorizedRooms; } - RoomExitType GetMergedExitType(Room room) + public RoomExitType GetMergedExitType(Room room) { var type = room.CategorizeExits(); if (room.LinkedRoomName != null && LinkedRooms.TryGetValue(room.LinkedRoomName, out var linked)) diff --git a/RandomizerCore/WeightedRandom.cs b/RandomizerCore/WeightedRandom.cs index a1f0fd4d0..2a01969e7 100644 --- a/RandomizerCore/WeightedRandom.cs +++ b/RandomizerCore/WeightedRandom.cs @@ -13,7 +13,7 @@ public interface IWeightedSampler where T : notnull int Weight(T t); /// Note: This returns a new copy of the class. This is pretty slow, /// but also needing this should be avoided in the first place. - IWeightedSampler Subtract(T keyToRemove); + IWeightedSampler? Subtract(T keyToRemove); /// This should not really be necessary since all members should be read-only. Might remove later. IWeightedSampler Clone(); } @@ -68,7 +68,7 @@ public IWeightedSampler Clone() return new TableWeightedRandom(_keys, _table, _totalWeight, _weights); } - public IWeightedSampler Subtract(T keyToRemove) + public IWeightedSampler? Subtract(T keyToRemove) { var keys = Keys(); if (!keys.Contains(keyToRemove)) @@ -77,6 +77,7 @@ public IWeightedSampler Subtract(T keyToRemove) } var newEntries = keys.Where(k => !k.Equals(keyToRemove)).Select(k => (k, Weight(k))).ToList(); + if (newEntries.Count == 0) { return null; } return new TableWeightedRandom(newEntries); } @@ -147,7 +148,7 @@ public IWeightedSampler Clone() return new LinearWeightedRandom(_keys, _cumulativeWeights, _totalWeight, _weights); } - public IWeightedSampler Subtract(T keyToRemove) + public IWeightedSampler? Subtract(T keyToRemove) { var keys = Keys(); if (!keys.Contains(keyToRemove)) @@ -156,6 +157,7 @@ public IWeightedSampler Subtract(T keyToRemove) } var newEntries = keys.Where(k => !k.Equals(keyToRemove)).Select(k => (k, Weight(k))).ToList(); + if (newEntries.Count == 0) { return null; } return new LinearWeightedRandom(newEntries); } diff --git a/Tests/RoomPoolTests.cs b/Tests/RoomPoolTests.cs new file mode 100644 index 000000000..fc6e73652 --- /dev/null +++ b/Tests/RoomPoolTests.cs @@ -0,0 +1,544 @@ +using FluentAssertions; +using Z2Randomizer.RandomizerCore; +using Z2Randomizer.RandomizerCore.Sidescroll; +using Random = Z2Randomizer.RandomizerCore.Random; + +namespace Tests; + +[TestClass] +public class RoomPoolTests +{ + Room CreateMockRoom(string name, RoomGroup group, bool enabled = true, bool hasItem = false, + bool isBossRoom = false, bool isEntrance = false, bool isThunderBirdRoom = false, + int? palaceNumber = null, string? linkedRoomName = null, string[]? tags = null, + bool hasUpExit = false, bool hasDownExit = false, bool hasLeftExit = false, + bool hasRightExit = false, bool hasDrop = false, bool isDropZone = false, + byte map = 0, string duplicateGroup = "") + { + var connections = new byte[] { + hasLeftExit ? (byte)0 : (byte)0xFC, + hasDownExit ? (byte)0 : (byte)0xFC, + hasUpExit ? (byte)0 : (byte)0xFC, + hasRightExit ? (byte)0 : (byte)0xFC + }; + + var room = new Room + { + Name = name, + Group = group, + Enabled = enabled, + HasItem = hasItem, + IsBossRoom = isBossRoom, + IsEntrance = isEntrance, + IsThunderBirdRoom = isThunderBirdRoom, + PalaceNumber = palaceNumber, + LinkedRoomName = linkedRoomName, + Tags = tags?.ToList() ?? [], + HasDrop = hasDrop, + IsDropZone = isDropZone, + DuplicateGroup = duplicateGroup, + SideView = [0x04, 0x60, 0x00, 0x08], + Enemies = [0x01], + ItemGetBits = [0x0F], + Connections = connections, + Requirements = Requirements.NONE, + Map = map + }; + room.OnDeserialized(); + return room; + } + + RandomizerProperties CreateMockProps(bool allowVanilla = true, bool allowV4 = false, + bool allowV5 = false, bool removeLongDeadEnds = false, bool includeExpert = true, + bool blockersAnywhere = true, bool replaceFireWithDash = true, + HashSet? removeItems = null) + { + return new RandomizerProperties + { + AllowVanillaRooms = allowVanilla, + AllowV4Rooms = allowV4, + AllowV5_0Rooms = allowV5, + RemoveLongDeadEnds = removeLongDeadEnds, + IncludeExpertRooms = includeExpert, + BlockersAnywhere = blockersAnywhere, + ReplaceFireWithDash = replaceFireWithDash, + NoDuplicateRooms = false, + NoDuplicateRoomsBySideview = false + }; + } + + /// + /// Creates PalaceRooms as required for FinalizePool + /// + PalaceRooms CreateMockPalaceRooms(List? vanillaRooms = null, List? v4Rooms = null, List? v5Rooms = null, int palaceNumber = 1) + { + var allRooms = new List(); + allRooms.AddRange(vanillaRooms ?? []); + allRooms.AddRange([ + CreateMockRoom("VanillaDownEntrance", RoomGroup.VANILLA, isEntrance: true, palaceNumber: palaceNumber, hasDownExit: true), + CreateMockRoom("VanillaP1Boss", RoomGroup.VANILLA, isBossRoom: true, palaceNumber: 1, map: 13, hasLeftExit: true), + CreateMockRoom("VanillaP1Item", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasRightExit: true), + ]); + allRooms.AddRange([ + CreateMockRoom("V4DownBoss", RoomGroup.V4_0, isBossRoom: true, hasDownExit: true, palaceNumber: palaceNumber >= 6 ? palaceNumber : null) + ]); + allRooms.AddRange(v4Rooms ?? []); + allRooms.AddRange([ + CreateMockRoom("V5UpEntrance", RoomGroup.V5_0, isEntrance: true, palaceNumber: palaceNumber, hasUpExit: true) + ]); + allRooms.AddRange(v5Rooms ?? []); + allRooms.AddRange([ + CreateMockRoom("StubDown", RoomGroup.STUBS, hasDownExit: true), + CreateMockRoom("StubUp", RoomGroup.STUBS, hasUpExit: true) + ]); + return new PalaceRooms(allRooms); + } + + #region Constructor - Props-based + + [TestMethod] + public void Constructor_Props_SplitsRoomsIntoCorrectCategories() + { + var normalRoom = CreateMockRoom("Normal1", RoomGroup.V4_0, palaceNumber: 1); + var entranceRoom = CreateMockRoom("Entrance1", RoomGroup.V4_0, isEntrance: true, palaceNumber: 1); + var bossRoom = CreateMockRoom("Boss1", RoomGroup.V4_0, isBossRoom: true, palaceNumber: 1); + var itemRoom = CreateMockRoom("Item1", RoomGroup.V4_0, hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([normalRoom, entranceRoom, bossRoom, itemRoom]); + var props = CreateMockProps(allowVanilla: false, allowV4: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.NormalRooms.Should().Contain(r => r.Name == "Normal1"); + pool.Entrances.Should().Contain(r => r.Name == "Entrance1"); + pool.BossRooms.Should().Contain(r => r.Name == "Boss1"); + pool.ItemRooms.Should().Contain(r => r.Name == "Item1"); + } + + [TestMethod] + public void Constructor_Props_IncludesTbirdRoomsForPalace7() + { + var tbirdRoom = CreateMockRoom("Tbird1", RoomGroup.VANILLA, isThunderBirdRoom: true, palaceNumber: 7); + var bossRoom = CreateMockRoom("Boss7", RoomGroup.VANILLA, isBossRoom: true, palaceNumber: 7); + + var mockPalaceRooms = CreateMockPalaceRooms([tbirdRoom, bossRoom], palaceNumber: 7); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 7, props); + + pool.TbirdRooms.Should().ContainSingle().Which.Name.Should().Be("Tbird1"); + } + + [TestMethod] + public void Constructor_Props_OnlyIncludesRoomsForMatchingPalace() + { + var p1Room = CreateMockRoom("P1Room", RoomGroup.VANILLA, palaceNumber: 1); + var p2Room = CreateMockRoom("P2Room", RoomGroup.VANILLA, palaceNumber: 2); + var genericRoom = CreateMockRoom("GenericRoom", RoomGroup.VANILLA); + var bossRoom = CreateMockRoom("Boss1", RoomGroup.VANILLA, isBossRoom: true, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([p1Room, p2Room, genericRoom, bossRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.NormalRooms.Should().Contain(r => r.Name == "P1Room"); + pool.NormalRooms.Should().Contain(r => r.Name == "GenericRoom"); + pool.NormalRooms.Should().NotContain(r => r.Name == "P2Room"); + } + + [TestMethod] + public void Constructor_Props_RespectsAllowV4Flag() + { + var v4Room = CreateMockRoom("V4Room", RoomGroup.V4_0, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([], [v4Room]); + + var propsWithV4 = CreateMockProps(allowVanilla: true, allowV4: true); + var poolWithV4 = new RoomPool(mockPalaceRooms, 1, propsWithV4); + poolWithV4.NormalRooms.Should().Contain(r => r.Name == "V4Room"); + + var propsWithoutV4 = CreateMockProps(allowVanilla: true, allowV4: false); + var poolWithoutV4 = new RoomPool(mockPalaceRooms, 1, propsWithoutV4); + poolWithoutV4.NormalRooms.Should().NotContain(r => r.Name == "V4Room"); + } + + [TestMethod] + public void Constructor_Props_RespectsAllowV5Flag() + { + var v5Room = CreateMockRoom("V5Room", RoomGroup.V5_0, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([], v5Rooms: [v5Room]); + + var propsWithV5 = CreateMockProps(allowVanilla: true, allowV5: true); + var poolWithV5 = new RoomPool(mockPalaceRooms, 1, propsWithV5); + poolWithV5.NormalRooms.Should().Contain(r => r.Name == "V5Room"); + + var propsWithoutV5 = CreateMockProps(allowVanilla: true, allowV5: false); + var poolWithoutV5 = new RoomPool(mockPalaceRooms, 1, propsWithoutV5); + poolWithoutV5.NormalRooms.Should().NotContain(r => r.Name == "V5Room"); + } + + [TestMethod] + public void Constructor_Props_RemovesLongDeadEndsWhenConfigured() + { + var longDeadEndRoom = CreateMockRoom("LongDeadEnd", RoomGroup.VANILLA, palaceNumber: 1, tags: ["LongDeadEnd"]); + var normalRoom = CreateMockRoom("Normal1", RoomGroup.VANILLA, palaceNumber: 1); + var bossRoom = CreateMockRoom("Boss1", RoomGroup.VANILLA, isBossRoom: true, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([longDeadEndRoom, normalRoom, bossRoom]); + var props = CreateMockProps(allowVanilla: true, removeLongDeadEnds: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.NormalRooms.Should().NotContain(r => r.Name == "LongDeadEnd"); + pool.NormalRooms.Should().Contain(r => r.Name == "Normal1"); + } + + [TestMethod] + public void Constructor_Props_RemovesExpertRoomsWhenConfigured() + { + var expertRoom = CreateMockRoom("Expert1", RoomGroup.VANILLA, palaceNumber: 1, tags: ["Expert"]); + var normalRoom = CreateMockRoom("Normal1", RoomGroup.VANILLA, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([expertRoom, normalRoom]); + var props = CreateMockProps(allowVanilla: true, includeExpert: false); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.NormalRooms.Should().NotContain(r => r.Name == "Expert1"); + pool.NormalRooms.Should().Contain(r => r.Name == "Normal1"); + } + + [TestMethod] + public void Constructor_Props_FallsBackToVanillaEntrancesWhenNoneInPool() + { + var entranceRoom = CreateMockRoom("VanillaEntrance", RoomGroup.VANILLA, isEntrance: true, palaceNumber: 1, hasDownExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([entranceRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.Entrances.Should().Contain(r => r.Name == "VanillaEntrance"); + } + + #endregion + + #region Copy constructor + + [TestMethod] + public void CopyConstructor_CopiesAllCollections() + { + var normalRoom = CreateMockRoom("Normal1", RoomGroup.VANILLA, palaceNumber: 1); + var entranceRoom = CreateMockRoom("Entrance1", RoomGroup.VANILLA, isEntrance: true, palaceNumber: 1); + var bossRoom = CreateMockRoom("Boss1", RoomGroup.VANILLA, isBossRoom: true, palaceNumber: 1); + var tbirdRoom = CreateMockRoom("Tbird1", RoomGroup.VANILLA, isThunderBirdRoom: true, palaceNumber: 7); + var itemRoom = CreateMockRoom("Item1", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([normalRoom, entranceRoom, bossRoom, tbirdRoom, itemRoom]); + var props = CreateMockProps(allowVanilla: true); + var original = new RoomPool(mockPalaceRooms, 1, props); + + var copy = new RoomPool(original); + + copy.TbirdRooms.Should().BeEmpty(); + copy.LinkedRooms.Should().BeEmpty(); + copy.NormalRooms.Should().BeEquivalentTo(original.NormalRooms); + copy.Entrances.Should().BeEquivalentTo(original.Entrances); + copy.BossRooms.Should().BeEquivalentTo(original.BossRooms); + copy.TbirdRooms.Should().BeEquivalentTo(original.TbirdRooms); + copy.ItemRooms.Should().BeEquivalentTo(original.ItemRooms); + copy.VanillaBossRoom.Should().Be(original.VanillaBossRoom); + copy.LinkedRooms.Should().BeEquivalentTo(original.LinkedRooms); + } + + [TestMethod] + public void CopyConstructor_ShallowClonesCollections() + { + var normalRoom = CreateMockRoom("Normal1", RoomGroup.VANILLA, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([normalRoom]); + var props = CreateMockProps(allowVanilla: true); + var original = new RoomPool(mockPalaceRooms, 1, props); + var copy = new RoomPool(original); + + copy.NormalRooms.Should().NotBeSameAs(original.NormalRooms); + copy.Entrances.Should().NotBeSameAs(original.Entrances); + copy.BossRooms.Should().NotBeSameAs(original.BossRooms); + copy.NormalRooms.First().Should().BeSameAs(original.NormalRooms.First()); + copy.BossRooms.First().Should().BeSameAs(original.BossRooms.First()); + } + + #endregion + + #region RemoveRoom / RemoveRooms + + [TestMethod] + public void RemoveRoom_RemovesFromCorrectCategory() + { + var normalRoom = CreateMockRoom("Normal1", RoomGroup.VANILLA, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([normalRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.RemoveRoom(normalRoom); + + pool.NormalRooms.Should().NotContain(normalRoom); + } + + [TestMethod] + public void RemoveRoom_RemovesItemRoomFromAllCollections() + { + var itemRoom = CreateMockRoom("Item1", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([itemRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.ItemRooms.Should().Contain(itemRoom); + pool.RemoveRoom(itemRoom); + pool.ItemRooms.Should().NotContain(itemRoom); + } + + [TestMethod] + public void RemoveRooms_WithPredicate_RemovesMatchingRooms() + { + var room1 = CreateMockRoom("Room1", RoomGroup.VANILLA, palaceNumber: 1, tags: ["Remove"]); + var room2 = CreateMockRoom("Room2", RoomGroup.VANILLA, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([room1, room2]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.RemoveRooms(r => r.HasTag("Remove")); + + pool.NormalRooms.Should().NotContain(r => r.Name == "Room1"); + pool.NormalRooms.Should().Contain(r => r.Name == "Room2"); + } + + #endregion + + #region RemoveDuplicates + + [TestMethod] + public void RemoveDuplicates_NoDuplicateRooms_RemovesSingleRoom() + { + var itemRoom1 = CreateMockRoom("Item1", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + var itemRoom2 = CreateMockRoom("Item2", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + var bossRoom = CreateMockRoom("Boss1", RoomGroup.VANILLA, isBossRoom: true, palaceNumber: 1); + + var mockPalaceRooms = CreateMockPalaceRooms([itemRoom1, itemRoom2, bossRoom]); + var props = CreateMockProps(allowVanilla: true); + props.NoDuplicateRooms = true; + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.ItemRooms.Should().Contain(itemRoom1); + pool.RemoveDuplicates(props, itemRoom1); + pool.ItemRooms.Should().NotContain(itemRoom1); + pool.ItemRooms.Should().Contain(itemRoom2); + } + + [TestMethod] + public void RemoveDuplicates_NoDuplicateRoomsBySideview_RemovesMatchingSideview() + { + var itemRoom1 = CreateMockRoom("ItemVariant1", RoomGroup.V4_0, duplicateGroup: "ItemVariantGroup1", hasItem: true, palaceNumber: 1, hasUpExit: true); + var itemRoom2 = CreateMockRoom("ItemVariant2", RoomGroup.V4_0, duplicateGroup: "ItemVariantGroup1", hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([itemRoom1, itemRoom2]); + var props = CreateMockProps(allowVanilla: false, allowV4: true); + props.NoDuplicateRoomsBySideview = true; + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.ItemRooms.Should().HaveCount(2); + pool.RemoveDuplicates(props, itemRoom1); + pool.ItemRooms.Should().BeEmpty(); + } + + #endregion + + #region LinkedRooms + + [TestMethod] + public void Constructor_GathersLinkedRooms() + { + var primaryRoom = CreateMockRoom("Primary", RoomGroup.VANILLA, palaceNumber: 1, + linkedRoomName: "Secondary"); + var secondaryRoom = CreateMockRoom("Secondary", RoomGroup.VANILLA, palaceNumber: 1, + linkedRoomName: "Primary", enabled: false); + + var mockPalaceRooms = CreateMockPalaceRooms([primaryRoom, secondaryRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.LinkedRooms.Should().ContainKey("Primary"); + pool.LinkedRooms.Should().ContainKey("Secondary"); + pool.NormalRooms.Should().Contain(primaryRoom); + pool.NormalRooms.Should().NotContain(secondaryRoom); + } + + #endregion + + #region CategorizeNormalRoomExits / GetNormalRoomsForExitType + + [TestMethod] + public void CategorizeNormalRoomExits_ReturnsCategorizedRooms() + { + var deadendRight = CreateMockRoom("DeadendRight", RoomGroup.VANILLA, palaceNumber: 1, hasRightExit: true); + var deadendLeft = CreateMockRoom("DeadendLeft", RoomGroup.VANILLA, palaceNumber: 1, hasLeftExit: true); + var passThrough = CreateMockRoom("PassThrough", RoomGroup.VANILLA, palaceNumber: 1, hasLeftExit: true, hasRightExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([deadendRight, deadendLeft, passThrough]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + var categorized = pool.CategorizeNormalRoomExits(); + + categorized[RoomExitType.DEADEND_EXIT_RIGHT].Should().ContainSingle().Which.Name.Should().Be("DeadendRight"); + categorized[RoomExitType.DEADEND_EXIT_LEFT].Should().ContainSingle().Which.Name.Should().Be("DeadendLeft"); + categorized[RoomExitType.HORIZONTAL_PASSTHROUGH].Should().ContainSingle().Which.Name.Should().Be("PassThrough"); + } + + [TestMethod] + public void GetNormalRoomsForExitType_ReturnsMatchingRooms() + { + var deadendRight = CreateMockRoom("DeadendRight", RoomGroup.VANILLA, palaceNumber: 1, hasRightExit: true); + var deadendLeft = CreateMockRoom("DeadendLeft", RoomGroup.VANILLA, palaceNumber: 1, hasLeftExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([deadendRight, deadendLeft]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + var rightRooms = pool.GetNormalRoomsForExitType(RoomExitType.DEADEND_EXIT_RIGHT); + rightRooms.Should().ContainSingle().Which.Name.Should().Be("DeadendRight"); + + var leftRooms = pool.GetNormalRoomsForExitType(RoomExitType.DEADEND_EXIT_LEFT); + leftRooms.Should().ContainSingle().Which.Name.Should().Be("DeadendLeft"); + } + + #endregion + + #region GetItemRoomShapes / GetItemRoomsForShape + + [TestMethod] + public void GetItemRoomShapes_ReturnsDistinctShapes() + { + var itemRight = CreateMockRoom("ItemRight", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasRightExit: true); + var itemUp = CreateMockRoom("ItemUp", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([itemRight, itemUp]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + var shapes = pool.GetItemRoomShapes().ToList(); + shapes.Should().Contain(RoomExitType.DEADEND_EXIT_RIGHT); + shapes.Should().Contain(RoomExitType.DEADEND_EXIT_UP); + } + + [TestMethod] + public void GetItemRoomsForShape_ReturnsMatchingRooms() + { + var itemRight = CreateMockRoom("ItemRight", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasRightExit: true); + var itemUp = CreateMockRoom("ItemUp", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([itemRight, itemUp]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + var rightRooms = pool.GetItemRoomsForShape(RoomExitType.DEADEND_EXIT_RIGHT); + rightRooms.Should().Contain(r => r.Name == "ItemRight"); + + var upRooms = pool.GetItemRoomsForShape(RoomExitType.DEADEND_EXIT_UP); + upRooms.Should().Contain(r => r.Name == "ItemUp"); + } + + #endregion + + #region GetMergedExitType + + [TestMethod] + public void GetMergedExitType_ReturnsMergedTypeForLinkedRoom() + { + var primaryRoom = CreateMockRoom("Primary", RoomGroup.VANILLA, palaceNumber: 1, + hasRightExit: true, linkedRoomName: "Secondary"); + var secondaryRoom = CreateMockRoom("Secondary", RoomGroup.VANILLA, palaceNumber: 1, + hasLeftExit: true, linkedRoomName: "Primary", enabled: false); + + var mockPalaceRooms = CreateMockPalaceRooms([primaryRoom, secondaryRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + var mergedType = pool.GetMergedExitType(primaryRoom); + mergedType.Should().Be(RoomExitType.HORIZONTAL_PASSTHROUGH); + } + + [TestMethod] + public void GetMergedExitType_ReturnsOwnTypeWhenNoLinkedRoom() + { + var normalRoom = CreateMockRoom("Normal1", RoomGroup.VANILLA, palaceNumber: 1, hasRightExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([normalRoom]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + var exitType = pool.GetMergedExitType(normalRoom); + exitType.Should().Be(RoomExitType.DEADEND_EXIT_RIGHT); + } + + #endregion + + #region RefillNormalRoomsForExitType + + [TestMethod] + public void RefillNormalRoomsForExitType_AddsRoomsFromSourcePool() + { + var room1 = CreateMockRoom("Room1", RoomGroup.VANILLA, palaceNumber: 1, hasRightExit: true); + var room2 = CreateMockRoom("Room2", RoomGroup.VANILLA, palaceNumber: 1, hasRightExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([room1, room2]); + var props = CreateMockProps(allowVanilla: true); + var sourcePool = new RoomPool(mockPalaceRooms, 1, props); + var targetPool = new RoomPool(mockPalaceRooms, 1, props); + + targetPool.NormalRooms.Clear(); + + targetPool.RefillNormalRoomsForExitType(sourcePool, RoomExitType.DEADEND_EXIT_RIGHT); + + targetPool.NormalRooms.Should().HaveCount(2); + targetPool.NormalRooms.Should().Contain(r => r.Name == "Room1"); + targetPool.NormalRooms.Should().Contain(r => r.Name == "Room2"); + } + + #endregion + + #region ItemRoomsByDirection + + [TestMethod] + public void FinalizePool_PopulatesItemRoomsByDirection() + { + var itemUp = CreateMockRoom("ItemUp", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + var itemDown = CreateMockRoom("ItemDown", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasDownExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([itemUp, itemDown]); + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.ItemRoomsByDirection.Should().ContainKey(Direction.NORTH); + pool.ItemRoomsByDirection.Should().ContainKey(Direction.SOUTH); + pool.ItemRoomsByDirection[Direction.NORTH].Keys().Should().Contain(itemUp); + pool.ItemRoomsByDirection[Direction.SOUTH].Keys().Should().Contain(itemDown); + } + + [TestMethod] + public void RemoveRoom_RemovesFromItemRoomsByDirection() + { + var itemUp = CreateMockRoom("ItemUp", RoomGroup.VANILLA, hasItem: true, palaceNumber: 1, hasUpExit: true); + + var mockPalaceRooms = CreateMockPalaceRooms([itemUp]); + + var props = CreateMockProps(allowVanilla: true); + var pool = new RoomPool(mockPalaceRooms, 1, props); + + pool.ItemRoomsByDirection.Should().ContainKey(Direction.NORTH); + pool.ItemRoomsByDirection[Direction.NORTH].Keys().Should().Contain(itemUp); + pool.RemoveRoom(itemUp); + pool.ItemRoomsByDirection.Should().NotContainKey(Direction.NORTH); + } + + #endregion +} From fa4c24c385d7a53ca01f22669701d765b1dd86b6 Mon Sep 17 00:00:00 2001 From: initsu Date: Sun, 26 Jul 2026 13:00:00 +0200 Subject: [PATCH 5/5] Improved GitHub Pages cache management for the web app --- CrossPlatformUI.Browser/wwwroot/main.js | 100 ++++++++++++++++++------ 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/CrossPlatformUI.Browser/wwwroot/main.js b/CrossPlatformUI.Browser/wwwroot/main.js index 97d8e590e..8d590e303 100644 --- a/CrossPlatformUI.Browser/wwwroot/main.js +++ b/CrossPlatformUI.Browser/wwwroot/main.js @@ -1,7 +1,7 @@ import { dotnet } from './_framework/dotnet.js' import { compile } from './js65/libassembler.js' -const BUNDLE_DOWNLOAD_SIZE = 80 * 1024 * 1024; // used for progress bar - doesn't have to be exact +const BUNDLE_DOWNLOAD_SIZE = 71 * 1024 * 1024; // used for progress bar - doesn't have to be exact const is_browser = typeof window != "undefined"; if (!is_browser) throw new Error(`Expected to be running in a browser`); @@ -17,8 +17,22 @@ function showError(msg) { (() => { const origFetch = globalThis.fetch.bind(globalThis); + // This is a custom cache workaround for GitHub Pages arbitrarily + // sending different ETags for the same files. (It might depend on + // which CDN handles the request.) + // Instead rely on GitHub Pages setting Last - Modified to be the + // same for all files when the page is updated, including this script. + const deployTimestampPromise = (async () => { + try { + const r = await origFetch(import.meta.url, { cache: "default" }); + return Date.parse(r.headers.get("Last-Modified") || "") || 0; + } catch { + return 0; + } + })(); + // track cumulative progress across all boot resources - let loadedKnownBytes = 0; + let completedBytes = 0; const inFlight = new Map(); // id -> {loaded,total} let started = false; let finishTimer; @@ -40,24 +54,57 @@ function showError(msg) { }; const updateOverall = () => { - updateProgress(loadedKnownBytes / BUNDLE_DOWNLOAD_SIZE); + let loaded = completedBytes; + for (const r of inFlight.values()) { + loaded += r.loaded; + } + updateProgress(loaded / BUNDLE_DOWNLOAD_SIZE); }; - globalThis.fetch = async (input, init) => { - const res = await origFetch(input, init).catch((e) => { - showError("Network error while loading app. Please reload."); - throw e; - }); + const fetchError = (e) => { + showError("Network error while loading app. Please reload."); + throw e; + }; + globalThis.fetch = async (input, init) => { + const deployTs = await deployTimestampPromise; const url = typeof input === "string" ? input : (input && input.url) || ""; - if (!isBootResource(url, res) || !res.body || res.bodyUsed) { - return res; // leave non-boot fetches alone + const absoluteUrl = new URL(url, window.location.href).href; + + var fetchResult; + + if (deployTs !== 0) { + // check if transferSize is non-zero (there was no local cache) + const cachedFetchResult = await origFetch(input, { ...init, cache: "force-cache" }).catch(fetchError); + const perfEntries = performance?.getEntriesByName(absoluteUrl, "resource"); + const lastEntry = perfEntries?.[perfEntries.length - 1]; + const wasNetworkRequest = lastEntry?.transferSize > 0; + + var staleCache = false; + if (!wasNetworkRequest) { + // if we have a reference timestamp, ensure the cached copy isn't + // much older than the deploy timestamp. + const lm = Date.parse(cachedFetchResult.headers.get("Last-Modified")); + staleCache = !lm || lm < deployTs - 180_000; + } + + if (!staleCache) { + fetchResult = cachedFetchResult; + } + } + + if (!fetchResult) { + fetchResult = await origFetch(input, { ...init, cache: "default" }).catch(fetchError); + } + + if (!isBootResource(url, fetchResult) || !fetchResult.body || fetchResult.bodyUsed) { + return fetchResult; // leave non-boot fetches alone } started = true; - const contentLength = parseInt(res.headers.get("Content-Length") || "0", 10); + const contentLength = parseInt(fetchResult.headers.get("Content-Length") || "0", 10); const id = Math.random().toString(36).slice(2); - const reader = res.body.getReader(); + const reader = fetchResult.body.getReader(); inFlight.set(id, { loaded: 0, total: contentLength }); @@ -67,21 +114,26 @@ function showError(msg) { showError("Error reading a resource stream. Please reload."); throw e; }); + if (done) { - controller.close(); - // account for any rounding misses const r = inFlight.get(id); - if (r && r.total > 0) loadedKnownBytes += (r.total - r.loaded); - inFlight.delete(id); + + if (r) { + completedBytes += r.loaded; + inFlight.delete(id); + } + updateOverall(); + controller.close(); return; } + controller.enqueue(value); - const r = inFlight.get(id); - if (r) { - r.loaded += value.length; - if (r.total > 0) loadedKnownBytes += value.length; - inFlight.set(id, r); + + const current = inFlight.get(id); + if (current) { + current.loaded += value.length; + inFlight.set(id, current); } updateOverall(); }, @@ -89,9 +141,9 @@ function showError(msg) { }); return new Response(stream, { - headers: res.headers, - status: res.status, - statusText: res.statusText + headers: fetchResult.headers, + status: fetchResult.status, + statusText: fetchResult.statusText }); };