From 346de40cff81bd731116234f7203c0dee4122d1e Mon Sep 17 00:00:00 2001 From: Luke Dupin Date: Fri, 30 Oct 2026 22:42:02 -0700 Subject: [PATCH] Added the ability to set skills and items Now you can select specifically which hard skills are allowed. You can now also select when items need to appear by. If you want the latern to show up at least by sphere 2, that is possible --- Randomizer.SMZ3.Tests/Logic/Case.cs | 2 +- Randomizer.SMZ3.Tests/Logic/SkillTests.cs | 81 +++++++ Randomizer.SMZ3.Tests/Logic/SphereTests.cs | 120 ++++++++++ Randomizer.SMZ3/Config.cs | 51 ++++- Randomizer.SMZ3/Filler.cs | 2 + Randomizer.SMZ3/Item.cs | 21 +- Randomizer.SMZ3/Location.cs | 6 +- Randomizer.SMZ3/Playthrough.cs | 36 +++ Randomizer.SMZ3/RandomizerOptions.cs | 53 ++++- Randomizer.SMZ3/Region.cs | 2 +- Randomizer.SMZ3/Regions/Zelda/CastleTower.cs | 4 +- .../Regions/Zelda/EasternPalace.cs | 4 +- Randomizer.SMZ3/Regions/Zelda/HyruleCastle.cs | 8 +- .../Zelda/LightWorld/DeathMountain/West.cs | 4 +- Randomizer.SMZ3/Regions/Zelda/MiseryMire.cs | 2 +- .../Regions/Zelda/PalaceOfDarkness.cs | 20 +- Randomizer.SMZ3/Regions/Zelda/TurtleRock.cs | 4 +- Randomizer.SMZ3/SphereConstraints.cs | 210 ++++++++++++++++++ .../ClientApp/src/generate/Configure.jsx | 49 +++- run.sh | 6 + sm_randomizer_rom | 2 +- 21 files changed, 638 insertions(+), 49 deletions(-) create mode 100644 Randomizer.SMZ3.Tests/Logic/SkillTests.cs create mode 100644 Randomizer.SMZ3.Tests/Logic/SphereTests.cs create mode 100644 Randomizer.SMZ3/SphereConstraints.cs create mode 100755 run.sh 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