diff --git a/BaseClasses.py b/BaseClasses.py index 95f24af26548..68b410010e9d 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -616,8 +616,7 @@ def location_condition(location: Location) -> bool: def location_relevant(location: Location) -> bool: """Determine if this location is relevant to sweep.""" - return location.progress_type != LocationProgressType.EXCLUDED \ - and (location.player in players["full"] or location.advancement) + return location.player in players["full"] or location.advancement def all_done() -> bool: """Check if all access rules are fulfilled""" diff --git a/Main.py b/Main.py index edae5d7b19b1..c931e22145a5 100644 --- a/Main.py +++ b/Main.py @@ -101,7 +101,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No multiworld.early_items[player][item_name] = max(0, early-count) remaining_count = count-early if remaining_count > 0: - local_early = multiworld.early_local_items[player].get(item_name, 0) + local_early = multiworld.local_early_items[player].get(item_name, 0) if local_early: multiworld.early_items[player][item_name] = max(0, local_early - remaining_count) del local_early diff --git a/MultiServer.py b/MultiServer.py index f59855fca6a4..b7c0e0f74555 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -991,7 +991,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): collect_player(ctx, team, group, True) -def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: +def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[typing.Tuple[int, int]]: return ctx.locations.get_remaining(ctx.location_checks, team, slot) @@ -1350,10 +1350,10 @@ def _cmd_collect(self) -> bool: def _cmd_remaining(self) -> bool: """List remaining items in your game, but not their location or recipient""" if self.ctx.remaining_mode == "enabled": - remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) - if remaining_item_ids: - self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id] - for item_id in remaining_item_ids)) + rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot) + if rest_locations: + self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] + for slot, item_id in rest_locations)) else: self.output("No remaining items found.") return True @@ -1363,10 +1363,10 @@ def _cmd_remaining(self) -> bool: return False else: # is goal if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: - remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) - if remaining_item_ids: - self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id] - for item_id in remaining_item_ids)) + rest_locations = get_remaining(self.ctx, self.client.team, self.client.slot) + if rest_locations: + self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[slot]][item_id] + for slot, item_id in rest_locations)) else: self.output("No remaining items found.") return True diff --git a/NetUtils.py b/NetUtils.py index f8d698c74fcc..f79773728cd6 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -397,12 +397,12 @@ def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int] location_id not in checked] def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int - ) -> typing.List[int]: + ) -> typing.List[typing.Tuple[int, int]]: checked = state[team, slot] player_locations = self[slot] - return sorted([player_locations[location_id][0] for - location_id in player_locations if - location_id not in checked]) + return sorted([(player_locations[location_id][1], player_locations[location_id][0]) for + location_id in player_locations if + location_id not in checked]) if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub diff --git a/_speedups.pyx b/_speedups.pyx index 4b083c2f9aef..dc039e336500 100644 --- a/_speedups.pyx +++ b/_speedups.pyx @@ -287,15 +287,15 @@ cdef class LocationStore: entry in self.entries[start:start + count] if entry.location not in checked] - def get_remaining(self, state: State, team: int, slot: int) -> List[int]: + def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]: cdef LocationEntry* entry cdef ap_player_t sender = slot cdef size_t start = self.sender_index[sender].start cdef size_t count = self.sender_index[sender].count cdef set checked = state[team, slot] - return sorted([entry.item for - entry in self.entries[start:start+count] if - entry.location not in checked]) + return sorted([(entry.receiver, entry.item) for + entry in self.entries[start:start+count] if + entry.location not in checked]) @cython.auto_pickle(False) diff --git a/docs/network protocol.md b/docs/network protocol.md index da5c41431501..f8080fecc879 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -702,14 +702,18 @@ GameData is a **dict** but contains these keys and values. It's broken out into | checksum | str | A checksum hash of this game's data. | ### Tags -Tags are represented as a list of strings, the common Client tags follow: - -| Name | Notes | -|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. | -| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets | -| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. | -| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. | +Tags are represented as a list of strings, the common client tags follow: + +| Name | Notes | +|-----------|--------------------------------------------------------------------------------------------------------------------------------------| +| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. | +| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets. | +| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² | +| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² | +| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² | + +¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\ +²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped. ### DeathLink A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data: diff --git a/test/bases.py b/test/bases.py index 9fb223af2ac1..83461b158f4f 100644 --- a/test/bases.py +++ b/test/bases.py @@ -293,13 +293,11 @@ def test_all_state_can_reach_everything(self): if not (self.run_default_tests and self.constructed): return with self.subTest("Game", game=self.game, seed=self.multiworld.seed): - excluded = self.multiworld.worlds[self.player].options.exclude_locations.value state = self.multiworld.get_all_state(False) for location in self.multiworld.get_locations(): - if location.name not in excluded: - with self.subTest("Location should be reached", location=location.name): - reachable = location.can_reach(state) - self.assertTrue(reachable, f"{location.name} unreachable") + with self.subTest("Location should be reached", location=location.name): + reachable = location.can_reach(state) + self.assertTrue(reachable, f"{location.name} unreachable") with self.subTest("Beatable"): self.multiworld.state = state self.assertBeatable(True) diff --git a/test/general/test_reachability.py b/test/general/test_reachability.py index 4b71762f77fe..d50013cc4178 100644 --- a/test/general/test_reachability.py +++ b/test/general/test_reachability.py @@ -37,12 +37,10 @@ def test_default_all_state_can_reach_everything(self): unreachable_regions = self.default_settings_unreachable_regions.get(game_name, set()) with self.subTest("Game", game=game_name): multiworld = setup_solo_multiworld(world_type) - excluded = multiworld.worlds[1].options.exclude_locations.value state = multiworld.get_all_state(False) for location in multiworld.get_locations(): - if location.name not in excluded: - with self.subTest("Location should be reached", location=location.name): - self.assertTrue(location.can_reach(state), f"{location.name} unreachable") + with self.subTest("Location should be reached", location=location.name): + self.assertTrue(location.can_reach(state), f"{location.name} unreachable") for region in multiworld.get_regions(): if region.name in unreachable_regions: diff --git a/test/netutils/test_location_store.py b/test/netutils/test_location_store.py index f3e83989bea4..1b984015844d 100644 --- a/test/netutils/test_location_store.py +++ b/test/netutils/test_location_store.py @@ -130,9 +130,9 @@ def test_get_missing(self) -> None: def test_get_remaining(self) -> None: self.assertEqual(self.store.get_remaining(full_state, 0, 1), []) - self.assertEqual(self.store.get_remaining(one_state, 0, 1), [13, 21]) - self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [13, 21, 22]) - self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [99]) + self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)]) + self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)]) + self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)]) def test_location_set_intersection(self) -> None: locations = {10, 11, 12} diff --git a/worlds/lingo/data/LL1.yaml b/worlds/lingo/data/LL1.yaml index 950fd326743a..16a1573b1d56 100644 --- a/worlds/lingo/data/LL1.yaml +++ b/worlds/lingo/data/LL1.yaml @@ -2049,6 +2049,18 @@ panel: I - room: Elements Area panel: A + Eight Door (Outside The Initiated): + id: Red Blue Purple Room Area Doors/Door_a_strands2 + item_name: Outside The Initiated - Eight Door + item_group: Achievement Room Entrances + skip_location: True + panels: + - room: The Incomparable + panel: I (Seven) + - room: Courtyard + panel: I + - room: Elements Area + panel: A panel_doors: Giant Sevens: item_name: Giant Seven Panels @@ -2067,8 +2079,8 @@ room: The Incomparable door: Eight Door Outside The Initiated: - room: Outside The Initiated - door: Eight Door + room: The Incomparable + door: Eight Door (Outside The Initiated) paintings: - id: eight_painting2 orientation: north @@ -3310,7 +3322,8 @@ room: Art Gallery door: Exit Eight Alcove: - door: Eight Door + room: The Incomparable + door: Eight Door (Outside The Initiated) The Optimistic: True panels: SEVEN (1): @@ -3463,17 +3476,6 @@ panel: GREEN - room: Outside The Agreeable panel: PURPLE - Eight Door: - id: Red Blue Purple Room Area Doors/Door_a_strands2 - item_group: Achievement Room Entrances - skip_location: True - panels: - - room: The Incomparable - panel: I (Seven) - - room: Courtyard - panel: I - - room: Elements Area - panel: A panel_doors: UNCOVER: panels: diff --git a/worlds/lingo/data/generated.dat b/worlds/lingo/data/generated.dat index 9a49d3d9d4b9..e2d3d06bec96 100644 Binary files a/worlds/lingo/data/generated.dat and b/worlds/lingo/data/generated.dat differ diff --git a/worlds/lingo/data/ids.yaml b/worlds/lingo/data/ids.yaml index b46f1d36ec1a..13b77145ea2c 100644 --- a/worlds/lingo/data/ids.yaml +++ b/worlds/lingo/data/ids.yaml @@ -1124,6 +1124,8 @@ doors: Eight Door: item: 444475 location: 445219 + Eight Door (Outside The Initiated): + item: 444578 Orange Tower: Second Floor: item: 444476 @@ -1242,8 +1244,6 @@ doors: Entrance: item: 444516 location: 445237 - Eight Door: - item: 444578 The Traveled: Color Hallways Entrance: item: 444517 diff --git a/worlds/osrs/Names.py b/worlds/osrs/Names.py index 95aed742b6f1..cc92439ef859 100644 --- a/worlds/osrs/Names.py +++ b/worlds/osrs/Names.py @@ -93,9 +93,9 @@ class ItemNames(str, Enum): Progressive_Armor = "Progressive Armor" Progressive_Weapons = "Progressive Weapons" Progressive_Tools = "Progressive Tools" - Progressive_Range_Armor = "Progressive Range Armor" - Progressive_Range_Weapon = "Progressive Range Weapon" - Progressive_Magic = "Progressive Magic Spell" + Progressive_Range_Armor = "Progressive Ranged Armor" + Progressive_Range_Weapon = "Progressive Ranged Weapons" + Progressive_Magic = "Progressive Magic" Lobsters = "10 Lobsters" Swordfish = "5 Swordfish" Energy_Potions = "10 Energy Potions" diff --git a/worlds/osrs/__init__.py b/worlds/osrs/__init__.py index f726b4b81bf2..d74dc7cfd9c2 100644 --- a/worlds/osrs/__init__.py +++ b/worlds/osrs/__init__.py @@ -524,7 +524,9 @@ def create_region(self, name: str) -> "Region": return region def create_item(self, item_name: str) -> "Item": - item = [item for item in item_rows if item.name == item_name][0] + items = [item for item in item_rows if item.name == item_name] + assert len(items) > 0, f"No matching item found for name {item_name} for player {self.player_name}" + item = items[0] index = item_rows.index(item) return OSRSItem(item.name, item.progression, self.base_id + index, self.player) diff --git a/worlds/pokemon_emerald/rom.py b/worlds/pokemon_emerald/rom.py index 968a103ccd25..75d7d575846d 100644 --- a/worlds/pokemon_emerald/rom.py +++ b/worlds/pokemon_emerald/rom.py @@ -817,6 +817,8 @@ def _randomize_opponent_battle_type(world: "PokemonEmeraldWorld", patch: Pokemon def _randomize_move_tutor_moves(world: "PokemonEmeraldWorld", patch: PokemonEmeraldProcedurePatch, easter_egg: Tuple[int, int]) -> None: + FORTREE_MOVE_TUTOR_INDEX = 24 + if easter_egg[0] == 2: for i in range(30): patch.write_token( @@ -840,18 +842,26 @@ def _randomize_move_tutor_moves(world: "PokemonEmeraldWorld", patch: PokemonEmer # Always set Fortree move tutor to Dig patch.write_token( APTokenTypes.WRITE, - data.rom_addresses["gTutorMoves"] + (24 * 2), + data.rom_addresses["gTutorMoves"] + (FORTREE_MOVE_TUTOR_INDEX * 2), struct.pack("=50%) compatibility + if world.options.tm_tutor_compatibility.value < 50: + compatibility &= ~(1 << FORTREE_MOVE_TUTOR_INDEX) + if world.random.random() < 0.5: + compatibility |= 1 << FORTREE_MOVE_TUTOR_INDEX + patch.write_token( APTokenTypes.WRITE, data.rom_addresses["sTutorLearnsets"] + (species.species_id * 4), - struct.pack(" str: # the vanilla connection destination="Town Basement", tag="_beach"), Portal(name="Changing Room Entrance", region="Overworld", destination="Changing Room", tag="_"), - Portal(name="Cube Cave Entrance", region="Overworld", + Portal(name="Cube Cave Entrance", region="Cube Cave Entrance Region", destination="CubeRoom", tag="_"), Portal(name="Stairs from Overworld to Mountain", region="Upper Overworld", destination="Mountain", tag="_"), @@ -562,6 +562,7 @@ class DeadEnd(IntEnum): "Overworld Temple Door": RegionInfo("Overworld Redux"), # the small space betweeen the door and the portal "Overworld Town Portal": RegionInfo("Overworld Redux"), # being able to go to or come from the portal "Overworld Spawn Portal": RegionInfo("Overworld Redux"), # being able to go to or come from the portal + "Cube Cave Entrance Region": RegionInfo("Overworld Redux"), # other side of the bomb wall "Stick House": RegionInfo("Sword Cave", dead_end=DeadEnd.all_cats), "Windmill": RegionInfo("Windmill"), "Old House Back": RegionInfo("Overworld Interiors"), # part with the hc door @@ -775,6 +776,8 @@ class DeadEnd(IntEnum): [["UR"]], "Overworld Old House Door": [], + "Cube Cave Entrance Region": + [], }, "East Overworld": { "Above Ruined Passage": @@ -920,6 +923,10 @@ class DeadEnd(IntEnum): "Overworld": [], }, + "Cube Cave Entrance Region": { + "Overworld": + [], + }, "Old House Front": { "Old House Back": [], diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index a54ea23bcc0a..3d1973beb375 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1,6 +1,7 @@ from typing import Dict, Set, List, Tuple, TYPE_CHECKING from worlds.generic.Rules import set_rule, forbid_item -from .rules import has_ability, has_sword, has_stick, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage +from .rules import (has_ability, has_sword, has_stick, has_ice_grapple_logic, has_lantern, has_mask, can_ladder_storage, + bomb_walls) from .er_data import Portal from BaseClasses import Region, CollectionState @@ -11,6 +12,7 @@ grapple = "Magic Orb" ice_dagger = "Magic Dagger" fire_wand = "Magic Wand" +gun = "Gun" lantern = "Lantern" fairies = "Fairy" coins = "Golden Coin" @@ -31,6 +33,10 @@ def has_ladder(ladder: str, state: CollectionState, world: "TunicWorld") -> bool return not world.options.shuffle_ladders or state.has(ladder, world.player) +def can_shop(state: CollectionState, world: "TunicWorld") -> bool: + return has_sword(state, world.player) and state.can_reach_region("Shop", world.player) + + def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_pairs: Dict[Portal, Portal]) -> None: player = world.player options = world.options @@ -217,12 +223,12 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ regions["Overworld"].connect( connecting_region=regions["Overworld after Envoy"], - rule=lambda state: state.has_any({laurels, grapple}, player) + rule=lambda state: state.has_any({laurels, grapple, gun}, player) or state.has("Sword Upgrade", player, 4) or options.logic_rules) regions["Overworld after Envoy"].connect( connecting_region=regions["Overworld"], - rule=lambda state: state.has_any({laurels, grapple}, player) + rule=lambda state: state.has_any({laurels, grapple, gun}, player) or state.has("Sword Upgrade", player, 4) or options.logic_rules) @@ -329,6 +335,12 @@ def set_er_region_rules(world: "TunicWorld", regions: Dict[str, Region], portal_ connecting_region=regions["Overworld"], rule=lambda state: state.has_any({grapple, laurels}, player)) + regions["Overworld"].connect( + connecting_region=regions["Cube Cave Entrance Region"], + rule=lambda state: state.has(gun, player) or can_shop(state, world)) + regions["Cube Cave Entrance Region"].connect( + connecting_region=regions["Overworld"]) + # Overworld side areas regions["Old House Front"].connect( connecting_region=regions["Old House Back"]) @@ -1527,6 +1539,10 @@ def set_er_location_rules(world: "TunicWorld") -> None: set_rule(world.get_location("Library Fuse"), lambda state: has_ability(prayer, state, world)) + # Bombable Walls + for location_name in bomb_walls: + set_rule(world.get_location(location_name), lambda state: state.has(gun, player) or can_shop(state, world)) + # Shop set_rule(world.get_location("Shop - Potion 1"), lambda state: has_sword(state, player)) diff --git a/worlds/tunic/items.py b/worlds/tunic/items.py index a8aec9f74485..3e7f2c1a4382 100644 --- a/worlds/tunic/items.py +++ b/worlds/tunic/items.py @@ -43,7 +43,7 @@ class TunicItemData(NamedTuple): "Magic Orb": TunicItemData(ItemClassification.progression, 1, 27), "Hero's Laurels": TunicItemData(ItemClassification.progression, 1, 28), "Lantern": TunicItemData(ItemClassification.progression, 1, 29), - "Gun": TunicItemData(ItemClassification.useful, 1, 30, "Weapons"), + "Gun": TunicItemData(ItemClassification.progression, 1, 30, "Weapons"), "Shield": TunicItemData(ItemClassification.useful, 1, 31), "Dath Stone": TunicItemData(ItemClassification.useful, 1, 32), "Hourglass": TunicItemData(ItemClassification.useful, 1, 33), diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 2ff588da904d..68756869038d 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -1,7 +1,7 @@ from random import Random from typing import Dict, TYPE_CHECKING -from worlds.generic.Rules import set_rule, forbid_item +from worlds.generic.Rules import set_rule, forbid_item, add_rule from BaseClasses import CollectionState from .options import TunicOptions if TYPE_CHECKING: @@ -11,6 +11,7 @@ grapple = "Magic Orb" ice_dagger = "Magic Dagger" fire_wand = "Magic Wand" +gun = "Gun" lantern = "Lantern" fairies = "Fairy" coins = "Golden Coin" @@ -26,6 +27,11 @@ blue_hexagon = "Blue Questagon" gold_hexagon = "Gold Questagon" +bomb_walls = ["East Forest - Bombable Wall", "Eastern Vault Fortress - [East Wing] Bombable Wall", + "Overworld - [Central] Bombable Wall", "Overworld - [Southwest] Bombable Wall Near Fountain", + "Quarry - [West] Upper Area Bombable Wall", "Quarry - [East] Bombable Wall", + "Ruined Atoll - [Northwest] Bombable Wall"] + def randomize_ability_unlocks(random: Random, options: TunicOptions) -> Dict[str, int]: ability_requirement = [1, 1, 1] @@ -110,7 +116,7 @@ def set_region_rules(world: "TunicWorld") -> None: lambda state: state.has_any({grapple, laurels}, player) and has_ability(prayer, state, world) world.get_entrance("Overworld -> Quarry").access_rule = \ lambda state: (has_sword(state, player) or state.has(fire_wand, player)) \ - and (state.has_any({grapple, laurels}, player) or can_ladder_storage(state, world)) + and (state.has_any({grapple, laurels, gun}, player) or can_ladder_storage(state, world)) world.get_entrance("Quarry Back -> Quarry").access_rule = \ lambda state: has_sword(state, player) or state.has(fire_wand, player) world.get_entrance("Quarry -> Lower Quarry").access_rule = \ @@ -326,6 +332,13 @@ def set_location_rules(world: "TunicWorld") -> None: set_rule(world.get_location("Hero's Grave - Feathers Relic"), lambda state: state.has(laurels, player) and has_ability(prayer, state, world)) + # Bombable Walls + for location_name in bomb_walls: + # has_sword is there because you can buy bombs in the shop + set_rule(world.get_location(location_name), lambda state: state.has(gun, player) or has_sword(state, player)) + add_rule(world.get_location("Cube Cave - Holy Cross Chest"), + lambda state: state.has(gun, player) or has_sword(state, player)) + # Shop set_rule(world.get_location("Shop - Potion 1"), lambda state: has_sword(state, player))