Archipelago/worlds/factorio/__init__.py

522 lines
26 KiB
Python

from __future__ import annotations
import collections
import logging
import typing
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import Component, components
from worlds.generic import Rules
from .Locations import location_pools, location_table
from .Mod import generate_mod
from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution
from .Shapes import get_shapes
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, \
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies, \
fluids, stacking_items, valid_ingredients, progressive_rows
components.append(Component("Factorio Client", "FactorioClient"))
class FactorioWeb(WebWorld):
tutorials = [Tutorial(
"Multiworld Setup Tutorial",
"A guide to setting up the Archipelago Factorio software on your computer.",
"English",
"setup_en.md",
"setup/en",
["Berserker, Farrak Kilhn"]
)]
class FactorioItem(Item):
game = "Factorio"
all_items = tech_table.copy()
all_items["Attack Trap"] = factorio_base_id - 1
all_items["Evolution Trap"] = factorio_base_id - 2
all_items["Teleport Trap"] = factorio_base_id - 3
all_items["Grenade Trap"] = factorio_base_id - 4
all_items["Cluster Grenade Trap"] = factorio_base_id - 5
all_items["Artillery Trap"] = factorio_base_id - 6
all_items["Atomic Rocket Trap"] = factorio_base_id - 7
class Factorio(World):
"""
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
research new technologies, and become more efficient in your quest to build a rocket and return home.
"""
game = "Factorio"
special_nodes = {"automation", "logistics", "rocket-silo"}
custom_recipes: typing.Dict[str, Recipe]
location_pool: typing.List[FactorioScienceLocation]
advancement_technologies: typing.Set[str]
web = FactorioWeb()
item_name_to_id = all_items
location_name_to_id = location_table
item_name_groups = {
"Progressive": set(progressive_tech_table.keys()),
}
data_version = 8
required_client_version = (0, 4, 0)
ordered_science_packs: typing.List[str] = MaxSciencePack.get_ordered_science_packs()
tech_tree_layout_prerequisites: typing.Dict[FactorioScienceLocation, typing.Set[FactorioScienceLocation]]
tech_mix: int = 0
skip_silo: bool = False
science_locations: typing.List[FactorioScienceLocation]
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
self.advancement_technologies = set()
self.custom_recipes = {}
self.science_locations = []
self.tech_tree_layout_prerequisites = {}
generate_output = generate_mod
def generate_early(self) -> None:
# if max < min, then swap max and min
if self.multiworld.max_tech_cost[self.player] < self.multiworld.min_tech_cost[self.player]:
self.multiworld.min_tech_cost[self.player].value, self.multiworld.max_tech_cost[self.player].value = \
self.multiworld.max_tech_cost[self.player].value, self.multiworld.min_tech_cost[self.player].value
self.tech_mix = self.multiworld.tech_cost_mix[self.player]
self.skip_silo = self.multiworld.silo[self.player].value == Silo.option_spawn
def create_regions(self):
player = self.player
random = self.multiworld.random
menu = Region("Menu", player, self.multiworld)
crash = Entrance(player, "Crash Land", menu)
menu.exits.append(crash)
nauvis = Region("Nauvis", player, self.multiworld)
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
self.multiworld.evolution_traps[player] + \
self.multiworld.attack_traps[player] + \
self.multiworld.teleport_traps[player] + \
self.multiworld.grenade_traps[player] + \
self.multiworld.cluster_grenade_traps[player] + \
self.multiworld.atomic_rocket_traps[player] + \
self.multiworld.artillery_traps[player]
location_pool = []
for pack in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
location_pool.extend(location_pools[pack])
try:
location_names = self.multiworld.random.sample(location_pool, location_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]
distribution: TechCostDistribution = self.multiworld.tech_cost_distribution[self.player]
min_cost = self.multiworld.min_tech_cost[self.player]
max_cost = self.multiworld.max_tech_cost[self.player]
if distribution == distribution.option_even:
rand_values = (random.randint(min_cost, max_cost) for _ in self.science_locations)
else:
mode = {distribution.option_low: min_cost,
distribution.option_middle: (min_cost+max_cost)//2,
distribution.option_high: max_cost}[distribution.value]
rand_values = (random.triangular(min_cost, max_cost, mode) for _ in self.science_locations)
rand_values = sorted(rand_values)
if self.multiworld.ramping_tech_costs[self.player]:
def sorter(loc: FactorioScienceLocation):
return loc.complexity, loc.rel_cost
else:
def sorter(loc: FactorioScienceLocation):
return loc.rel_cost
for i, location in enumerate(sorted(self.science_locations, key=sorter)):
location.count = rand_values[i]
del rand_values
nauvis.locations.extend(self.science_locations)
location = FactorioLocation(player, "Rocket Launch", None, nauvis)
nauvis.locations.append(location)
event = FactorioItem("Victory", ItemClassification.progression, None, player)
location.place_locked_item(event)
for ingredient in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
location = FactorioLocation(player, f"Automate {ingredient}", None, nauvis)
nauvis.locations.append(location)
event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player)
location.place_locked_item(event)
crash.connect(nauvis)
self.multiworld.regions += [menu, nauvis]
def create_items(self) -> None:
player = self.player
self.custom_technologies = self.set_custom_technologies()
self.set_custom_recipes()
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket")
for trap_name in traps:
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
range(getattr(self.multiworld,
f"{trap_name.lower().replace(' ', '_')}_traps")[player]))
want_progressives = collections.defaultdict(lambda: self.multiworld.progressive[player].
want_progressives(self.multiworld.random))
cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name)
special_index = {"automation": 0,
"logistics": 1,
"rocket-silo": -1}
loc: FactorioScienceLocation
if self.multiworld.tech_tree_information[player] == TechTreeInformation.option_full:
# mark all locations as pre-hinted
for loc in self.science_locations:
loc.revealed = True
if self.skip_silo:
removed = useless_technologies | {"rocket-silo"}
else:
removed = useless_technologies
for tech_name in base_tech_table:
if tech_name not in removed:
progressive_item_name = tech_to_progressive_lookup.get(tech_name, tech_name)
want_progressive = want_progressives[progressive_item_name]
item_name = progressive_item_name if want_progressive else tech_name
tech_item = self.create_item(item_name)
index = special_index.get(tech_name, None)
if index is None:
self.multiworld.itempool.append(tech_item)
else:
loc = cost_sorted_locations[index]
if index >= 0:
# beginning techs - limit cost to 10
# as automation is not achievable yet and hand-crafting for hours is not fun gameplay
loc.count = min(loc.count, 10)
loc.place_locked_item(tech_item)
loc.revealed = True
def set_rules(self):
world = self.multiworld
player = self.player
shapes = get_shapes(self)
for ingredient in self.multiworld.max_science_pack[self.player].get_allowed_packs():
location = world.get_location(f"Automate {ingredient}", player)
if self.multiworld.recipe_ingredients[self.player]:
custom_recipe = self.custom_recipes[ingredient]
location.access_rule = lambda state, ingredient=ingredient, custom_recipe=custom_recipe: \
(ingredient not in technology_table or state.has(ingredient, player)) and \
all(state.has(technology.name, player) for sub_ingredient in custom_recipe.ingredients
for technology in required_technologies[sub_ingredient])
else:
location.access_rule = lambda state, ingredient=ingredient: \
all(state.has(technology.name, player) for technology in required_technologies[ingredient])
for location in self.science_locations:
Rules.set_rule(location, lambda state, ingredients=location.ingredients:
all(state.has(f"Automated {ingredient}", player) for ingredient in ingredients))
prerequisites = shapes.get(location)
if prerequisites:
Rules.add_rule(location, lambda state, locations=
prerequisites: all(state.can_reach(loc) for loc in locations))
silo_recipe = None
if self.multiworld.silo[self.player] == Silo.option_spawn:
silo_recipe = self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \
else next(iter(all_product_sources.get("rocket-silo")))
part_recipe = self.custom_recipes["rocket-part"]
satellite_recipe = None
if self.multiworld.goal[self.player] == Goal.option_satellite:
satellite_recipe = self.custom_recipes["satellite"] if "satellite" in self.custom_recipes \
else next(iter(all_product_sources.get("satellite")))
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe)
if self.multiworld.silo[self.player] != Silo.option_spawn:
victory_tech_names.add("rocket-silo")
world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player)
for technology in
victory_tech_names)
world.completion_condition[player] = lambda state: state.has('Victory', player)
def generate_basic(self):
map_basic_settings = self.multiworld.world_gen[self.player].value["basic"]
if map_basic_settings.get("seed", None) is None: # allow seed 0
# 32 bit uint
map_basic_settings["seed"] = self.multiworld.per_slot_randoms[self.player].randint(0, 2 ** 32 - 1)
start_location_hints: typing.Set[str] = self.multiworld.start_location_hints[self.player].value
for loc in self.science_locations:
# show start_location_hints ingame
if loc.name in start_location_hints:
loc.revealed = True
# make spoiler match mod info
elif loc.revealed:
start_location_hints.add(loc.name)
def collect_item(self, state, item, remove=False):
if item.advancement and item.name in progressive_technology_table:
prog_table = progressive_technology_table[item.name].progressive
if remove:
for item_name in reversed(prog_table):
if state.has(item_name, item.player):
return item_name
else:
for item_name in prog_table:
if not state.has(item_name, item.player):
return item_name
return super(Factorio, self).collect_item(state, item, remove)
option_definitions = factorio_options
@classmethod
def stage_write_spoiler(cls, world, spoiler_handle):
factorio_players = world.get_game_players(cls.game)
spoiler_handle.write('\n\nFactorio Recipes:\n')
for player in factorio_players:
name = world.get_player_name(player)
for recipe in world.worlds[player].custom_recipes.values():
spoiler_handle.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
@staticmethod
def get_category(category: str, liquids: int) -> str:
categories = {1: "crafting-with-fluid",
2: "chemistry"}
return categories.get(liquids, category)
def make_quick_recipe(self, original: Recipe, pool: list, allow_liquids: int = 2,
ingredients_offset: int = 0) -> Recipe:
new_ingredients = {}
liquids_used = 0
for _ in range(len(original.ingredients) + ingredients_offset):
new_ingredient = pool.pop()
if new_ingredient in fluids:
while liquids_used == allow_liquids and new_ingredient in fluids:
# liquids already at max for current recipe.
# Return the liquid to the pool and get a new ingredient.
pool.append(new_ingredient)
new_ingredient = pool.pop(0)
liquids_used += 1 if new_ingredient in fluids else 0
new_ingredients[new_ingredient] = 1
return Recipe(original.name, self.get_category(original.category, liquids_used), new_ingredients,
original.products, original.energy)
def make_balanced_recipe(self, original: Recipe, pool: typing.Set[str], factor: float = 1,
allow_liquids: int = 2, ingredients_offset: int = 0) -> Recipe:
"""Generate a recipe from pool with time and cost similar to original * factor"""
new_ingredients = {}
# have to first sort for determinism, while filtering out non-stacking items
pool: typing.List[str] = sorted(pool & valid_ingredients)
# then sort with random data to shuffle
self.multiworld.random.shuffle(pool)
target_raw = int(sum((count for ingredient, count in original.base_cost.items())) * factor)
target_energy = original.total_energy * factor
target_num_ingredients = len(original.ingredients) + ingredients_offset
remaining_raw = target_raw
remaining_energy = target_energy
remaining_num_ingredients = target_num_ingredients
fallback_pool = []
liquids_used = 0
# fill all but one slot with random ingredients, last with a good match
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if liquids_used == allow_liquids and ingredient in fluids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
ingredient_raw = 0
if ingredient in all_product_sources:
ingredient_recipe = min(all_product_sources[ingredient], key=lambda recipe: recipe.rel_cost)
ingredient_raw = sum((count for ingredient, count in ingredient_recipe.base_cost.items()))
ingredient_energy = ingredient_recipe.total_energy
else:
# assume simple ore TODO: remove if tree when mining data is harvested from Factorio
ingredient_energy = 2
if not ingredient_raw:
ingredient_raw = 1
if remaining_num_ingredients == 1:
max_raw = 1.1 * remaining_raw
min_raw = 0.9 * remaining_raw
max_energy = 1.1 * remaining_energy
min_energy = 0.9 * remaining_energy
else:
max_raw = remaining_raw * 0.75
min_raw = (remaining_raw - max_raw) / remaining_num_ingredients
max_energy = remaining_energy * 0.75
min_energy = (remaining_energy - max_energy) / remaining_num_ingredients
min_num_raw = min_raw / ingredient_raw
max_num_raw = max_raw / ingredient_raw
min_num_energy = min_energy / ingredient_energy
max_num_energy = max_energy / ingredient_energy
min_num = int(max(1, min_num_raw, min_num_energy))
max_num = int(min(1000, max_num_raw, max_num_energy))
if min_num > max_num:
fallback_pool.append(ingredient)
continue # can't use that ingredient
num = self.multiworld.random.randint(min_num, max_num)
new_ingredients[ingredient] = num
remaining_raw -= num * ingredient_raw
remaining_energy -= num * ingredient_energy
remaining_num_ingredients -= 1
if ingredient in fluids:
liquids_used += 1
# fill failed slots with whatever we got
pool = fallback_pool
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if liquids_used == allow_liquids and ingredient in fluids:
continue # can't use this ingredient as we already have maximum liquid in our recipe.
ingredient_recipe = recipes.get(ingredient, None)
if not ingredient_recipe and ingredient.endswith("-barrel"):
ingredient_recipe = recipes.get(f"fill-{ingredient}", None)
if not ingredient_recipe:
logging.warning(f"missing recipe for {ingredient}")
continue
ingredient_raw = sum((count for ingredient, count in ingredient_recipe.base_cost.items()))
ingredient_energy = ingredient_recipe.total_energy
num_raw = remaining_raw / ingredient_raw / remaining_num_ingredients
num_energy = remaining_energy / ingredient_energy / remaining_num_ingredients
num = int(min(num_raw, num_energy))
if num < 1:
continue
new_ingredients[ingredient] = num
remaining_raw -= num * ingredient_raw
remaining_energy -= num * ingredient_energy
remaining_num_ingredients -= 1
if ingredient in fluids:
liquids_used += 1
if remaining_num_ingredients > 1:
logging.warning("could not randomize recipe")
return Recipe(original.name, self.get_category(original.category, liquids_used), new_ingredients,
original.products, original.energy)
def set_custom_technologies(self):
custom_technologies = {}
allowed_packs = self.multiworld.max_science_pack[self.player].get_allowed_packs()
for technology_name, technology in base_technology_table.items():
custom_technologies[technology_name] = technology.get_custom(self.multiworld, allowed_packs, self.player)
return custom_technologies
def set_custom_recipes(self):
ingredients_offset = self.multiworld.recipe_ingredients_offset[self.player]
original_rocket_part = recipes["rocket-part"]
science_pack_pools = get_science_pack_pools()
valid_pool = sorted(science_pack_pools[self.multiworld.max_science_pack[self.player].get_max_pack()] & valid_ingredients)
self.multiworld.random.shuffle(valid_pool)
self.custom_recipes = {"rocket-part": Recipe("rocket-part", original_rocket_part.category,
{valid_pool[x]: 10 for x in range(3 + ingredients_offset)},
original_rocket_part.products,
original_rocket_part.energy)}
if self.multiworld.recipe_ingredients[self.player]:
valid_pool = []
for pack in self.multiworld.max_science_pack[self.player].get_ordered_science_packs():
valid_pool += sorted(science_pack_pools[pack])
self.multiworld.random.shuffle(valid_pool)
if pack in recipes: # skips over space science pack
new_recipe = self.make_quick_recipe(recipes[pack], valid_pool, ingredients_offset=
ingredients_offset)
self.custom_recipes[pack] = new_recipe
if self.multiworld.silo[self.player].value == Silo.option_randomize_recipe \
or self.multiworld.satellite[self.player].value == Satellite.option_randomize_recipe:
valid_pool = set()
for pack in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
valid_pool |= science_pack_pools[pack]
if self.multiworld.silo[self.player].value == Silo.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(
recipes["rocket-silo"], valid_pool,
factor=(self.multiworld.max_science_pack[self.player].value + 1) / 7,
ingredients_offset=ingredients_offset)
self.custom_recipes["rocket-silo"] = new_recipe
if self.multiworld.satellite[self.player].value == Satellite.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(
recipes["satellite"], valid_pool,
factor=(self.multiworld.max_science_pack[self.player].value + 1) / 7,
ingredients_offset=ingredients_offset)
self.custom_recipes["satellite"] = new_recipe
bridge = "ap-energy-bridge"
new_recipe = self.make_quick_recipe(
Recipe(bridge, "crafting", {"replace_1": 1, "replace_2": 1, "replace_3": 1,
"replace_4": 1, "replace_5": 1, "replace_6": 1},
{bridge: 1}, 10),
sorted(science_pack_pools[self.multiworld.max_science_pack[self.player].get_ordered_science_packs()[0]]),
ingredients_offset=ingredients_offset)
for ingredient_name in new_recipe.ingredients:
new_recipe.ingredients[ingredient_name] = self.multiworld.random.randint(50, 500)
self.custom_recipes[bridge] = new_recipe
needed_recipes = self.multiworld.max_science_pack[self.player].get_allowed_packs() | {"rocket-part"}
if self.multiworld.silo[self.player] != Silo.option_spawn:
needed_recipes |= {"rocket-silo"}
if self.multiworld.goal[self.player].value == Goal.option_satellite:
needed_recipes |= {"satellite"}
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}
# handle marking progressive techs as advancement
prog_add = set()
for tech in self.advancement_technologies:
if tech in tech_to_progressive_lookup:
prog_add.add(tech_to_progressive_lookup[tech])
self.advancement_technologies |= prog_add
def create_item(self, name: str) -> FactorioItem:
if name in tech_table: # is a Technology
if name in self.advancement_technologies:
classification = ItemClassification.progression
else:
classification = ItemClassification.filler
return FactorioItem(name,
classification,
tech_table[name], self.player)
item = FactorioItem(name,
ItemClassification.trap if name.endswith("Trap") else ItemClassification.filler,
all_items[name], self.player)
return item
class FactorioLocation(Location):
game: str = Factorio.game
class FactorioScienceLocation(FactorioLocation):
complexity: int
revealed: bool = False
# Factorio technology properties:
ingredients: typing.Dict[str, int]
count: int = 0
def __init__(self, player: int, name: str, address: int, parent: Region):
super(FactorioScienceLocation, self).__init__(player, name, address, parent)
# "AP-{Complexity}-{Cost}"
self.complexity = int(self.name[3]) - 1
self.rel_cost = int(self.name[5:], 16)
self.ingredients = {Factorio.ordered_science_packs[self.complexity]: 1}
for complexity in range(self.complexity):
if parent.multiworld.tech_cost_mix[self.player] > parent.multiworld.random.randint(0, 99):
self.ingredients[Factorio.ordered_science_packs[complexity]] = 1
@property
def factorio_ingredients(self) -> typing.List[typing.Tuple[str, int]]:
return [(name, count) for name, count in self.ingredients.items()]