From 3535daf043c24db931e808b3ea94bd7a8e62cdfe Mon Sep 17 00:00:00 2001 From: Jarno Westhof Date: Fri, 11 Apr 2025 00:05:58 +0200 Subject: [PATCH] Added options groups and presets --- worlds/satisfactory/CriticalPathCalculator.py | 57 +++++---- worlds/satisfactory/GameLogic.py | 16 --- worlds/satisfactory/Items.py | 2 +- worlds/satisfactory/Locations.py | 2 +- worlds/satisfactory/Options.py | 116 +++++++++++++++--- worlds/satisfactory/StateLogic.py | 14 ++- worlds/satisfactory/Web.py | 4 + worlds/satisfactory/__init__.py | 2 +- 8 files changed, 146 insertions(+), 67 deletions(-) diff --git a/worlds/satisfactory/CriticalPathCalculator.py b/worlds/satisfactory/CriticalPathCalculator.py index 734816d681..dac04c46c1 100644 --- a/worlds/satisfactory/CriticalPathCalculator.py +++ b/worlds/satisfactory/CriticalPathCalculator.py @@ -16,13 +16,14 @@ class CriticalPathCalculator: required_power_level: int __potential_required_belt_speed: int - __potential_required_pipes: bool - __potential_required_radioactive: bool parts_to_exclude: set[str] recipes_to_exclude: set[str] buildings_to_exclude: set[str] + implicitly_unlocked: set[str] + handcraftable_parts: dict[str, list[Recipe]] + def __init__(self, logic: GameLogic, random: Random, options: SatisfactoryOptions): self.logic = logic self.random = random @@ -33,9 +34,24 @@ class CriticalPathCalculator: self.required_power_level: int = 1 self.__potential_required_belt_speed = 1 - self.__potential_required_pipes = False selected_power_infrastructure: dict[int, Recipe] = {} + + self.implicitly_unlocked: set[str] = { + recipe.name + for recipes_per_part in logic.recipes.values() + for recipe in recipes_per_part if recipe.implicitly_unlocked + } + self.implicitly_unlocked.update({ + building.name + for building in logic.buildings.values() if building.implicitly_unlocked + }) + + self.handcraftable_parts: dict[str, list[Recipe]] = {} + for part, recipes_per_part in logic.recipes.items(): + for recipe in recipes_per_part: + if recipe.handcraftable: + self.handcraftable_parts.setdefault(part, list()).append(recipe) self.select_minimal_required_parts_for(self.logic.space_elevator_tiers[options.final_elevator_package-1].keys()) @@ -59,39 +75,39 @@ class CriticalPathCalculator: self.select_minimal_required_parts_for_building("Walls Orange") self.select_minimal_required_parts_for_building("Power Storage") self.select_minimal_required_parts_for_building("Miner Mk.2") + self.select_minimal_required_parts_for_building("Pipes Mk.1") + self.select_minimal_required_parts_for_building("Pipes Mk.2") + self.select_minimal_required_parts_for_building("Pipeline Pump Mk.1") + self.select_minimal_required_parts_for_building("Pipeline Pump Mk.2") if self.logic.recipes["Uranium"][0].minimal_tier <= options.final_elevator_package: self.select_minimal_required_parts_for(("Hazmat Suit", "Iodine-Infused Filter")) for i in range(1, self.__potential_required_belt_speed + 1): self.select_minimal_required_parts_for_building(f"Conveyor Mk.{i}") - if self.__potential_required_pipes: - self.select_minimal_required_parts_for_building("Pipes Mk.1") - self.select_minimal_required_parts_for_building("Pipes Mk.2") - self.select_minimal_required_parts_for_building("Pipeline Pump Mk.1") - self.select_minimal_required_parts_for_building("Pipeline Pump Mk.2") + for i in range(1, self.required_power_level + 1): power_recipe = random.choice(self.logic.requirement_per_powerlevel[i]) selected_power_infrastructure[i] = power_recipe self.select_minimal_required_parts_for(power_recipe.inputs) self.select_minimal_required_parts_for_building(power_recipe.building) - self.required_item_names = set( + self.required_item_names = { recipe.name for part in self.required_parts for recipe in self.logic.recipes[part] if recipe.minimal_tier <= self.options.final_elevator_package - ) - self.required_item_names.update("Building: "+ building for building in self.required_buildings) + } + self.required_item_names.update({"Building: "+ building for building in self.required_buildings}) self.parts_to_exclude = set() self.buildings_to_exclude = set() - self.recipes_to_exclude = set( + self.recipes_to_exclude = { recipe.name for part in self.logic.recipes for recipe in self.logic.recipes[part] if recipe.minimal_tier > self.options.final_elevator_package - ) + } excluded_count = len(self.recipes_to_exclude) while True: @@ -109,20 +125,20 @@ class CriticalPathCalculator: if all(r.name in self.recipes_to_exclude for r in self.logic.recipes[part]): self.parts_to_exclude.add(part) - new_buildings_to_exclude = set( + new_buildings_to_exclude = { building_name for building_name, building in self.logic.buildings.items() if building_name not in self.buildings_to_exclude and building.inputs and any(input in self.parts_to_exclude for input in building.inputs) - ) + } - self.recipes_to_exclude.update( + self.recipes_to_exclude.update({ recipe_per_part.name for building_to_exclude in new_buildings_to_exclude for recipes_per_part in self.logic.recipes.values() for recipe_per_part in recipes_per_part if recipe_per_part.building == building_to_exclude - ) + }) self.buildings_to_exclude.update(new_buildings_to_exclude) @@ -131,10 +147,12 @@ class CriticalPathCalculator: break excluded_count = new_length + def select_minimal_required_parts_for_building(self, building: str) -> None: self.select_minimal_required_parts_for(self.logic.buildings[building].inputs) self.required_buildings.add(building) + def select_minimal_required_parts_for(self, parts: Optional[Iterable[str]]) -> None: if parts is None: return @@ -154,11 +172,6 @@ class CriticalPathCalculator: self.select_minimal_required_parts_for(recipe.inputs) - if recipe.needs_pipes: - self.__potential_required_pipes = True - if recipe.is_radio_active: - self.__potential_required_radioactive = True - if recipe.building: self.select_minimal_required_parts_for(self.logic.buildings[recipe.building].inputs) self.required_buildings.add(recipe.building) diff --git a/worlds/satisfactory/GameLogic.py b/worlds/satisfactory/GameLogic.py index 297eae9cd5..5748ce29c5 100644 --- a/worlds/satisfactory/GameLogic.py +++ b/worlds/satisfactory/GameLogic.py @@ -632,22 +632,6 @@ class GameLogic: #1.0 } - handcraftable_recipes: dict[str, list[Recipe]] = {} - for part, recipes_per_part in recipes.items(): - for recipe in recipes_per_part: - if recipe.handcraftable: - handcraftable_recipes.setdefault(part, list()).append(recipe) - - implicitly_unlocked_recipes: dict[str, Recipe] = { - recipe.name: recipe - for recipes_per_part in recipes.values() - for recipe in recipes_per_part if recipe.implicitly_unlocked - } - implicitly_unlocked_recipes.update({ - building.name: building - for building in buildings.values() if building.implicitly_unlocked - }) - requirement_per_powerlevel: dict[PowerInfrastructureLevel, tuple[Recipe, ...]] = { # no need to polute the logic by including higher level recipes based on previus recipes PowerInfrastructureLevel.Basic: ( diff --git a/worlds/satisfactory/Items.py b/worlds/satisfactory/Items.py index b8ee44b6e1..f14723f4a0 100644 --- a/worlds/satisfactory/Items.py +++ b/worlds/satisfactory/Items.py @@ -929,7 +929,7 @@ class Items: def build_item_pool(self, random: Random, multiworld: MultiWorld, options: SatisfactoryOptions, number_of_locations: int) -> list[Item]: excluded_from_pool: set[str] = self.get_excluded_items(multiworld, options) \ - .union(self.logic.implicitly_unlocked_recipes.keys()) + .union(self.critical_path.implicitly_unlocked) pool: list[Item] = [] for name, data in self.item_data.items(): diff --git a/worlds/satisfactory/Locations.py b/worlds/satisfactory/Locations.py index 3add0ef53f..8f8d46ab1e 100644 --- a/worlds/satisfactory/Locations.py +++ b/worlds/satisfactory/Locations.py @@ -63,7 +63,7 @@ class EventBuilding(LocationData): ) -> Callable[[CollectionState], bool]: def can_build(state: CollectionState) -> bool: - return (building.implicitly_unlocked or state_logic.has_recipe(state, building)) \ + return state_logic.has_recipe(state, building) \ and state_logic.can_power(state, building.power_requirement) \ and state_logic.can_produce_all_allowing_handcrafting(state, game_logic, building.inputs) diff --git a/worlds/satisfactory/Options.py b/worlds/satisfactory/Options.py index ee95e8708a..8f6936dfc9 100644 --- a/worlds/satisfactory/Options.py +++ b/worlds/satisfactory/Options.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import ClassVar, Any, cast from enum import IntEnum -from Options import PerGameCommonOptions, DeathLinkMixin, AssembleOptions, Visibility +from Options import PerGameCommonOptions, DeathLinkMixin, AssembleOptions, Visibility, OptionGroup from Options import Range, NamedRange, Toggle, DefaultOnToggle, OptionSet, StartInventoryPool, Choice from schema import Schema, And @@ -30,11 +30,13 @@ class ChoiceMapMeta(AssembleOptions): option_name = "option_" + choice.replace(' ', '_') attrs[option_name] = index + if "default" in attrs and attrs["default"] == choice: + attrs["default"] = index + cls = super(ChoiceMapMeta, mcs).__new__(mcs, name, bases, attrs) return cast(ChoiceMapMeta, cls) class ChoiceMap(Choice, metaclass=ChoiceMapMeta): - # TODO `default` doesn't do anything, default is always the first `choices` value. if uncommented it messes up the template file generation (caps mismatch) choices: ClassVar[dict[str, list[str]]] def get_selected_list(self) -> list[str]: @@ -47,7 +49,7 @@ class ElevatorTier(NamedRange): Put these Shipments to Space Elevator packages in logic. if your goal selection contains *Space Elevator Tier* then the goal will be to complete these shipments. """ - display_name = "Goal: Space Elevator shipment" + display_name = "Space Elevator shipments in logic" default = 2 range_start = 1 range_end = 5 @@ -73,7 +75,7 @@ class ResourceSinkPointsTotal(NamedRange): If you have *Free Samples* enabled, consider setting this higher so that you can't reach the goal just by sinking your Free Samples. """ # Coupon data for above comment from https://satisfactory.wiki.gg/wiki/AWESOME_Shop - display_name = "Goal: AWESOME Sink points total" + display_name = "AWESOME Sink points total" default = 2166000 range_start = 2166000 range_end = 18436379500 @@ -110,7 +112,7 @@ class ResourceSinkPointsPerMinute(NamedRange): Use the **TFIT - Ficsit Information Tool** mod or the Satisfactory wiki to find out how many points items are worth. """ # Coupon data for above comment from https://satisfactory.wiki.gg/wiki/AWESOME_Shop - display_name = "Goal: AWESOME Sink points per minute" + display_name = "AWESOME Sink points per minute" default = 50000 range_start = 1000 range_end = 10000000 @@ -124,7 +126,7 @@ class ResourceSinkPointsPerMinute(NamedRange): "~50 motor/min": 76000, "~10 heavy modular frame/min": 100000, "~10 radio control unit": 300000, - "~10 heavy modular frame/min": 625000, + "~10 fused modular frame/min": 625000, "~10 supercomputer/min": 1000000, "~10 pressure conversion cube/min": 2500000, "~10 nuclear pasta/min": 5000000, @@ -253,8 +255,8 @@ class TrapSelectionPreset(ChoiceMap): """ display_name = "Trap Presets" choices = { - "Normal": ["Trap: Doggo with Pulse Nobelisk", "Trap: Doggo with Gas Nobelisk", "Trap: Hog", "Trap: Alpha Hog", "Trap: Hatcher", "Trap: Elite Hatcher", "Trap: Small Stinger", "Trap: Stinger", "Trap: Spitter", "Trap: Alpha Spitter", "Trap: Not the Bees", "Trap: Nuclear Waste Drop", "Bundle: Uranium", "Bundle: Non-fissile Uranium", "Trap: Can of Beans", "Trap: Fart Cloud"], "Gentle": ["Trap: Doggo with Pulse Nobelisk", "Trap: Hog", "Trap: Spitter", "Trap: Can of Beans"], + "Normal": ["Trap: Doggo with Pulse Nobelisk", "Trap: Doggo with Gas Nobelisk", "Trap: Hog", "Trap: Alpha Hog", "Trap: Hatcher", "Trap: Elite Hatcher", "Trap: Small Stinger", "Trap: Stinger", "Trap: Spitter", "Trap: Alpha Spitter", "Trap: Not the Bees", "Trap: Nuclear Waste Drop", "Bundle: Uranium", "Bundle: Non-fissile Uranium", "Trap: Can of Beans", "Trap: Fart Cloud"], "Harder": ["Trap: Doggo with Pulse Nobelisk", "Trap: Doggo with Nuke Nobelisk", "Trap: Doggo with Gas Nobelisk", "Trap: Alpha Hog", "Trap: Cliff Hog", "Trap: Spore Flower", "Trap: Hatcher", "Trap: Elite Hatcher", "Trap: Stinger", "Trap: Alpha Spitter", "Trap: Not the Bees", "Trap: Fart Cloud", "Trap: Nuclear Waste Drop", "Trap: Plutonium Waste Drop", "Bundle: Uranium", "Bundle: Uranium Fuel Rod", "Bundle: Uranium Waste", "Bundle: Plutonium Fuel Rod", "Bundle: Plutonium Pellet", "Bundle: Plutonium Waste", "Bundle: Non-fissile Uranium"], "All": list(_trap_types), "Ruthless": ["Trap: Doggo with Nuke Nobelisk", "Trap: Nuclear Hog", "Trap: Cliff Hog", "Trap: Elite Hatcher", "Trap: Spore Flower", "Trap: Gas Stinger", "Trap: Nuclear Waste Drop", "Trap: Plutonium Waste Drop", "Bundle: Uranium Fuel Rod", "Bundle: Uranium Waste", "Bundle: Plutonium Fuel Rod", "Bundle: Plutonium Pellet", "Bundle: Plutonium Waste", "Bundle: Non-fissile Uranium", "Bundle: Ficsonium", "Bundle: Ficsonium Fuel Rod"], @@ -263,7 +265,7 @@ class TrapSelectionPreset(ChoiceMap): "Nicholas Cage": ["Trap: Hatcher", "Trap: Elite Hatcher", "Trap: Not the Bees"], "Fallout": ["Trap: Doggo with Nuke Nobelisk", "Trap: Nuclear Hog", "Trap: Nuclear Waste Drop", "Trap: Plutonium Waste Drop", "Bundle: Uranium", "Bundle: Uranium Fuel Rod", "Bundle: Uranium Waste", "Bundle: Plutonium Fuel Rod", "Bundle: Plutonium Waste", "Bundle: Ficsonium", "Bundle: Ficsonium Fuel Rod"], } - # default="Normal" # TODO `default` doesn't do anything, default is always the first `choices` value. if uncommented it messes up the template file generation (caps mismatch) + default="Normal" class TrapSelectionOverride(OptionSet): """ @@ -358,13 +360,13 @@ class StartingInventoryPreset(ChoiceMap): """ display_name = "Starting Goodies Presets" choices = { - "Archipelago": _default_starting_items, "Barebones": [], # Nothing but the xeno zapper "Skip Tutorial Inspired": _skip_tutorial_starting_items, + "Archipelago": _default_starting_items, "Foundations": _default_plus_foundations_starting_items, "Foundation Lover": _foundation_lover_starting_items } - # default = "Archipelago" # TODO `default` doesn't do anything, default is always the first `choices` value. if uncommented it messes up the template file generation (caps mismatch) + default = "Archipelago" class ExplorationCollectableCount(Range): """ @@ -372,7 +374,7 @@ class ExplorationCollectableCount(Range): Collect this amount of Mercer Spheres and Summer Sloops each to finish. """ - display_name = "Goal: Exploration Collectables" + display_name = "Exploration Collectables" default = 20 range_start = 20 range_end = 100 @@ -417,22 +419,20 @@ class GoalRequirement(Choice): option_require_all_goals = 1 default = 0 -class ExperimentalGeneration(Toggle): +class RandomizeTier0(DefaultOnToggle): """ - Attempts to only mark recipes as progression if they are on your path to victory. - WARNING: has a very high change of generation failure and should therefore only be used in single player games. + Randomizer the way you obtain basic parts such as ingots and wire """ - display_name = "Experimental Generation" - visibility = Visibility.none + display_name = "Randomize tier 0 recipes" @dataclass class SatisfactoryOptions(PerGameCommonOptions, DeathLinkMixin): goal_selection: GoalSelection goal_requirement: GoalRequirement final_elevator_package: ElevatorTier - final_awesome_sink_points_total: ResourceSinkPointsTotal - final_awesome_sink_points_per_minute: ResourceSinkPointsPerMinute - final_exploration_collectables_amount: ExplorationCollectableCount + goal_awesome_sink_points_total: ResourceSinkPointsTotal + goal_awesome_sink_points_per_minute: ResourceSinkPointsPerMinute + goal_exploration_collectables_amount: ExplorationCollectableCount hard_drive_progression_limit: HardDriveProgressionLimit free_sample_equipment: FreeSampleEquipment free_sample_buildings: FreeSampleBuildings @@ -449,4 +449,80 @@ class SatisfactoryOptions(PerGameCommonOptions, DeathLinkMixin): trap_selection_override: TrapSelectionOverride energy_link: EnergyLink start_inventory_from_pool: StartInventoryPool - experimental_generation: ExperimentalGeneration + randomize_tier_0: RandomizeTier0 + +option_groups = [ + OptionGroup("Game Scope", [ + ElevatorTier, + HardDriveProgressionLimit + ]), + OptionGroup("Goal Selection", [ + GoalSelection, + GoalRequirement, + ResourceSinkPointsTotal, + ResourceSinkPointsPerMinute, + ExplorationCollectableCount + ]), + OptionGroup("Placement logic", [ + StartingInventoryPreset, + RandomizeTier0, + MamLogic, + AwesomeLogic, + SplitterLogic, + EnergyLinkLogic + ], start_collapsed = True), + OptionGroup("Free Samples", [ + FreeSampleEquipment, + FreeSampleBuildings, + FreeSampleParts, + FreeSampleRadioactive + ], start_collapsed = True), + OptionGroup("Traps", [ + TrapChance, + TrapSelectionPreset, + TrapSelectionOverride + ], start_collapsed = True) +] + +option_presets: dict[str, dict[str, Any]] = { + "Short": { + "final_elevator_package": 1, + "goal_selection": {"Space Elevator Tier", "AWESOME Sink Points (total)"}, + "goal_requirement": GoalRequirement.option_require_any_one_goal, + "goal_awesome_sink_points_total": 17804500, # 100 coupons + "hard_drive_progression_limit": 20, + "starting_inventory_preset": 3, # "Foundations" + "randomize_tier_0": False, + "mam_logic_placement": int(Placement.starting_inventory), + "awesome_logic_placement": int(Placement.starting_inventory), + "energy_link_logic_placement": int(Placement.starting_inventory), + "splitter_placement": int(Placement.starting_inventory), + "milestone_cost_multiplier": 50, + "trap_selection_preset": 1 # Gentle + }, + "Long": { + "final_elevator_package": 3, + "goal_selection": {"Space Elevator Tier", "AWESOME Sink Points (per minute)"}, + "goal_requirement": GoalRequirement.option_require_all_goals, + "goal_awesome_sink_points_per_minute": 100000, # ~10 heavy modular frame/min + "hard_drive_progression_limit": 60, + "mam_logic_placement": int(Placement.somewhere), + "awesome_logic_placement": int(Placement.somewhere), + "energy_link_logic_placement": int(Placement.somewhere), + "splitter_placement": int(Placement.somewhere), + "trap_selection_preset": 3 # Harder + }, + "Extra long": { + "final_elevator_package": 5, + "goal_selection": {"Space Elevator Tier", "AWESOME Sink Points (per minute)"}, + "goal_requirement": GoalRequirement.option_require_all_goals, + "goal_awesome_sink_points_per_minute": 625000, # ~10 fused modular frame/min + "hard_drive_progression_limit": 100, + "mam_logic_placement": int(Placement.somewhere), + "awesome_logic_placement": int(Placement.somewhere), + "energy_link_logic_placement": int(Placement.somewhere), + "splitter_placement": int(Placement.somewhere), + "milestone_cost_multiplier": 300, + "trap_selection_preset": 4 # All + } +} diff --git a/worlds/satisfactory/StateLogic.py b/worlds/satisfactory/StateLogic.py index cb089ece1e..893c822488 100644 --- a/worlds/satisfactory/StateLogic.py +++ b/worlds/satisfactory/StateLogic.py @@ -3,6 +3,7 @@ from collections.abc import Iterable from BaseClasses import CollectionState from .GameLogic import GameLogic, Recipe, PowerInfrastructureLevel from .Options import SatisfactoryOptions +from .CriticalPathCalculator import CriticalPathCalculator EventId: Optional[int] = None @@ -12,14 +13,16 @@ building_event_prefix = "Can Build: " class StateLogic: player: int options: SatisfactoryOptions + critical_path: CriticalPathCalculator initial_unlocked_items: set[str] - def __init__(self, player: int, options: SatisfactoryOptions): + def __init__(self, player: int, options: SatisfactoryOptions, critical_path: CriticalPathCalculator): self.player = player self.options = options + self.critical_path = critical_path def has_recipe(self, state: CollectionState, recipe: Recipe): - return recipe.implicitly_unlocked or state.has(recipe.name, self.player) + return state.has(recipe.name, self.player) or recipe.name in self.critical_path.implicitly_unlocked def can_build(self, state: CollectionState, building_name: Optional[str]) -> bool: return building_name is None or state.has(building_event_prefix + building_name, self.player) @@ -48,11 +51,10 @@ class StateLogic: def can_handcraft_part(part: str) -> bool: if self.can_produce(state, part): return True - elif part not in logic.handcraftable_recipes: + elif part not in self.critical_path.handcraftable_parts: return False - recipes: list[Recipe] = logic.handcraftable_recipes[part] - + recipes: list[Recipe] = self.critical_path.handcraftable_parts[part] return any( self.has_recipe(state, recipe) and (not recipe.inputs or self.can_produce_all_allowing_handcrafting(state, logic, recipe.inputs)) @@ -69,7 +71,7 @@ class StateLogic: if recipe.is_radio_active and not self.can_produce_all(state, ("Hazmat Suit", "Iodine-Infused Filter")): return False - if not self.options.experimental_generation and recipe.minimal_belt_speed and \ + if recipe.minimal_belt_speed and \ not self.can_build_any(state, map(self.to_belt_name, range(recipe.minimal_belt_speed, 6))): return False diff --git a/worlds/satisfactory/Web.py b/worlds/satisfactory/Web.py index 1be0e10f57..09f4e70104 100644 --- a/worlds/satisfactory/Web.py +++ b/worlds/satisfactory/Web.py @@ -1,4 +1,5 @@ from BaseClasses import Tutorial +from .Options import option_groups, option_presets from ..AutoWorld import WebWorld class SatisfactoryWebWorld(WebWorld): @@ -13,3 +14,6 @@ class SatisfactoryWebWorld(WebWorld): ) tutorials = [setup] rich_text_options_doc = True + + option_groups = option_groups + options_presets = option_presets diff --git a/worlds/satisfactory/__init__.py b/worlds/satisfactory/__init__.py index 6effd58f93..4f9aa0eb77 100644 --- a/worlds/satisfactory/__init__.py +++ b/worlds/satisfactory/__init__.py @@ -34,8 +34,8 @@ class SatisfactoryWorld(World): critical_path: CriticalPathCalculator def generate_early(self) -> None: - self.state_logic = StateLogic(self.player, self.options) self.critical_path = CriticalPathCalculator(self.game_logic, self.random, self.options) + self.state_logic = StateLogic(self.player, self.options, self.critical_path) self.items = Items(self.player, self.game_logic, self.random, self.options, self.critical_path) if self.options.mam_logic_placement.value == Placement.starting_inventory: