diff --git a/Randomizer.SMZ3.Tests/Logic/Case.cs b/Randomizer.SMZ3.Tests/Logic/Case.cs index 2b318650..e38eeee6 100644 --- a/Randomizer.SMZ3.Tests/Logic/Case.cs +++ b/Randomizer.SMZ3.Tests/Logic/Case.cs @@ -127,7 +127,7 @@ internal Progression Prepare(World world, string name, int? skip = null) { if (skip != null) { preparations.ElementAt((int) skip).Skip(world, name, pool); } - return new Progression(pool); + return new Progression(pool, world.Config.Skills); } public IEnumerator GetEnumerator() => preparations.GetEnumerator(); diff --git a/Randomizer.SMZ3.Tests/Logic/SkillTests.cs b/Randomizer.SMZ3.Tests/Logic/SkillTests.cs new file mode 100644 index 00000000..f7dec95f --- /dev/null +++ b/Randomizer.SMZ3.Tests/Logic/SkillTests.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using static System.Linq.Enumerable; +using static Randomizer.SMZ3.WorldState; +using static Randomizer.SMZ3.ItemType; + +namespace Randomizer.SMZ3.Tests.Logic { + + public class SkillTests { + + static World CreateWorld(Config config) { + var world = new World(config, "", 0, ""); + world.Setup(new WorldState { + Rewards = Repeat(RewardType.None, 7 + 4), + Medallions = new[] { Medallion.Ether, Medallion.Quake }, + GanonCrystals = 0, + TowerCrystals = 0, + TourianBossTokens = 0, + }); + return world; + } + + static Progression Items(World world, params ItemType[] types) => + new(types.Select(t => new Item(t, world)), world.Config.Skills); + + static Config Parse(params (string, string)[] options) => + RandomizerOptions.Parse(options.ToDictionary(o => o.Item1, o => o.Item2)); + + [Test] + public void LogicIsOnlyAPresetForTheSkills() { + Assert.That(Parse(("smlogic", "normal")).Skills.HardSkills, Is.False); + Assert.That(Parse(("smlogic", "hard")).Skills.HardSkills, Is.True); + } + + [Test] + public void ExplicitSkillOverridesThePreset() { + var config = Parse(("smlogic", "hard"), ("hardskills", "disabled"), ("mockball", "enabled")); + Assert.That(config.Skills.HardSkills, Is.False); + Assert.That(config.Skills.MockBall, Is.True); + Assert.That(config.Skills.InfiniteBombJump, Is.True); + } + + [Test] + public void HardSkillsOnNormalLogicUsesHardRoutes() { + // Hard drops the energy requirement on the Crateria gauntlet energy tank + var normal = CreateWorld(Parse(("smlogic", "normal"))); + var hard = CreateWorld(Parse(("smlogic", "normal"), ("hardskills", "enabled"))); + var gauntlet = new[] { CardCrateriaL1, Morph, Bombs }; + Assert.That(normal.Locations.Get("Energy Tank, Gauntlet").Available(Items(normal, gauntlet)), Is.False); + Assert.That(hard.Locations.Get("Energy Tank, Gauntlet").Available(Items(hard, gauntlet)), Is.True); + } + + [Test] + public void HealthIndifferentIgnoresEnergyRequirements() { + var gauntlet = new[] { CardCrateriaL1, Morph, Bombs }; + var world = CreateWorld(Parse(("healthindifferent", "enabled"))); + Assert.That(world.Locations.Get("Energy Tank, Gauntlet").Available(Items(world, gauntlet)), Is.True); + } + + [Test] + public void InfiniteBombJumpCanBeDisabled() { + var items = new[] { Morph, Bombs }; + var on = CreateWorld(Parse()); + var off = CreateWorld(Parse(("infinitebombjump", "disabled"))); + Assert.That(Items(on, items).CanFly(), Is.True); + Assert.That(Items(off, items).CanFly(), Is.False); + } + + [Test] + public void DarkRoomsDoNotNeedTheLamp() { + var normal = CreateWorld(Parse()); + var skilled = CreateWorld(Parse(("darkrooms", "enabled"))); + Assert.That(Items(normal).CanNavigateDarkRooms(), Is.False); + Assert.That(Items(normal, Lamp).CanNavigateDarkRooms(), Is.True); + Assert.That(Items(skilled).CanNavigateDarkRooms(), Is.True); + } + + } + +} diff --git a/Randomizer.SMZ3.Tests/Logic/SphereTests.cs b/Randomizer.SMZ3.Tests/Logic/SphereTests.cs new file mode 100644 index 00000000..1c934f59 --- /dev/null +++ b/Randomizer.SMZ3.Tests/Logic/SphereTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using NUnit.Framework; + +namespace Randomizer.SMZ3.Tests.Logic { + + public class SphereTests { + + static (List worlds, Dictionary spheres) Generate(int seed, params (string, string)[] options) { + var config = RandomizerOptions.Parse(options.ToDictionary(o => o.Item1, o => o.Item2)); + var worlds = new List { new World(config, "Player", 0, "") }; + new Filler(worlds, config, new Random(seed), CancellationToken.None).Fill(); + return (worlds, Playthrough.LocationSpheres(worlds)); + } + + static List SpheresOf(List worlds, Dictionary spheres, ItemType type) => + worlds.SelectMany(w => w.Locations).Where(l => l.Item.Type == type).Select(l => spheres[l]).OrderBy(s => s).ToList(); + + [Test] + public void EveryCoreItemHasAnOptionAndEveryProgressiveCopyIsListed() { + var keys = SphereConstraints.CreateOptions().Select(o => o.Key).ToList(); + Assert.That(keys, Is.Unique); + Assert.That(keys.Count(k => k.StartsWith("sphere-progressivesword-")), Is.EqualTo(4)); + Assert.That(keys.Count(k => k.StartsWith("sphere-progressiveshield-")), Is.EqualTo(3)); + Assert.That(keys.Count(k => k.StartsWith("sphere-progressiveglove-")), Is.EqualTo(2)); + Assert.That(SphereConstraints.CreateOptions().All(o => o.Default == "random"), Is.True); + Assert.That(RandomizerOptions.List.Select(o => o.Key), Is.SupersetOf(keys)); + } + + [Test] + public void RandomIsTheDefault() { + Assert.That(RandomizerOptions.Parse(new Dictionary()).ItemSpheres, Is.Empty); + Assert.That(RandomizerOptions.Parse(new Dictionary { ["sphere-spacejump"] = "random" }).ItemSpheres, Is.Empty); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(5)] + [TestCase(6)] + [TestCase(7)] + [TestCase(8)] + public void ItemAppearsByRequestedSphere(int seed) { + var (worlds, spheres) = Generate(seed, ("sphere-spacejump", "2"), ("sphere-moonpearl", "3"), ("sphere-hookshot", "1")); + Assert.That(SpheresOf(worlds, spheres, ItemType.SpaceJump)[0], Is.LessThanOrEqualTo(2)); + Assert.That(SpheresOf(worlds, spheres, ItemType.MoonPearl)[0], Is.LessThanOrEqualTo(3)); + Assert.That(SpheresOf(worlds, spheres, ItemType.Hookshot)[0], Is.EqualTo(1)); + Assert.That(spheres.Count, Is.EqualTo(worlds[0].Locations.Count), "all locations must stay reachable"); + } + + [TestCase(11, "false")] + [TestCase(12, "false")] + [TestCase(13, "true")] + public void ManyTightSettingsAreAllMet(int seed, string keysanity) { + var limits = new Dictionary { + ["sphere-spacejump"] = "2", ["sphere-speedbooster"] = "3", ["sphere-gravity"] = "3", + ["sphere-varia"] = "3", ["sphere-hammer"] = "2", ["sphere-boots"] = "1", ["sphere-flippers"] = "2", + ["sphere-mirror"] = "2", ["sphere-bombs"] = "1", ["sphere-progressivesword-1"] = "1", + ["sphere-progressiveglove-1"] = "2", ["sphere-progressiveglove-2"] = "3", ["sphere-bottle-4"] = "4", + ["keyshuffle"] = keysanity == "true" ? "keysanity" : "none", + }; + var config = RandomizerOptions.Parse(limits); + var worlds = new List { new World(config, "Player", 0, "") }; + new Filler(worlds, config, new Random(seed), System.Threading.CancellationToken.None).Fill(); + var spheres = Playthrough.LocationSpheres(worlds); + + foreach (var (type, sphereLimits) in config.ItemSpheres) { + var actual = SpheresOf(worlds, spheres, type); + for (var i = 0; i < sphereLimits.Count; i++) { + Assert.That(actual[i], Is.LessThanOrEqualTo(sphereLimits[i]), $"{type} copy {i + 1}"); + } + } + + /* The same seed without settings; the filler itself can leave a location unreachable (e.g. the Hera key), but we must not add to that */ + var plainConfig = RandomizerOptions.Parse(new Dictionary { ["keyshuffle"] = limits["keyshuffle"] }); + var plainWorlds = new List { new World(plainConfig, "Player", 0, "") }; + new Filler(plainWorlds, plainConfig, new Random(seed), System.Threading.CancellationToken.None).Fill(); + Assert.That(spheres.Count, Is.GreaterThanOrEqualTo(Playthrough.LocationSpheres(plainWorlds).Count)); + } + + [Test] + public void MultiworldSettingsApplyToEveryWorld() { + var options = new Dictionary { + ["gamemode"] = "multiworld", ["sphere-moonpearl"] = "2", ["sphere-morph"] = "2", ["sphere-hookshot"] = "3", + }; + var config = RandomizerOptions.Parse(options); + var worlds = Enumerable.Range(0, 2).Select(p => new World(config, $"P{p}", p, "")).ToList(); + new Filler(worlds, config, new Random(21), System.Threading.CancellationToken.None).Fill(); + var spheres = Playthrough.LocationSpheres(worlds); + + foreach (var world in worlds) { + foreach (var (type, limit) in new[] { (ItemType.MoonPearl, 2), (ItemType.Morph, 2), (ItemType.Hookshot, 3) }) { + var location = worlds.SelectMany(w => w.Locations).Single(l => l.Item.Is(type, world)); + Assert.That(spheres[location], Is.LessThanOrEqualTo(limit), $"{type} of {world.Player}"); + } + } + } + + [Test] + public void EachProgressiveCopyIsMatchedSeparately() { + var (worlds, spheres) = Generate(7, + ("sphere-progressivesword-1", "1"), ("sphere-progressivesword-2", "2"), + ("sphere-progressiveglove-1", "2"), ("sphere-progressiveglove-2", "4"), + ("sphere-progressiveshield-3", "5")); + var swords = SpheresOf(worlds, spheres, ItemType.ProgressiveSword); + Assert.That(swords.Count, Is.EqualTo(4)); + Assert.That(swords[0], Is.LessThanOrEqualTo(1)); + Assert.That(swords[1], Is.LessThanOrEqualTo(2)); + var gloves = SpheresOf(worlds, spheres, ItemType.ProgressiveGlove); + Assert.That(gloves[0], Is.LessThanOrEqualTo(2)); + Assert.That(gloves[1], Is.LessThanOrEqualTo(4)); + Assert.That(SpheresOf(worlds, spheres, ItemType.ProgressiveShield).Count, Is.EqualTo(3)); + } + + } + +} diff --git a/Randomizer.SMZ3/Config.cs b/Randomizer.SMZ3/Config.cs index 7f87fb74..f051b1ae 100644 --- a/Randomizer.SMZ3/Config.cs +++ b/Randomizer.SMZ3/Config.cs @@ -29,6 +29,42 @@ enum SMLogic { Hard, } + [DefaultValue(Default)] + enum SkillSetting { + [Description("Default (per logic)")] + Default, + [Description("Enabled")] + Enabled, + [Description("Disabled")] + Disabled, + } + + /// + /// The Super Metroid / dungeon tricks the player is expected to be able to perform. + /// only selects a preset of these, each can be overridden individually. + /// + class SMSkills { + + /// Generic "hard" Super Metroid routes (everything the Hard logic adds over Normal). + public bool HardSkills { get; set; } + /// Infinite bomb jumping, Morph + Bombs counts as flight. + public bool InfiniteBombJump { get; set; } + /// Mockball (entering morph-only passages without stopping). + public bool MockBall { get; set; } + /// Short charge / short spark with a minimal speed booster runway. + public bool ShortSpark { get; set; } + /// Energy tank / reserve requirements are ignored. + public bool HealthIndifferent { get; set; } + /// Dark rooms can be navigated without the Lamp. + public bool DarkRooms { get; set; } + + public static SMSkills Preset(SMLogic logic) => logic switch { + SMLogic.Hard => new SMSkills { HardSkills = true, InfiniteBombJump = true }, + _ => new SMSkills { InfiniteBombJump = true }, + }; + + } + [DefaultValue(Randomized)] enum SwordLocation { [Description("Randomized")] @@ -131,7 +167,18 @@ class Config { public GameMode GameMode { get; set; } = GameMode.Normal; public Z3Logic Z3Logic { get; set; } = Z3Logic.Normal; - public SMLogic SMLogic { get; set; } = SMLogic.Normal; + SMLogic smLogic = SMLogic.Normal; + SMSkills skills = SMSkills.Preset(SMLogic.Normal); + + /// Selecting a logic resets to that logic's preset. + public SMLogic SMLogic { + get => smLogic; + set { + smLogic = value; + skills = SMSkills.Preset(value); + } + } + public SMSkills Skills { get => skills; set => skills = value; } public SwordLocation SwordLocation { get; set; } = SwordLocation.Randomized; public MorphLocation MorphLocation { get; set; } = MorphLocation.Randomized; public Goal Goal { get; set; } = Goal.DefeatBoth; @@ -141,6 +188,8 @@ class Config { public GanonVulnerable GanonVulnerable { get; set; } = GanonVulnerable.SevenCrystals; public OpenTourian OpenTourian { get; set; } = OpenTourian.FourBosses; public Dictionary InitialItems = new Dictionary(); + /// Latest sphere each copy of an item may appear in, sorted ascending. Items not listed are random. + public Dictionary> ItemSpheres { get; set; } = new Dictionary>(); public bool SingleWorld => GameMode == GameMode.Normal; public bool MultiWorld => GameMode == GameMode.Multiworld; diff --git a/Randomizer.SMZ3/Filler.cs b/Randomizer.SMZ3/Filler.cs index 08254704..0bf198ea 100644 --- a/Randomizer.SMZ3/Filler.cs +++ b/Randomizer.SMZ3/Filler.cs @@ -79,6 +79,8 @@ public void Fill() { AssumedFill(progressionItems, baseItems, locations, Worlds); FastFill(niceItems, locations); FastFill(junkItems, locations); + + SphereConstraints.Enforce(Worlds, Config, Rnd, CancellationToken); } void ApplyItemBias(List itemPool, IEnumerable<(ItemType type, double weight)> reorder) { diff --git a/Randomizer.SMZ3/Item.cs b/Randomizer.SMZ3/Item.cs index 4bf74b8b..77137de0 100644 --- a/Randomizer.SMZ3/Item.cs +++ b/Randomizer.SMZ3/Item.cs @@ -704,7 +704,10 @@ class Progression { int shield; - public Progression(IEnumerable items) { + public SMSkills Skills { get; } + + public Progression(IEnumerable items, SMSkills skills = null) { + Skills = skills ?? items.FirstOrDefault()?.World?.Config.Skills ?? SMSkills.Preset(SMLogic.Normal); Add(items); } @@ -843,7 +846,7 @@ public static bool CanAccessDeathMountainPortal(this Progression items) { } public static bool CanAccessDarkWorldPortal(this Progression items, Config config) { - return config.SMLogic switch { + return (config.Skills.HardSkills ? Hard : Normal) switch { Normal => items.CardMaridiaL1 && items.CardMaridiaL2 && items.CanUsePowerBombs() && items.Super && items.Gravity && items.SpeedBooster, _ => @@ -855,7 +858,7 @@ public static bool CanAccessDarkWorldPortal(this Progression items, Config confi } public static bool CanAccessMiseryMirePortal(this Progression items, Config config) { - return config.SMLogic switch { + return (config.Skills.HardSkills ? Hard : Normal) switch { Normal => (items.CardNorfairL2 || items.SpeedBooster && items.Wave) && items.Varia && items.Super && items.Gravity && items.SpaceJump && items.CanUsePowerBombs(), @@ -866,8 +869,12 @@ public static bool CanAccessMiseryMirePortal(this Progression items, Config conf }; } + public static bool CanNavigateDarkRooms(this Progression items) { + return items.Lamp || items.Skills.DarkRooms; + } + public static bool CanIbj(this Progression items) { - return items.Morph && items.Bombs; + return items.Skills.InfiniteBombJump && items.Morph && items.Bombs; } public static bool CanFly(this Progression items) { @@ -895,7 +902,7 @@ public static bool CanHellRun(this Progression items) { } public static bool HasEnergyReserves(this Progression items, int amount) { - return (items.ETank + items.ReserveTank) >= amount; + return items.Skills.HealthIndifferent || (items.ETank + items.ReserveTank) >= amount; } public static bool CanOpenRedDoors(this Progression items) { @@ -903,7 +910,7 @@ public static bool CanOpenRedDoors(this Progression items) { } public static bool CanAccessNorfairUpperPortal(this Progression items) { - return items.Flute || items.CanLiftLight() && items.Lamp; + return items.Flute || items.CanLiftLight() && items.CanNavigateDarkRooms(); } public static bool CanAccessNorfairLowerPortal(this Progression items) { @@ -911,7 +918,7 @@ public static bool CanAccessNorfairLowerPortal(this Progression items) { } public static bool CanAccessMaridiaPortal(this Progression items, World world) { - return world.Config.SMLogic switch { + return (world.Config.Skills.HardSkills ? Hard : Normal) switch { Normal => items.MoonPearl && items.Flippers && items.Gravity && items.Morph && diff --git a/Randomizer.SMZ3/Location.cs b/Randomizer.SMZ3/Location.cs index d3361a30..35e6aceb 100644 --- a/Randomizer.SMZ3/Location.cs +++ b/Randomizer.SMZ3/Location.cs @@ -111,13 +111,13 @@ public static IEnumerable AvailableWithinWorld(this IEnumerable Available(this IEnumerable locations, IEnumerable items) { - var progression = new Progression(items); + var progression = new Progression(items, locations.FirstOrDefault()?.Region.World.Config.Skills); return locations.Where(l => l.Available(progression)); } public static IEnumerable CanFillWithinWorld(this IEnumerable locations, Item item, IEnumerable items) { - var itemWorldProgression = new Progression(items.Where(i => i.World == item.World).Append(item)); - var worldProgression = locations.Select(x => x.Region.World).Distinct().ToDictionary(world => world.Id, world => new Progression(items.Where(i => i.World == world))); + var itemWorldProgression = new Progression(items.Where(i => i.World == item.World).Append(item), item.World.Config.Skills); + var worldProgression = locations.Select(x => x.Region.World).Distinct().ToDictionary(world => world.Id, world => new Progression(items.Where(i => i.World == world), world.Config.Skills)); return locations.Where(l => l.CanFill(item, worldProgression[l.Region.World.Id]) && diff --git a/Randomizer.SMZ3/Playthrough.cs b/Randomizer.SMZ3/Playthrough.cs index 4402d759..2dd2704a 100644 --- a/Randomizer.SMZ3/Playthrough.cs +++ b/Randomizer.SMZ3/Playthrough.cs @@ -77,6 +77,42 @@ public static List> Generate(List worlds, Conf return spheres; } + /// The 1-based sphere each reachable location is first available in + public static Dictionary LocationSpheres(List worlds) { + var spheres = new Dictionary(); + var items = new List(); + + foreach (var world in worlds) { + world.ForwardSearch = true; + if (!world.Config.Keysanity) { + items.AddRange(Item.CreateKeycards(world)); + } + items.AddRange(world.Config.InitialItems.SelectMany(ii => Enumerable.Repeat(new Item(ii.Key, world), ii.Value))); + } + + try { + for (var sphere = 1; ; sphere++) { + var added = worlds + .SelectMany(w => w.Locations.Available(items.Where(i => i.World == w))) + .Where(l => !spheres.ContainsKey(l)) + .ToList(); + if (added.Count == 0) + break; + + foreach (var location in added) { + spheres[location] = sphere; + } + items.AddRange(added.Select(l => l.Item)); + } + } finally { + foreach (var world in worlds) { + world.ForwardSearch = false; + } + } + + return spheres; + } + static void AddLocation(Dictionary sphere, Location location, bool multiWorld) { sphere.Add( multiWorld ? $"{location.Name} ({location.Region.World.Player})" diff --git a/Randomizer.SMZ3/RandomizerOptions.cs b/Randomizer.SMZ3/RandomizerOptions.cs index be0514ad..33732de5 100644 --- a/Randomizer.SMZ3/RandomizerOptions.cs +++ b/Randomizer.SMZ3/RandomizerOptions.cs @@ -9,7 +9,16 @@ namespace Randomizer.SMZ3 { static class RandomizerOptions { - public static List List { get; } = new List { + public static List List { get; } = CreateList(); + + static List CreateList() { + var list = CreateCommonOptions(); + /* Skills and per item sphere options come last, the web UI tucks them into collapsible sections */ + list.AddRange(SphereConstraints.CreateOptions()); + return list; + } + + static List CreateCommonOptions() => new List { CreateEnumOption("Super Metroid Logic"), CreateEnumOption("Goal"), CreateEnumOption("Open Ganon's Tower"), @@ -23,6 +32,12 @@ static class RandomizerOptions { CreateBoolOption("Race", "Race ROM (no spoilers)", false), CreateEnumOption("Game mode"), CreatePlayerOption(), + CreateSkillOption("HardSkills", "Hard skills (generic difficulty)"), + CreateSkillOption("InfiniteBombJump", "Infinite bomb jump"), + CreateSkillOption("MockBall", "Mockball"), + CreateSkillOption("ShortSpark", "Short spark"), + CreateSkillOption("HealthIndifferent", "Health indifferent"), + CreateSkillOption("DarkRooms", "Navigate dark rooms without lamp"), }; static RandomizerOption CreateEnumOption(string description, string defaultOption = null) where TEnum : Enum { @@ -36,6 +51,16 @@ static RandomizerOption CreateEnumOption(string description, string defau }; } + static RandomizerOption CreateSkillOption(string key, string description) { + return new RandomizerOption { + Key = key.ToLower(), + Description = description, + Type = Dropdown, + Default = SkillSetting.Default.ToLowerString(), + Values = Enum.GetValues(typeof(SkillSetting)).Cast().ToDictionary(k => k.ToLowerString(), v => v.GetDescription()), + }; + } + static TEnum GetDefaultValue() where TEnum : Enum { var enumType = typeof(TEnum); var attrs = (DefaultValueAttribute[])enumType.GetCustomAttributes(typeof(DefaultValueAttribute), false); @@ -59,7 +84,7 @@ static RandomizerOption CreatePlayerOption() => new RandomizerOption { Key = "players", Description = "Players", Type = Players, Default = "2" }; public static Config Parse(IDictionary options) { - return new Config { + var config = new Config { GameMode = ParseOption(options, GameMode.Normal), Z3Logic = ParseOption(options, Z3Logic.Normal), SMLogic = ParseOption(options, SMLogic.Normal), @@ -71,8 +96,30 @@ public static Config Parse(IDictionary options) { GanonVulnerable = ParseOption(options, GanonVulnerable.SevenCrystals), OpenTourian = ParseOption(options, OpenTourian.FourBosses), Race = ParseOption(options, "Race", false), - InitialItems = ParseOption(options, "InitialItems") + InitialItems = ParseOption(options, "InitialItems"), + ItemSpheres = SphereConstraints.Parse(options), }; + + // Logic only selects the preset, explicit skill settings override it + var skills = config.Skills; + skills.HardSkills = ParseSkill(options, "HardSkills", skills.HardSkills); + skills.InfiniteBombJump = ParseSkill(options, "InfiniteBombJump", skills.InfiniteBombJump); + skills.MockBall = ParseSkill(options, "MockBall", skills.MockBall); + skills.ShortSpark = ParseSkill(options, "ShortSpark", skills.ShortSpark); + skills.HealthIndifferent = ParseSkill(options, "HealthIndifferent", skills.HealthIndifferent); + skills.DarkRooms = ParseSkill(options, "DarkRooms", skills.DarkRooms); + return config; + } + + static bool ParseSkill(IDictionary options, string key, bool preset) { + if (options.TryGetValue(key.ToLower(), out var value) && Enum.TryParse(value, true, out SkillSetting setting)) { + return setting switch { + SkillSetting.Enabled => true, + SkillSetting.Disabled => false, + _ => preset, + }; + } + return preset; } static TEnum ParseOption(IDictionary options, TEnum defaultValue) where TEnum : Enum { diff --git a/Randomizer.SMZ3/Region.cs b/Randomizer.SMZ3/Region.cs index 032fd78e..b01b9d96 100644 --- a/Randomizer.SMZ3/Region.cs +++ b/Randomizer.SMZ3/Region.cs @@ -45,7 +45,7 @@ interface IMedallionAccess { } abstract class SMRegion : Region { - public SMLogic Logic => Config.SMLogic; + public SMLogic Logic => Config.Skills.HardSkills ? SMLogic.Hard : SMLogic.Normal; public SMRegion(World world, Config config) : base(world, config) { } } diff --git a/Randomizer.SMZ3/Regions/Zelda/CastleTower.cs b/Randomizer.SMZ3/Regions/Zelda/CastleTower.cs index 69211be7..b3367df6 100644 --- a/Randomizer.SMZ3/Regions/Zelda/CastleTower.cs +++ b/Randomizer.SMZ3/Regions/Zelda/CastleTower.cs @@ -15,7 +15,7 @@ public CastleTower(World world, Config config) : base(world, config) { Locations = new List { new Location(this, 256+101, 0x1EAB5, LocationType.Regular, "Castle Tower - Foyer"), new Location(this, 256+102, 0x1EAB2, LocationType.Regular, "Castle Tower - Dark Maze", - items => items.Lamp && items.KeyCT >= 1), + items => items.CanNavigateDarkRooms() && items.KeyCT >= 1), }; } @@ -24,7 +24,7 @@ public override bool CanEnter(Progression items) { } public bool CanComplete(Progression items) { - return CanEnter(items) && items.Lamp && items.KeyCT >= 2 && items.Sword; + return CanEnter(items) && items.CanNavigateDarkRooms() && items.KeyCT >= 2 && items.Sword; } } diff --git a/Randomizer.SMZ3/Regions/Zelda/EasternPalace.cs b/Randomizer.SMZ3/Regions/Zelda/EasternPalace.cs index 64c3df85..7591f00b 100644 --- a/Randomizer.SMZ3/Regions/Zelda/EasternPalace.cs +++ b/Randomizer.SMZ3/Regions/Zelda/EasternPalace.cs @@ -19,9 +19,9 @@ public EasternPalace(World world, Config config) : base(world, config) { new Location(this, 256+106, 0x1E97D, LocationType.Regular, "Eastern Palace - Big Chest", items => items.BigKeyEP), new Location(this, 256+107, 0x1E9B9, LocationType.Regular, "Eastern Palace - Big Key Chest", - items => items.Lamp), + items => items.CanNavigateDarkRooms()), new Location(this, 256+108, 0x308150, LocationType.Regular, "Eastern Palace - Armos Knights", - items => items.BigKeyEP && items.Bow && items.Lamp), + items => items.BigKeyEP && items.Bow && items.CanNavigateDarkRooms()), }; } diff --git a/Randomizer.SMZ3/Regions/Zelda/HyruleCastle.cs b/Randomizer.SMZ3/Regions/Zelda/HyruleCastle.cs index 7cbfd68e..2656bf4e 100644 --- a/Randomizer.SMZ3/Regions/Zelda/HyruleCastle.cs +++ b/Randomizer.SMZ3/Regions/Zelda/HyruleCastle.cs @@ -14,13 +14,13 @@ public HyruleCastle(World world, Config config) : base(world, config) { Locations = new List { new Location(this, 256+91, 0x1EA79, LocationType.Regular, "Sanctuary").Weighted(sphereOne), new Location(this, 256+92, 0x1EB5D, LocationType.Regular, "Sewers - Secret Room - Left", - items => items.CanLiftLight() || items.Lamp && items.KeyHC), + items => items.CanLiftLight() || items.CanNavigateDarkRooms() && items.KeyHC), new Location(this, 256+93, 0x1EB60, LocationType.Regular, "Sewers - Secret Room - Middle", - items => items.CanLiftLight() || items.Lamp && items.KeyHC), + items => items.CanLiftLight() || items.CanNavigateDarkRooms() && items.KeyHC), new Location(this, 256+94, 0x1EB63, LocationType.Regular, "Sewers - Secret Room - Right", - items => items.CanLiftLight() || items.Lamp && items.KeyHC), + items => items.CanLiftLight() || items.CanNavigateDarkRooms() && items.KeyHC), new Location(this, 256+95, 0x1E96E, LocationType.Regular, "Sewers - Dark Cross", - items => items.Lamp), + items => items.CanNavigateDarkRooms()), new Location(this, 256+96, 0x1EB0C, LocationType.Regular, "Hyrule Castle - Map Chest").Weighted(sphereOne), new Location(this, 256+97, 0x1E974, LocationType.Regular, "Hyrule Castle - Boomerang Chest", items => items.KeyHC).Weighted(sphereOne), diff --git a/Randomizer.SMZ3/Regions/Zelda/LightWorld/DeathMountain/West.cs b/Randomizer.SMZ3/Regions/Zelda/LightWorld/DeathMountain/West.cs index e85b00e3..acb567e7 100644 --- a/Randomizer.SMZ3/Regions/Zelda/LightWorld/DeathMountain/West.cs +++ b/Randomizer.SMZ3/Regions/Zelda/LightWorld/DeathMountain/West.cs @@ -15,12 +15,12 @@ public West(World world, Config config) : base(world, config) { items => items.Mirror), new Location(this, 256+2, 0x308002, LocationType.Regular, "Spectacle Rock Cave"), new Location(this, 256+3, 0x1EE9FA, LocationType.Regular, "Old Man", - items => items.Lamp), + items => items.CanNavigateDarkRooms()), }; } public override bool CanEnter(Progression items) { - return items.Flute || items.CanLiftLight() && items.Lamp || items.CanAccessDeathMountainPortal(); + return items.Flute || items.CanLiftLight() && items.CanNavigateDarkRooms() || items.CanAccessDeathMountainPortal(); } } diff --git a/Randomizer.SMZ3/Regions/Zelda/MiseryMire.cs b/Randomizer.SMZ3/Regions/Zelda/MiseryMire.cs index b326aa8b..ca1669df 100644 --- a/Randomizer.SMZ3/Regions/Zelda/MiseryMire.cs +++ b/Randomizer.SMZ3/Regions/Zelda/MiseryMire.cs @@ -31,7 +31,7 @@ public MiseryMire(World world, Config config) : base(world, config) { new Location(this, 256+175, 0x1EA67, LocationType.Regular, "Misery Mire - Big Chest", items => items.BigKeyMM), new Location(this, 256+176, 0x308158, LocationType.Regular, "Misery Mire - Vitreous", - items => items.BigKeyMM && items.Lamp && items.Somaria), + items => items.BigKeyMM && items.CanNavigateDarkRooms() && items.Somaria), }; } diff --git a/Randomizer.SMZ3/Regions/Zelda/PalaceOfDarkness.cs b/Randomizer.SMZ3/Regions/Zelda/PalaceOfDarkness.cs index 88ca1050..4d88f767 100644 --- a/Randomizer.SMZ3/Regions/Zelda/PalaceOfDarkness.cs +++ b/Randomizer.SMZ3/Regions/Zelda/PalaceOfDarkness.cs @@ -17,7 +17,7 @@ public PalaceOfDarkness(World world, Config config) : base(world, config) { new Location(this, 256+121, 0x1EA5B, LocationType.Regular, "Palace of Darkness - Shooter Room"), new Location(this, 256+122, 0x1EA37, LocationType.Regular, "Palace of Darkness - Big Key Chest", items => items.KeyPD >= (GetLocation("Palace of Darkness - Big Key Chest").ItemIs(KeyPD, World) ? 1 : - (items.Hammer && items.Bow && items.Lamp) || config.Keysanity ? 6 : 5)) + (items.Hammer && items.Bow && items.CanNavigateDarkRooms()) || config.Keysanity ? 6 : 5)) .AlwaysAllow((item, items) => item.Is(KeyPD, World) && items.KeyPD >= 5), new Location(this, 256+123, 0x1EA49, LocationType.Regular, "Palace of Darkness - Stalfos Basement", items => items.KeyPD >= 1 || items.Bow && items.Hammer), @@ -28,24 +28,24 @@ public PalaceOfDarkness(World world, Config config) : base(world, config) { new Location(this, 256+126, 0x1EA52, LocationType.Regular, "Palace of Darkness - Map Chest", items => items.Bow), new Location(this, 256+127, 0x1EA43, LocationType.Regular, "Palace of Darkness - Compass Chest", - items => items.KeyPD >= ((items.Hammer && items.Bow && items.Lamp) || config.Keysanity ? 4 : 3)), + items => items.KeyPD >= ((items.Hammer && items.Bow && items.CanNavigateDarkRooms()) || config.Keysanity ? 4 : 3)), new Location(this, 256+128, 0x1EA46, LocationType.Regular, "Palace of Darkness - Harmless Hellway", items => items.KeyPD >= (GetLocation("Palace of Darkness - Harmless Hellway").ItemIs(KeyPD, World) ? - (items.Hammer && items.Bow && items.Lamp) || config.Keysanity ? 4 : 3 : - (items.Hammer && items.Bow && items.Lamp) || config.Keysanity ? 6 : 5)) + (items.Hammer && items.Bow && items.CanNavigateDarkRooms()) || config.Keysanity ? 4 : 3 : + (items.Hammer && items.Bow && items.CanNavigateDarkRooms()) || config.Keysanity ? 6 : 5)) .AlwaysAllow((item, items) => item.Is(KeyPD, World) && items.KeyPD >= 5), new Location(this, 256+129, 0x1EA4C, LocationType.Regular, "Palace of Darkness - Dark Basement - Left", - items => items.Lamp && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 4 : 3)), + items => items.CanNavigateDarkRooms() && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 4 : 3)), new Location(this, 256+130, 0x1EA4F, LocationType.Regular, "Palace of Darkness - Dark Basement - Right", - items => items.Lamp && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 4 : 3)), + items => items.CanNavigateDarkRooms() && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 4 : 3)), new Location(this, 256+131, 0x1EA55, LocationType.Regular, "Palace of Darkness - Dark Maze - Top", - items => items.Lamp && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 6 : 5)), + items => items.CanNavigateDarkRooms() && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 6 : 5)), new Location(this, 256+132, 0x1EA58, LocationType.Regular, "Palace of Darkness - Dark Maze - Bottom", - items => items.Lamp && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 6 : 5)), + items => items.CanNavigateDarkRooms() && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 6 : 5)), new Location(this, 256+133, 0x1EA40, LocationType.Regular, "Palace of Darkness - Big Chest", - items => items.BigKeyPD && items.Lamp && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 6 : 5)), + items => items.BigKeyPD && items.CanNavigateDarkRooms() && items.KeyPD >= ((items.Hammer && items.Bow) || config.Keysanity ? 6 : 5)), new Location(this, 256+134, 0x308153, LocationType.Regular, "Palace of Darkness - Helmasaur King", - items => items.Lamp && items.Hammer && items.Bow && items.BigKeyPD && items.KeyPD >= 6), + items => items.CanNavigateDarkRooms() && items.Hammer && items.Bow && items.BigKeyPD && items.KeyPD >= 6), }; } diff --git a/Randomizer.SMZ3/Regions/Zelda/TurtleRock.cs b/Randomizer.SMZ3/Regions/Zelda/TurtleRock.cs index 4bf548cb..56c78bc9 100644 --- a/Randomizer.SMZ3/Regions/Zelda/TurtleRock.cs +++ b/Randomizer.SMZ3/Regions/Zelda/TurtleRock.cs @@ -38,12 +38,12 @@ public TurtleRock(World world, Config config) : base(world, config) { new Location(this, 256+186, 0x1EA2E, LocationType.Regular, "Turtle Rock - Eye Bridge - Bottom Right", LaserBridge), new Location(this, 256+187, 0x1EA31, LocationType.Regular, "Turtle Rock - Eye Bridge - Bottom Left", LaserBridge), new Location(this, 256+188, 0x308159, LocationType.Regular, "Turtle Rock - Trinexx", - items => items.BigKeyTR && items.KeyTR >= 4 && items.Lamp && CanBeatBoss(items)), + items => items.BigKeyTR && items.KeyTR >= 4 && items.CanNavigateDarkRooms() && CanBeatBoss(items)), }; } bool LaserBridge(Progression items) { - return items.BigKeyTR && items.KeyTR >= 3 && items.Lamp && (items.Cape || items.Byrna || items.CanBlockLasers); + return items.BigKeyTR && items.KeyTR >= 3 && items.CanNavigateDarkRooms() && (items.Cape || items.Byrna || items.CanBlockLasers); } bool CanBeatBoss(Progression items) { diff --git a/Randomizer.SMZ3/SphereConstraints.cs b/Randomizer.SMZ3/SphereConstraints.cs new file mode 100644 index 00000000..4daaf5c1 --- /dev/null +++ b/Randomizer.SMZ3/SphereConstraints.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Randomizer.Shared.Contracts; +using static Randomizer.SMZ3.ItemType; + +namespace Randomizer.SMZ3 { + + /// + /// Lets the player require that core items show up by a given sphere. + /// "By sphere N" means the item is obtainable at sphere N or earlier, never later. + /// Items with several copies get one setting per copy, the earliest copy is matched + /// against the tightest setting and so on. + /// + static class SphereConstraints { + + public const string RandomValue = "random"; + public const int MaxSphere = 10; + const int Unreachable = 1000; + const int MaxCandidatesPerMiss = 300; + + /// Every core item and how many copies of it exist in the pools + static readonly (ItemType Type, int Copies)[] coreItems = { + (ProgressiveSword, 4), (ProgressiveShield, 3), (ProgressiveGlove, 2), (ProgressiveTunic, 2), + (Bow, 1), (SilverArrows, 1), (Hookshot, 1), (Mushroom, 1), (Powder, 1), (Firerod, 1), (Icerod, 1), + (Bombos, 1), (Ether, 1), (Quake, 1), (Lamp, 1), (Hammer, 1), (Shovel, 1), (Flute, 1), + (Bugnet, 1), (Book, 1), (Bottle, 4), (Somaria, 1), (Byrna, 1), (Cape, 1), (Mirror, 1), + (Boots, 1), (Flippers, 1), (MoonPearl, 1), (HalfMagic, 1), + + (Morph, 1), (Bombs, 1), (Charge, 1), (Ice, 1), (Wave, 1), (Spazer, 1), (Plasma, 1), + (Varia, 1), (Gravity, 1), (SpringBall, 1), (ScrewAttack, 1), (HiJump, 1), (SpaceJump, 1), + (SpeedBooster, 1), (Grapple, 1), (XRay, 1), (Super, 1), (PowerBomb, 2), + }; + + static string Key(ItemType type, int copy, int copies) { + var key = $"sphere-{type.ToString().ToLower()}"; + return copies > 1 ? $"{key}-{copy}" : key; + } + + public static IEnumerable CreateOptions() { + var values = new Dictionary { [RandomValue] = "Random" }; + for (var sphere = 1; sphere <= MaxSphere; sphere++) { + values[sphere.ToString()] = $"By sphere {sphere}"; + } + + foreach (var (type, copies) in coreItems) { + for (var copy = 1; copy <= copies; copy++) { + yield return new RandomizerOption { + Key = Key(type, copy, copies), + Description = copies > 1 ? $"{type.GetDescription()} #{copy}" : type.GetDescription(), + Type = RandomizerOptionType.Dropdown, + Default = RandomValue, + Values = values, + }; + } + } + } + + public static Dictionary> Parse(IDictionary options) { + var result = new Dictionary>(); + foreach (var (type, copies) in coreItems) { + for (var copy = 1; copy <= copies; copy++) { + if (options.TryGetValue(Key(type, copy, copies), out var value) && int.TryParse(value, out var sphere) && sphere >= 1) { + if (!result.ContainsKey(type)) + result[type] = new List(); + result[type].Add(sphere); + } + } + } + foreach (var limits in result.Values) { + limits.Sort(); + } + return result; + } + + /// + /// Moves items earlier until every setting is satisfied. Each swap must keep the + /// seed fully reachable and strictly improve how well the settings are met. + /// + public static void Enforce(List worlds, Config config, Random rnd, CancellationToken cancellationToken) { + if (config.ItemSpheres.Count == 0) + return; + + var locations = worlds.SelectMany(w => w.Locations).ToList(); + /* Keycards and initial items are not placed in locations but still count as owned */ + var everything = worlds.ToDictionary(w => w.Id, + w => new Progression(locations.Select(l => l.Item).Where(i => i.World == w) + .Concat(w.Config.Keysanity ? Enumerable.Empty() : Item.CreateKeycards(w)) + .Concat(w.Config.InitialItems.SelectMany(ii => Enumerable.Repeat(new Item(ii.Key, w), ii.Value))), + w.Config.Skills)); + + bool CanHold(Location location, Item item) => location.CanFill(item, everything[location.Region.World.Id]); + + bool IsPinned(Location location) => + location.Name == "Skull Woods - Pinball Room" || + config.SwordLocation == SwordLocation.Uncle && location.Name == "Link's Uncle" || + config.MorphLocation == MorphLocation.Original && location.Name == "Morphing Ball"; + + var spheres = Playthrough.LocationSpheres(worlds); + var misses = FindMisses(worlds, config, locations, spheres); + + while (misses.Count > 0) { + var miss = misses[0]; + var score = misses.Sum(m => m.Excess); + var candidates = locations + .Where(l => l != miss.Location && l.Item.Type != miss.Location.Item.Type && + spheres.TryGetValue(l, out var s) && s <= miss.Limit && !IsPinned(l)) + .Shuffle(rnd) + .Take(MaxCandidatesPerMiss) + .ToList(); + + /* Applies the assignment if the seed stays reachable and the settings are better met, otherwise undoes it */ + bool TryApply(params (Location location, Item item)[] assignment) { + cancellationToken.ThrowIfCancellationRequested(); + + var previous = assignment.Select(a => a.location.Item).ToArray(); + foreach (var (location, item) in assignment) { + location.Item = item; + } + + var newSpheres = Playthrough.LocationSpheres(worlds); + if (newSpheres.Count >= spheres.Count) { + var newMisses = FindMisses(worlds, config, locations, newSpheres); + if (newMisses.Sum(m => m.Excess) < score) { + spheres = newSpheres; + misses = newMisses; + return true; + } + } + + for (var i = 0; i < assignment.Length; i++) { + assignment[i].location.Item = previous[i]; + } + return false; + } + + var moving = miss.Location.Item; + var fixedMiss = false; + + /* Swap with an item that is already early enough */ + foreach (var candidate in candidates) { + var displaced = candidate.Item; + if (CanHold(candidate, moving) && CanHold(miss.Location, displaced) && + TryApply((miss.Location, displaced), (candidate, moving))) { + fixedMiss = true; + break; + } + } + + /* Early locations tend to hold items the rest of the seed depends on, so also try + * sending the displaced item to where a non progression item is, and that one to + * the old location */ + if (!fixedMiss) { + var fillers = locations + .Where(l => l != miss.Location && !l.Item.Progression && !IsPinned(l)) + .Shuffle(rnd); + foreach (var candidate in candidates) { + var displaced = candidate.Item; + if (!CanHold(candidate, moving)) + continue; + + foreach (var filler in fillers.Where(f => f != candidate).Take(MaxCandidatesPerMiss)) { + var junk = filler.Item; + if (CanHold(filler, displaced) && CanHold(miss.Location, junk) && + TryApply((candidate, moving), (filler, displaced), (miss.Location, junk))) { + fixedMiss = true; + break; + } + } + if (fixedMiss) + break; + } + } + + if (!fixedMiss) { + throw new InvalidOperationException( + $"Unable to place {miss.Location.Item.Name} by sphere {miss.Limit}, try a later sphere or fewer sphere settings"); + } + } + } + + record Miss(Location Location, int Limit, int Excess); + + /// Settings that are not met, tightest first + static List FindMisses(List worlds, Config config, List locations, Dictionary spheres) { + var misses = new List(); + foreach (var world in worlds) { + foreach (var (type, limits) in config.ItemSpheres) { + /* Initial items are available from the start, i.e. sphere 0 */ + var initial = config.InitialItems.TryGetValue(type, out var n) ? n : 0; + var copies = Enumerable.Repeat((Location: (Location)null, Sphere: 0), initial) + .Concat(locations.Where(l => l.Item.Is(type, world)) + .Select(l => (Location: l, Sphere: spheres.TryGetValue(l, out var s) ? s : Unreachable))) + .OrderBy(c => c.Sphere) + .ToList(); + + for (var i = 0; i < Math.Min(limits.Count, copies.Count); i++) { + if (copies[i].Sphere > limits[i]) { + misses.Add(new Miss(copies[i].Location, limits[i], copies[i].Sphere - limits[i])); + } + } + } + } + return misses.OrderBy(m => m.Limit).ToList(); + } + + } + +} diff --git a/WebRandomizer/ClientApp/src/generate/Configure.jsx b/WebRandomizer/ClientApp/src/generate/Configure.jsx index c34e0397..87e68ce0 100644 --- a/WebRandomizer/ClientApp/src/generate/Configure.jsx +++ b/WebRandomizer/ClientApp/src/generate/Configure.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useContext } from 'react'; import { useHistory, useParams } from 'react-router-dom'; -import { Container, Row, Col, Card, CardHeader, CardBody } from 'reactstrap'; +import { Container, Row, Col, Card, CardHeader, CardBody, Collapse } from 'reactstrap'; import { Form, FormGroup, Button, Input } from 'reactstrap'; import { Modal, ModalHeader, ModalBody, Progress } from 'reactstrap'; import BootstrapSwitchButton from 'bootstrap-switch-button-react'; @@ -18,9 +18,14 @@ import map from 'lodash/map'; import transform from 'lodash/transform'; import chunk from 'lodash/chunk'; import range from 'lodash/range'; +import partition from 'lodash/partition'; import attempt from 'lodash/attempt'; import defaultTo from 'lodash/defaultTo'; +/* Still sent with the options, just not shown */ +const hiddenKeys = ['hardskills']; +const skillKeys = ['hardskills', 'infinitebombjump', 'mockball', 'shortspark', 'healthindifferent', 'darkrooms']; + export default function Configure() { const game = useContext(GameTraitsCtx); @@ -35,6 +40,8 @@ export default function Configure() { const [randomizer, setRandomizer] = useState(null); const [modal, setModal] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const [showSkills, setShowSkills] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); useEffect(() => { attempt(async () => { @@ -90,6 +97,35 @@ export default function Configure() { setOptions({ ...options, [key]: value }); } + /* Skill and sphere options are collapsed by default, everything else is shown up front */ + const isSkill = (opt) => skillKeys.includes(opt.key); + const isAdvanced = (opt) => opt.key.startsWith('sphere-'); + const allOptions = randomizer ? randomizer.options : []; + const [skillOptions, rest] = partition(allOptions.filter(opt => !hiddenKeys.includes(opt.key)), isSkill); + const [advancedOptions, mainOptions] = partition(rest, isAdvanced); + + function createOptionRows(list) { + return map(chunk(list, 2), (group, i) => ( + + {map(group, (option) => { + let input = createFormOption(option); + return input && {input}; + })} + + )); + } + + function createSection(title, list, open, setOpen) { + return list.length > 0 && ( +
+ + {createOptionRows(list)} +
+ ); + } + const gameMismatch = gameId !== game.id; const content = randomizer && !gameMismatch ? ( @@ -98,14 +134,7 @@ export default function Configure() {
{ e.preventDefault(); createGame(); }}> - {map(chunk(randomizer.options, 2), (group, i) => ( - - {map(group, (option) => { - let input = createFormOption(option); - return input && {input}; - })} - - ))} + {createOptionRows(mainOptions)} {options.gamemode === 'multiworld' && ( {map(createPlayerInputs(), (input, p) => ( @@ -115,6 +144,8 @@ export default function Configure() { ))} )} + {createSection('Skills', skillOptions, showSkills, setShowSkills)} + {createSection('Advanced', advancedOptions, showAdvanced, setShowAdvanced)} diff --git a/run.sh b/run.sh new file mode 100755 index 00000000..b95600f5 --- /dev/null +++ b/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/bash + +export DOTNET_ROLL_FORWARD=Major +cd WebRandomizer +ASPNETCORE_ENVIRONMENT=Development dotnet run + diff --git a/sm_randomizer_rom b/sm_randomizer_rom index c8af9fe6..816ce774 160000 --- a/sm_randomizer_rom +++ b/sm_randomizer_rom @@ -1 +1 @@ -Subproject commit c8af9fe6c064cbbc28ff96747e7b9890b149f82b +Subproject commit 816ce774e079dcd3f5c819a538fec9a25af81479