Added options groups and presets

This commit is contained in:
Jarno Westhof
2025-04-11 00:05:58 +02:00
parent 05ab8eefda
commit 3535daf043
8 changed files with 146 additions and 67 deletions

View File

@@ -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)

View File

@@ -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: (

View File

@@ -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():

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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: