diff --git a/BaseClasses.py b/BaseClasses.py index 5840d5c80f5..f037c84451c 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -48,7 +48,8 @@ class MultiWorld(): state: CollectionState accessibility: Dict[int, Options.Accessibility] - early_items: Dict[int, Options.EarlyItems] + early_items: Dict[int, Dict[str, int]] + local_early_items: Dict[int, Dict[str, int]] local_items: Dict[int, Options.LocalItems] non_local_items: Dict[int, Options.NonLocalItems] progression_balancing: Dict[int, Options.ProgressionBalancing] @@ -94,6 +95,8 @@ def __init__(self, players: int): self.customitemarray = [] self.shuffle_ganon = True self.spoiler = Spoiler(self) + self.early_items = {player: {} for player in self.player_ids} + self.local_early_items = {player: {} for player in self.player_ids} self.indirect_connections = {} self.fix_trock_doors = self.AttributeProxy( lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') @@ -157,7 +160,7 @@ def set_player_attr(attr, val): self.worlds = {} self.slot_seeds = {} - def get_all_ids(self): + def get_all_ids(self) -> Tuple[int, ...]: return self.player_ids + tuple(self.groups) def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]: @@ -282,11 +285,11 @@ def secure(self): self.is_race = True @functools.cached_property - def player_ids(self): + def player_ids(self) -> Tuple[int, ...]: return tuple(range(1, self.players + 1)) @functools.lru_cache() - def get_game_players(self, game_name: str): + def get_game_players(self, game_name: str) -> Tuple[int, ...]: return tuple(player for player in self.player_ids if self.game[player] == game_name) @functools.lru_cache() @@ -305,10 +308,7 @@ def get_file_safe_player_name(self, player: int) -> str: def get_out_file_name_base(self, player: int) -> str: """ the base name (without file extension) for each player's output file for a seed """ - return f"AP_{self.seed_name}_P{player}" \ - + (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}" - if (self.player_name[player] != f"Player{player}") - else '') + return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}" def initialize_regions(self, regions=None): for region in regions if regions else self.regions: @@ -424,46 +424,35 @@ def register_indirect_condition(self, region: Region, entrance: Entrance): state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic.""" self.indirect_connections.setdefault(region, set()).add(entrance) - def get_locations(self) -> List[Location]: + def get_locations(self, player: Optional[int] = None) -> List[Location]: if self._cached_locations is None: self._cached_locations = [location for region in self.regions for location in region.locations] + if player is not None: + return [location for location in self._cached_locations if location.player == player] return self._cached_locations def clear_location_cache(self): self._cached_locations = None def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]: - if player is not None: - return [location for location in self.get_locations() if - location.player == player and not location.item] - return [location for location in self.get_locations() if not location.item] - - def get_unfilled_dungeon_locations(self): - return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon] + return [location for location in self.get_locations(player) if location.item is None] def get_filled_locations(self, player: Optional[int] = None) -> List[Location]: - if player is not None: - return [location for location in self.get_locations() if - location.player == player and location.item is not None] - return [location for location in self.get_locations() if location.item is not None] + return [location for location in self.get_locations(player) if location.item is not None] def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]: - if state is None: - state = self.state - return [location for location in self.get_locations() if - (player is None or location.player == player) and location.can_reach(state)] + state: CollectionState = state if state else self.state + return [location for location in self.get_locations(player) if location.can_reach(state)] def get_placeable_locations(self, state=None, player=None) -> List[Location]: - if state is None: - state = self.state - 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)] + state: CollectionState = state if state else self.state + return [location for location in self.get_locations(player) if location.item is None and location.can_reach(state)] - def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]): + def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]): for player in players: - if len(locations) == 0: - locations = [location.name for location in self.get_unfilled_locations(player)] - for location_name in locations: + if not location_names: + location_names = [location.name for location in self.get_unfilled_locations(player)] + for location_name in location_names: location = self._location_cache.get((location_name, player), None) if location is not None and location.item is None: yield location diff --git a/Fill.py b/Fill.py index b98889621ba..ac3ae8fc6dd 100644 --- a/Fill.py +++ b/Fill.py @@ -24,7 +24,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, - swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None: + swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None, + allow_partial: bool = False) -> None: unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] @@ -132,7 +133,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if on_place: on_place(spot_to_fill) - if len(unplaced_items) > 0 and len(locations) > 0: + if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0: # There are leftover unplaceable items and locations that won't accept them if world.can_beat_game(): logging.warning( @@ -252,16 +253,20 @@ def distribute_early_items(world: MultiWorld, fill_locations: typing.List[Location], itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]: """ returns new fill_locations and itempool """ - early_items_count: typing.Dict[typing.Tuple[str, int], int] = {} + early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {} for player in world.player_ids: - for item, count in world.early_items[player].value.items(): - early_items_count[(item, player)] = count + items = itertools.chain(world.early_items[player], world.local_early_items[player]) + for item in items: + early_items_count[item, player] = [world.early_items[player].get(item, 0), + world.local_early_items[player].get(item, 0)] if early_items_count: early_locations: typing.List[Location] = [] early_priority_locations: typing.List[Location] = [] loc_indexes_to_remove: typing.Set[int] = set() + base_state = world.state.copy() + base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None)) for i, loc in enumerate(fill_locations): - if loc.can_reach(world.state): + if loc.can_reach(base_state): if loc.progress_type == LocationProgressType.PRIORITY: early_priority_locations.append(loc) else: @@ -271,27 +276,56 @@ def distribute_early_items(world: MultiWorld, early_prog_items: typing.List[Item] = [] early_rest_items: typing.List[Item] = [] + early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} + early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids} item_indexes_to_remove: typing.Set[int] = set() for i, item in enumerate(itempool): if (item.name, item.player) in early_items_count: if item.advancement: - early_prog_items.append(item) + if early_items_count[item.name, item.player][1]: + early_local_prog_items[item.player].append(item) + early_items_count[item.name, item.player][1] -= 1 + else: + early_prog_items.append(item) + early_items_count[item.name, item.player][0] -= 1 else: - early_rest_items.append(item) + if early_items_count[item.name, item.player][1]: + early_local_rest_items[item.player].append(item) + early_items_count[item.name, item.player][1] -= 1 + else: + early_rest_items.append(item) + early_items_count[item.name, item.player][0] -= 1 item_indexes_to_remove.add(i) - early_items_count[(item.name, item.player)] -= 1 - if early_items_count[(item.name, item.player)] == 0: - del early_items_count[(item.name, item.player)] + if early_items_count[item.name, item.player] == [0, 0]: + del early_items_count[item.name, item.player] if len(early_items_count) == 0: break itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove] - fill_restrictive(world, world.state, early_locations, early_rest_items, lock=True) + for player in world.player_ids: + player_local = early_local_rest_items[player] + fill_restrictive(world, base_state, + [loc for loc in early_locations if loc.player == player], + player_local, lock=True, allow_partial=True) + if player_local: + logging.warning(f"Could not fulfill rules of early items: {player_local}") + early_rest_items.extend(early_local_rest_items[player]) + early_locations = [loc for loc in early_locations if not loc.item] + fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True) early_locations += early_priority_locations - fill_restrictive(world, world.state, early_locations, early_prog_items, lock=True) + for player in world.player_ids: + player_local = early_local_prog_items[player] + fill_restrictive(world, base_state, + [loc for loc in early_locations if loc.player == player], + player_local, lock=True, allow_partial=True) + if player_local: + logging.warning(f"Could not fulfill rules of early items: {player_local}") + early_prog_items.extend(player_local) + early_locations = [loc for loc in early_locations if not loc.item] + fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True) unplaced_early_items = early_rest_items + early_prog_items if unplaced_early_items: - logging.warning(f"Ran out of early locations for early items. Failed to place \ - {len(unplaced_early_items)} items early.") + logging.warning("Ran out of early locations for early items. Failed to place " + f"{unplaced_early_items} early.") itempool += unplaced_early_items fill_locations.extend(early_locations) @@ -659,6 +693,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: else: warn(warning, force) + swept_state = world.state.copy() + swept_state.sweep_for_events() + reachable = frozenset(world.get_reachable_locations(swept_state)) + early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) + non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list) + for loc in world.get_unfilled_locations(): + if loc in reachable: + early_locations[loc.player].append(loc.name) + else: # not reachable with swept state + non_early_locations[loc.player].append(loc.name) + # TODO: remove. Preferably by implementing key drop from worlds.alttp.Regions import key_drop_data world_name_lookup = world.world_name_lookup @@ -674,7 +719,39 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: if 'from_pool' not in block: block['from_pool'] = True if 'world' not in block: - block['world'] = False + target_world = False + else: + target_world = block['world'] + + if target_world is False or world.players == 1: # target own world + worlds: typing.Set[int] = {player} + elif target_world is True: # target any worlds besides own + worlds = set(world.player_ids) - {player} + elif target_world is None: # target all worlds + worlds = set(world.player_ids) + elif type(target_world) == list: # list of target worlds + worlds = set() + 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.", + block['force']) + continue + worlds.add(world_name_lookup[listed_world]) + elif type(target_world) == int: # target world by slot number + 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})", + block['force']) + continue + worlds = {target_world} + else: # target world by slot 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.", + block['force']) + continue + worlds = {world_name_lookup[target_world]} + block['world'] = worlds + items: block_value = [] if "items" in block: items = block["items"] @@ -711,6 +788,17 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: for key, value in locations.items(): location_list += [key] * value locations = location_list + + if "early_locations" in locations: + locations.remove("early_locations") + for player in worlds: + locations += early_locations[player] + if "non_early_locations" in locations: + locations.remove("non_early_locations") + for player in worlds: + locations += non_early_locations[player] + + block['locations'] = locations if not block['count']: @@ -746,38 +834,11 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: for placement in plando_blocks: player = placement['player'] try: - target_world = placement['world'] + worlds = 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: # target own world - worlds: typing.Set[int] = {player} - elif target_world is True: # target any worlds besides own - worlds = set(world.player_ids) - {player} - elif target_world is None: # target all worlds - worlds = set(world.player_ids) - elif type(target_world) == list: # list of target worlds - worlds = set() - 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.add(world_name_lookup[listed_world]) - elif type(target_world) == int: # target world by slot number - 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['force']) - continue - worlds = {target_world} - else: # target world by slot 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]} candidates = list(location for location in world.get_unfilled_locations_for_players(locations, worlds)) diff --git a/Main.py b/Main.py index d4df1b18cab..1e7de31cd79 100644 --- a/Main.py +++ b/Main.py @@ -116,19 +116,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for _ in range(count): world.push_precollected(world.create_item(item_name, player)) - for player in world.player_ids: - if player in world.get_game_players("A Link to the Past"): - # enforce pre-defined local items. - if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: - world.local_items[player].value.add('Triforce Piece') - - # Not possible to place pendants/crystals outside boss prizes yet. - world.non_local_items[player].value -= item_name_groups['Pendants'] - world.non_local_items[player].value -= item_name_groups['Crystals'] - - # items can't be both local and non-local, prefer local - world.non_local_items[player].value -= world.local_items[player].value - logger.info('Creating World.') AutoWorld.call_all(world, "create_regions") @@ -136,6 +123,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No AutoWorld.call_all(world, "create_items") logger.info('Calculating Access Rules.') + + for player in world.player_ids: + # items can't be both local and non-local, prefer local + world.non_local_items[player].value -= world.local_items[player].value + world.non_local_items[player].value -= set(world.local_early_items[player]) + if world.players > 1: locality_rules(world) else: @@ -218,8 +211,8 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ items_to_add = [] for player in group["players"]: if group["replacement_items"][player]: - items_to_add.append(AutoWorld.call_single(world, "create_item", player, - group["replacement_items"][player])) + items_to_add.append( + AutoWorld.call_single(world, "create_item", player, group["replacement_items"][player])) else: items_to_add.append(AutoWorld.call_single(world, "create_filler", player)) world.random.shuffle(items_to_add) diff --git a/MultiServer.py b/MultiServer.py index fc19f8feca3..6e82d157a09 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -1905,6 +1905,37 @@ def _cmd_send(self, player_name: str, *item_name: str) -> bool: """Sends an item to the specified player""" return self._cmd_send_multiple(1, player_name, *item_name) + def _cmd_send_location(self, player_name: str, *location_name: str) -> bool: + """Send out item from a player's location as though they checked it""" + seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) + if usable: + team, slot = self.ctx.player_name_lookup[seeked_player] + game = self.ctx.games[slot] + full_name = " ".join(location_name) + + if full_name.isnumeric(): + location, usable, response = int(full_name), True, None + elif self.ctx.location_names_for_game(game) is not None: + location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game)) + else: + self.output("Can't look up location for unknown game. Send by ID instead.") + return False + + if usable: + if isinstance(location, int): + register_location_checks(self.ctx, team, slot, [location]) + else: + seeked_location: int = self.ctx.location_names_for_game(self.ctx.games[slot])[location] + register_location_checks(self.ctx, team, slot, [seeked_location]) + return True + else: + self.output(response) + return False + + else: + self.output(response) + return False + def _cmd_hint(self, player_name: str, *item_name: str) -> bool: """Send out a hint for a player's item to their team""" seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) diff --git a/Options.py b/Options.py index e13e1d7be3a..16deb90d77c 100644 --- a/Options.py +++ b/Options.py @@ -883,11 +883,6 @@ class NonLocalItems(ItemSet): display_name = "Not Local Items" -class EarlyItems(ItemDict): - """Force the specified items to be in locations that are reachable from the start.""" - display_name = "Early Items" - - class StartInventory(ItemDict): """Start with these items.""" verify_item_name = True @@ -986,7 +981,6 @@ def verify(self, world, player_name: str, plando_options) -> None: **common_options, # can be overwritten per-game "local_items": LocalItems, "non_local_items": NonLocalItems, - "early_items": EarlyItems, "start_inventory": StartInventory, "start_hints": StartHints, "start_location_hints": StartLocationHints, diff --git a/Utils.py b/Utils.py index a88611b6c5d..e7833f9a22e 100644 --- a/Utils.py +++ b/Utils.py @@ -236,7 +236,7 @@ def get_default_options() -> OptionsType: "bridge_chat_out": True, }, "sni_options": { - "sni": "SNI", + "sni_path": "SNI", "snes_rom_start": True, }, "sm_options": { @@ -452,6 +452,7 @@ def get_text_after(text: str, start: str) -> str: def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s", exception_logger: typing.Optional[str] = None): + import datetime loglevel: int = loglevel_mapping.get(loglevel, loglevel) log_folder = user_path("logs") os.makedirs(log_folder, exist_ok=True) @@ -460,6 +461,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri root_logger.removeHandler(handler) handler.close() root_logger.setLevel(loglevel) + if "a" not in write_mode: + name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}" file_handler = logging.FileHandler( os.path.join(log_folder, f"{name}.txt"), write_mode, @@ -487,7 +490,25 @@ def handle_exception(exc_type, exc_value, exc_traceback): sys.excepthook = handle_exception - logging.info(f"Archipelago ({__version__}) logging initialized.") + def _cleanup(): + for file in os.scandir(log_folder): + if file.name.endswith(".txt"): + last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime) + if datetime.datetime.now() - last_change > datetime.timedelta(days=7): + try: + os.unlink(file.path) + except Exception as e: + logging.exception(e) + else: + logging.info(f"Deleted old logfile {file.path}") + import threading + threading.Thread(target=_cleanup, name="LogCleaner").start() + import platform + logging.info( + f"Archipelago ({__version__}) logging initialized" + f" on {platform.platform()}" + f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + ) def stream_input(stream, queue): diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index e46738da766..11d70da2fbb 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -92,7 +92,7 @@ def generate(race=False): return render_template("generate.html", race=race, version=__version__) -def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): +def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None): if not meta: meta: Dict[str, Any] = {} diff --git a/WebHostLib/stats.py b/WebHostLib/stats.py index c624163ed52..36545ac96f1 100644 --- a/WebHostLib/stats.py +++ b/WebHostLib/stats.py @@ -22,7 +22,7 @@ def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str typing.DefaultDict[datetime.date, typing.Dict[str, int]]]: games_played = defaultdict(Counter) total_games = Counter() - cutoff = date.today()-timedelta(days=30) + cutoff = date.today() - timedelta(days=30) room: Room for room in select(room for room in Room if room.creation_time >= cutoff): for slot in room.seed.slots: diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 22e1353fbe5..1aa60ffc5f9 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -15,7 +15,7 @@ from . import app from .models import Seed, Room, Slot -banned_zip_contents = (".sfc",) +banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb") def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None): @@ -24,59 +24,28 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s infolist = zfile.infolist() slots: typing.Set[Slot] = set() spoiler = "" + files = {} multidata = None + + # Load files. for file in infolist: handler = AutoPatchRegister.get_handler(file.filename) if file.filename.endswith(banned_zip_contents): return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ "Your file was deleted." - elif handler: - raw = zfile.open(file, "r").read() - patch = handler(BytesIO(raw)) - patch.read() - slots.add(Slot(data=raw, - player_name=patch.player_name, - player_id=patch.player, - game=patch.game)) - elif file.filename.endswith(".apmc"): + # AP Container + elif handler: data = zfile.open(file, "r").read() - metadata = json.loads(base64.b64decode(data).decode("utf-8")) - slots.add(Slot(data=data, - player_name=metadata["player_name"], - player_id=metadata["player_id"], - game="Minecraft")) - - elif file.filename.endswith(".apv6"): - _, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3) - slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name, - player_id=int(slot_id[1:]), game="VVVVVV")) - - elif file.filename.endswith(".apsm64ex"): - _, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3) - slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name, - player_id=int(slot_id[1:]), game="Super Mario 64")) - - elif file.filename.endswith(".zip"): - # Factorio mods need a specific name or they do not function - _, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-", 3) - slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name, - player_id=int(slot_id[1:]), game="Factorio")) - - elif file.filename.endswith(".apz5"): - # .apz5 must be named specifically since they don't contain any metadata - _, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3) - slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name, - player_id=int(slot_id[1:]), game="Ocarina of Time")) - - elif file.filename.endswith(".json"): - _, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3) - slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name, - player_id=int(slot_id[1:]), game="Dark Souls III")) + patch = handler(BytesIO(data)) + patch.read() + files[patch.player] = data + # Spoiler elif file.filename.endswith(".txt"): spoiler = zfile.open(file, "r").read().decode("utf-8-sig") + # Multi-data elif file.filename.endswith(".archipelago"): try: multidata = zfile.open(file).read() @@ -84,17 +53,36 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s flash("Could not load multidata. File may be corrupted or incompatible.") multidata = None + # Minecraft + elif file.filename.endswith(".apmc"): + data = zfile.open(file, "r").read() + metadata = json.loads(base64.b64decode(data).decode("utf-8")) + files[metadata["player_id"]] = data + + # Factorio + elif file.filename.endswith(".zip"): + _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3) + data = zfile.open(file, "r").read() + files[int(slot_id[1:])] = data + + # All other files using the standard MultiWorld.get_out_file_name_base method + else: + _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3) + data = zfile.open(file, "r").read() + files[int(slot_id[1:])] = data + + # Load multi data. if multidata: decompressed_multidata = MultiServer.Context.decompress(multidata) if "slot_info" in decompressed_multidata: - player_names = {slot.player_name for slot in slots} - leftover_names: typing.Dict[int, NetworkSlot] = { - slot_id: slot_info for slot_id, slot_info in decompressed_multidata["slot_info"].items() - if slot_info.name not in player_names and slot_info.type != SlotType.group} - newslots = [(Slot(data=None, player_name=slot_info.name, player_id=slot, game=slot_info.game)) - for slot, slot_info in leftover_names.items()] - for slot in newslots: - slots.add(slot) + for slot, slot_info in decompressed_multidata["slot_info"].items(): + # Ignore Player Groups (e.g. item links) + if slot_info.type == SlotType.group: + continue + slots.add(Slot(data=files.get(slot, None), + player_name=slot_info.name, + player_id=slot, + game=slot_info.game)) flush() # commit slots diff --git a/playerSettings.yaml b/playerSettings.yaml index 194894b4b76..ee671862f04 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -120,8 +120,8 @@ A Link to the Past: open_pyramid: goal: 50 # Opens the pyramid if the goal requires you to kill Ganon, unless the goal is Slow Ganon or All Dungeons auto: 0 # Same as Goal, but also is closed if holes are shuffled and ganon is part of the shuffle pool - yes: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt - no: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower + open: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt + closed: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces. extra: 0 # available = triforce_pieces_extra + triforce_pieces_required percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required diff --git a/setup.py b/setup.py index 19d042189bc..63af1341730 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,7 @@ # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it import subprocess import pkg_resources -requirement = 'cx-Freeze>=6.11' +requirement = 'cx-Freeze>=6.13.1' try: pkg_resources.require(requirement) import cx_Freeze diff --git a/test/general/TestFill.py b/test/general/TestFill.py index c5130eed5d3..102e1fd6f6a 100644 --- a/test/general/TestFill.py +++ b/test/general/TestFill.py @@ -628,9 +628,9 @@ def test_early_items(self) -> None: mw = generate_multi_world(2) player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5) player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5) - mw.early_items[1].value[player1.basic_items[0].name] = 1 - mw.early_items[2].value[player2.basic_items[2].name] = 1 - mw.early_items[2].value[player2.basic_items[3].name] = 1 + mw.early_items[1][player1.basic_items[0].name] = 1 + mw.early_items[2][player2.basic_items[2].name] = 1 + mw.early_items[2][player2.basic_items[3].name] = 1 early_items = [ player1.basic_items[0], diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 00588a01306..b8329b714f3 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -3,7 +3,8 @@ import logging import sys import pathlib -from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING +from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING, \ + ClassVar from Options import AssembleOptions from BaseClasses import CollectionState @@ -71,39 +72,39 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut return new_class -def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any) -> Any: - method = getattr(world.worlds[player], method_name) +def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any: + method = getattr(multiworld.worlds[player], method_name) return method(*args) -def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None: +def call_all(multiworld: "MultiWorld", method_name: str, *args: Any) -> None: world_types: Set[AutoWorldRegister] = set() - for player in world.player_ids: - prev_item_count = len(world.itempool) - world_types.add(world.worlds[player].__class__) - call_single(world, method_name, player, *args) + for player in multiworld.player_ids: + prev_item_count = len(multiworld.itempool) + world_types.add(multiworld.worlds[player].__class__) + call_single(multiworld, method_name, player, *args) if __debug__: - new_items = world.itempool[prev_item_count:] + new_items = multiworld.itempool[prev_item_count:] for i, item in enumerate(new_items): for other in new_items[i+1:]: assert item is not other, ( - f"Duplicate item reference of \"{item.name}\" in \"{world.worlds[player].game}\" " - f"of player \"{world.player_name[player]}\". Please make a copy instead.") + f"Duplicate item reference of \"{item.name}\" in \"{multiworld.worlds[player].game}\" " + f"of player \"{multiworld.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: - stage_callable(world, *args) + stage_callable(multiworld, *args) -def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None: - world_types = {world.worlds[player].__class__ for player in world.player_ids} +def call_stage(multiworld: "MultiWorld", method_name: str, *args: Any) -> None: + world_types = {multiworld.worlds[player].__class__ for player in multiworld.player_ids} for world_type in world_types: stage_callable = getattr(world_type, f"stage_{method_name}", None) if stage_callable: - stage_callable(world, *args) + stage_callable(multiworld, *args) class WebWorld: @@ -130,24 +131,24 @@ class World(metaclass=AutoWorldRegister): """A World object encompasses a game's Items, Locations, Rules and additional data or functionality required. A Game should have its own subclass of World in which it defines the required data structures.""" - option_definitions: Dict[str, AssembleOptions] = {} # link your Options mapping - game: str # name the game - topology_present: bool = False # indicate if world type has any meaningful layout/pathing + option_definitions: ClassVar[Dict[str, AssembleOptions]] = {} # link your Options mapping + game: ClassVar[str] # name the game + topology_present: ClassVar[bool] = False # indicate if world type has any meaningful layout/pathing # gets automatically populated with all item and item group names - all_item_and_group_names: FrozenSet[str] = frozenset() + all_item_and_group_names: ClassVar[FrozenSet[str]] = frozenset() # map names to their IDs - item_name_to_id: Dict[str, int] = {} - location_name_to_id: Dict[str, int] = {} + item_name_to_id: ClassVar[Dict[str, int]] = {} + location_name_to_id: ClassVar[Dict[str, int]] = {} # maps item group names to sets of items. Example: "Weapons" -> {"Sword", "Bow"} - item_name_groups: Dict[str, Set[str]] = {} + item_name_groups: ClassVar[Dict[str, Set[str]]] = {} # increment this every time something in your world's names/id mappings changes. # While this is set to 0 in *any* AutoWorld, the entire DataPackage is considered in testing mode and will be # retrieved by clients on every connection. - data_version: int = 1 + data_version: ClassVar[int] = 1 # override this if changes to a world break forward-compatibility of the client # The base version of (0, 1, 6) is provided for backwards compatibility and does *not* need to be updated in the @@ -157,7 +158,7 @@ class World(metaclass=AutoWorldRegister): # update this if the resulting multidata breaks forward-compatibility of the server required_server_version: Tuple[int, int, int] = (0, 2, 4) - hint_blacklist: FrozenSet[str] = frozenset() # any names that should not be hintable + hint_blacklist: ClassVar[FrozenSet[str]] = frozenset() # any names that should not be hintable # NOTE: remote_items and remote_start_inventory are now available in the network protocol for the client to set. # These values will be removed. @@ -176,27 +177,27 @@ class World(metaclass=AutoWorldRegister): forced_auto_forfeit: bool = False # Hide World Type from various views. Does not remove functionality. - hidden: bool = False + hidden: ClassVar[bool] = False # see WebWorld for options - web: WebWorld = WebWorld() + web: ClassVar[WebWorld] = WebWorld() # autoset on creation: multiworld: "MultiWorld" player: int # automatically generated - item_id_to_name: Dict[int, str] - location_id_to_name: Dict[int, str] + item_id_to_name: ClassVar[Dict[int, str]] + location_id_to_name: ClassVar[Dict[int, str]] - item_names: Set[str] # set of all potential item names - location_names: Set[str] # set of all potential location names + item_names: ClassVar[Set[str]] # set of all potential item names + location_names: ClassVar[Set[str]] # set of all potential location names - zip_path: Optional[pathlib.Path] = None # If loaded from a .apworld, this is the Path to it. - __file__: str # path it was loaded from + zip_path: ClassVar[Optional[pathlib.Path]] = None # If loaded from a .apworld, this is the Path to it. + __file__: ClassVar[str] # path it was loaded from - def __init__(self, world: "MultiWorld", player: int): - self.multiworld = world + def __init__(self, multiworld: "MultiWorld", player: int): + self.multiworld = multiworld self.player = player # overridable methods that get called by Main.py, sorted by execution order diff --git a/worlds/__init__.py b/worlds/__init__.py index 039f12eb936..a18b8e093cf 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -20,6 +20,17 @@ from .AutoWorld import World +class GamesPackage(typing.TypedDict): + item_name_to_id: typing.Dict[str, int] + location_name_to_id: typing.Dict[str, int] + version: int + + +class DataPackage(typing.TypedDict): + version: int + games: typing.Dict[str, GamesPackage] + + class WorldSource(typing.NamedTuple): path: str # typically relative path from this module is_zip: bool = False @@ -54,7 +65,7 @@ class WorldSource(typing.NamedTuple): lookup_any_item_id_to_name = {} lookup_any_location_id_to_name = {} -games = {} +games: typing.Dict[str, GamesPackage] = {} from .AutoWorld import AutoWorldRegister @@ -69,7 +80,7 @@ class WorldSource(typing.NamedTuple): lookup_any_item_id_to_name.update(world.item_id_to_name) lookup_any_location_id_to_name.update(world.location_id_to_name) -network_data_package = { +network_data_package: DataPackage = { "version": sum(world.data_version for world in AutoWorldRegister.world_types.values()), "games": games, } diff --git a/worlds/alttp/Dungeons.py b/worlds/alttp/Dungeons.py index 866eb3e0dc7..a37ded8d104 100644 --- a/worlds/alttp/Dungeons.py +++ b/worlds/alttp/Dungeons.py @@ -118,6 +118,10 @@ def get_dungeon_item_pool_player(world, player) -> typing.List: return [item for dungeon in world.dungeons.values() if dungeon.player == player for item in dungeon.all_items] +def get_unfilled_dungeon_locations(multiworld) -> typing.List: + return [location for location in multiworld.get_locations() if not location.item and location.parent_region.dungeon] + + def fill_dungeons_restrictive(world): """Places dungeon-native items into their dungeons, places nothing if everything is shuffled outside.""" localized: set = set() @@ -134,7 +138,7 @@ def fill_dungeons_restrictive(world): if in_dungeon_items: restricted_players = {player for player, restricted in world.restrict_dungeon_item_on_boss.items() if restricted} - locations = [location for location in world.get_unfilled_dungeon_locations() + locations = [location for location in get_unfilled_dungeon_locations(world) # filter boss if not (location.player in restricted_players and location.name in lookup_boss_drops)] if dungeon_specific: diff --git a/worlds/alttp/Options.py b/worlds/alttp/Options.py index de6c479bd37..b1cfbc674e1 100644 --- a/worlds/alttp/Options.py +++ b/worlds/alttp/Options.py @@ -39,8 +39,8 @@ class OpenPyramid(Choice): option_auto = 3 default = option_goal - alias_yes = option_open - alias_no = option_closed + alias_true = option_open + alias_false = option_closed def to_bool(self, world: MultiWorld, player: int) -> bool: if self.value == self.option_goal: diff --git a/worlds/alttp/Rom.py b/worlds/alttp/Rom.py index 24a1588c52a..18e09ab1940 100644 --- a/worlds/alttp/Rom.py +++ b/worlds/alttp/Rom.py @@ -1,7 +1,6 @@ from __future__ import annotations import Utils -import worlds.AutoWorld import worlds.Files LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173" @@ -17,7 +16,6 @@ import struct import subprocess import threading -import xxtea import concurrent.futures import bsdiff4 from typing import Optional, List @@ -39,7 +37,6 @@ from worlds.alttp.Items import ItemFactory, item_table, item_name_groups, progression_items from worlds.alttp.EntranceShuffle import door_addresses from worlds.alttp.Options import smallkey_shuffle -import Patch try: from maseya import z3pr @@ -47,6 +44,11 @@ except: z3pr = None +try: + import xxtea +except: + xxtea = None + enemizer_logger = logging.getLogger("Enemizer") @@ -85,6 +87,11 @@ def encrypt_range(self, startaddress: int, length: int, key: bytes): self.write_bytes(startaddress + i, bytearray(data)) def encrypt(self, world, player): + global xxtea + if xxtea is None: + # cause crash to provide traceback + import xxtea + local_random = world.slot_seeds[player] key = bytes(local_random.getrandbits(8 * 16).to_bytes(16, 'big')) self.write_bytes(0x1800B0, bytearray(key)) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index 93e0b02b14f..e2965b4315e 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -157,6 +157,8 @@ def stage_assert_generate(cls, world): rom_file = get_base_rom_path() if not os.path.exists(rom_file): raise FileNotFoundError(rom_file) + if world.is_race: + import xxtea def generate_early(self): if self.use_enemizer(): @@ -193,6 +195,14 @@ def generate_early(self): world.difficulty_requirements[player] = difficulties[world.difficulty[player]] + # enforce pre-defined local items. + if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: + world.local_items[player].value.add('Triforce Piece') + + # Not possible to place crystals outside boss prizes yet (might as well make it consistent with pendants too). + world.non_local_items[player].value -= item_name_groups['Pendants'] + world.non_local_items[player].value -= item_name_groups['Crystals'] + def create_regions(self): player = self.player world = self.multiworld diff --git a/worlds/bk_sudoku/docs/en_Sudoku.md b/worlds/bk_sudoku/docs/en_Sudoku.md index 072e43a9805..0e20bcbcb10 100644 --- a/worlds/bk_sudoku/docs/en_Sudoku.md +++ b/worlds/bk_sudoku/docs/en_Sudoku.md @@ -6,7 +6,7 @@ BK Sudoku is not a typical Archipelago game; instead, it is a generic Sudoku cli ## What hints are unlocked? -After completing a Sudoku puzzle, the game will unlock 1 random hint for an unchecked location in the slot you are connected to. It is possible to hint the same location repeatedly if that location is still unchecked. +After completing a Sudoku puzzle, the game will unlock 1 random hint for an unchecked location in the slot you are connected to. It is possible to hint a location that was previously hinted for using the !hint command. ## Where is the settings page? diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index a3580f8ff2f..824c085070c 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -10,7 +10,9 @@ class MaxSciencePack(Choice): - """Maximum level of science pack required to complete the game.""" + """Maximum level of science pack required to complete the game. + This also affects the relative cost of silo and satellite recipes if they are randomized. + That is the only thing in which the Utility Science Pack and Space Science Pack settings differ.""" display_name = "Maximum Required Science Pack" option_automation_science_pack = 0 option_logistic_science_pack = 1 @@ -95,7 +97,14 @@ class FreeSamples(Choice): class TechTreeLayout(Choice): - """Selects how the tech tree nodes are interwoven.""" + """Selects how the tech tree nodes are interwoven. + Single: No dependencies + Diamonds: Several grid graphs (4/9/16 nodes each) + Pyramids: Several top halves of diamonds (6/10/15 nodes each) + Funnels: Several bottom halves of diamonds (6/10/15 nodes each) + Trees: Several trees + Choices: A single balanced binary tree + """ display_name = "Technology Tree Layout" option_single = 0 option_small_diamonds = 1 @@ -113,7 +122,11 @@ class TechTreeLayout(Choice): class TechTreeInformation(Choice): - """How much information should be displayed in the tech tree.""" + """How much information should be displayed in the tech tree. + None: No indication what a research unlocks + Advancement: Indicators which researches unlock items that are considered logical advancements + Full: Labels with exact names and recipients of unlocked items; all researches are prefilled into the !hint command. + """ display_name = "Technology Tree Information" option_none = 0 option_advancement = 1 diff --git a/worlds/factorio/docs/setup_en.md b/worlds/factorio/docs/setup_en.md index 73ff5c8c489..09ad431a21c 100644 --- a/worlds/factorio/docs/setup_en.md +++ b/worlds/factorio/docs/setup_en.md @@ -143,27 +143,53 @@ For more information about the commands you can use, see the [Commands Guide](/t 4. Provide your IP address to anyone you want to join your game, and have them follow the steps for "Connecting to Someone Else's Factorio Game" above. +## Enabling Peaceful Mode + +By default, peaceful mode is disabled. There are two methods to enable peaceful mode: + +### By config file +You can specify Factorio game settings such as peaceful mode and terrain and resource generation parameters in your +config .yaml file by including the `world_gen` setting. This setting is currently not supported by the web UI, so you'll +have to manually create or edit your config file with a text editor of your choice. +The [template file](/static/generated/configs/Factorio.yaml) is a good starting point and contains the default value of +the `world_gen` setting. If you already have a config file you may also just copy that setting over from the template. +To enable peaceful mode, simply replace `peaceful_mode: false` with `peaceful_mode: true`. Finally, use the +[.yaml checker](/check) to ensure your file is valid. + +### After starting +If you have already submitted your config file, generated the seed, or even started playing, you can retroactively +enable peaceful mode by entering the following commands into your Archipelago Factorio Client: +``` +/factorio /c game.surfaces[1].peaceful_mode=true +/factorio /c game.forces["enemy"].kill_all_units() +``` +(If this warns you that these commands may disable achievements, you may need to repeat them for them to take effect.) + ## Other Settings -- By default, all item sends are displayed in-game. In larger async seeds this may become overly spammy. - To hide all item sends that are not to or from your factory, do one of the following: - - Type `/toggle-ap-send-filter` in-game - - Type `/toggle_send_filter` in the Archipelago Client - - In your `host.yaml` set +### filter_item_sends + +By default, all item sends are displayed in-game. In larger async seeds this may become overly spammy. +To hide all item sends that are not to or from your factory, do one of the following: +- Type `/toggle-ap-send-filter` in-game +- Type `/toggle_send_filter` in the Archipelago Client +- In your `host.yaml` set ``` factorio_options: filter_item_sends: true ``` -- By default, in-game chat is bridged to Archipelago. If you prefer to be able to speak privately, you can disable this - feature by doing one of the following: - - Type `/toggle-ap-chat` in-game - - Type `/toggle_chat` in the Archipelago Client - - In your `host.yaml` set + +### bridge_chat_out +By default, in-game chat is bridged to Archipelago. If you prefer to be able to speak privately, you can disable this +feature by doing one of the following: +- Type `/toggle-ap-chat` in-game +- Type `/toggle_chat` in the Archipelago Client +- In your `host.yaml` set ``` factorio_options: bridge_chat_out: false ``` - Note that this will also disable `!` commands from within the game, and that it will not affect incoming chat. +Note that this will also disable `!` commands from within the game, and that it will not affect incoming chat. ## Troubleshooting diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index 45f653e8bb2..a96598a8da4 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -106,7 +106,7 @@ settings. If a game can be rolled it **must** have a settings section even if it Some options in Archipelago can be used by every game but must still be placed within the relevant game's section. -Currently, these options are `start_inventory`, `early_items`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints` +Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints` , `exclude_locations`, and various plando options. See the plando guide for more info on plando options. Plando @@ -115,8 +115,6 @@ guide: [Archipelago Plando Guide](/tutorial/Archipelago/plando/en) * `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which will give you 30 rupees. -* `early_items` is formatted in the same way as `start_inventory` and will force the number of each item specified to be -forced into locations that are reachable from the start, before obtaining any items. * `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for the location without using any hint points. * `local_items` will force any items you want to be in your world instead of being in another world. @@ -174,8 +172,6 @@ A Link to the Past: - Quake non_local_items: - Moon Pearl - early_items: - Flute: 1 start_location_hints: - Spike Cave priority_locations: @@ -239,9 +235,6 @@ Timespinner: * `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we have to find it ourselves. * `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it. -* `early_items` forces the `Flute` to be placed in a location that is available from the beginning of the game ("Sphere -1"). Since it is not specified in `local_items` or `non_local_items`, it can be placed one of these locations in any -world. * `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the multiworld that can be used for no cost. * `priority_locations` forces a progression item to be placed on the `Link's House` location. diff --git a/worlds/generic/docs/commands_en.md b/worlds/generic/docs/commands_en.md index dd36460c774..28bbe23140b 100644 --- a/worlds/generic/docs/commands_en.md +++ b/worlds/generic/docs/commands_en.md @@ -92,5 +92,6 @@ including the exclamation point. - `/forbid_forfeit ` Bars the given player from using the `!forfeit` command. - `/send ` Grants the given player the specified item. - `/send_multiple ` Grants the given player the stated amount of the specified item. +- `/send_location ` Send out the given location for the specified player as if the player checked it - `/hint ` Send out a hint for the given item or location for the specified player. - `/option