diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index be053bdc2d..60dbd15b9b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools pip install -r requirements.txt - python setup.py build --yes + python setup.py build_exe --yes $NAME="$(ls build)".Split('.',2)[1] $ZIP_NAME="Archipelago_$NAME.7z" echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV @@ -82,7 +82,7 @@ jobs: "${{ env.PYTHON }}" -m venv venv source venv/bin/activate pip install -r requirements.txt - python setup.py build --yes bdist_appimage --yes + python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. diff --git a/Main.py b/Main.py index 38100bd050..d4df1b18ca 100644 --- a/Main.py +++ b/Main.py @@ -8,9 +8,9 @@ import concurrent.futures import pickle import tempfile import zipfile -from typing import Dict, Tuple, Optional, Set +from typing import Dict, List, Tuple, Optional, Set -from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location +from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location from worlds.alttp.Items import item_name_groups from worlds.alttp.Regions import is_main_entrance from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned @@ -154,8 +154,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No # temporary home for item links, should be moved out of Main for group_id, group in world.groups.items(): - def find_common_pool(players: Set[int], shared_pool: Set[str]): - classifications = collections.defaultdict(int) + def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ + Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]] + ]: + classifications: Dict[str, int] = collections.defaultdict(int) counters = {player: {name: 0 for name in shared_pool} for player in players} for item in world.itempool: if item.player in counters and item.name in shared_pool: @@ -165,7 +167,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player in players.copy(): if all([counters[player][item] == 0 for item in shared_pool]): players.remove(player) - del(counters[player]) + del (counters[player]) if not players: return None, None @@ -177,14 +179,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No counters[player][item] = count else: for player in players: - del(counters[player][item]) + del (counters[player][item]) return counters, classifications common_item_count, classifications = find_common_pool(group["players"], group["item_pool"]) if not common_item_count: continue - new_itempool = [] + new_itempool: List[Item] = [] for item_name, item_count in next(iter(common_item_count.values())).items(): for _ in range(item_count): new_item = group["world"].create_item(item_name) diff --git a/Utils.py b/Utils.py index e0c86ddb39..447d39d26c 100644 --- a/Utils.py +++ b/Utils.py @@ -37,7 +37,7 @@ class Version(typing.NamedTuple): build: int -__version__ = "0.3.5" +__version__ = "0.3.6" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") diff --git a/docs/network protocol.md b/docs/network protocol.md index 84587ab237..c0a0764881 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -25,6 +25,7 @@ There are also a number of community-supported libraries available that implemen | | [APCpp](https://github.com/N00byKing/APCpp) | CMake | | JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported | | Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | | +| Rust | [ArchipelagoRS](https://github.com/ryanisaacg/archipelago_rs) | | ## Synchronizing Items When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet. diff --git a/docs/style.md b/docs/style.md index a9f55caa7c..745b51ed6e 100644 --- a/docs/style.md +++ b/docs/style.md @@ -15,7 +15,9 @@ * Strings in worlds should use double quotes as well, but imported code may differ. * Prefer [format string literals](https://peps.python.org/pep-0498/) over string concatenation, use single quotes inside them: `f"Like {dct['key']}"` -* Use type annotation where possible. +* Use type annotations where possible for function signatures and class members. +* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the + type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls. ## Markdown diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 8d3fab64cf..37cf5300a2 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -90,6 +90,8 @@ def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None: f"Duplicate item reference of \"{item.name}\" in \"{world.worlds[player].game}\" " f"of player \"{world.player_name[player]}\". Please make a copy instead.") + # TODO: investigate: Iterating through a set is not a deterministic order. + # If any random is used, this could make unreproducible seed. for world_type in world_types: stage_callable = getattr(world_type, f"stage_{method_name}", None) if stage_callable: diff --git a/worlds/factorio/Locations.py b/worlds/factorio/Locations.py new file mode 100644 index 0000000000..1903e589be --- /dev/null +++ b/worlds/factorio/Locations.py @@ -0,0 +1,32 @@ +from typing import Dict, List + +from .Technologies import factorio_base_id, factorio_id +from .Options import MaxSciencePack + +boundary: int = 0xff +total_locations: int = 0xff + +assert total_locations <= boundary +assert factorio_base_id != factorio_id + + +def make_pools() -> Dict[str, List[str]]: + pools: Dict[str, List[str]] = {} + for i, pack in enumerate(MaxSciencePack.get_ordered_science_packs(), start=1): + max_needed: int = sum(divmod(boundary, i)) + scale: float = boundary / max_needed + prefix: str = f"AP-{i}-" + pools[pack] = [prefix + hex(int(x * scale))[2:].upper().zfill(2) for x in range(1, max_needed + 1)] + return pools + + +location_pools: Dict[str, List[str]] = make_pools() + +location_table: Dict[str, int] = {} +end_id: int = factorio_id +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) + +assert end_id - len(location_table) == factorio_id +del pool diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index 018816d90a..33e3b75a7b 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -1,23 +1,23 @@ """Outputs a Factorio Mod to facilitate integration with Archipelago""" -import os -import zipfile -from typing import Optional -import threading import json +import os +import shutil +import threading +import zipfile +from typing import Optional, TYPE_CHECKING import jinja2 -import shutil import Utils -import Patch -import worlds.AutoWorld import worlds.Files from . import Options - from .Technologies import tech_table, recipes, free_sample_exclusions, progressive_technology_table, \ base_tech_table, tech_to_progressive_lookup, fluids +if TYPE_CHECKING: + from . import Factorio + template_env: Optional[jinja2.Environment] = None data_template: Optional[jinja2.Template] = None @@ -75,7 +75,7 @@ class FactorioModFile(worlds.Files.APContainer): super(FactorioModFile, self).write_contents(opened_zipfile) -def generate_mod(world, output_directory: str): +def generate_mod(world: "Factorio", output_directory: str): player = world.player multiworld = world.world global data_final_template, locale_template, control_template, data_template, settings_template @@ -95,18 +95,10 @@ def generate_mod(world, output_directory: str): control_template = template_env.get_template("control.lua") settings_template = template_env.get_template("settings.lua") # get data for templates - locations = [] - for location in multiworld.get_filled_locations(player): - if location.address: - locations.append((location.name, location.item.name, location.item.player, location.item.advancement)) + locations = [(location, location.item) + for location in world.locations] mod_name = f"AP-{multiworld.seed_name}-P{player}-{multiworld.get_file_safe_player_name(player)}" - tech_cost_scale = {0: 0.1, - 1: 0.25, - 2: 0.5, - 3: 1, - 4: 2, - 5: 5, - 6: 10}[multiworld.tech_cost[player].value] + random = multiworld.slot_seeds[player] def flop_random(low, high, base=None): @@ -120,18 +112,19 @@ def generate_mod(world, output_directory: str): return random.uniform(low, high) template_data = { - "locations": locations, "player_names": multiworld.player_name, "tech_table": tech_table, - "base_tech_table": base_tech_table, "tech_to_progressive_lookup": tech_to_progressive_lookup, + "locations": locations, + "player_names": multiworld.player_name, + "tech_table": tech_table, + "base_tech_table": base_tech_table, + "tech_to_progressive_lookup": tech_to_progressive_lookup, "mod_name": mod_name, "allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(), - "tech_cost_scale": tech_cost_scale, "custom_technologies": multiworld.worlds[player].custom_technologies, "tech_tree_layout_prerequisites": multiworld.tech_tree_layout_prerequisites[player], "slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name, "slot_player": player, "starting_items": multiworld.starting_items[player], "recipes": recipes, "random": random, "flop_random": flop_random, - "static_nodes": multiworld.worlds[player].static_nodes, "recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None), "recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None), "free_sample_blacklist": {item: 1 for item in free_sample_exclusions}, @@ -141,7 +134,7 @@ def generate_mod(world, output_directory: str): "max_science_pack": multiworld.max_science_pack[player].value, "liquids": fluids, "goal": multiworld.goal[player].value, - "energy_link": multiworld.energy_link[player].value + "energy_link": multiworld.energy_link[player].value, } for factorio_option in Options.factorio_options: diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 64e03c4338..a3580f8ff2 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -41,17 +41,30 @@ class Goal(Choice): default = 0 -class TechCost(Choice): - """How expensive are the technologies.""" - display_name = "Technology Cost Scale" - option_very_easy = 0 - option_easy = 1 - option_kind = 2 - option_normal = 3 - option_hard = 4 - option_very_hard = 5 - option_insane = 6 - default = 3 +class TechCost(Range): + range_start = 1 + range_end = 10000 + default = 5 + + +class MinTechCost(TechCost): + """The cheapest a Technology can be in Science Packs.""" + display_name = "Minimum Science Pack Cost" + default = 5 + + +class MaxTechCost(TechCost): + """The most expensive a Technology can be in Science Packs.""" + display_name = "Maximum Science Pack Cost" + default = 500 + + +class TechCostMix(Range): + """Percent chance that a preceding Science Pack is also required. + Chance is rolled per preceding pack.""" + display_name = "Science Pack Cost Mix" + range_end = 100 + default = 70 class Silo(Choice): @@ -168,7 +181,7 @@ class FactorioFreeSampleWhitelist(OptionSet): class TrapCount(Range): - range_end = 4 + range_end = 25 class AttackTrapCount(TrapCount): @@ -343,7 +356,9 @@ factorio_options: typing.Dict[str, type(Option)] = { "max_science_pack": MaxSciencePack, "goal": Goal, "tech_tree_layout": TechTreeLayout, - "tech_cost": TechCost, + "min_tech_cost": MinTechCost, + "max_tech_cost": MaxTechCost, + "tech_cost_mix": TechCostMix, "silo": Silo, "satellite": Satellite, "free_samples": FreeSamples, diff --git a/worlds/factorio/Shapes.py b/worlds/factorio/Shapes.py index f42da4d20c..4f093c3a86 100644 --- a/worlds/factorio/Shapes.py +++ b/worlds/factorio/Shapes.py @@ -1,8 +1,11 @@ -from typing import Dict, List, Set +from typing import Dict, List, Set, TYPE_CHECKING from collections import deque from .Options import TechTreeLayout +if TYPE_CHECKING: + from . import Factorio, FactorioScienceLocation + funnel_layers = {TechTreeLayout.option_small_funnels: 3, TechTreeLayout.option_medium_funnels: 4, TechTreeLayout.option_large_funnels: 5} @@ -12,24 +15,26 @@ funnel_slice_sizes = {TechTreeLayout.option_small_funnels: 6, TechTreeLayout.option_large_funnels: 15} -def get_shapes(factorio_world) -> Dict[str, List[str]]: +def _sorter(location: "FactorioScienceLocation"): + return location.complexity, location.rel_cost + + +def get_shapes(factorio_world: "Factorio") -> Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]]: world = factorio_world.world player = factorio_world.player - prerequisites: Dict[str, Set[str]] = {} + prerequisites: Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]] = {} layout = world.tech_tree_layout[player].value - custom_technologies = factorio_world.custom_technologies - tech_names: List[str] = list(set(custom_technologies) - world.worlds[player].static_nodes) - tech_names.sort() - world.random.shuffle(tech_names) + locations: List["FactorioScienceLocation"] = sorted(factorio_world.locations, key=lambda loc: loc.name) + world.random.shuffle(locations) if layout == TechTreeLayout.option_single: pass elif layout == TechTreeLayout.option_small_diamonds: slice_size = 4 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) diamond_0, diamond_1, diamond_2, diamond_3 = slice # 0 | @@ -40,10 +45,10 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: elif layout == TechTreeLayout.option_medium_diamonds: slice_size = 9 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) # 0 | # 1 2 | @@ -65,10 +70,10 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: elif layout == TechTreeLayout.option_large_diamonds: slice_size = 16 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) # 0 | # 1 2 | @@ -101,10 +106,10 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: elif layout == TechTreeLayout.option_small_pyramids: slice_size = 6 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) # 0 | # 1 2 | @@ -119,10 +124,10 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: elif layout == TechTreeLayout.option_medium_pyramids: slice_size = 10 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) # 0 | # 1 2 | @@ -144,10 +149,10 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: elif layout == TechTreeLayout.option_large_pyramids: slice_size = 15 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) # 0 | # 1 2 | @@ -176,17 +181,17 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: elif layout in funnel_layers: slice_size = funnel_slice_sizes[layout] - world.random.shuffle(tech_names) + world.random.shuffle(locations) - while len(tech_names) > slice_size: - tech_names = tech_names[slice_size:] - current_tech_names = tech_names[:slice_size] + while len(locations) > slice_size: + locations = locations[slice_size:] + current_locations = locations[:slice_size] layer_size = funnel_layers[layout] previous_slice = [] - current_tech_names.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + current_locations.sort(key=_sorter) for layer in range(funnel_layers[layout]): - slice = current_tech_names[:layer_size] - current_tech_names = current_tech_names[layer_size:] + slice = current_locations[:layer_size] + current_locations = current_locations[layer_size:] if previous_slice: for i, tech_name in enumerate(slice): prerequisites.setdefault(tech_name, set()).update(previous_slice[i:i+2]) @@ -202,10 +207,10 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: # 15 | # 16 | slice_size = 17 - while len(tech_names) > slice_size: - slice = tech_names[:slice_size] - tech_names = tech_names[slice_size:] - slice.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) + while len(locations) > slice_size: + slice = locations[:slice_size] + locations = locations[slice_size:] + slice.sort(key=_sorter) prerequisites[slice[1]] = {slice[0]} prerequisites[slice[2]] = {slice[0]} @@ -229,13 +234,13 @@ def get_shapes(factorio_world) -> Dict[str, List[str]]: prerequisites[slice[15]] = {slice[9], slice[10], slice[11], slice[12], slice[13], slice[14]} prerequisites[slice[16]] = {slice[15]} elif layout == TechTreeLayout.option_choices: - tech_names.sort(key=lambda tech_name: len(custom_technologies[tech_name].get_prior_technologies())) - current_choices = deque([tech_names[0]]) - tech_names = tech_names[1:] - while len(tech_names) > 1: + locations.sort(key=_sorter) + current_choices = deque([locations[0]]) + locations = locations[1:] + while len(locations) > 1: source = current_choices.pop() - choices = tech_names[:2] - tech_names = tech_names[2:] + choices = locations[:2] + locations = locations[2:] for choice in choices: prerequisites[choice] = {source} current_choices.extendleft(choices) diff --git a/worlds/factorio/Technologies.py b/worlds/factorio/Technologies.py index b88cc9b1ad..09f9fc93f7 100644 --- a/worlds/factorio/Technologies.py +++ b/worlds/factorio/Technologies.py @@ -36,7 +36,7 @@ technology_table: Dict[str, Technology] = {} always = lambda state: True -class FactorioElement(): +class FactorioElement: name: str def __repr__(self): @@ -98,7 +98,7 @@ class CustomTechnology(Technology): and ((ingredients & {"chemical-science-pack", "production-science-pack", "utility-science-pack"}) or origin.name == "rocket-silo") self.player = player - if origin.name not in world.worlds[player].static_nodes: + if origin.name not in world.worlds[player].special_nodes: if military_allowed: ingredients.add("military-science-pack") ingredients = list(ingredients) diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index a01abac748..f1bdce1755 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -1,19 +1,20 @@ +from __future__ import annotations + import collections +import logging import typing -from worlds.AutoWorld import World, WebWorld - from BaseClasses import Region, Entrance, Location, Item, RegionType, Tutorial, ItemClassification +from worlds.AutoWorld import World, WebWorld +from .Mod import generate_mod +from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal +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 -from .Shapes import get_shapes -from .Mod import generate_mod -from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal - -import logging + fluids, stacking_items, valid_ingredients, progressive_rows +from .Locations import location_pools, location_table class FactorioWeb(WebWorld): @@ -43,89 +44,75 @@ class Factorio(World): research new technologies, and become more efficient in your quest to build a rocket and return home. """ game: str = "Factorio" - static_nodes = {"automation", "logistics", "rocket-silo"} + 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 = base_tech_table + # TODO: remove base_tech_table ~ 0.3.7 + location_name_to_id = {**base_tech_table, **location_table} item_name_groups = { "Progressive": set(progressive_tech_table.keys()), } - data_version = 5 - required_client_version = (0, 3, 0) + data_version = 6 + required_client_version = (0, 3, 6) + + ordered_science_packs: typing.List[str] = MaxSciencePack.get_ordered_science_packs() + tech_mix: int = 0 + skip_silo: bool = False def __init__(self, world, player: int): super(Factorio, self).__init__(world, player) self.advancement_technologies = set() self.custom_recipes = {} - - def generate_basic(self): - player = self.player - want_progressives = collections.defaultdict(lambda: self.world.progressive[player]. - want_progressives(self.world.random)) - skip_silo = self.world.silo[player].value == Silo.option_spawn - evolution_traps_wanted = self.world.evolution_traps[player].value - attack_traps_wanted = self.world.attack_traps[player].value - traps_wanted = ["Evolution Trap"] * evolution_traps_wanted + ["Attack Trap"] * attack_traps_wanted - self.world.random.shuffle(traps_wanted) - - for tech_name in base_tech_table: - if traps_wanted and tech_name in useless_technologies: - self.world.itempool.append(self.create_item(traps_wanted.pop())) - elif skip_silo and tech_name == "rocket-silo": - pass - else: - 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) - if tech_name in self.static_nodes: - self.world.get_location(tech_name, player).place_locked_item(tech_item) - else: - self.world.itempool.append(tech_item) - - map_basic_settings = self.world.world_gen[player].value["basic"] - if map_basic_settings.get("seed", None) is None: # allow seed 0 - map_basic_settings["seed"] = self.world.slot_seeds[player].randint(0, 2 ** 32 - 1) # 32 bit uint - - # used to be called "sending_visible" - if self.world.tech_tree_information[player] == TechTreeInformation.option_full: - # mark all locations as pre-hinted - self.world.start_location_hints[self.player].value.update(base_tech_table) + self.locations = [] generate_output = generate_mod + def generate_early(self) -> None: + self.world.max_tech_cost[self.player] = max(self.world.max_tech_cost[self.player], + self.world.min_tech_cost[self.player]) + self.tech_mix = self.world.tech_cost_mix[self.player] + self.skip_silo = self.world.silo[self.player].value == Silo.option_spawn + def create_regions(self): player = self.player + random = self.world.random menu = Region("Menu", RegionType.Generic, "Menu", player, self.world) crash = Entrance(player, "Crash Land", menu) menu.exits.append(crash) nauvis = Region("Nauvis", RegionType.Generic, "Nauvis", player, self.world) - skip_silo = self.world.silo[self.player].value == Silo.option_spawn - for tech_name, tech_id in base_tech_table.items(): - if skip_silo and tech_name == "rocket-silo": - continue - tech = Location(player, tech_name, tech_id, nauvis) - nauvis.locations.append(tech) - tech.game = "Factorio" - location = Location(player, "Rocket Launch", None, nauvis) + location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \ + self.world.evolution_traps[player].value + self.world.attack_traps[player].value + + location_pool = [] + + for pack in self.world.max_science_pack[self.player].get_allowed_packs(): + location_pool.extend(location_pools[pack]) + location_names = self.world.random.sample(location_pool, location_count) + self.locations = [FactorioScienceLocation(player, loc_name, self.location_name_to_id[loc_name], nauvis) + for loc_name in location_names] + rand_values = sorted(random.randint(self.world.min_tech_cost[self.player], + self.world.max_tech_cost[self.player]) for _ in self.locations) + for i, location in enumerate(sorted(self.locations, key=lambda loc: loc.rel_cost)): + location.count = rand_values[i] + del rand_values + nauvis.locations.extend(self.locations) + location = FactorioLocation(player, "Rocket Launch", None, nauvis) nauvis.locations.append(location) - location.game = "Factorio" event = FactorioItem("Victory", ItemClassification.progression, None, player) - event.game = "Factorio" - self.world.push_item(location, event, False) - location.event = location.locked = True + location.place_locked_item(event) + for ingredient in self.world.max_science_pack[self.player].get_allowed_packs(): - location = Location(player, f"Automate {ingredient}", None, nauvis) - location.game = "Factorio" + location = FactorioLocation(player, f"Automate {ingredient}", None, nauvis) nauvis.locations.append(location) event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player) - self.world.push_item(location, event, False) - location.event = location.locked = True + location.place_locked_item(event) + crash.connect(nauvis) self.world.regions += [menu, nauvis] @@ -151,17 +138,13 @@ class Factorio(World): location.access_rule = lambda state, ingredient=ingredient: \ all(state.has(technology.name, player) for technology in required_technologies[ingredient]) - skip_silo = self.world.silo[self.player].value == Silo.option_spawn - for tech_name, technology in self.custom_technologies.items(): - if skip_silo and tech_name == "rocket-silo": - continue - location = world.get_location(tech_name, player) - Rules.set_rule(location, technology.build_rule(player)) - prequisites = shapes.get(tech_name) - if prequisites: - locations = {world.get_location(requisite, player) for requisite in prequisites} - Rules.add_rule(location, lambda state, - locations=locations: all(state.can_reach(loc) for loc in locations)) + for location in self.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.world.silo[self.player] == Silo.option_spawn: @@ -179,6 +162,48 @@ class Factorio(World): world.completion_condition[player] = lambda state: state.has('Victory', player) + def generate_basic(self): + player = self.player + want_progressives = collections.defaultdict(lambda: self.world.progressive[player]. + want_progressives(self.world.random)) + self.world.itempool.extend(self.create_item("Evolution Trap") for _ in + range(self.world.evolution_traps[player].value)) + self.world.itempool.extend(self.create_item("Attack Trap") for _ in + range(self.world.attack_traps[player].value)) + + cost_sorted_locations = sorted(self.locations, key=lambda location: location.name) + special_index = {"automation": 0, + "logistics": 1, + "rocket-silo": -1} + loc: FactorioScienceLocation + 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.world.itempool.append(tech_item) + else: + loc = cost_sorted_locations[index] + loc.place_locked_item(tech_item) + loc.revealed = True + + map_basic_settings = self.world.world_gen[player].value["basic"] + if map_basic_settings.get("seed", None) is None: # allow seed 0 + map_basic_settings["seed"] = self.world.slot_seeds[player].randint(0, 2 ** 32 - 1) # 32 bit uint + + if self.world.tech_tree_information[player] == TechTreeInformation.option_full: + # mark all locations as pre-hinted + self.world.start_location_hints[self.player].value.update(base_tech_table) + for loc in self.locations: + loc.revealed = True + 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 @@ -400,3 +425,33 @@ class Factorio(World): ItemClassification.trap if "Trap" in name 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 + + 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.world.tech_cost_mix[self.player] > parent.world.random.randint(0, 99): + self.ingredients[Factorio.ordered_science_packs[complexity]] = 1 + self.count = parent.world.random.randint(parent.world.min_tech_cost[self.player], + parent.world.max_tech_cost[self.player]) + + @property + def factorio_ingredients(self) -> typing.List[typing.Tuple[str, int]]: + return [(name, count) for name, count in self.ingredients.items()] diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 70bc1eac0a..3021fd5dad 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -1,4 +1,4 @@ -{% from "macros.lua" import dict_to_recipe %} +{% from "macros.lua" import dict_to_recipe, variable_to_lua %} -- this file gets written automatically by the Archipelago Randomizer and is in its raw form a Jinja2 Template require('lib') data.raw["rocket-silo"]["rocket-silo"].fluid_boxes = { @@ -50,16 +50,8 @@ data.raw["recipe"]["{{recipe_name}}"].ingredients = {{ dict_to_recipe(recipe.ing {%- endfor %} local technologies = data.raw["technology"] -local original_tech local new_tree_copy -allowed_ingredients = {} -{%- for tech_name, technology in custom_technologies.items() %} -allowed_ingredients["{{ tech_name }}"] = { -{%- for ingredient in technology.ingredients %} -["{{ingredient}}"] = 1, -{%- endfor %} -} -{% endfor %} + local template_tech = table.deepcopy(technologies["automation"]) {#- ensure the copy unlocks nothing #} template_tech.unlocks = {} @@ -87,39 +79,6 @@ template_tech.prerequisites = {} data.raw["recipe"]["rocket-silo"].enabled = true {% endif %} -function prep_copy(new_copy, old_tech) - old_tech.hidden = true - local ingredient_filter = allowed_ingredients[old_tech.name] - if ingredient_filter ~= nil then - if mods["science-not-invited"] then - local weights = { - ["automation-science-pack"] = 0, -- Red science - ["logistic-science-pack"] = 0, -- Green science - ["military-science-pack"] = 0, -- Black science - ["chemical-science-pack"] = 0, -- Blue science - ["production-science-pack"] = 0, -- Purple science - ["utility-science-pack"] = 0, -- Yellow science - ["space-science-pack"] = 0 -- Space science - } - for key, value in pairs(ingredient_filter) do - weights[key] = value - end - SNI.setWeights(weights) - -- Just in case an ingredient is being added to an existing tech. Found the root cause of the 9.223e+18 problem. - -- Turns out science-not-invited was ultimately dividing by zero, due to it being unaware of there being added ingredients. - old_tech.unit.ingredients = add_ingredients(old_tech.unit.ingredients, ingredient_filter) - SNI.sendInvite(old_tech) - -- SCIENCE-not-invited could potentially make tech cost 9.223e+18. - old_tech.unit.count = math.min(100000, old_tech.unit.count) - end - new_copy.unit = table.deepcopy(old_tech.unit) - new_copy.unit.ingredients = filter_ingredients(new_copy.unit.ingredients, ingredient_filter) - new_copy.unit.ingredients = add_ingredients(new_copy.unit.ingredients, ingredient_filter) - else - new_copy.unit = table.deepcopy(old_tech.unit) - end -end - function set_ap_icon(tech) tech.icon = "__{{ mod_name }}__/graphics/icons/ap.png" tech.icons = nil @@ -198,38 +157,40 @@ end data.raw["ammo"]["artillery-shell"].stack_size = 10 {# each randomized tech gets set to be invisible, with new nodes added that trigger those #} -{%- for original_tech_name, item_name, receiving_player, advancement in locations %} -original_tech = technologies["{{original_tech_name}}"] +{%- for original_tech_name in base_tech_table -%} +technologies["{{ original_tech_name }}"].hidden = true +{% endfor %} +{%- for location, item in locations %} {#- the tech researched by the local player #} new_tree_copy = table.deepcopy(template_tech) -new_tree_copy.name = "ap-{{ tech_table[original_tech_name] }}-"{# use AP ID #} -prep_copy(new_tree_copy, original_tech) -{% if tech_cost_scale != 1 %} -new_tree_copy.unit.count = math.max(1, math.floor(new_tree_copy.unit.count * {{ tech_cost_scale }})) -{% endif %} -{%- if (tech_tree_information == 2 or original_tech_name in static_nodes) and item_name in base_tech_table -%} -{#- copy Factorio Technology Icon -#} -copy_factorio_icon(new_tree_copy, "{{ item_name }}") -{%- if original_tech_name == "rocket-silo" and original_tech_name in static_nodes %} +new_tree_copy.name = "ap-{{ location.address }}-"{# use AP ID #} +new_tree_copy.unit.count = {{ location.count }} +new_tree_copy.unit.ingredients = {{ variable_to_lua(location.factorio_ingredients) }} + +{%- if location.revealed and item.name in base_tech_table -%} +{#- copy Factorio Technology Icon #} +copy_factorio_icon(new_tree_copy, "{{ item.name }}") +{%- if item.name == "rocket-silo" and item.player == location.player %} {%- for ingredient in custom_recipes["rocket-part"].ingredients %} table.insert(new_tree_copy.effects, {type = "nothing", effect_description = "Ingredient {{ loop.index }}: {{ ingredient }}"}) {% endfor -%} {% endif -%} -{%- elif (tech_tree_information == 2 or original_tech_name in static_nodes) and item_name in progressive_technology_table -%} -copy_factorio_icon(new_tree_copy, "{{ progressive_technology_table[item_name][0] }}") +{%- elif location.revealed and item.name in progressive_technology_table -%} +copy_factorio_icon(new_tree_copy, "{{ progressive_technology_table[item.name][0] }}") {%- else -%} {#- use default AP icon if no Factorio graphics exist -#} -{% if advancement or not tech_tree_information %}set_ap_icon(new_tree_copy){% else %}set_ap_unimportant_icon(new_tree_copy){% endif %} +{% if item.advancement or not tech_tree_information %}set_ap_icon(new_tree_copy){% else %}set_ap_unimportant_icon(new_tree_copy){% endif %} {%- endif -%} {#- connect Technology #} -{%- if original_tech_name in tech_tree_layout_prerequisites %} -{%- for prerequisite in tech_tree_layout_prerequisites[original_tech_name] %} -table.insert(new_tree_copy.prerequisites, "ap-{{ tech_table[prerequisite] }}-") +{%- if location in tech_tree_layout_prerequisites %} +{%- for prerequisite in tech_tree_layout_prerequisites[location] %} +table.insert(new_tree_copy.prerequisites, "ap-{{ prerequisite.address }}-") {% endfor %} {% endif -%} {#- add new Technology to game #} data:extend{new_tree_copy} {% endfor %} +{#- Recipe Rando #} {% if recipe_time_scale %} {%- for recipe_name, recipe in recipes.items() %} {%- if recipe.category not in ("basic-solid", "basic-fluid") %} diff --git a/worlds/factorio/data/mod_template/locale/en/locale.cfg b/worlds/factorio/data/mod_template/locale/en/locale.cfg index e970dbfa8c..59dcffcd63 100644 --- a/worlds/factorio/data/mod_template/locale/en/locale.cfg +++ b/worlds/factorio/data/mod_template/locale/en/locale.cfg @@ -5,22 +5,22 @@ archipelago=Archipelago archipelago=World preset created by the Archipelago Randomizer. World may or may not contain actual archipelagos. [technology-name] -{% for original_tech_name, item_name, receiving_player, advancement in locations %} -{%- if tech_tree_information == 2 or original_tech_name in static_nodes %} -ap-{{ tech_table[original_tech_name] }}-={{ player_names[receiving_player] }}'s {{ item_name }} +{% for location, item in locations %} +{%- if location.revealed %} +ap-{{ location.address }}-={{ player_names[item.player] }}'s {{ item.name }} ({{ location.name }}) {%- else %} -ap-{{ tech_table[original_tech_name] }}-=An Archipelago Sendable +ap-{{ location.address }}-= {{location.name}} {%- endif -%} {% endfor %} [technology-description] -{% for original_tech_name, item_name, receiving_player, advancement in locations %} -{%- if tech_tree_information == 2 or original_tech_name in static_nodes %} -ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends {{ item_name }} to {{ player_names[receiving_player] }}{% if advancement %}, which is considered a logical advancement{% endif %}. -{%- elif tech_tree_information == 1 and advancement %} -ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends something to someone, which is considered a logical advancement. For purposes of hints, this location is called "{{ original_tech_name }}". +{% for location, item in locations %} +{%- if location.revealed %} +ap-{{ location.address }}-=Researching this technology sends {{ item.name }} to {{ player_names[item.player] }}{% if item.advancement %}, which is considered a logical advancement{% elif item.useful %}, which is considered useful{% elif item.trap %}, which is considered fun{% endif %}. +{%- elif tech_tree_information == 1 and item.advancement %} +ap-{{ location.address }}-=Researching this technology sends something to someone, which is considered a logical advancement. {%- else %} -ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends something to someone. For purposes of hints, this location is called "{{ original_tech_name }}". +ap-{{ location.address }}-=Researching this technology sends something to someone. {%- endif -%} {% endfor %} diff --git a/worlds/factorio/data/mod_template/macros.lua b/worlds/factorio/data/mod_template/macros.lua index c81ddc5fcc..1b271031a3 100644 --- a/worlds/factorio/data/mod_template/macros.lua +++ b/worlds/factorio/data/mod_template/macros.lua @@ -4,7 +4,7 @@ ["{{ key }}"] = {{ variable_to_lua(value) }}{% if not loop.last %},{% endif %} {% endfor -%} } -{%- endmacro %} +{% endmacro %} {% macro list_to_lua(list) -%} { {%- for key in list -%} diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index d982782840..918b91ec9b 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -11,7 +11,7 @@ from BaseClasses import ItemClassification, LocationProgressType, \ from Options import AssembleOptions from .logic import cs_to_zz_locs from .region import ZillionLocation, ZillionRegion -from .options import zillion_options, validate +from .options import ZillionStartChar, zillion_options, validate from .id_maps import item_name_to_id as _item_name_to_id, \ loc_name_to_id as _loc_name_to_id, make_id_to_others, \ zz_reg_name_to_reg_name, base_id @@ -242,6 +242,42 @@ class ZillionWorld(World): self.world.completion_condition[self.player] = \ lambda state: state.has("Win", self.player) + @staticmethod + def stage_generate_basic(multiworld: MultiWorld, *args: Any) -> None: + # item link pools are about to be created in main + # JJ can't be an item link unless all the players share the same start_char + # (The reason for this is that the JJ ZillionItem will have a different ZzItem depending + # on whether the start char is Apple or Champ, and the logic depends on that ZzItem.) + for group in multiworld.groups.values(): + # TODO: remove asserts on group when we can specify which members of TypedDict are optional + assert "game" in group + if group["game"] == "Zillion": + assert "item_pool" in group + item_pool = group["item_pool"] + to_stay = "JJ" + if "JJ" in item_pool: + assert "players" in group + group_players = group["players"] + start_chars = cast(Dict[int, ZillionStartChar], getattr(multiworld, "start_char")) + players_start_chars = [ + (player, start_chars[player].get_current_option_name()) + for player in group_players + ] + start_char_counts = Counter(sc for _, sc in players_start_chars) + # majority rules + if start_char_counts["Apple"] > start_char_counts["Champ"]: + to_stay = "Apple" + elif start_char_counts["Champ"] > start_char_counts["Apple"]: + to_stay = "Champ" + else: # equal + to_stay = multiworld.random.choice(("Apple", "Champ")) + + for p, sc in players_start_chars: + if sc != to_stay: + group_players.remove(p) + assert "world" in group + cast(ZillionWorld, group["world"])._make_item_maps(to_stay) + def post_fill(self) -> None: """Optional Method that is called after regular fill. Can be used to do adjustments before output generation. This happens before progression balancing, so the items may not be in their final locations yet."""