From b42fb774518a2dd061b66895dac1b80ef223ae01 Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Thu, 18 Dec 2025 01:52:15 -0500 Subject: [PATCH] Factorio: Craftsanity (#5529) --- worlds/factorio/Locations.py | 15 ++- worlds/factorio/Mod.py | 2 +- worlds/factorio/Options.py | 17 ++- worlds/factorio/Technologies.py | 7 +- worlds/factorio/__init__.py | 126 ++++++++++++++++-- .../data/mod_template/data-final-fixes.lua | 25 ++-- 6 files changed, 160 insertions(+), 32 deletions(-) diff --git a/worlds/factorio/Locations.py b/worlds/factorio/Locations.py index 52f0954cba..844739b16f 100644 --- a/worlds/factorio/Locations.py +++ b/worlds/factorio/Locations.py @@ -1,6 +1,6 @@ from typing import Dict, List -from .Technologies import factorio_base_id +from .Technologies import factorio_base_id, recipes from .Options import MaxSciencePack @@ -21,5 +21,18 @@ for pool in location_pools.values(): location_table.update({name: ap_id for ap_id, name in enumerate(pool, start=end_id)}) end_id += len(pool) +craftsanity_locations = [] +valid_items = [] +item_category = {} +for recipe_name, recipe in recipes.items(): + if not recipe_name.endswith(("-barrel", "-science-pack")): + for result in recipe.products: + if result not in valid_items: + valid_items.append(result) +for i, item in enumerate(valid_items, start=end_id): + location_table[f"Craft {item}"] = i + craftsanity_locations.append(f"Craft {item}") + end_id += 1 + assert end_id - len(location_table) == factorio_base_id del pool diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index 3cc156112d..00ef0d866a 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -112,7 +112,7 @@ def generate_mod(world: "Factorio", output_directory: str): settings_template = template_env.get_template("settings.lua") # get data for templates locations = [(location, location.item) - for location in world.science_locations] + for location in world.science_locations + world.craftsanity_locations] mod_name = f"AP-{multiworld.seed_name}-P{player}-{multiworld.get_file_safe_player_name(player)}" versioned_mod_name = mod_name + "_" + Utils.__version__ diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index d7a4977eb3..70880aaaf9 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -6,7 +6,7 @@ import typing from schema import Schema, Optional, And, Or, SchemaError from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \ - StartInventoryPool, PerGameCommonOptions, OptionGroup + StartInventoryPool, PerGameCommonOptions, OptionGroup, NamedRange # schema helpers @@ -60,6 +60,20 @@ class Goal(Choice): default = 0 +class CraftSanity(NamedRange): + """Choose a number of researches to require crafting a specific item rather than with science packs. + May be capped based on the total number of locations. + There will always be at least 2 Science Pack research locations for automation and logistics, and 1 for rocket-silo + if the Rocket Silo option is not set to Spawn.""" + display_name = "CraftSanity" + default = 0 + range_start = 0 + range_end = 183 + special_range_names = { + "disabled": 0 + } + + class TechCost(Range): range_start = 1 range_end = 10000 @@ -475,6 +489,7 @@ class EnergyLink(Toggle): class FactorioOptions(PerGameCommonOptions): max_science_pack: MaxSciencePack goal: Goal + craftsanity: CraftSanity tech_tree_layout: TechTreeLayout min_tech_cost: MinTechCost max_tech_cost: MaxTechCost diff --git a/worlds/factorio/Technologies.py b/worlds/factorio/Technologies.py index 192cd1fefb..551276fbe3 100644 --- a/worlds/factorio/Technologies.py +++ b/worlds/factorio/Technologies.py @@ -334,14 +334,15 @@ required_technologies: Dict[str, FrozenSet[Technology]] = Utils.KeyedDefaultDict recursively_get_unlocking_technologies(ingredient_name, unlock_func=unlock))) -def get_rocket_requirements(silo_recipe: Optional[Recipe], part_recipe: Recipe, +def get_rocket_requirements(silo_recipe: Optional[Recipe], part_recipe: Optional[Recipe], satellite_recipe: Optional[Recipe], cargo_landing_pad_recipe: Optional[Recipe]) -> Set[str]: techs = set() if silo_recipe: for ingredient in silo_recipe.ingredients: techs |= recursively_get_unlocking_technologies(ingredient) - for ingredient in part_recipe.ingredients: - techs |= recursively_get_unlocking_technologies(ingredient) + if part_recipe: + for ingredient in part_recipe.ingredients: + techs |= recursively_get_unlocking_technologies(ingredient) if cargo_landing_pad_recipe: for ingredient in cargo_landing_pad_recipe.ingredients: techs |= recursively_get_unlocking_technologies(ingredient) diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index 8dc654099b..bdc375f679 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -9,7 +9,7 @@ from BaseClasses import Region, Location, Item, Tutorial, ItemClassification from worlds.AutoWorld import World, WebWorld from worlds.LauncherComponents import Component, components, Type, launch as launch_component from worlds.generic import Rules -from .Locations import location_pools, location_table +from .Locations import location_pools, location_table, craftsanity_locations from .Mod import generate_mod from .Options import (FactorioOptions, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution, option_groups) @@ -88,6 +88,7 @@ class Factorio(World): skip_silo: bool = False origin_region_name = "Nauvis" science_locations: typing.List[FactorioScienceLocation] + craftsanity_locations: typing.List[FactorioCraftsanityLocation] removed_technologies: typing.Set[str] settings: typing.ClassVar[FactorioSettings] trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", @@ -100,6 +101,7 @@ class Factorio(World): self.advancement_technologies = set() self.custom_recipes = {} self.science_locations = [] + self.craftsanity_locations = [] self.tech_tree_layout_prerequisites = {} generate_output = generate_mod @@ -127,17 +129,42 @@ class Factorio(World): location_pool = [] + craftsanity_pool = [craft for craft in craftsanity_locations + if self.options.silo != Silo.option_spawn + or craft not in ["Craft rocket-silo", "Craft cargo-landing-pad"]] + # Ensure at least 2 science pack locations for automation and logistics, and 1 more for rocket-silo + # if it is not pre-spawned + craftsanity_count = min(self.options.craftsanity.value, len(craftsanity_pool), + location_count - (2 if self.options.silo == Silo.option_spawn else 3)) + + location_count -= craftsanity_count + for pack in sorted(self.options.max_science_pack.get_allowed_packs()): location_pool.extend(location_pools[pack]) try: - location_names = random.sample(location_pool, location_count) + # Ensure there are two "AP-1-" locations for automation and logistics, and one max science pack location + # for rocket-silo if it is not pre-spawned + max_science_pack_number = len(self.options.max_science_pack.get_allowed_packs()) + science_location_names = None + while (not science_location_names or + len([location for location in science_location_names if location.startswith("AP-1-")]) < 2 + or (self.options.silo != Silo.option_spawn and len([location for location in science_location_names + if location.startswith(f"AP-{max_science_pack_number}")]) < 1)): + science_location_names = random.sample(location_pool, location_count) + craftsanity_location_names = random.sample(craftsanity_pool, craftsanity_count) + except ValueError as e: # should be "ValueError: Sample larger than population or is negative" raise Exception("Too many traps for too few locations. Either decrease the trap count, " f"or increase the location count (higher max science pack). (Player {self.player})") from e self.science_locations = [FactorioScienceLocation(player, loc_name, self.location_name_to_id[loc_name], nauvis) - for loc_name in location_names] + for loc_name in science_location_names] + + self.craftsanity_locations = [FactorioCraftsanityLocation(player, loc_name, self.location_name_to_id[loc_name], nauvis) + for loc_name in craftsanity_location_names] + + distribution: TechCostDistribution = self.options.tech_cost_distribution min_cost = self.options.min_tech_cost.value max_cost = self.options.max_tech_cost.value @@ -159,6 +186,7 @@ class Factorio(World): location.count = rand_values[i] del rand_values nauvis.locations.extend(self.science_locations) + nauvis.locations.extend(self.craftsanity_locations) location = FactorioLocation(player, "Rocket Launch", None, nauvis) nauvis.locations.append(location) event = FactorioItem("Victory", ItemClassification.progression, None, player) @@ -188,7 +216,7 @@ class Factorio(World): loc: FactorioScienceLocation if self.options.tech_tree_information == TechTreeInformation.option_full: # mark all locations as pre-hinted - for loc in self.science_locations: + for loc in self.science_locations + self.craftsanity_locations: loc.revealed = True if self.skip_silo: self.removed_technologies |= {"rocket-silo"} @@ -236,6 +264,23 @@ class Factorio(World): location.access_rule = lambda state, ingredient=ingredient: \ all(state.has(technology.name, player) for technology in required_technologies[ingredient]) + for location in self.craftsanity_locations: + if location.crafted_item == "crude-oil": + recipe = recipes["pumpjack"] + elif location.crafted_item in recipes: + recipe = recipes[location.crafted_item] + else: + for recipe_name, recipe in recipes.items(): + if recipe_name.endswith("-barrel"): + continue + if location.crafted_item in recipe.products: + break + else: + raise Exception( + f"No recipe found for {location.crafted_item} for Craftsanity for player {self.player}") + location.access_rule = lambda state, recipe=recipe: \ + state.has_all({technology.name for technology in recipe.recursive_unlocking_technologies}, player) + for location in self.science_locations: Rules.set_rule(location, lambda state, ingredients=frozenset(location.ingredients): all(state.has(f"Automated {ingredient}", player) for ingredient in ingredients)) @@ -250,10 +295,11 @@ class Factorio(World): silo_recipe = self.get_recipe("rocket-silo") cargo_pad_recipe = self.get_recipe("cargo-landing-pad") part_recipe = self.custom_recipes["rocket-part"] - satellite_recipe = None - if self.options.goal == Goal.option_satellite: - satellite_recipe = self.get_recipe("satellite") - victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe, cargo_pad_recipe) + satellite_recipe = self.get_recipe("satellite") + victory_tech_names = get_rocket_requirements( + silo_recipe, part_recipe, + satellite_recipe if self.options.goal == Goal.option_satellite else None, + cargo_pad_recipe) if self.options.silo == Silo.option_spawn: victory_tech_names -= {"rocket-silo"} else: @@ -263,6 +309,46 @@ class Factorio(World): victory_tech_names) self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player) + if "Craft rocket-silo" in self.multiworld.regions.location_cache[self.player]: + victory_tech_names_r = get_rocket_requirements(silo_recipe, None, None, None) + if self.options.silo == Silo.option_spawn: + victory_tech_names_r -= {"rocket-silo"} + else: + victory_tech_names_r |= {"rocket-silo"} + self.get_location("Craft rocket-silo").access_rule = lambda state: all(state.has(technology, player) + for technology in + victory_tech_names_r) + + if "Craft rocket-part" in self.multiworld.regions.location_cache[self.player]: + victory_tech_names_p = get_rocket_requirements(silo_recipe, part_recipe, None, None) + if self.options.silo == Silo.option_spawn: + victory_tech_names_p -= {"rocket-silo"} + else: + victory_tech_names_p |= {"rocket-silo"} + self.get_location("Craft rocket-part").access_rule = lambda state: all(state.has(technology, player) + for technology in + victory_tech_names_p) + + if "Craft satellite" in self.multiworld.regions.location_cache[self.player]: + victory_tech_names_s = get_rocket_requirements(None, None, satellite_recipe, None) + if self.options.silo == Silo.option_spawn: + victory_tech_names_s -= {"rocket-silo"} + else: + victory_tech_names_s |= {"rocket-silo"} + self.get_location("Craft satellite").access_rule = lambda state: all(state.has(technology, player) + for technology in + victory_tech_names_s) + + if "Craft cargo-landing-pad" in self.multiworld.regions.location_cache[self.player]: + victory_tech_names_c = get_rocket_requirements(None, None, None, cargo_pad_recipe) + if self.options.silo == Silo.option_spawn: + victory_tech_names_c -= {"rocket-silo"} + else: + victory_tech_names_c |= {"rocket-silo"} + self.get_location("Craft cargo-landing-pad").access_rule = lambda state: all(state.has(technology, player) + for technology in + victory_tech_names_c) + def get_recipe(self, name: str) -> Recipe: return self.custom_recipes[name] if name in self.custom_recipes \ else next(iter(all_product_sources.get(name))) @@ -486,9 +572,17 @@ class Factorio(World): needed_recipes = self.options.max_science_pack.get_allowed_packs() | {"rocket-part"} if self.options.silo != Silo.option_spawn: needed_recipes |= {"rocket-silo", "cargo-landing-pad"} - if self.options.goal.value == Goal.option_satellite: + if (self.options.goal.value == Goal.option_satellite + or "Craft satellite" in self.multiworld.regions.location_cache[self.player]): needed_recipes |= {"satellite"} + needed_items = {location.crafted_item for location in self.craftsanity_locations} + for recipe_name, recipe in recipes.items(): + for product in recipe.products: + if product in needed_items: + self.advancement_technologies |= {tech.name for tech in recipe.recursive_unlocking_technologies} + break + for recipe in needed_recipes: recipe = self.custom_recipes.get(recipe, recipes[recipe]) self.advancement_technologies |= {tech.name for tech in recipe.recursive_unlocking_technologies} @@ -520,9 +614,23 @@ class FactorioLocation(Location): game: str = Factorio.game +class FactorioCraftsanityLocation(FactorioLocation): + ingredients = {} + count = 0 + revealed = False + + def __init__(self, player: int, name: str, address: int, parent: Region): + super(FactorioCraftsanityLocation, self).__init__(player, name, address, parent) + + @property + def crafted_item(self): + return " ".join(self.name.split(" ")[1:]) + + class FactorioScienceLocation(FactorioLocation): complexity: int revealed: bool = False + crafted_item = None # Factorio technology properties: ingredients: typing.Dict[str, int] diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 8092062bc3..2ddcd8d8ab 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -63,22 +63,6 @@ template_tech.upgrade = false template_tech.effects = {} template_tech.prerequisites = {} -{%- if max_science_pack < 6 %} - technologies["space-science-pack"].effects = {} - {%- if max_science_pack == 0 %} - table.insert (technologies["automation"].effects, {type = "unlock-recipe", recipe = "satellite"}) - {%- elif max_science_pack == 1 %} - table.insert (technologies["logistic-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"}) - {%- elif max_science_pack == 2 %} - table.insert (technologies["military-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"}) - {%- elif max_science_pack == 3 %} - table.insert (technologies["chemical-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"}) - {%- elif max_science_pack == 4 %} - table.insert (technologies["production-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"}) - {%- elif max_science_pack == 5 %} - table.insert (technologies["utility-science-pack"].effects, {type = "unlock-recipe", recipe = "satellite"}) - {% endif %} -{% endif %} {%- if silo == 2 %} data.raw["recipe"]["rocket-silo"].enabled = true {% endif %} @@ -169,9 +153,16 @@ technologies["{{ original_tech_name }}"].hidden_in_factoriopedia = true {#- the tech researched by the local player #} new_tree_copy = table.deepcopy(template_tech) new_tree_copy.name = "ap-{{ location.address }}-"{# use AP ID #} +{% if location.crafted_item is not none %} +new_tree_copy.research_trigger = { + type = "{{ 'craft-fluid' if location.crafted_item in liquids else 'craft-item' }}", + {{ 'fluid' if location.crafted_item in liquids else 'item' }} = {{ variable_to_lua(location.crafted_item) }} +} +new_tree_copy.unit = nil +{% else %} new_tree_copy.unit.count = {{ location.count }} new_tree_copy.unit.ingredients = {{ variable_to_lua(location.factorio_ingredients) }} - +{% endif %} {%- if location.revealed and item.name in base_tech_table -%} {#- copy Factorio Technology Icon #} copy_factorio_icon(new_tree_copy, "{{ item.name }}")