diff --git a/CoreSourceGenerator/FlagsSerializeGenerator.cs b/CoreSourceGenerator/FlagsSerializeGenerator.cs index efebdd85..2decebcd 100644 --- a/CoreSourceGenerator/FlagsSerializeGenerator.cs +++ b/CoreSourceGenerator/FlagsSerializeGenerator.cs @@ -85,6 +85,7 @@ private static bool IsPartialClass(SyntaxNode node) { FieldName = f.Name, FieldType = f.Type.ToDisplayString(), + IsDifficultyOnly = HasDifficultyOnlyAttribute(f), IsConditionallyIncluded = HasConditionallyIncludedInFlagsAttribute(f), DefaultValue = GetDefaultValue(f), IsEnum = f.Type.TypeKind == TypeKind.Enum, @@ -133,6 +134,12 @@ private static bool HasConditionallyIncludedInFlagsAttribute(IFieldSymbol field) .Any(attr => attr.AttributeClass?.Name.StartsWith("ConditionallyIncludeInFlags") ?? false); } + private static bool HasDifficultyOnlyAttribute(IFieldSymbol field) + { + return field.GetAttributes() + .Any(attr => attr.AttributeClass?.Name.StartsWith("DifficultyOnly") ?? false); + } + private static string GetDefaultValue(IFieldSymbol f) { var defaultAttr = f.GetAttributes() @@ -209,7 +216,7 @@ private static List GetPassThroughAttributes(IFieldSymbol field) // Look for attributes with "property:" target if (attr.AttributeClass == null) continue; var attrName = attr.AttributeClass.Name; - if (attrName.StartsWith("Reactive") || attrName.StartsWith("CustomFlagSerializer")) continue; + if (attrName.StartsWith("Reactive") || attrName.StartsWith("CustomFlagSerializer") || attrName.StartsWith("DifficultyOnly")) continue; // if (attrName.EndsWith("Attribute")) // attrName = attrName[..^9]; @@ -318,7 +325,7 @@ private static void GenerateReactiveProperty(StringBuilder sb, ReactiveFieldInfo private static void GenerateSerializeMethod(StringBuilder sb, List fields, string indent) { sb.AppendLine(); - sb.AppendLine($"{indent} public string Serialize()"); + sb.AppendLine($"{indent} private string Serialize(bool includeDifficultyOnly)"); sb.AppendLine($"{indent} {{"); sb.AppendLine($"{indent} global::Z2Randomizer.RandomizerCore.Flags.FlagBuilder flags = new();"); sb.AppendLine(); @@ -326,9 +333,19 @@ private static void GenerateSerializeMethod(StringBuilder sb, List(); + if (field.IsDifficultyOnly) + { + conditions.Add("includeDifficultyOnly"); + } if (field.IsConditionallyIncluded) { - sb.AppendLine($"{indent} if ({field.FieldName}Included()) {{ "); + conditions.Add($"{field.FieldName}Included()"); + } + + if (conditions.Count > 0) + { + sb.AppendLine($"{indent} if ({string.Join(" && ", conditions)}) {{ "); sb.AppendLine($"{indent} {serializeCall};"); sb.AppendLine($"{indent} }}"); } @@ -341,6 +358,16 @@ private static void GenerateSerializeMethod(StringBuilder sb, List fields, string indent) @@ -579,6 +606,7 @@ public class SerializedFieldInfo { public string FieldName { get; set; } = string.Empty; public string FieldType { get; set; } = string.Empty; + public bool IsDifficultyOnly { get; set; } public bool IsConditionallyIncluded { get; set; } public string? DefaultValue { get; set; } public bool IsEnum { get; set; } @@ -601,4 +629,4 @@ public class AttributeInfo { public string Name { get; set; } = string.Empty; public List Arguments { get; set; } = new(); -} \ No newline at end of file +} diff --git a/CrossPlatformUI/Lang/Resources.resx b/CrossPlatformUI/Lang/Resources.resx index 209ec312..01d13f4b 100644 --- a/CrossPlatformUI/Lang/Resources.resx +++ b/CrossPlatformUI/Lang/Resources.resx @@ -934,6 +934,20 @@ randomly selected between these values (inclusive). Select the number of lives Link starts with when the game begins or on game over. Random selects a random number of starting lives between 2 and 5. + + +When enabled, the following settings no longer change the shared 4-character seed prefix: +- Starting candle / cross +- Starting sword techniques (upstab / downstab) +- Starting lives +- Starting attack / magic / life levels +- Attack / magic / life level caps and "scale level requirements to cap" +- Attack and life effectiveness +- Enemy HP, boss HP, and enemy XP drop shuffle + +The last 2 characters of the hash still reflect these settings, so two seeds with different difficulty will share a prefix but differ in the suffix. + +Use this only when everyone intentionally wants the same base seed with different handicaps. For flags set to an indeterminate value (question mark), those flags will be on or off at diff --git a/CrossPlatformUI/Views/Tabs/StartView.axaml b/CrossPlatformUI/Views/Tabs/StartView.axaml index 93b07580..a9cf4142 100644 --- a/CrossPlatformUI/Views/Tabs/StartView.axaml +++ b/CrossPlatformUI/Views/Tabs/StartView.axaml @@ -79,6 +79,14 @@ > + + + + diff --git a/RandomizerCore/Flags/DifficultyOnlyAttribute.cs b/RandomizerCore/Flags/DifficultyOnlyAttribute.cs new file mode 100644 index 00000000..d58b2a42 --- /dev/null +++ b/RandomizerCore/Flags/DifficultyOnlyAttribute.cs @@ -0,0 +1,8 @@ +using System; + +namespace Z2Randomizer.RandomizerCore.Flags; + +[AttributeUsage(AttributeTargets.Field)] +public class DifficultyOnlyAttribute : Attribute +{ +} diff --git a/RandomizerCore/Hyrule.cs b/RandomizerCore/Hyrule.cs index 993f4584..6d4e1804 100644 --- a/RandomizerCore/Hyrule.cs +++ b/RandomizerCore/Hyrule.cs @@ -229,13 +229,16 @@ public async Task Randomize(byte[] vanillaRomData, RandomizerC SeedHash = BitConverter.ToInt32(MD5Hash.ComputeHash(Encoding.UTF8.GetBytes(config.Seed!)).AsSpan()[..4]); r = new Random(SeedHash); + bool shareSeedAcrossDifficulty = config.ShareSeedAcrossDifficulty; + Random? difficultyRng = shareSeedAcrossDifficulty ? CreateDifficultyRng(config.Seed) : null; config.CheckForFlagConflicts(); - props = config.Export(r); + props = config.Export(r, includeDifficulty: !shareSeedAcrossDifficulty); //To make sure there isn't any similarity between the spoiler and non-spoiler versions of the seed, spin the RNG a bit. if(config.GenerateSpoiler) { r.NextBytes(new byte[64]); + difficultyRng?.NextBytes(new byte[64]); } #if UNSAFE_DEBUG string export = JsonSerializer.Serialize(props, SourceGenerationContext.Default.RandomizerProperties); @@ -243,8 +246,12 @@ public async Task Randomize(byte[] vanillaRomData, RandomizerC #endif Flags = config.SerializeFlags(); + // If shared difficulty is on, we need a stripped version of the + // flags for the shared part of the seed hash. + string sharedSeedFlags = shareSeedAcrossDifficulty ? config.SerializeSharedSeedFlags() : Flags; + using Assembler assembler = CreateAssemblyEngine(); - logger.Info($"Started generation for flags: {Flags} seed: {config.Seed} seedhash: {SeedHash}"); + logger.Info($"Started generation for flags: {Flags} sharedseedflags: {sharedSeedFlags} seed: {config.Seed} seedhash: {SeedHash}"); //character = new Character(props); shuffler = new Shuffler(props); @@ -413,7 +420,22 @@ public async Task Randomize(byte[] vanillaRomData, RandomizerC List texts = CustomTexts.GenerateTexts(AllLocationsForReal(), itemLocs, ROMData.GetGameText(), props, r); StatRandomizer randomizedStats = new(ROMData, props); - randomizedStats.Randomize(r); + randomizedStats.Randomize(r, skipDifficultyOnly: shareSeedAcrossDifficulty); + + // Apply difficulty after shared randomization, then let ApplyAsmPatches see full + // props. A second ApplyAsm pass would re-claim PRG4/PRG5 free space (js65 sees it + // as fully free) and clobber first-pass code like the palace elevator routines. + byte[]? sharedRngState = null; + if (shareSeedAcrossDifficulty) + { + sharedRngState = new byte[32]; + r.NextBytes(sharedRngState); + + config.ApplyDifficultyOnlySettings(props, difficultyRng!); + ReplaceDifficultyStartingItemsInWorld(difficultyRng!); + randomizedStats.RandomizeDifficultyOnly(difficultyRng!); + } + randomizedStats.Write(ROMData); // ideally this should be calculated later, but custom music changes asm patches @@ -498,31 +520,25 @@ public async Task Randomize(byte[] vanillaRomData, RandomizerC } } - byte[] finalRNGState = new byte[32]; - - r.NextBytes(finalRNGState); - byte[] hash = MD5Hash.ComputeHash(Encoding.UTF8.GetBytes( - Flags + - SeedHash + - randoRomHash + // ideally this should be all that's required - // Util.ReadAllTextFromFile(config.GetRoomsFile()) + - Util.ByteArrayToHexString(finalRNGState) - )); - UpdateRom(); - //0 -> W to avoid 0/O confusion, also 6/G so 6 -> X (these are not hypothetical, they have already caused confusion) - byte[] z2Hash = ConvertHash(hash); - for(int i = 0; i < z2Hash.Length; i++) + byte[] z2Hash; + if (shareSeedAcrossDifficulty) { - if(z2Hash[i] == 0xD0) - { - z2Hash[i] = 0xF0; - } - if (z2Hash[i] == 0xD6) - { - z2Hash[i] = 0xF1; - } + byte[] sharedHash = CalculateHash(sharedSeedFlags, SeedHash, randoRomHash, sharedRngState!); + + byte[] difficultyRngState = new byte[32]; + difficultyRng!.NextBytes(difficultyRngState); + byte[] finalHash = CalculateHash(Flags, SeedHash, randoRomHash, difficultyRngState); + z2Hash = CombineHashes(sharedHash, finalHash); + } + else + { + byte[] finalRngState = new byte[32]; + r.NextBytes(finalRngState); + byte[] finalHash = CalculateHash(Flags, SeedHash, randoRomHash, finalRngState); + z2Hash = ConvertHash(finalHash); + SanitizeHashCharacters(z2Hash); } ROMData.Put(0x17C2C, z2Hash); @@ -590,6 +606,84 @@ private static byte[] ConvertHash(byte[] hash) ]; } + private static byte[] CalculateHash(string flags, int seedHash, byte[] romHash, byte[] rngState) + { + return MD5Hash.ComputeHash(Encoding.UTF8.GetBytes( + flags + + seedHash + + romHash + + Util.ByteArrayToHexString(rngState) + )); + } + + private static Random CreateDifficultyRng(string? seed) + { + int difficultySeedHash = BitConverter.ToInt32(MD5Hash.ComputeHash(Encoding.UTF8.GetBytes($"{seed}:difficulty")).AsSpan()[..4]); + return new Random(difficultySeedHash); + } + + private static byte[] CombineHashes(byte[] sharedHash, byte[] finalHash) + { + byte[] sharedZ2Hash = ConvertHash(sharedHash); + byte[] finalZ2Hash = ConvertHash(finalHash); + SanitizeHashCharacters(sharedZ2Hash); + SanitizeHashCharacters(finalZ2Hash); + + return [ + sharedZ2Hash[0], 0xF4, + sharedZ2Hash[2], 0xF4, + sharedZ2Hash[4], 0xF4, + sharedZ2Hash[6], 0xF4, + finalZ2Hash[8], 0xF4, + finalZ2Hash[10] + ]; + } + + private static void SanitizeHashCharacters(byte[] z2Hash) + { + for (int i = 0; i < z2Hash.Length; i++) + { + if (z2Hash[i] == 0xD0) + { + z2Hash[i] = 0xF0; + } + if (z2Hash[i] == 0xD6) + { + z2Hash[i] = 0xF1; + } + } + } + + // In shared-seed mode, candle/cross are placed in the world during the + // shared shuffle (StartCandle/StartCross were false at Export time). If + // the final difficulty flags have the player starting with either item, + // swap that item's world pickup for a minor item so the seed doesn't + // hand out a duplicate. + private void ReplaceDifficultyStartingItemsInWorld(Random r) + { + List minorItems = [ + Collectable.BLUE_JAR, Collectable.RED_JAR, Collectable.SMALL_BAG, + Collectable.MEDIUM_BAG, Collectable.LARGE_BAG, Collectable.XL_BAG, + Collectable.ONEUP, Collectable.KEY + ]; + Collectable[] difficultyStartingItems = [Collectable.CANDLE, Collectable.CROSS]; + + foreach (Collectable item in difficultyStartingItems) + { + if (!props.StartsWithCollectable(item)) { continue; } + foreach (Location location in itemLocs) + { + for (int i = 0; i < location.Collectables.Count; i++) + { + if (location.Collectables[i] == item) + { + location.Collectables[i] = minorItems.Sample(r); + } + } + } + } + } + /* Text Notes: diff --git a/RandomizerCore/RandomizerConfiguration.cs b/RandomizerCore/RandomizerConfiguration.cs index e207672d..1a2ebdfe 100644 --- a/RandomizerCore/RandomizerConfiguration.cs +++ b/RandomizerCore/RandomizerConfiguration.cs @@ -47,6 +47,22 @@ public sealed partial class RandomizerConfiguration : INotifyPropertyChanged Collectable.MAGIC_KEY ]; + [IgnoreInFlags] + private readonly static Collectable[] POSSIBLE_SHARED_STARTING_ITEMS = [ + Collectable.GLOVE, + Collectable.RAFT, + Collectable.BOOTS, + Collectable.FLUTE, + Collectable.HAMMER, + Collectable.MAGIC_KEY + ]; + + [IgnoreInFlags] + private readonly static Collectable[] POSSIBLE_DIFFICULTY_STARTING_ITEMS = [ + Collectable.CANDLE, + Collectable.CROSS + ]; + [IgnoreInFlags] private readonly static Collectable[] POSSIBLE_STARTING_SPELLS = [ Collectable.SHIELD_SPELL, @@ -65,6 +81,7 @@ public sealed partial class RandomizerConfiguration : INotifyPropertyChanged private bool shuffleStartingItems; [Reactive] + [DifficultyOnly] private bool startWithCandle; [Reactive] @@ -80,6 +97,7 @@ public sealed partial class RandomizerConfiguration : INotifyPropertyChanged private bool startWithFlute; [Reactive] + [DifficultyOnly] private bool startWithCross; [Reactive] @@ -145,22 +163,27 @@ public sealed partial class RandomizerConfiguration : INotifyPropertyChanged private MaxHeartsOption maxHeartContainers; [Reactive] + [DifficultyOnly] private StartingTechs startingTechniques; [Reactive] + [DifficultyOnly] private StartingLives startingLives; [Reactive] + [DifficultyOnly] [Minimum(1)] [Maximum(8)] private int startingAttackLevel; [Reactive] + [DifficultyOnly] [Minimum(1)] [Maximum(8)] private int startingMagicLevel; [Reactive] + [DifficultyOnly] [Minimum(1)] [Maximum(8)] private int startingLifeLevel; @@ -436,32 +459,38 @@ private bool palaceStylesAnyMetastyleSelected() private bool shuffleLifeExperience; [Reactive] + [DifficultyOnly] [Minimum(1)] [Maximum(8)] private int attackLevelCap; [Reactive] + [DifficultyOnly] [Minimum(1)] [Maximum(8)] private int magicLevelCap; [Reactive] + [DifficultyOnly] [Minimum(1)] [Maximum(8)] private int lifeLevelCap; [Reactive] + [DifficultyOnly] [ConditionallyIncludeInFlags] private bool scaleLevelRequirementsToCap; public bool scaleLevelRequirementsToCapIncluded() => attackLevelCap < 8 || magicLevelCap < 8 || lifeLevelCap < 8; [Reactive] + [DifficultyOnly] private AttackEffectiveness attackEffectiveness; [Reactive] private MagicEffectiveness magicEffectiveness; [Reactive] + [DifficultyOnly] private LifeEffectiveness lifeEffectiveness; //Spells @@ -508,9 +537,11 @@ private bool palaceStylesAnyMetastyleSelected() public bool generatorsAlwaysMatchIncluded() => anyEnemiesAreShuffled(); [Reactive] + [DifficultyOnly] private EnemyLifeOption shuffleEnemyHP; [Reactive] + [DifficultyOnly] private EnemyLifeOption shuffleBossHP; [Reactive] @@ -523,6 +554,7 @@ private bool palaceStylesAnyMetastyleSelected() private bool shuffleSwordImmunity; [Reactive] + [DifficultyOnly] private XPEffectiveness enemyXPDrops; //Items @@ -758,6 +790,9 @@ private bool palaceStylesAnyMetastyleSelected() [Reactive] private bool revealWalkthroughWalls; + [Reactive] + private bool shareSeedAcrossDifficulty; + //Meta [Reactive] [Required] @@ -774,6 +809,11 @@ public String SerializeFlags() return Serialize(); } + public string SerializeSharedSeedFlags() + { + return SerializeSharedSeed(); + } + public RandomizerConfiguration() { startingAttackLevel = 1; @@ -874,7 +914,7 @@ public static void SerializeEnum(FlagBuilder flags, string name, T? val) wher flags.Append(index, extent); } - public RandomizerProperties Export(Random r) + public RandomizerProperties Export(Random r, bool includeDifficulty = true) { RandomizerProperties properties = new() { @@ -893,7 +933,8 @@ public RandomizerProperties Export(Random r) do // while (!properties.HasEnoughSpaceToAllocateItems()) { //Start Configuration - ShuffleStartingCollectables(POSSIBLE_STARTING_ITEMS, startItemsLimit, shuffleStartingItems, properties, r); + ShuffleStartingCollectables(includeDifficulty ? POSSIBLE_STARTING_ITEMS : POSSIBLE_SHARED_STARTING_ITEMS, + startItemsLimit, shuffleStartingItems, properties, r); ShuffleStartingCollectables(POSSIBLE_STARTING_SPELLS, startSpellsLimit, shuffleStartingSpells, properties, r); List allowedPalaceStyles; @@ -955,47 +996,7 @@ public RandomizerProperties Export(Random r) AssignPalaceItemCounts(properties, r); //Other starting attributes - int startHeartsMin, startHeartsMax; - if (startingHeartContainersMin == null) - { - startHeartsMin = r.Next(1, 9); - } - else - { - startHeartsMin = (int)startingHeartContainersMin; - } - if (startingHeartContainersMax == null) - { - startHeartsMax = r.Next(startHeartsMin, 9); - } - else - { - startHeartsMax = (int)startingHeartContainersMax; - } - properties.StartHearts = r.Next(startHeartsMin, startHeartsMax + 1); - - //+1/+2/+3 - if (maxHeartContainers == MaxHeartsOption.RANDOM) - { - properties.MaxHearts = r.Next(properties.StartHearts, 9); - } - else if ((int)maxHeartContainers <= 8) - { - properties.MaxHearts = (int)maxHeartContainers; - } - else - { - int additionalHearts = maxHeartContainers switch - { - MaxHeartsOption.PLUS_ONE => 1, - MaxHeartsOption.PLUS_TWO => 2, - MaxHeartsOption.PLUS_THREE => 3, - MaxHeartsOption.PLUS_FOUR => 4, - _ => throw new ImpossibleException("Invalid heart container max configuration") - }; - properties.MaxHearts = Math.Min(properties.StartHearts + additionalHearts, 8); - } - properties.MaxHearts = Math.Max(properties.MaxHearts, properties.StartHearts); + (properties.StartHearts, properties.MaxHearts) = ResolveHeartSettings(r); int startMagicsMin, startMagicsMax; if (startingMagicContainersMin == null) @@ -1055,56 +1056,21 @@ public RandomizerProperties Export(Random r) break; } - //If both stabs are random, use the classic weightings - if (startingTechniques == StartingTechs.RANDOM) - { - switch (r.Next(7)) - { - case 0: - case 1: - case 2: - case 3: - properties.StartWithDownstab = false; - properties.StartWithUpstab = false; - break; - case 4: - properties.StartWithDownstab = true; - properties.StartWithUpstab = false; - break; - case 5: - properties.StartWithDownstab = false; - properties.StartWithUpstab = true; - break; - case 6: - properties.StartWithDownstab = true; - properties.StartWithUpstab = true; - break; - } - } - else - { - properties.StartWithDownstab = startingTechniques.StartWithDownstab(); - properties.StartWithUpstab = startingTechniques.StartWithUpstab(); - } + ResolveStartingTechniques(properties, r, includeDifficulty); properties.SwapUpAndDownStab = swapUpAndDownStab ?? GetIndeterminateFlagValue(r); - properties.StartLives = startingLives switch - { - StartingLives.Lives1 => 1, - StartingLives.Lives2 => 2, - StartingLives.Lives3 => 3, - StartingLives.Lives4 => 4, - StartingLives.Lives5 => 5, - StartingLives.Lives8 => 8, - StartingLives.Lives16 => 16, - _ => r.Next(2, 6) - }; + properties.StartLives = ResolveStartingLives(r, includeDifficulty); properties.PermanentBeam = permanentBeamSword; properties.UseCommunityText = useCommunityText; - properties.StartAtk = startingAttackLevel; - properties.StartingMagicLevel = startingMagicLevel; - properties.StartLifeLvl = startingLifeLevel; + + // If shared difficulty is enabled and we're setting up the shared + // part of the properties, just set the attributes to basic values + // for the sake of keeping everything happy. ApplyDifficultyOnlySettings + // will take care of these settings later. + properties.StartAtk = includeDifficulty ? startingAttackLevel : 1; + properties.StartingMagicLevel = includeDifficulty ? startingMagicLevel : 1; + properties.StartLifeLvl = includeDifficulty ? startingLifeLevel : 1; //Overworld properties.ShuffleEncounters = shuffleEncounters ?? GetIndeterminateFlagValue(r); @@ -1372,8 +1338,8 @@ public RandomizerProperties Export(Random r) properties.RevealWalkthroughWalls = revealWalkthroughWalls; //Enemies - properties.ShuffleEnemyHP = shuffleEnemyHP; - properties.ShuffleBossHP = shuffleBossHP; + properties.ShuffleEnemyHP = includeDifficulty ? shuffleEnemyHP : EnemyLifeOption.VANILLA; + properties.ShuffleBossHP = includeDifficulty ? shuffleBossHP : EnemyLifeOption.VANILLA; properties.ShuffleEnemyStealExp = shuffleXPStealers; properties.ShuffleStealExpAmt = shuffleXPStolenAmount; properties.ShuffleSwordImmunity = shuffleSwordImmunity; @@ -1383,22 +1349,22 @@ public RandomizerProperties Export(Random r) properties.DripperEnemyOption = dripperEnemyOption; properties.SpellEnemy = randomizeSpellSpellEnemy ?? GetIndeterminateFlagValue(r); properties.ShuffleEnemyPalettes = shuffleSpritePalettes; - properties.EnemyXPDrops = enemyXPDrops; + properties.EnemyXPDrops = includeDifficulty ? enemyXPDrops : XPEffectiveness.VANILLA; //Levels properties.ShuffleAtkExp = shuffleAttackExperience; properties.ShuffleMagicExp = shuffleMagicExperience; properties.ShuffleLifeExp = shuffleLifeExperience; - properties.AttackEffectiveness = attackEffectiveness; + properties.AttackEffectiveness = includeDifficulty ? attackEffectiveness : AttackEffectiveness.VANILLA; properties.MagicEffectiveness = magicEffectiveness; - properties.LifeEffectiveness = lifeEffectiveness; + properties.LifeEffectiveness = includeDifficulty ? lifeEffectiveness : LifeEffectiveness.VANILLA; properties.ShuffleLifeRefill = shuffleLifeRefillAmount; properties.ShuffleSpellLocations = shuffleSpellLocations ?? GetIndeterminateFlagValue(r); properties.DisableMagicRecs = disableMagicContainerRequirements ?? GetIndeterminateFlagValue(r); - properties.AttackCap = attackLevelCap; - properties.MagicCap = magicLevelCap; - properties.LifeCap = lifeLevelCap; - properties.ScaleLevels = scaleLevelRequirementsToCap; + properties.AttackCap = includeDifficulty ? attackLevelCap : 8; + properties.MagicCap = includeDifficulty ? magicLevelCap : 8; + properties.LifeCap = includeDifficulty ? lifeLevelCap : 8; + properties.ScaleLevels = includeDifficulty && scaleLevelRequirementsToCap; //Items properties.ShuffleOverworldItems = shuffleOverworldItems ?? GetIndeterminateFlagValue(r); @@ -1615,6 +1581,25 @@ public RandomizerProperties Export(Random r) return properties; } + public void ApplyDifficultyOnlySettings(RandomizerProperties properties, Random r) + { + ShuffleStartingCollectables(POSSIBLE_DIFFICULTY_STARTING_ITEMS, startItemsLimit, shuffleStartingItems, properties, r); + ResolveStartingTechniques(properties, r, includeDifficulty: true); + properties.StartLives = ResolveStartingLives(r, includeDifficulty: true); + properties.StartAtk = startingAttackLevel; + properties.StartingMagicLevel = startingMagicLevel; + properties.StartLifeLvl = startingLifeLevel; + properties.AttackCap = attackLevelCap; + properties.MagicCap = magicLevelCap; + properties.LifeCap = lifeLevelCap; + properties.ScaleLevels = scaleLevelRequirementsToCap; + properties.AttackEffectiveness = attackEffectiveness; + properties.LifeEffectiveness = lifeEffectiveness; + properties.ShuffleEnemyHP = shuffleEnemyHP; + properties.ShuffleBossHP = shuffleBossHP; + properties.EnemyXPDrops = enemyXPDrops; + } + public void AssignPalaceItemCounts(RandomizerProperties properties, Random r) { //I'm not sure whether I like the bias introduced in generating random values and then capping them @@ -1709,11 +1694,7 @@ public static int[] GetPalaceItemRoomLimits(PalaceStyle[] palaceStyles) /// scenario should work. public void CheckForFlagConflicts() { - int requiredMinorItemReplacements = 0; - if ((startingHeartContainersMax ?? 8) < 4) - { - requiredMinorItemReplacements = 4 - (startingHeartContainersMax ?? 4); - } + int requiredMinorItemReplacements = Math.Max(0, 4 - ((startingHeartContainersMax ?? 4))); if (CountPossibleMinorItems() < requiredMinorItemReplacements) { throw new UserFacingException("Impossible Item Flags", "Not enough possible item locations for removed palace items.\n\nAdd more starting items or more palace items."); @@ -1837,20 +1818,116 @@ private void ShuffleStartingCollectables(Collectable[] possibleCollectables, Sta } } + private (int StartHearts, int MaxHearts) ResolveHeartSettings(Random r) + { + int startHeartsMin = startingHeartContainersMin ?? r.Next(1, 9); + int startHeartsMax = startingHeartContainersMax ?? r.Next(startHeartsMin, 9); + int startHearts = r.Next(startHeartsMin, startHeartsMax + 1); + int maxHearts = ResolveMaxHearts(r, startHearts); + maxHearts = Math.Max(maxHearts, startHearts); + return (startHearts, maxHearts); + } + + private int ResolveMaxHearts(Random r, int startHearts) + { + if (maxHeartContainers == MaxHeartsOption.RANDOM) + { + return r.Next(startHearts, 9); + } + if ((int)maxHeartContainers <= 8) + { + return (int)maxHeartContainers; + } + + int additionalHearts = maxHeartContainers switch + { + MaxHeartsOption.PLUS_ONE => 1, + MaxHeartsOption.PLUS_TWO => 2, + MaxHeartsOption.PLUS_THREE => 3, + MaxHeartsOption.PLUS_FOUR => 4, + _ => throw new ImpossibleException("Invalid heart container max configuration") + }; + return Math.Min(startHearts + additionalHearts, 8); + } + + private void ResolveStartingTechniques(RandomizerProperties properties, Random r, bool includeDifficulty) + { + if (!includeDifficulty) + { + properties.StartWithDownstab = false; + properties.StartWithUpstab = false; + return; + } + + if (startingTechniques == StartingTechs.RANDOM) + { + switch (r.Next(7)) + { + case 0: + case 1: + case 2: + case 3: + properties.StartWithDownstab = false; + properties.StartWithUpstab = false; + break; + case 4: + properties.StartWithDownstab = true; + properties.StartWithUpstab = false; + break; + case 5: + properties.StartWithDownstab = false; + properties.StartWithUpstab = true; + break; + case 6: + properties.StartWithDownstab = true; + properties.StartWithUpstab = true; + break; + } + } + else + { + properties.StartWithDownstab = startingTechniques.StartWithDownstab(); + properties.StartWithUpstab = startingTechniques.StartWithUpstab(); + } + } + + private int ResolveStartingLives(Random r, bool includeDifficulty) + { + if (!includeDifficulty) + { + return 3; + } + + return startingLives switch + { + StartingLives.Lives1 => 1, + StartingLives.Lives2 => 2, + StartingLives.Lives3 => 3, + StartingLives.Lives4 => 4, + StartingLives.Lives5 => 5, + StartingLives.Lives8 => 8, + StartingLives.Lives16 => 16, + _ => r.Next(2, 6) + }; + } + private int CountPossibleMinorItems() { int count = 3, hardStartItemsCount = 0; - hardStartItemsCount += shuffleStartingItems || startWithCandle ? 1 : 0; + hardStartItemsCount += !shareSeedAcrossDifficulty && (shuffleStartingItems || startWithCandle) ? 1 : 0; hardStartItemsCount += shuffleStartingItems || startWithBoots ? 1 : 0; - hardStartItemsCount += shuffleStartingItems || startWithCross ? 1 : 0; + hardStartItemsCount += !shareSeedAcrossDifficulty && (shuffleStartingItems || startWithCross) ? 1 : 0; hardStartItemsCount += shuffleStartingItems || startWithFlute ? 1 : 0; hardStartItemsCount += shuffleStartingItems || startWithGlove ? 1 : 0; hardStartItemsCount += shuffleStartingItems || startWithHammer ? 1 : 0; hardStartItemsCount += shuffleStartingItems || startWithMagicKey ? 1 : 0; hardStartItemsCount += shuffleStartingItems || startWithRaft ? 1 : 0; - count += Math.Max(hardStartItemsCount, shuffleStartingItems ? startItemsLimit.AsInt() : 0); + int possibleStartItemLimit = shareSeedAcrossDifficulty + ? Math.Min(startItemsLimit.AsInt(), POSSIBLE_SHARED_STARTING_ITEMS.Length) + : startItemsLimit.AsInt(); + count += Math.Max(hardStartItemsCount, shuffleStartingItems ? possibleStartItemLimit : 0); if(includeSpellsInShuffle ?? true) { diff --git a/RandomizerCore/StatRandomizer.cs b/RandomizerCore/StatRandomizer.cs index f0a8b050..c3e6f086 100644 --- a/RandomizerCore/StatRandomizer.cs +++ b/RandomizerCore/StatRandomizer.cs @@ -54,15 +54,22 @@ public StatRandomizer(ROM rom, RandomizerProperties props) ReadEnemyStats(rom); } - public void Randomize(Random r) + public void Randomize(Random r, bool skipDifficultyOnly = false) { #if DEBUG Debug.Assert(!hasRandomized); hasRandomized = true; #endif - ExperienceToLevelTable = RandomizeExperienceToLevel(ExperienceToLevelTable, r, - [props.ShuffleAtkExp, props.ShuffleMagicExp, props.ShuffleLifeExp], - [props.AttackCap, props.MagicCap, props.LifeCap], props.ScaleLevels); + // When shared-seed mode is on, the XP level table depends on + // difficulty-only props (level caps, scaleLevels) and is randomized + // from vanilla in RandomizeDifficultyOnly. Skipping here keeps a + // single randomization pass against the vanilla baseline. + if (!skipDifficultyOnly) + { + ExperienceToLevelTable = RandomizeExperienceToLevel(ExperienceToLevelTable, r, + [props.ShuffleAtkExp, props.ShuffleMagicExp, props.ShuffleLifeExp], + [props.AttackCap, props.MagicCap, props.LifeCap], props.ScaleLevels); + } RandomizeAttackEffectiveness(r, props.AttackEffectiveness); RandomizeLifeEffectiveness(r, props.LifeEffectiveness); @@ -74,6 +81,19 @@ public void Randomize(Random r) RandomizeEnemyStats(r); } + public void RandomizeDifficultyOnly(Random r) + { + ExperienceToLevelTable = RandomizeExperienceToLevel(ExperienceToLevelTable, r, + [props.ShuffleAtkExp, props.ShuffleMagicExp, props.ShuffleLifeExp], + [props.AttackCap, props.MagicCap, props.LifeCap], props.ScaleLevels); + RandomizeAttackEffectiveness(r, props.AttackEffectiveness); + RandomizeLifeEffectiveness(r, props.LifeEffectiveness); + RandomizeRegularEnemyHp(r); + RandomizeBossHp(r); + FixRebonackHorseKillBug(); + RandomizeEnemyExperienceDrops(r); + } + public void Write(ROM rom) { #if DEBUG @@ -568,6 +588,16 @@ protected void RandomizeEnemyStats(Random r) RandomizeEnemyExp(r, BossExpTable, props.EnemyXPDrops); // randomize boss XP separately } + protected void RandomizeEnemyExperienceDrops(Random r) + { + RandomizeEnemyExpForTable(r, WestEnemyStatsTable, Enemies.WestGroundEnemies, Enemies.WestFlyingEnemies, Enemies.WestGenerators); + RandomizeEnemyExpForTable(r, EastEnemyStatsTable, Enemies.EastGroundEnemies, Enemies.EastFlyingEnemies, Enemies.EastGenerators); + RandomizeEnemyExpForTable(r, Palace125EnemyStatsTable, Enemies.Palace125GroundEnemies, Enemies.Palace125FlyingEnemies, Enemies.Palace125Generators); + RandomizeEnemyExpForTable(r, Palace346EnemyStatsTable, Enemies.Palace346GroundEnemies, Enemies.Palace346FlyingEnemies, Enemies.Palace346Generators); + RandomizeEnemyExpForTable(r, GpEnemyStatsTable, Enemies.GPGroundEnemies, Enemies.GPFlyingEnemies, Enemies.GPGenerators); + RandomizeEnemyExp(r, BossExpTable, props.EnemyXPDrops); + } + protected void RandomizeEnemyAttributes(Random r, byte[] bytes, T[] groundEnemies, T[] flyingEnemies, T[] generators) where T : Enum { List allEnemies = [.. groundEnemies, .. flyingEnemies, .. generators]; @@ -624,6 +654,17 @@ protected void RandomizeEnemyAttributes(Random r, byte[] bytes, T[] groundEne } } + protected void RandomizeEnemyExpForTable(Random r, byte[] bytes, T[] groundEnemies, T[] flyingEnemies, T[] generators) where T : Enum + { + List allEnemies = [.. groundEnemies, .. flyingEnemies, .. generators]; + byte[] enemyBytes = allEnemies.Select(n => bytes[(int)(object)n]).ToArray(); + RandomizeEnemyExp(r, enemyBytes, props.EnemyXPDrops); + for (int i = 0; i < allEnemies.Count; i++) + { + bytes[(int)(object)allEnemies[i]] = enemyBytes[i]; + } + } + protected static void RandomizeEnemyExp(Random r, byte[] bytes, XPEffectiveness effectiveness) { RandomRangeIntAttribute? range = effectiveness.GetRandomRangeInt(); @@ -713,10 +754,10 @@ public void FixRebonackHorseKillBug() } } - public int GetSpellCost(Collectable spell, int level) - { - var spellIndex = spell.VanillaSpellOrder(); - int index = spellIndex * 8 + level - 1; - return MagicEffectivenessTable[index]; + public int GetSpellCost(Collectable spell, int level) + { + var spellIndex = spell.VanillaSpellOrder(); + int index = spellIndex * 8 + level - 1; + return MagicEffectivenessTable[index]; } } diff --git a/Tests/FlagsTests.cs b/Tests/FlagsTests.cs index d9bd0a11..7318fb3d 100644 --- a/Tests/FlagsTests.cs +++ b/Tests/FlagsTests.cs @@ -156,4 +156,85 @@ public void TestMaxRandoEncodeCycle() RandomizerConfiguration config2 = new RandomizerConfiguration(MaxRando2025Preset.Preset.SerializeFlags()); Assert.AreEqual(config.SerializeFlags(), config2.SerializeFlags()); } + + [TestMethod] + public void DifficultyOnlyFlagsDoNotChangeSharedSeedFlags() + { + RandomizerConfiguration baseConfig = new() + { + ShareSeedAcrossDifficulty = true + }; + RandomizerConfiguration difficultyConfig = new() + { + ShareSeedAcrossDifficulty = true, + StartWithCandle = true, + StartWithCross = true, + StartingTechniques = StartingTechs.BOTH, + StartingLives = StartingLives.Lives1, + AttackLevelCap = 4, + MagicLevelCap = 6, + LifeLevelCap = 7, + ScaleLevelRequirementsToCap = true, + AttackEffectiveness = AttackEffectiveness.OHKO, + LifeEffectiveness = LifeEffectiveness.INVINCIBLE, + ShuffleEnemyHP = EnemyLifeOption.WIDE, + ShuffleBossHP = EnemyLifeOption.MEDIUM_HIGH, + EnemyXPDrops = XPEffectiveness.NONE + }; + + Assert.AreNotEqual(baseConfig.SerializeFlags(), difficultyConfig.SerializeFlags()); + Assert.AreEqual(baseConfig.SerializeSharedSeedFlags(), difficultyConfig.SerializeSharedSeedFlags()); + } + + [TestMethod] + public void SharedSeedExportIgnoresDifficultyOnlySettings() + { + RandomizerConfiguration config = new() + { + ShareSeedAcrossDifficulty = true, + StartWithCandle = true, + StartWithCross = true, + StartingTechniques = StartingTechs.BOTH, + StartingLives = StartingLives.Lives1, + AttackLevelCap = 4, + MagicLevelCap = 6, + LifeLevelCap = 7, + ScaleLevelRequirementsToCap = true, + AttackEffectiveness = AttackEffectiveness.OHKO, + LifeEffectiveness = LifeEffectiveness.INVINCIBLE, + ShuffleEnemyHP = EnemyLifeOption.WIDE, + ShuffleBossHP = EnemyLifeOption.MEDIUM_HIGH, + EnemyXPDrops = XPEffectiveness.NONE + }; + + RandomizerProperties properties = config.Export(new Random(1234), includeDifficulty: false); + + Assert.IsFalse(properties.StartCandle); + Assert.IsFalse(properties.StartCross); + Assert.IsFalse(properties.StartWithDownstab); + Assert.IsFalse(properties.StartWithUpstab); + Assert.AreEqual(3, properties.StartLives); + Assert.AreEqual(8, properties.AttackCap); + Assert.AreEqual(8, properties.MagicCap); + Assert.AreEqual(8, properties.LifeCap); + Assert.IsFalse(properties.ScaleLevels); + Assert.AreEqual(AttackEffectiveness.VANILLA, properties.AttackEffectiveness); + Assert.AreEqual(LifeEffectiveness.VANILLA, properties.LifeEffectiveness); + Assert.AreEqual(EnemyLifeOption.VANILLA, properties.ShuffleEnemyHP); + Assert.AreEqual(EnemyLifeOption.VANILLA, properties.ShuffleBossHP); + Assert.AreEqual(XPEffectiveness.VANILLA, properties.EnemyXPDrops); + } + + [TestMethod] + public void ShareSeedAcrossDifficultyRoundTripsInFlags() + { + RandomizerConfiguration config = new() + { + ShareSeedAcrossDifficulty = true + }; + + RandomizerConfiguration config2 = new(config.SerializeFlags()); + + Assert.IsTrue(config2.ShareSeedAcrossDifficulty); + } }