From dc6f1c4dd26d8cef2f45dff02241409a715431bf Mon Sep 17 00:00:00 2001 From: Alchav <59858495+Alchav@users.noreply.github.com> Date: Thu, 20 Jan 2022 13:34:17 -0500 Subject: [PATCH] Item Plando overhaul (#205) --- BaseClasses.py | 11 +- Fill.py | 230 +++++++++++++----- Generate.py | 41 +--- .../assets/tutorial/archipelago/plando_en.md | 74 ++++-- .../static/assets/tutorial/tutorials.json | 3 +- 5 files changed, 230 insertions(+), 129 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index d49afd1c..f662be5c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -334,11 +334,14 @@ class MultiWorld(): return [location for location in self.get_locations() if (player is None or location.player == player) and location.item is None and location.can_reach(state)] - def get_unfilled_locations_for_players(self, location_name: str, players: Iterable[int]): + def get_unfilled_locations_for_players(self, locations, players: Iterable[int]): for player in players: - location = self.get_location(location_name, player) - if location.item is None: - yield location + if len(locations) == 0: + locations = [location.name for location in self.get_unfilled_locations(player)] + for location_name in locations: + location = self._location_cache.get((location_name, player), None) + if location is not None and location.item is None: + yield location def unlocks_new_location(self, item) -> bool: temp_state = self.state.copy() diff --git a/Fill.py b/Fill.py index 2e8e5011..1d3e2b49 100644 --- a/Fill.py +++ b/Fill.py @@ -6,7 +6,7 @@ from collections import Counter, deque from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item -from worlds.generic import PlandoItem + from worlds.AutoWorld import call_all @@ -407,80 +407,182 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked= location_1.item.location = location_1 location_2.item.location = location_2 location_1.event, location_2.event = location_2.event, location_1.event - - def distribute_planned(world: MultiWorld): + def warn(warning: str, force): + if force in ['true', 'fail', 'failure', 'none', 'false', 'warn', 'warning']: + logging.warning(f'{warning}') + else: + logging.debug(f'{warning}') + + def failed(warning: str, force): + if force in ['true', 'fail', 'failure']: + raise Exception(warning) + else: + warn(warning, force) + # TODO: remove. Preferably by implementing key drop from worlds.alttp.Regions import key_drop_data world_name_lookup = world.world_name_lookup - for player in world.player_ids: + plando_blocks = [] + player_ids = set(world.player_ids) + for player in player_ids: + for block in world.plando_items[player]: + block['player'] = player + if 'force' not in block: + block['force'] = 'silent' + if 'from_pool' not in block: + block['from_pool'] = True + if 'world' not in block: + block['world'] = False + items = [] + if "items" in block: + items = block["items"] + if 'count' not in block: + block['count'] = False + elif "item" in block: + items = block["item"] + if 'count' not in block: + block['count'] = 1 + else: + failed("You must specify at least one item to place items with plando.", block['forced']) + if isinstance(items, dict): + item_list = [] + for key, value in items.items(): + item_list += [key] * value + items = item_list + if isinstance(items, str): + items = [items] + block['items'] = items + + locations = [] + if 'locations' in block: + locations.extend(block['locations']) + elif 'location' in block: + locations.extend(block['location']) + + if isinstance(locations, dict): + location_list = [] + for key, value in locations.items(): + location_list += [key] * value + locations = location_list + if isinstance(locations, str): + locations = [locations] + block['locations'] = locations + c = block['count'] + + if not block['count']: + block['count'] = (min(len(block['items']), len(block['locations'])) if len(block['locations']) + > 0 else len(block['items'])) + if isinstance(block['count'], int): + block['count'] = {'min': block['count'], 'max': block['count']} + if 'min' not in block['count']: + block['count']['min'] = 0 + if 'max' not in block['count']: + block['count']['max'] = (min(len(block['items']), len(block['locations'])) if len(block['locations']) + > 0 else len(block['items'])) + if block['count']['max'] > len(block['items']): + count = block['count'] + failed(f"Plando count {count} greater than items specified", block['force']) + block['count'] = len(block['items']) + if block['count']['max'] > len(block['locations']) > 0: + count = block['count'] + failed(f"Plando count {count} greater than locations specified for player ", block['force']) + block['count'] = len(block['locations']) + block['count']['target'] = world.random.randint(block['count']['min'], block['count']['max']) + c = block['count'] + + if block['count']['target'] > 0: + plando_blocks.append(block) + + # shuffle, but then sort blocks by number of items, so blocks with fewer items get priority + world.random.shuffle(plando_blocks) + plando_blocks.sort(key=lambda block: block['count']['target']) + + for placement in plando_blocks: + player = placement['player'] try: - placement: PlandoItem - for placement in world.plando_items[player]: - if placement.location in key_drop_data: - placement.warn( - f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.") + target_world = placement['world'] + locations = placement['locations'] + items = placement['items'] + maxcount = placement['count']['target'] + from_pool = placement['from_pool'] + if target_world is False or world.players == 1: + worlds = {player} + elif target_world is True: + worlds = set(world.player_ids) - {player} + elif target_world is None: + worlds = set(world.player_ids) + elif type(target_world) == list: + worlds = [] + for listed_world in target_world: + if listed_world not in world_name_lookup: + failed(f"Cannot place item to {target_world}'s world as that world does not exist.", + placement['force']) + continue + worlds.append(world_name_lookup[listed_world]) + worlds = set(worlds) + elif type(target_world) == int: + if target_world not in range(1, world.players + 1): + failed( + f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", + placement['forced']) continue - item = world.worlds[player].create_item(placement.item) - target_world: int = placement.world - if target_world is False or world.players == 1: - target_world = player # in own world - elif target_world is True: # in any other world - unfilled = list(location for location in world.get_unfilled_locations_for_players( - placement.location, - set(world.player_ids) - {player}) if location.item_rule(item) - ) - if not unfilled: - placement.failed(f"Could not find a world with an unfilled location {placement.location}", - FillError) - continue - - target_world = world.random.choice(unfilled).player - - elif target_world is None: # any random world - unfilled = list(location for location in world.get_unfilled_locations_for_players( - placement.location, - set(world.player_ids)) if location.item_rule(item) - ) - if not unfilled: - placement.failed(f"Could not find a world with an unfilled location {placement.location}", - FillError) - continue - - target_world = world.random.choice(unfilled).player - - elif type(target_world) == int: # target world by player id - if target_world not in range(1, world.players + 1): - placement.failed( - f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", - ValueError) - continue - else: # find world by name - if target_world not in world_name_lookup: - placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.", - ValueError) - continue - target_world = world_name_lookup[target_world] - - location = world.get_location(placement.location, target_world) - if location.item: - placement.failed(f"Cannot place item into already filled location {location}.") + worlds = {target_world} + else: # find world by name + if target_world not in world_name_lookup: + failed(f"Cannot place item to {target_world}'s world as that world does not exist.", + placement['force']) continue + worlds = {world_name_lookup[target_world]} - if location.can_fill(world.state, item, False): - world.push_item(location, item, collect=False) - location.event = True # flag location to be checked during fill - location.locked = True - logging.debug(f"Plando placed {item} at {location}") - else: - placement.failed(f"Can't place {item} at {location} due to fill condition not met.") - continue + candidates = list(location for location in world.get_unfilled_locations_for_players(locations, + worlds)) - if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement. + world.random.shuffle(candidates) + world.random.shuffle(items) + count = 0 + err = "Unknown error" + successful_pairs = [] + for item_name in items: + item = world.worlds[player].create_item(item_name) + for location in reversed(candidates): + if location in key_drop_data: + warn( + f"Can't place '{item_name}' at '{placement.location}', as key drop shuffle locations are not supported yet.") + if not location.item: + if location.item_rule(item): + if location.can_fill(world.state, item, False): + successful_pairs.append([item, location]) + candidates.remove(location) + count = count + 1 + break + else: + err = f"Can't place item at {location} due to fill condition not met." + else: + err = f"{item_name} not allowed at {location}." + else: + err = f"Cannot place {item_name} into already filled location {location}." + if count == maxcount: + break + if count < placement['count']['min']: + failed( + f"Plando block failed to place item(s) for {world.player_name[player]}, most recent cause: {err}", + placement['force']) + continue + for (item, location) in successful_pairs: + world.push_item(location, item, collect=False) + location.event = True # flag location to be checked during fill + location.locked = True + logging.debug(f"Plando placed {item} at {location}") + if from_pool: try: world.itempool.remove(item) except ValueError: - placement.warn(f"Could not remove {item} from pool as it's already missing from it.") + warn( + f"Could not remove {item} from pool for {world.player_name[player]} as it's already missing from it.", + placement['force']) + except Exception as e: - raise Exception(f"Error running plando for player {player} ({world.player_name[player]})") from e + raise Exception( + f"Error running plando for player {player} ({world.player_name[player]})") from e diff --git a/Generate.py b/Generate.py index ea2862c2..aa43b106 100644 --- a/Generate.py +++ b/Generate.py @@ -494,7 +494,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b if not (option_key in Options.common_options and option_key not in game_weights): handle_option(ret, game_weights, option_key, option) if "items" in plando_options: - ret.plando_items = roll_item_plando(world_type, game_weights) + ret.plando_items = game_weights.get("plando_items", []) if ret.game == "Minecraft" or ret.game == "Ocarina of Time": # bad hardcoded behavior to make this work for now ret.plando_connections = [] @@ -513,45 +513,6 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b raise Exception(f"Unsupported game {ret.game}") return ret - -def roll_item_plando(world_type, weights): - plando_items = [] - - def add_plando_item(item: str, location: str): - if item not in world_type.item_name_to_id: - raise Exception(f"Could not plando item {item} as the item was not recognized") - if location not in world_type.location_name_to_id: - raise Exception( - f"Could not plando item {item} at location {location} as the location was not recognized") - plando_items.append(PlandoItem(item, location, location_world, from_pool, force)) - - options = weights.get("plando_items", []) - for placement in options: - if roll_percentage(get_choice("percentage", placement, 100)): - from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"]) - location_world = get_choice("world", placement, PlandoItem._field_defaults["world"]) - force = str(get_choice("force", placement, PlandoItem._field_defaults["force"])).lower() - if "items" in placement and "locations" in placement: - items = placement["items"] - locations = placement["locations"] - if isinstance(items, dict): - item_list = [] - for key, value in items.items(): - item_list += [key] * value - items = item_list - if not items or not locations: - raise Exception("You must specify at least one item and one location to place items.") - random.shuffle(items) - random.shuffle(locations) - for item, location in zip(items, locations): - add_plando_item(item, location) - else: - item = get_choice("item", placement, get_choice("items", placement)) - location = get_choice("location", placement) - add_plando_item(item, location) - return plando_items - - def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none": raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.") diff --git a/WebHostLib/static/assets/tutorial/archipelago/plando_en.md b/WebHostLib/static/assets/tutorial/archipelago/plando_en.md index 94c30fae..e9166acd 100644 --- a/WebHostLib/static/assets/tutorial/archipelago/plando_en.md +++ b/WebHostLib/static/assets/tutorial/archipelago/plando_en.md @@ -30,24 +30,22 @@ enabled (opt-in). ``` ## Item Plando - Item plando allows a player to place an item in a specific location or specific locations, place multiple items into a -list of specific locations both in their own game or in another player's game. **Note that there's a very good chance -that cross-game plando could very well be broken i.e. placing on of your items in someone else's world playing a -different game.** +list of specific locations both in their own game or in another player's game. -* The options for item plando are `from_pool`, `world`, `percentage`, `force`, and either item and location, or items +* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, and either item and location, or items and locations. * `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or false and defaults to true if omitted. * `world` is the target world to place the item in. * It gets ignored if only one world is generated. - * Can be a number, name, true, false, or null. False is the default. + * Can be a number, name, true, false, null, or a list. False is the default. * If a number is used it targets that slot or player number in the multiworld. * If a name is used it will target the world with that player name. * If set to true it will be any player's world besides your own. * If set to false it will target your own world. * If set to null it will target a random world in the multiworld. + * If a list of names is used, it will target the games with the player names specified. * `force` determines whether the generator will fail if the item can't be placed in the location can be true, false, or silent. Silent is the default. * If set to true the item must be placed and the generator will throw an error if it is unable to do so. @@ -61,6 +59,11 @@ different game.** * `items` defines the items to use and a number letting you place multiple of it. * `locations` is a list of possible locations those items can be placed in. * Using the multi placement method, placements are picked randomly. + * `count` can be used to set the maximum number of items placed from the block. The default is 1 if using `item` and False if using `items` + * If a number is used it will try to place this number of items. + * If set to false it will try to place as many items from the block as it can. + * If `min` and `max` are defined, it will try to place a number of items between these two numbers at random + ### Available Items @@ -68,23 +71,29 @@ different game.** * [Factorio Non-Progressive](https://wiki.factorio.com/Technologies) Note that these use the *internal names*. For example, `advanced-electronics` * [Factorio Progressive](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/factorio/Technologies.py#L374) +* [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ff1/data/items.json) * [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Items.py#L14) * [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py#L61) * [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Items.py#L8) +* [Rogue Legacy](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/rogue-legacy/Names/ItemName.py) * [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Items.py#L13) * [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/items.json) +* [Super Metroid](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/sm/variaRandomizer/rando/Items.py#L37) Look for "Name=" * [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Items.py#L11) ### Available Locations * [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L429) * [Factorio](https://wiki.factorio.com/Technologies) Same as items +* [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ff1/data/locations.json) * [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Locations.py#L18) * [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LocationList.py#L38) * [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Locations.py#L17) This is a special case. The locations are "ItemPickup[number]" up to the maximum set in the yaml. +* [Rogue Legacy](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/rogue-legacy/Names/LocationName.py) * [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Locations.py) * [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/locations.json) +* [Super Metroid](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/sm/variaRandomizer/graph/location.py#L132) * [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Locations.py#L13) A list of all available items and locations can also be found in the [server's datapackage](/api/datapackage). @@ -126,9 +135,9 @@ plando_items: - items: Boss Relic: 3 locations: - Boss Relic 1 - Boss Relic 2 - Boss Relic 3 + - Boss Relic 1 + - Boss Relic 2 + - Boss Relic 3 # example block 4 - Factorio - items: @@ -136,21 +145,46 @@ plando_items: electric-energy-accumulators: 1 progressive-turret: 2 locations: - military - gun-turret - logistic-science-pack - steel-processing + - military + - gun-turret + - logistic-science-pack + - steel-processing percentage: 80 force: true -``` -1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another - player's Starter Chest 1 and removes the chosen item from the item pool. +# example block 5 - Secret of Evermore + - items: + Levitate: 1 + Revealer: 1 + Energize: 1 + locations: + - Master Sword Pedestal + - Boss Relic 1 + world: true + count: 2 + +# example block 6 - A Link to the Past + - items: + Progressive Sword: 4 + world: + - BobsSlaytheSpire + - BobsRogueLegacy + count: + min: 1 + max: 4 +``` +1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another player's +Starter Chest 1 and removes the chosen item from the item pool. 2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots - in their own dungeon major item chests. +in their own dungeon major item chests. 3. This block will always trigger and will lock boss relics on the bosses. -4. This block has an 80% chance of occuring and when it does will place all but 1 of the items randomly among the four - locations chosen here. +4. This block has an 80% chance of occurring and when it does will place all but 1 of the items randomly among the four +locations chosen here. +5. This block will always trigger and will attempt to place a random 2 of Levitate, Revealer and Energize into +other players' Master Sword Pedestals or Boss Relic 1 locations. +6. This block will always trigger and will attempt to place a random number, between 1 and 4, of progressive swords +into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy + ## Boss Plando @@ -208,4 +242,4 @@ plando_connections: the lake hylia cave shop. Walking into the entrance for the old man cave and Agahnim's Tower entrance will both take you to their locations as normal but leaving old man cave will exit at Agahnim's Tower. 2. This will force a nether fortress and a village to be the overworld structures for your game. Note that for the - Minecraft connection plando to work structure shuffle must be enabled. \ No newline at end of file + Minecraft connection plando to work structure shuffle must be enabled. diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index ac357a3d..2ef3ed13 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -83,7 +83,8 @@ "filename": "archipelago/plando_en.md", "link": "archipelago/plando/en", "authors": [ - "alwaysintreble" + "alwaysintreble", + "Alchav" ] } ]