Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Randomizer.SMZ3.Tests/Logic/Case.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Preparation> GetEnumerator() => preparations.GetEnumerator();
Expand Down
81 changes: 81 additions & 0 deletions Randomizer.SMZ3.Tests/Logic/SkillTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}

}

}
120 changes: 120 additions & 0 deletions Randomizer.SMZ3.Tests/Logic/SphereTests.cs
Original file line number Diff line number Diff line change
@@ -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<World> worlds, Dictionary<Location, int> spheres) Generate(int seed, params (string, string)[] options) {
var config = RandomizerOptions.Parse(options.ToDictionary(o => o.Item1, o => o.Item2));
var worlds = new List<World> { new World(config, "Player", 0, "") };
new Filler(worlds, config, new Random(seed), CancellationToken.None).Fill();
return (worlds, Playthrough.LocationSpheres(worlds));
}

static List<int> SpheresOf(List<World> worlds, Dictionary<Location, int> 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<string, string>()).ItemSpheres, Is.Empty);
Assert.That(RandomizerOptions.Parse(new Dictionary<string, string> { ["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<string, string> {
["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<World> { 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<string, string> { ["keyshuffle"] = limits["keyshuffle"] });
var plainWorlds = new List<World> { 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<string, string> {
["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));
}

}

}
51 changes: 50 additions & 1 deletion Randomizer.SMZ3/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,42 @@ enum SMLogic {
Hard,
}

[DefaultValue(Default)]
enum SkillSetting {
[Description("Default (per logic)")]
Default,
[Description("Enabled")]
Enabled,
[Description("Disabled")]
Disabled,
}

/// <summary>
/// The Super Metroid / dungeon tricks the player is expected to be able to perform.
/// <see cref="SMLogic"/> only selects a preset of these, each can be overridden individually.
/// </summary>
class SMSkills {

/// <summary>Generic "hard" Super Metroid routes (everything the Hard logic adds over Normal).</summary>
public bool HardSkills { get; set; }
/// <summary>Infinite bomb jumping, Morph + Bombs counts as flight.</summary>
public bool InfiniteBombJump { get; set; }
/// <summary>Mockball (entering morph-only passages without stopping).</summary>
public bool MockBall { get; set; }
/// <summary>Short charge / short spark with a minimal speed booster runway.</summary>
public bool ShortSpark { get; set; }
/// <summary>Energy tank / reserve requirements are ignored.</summary>
public bool HealthIndifferent { get; set; }
/// <summary>Dark rooms can be navigated without the Lamp.</summary>
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")]
Expand Down Expand Up @@ -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);

/// <summary>Selecting a logic resets <see cref="Skills"/> to that logic's preset.</summary>
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;
Expand All @@ -141,6 +188,8 @@ class Config {
public GanonVulnerable GanonVulnerable { get; set; } = GanonVulnerable.SevenCrystals;
public OpenTourian OpenTourian { get; set; } = OpenTourian.FourBosses;
public Dictionary<ItemType, int> InitialItems = new Dictionary<ItemType, int>();
/// <summary>Latest sphere each copy of an item may appear in, sorted ascending. Items not listed are random.</summary>
public Dictionary<ItemType, List<int>> ItemSpheres { get; set; } = new Dictionary<ItemType, List<int>>();

public bool SingleWorld => GameMode == GameMode.Normal;
public bool MultiWorld => GameMode == GameMode.Multiworld;
Expand Down
2 changes: 2 additions & 0 deletions Randomizer.SMZ3/Filler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Item> itemPool, IEnumerable<(ItemType type, double weight)> reorder) {
Expand Down
21 changes: 14 additions & 7 deletions Randomizer.SMZ3/Item.cs
Original file line number Diff line number Diff line change
Expand Up @@ -704,7 +704,10 @@ class Progression {

int shield;

public Progression(IEnumerable<Item> items) {
public SMSkills Skills { get; }

public Progression(IEnumerable<Item> items, SMSkills skills = null) {
Skills = skills ?? items.FirstOrDefault()?.World?.Config.Skills ?? SMSkills.Preset(SMLogic.Normal);
Add(items);
}

Expand Down Expand Up @@ -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,
_ =>
Expand All @@ -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(),
Expand All @@ -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) {
Expand Down Expand Up @@ -895,23 +902,23 @@ 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) {
return items.Missile || items.Super;
}

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) {
return items.Flute && items.CanLiftHeavy();
}

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 &&
Expand Down
6 changes: 3 additions & 3 deletions Randomizer.SMZ3/Location.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,13 @@ public static IEnumerable<Location> AvailableWithinWorld(this IEnumerable<Locati
}

public static IEnumerable<Location> Available(this IEnumerable<Location> locations, IEnumerable<Item> 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<Location> CanFillWithinWorld(this IEnumerable<Location> locations, Item item, IEnumerable<Item> 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]) &&
Expand Down
Loading