mirror of
https://github.com/MarioSpore/Grinch-AP.git
synced 2025-10-21 12:11:33 -06:00
The Wind Waker: Implement New Game (#4458)
Adds The Legend of Zelda: The Wind Waker as a supported game in Archipelago. The game uses [LagoLunatic's randomizer](https://github.com/LagoLunatic/wwrando) as its base (regarding logic, options, etc.) and builds from there.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@
|
||||
*.apmc
|
||||
*.apz5
|
||||
*.aptloz
|
||||
*.aptww
|
||||
*.apemerald
|
||||
*.pyc
|
||||
*.pyd
|
||||
|
@@ -81,6 +81,7 @@ Currently, the following games are supported:
|
||||
* Castlevania: Circle of the Moon
|
||||
* Inscryption
|
||||
* Civilization VI
|
||||
* The Legend of Zelda: The Wind Waker
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
@@ -214,6 +214,9 @@
|
||||
# Wargroove
|
||||
/worlds/wargroove/ @FlySniper
|
||||
|
||||
# The Wind Waker
|
||||
/worlds/tww/ @tanjo3
|
||||
|
||||
# The Witness
|
||||
/worlds/witness/ @NewSoupVi @blastron
|
||||
|
||||
|
373
worlds/tww/Items.py
Normal file
373
worlds/tww/Items.py
Normal file
@@ -0,0 +1,373 @@
|
||||
from collections.abc import Iterable
|
||||
from typing import TYPE_CHECKING, NamedTuple, Optional
|
||||
|
||||
from BaseClasses import Item
|
||||
from BaseClasses import ItemClassification as IC
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .randomizers.Dungeons import Dungeon
|
||||
|
||||
|
||||
def item_factory(items: str | Iterable[str], world: World) -> Item | list[Item]:
|
||||
"""
|
||||
Create items based on their names.
|
||||
Depending on the input, this function can return a single item or a list of items.
|
||||
|
||||
:param items: The name or names of the items to create.
|
||||
:param world: The game world.
|
||||
:raises KeyError: If an unknown item name is provided.
|
||||
:return: A single item or a list of items.
|
||||
"""
|
||||
ret: list[Item] = []
|
||||
singleton = False
|
||||
if isinstance(items, str):
|
||||
items = [items]
|
||||
singleton = True
|
||||
for item in items:
|
||||
if item in ITEM_TABLE:
|
||||
ret.append(world.create_item(item))
|
||||
else:
|
||||
raise KeyError(f"Unknown item {item}")
|
||||
|
||||
return ret[0] if singleton else ret
|
||||
|
||||
|
||||
class TWWItemData(NamedTuple):
|
||||
"""
|
||||
This class represents the data for an item in The Wind Waker.
|
||||
|
||||
:param type: The type of the item (e.g., "Item", "Dungeon Item").
|
||||
:param classification: The item's classification (progression, useful, filler).
|
||||
:param code: The unique code identifier for the item.
|
||||
:param quantity: The number of this item available.
|
||||
:param item_id: The ID used to represent the item in-game.
|
||||
"""
|
||||
|
||||
type: str
|
||||
classification: IC
|
||||
code: Optional[int]
|
||||
quantity: int
|
||||
item_id: Optional[int]
|
||||
|
||||
|
||||
class TWWItem(Item):
|
||||
"""
|
||||
This class represents an item in The Wind Waker.
|
||||
|
||||
:param name: The item's name.
|
||||
:param player: The ID of the player who owns the item.
|
||||
:param data: The data associated with this item.
|
||||
:param classification: Optional classification to override the default.
|
||||
"""
|
||||
|
||||
game: str = "The Wind Waker"
|
||||
type: Optional[str]
|
||||
dungeon: Optional["Dungeon"] = None
|
||||
|
||||
def __init__(self, name: str, player: int, data: TWWItemData, classification: Optional[IC] = None) -> None:
|
||||
super().__init__(
|
||||
name,
|
||||
data.classification if classification is None else classification,
|
||||
None if data.code is None else TWWItem.get_apid(data.code),
|
||||
player,
|
||||
)
|
||||
|
||||
self.type = data.type
|
||||
self.item_id = data.item_id
|
||||
|
||||
@staticmethod
|
||||
def get_apid(code: int) -> int:
|
||||
"""
|
||||
Compute the Archipelago ID for the given item code.
|
||||
|
||||
:param code: The unique code for the item.
|
||||
:return: The computed Archipelago ID.
|
||||
"""
|
||||
base_id: int = 2322432
|
||||
return base_id + code
|
||||
|
||||
@property
|
||||
def dungeon_item(self) -> Optional[str]:
|
||||
"""
|
||||
Determine if the item is a dungeon item and, if so, returns its type.
|
||||
|
||||
:return: The type of dungeon item, or `None` if it is not a dungeon item.
|
||||
"""
|
||||
if self.type in ("Small Key", "Big Key", "Map", "Compass"):
|
||||
return self.type
|
||||
return None
|
||||
|
||||
|
||||
ITEM_TABLE: dict[str, TWWItemData] = {
|
||||
"Telescope": TWWItemData("Item", IC.useful, 0, 1, 0x20),
|
||||
# "Boat's Sail": TWWItemData("Item", IC.progression, 1, 1, 0x78), # noqa: E131
|
||||
"Wind Waker": TWWItemData("Item", IC.progression, 2, 1, 0x22),
|
||||
"Grappling Hook": TWWItemData("Item", IC.progression, 3, 1, 0x25),
|
||||
"Spoils Bag": TWWItemData("Item", IC.progression, 4, 1, 0x24),
|
||||
"Boomerang": TWWItemData("Item", IC.progression, 5, 1, 0x2D),
|
||||
"Deku Leaf": TWWItemData("Item", IC.progression, 6, 1, 0x34),
|
||||
"Tingle Tuner": TWWItemData("Item", IC.progression, 7, 1, 0x21),
|
||||
"Iron Boots": TWWItemData("Item", IC.progression, 8, 1, 0x29),
|
||||
"Magic Armor": TWWItemData("Item", IC.progression, 9, 1, 0x2A),
|
||||
"Bait Bag": TWWItemData("Item", IC.progression, 10, 1, 0x2C),
|
||||
"Bombs": TWWItemData("Item", IC.progression, 11, 1, 0x31),
|
||||
"Delivery Bag": TWWItemData("Item", IC.progression, 12, 1, 0x30),
|
||||
"Hookshot": TWWItemData("Item", IC.progression, 13, 1, 0x2F),
|
||||
"Skull Hammer": TWWItemData("Item", IC.progression, 14, 1, 0x33),
|
||||
"Power Bracelets": TWWItemData("Item", IC.progression, 15, 1, 0x28),
|
||||
|
||||
"Hero's Charm": TWWItemData("Item", IC.useful, 16, 1, 0x43),
|
||||
"Hurricane Spin": TWWItemData("Item", IC.useful, 17, 1, 0xAA),
|
||||
"Dragon Tingle Statue": TWWItemData("Item", IC.progression, 18, 1, 0xA3),
|
||||
"Forbidden Tingle Statue": TWWItemData("Item", IC.progression, 19, 1, 0xA4),
|
||||
"Goddess Tingle Statue": TWWItemData("Item", IC.progression, 20, 1, 0xA5),
|
||||
"Earth Tingle Statue": TWWItemData("Item", IC.progression, 21, 1, 0xA6),
|
||||
"Wind Tingle Statue": TWWItemData("Item", IC.progression, 22, 1, 0xA7),
|
||||
|
||||
"Wind's Requiem": TWWItemData("Item", IC.progression, 23, 1, 0x6D),
|
||||
"Ballad of Gales": TWWItemData("Item", IC.progression, 24, 1, 0x6E),
|
||||
"Command Melody": TWWItemData("Item", IC.progression, 25, 1, 0x6F),
|
||||
"Earth God's Lyric": TWWItemData("Item", IC.progression, 26, 1, 0x70),
|
||||
"Wind God's Aria": TWWItemData("Item", IC.progression, 27, 1, 0x71),
|
||||
"Song of Passing": TWWItemData("Item", IC.progression, 28, 1, 0x72),
|
||||
|
||||
"Triforce Shard 1": TWWItemData("Item", IC.progression, 29, 1, 0x61),
|
||||
"Triforce Shard 2": TWWItemData("Item", IC.progression, 30, 1, 0x62),
|
||||
"Triforce Shard 3": TWWItemData("Item", IC.progression, 31, 1, 0x63),
|
||||
"Triforce Shard 4": TWWItemData("Item", IC.progression, 32, 1, 0x64),
|
||||
"Triforce Shard 5": TWWItemData("Item", IC.progression, 33, 1, 0x65),
|
||||
"Triforce Shard 6": TWWItemData("Item", IC.progression, 34, 1, 0x66),
|
||||
"Triforce Shard 7": TWWItemData("Item", IC.progression, 35, 1, 0x67),
|
||||
"Triforce Shard 8": TWWItemData("Item", IC.progression, 36, 1, 0x68),
|
||||
|
||||
"Skull Necklace": TWWItemData("Item", IC.filler, 37, 9, 0x45),
|
||||
"Boko Baba Seed": TWWItemData("Item", IC.filler, 38, 1, 0x46),
|
||||
"Golden Feather": TWWItemData("Item", IC.filler, 39, 9, 0x47),
|
||||
"Knight's Crest": TWWItemData("Item", IC.filler, 40, 3, 0x48),
|
||||
"Red Chu Jelly": TWWItemData("Item", IC.filler, 41, 1, 0x49),
|
||||
"Green Chu Jelly": TWWItemData("Item", IC.filler, 42, 1, 0x4A),
|
||||
"Joy Pendant": TWWItemData("Item", IC.filler, 43, 20, 0x1F),
|
||||
"All-Purpose Bait": TWWItemData("Item", IC.filler, 44, 1, 0x82),
|
||||
"Hyoi Pear": TWWItemData("Item", IC.filler, 45, 4, 0x83),
|
||||
|
||||
"Note to Mom": TWWItemData("Item", IC.progression, 46, 1, 0x99),
|
||||
"Maggie's Letter": TWWItemData("Item", IC.progression, 47, 1, 0x9A),
|
||||
"Moblin's Letter": TWWItemData("Item", IC.progression, 48, 1, 0x9B),
|
||||
"Cabana Deed": TWWItemData("Item", IC.progression, 49, 1, 0x9C),
|
||||
"Fill-Up Coupon": TWWItemData("Item", IC.useful, 50, 1, 0x9E),
|
||||
|
||||
"Nayru's Pearl": TWWItemData("Item", IC.progression, 51, 1, 0x69),
|
||||
"Din's Pearl": TWWItemData("Item", IC.progression, 52, 1, 0x6A),
|
||||
"Farore's Pearl": TWWItemData("Item", IC.progression, 53, 1, 0x6B),
|
||||
|
||||
"Progressive Sword": TWWItemData("Item", IC.progression, 54, 4, 0x38),
|
||||
"Progressive Shield": TWWItemData("Item", IC.progression, 55, 2, 0x3B),
|
||||
"Progressive Picto Box": TWWItemData("Item", IC.progression, 56, 2, 0x23),
|
||||
"Progressive Bow": TWWItemData("Item", IC.progression, 57, 3, 0x27),
|
||||
"Progressive Magic Meter": TWWItemData("Item", IC.progression, 58, 2, 0xB1),
|
||||
"Quiver Capacity Upgrade": TWWItemData("Item", IC.progression, 59, 2, 0xAF),
|
||||
"Bomb Bag Capacity Upgrade": TWWItemData("Item", IC.useful, 60, 2, 0xAD),
|
||||
"Wallet Capacity Upgrade": TWWItemData("Item", IC.progression, 61, 2, 0xAB),
|
||||
"Empty Bottle": TWWItemData("Item", IC.progression, 62, 4, 0x50),
|
||||
|
||||
"Triforce Chart 1": TWWItemData("Item", IC.progression_skip_balancing, 63, 1, 0xFE),
|
||||
"Triforce Chart 2": TWWItemData("Item", IC.progression_skip_balancing, 64, 1, 0xFD),
|
||||
"Triforce Chart 3": TWWItemData("Item", IC.progression_skip_balancing, 65, 1, 0xFC),
|
||||
"Triforce Chart 4": TWWItemData("Item", IC.progression_skip_balancing, 66, 1, 0xFB),
|
||||
"Triforce Chart 5": TWWItemData("Item", IC.progression_skip_balancing, 67, 1, 0xFA),
|
||||
"Triforce Chart 6": TWWItemData("Item", IC.progression_skip_balancing, 68, 1, 0xF9),
|
||||
"Triforce Chart 7": TWWItemData("Item", IC.progression_skip_balancing, 69, 1, 0xF8),
|
||||
"Triforce Chart 8": TWWItemData("Item", IC.progression_skip_balancing, 70, 1, 0xF7),
|
||||
"Treasure Chart 1": TWWItemData("Item", IC.progression_skip_balancing, 71, 1, 0xE7),
|
||||
"Treasure Chart 2": TWWItemData("Item", IC.progression_skip_balancing, 72, 1, 0xEE),
|
||||
"Treasure Chart 3": TWWItemData("Item", IC.progression_skip_balancing, 73, 1, 0xE0),
|
||||
"Treasure Chart 4": TWWItemData("Item", IC.progression_skip_balancing, 74, 1, 0xE1),
|
||||
"Treasure Chart 5": TWWItemData("Item", IC.progression_skip_balancing, 75, 1, 0xF2),
|
||||
"Treasure Chart 6": TWWItemData("Item", IC.progression_skip_balancing, 76, 1, 0xEA),
|
||||
"Treasure Chart 7": TWWItemData("Item", IC.progression_skip_balancing, 77, 1, 0xCC),
|
||||
"Treasure Chart 8": TWWItemData("Item", IC.progression_skip_balancing, 78, 1, 0xD4),
|
||||
"Treasure Chart 9": TWWItemData("Item", IC.progression_skip_balancing, 79, 1, 0xDA),
|
||||
"Treasure Chart 10": TWWItemData("Item", IC.progression_skip_balancing, 80, 1, 0xDE),
|
||||
"Treasure Chart 11": TWWItemData("Item", IC.progression_skip_balancing, 81, 1, 0xF6),
|
||||
"Treasure Chart 12": TWWItemData("Item", IC.progression_skip_balancing, 82, 1, 0xE9),
|
||||
"Treasure Chart 13": TWWItemData("Item", IC.progression_skip_balancing, 83, 1, 0xCF),
|
||||
"Treasure Chart 14": TWWItemData("Item", IC.progression_skip_balancing, 84, 1, 0xDD),
|
||||
"Treasure Chart 15": TWWItemData("Item", IC.progression_skip_balancing, 85, 1, 0xF5),
|
||||
"Treasure Chart 16": TWWItemData("Item", IC.progression_skip_balancing, 86, 1, 0xE3),
|
||||
"Treasure Chart 17": TWWItemData("Item", IC.progression_skip_balancing, 87, 1, 0xD7),
|
||||
"Treasure Chart 18": TWWItemData("Item", IC.progression_skip_balancing, 88, 1, 0xE4),
|
||||
"Treasure Chart 19": TWWItemData("Item", IC.progression_skip_balancing, 89, 1, 0xD1),
|
||||
"Treasure Chart 20": TWWItemData("Item", IC.progression_skip_balancing, 90, 1, 0xF3),
|
||||
"Treasure Chart 21": TWWItemData("Item", IC.progression_skip_balancing, 91, 1, 0xCE),
|
||||
"Treasure Chart 22": TWWItemData("Item", IC.progression_skip_balancing, 92, 1, 0xD9),
|
||||
"Treasure Chart 23": TWWItemData("Item", IC.progression_skip_balancing, 93, 1, 0xF1),
|
||||
"Treasure Chart 24": TWWItemData("Item", IC.progression_skip_balancing, 94, 1, 0xEB),
|
||||
"Treasure Chart 25": TWWItemData("Item", IC.progression_skip_balancing, 95, 1, 0xD6),
|
||||
"Treasure Chart 26": TWWItemData("Item", IC.progression_skip_balancing, 96, 1, 0xD3),
|
||||
"Treasure Chart 27": TWWItemData("Item", IC.progression_skip_balancing, 97, 1, 0xCD),
|
||||
"Treasure Chart 28": TWWItemData("Item", IC.progression_skip_balancing, 98, 1, 0xE2),
|
||||
"Treasure Chart 29": TWWItemData("Item", IC.progression_skip_balancing, 99, 1, 0xE6),
|
||||
"Treasure Chart 30": TWWItemData("Item", IC.progression_skip_balancing, 100, 1, 0xF4),
|
||||
"Treasure Chart 31": TWWItemData("Item", IC.progression_skip_balancing, 101, 1, 0xF0),
|
||||
"Treasure Chart 32": TWWItemData("Item", IC.progression_skip_balancing, 102, 1, 0xD0),
|
||||
"Treasure Chart 33": TWWItemData("Item", IC.progression_skip_balancing, 103, 1, 0xEF),
|
||||
"Treasure Chart 34": TWWItemData("Item", IC.progression_skip_balancing, 104, 1, 0xE5),
|
||||
"Treasure Chart 35": TWWItemData("Item", IC.progression_skip_balancing, 105, 1, 0xE8),
|
||||
"Treasure Chart 36": TWWItemData("Item", IC.progression_skip_balancing, 106, 1, 0xD8),
|
||||
"Treasure Chart 37": TWWItemData("Item", IC.progression_skip_balancing, 107, 1, 0xD5),
|
||||
"Treasure Chart 38": TWWItemData("Item", IC.progression_skip_balancing, 108, 1, 0xED),
|
||||
"Treasure Chart 39": TWWItemData("Item", IC.progression_skip_balancing, 109, 1, 0xEC),
|
||||
"Treasure Chart 40": TWWItemData("Item", IC.progression_skip_balancing, 110, 1, 0xDF),
|
||||
"Treasure Chart 41": TWWItemData("Item", IC.progression_skip_balancing, 111, 1, 0xD2),
|
||||
|
||||
"Tingle's Chart": TWWItemData("Item", IC.filler, 112, 1, 0xDC),
|
||||
"Ghost Ship Chart": TWWItemData("Item", IC.progression, 113, 1, 0xDB),
|
||||
"Octo Chart": TWWItemData("Item", IC.filler, 114, 1, 0xCA),
|
||||
"Great Fairy Chart": TWWItemData("Item", IC.filler, 115, 1, 0xC9),
|
||||
"Secret Cave Chart": TWWItemData("Item", IC.filler, 116, 1, 0xC6),
|
||||
"Light Ring Chart": TWWItemData("Item", IC.filler, 117, 1, 0xC5),
|
||||
"Platform Chart": TWWItemData("Item", IC.filler, 118, 1, 0xC4),
|
||||
"Beedle's Chart": TWWItemData("Item", IC.filler, 119, 1, 0xC3),
|
||||
"Submarine Chart": TWWItemData("Item", IC.filler, 120, 1, 0xC2),
|
||||
|
||||
"Green Rupee": TWWItemData("Item", IC.filler, 121, 1, 0x01),
|
||||
"Blue Rupee": TWWItemData("Item", IC.filler, 122, 2, 0x02),
|
||||
"Yellow Rupee": TWWItemData("Item", IC.filler, 123, 3, 0x03),
|
||||
"Red Rupee": TWWItemData("Item", IC.filler, 124, 8, 0x04),
|
||||
"Purple Rupee": TWWItemData("Item", IC.filler, 125, 10, 0x05),
|
||||
"Orange Rupee": TWWItemData("Item", IC.useful, 126, 15, 0x06),
|
||||
"Silver Rupee": TWWItemData("Item", IC.useful, 127, 20, 0x0F),
|
||||
"Rainbow Rupee": TWWItemData("Item", IC.useful, 128, 1, 0xB8),
|
||||
|
||||
"Piece of Heart": TWWItemData("Item", IC.useful, 129, 44, 0x07),
|
||||
"Heart Container": TWWItemData("Item", IC.useful, 130, 6, 0x08),
|
||||
|
||||
"DRC Big Key": TWWItemData("Big Key", IC.progression, 131, 1, 0x14),
|
||||
"DRC Small Key": TWWItemData("Small Key", IC.progression, 132, 4, 0x13),
|
||||
"FW Big Key": TWWItemData("Big Key", IC.progression, 133, 1, 0x40),
|
||||
"FW Small Key": TWWItemData("Small Key", IC.progression, 134, 1, 0x1D),
|
||||
"TotG Big Key": TWWItemData("Big Key", IC.progression, 135, 1, 0x5C),
|
||||
"TotG Small Key": TWWItemData("Small Key", IC.progression, 136, 2, 0x5B),
|
||||
"ET Big Key": TWWItemData("Big Key", IC.progression, 138, 1, 0x74),
|
||||
"ET Small Key": TWWItemData("Small Key", IC.progression, 139, 3, 0x73),
|
||||
"WT Big Key": TWWItemData("Big Key", IC.progression, 140, 1, 0x81),
|
||||
"WT Small Key": TWWItemData("Small Key", IC.progression, 141, 2, 0x77),
|
||||
"DRC Dungeon Map": TWWItemData("Map", IC.filler, 142, 1, 0x1B),
|
||||
"DRC Compass": TWWItemData("Compass", IC.filler, 143, 1, 0x1C),
|
||||
"FW Dungeon Map": TWWItemData("Map", IC.filler, 144, 1, 0x41),
|
||||
"FW Compass": TWWItemData("Compass", IC.filler, 145, 1, 0x5A),
|
||||
"TotG Dungeon Map": TWWItemData("Map", IC.filler, 146, 1, 0x5D),
|
||||
"TotG Compass": TWWItemData("Compass", IC.filler, 147, 1, 0x5E),
|
||||
"FF Dungeon Map": TWWItemData("Map", IC.filler, 148, 1, 0x5F),
|
||||
"FF Compass": TWWItemData("Compass", IC.filler, 149, 1, 0x60),
|
||||
"ET Dungeon Map": TWWItemData("Map", IC.filler, 150, 1, 0x75),
|
||||
"ET Compass": TWWItemData("Compass", IC.filler, 151, 1, 0x76),
|
||||
"WT Dungeon Map": TWWItemData("Map", IC.filler, 152, 1, 0x84),
|
||||
"WT Compass": TWWItemData("Compass", IC.filler, 153, 1, 0x85),
|
||||
|
||||
"Victory": TWWItemData("Event", IC.progression, None, 1, None),
|
||||
}
|
||||
|
||||
ISLAND_NUMBER_TO_CHART_NAME = {
|
||||
1: "Treasure Chart 25",
|
||||
2: "Treasure Chart 7",
|
||||
3: "Treasure Chart 24",
|
||||
4: "Triforce Chart 2",
|
||||
5: "Treasure Chart 11",
|
||||
6: "Triforce Chart 7",
|
||||
7: "Treasure Chart 13",
|
||||
8: "Treasure Chart 41",
|
||||
9: "Treasure Chart 29",
|
||||
10: "Treasure Chart 22",
|
||||
11: "Treasure Chart 18",
|
||||
12: "Treasure Chart 30",
|
||||
13: "Treasure Chart 39",
|
||||
14: "Treasure Chart 19",
|
||||
15: "Treasure Chart 8",
|
||||
16: "Treasure Chart 2",
|
||||
17: "Treasure Chart 10",
|
||||
18: "Treasure Chart 26",
|
||||
19: "Treasure Chart 3",
|
||||
20: "Treasure Chart 37",
|
||||
21: "Treasure Chart 27",
|
||||
22: "Treasure Chart 38",
|
||||
23: "Triforce Chart 1",
|
||||
24: "Treasure Chart 21",
|
||||
25: "Treasure Chart 6",
|
||||
26: "Treasure Chart 14",
|
||||
27: "Treasure Chart 34",
|
||||
28: "Treasure Chart 5",
|
||||
29: "Treasure Chart 28",
|
||||
30: "Treasure Chart 35",
|
||||
31: "Triforce Chart 3",
|
||||
32: "Triforce Chart 6",
|
||||
33: "Treasure Chart 1",
|
||||
34: "Treasure Chart 20",
|
||||
35: "Treasure Chart 36",
|
||||
36: "Treasure Chart 23",
|
||||
37: "Treasure Chart 12",
|
||||
38: "Treasure Chart 16",
|
||||
39: "Treasure Chart 4",
|
||||
40: "Treasure Chart 17",
|
||||
41: "Treasure Chart 31",
|
||||
42: "Triforce Chart 5",
|
||||
43: "Treasure Chart 9",
|
||||
44: "Triforce Chart 4",
|
||||
45: "Treasure Chart 40",
|
||||
46: "Triforce Chart 8",
|
||||
47: "Treasure Chart 15",
|
||||
48: "Treasure Chart 32",
|
||||
49: "Treasure Chart 33",
|
||||
}
|
||||
|
||||
|
||||
LOOKUP_ID_TO_NAME: dict[int, str] = {
|
||||
TWWItem.get_apid(data.code): item for item, data in ITEM_TABLE.items() if data.code is not None
|
||||
}
|
||||
|
||||
item_name_groups = {
|
||||
"Songs": {
|
||||
"Wind's Requiem",
|
||||
"Ballad of Gales",
|
||||
"Command Melody",
|
||||
"Earth God's Lyric",
|
||||
"Wind God's Aria",
|
||||
"Song of Passing",
|
||||
},
|
||||
"Mail": {
|
||||
"Note to Mom",
|
||||
"Maggie's Letter",
|
||||
"Moblin's Letter",
|
||||
},
|
||||
"Special Charts": {
|
||||
"Tingle's Chart",
|
||||
"Ghost Ship Chart",
|
||||
"Octo Chart",
|
||||
"Great Fairy Chart",
|
||||
"Secret Cave Chart",
|
||||
"Light Ring Chart",
|
||||
"Platform Chart",
|
||||
"Beedle's Chart",
|
||||
"Submarine Chart",
|
||||
},
|
||||
}
|
||||
# generic groups, (Name, substring)
|
||||
_simple_groups = {
|
||||
("Tingle Statues", "Tingle Statue"),
|
||||
("Shards", "Shard"),
|
||||
("Pearls", "Pearl"),
|
||||
("Triforce Charts", "Triforce Chart"),
|
||||
("Treasure Charts", "Treasure Chart"),
|
||||
("Small Keys", "Small Key"),
|
||||
("Big Keys", "Big Key"),
|
||||
("Rupees", "Rupee"),
|
||||
("Dungeon Items", "Compass"),
|
||||
("Dungeon Items", "Map"),
|
||||
}
|
||||
for basename, substring in _simple_groups:
|
||||
if basename not in item_name_groups:
|
||||
item_name_groups[basename] = set()
|
||||
for itemname in ITEM_TABLE:
|
||||
if substring in itemname:
|
||||
item_name_groups[basename].add(itemname)
|
1272
worlds/tww/Locations.py
Normal file
1272
worlds/tww/Locations.py
Normal file
File diff suppressed because it is too large
Load Diff
1114
worlds/tww/Macros.py
Normal file
1114
worlds/tww/Macros.py
Normal file
File diff suppressed because it is too large
Load Diff
854
worlds/tww/Options.py
Normal file
854
worlds/tww/Options.py
Normal file
@@ -0,0 +1,854 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from Options import (
|
||||
Choice,
|
||||
DeathLink,
|
||||
DefaultOnToggle,
|
||||
OptionGroup,
|
||||
OptionSet,
|
||||
PerGameCommonOptions,
|
||||
Range,
|
||||
StartInventoryPool,
|
||||
Toggle,
|
||||
)
|
||||
|
||||
from .Locations import DUNGEON_NAMES
|
||||
|
||||
|
||||
class Dungeons(DefaultOnToggle):
|
||||
"""
|
||||
This controls whether dungeon locations are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Dungeons"
|
||||
|
||||
|
||||
class TingleChests(Toggle):
|
||||
"""
|
||||
Tingle Chests are hidden in dungeons and must be bombed to make them appear. (2 in DRC, 1 each in FW, TotG, ET, and
|
||||
WT). This controls whether they are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Tingle Chests"
|
||||
|
||||
|
||||
class DungeonSecrets(Toggle):
|
||||
"""
|
||||
DRC, FW, TotG, ET, and WT each contain 2-3 secret items (11 in total). This controls whether these are randomized.
|
||||
|
||||
The items are relatively well-hidden (they aren't in chests), so don't select this option unless you're prepared to
|
||||
search each dungeon high and low!
|
||||
"""
|
||||
|
||||
display_name = "Dungeon Secrets"
|
||||
|
||||
|
||||
class PuzzleSecretCaves(DefaultOnToggle):
|
||||
"""
|
||||
This controls whether the rewards from puzzle-focused secret caves are randomized locations.
|
||||
"""
|
||||
|
||||
display_name = "Puzzle Secret Caves"
|
||||
|
||||
|
||||
class CombatSecretCaves(Toggle):
|
||||
"""
|
||||
This controls whether the rewards from combat-focused secret caves (besides Savage Labyrinth) are randomized
|
||||
locations.
|
||||
"""
|
||||
|
||||
display_name = "Combat Secret Caves"
|
||||
|
||||
|
||||
class SavageLabyrinth(Toggle):
|
||||
"""
|
||||
This controls whether the two locations in Savage Labyrinth are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Savage Labyrinth"
|
||||
|
||||
|
||||
class GreatFairies(DefaultOnToggle):
|
||||
"""
|
||||
This controls whether the items given by Great Fairies are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Great Fairies"
|
||||
|
||||
|
||||
class ShortSidequests(Toggle):
|
||||
"""
|
||||
This controls whether sidequests that can be completed quickly are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Short Sidequests"
|
||||
|
||||
|
||||
class LongSidequests(Toggle):
|
||||
"""
|
||||
This controls whether long sidequests (e.g., Lenzo's assistant, withered trees, goron trading) are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Long Sidequests"
|
||||
|
||||
|
||||
class SpoilsTrading(Toggle):
|
||||
"""
|
||||
This controls whether the items you get by trading in spoils to NPCs are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Spoils Trading"
|
||||
|
||||
|
||||
class Minigames(Toggle):
|
||||
"""
|
||||
This controls whether most minigames are randomized (auctions, mail sorting, barrel shooting, bird-man contest).
|
||||
"""
|
||||
|
||||
display_name = "Minigames"
|
||||
|
||||
|
||||
class Battlesquid(Toggle):
|
||||
"""
|
||||
This controls whether the Windfall battleship minigame is randomized.
|
||||
"""
|
||||
|
||||
display_name = "Battlesquid Minigame"
|
||||
|
||||
|
||||
class FreeGifts(DefaultOnToggle):
|
||||
"""
|
||||
This controls whether gifts freely given by NPCs are randomized (Tott, Salvage Corp, imprisoned Tingle).
|
||||
"""
|
||||
|
||||
display_name = "Free Gifts"
|
||||
|
||||
|
||||
class Mail(Toggle):
|
||||
"""
|
||||
This controls whether items received from the mail are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Mail"
|
||||
|
||||
|
||||
class PlatformsRafts(Toggle):
|
||||
"""
|
||||
This controls whether lookout platforms and rafts are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Lookout Platforms and Rafts"
|
||||
|
||||
|
||||
class Submarines(Toggle):
|
||||
"""
|
||||
This controls whether submarines are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Submarines"
|
||||
|
||||
|
||||
class EyeReefChests(Toggle):
|
||||
"""
|
||||
This controls whether the chests that appear after clearing out the eye reefs are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Eye Reef Chests"
|
||||
|
||||
|
||||
class BigOctosGunboats(Toggle):
|
||||
"""
|
||||
This controls whether the items dropped by Big Octos and Gunboats are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Big Octos and Gunboats"
|
||||
|
||||
|
||||
class TriforceCharts(Toggle):
|
||||
"""
|
||||
This controls whether the sunken treasure chests marked on Triforce Charts are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Sunken Treasure (From Triforce Charts)"
|
||||
|
||||
|
||||
class TreasureCharts(Toggle):
|
||||
"""
|
||||
This controls whether the sunken treasure chests marked on Treasure Charts are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Sunken Treasure (From Treasure Charts)"
|
||||
|
||||
|
||||
class ExpensivePurchases(DefaultOnToggle):
|
||||
"""
|
||||
This controls whether items that cost many Rupees are randomized (Rock Spire shop, auctions, Tingle's letter,
|
||||
trading quest).
|
||||
"""
|
||||
|
||||
display_name = "Expensive Purchases"
|
||||
|
||||
|
||||
class IslandPuzzles(Toggle):
|
||||
"""
|
||||
This controls whether various island puzzles are randomized (e.g., chests hidden in unusual places).
|
||||
"""
|
||||
|
||||
display_name = "Island Puzzles"
|
||||
|
||||
|
||||
class Misc(Toggle):
|
||||
"""
|
||||
Miscellaneous locations that don't fit into any of the above categories (outdoors chests, wind shrine, Cyclos, etc).
|
||||
This controls whether these are randomized.
|
||||
"""
|
||||
|
||||
display_name = "Miscellaneous"
|
||||
|
||||
|
||||
class DungeonItem(Choice):
|
||||
"""
|
||||
This is the base class for the shuffle options for dungeon items.
|
||||
"""
|
||||
|
||||
value: int
|
||||
option_startwith = 0
|
||||
option_vanilla = 1
|
||||
option_dungeon = 2
|
||||
option_any_dungeon = 3
|
||||
option_local = 4
|
||||
option_keylunacy = 5
|
||||
default = 2
|
||||
|
||||
@property
|
||||
def in_dungeon(self) -> bool:
|
||||
"""
|
||||
Return whether the item should be shuffled into a dungeon.
|
||||
|
||||
:return: Whether the item is shuffled into a dungeon.
|
||||
"""
|
||||
return self.value in (2, 3)
|
||||
|
||||
|
||||
class RandomizeMapCompass(DungeonItem):
|
||||
"""
|
||||
Controls how dungeon maps and compasses are randomized.
|
||||
|
||||
- **Start With Maps & Compasses:** You will start the game with the dungeon maps and compasses for all dungeons.
|
||||
- **Vanilla Maps & Compasses:** Dungeon maps and compasses will be kept in their vanilla location (non-randomized).
|
||||
- **Own Dungeon Maps & Compasses:** Dungeon maps and compasses will be randomized locally within their own dungeon.
|
||||
- **Any Dungeon Maps & Compasses:** Dungeon maps and compasses will be randomized locally within any dungeon.
|
||||
- **Local Maps & Compasses:** Dungeon maps and compasses will be randomized locally anywhere.
|
||||
- **Key-Lunacy:** Dungeon maps and compasses can be found anywhere, without restriction.
|
||||
"""
|
||||
|
||||
item_name_group = "Dungeon Items"
|
||||
display_name = "Randomize Maps & Compasses"
|
||||
default = 2
|
||||
|
||||
|
||||
class RandomizeSmallKeys(DungeonItem):
|
||||
"""
|
||||
Controls how small keys are randomized.
|
||||
|
||||
- **Start With Small Keys:** You will start the game with the small keys for all dungeons.
|
||||
- **Vanilla Small Keys:** Small keys will be kept in their vanilla location (non-randomized).
|
||||
- **Own Dungeon Small Keys:** Small keys will be randomized locally within their own dungeon.
|
||||
- **Any Dungeon Small Keys:** Small keys will be randomized locally within any dungeon.
|
||||
- **Local Small Keys:** Small keys will be randomized locally anywhere.
|
||||
- **Key-Lunacy:** Small keys can be found in any progression location, if dungeons are randomized.
|
||||
"""
|
||||
|
||||
item_name_group = "Small Keys"
|
||||
display_name = "Randomize Small Keys"
|
||||
default = 2
|
||||
|
||||
|
||||
class RandomizeBigKeys(DungeonItem):
|
||||
"""
|
||||
Controls how big keys are randomized.
|
||||
|
||||
- **Start With Big Keys:** You will start the game with the big keys for all dungeons.
|
||||
- **Vanilla Big Keys:** Big keys will be kept in their vanilla location (non-randomized).
|
||||
- **Own Dungeon Big Keys:** Big keys will be randomized locally within their own dungeon.
|
||||
- **Any Dungeon Big Keys:** Big keys will be randomized locally within any dungeon.
|
||||
- **Local Big Keys:** Big keys will be randomized locally anywhere.
|
||||
- **Key-Lunacy:** Big keys can be found in any progression location, if dungeons are randomized.
|
||||
"""
|
||||
|
||||
item_name_group = "Big Keys"
|
||||
display_name = "Randomize Big Keys"
|
||||
default = 2
|
||||
|
||||
|
||||
class SwordMode(Choice):
|
||||
"""
|
||||
Controls whether you start with the Hero's Sword, the Hero's Sword is randomized, or if there are no swords in the
|
||||
entire game.
|
||||
|
||||
- **Start with Hero's Sword:** You will start the game with the basic Hero's Sword already in your inventory.
|
||||
- **No Starting Sword:** You will start the game with no sword, and have to find it somewhere in the world like
|
||||
other randomized items.
|
||||
- **Swords Optional:** You will start the game with no sword, but they'll still be randomized. However, they are not
|
||||
necessary to beat the game. The Hyrule Barrier will be gone, Phantom Ganon in FF is vulnerable to Skull Hammer,
|
||||
and the logic does not expect you to have a sword.
|
||||
- **Swordless:** You will start the game with no sword, and won't be able to find it anywhere. You have to beat the
|
||||
entire game using other items as weapons instead of the sword. (Note that Phantom Ganon in FF becomes vulnerable
|
||||
to Skull Hammer in this mode.)
|
||||
"""
|
||||
|
||||
display_name = "Sword Mode"
|
||||
option_start_with_sword = 0
|
||||
option_no_starting_sword = 1
|
||||
option_swords_optional = 2
|
||||
option_swordless = 3
|
||||
default = 0
|
||||
|
||||
|
||||
class RequiredBosses(Toggle):
|
||||
"""
|
||||
In this mode, you will not be allowed to beat the game until certain randomly-chosen bosses are defeated. Nothing in
|
||||
dungeons for other bosses will ever be required.
|
||||
|
||||
You can see which islands have the required bosses on them by opening the sea chart and checking which islands have
|
||||
blue quest markers.
|
||||
"""
|
||||
|
||||
display_name = "Required Bosses Mode"
|
||||
|
||||
|
||||
class NumRequiredBosses(Range):
|
||||
"""
|
||||
Select the number of randomly-chosen bosses that are required in Required Bosses Mode.
|
||||
|
||||
The door to Puppet Ganon will not unlock until you've defeated all of these bosses. Nothing in dungeons for other
|
||||
bosses will ever be required.
|
||||
"""
|
||||
|
||||
display_name = "Number of Required Bosses"
|
||||
range_start = 1
|
||||
range_end = 6
|
||||
default = 4
|
||||
|
||||
|
||||
class IncludedDungeons(OptionSet):
|
||||
"""
|
||||
A list of dungeons that should always be included when required bosses mode is on.
|
||||
"""
|
||||
|
||||
display_name = "Included Dungeons"
|
||||
valid_keys = frozenset(DUNGEON_NAMES)
|
||||
|
||||
|
||||
class ExcludedDungeons(OptionSet):
|
||||
"""
|
||||
A list of dungeons that should always be excluded when required bosses mode is on.
|
||||
"""
|
||||
|
||||
display_name = "Excluded Dungeons"
|
||||
valid_keys = frozenset(DUNGEON_NAMES)
|
||||
|
||||
|
||||
class ChestTypeMatchesContents(Toggle):
|
||||
"""
|
||||
Changes the chest type to reflect its contents. A metal chest has a progress item, a wooden chest has a non-progress
|
||||
item or a consumable, and a green chest has a potentially required dungeon key.
|
||||
"""
|
||||
|
||||
display_name = "Chest Type Matches Contents"
|
||||
|
||||
|
||||
class TrapChests(Toggle):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
Allows the randomizer to place several trapped chests across the game that do not give you items. Perfect for
|
||||
spicing up any run!
|
||||
"""
|
||||
|
||||
display_name = "Enable Trap Chests"
|
||||
|
||||
|
||||
class HeroMode(Toggle):
|
||||
"""
|
||||
In Hero Mode, you take four times more damage than normal and heart refills will not drop.
|
||||
"""
|
||||
|
||||
display_name = "Hero Mode"
|
||||
|
||||
|
||||
class LogicObscurity(Choice):
|
||||
"""
|
||||
Obscure tricks are ways of obtaining items that are not obvious and may involve thinking outside the box.
|
||||
|
||||
This option controls the maximum difficulty of obscure tricks the randomizer will require you to do to beat the
|
||||
game.
|
||||
"""
|
||||
|
||||
display_name = "Obscure Tricks Required"
|
||||
option_none = 0
|
||||
option_normal = 1
|
||||
option_hard = 2
|
||||
option_very_hard = 3
|
||||
default = 0
|
||||
|
||||
|
||||
class LogicPrecision(Choice):
|
||||
"""
|
||||
Precise tricks are ways of obtaining items that involve difficult inputs such as accurate aiming or perfect timing.
|
||||
|
||||
This option controls the maximum difficulty of precise tricks the randomizer will require you to do to beat the
|
||||
game.
|
||||
"""
|
||||
|
||||
display_name = "Precise Tricks Required"
|
||||
option_none = 0
|
||||
option_normal = 1
|
||||
option_hard = 2
|
||||
option_very_hard = 3
|
||||
default = 0
|
||||
|
||||
|
||||
class EnableTunerLogic(Toggle):
|
||||
"""
|
||||
If enabled, the randomizer can logically expect the Tingle Tuner for Tingle Chests.
|
||||
|
||||
The randomizer behavior of logically expecting Bombs/bomb flowers to spawn in Tingle Chests remains unchanged.
|
||||
"""
|
||||
|
||||
display_name = "Enable Tuner Logic"
|
||||
|
||||
|
||||
class RandomizeDungeonEntrances(Toggle):
|
||||
"""
|
||||
Shuffles around which dungeon entrances take you into which dungeons.
|
||||
|
||||
(No effect on Forsaken Fortress or Ganon's Tower.)
|
||||
"""
|
||||
|
||||
display_name = "Randomize Dungeons"
|
||||
|
||||
|
||||
class RandomizeSecretCavesEntrances(Toggle):
|
||||
"""
|
||||
Shuffles around which secret cave entrances take you into which secret caves.
|
||||
"""
|
||||
|
||||
display_name = "Randomize Secret Caves"
|
||||
|
||||
|
||||
class RandomizeMinibossEntrances(Toggle):
|
||||
"""
|
||||
Allows dungeon miniboss doors to act as entrances to be randomized.
|
||||
|
||||
If on with random dungeon entrances, dungeons may nest within each other, forming chains of connected dungeons.
|
||||
"""
|
||||
|
||||
display_name = "Randomize Nested Minibosses"
|
||||
|
||||
|
||||
class RandomizeBossEntrances(Toggle):
|
||||
"""
|
||||
Allows dungeon boss doors to act as entrances to be randomized.
|
||||
|
||||
If on with random dungeon entrances, dungeons may nest within each other, forming chains of connected dungeons.
|
||||
"""
|
||||
|
||||
display_name = "Randomize Nested Bosses"
|
||||
|
||||
|
||||
class RandomizeSecretCaveInnerEntrances(Toggle):
|
||||
"""
|
||||
Allows the pit in Ice Ring Isle's secret cave and the rear exit out of Cliff Plateau Isles' secret cave to act as
|
||||
entrances to be randomized."""
|
||||
|
||||
display_name = "Randomize Inner Secret Caves"
|
||||
|
||||
|
||||
class RandomizeFairyFountainEntrances(Toggle):
|
||||
"""
|
||||
Allows the pits that lead down into Fairy Fountains to act as entrances to be randomized.
|
||||
"""
|
||||
|
||||
display_name = "Randomize Fairy Fountains"
|
||||
|
||||
|
||||
class MixEntrances(Choice):
|
||||
"""
|
||||
Controls how the different types (pools) of randomized entrances should be shuffled.
|
||||
|
||||
- **Separate Pools:** Each pool of randomized entrances will shuffle into itself (e.g., dungeons into dungeons).
|
||||
- **Mix Pools:** All pools of randomized entrances will be combined into one pool to be shuffled.
|
||||
"""
|
||||
|
||||
display_name = "Mix Entrances"
|
||||
option_separate_pools = 0
|
||||
option_mix_pools = 1
|
||||
default = 0
|
||||
|
||||
|
||||
class RandomizeEnemies(Toggle):
|
||||
"""
|
||||
Randomizes the placement of non-boss enemies.
|
||||
|
||||
This option is an *incomplete* option from the base randomizer and **may result in unbeatable seeds! Use at your own
|
||||
risk!**
|
||||
"""
|
||||
|
||||
display_name = "Randomize Enemies"
|
||||
|
||||
|
||||
# class RandomizeMusic(Toggle):
|
||||
# """
|
||||
# Shuffles around all the music in the game. This affects background music, combat music, fanfares, etc.
|
||||
# """
|
||||
|
||||
# display_name = "Randomize Music"
|
||||
|
||||
|
||||
class RandomizeStartingIsland(Toggle):
|
||||
"""
|
||||
Randomizes which island you start the game on.
|
||||
"""
|
||||
|
||||
display_name = "Randomize Starting Island"
|
||||
|
||||
|
||||
class RandomizeCharts(Toggle):
|
||||
"""
|
||||
Randomizes which sector is drawn on each Triforce/Treasure Chart.
|
||||
"""
|
||||
|
||||
display_name = "Randomize Charts"
|
||||
|
||||
|
||||
class HoHoHints(DefaultOnToggle):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
Places hints on Old Man Ho Ho. Old Man Ho Ho appears at 10 different islands in the game. Talk to Old Man Ho Ho to
|
||||
get hints.
|
||||
"""
|
||||
|
||||
display_name = "Place Hints on Old Man Ho Ho"
|
||||
|
||||
|
||||
class FishmenHints(DefaultOnToggle):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
Places hints on the fishmen. There is one fishman at each of the 49 islands of the Great Sea. Each fishman must be
|
||||
fed an All-Purpose Bait before he will give a hint.
|
||||
"""
|
||||
|
||||
display_name = "Place Hints on Fishmen"
|
||||
|
||||
|
||||
class KoRLHints(Toggle):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
Places hints on the King of Red Lions. Talk to the King of Red Lions to get hints.
|
||||
"""
|
||||
|
||||
display_name = "Place Hints on King of Red Lions"
|
||||
|
||||
|
||||
class NumItemHints(Range):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
The number of item hints that will be placed. Item hints tell you which area contains a particular progress item in
|
||||
this seed.
|
||||
|
||||
If multiple hint placement options are selected, the hint count will be split evenly among the placement options.
|
||||
"""
|
||||
|
||||
display_name = "Item Hints"
|
||||
range_start = 0
|
||||
range_end = 15
|
||||
default = 15
|
||||
|
||||
|
||||
class NumLocationHints(Range):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
The number of location hints that will be placed. Location hints tell you what item is at a specific location in
|
||||
this seed.
|
||||
|
||||
If multiple hint placement options are selected, the hint count will be split evenly among the placement options.
|
||||
"""
|
||||
|
||||
display_name = "Location Hints"
|
||||
range_start = 0
|
||||
range_end = 15
|
||||
default = 5
|
||||
|
||||
|
||||
class NumBarrenHints(Range):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
The number of barren hints that will be placed. Barren hints tell you that an area does not contain any required
|
||||
items in this seed.
|
||||
|
||||
If multiple hint placement options are selected, the hint count will be split evenly among the placement options.
|
||||
"""
|
||||
|
||||
display_name = "Barren Hints"
|
||||
range_start = 0
|
||||
range_end = 15
|
||||
default = 0
|
||||
|
||||
|
||||
class NumPathHints(Range):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
The number of path hints that will be placed. Path hints tell you that an area contains an item that is required to
|
||||
reach a particular goal in this seed.
|
||||
|
||||
If multiple hint placement options are selected, the hint count will be split evenly among the placement options.
|
||||
"""
|
||||
|
||||
display_name = "Path Hints"
|
||||
range_start = 0
|
||||
range_end = 15
|
||||
default = 0
|
||||
|
||||
|
||||
class PrioritizeRemoteHints(Toggle):
|
||||
"""
|
||||
**DEV NOTE:** This option is currently unimplemented and will be ignored.
|
||||
|
||||
When this option is selected, certain locations that are out of the way and time-consuming to complete will take
|
||||
precedence over normal location hints."""
|
||||
|
||||
display_name = "Prioritize Remote Location Hints"
|
||||
|
||||
|
||||
class SwiftSail(DefaultOnToggle):
|
||||
"""
|
||||
Sailing speed is doubled and the direction of the wind is always at your back as long as the sail is out.
|
||||
"""
|
||||
|
||||
display_name = "Swift Sail"
|
||||
|
||||
|
||||
class InstantTextBoxes(DefaultOnToggle):
|
||||
"""
|
||||
Text appears instantly. Also, the B button is changed to instantly skip through text as long as you hold it down.
|
||||
"""
|
||||
|
||||
display_name = "Instant Text Boxes"
|
||||
|
||||
|
||||
class RevealFullSeaChart(DefaultOnToggle):
|
||||
"""
|
||||
Start the game with the sea chart fully drawn out.
|
||||
"""
|
||||
|
||||
display_name = "Reveal Full Sea Chart"
|
||||
|
||||
|
||||
class AddShortcutWarpsBetweenDungeons(Toggle):
|
||||
"""
|
||||
Adds new warp pots that act as shortcuts connecting dungeons to each other directly. (DRC, FW, TotG, and separately
|
||||
FF, ET, WT.)
|
||||
|
||||
Each pot must be unlocked before it can be used, so you cannot use them to access dungeons
|
||||
you wouldn't already have access to.
|
||||
"""
|
||||
|
||||
display_name = "Add Shortcut Warps Between Dungeons"
|
||||
|
||||
|
||||
class SkipRematchBosses(DefaultOnToggle):
|
||||
"""
|
||||
Removes the door in Ganon's Tower that only unlocks when you defeat the rematch versions of Gohma, Kalle Demos,
|
||||
Jalhalla, and Molgera.
|
||||
"""
|
||||
|
||||
display_name = "Skip Boss Rematches"
|
||||
|
||||
|
||||
class RemoveMusic(Toggle):
|
||||
"""
|
||||
Mutes all ingame music.
|
||||
"""
|
||||
|
||||
display_name = "Remove Music"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TWWOptions(PerGameCommonOptions):
|
||||
"""
|
||||
A data class that encapsulates all configuration options for The Wind Waker.
|
||||
"""
|
||||
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
progression_dungeons: Dungeons
|
||||
progression_tingle_chests: TingleChests
|
||||
progression_dungeon_secrets: DungeonSecrets
|
||||
progression_puzzle_secret_caves: PuzzleSecretCaves
|
||||
progression_combat_secret_caves: CombatSecretCaves
|
||||
progression_savage_labyrinth: SavageLabyrinth
|
||||
progression_great_fairies: GreatFairies
|
||||
progression_short_sidequests: ShortSidequests
|
||||
progression_long_sidequests: LongSidequests
|
||||
progression_spoils_trading: SpoilsTrading
|
||||
progression_minigames: Minigames
|
||||
progression_battlesquid: Battlesquid
|
||||
progression_free_gifts: FreeGifts
|
||||
progression_mail: Mail
|
||||
progression_platforms_rafts: PlatformsRafts
|
||||
progression_submarines: Submarines
|
||||
progression_eye_reef_chests: EyeReefChests
|
||||
progression_big_octos_gunboats: BigOctosGunboats
|
||||
progression_triforce_charts: TriforceCharts
|
||||
progression_treasure_charts: TreasureCharts
|
||||
progression_expensive_purchases: ExpensivePurchases
|
||||
progression_island_puzzles: IslandPuzzles
|
||||
progression_misc: Misc
|
||||
randomize_mapcompass: RandomizeMapCompass
|
||||
randomize_smallkeys: RandomizeSmallKeys
|
||||
randomize_bigkeys: RandomizeBigKeys
|
||||
sword_mode: SwordMode
|
||||
required_bosses: RequiredBosses
|
||||
num_required_bosses: NumRequiredBosses
|
||||
included_dungeons: IncludedDungeons
|
||||
excluded_dungeons: ExcludedDungeons
|
||||
chest_type_matches_contents: ChestTypeMatchesContents
|
||||
# trap_chests: TrapChests
|
||||
hero_mode: HeroMode
|
||||
logic_obscurity: LogicObscurity
|
||||
logic_precision: LogicPrecision
|
||||
enable_tuner_logic: EnableTunerLogic
|
||||
randomize_dungeon_entrances: RandomizeDungeonEntrances
|
||||
randomize_secret_cave_entrances: RandomizeSecretCavesEntrances
|
||||
randomize_miniboss_entrances: RandomizeMinibossEntrances
|
||||
randomize_boss_entrances: RandomizeBossEntrances
|
||||
randomize_secret_cave_inner_entrances: RandomizeSecretCaveInnerEntrances
|
||||
randomize_fairy_fountain_entrances: RandomizeFairyFountainEntrances
|
||||
mix_entrances: MixEntrances
|
||||
randomize_enemies: RandomizeEnemies
|
||||
# randomize_music: RandomizeMusic
|
||||
randomize_starting_island: RandomizeStartingIsland
|
||||
randomize_charts: RandomizeCharts
|
||||
# hoho_hints: HoHoHints
|
||||
# fishmen_hints: FishmenHints
|
||||
# korl_hints: KoRLHints
|
||||
# num_item_hints: NumItemHints
|
||||
# num_location_hints: NumLocationHints
|
||||
# num_barren_hints: NumBarrenHints
|
||||
# num_path_hints: NumPathHints
|
||||
# prioritize_remote_hints: PrioritizeRemoteHints
|
||||
swift_sail: SwiftSail
|
||||
instant_text_boxes: InstantTextBoxes
|
||||
reveal_full_sea_chart: RevealFullSeaChart
|
||||
add_shortcut_warps_between_dungeons: AddShortcutWarpsBetweenDungeons
|
||||
skip_rematch_bosses: SkipRematchBosses
|
||||
remove_music: RemoveMusic
|
||||
death_link: DeathLink
|
||||
|
||||
|
||||
tww_option_groups: list[OptionGroup] = [
|
||||
OptionGroup(
|
||||
"Progression Locations",
|
||||
[
|
||||
Dungeons,
|
||||
DungeonSecrets,
|
||||
TingleChests,
|
||||
PuzzleSecretCaves,
|
||||
CombatSecretCaves,
|
||||
SavageLabyrinth,
|
||||
IslandPuzzles,
|
||||
GreatFairies,
|
||||
Submarines,
|
||||
PlatformsRafts,
|
||||
ShortSidequests,
|
||||
LongSidequests,
|
||||
SpoilsTrading,
|
||||
EyeReefChests,
|
||||
BigOctosGunboats,
|
||||
Misc,
|
||||
Minigames,
|
||||
Battlesquid,
|
||||
FreeGifts,
|
||||
Mail,
|
||||
ExpensivePurchases,
|
||||
TriforceCharts,
|
||||
TreasureCharts,
|
||||
],
|
||||
),
|
||||
OptionGroup(
|
||||
"Item Randomizer Modes",
|
||||
[
|
||||
SwordMode,
|
||||
RandomizeMapCompass,
|
||||
RandomizeSmallKeys,
|
||||
RandomizeBigKeys,
|
||||
ChestTypeMatchesContents,
|
||||
# TrapChests,
|
||||
],
|
||||
),
|
||||
OptionGroup(
|
||||
"Entrance Randomizer Options",
|
||||
[
|
||||
RandomizeDungeonEntrances,
|
||||
RandomizeBossEntrances,
|
||||
RandomizeMinibossEntrances,
|
||||
RandomizeSecretCavesEntrances,
|
||||
RandomizeSecretCaveInnerEntrances,
|
||||
RandomizeFairyFountainEntrances,
|
||||
MixEntrances,
|
||||
],
|
||||
),
|
||||
OptionGroup(
|
||||
"Other Randomizers",
|
||||
[
|
||||
RandomizeStartingIsland,
|
||||
RandomizeCharts,
|
||||
# RandomizeMusic,
|
||||
],
|
||||
),
|
||||
OptionGroup(
|
||||
"Convenience Tweaks",
|
||||
[
|
||||
SwiftSail,
|
||||
InstantTextBoxes,
|
||||
RevealFullSeaChart,
|
||||
SkipRematchBosses,
|
||||
AddShortcutWarpsBetweenDungeons,
|
||||
RemoveMusic,
|
||||
],
|
||||
),
|
||||
OptionGroup(
|
||||
"Required Bosses",
|
||||
[
|
||||
RequiredBosses,
|
||||
NumRequiredBosses,
|
||||
IncludedDungeons,
|
||||
ExcludedDungeons,
|
||||
],
|
||||
start_collapsed=True,
|
||||
),
|
||||
OptionGroup(
|
||||
"Difficulty Options",
|
||||
[
|
||||
HeroMode,
|
||||
LogicObscurity,
|
||||
LogicPrecision,
|
||||
EnableTunerLogic,
|
||||
],
|
||||
start_collapsed=True,
|
||||
),
|
||||
OptionGroup(
|
||||
"Work-in-Progress Options",
|
||||
[
|
||||
RandomizeEnemies,
|
||||
],
|
||||
start_collapsed=True,
|
||||
),
|
||||
]
|
138
worlds/tww/Presets.py
Normal file
138
worlds/tww/Presets.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from typing import Any
|
||||
|
||||
tww_options_presets: dict[str, dict[str, Any]] = {
|
||||
"Tournament S7": {
|
||||
"progression_dungeon_secrets": True,
|
||||
"progression_combat_secret_caves": True,
|
||||
"progression_short_sidequests": True,
|
||||
"progression_spoils_trading": True,
|
||||
"progression_big_octos_gunboats": True,
|
||||
"progression_mail": True,
|
||||
"progression_island_puzzles": True,
|
||||
"progression_misc": True,
|
||||
"randomize_mapcompass": "startwith",
|
||||
"required_bosses": True,
|
||||
"num_required_bosses": 3,
|
||||
"chest_type_matches_contents": True,
|
||||
"logic_obscurity": "hard",
|
||||
"randomize_starting_island": True,
|
||||
"add_shortcut_warps_between_dungeons": True,
|
||||
"start_inventory_from_pool": {
|
||||
"Telescope": 1,
|
||||
"Wind Waker": 1,
|
||||
"Goddess Tingle Statue": 1,
|
||||
"Earth Tingle Statue": 1,
|
||||
"Wind Tingle Statue": 1,
|
||||
"Wind's Requiem": 1,
|
||||
"Ballad of Gales": 1,
|
||||
"Earth God's Lyric": 1,
|
||||
"Wind God's Aria": 1,
|
||||
"Song of Passing": 1,
|
||||
"Progressive Magic Meter": 2,
|
||||
},
|
||||
"start_location_hints": ["Ganon's Tower - Maze Chest"],
|
||||
"exclude_locations": [
|
||||
"Outset Island - Orca - Give 10 Knight's Crests",
|
||||
"Outset Island - Great Fairy",
|
||||
"Windfall Island - Chu Jelly Juice Shop - Give 15 Green Chu Jelly",
|
||||
"Windfall Island - Mrs. Marie - Give 21 Joy Pendants",
|
||||
"Windfall Island - Mrs. Marie - Give 40 Joy Pendants",
|
||||
"Windfall Island - Maggie's Father - Give 20 Skull Necklaces",
|
||||
"Dragon Roost Island - Rito Aerie - Give Hoskit 20 Golden Feathers",
|
||||
"Fire Mountain - Big Octo",
|
||||
"Mailbox - Letter from Hoskit's Girlfriend",
|
||||
"Private Oasis - Big Octo",
|
||||
"Stone Watcher Island - Cave",
|
||||
"Overlook Island - Cave",
|
||||
"Thorned Fairy Island - Great Fairy",
|
||||
"Eastern Fairy Island - Great Fairy",
|
||||
"Western Fairy Island - Great Fairy",
|
||||
"Southern Fairy Island - Great Fairy",
|
||||
"Northern Fairy Island - Great Fairy",
|
||||
"Tingle Island - Big Octo",
|
||||
"Diamond Steppe Island - Big Octo",
|
||||
"Rock Spire Isle - Beedle's Special Shop Ship - 500 Rupee Item",
|
||||
"Rock Spire Isle - Beedle's Special Shop Ship - 950 Rupee Item",
|
||||
"Rock Spire Isle - Beedle's Special Shop Ship - 900 Rupee Item",
|
||||
"Shark Island - Cave",
|
||||
"Seven-Star Isles - Big Octo",
|
||||
],
|
||||
},
|
||||
"Miniblins 2025": {
|
||||
"progression_great_fairies": False,
|
||||
"progression_short_sidequests": True,
|
||||
"progression_mail": True,
|
||||
"progression_expensive_purchases": False,
|
||||
"progression_island_puzzles": True,
|
||||
"progression_misc": True,
|
||||
"randomize_mapcompass": "startwith",
|
||||
"required_bosses": True,
|
||||
"num_required_bosses": 2,
|
||||
"chest_type_matches_contents": True,
|
||||
"randomize_starting_island": True,
|
||||
"add_shortcut_warps_between_dungeons": True,
|
||||
"start_inventory_from_pool": {
|
||||
"Telescope": 1,
|
||||
"Wind Waker": 1,
|
||||
"Wind's Requiem": 1,
|
||||
"Ballad of Gales": 1,
|
||||
"Command Melody": 1,
|
||||
"Earth God's Lyric": 1,
|
||||
"Wind God's Aria": 1,
|
||||
"Song of Passing": 1,
|
||||
"Nayru's Pearl": 1,
|
||||
"Din's Pearl": 1,
|
||||
"Progressive Shield": 1,
|
||||
"Progressive Magic Meter": 2,
|
||||
"Quiver Capacity Upgrade": 1,
|
||||
"Bomb Bag Capacity Upgrade": 1,
|
||||
"Piece of Heart": 12,
|
||||
},
|
||||
"start_location_hints": ["Ganon's Tower - Maze Chest"],
|
||||
"exclude_locations": [
|
||||
"Outset Island - Jabun's Cave",
|
||||
"Windfall Island - Jail - Tingle - First Gift",
|
||||
"Windfall Island - Jail - Tingle - Second Gift",
|
||||
"Windfall Island - Jail - Maze Chest",
|
||||
"Windfall Island - Maggie - Delivery Reward",
|
||||
"Windfall Island - Cafe Bar - Postman",
|
||||
"Windfall Island - Zunari - Stock Exotic Flower in Zunari's Shop",
|
||||
"Tingle Island - Ankle - Reward for All Tingle Statues",
|
||||
"Horseshoe Island - Play Golf",
|
||||
],
|
||||
},
|
||||
"Mixed Pools": {
|
||||
"progression_tingle_chests": True,
|
||||
"progression_dungeon_secrets": True,
|
||||
"progression_combat_secret_caves": True,
|
||||
"progression_short_sidequests": True,
|
||||
"progression_mail": True,
|
||||
"progression_submarines": True,
|
||||
"progression_expensive_purchases": False,
|
||||
"progression_island_puzzles": True,
|
||||
"progression_misc": True,
|
||||
"randomize_mapcompass": "startwith",
|
||||
"required_bosses": True,
|
||||
"num_required_bosses": 6,
|
||||
"chest_type_matches_contents": True,
|
||||
"randomize_dungeon_entrances": True,
|
||||
"randomize_secret_cave_entrances": True,
|
||||
"randomize_miniboss_entrances": True,
|
||||
"randomize_boss_entrances": True,
|
||||
"randomize_secret_cave_inner_entrances": True,
|
||||
"randomize_fairy_fountain_entrances": True,
|
||||
"mix_entrances": "mix_pools",
|
||||
"randomize_starting_island": True,
|
||||
"add_shortcut_warps_between_dungeons": True,
|
||||
"start_inventory_from_pool": {
|
||||
"Telescope": 1,
|
||||
"Wind Waker": 1,
|
||||
"Wind's Requiem": 1,
|
||||
"Ballad of Gales": 1,
|
||||
"Earth God's Lyric": 1,
|
||||
"Wind God's Aria": 1,
|
||||
"Song of Passing": 1,
|
||||
},
|
||||
"start_location_hints": ["Ganon's Tower - Maze Chest", "Shark Island - Cave"],
|
||||
},
|
||||
}
|
1414
worlds/tww/Rules.py
Normal file
1414
worlds/tww/Rules.py
Normal file
File diff suppressed because it is too large
Load Diff
739
worlds/tww/TWWClient.py
Normal file
739
worlds/tww/TWWClient.py
Normal file
@@ -0,0 +1,739 @@
|
||||
import asyncio
|
||||
import time
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
import dolphin_memory_engine
|
||||
|
||||
import Utils
|
||||
from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, gui_enabled, logger, server_loop
|
||||
from NetUtils import ClientStatus
|
||||
|
||||
from .Items import ITEM_TABLE, LOOKUP_ID_TO_NAME
|
||||
from .Locations import ISLAND_NAME_TO_SALVAGE_BIT, LOCATION_TABLE, TWWLocation, TWWLocationData, TWWLocationType
|
||||
from .randomizers.Charts import ISLAND_NUMBER_TO_NAME
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import kvui
|
||||
|
||||
CONNECTION_REFUSED_GAME_STATUS = (
|
||||
"Dolphin failed to connect. Please load a randomized ROM for The Wind Waker. Trying again in 5 seconds..."
|
||||
)
|
||||
CONNECTION_REFUSED_SAVE_STATUS = (
|
||||
"Dolphin failed to connect. Please load into the save file. Trying again in 5 seconds..."
|
||||
)
|
||||
CONNECTION_LOST_STATUS = (
|
||||
"Dolphin connection was lost. Please restart your emulator and make sure The Wind Waker is running."
|
||||
)
|
||||
CONNECTION_CONNECTED_STATUS = "Dolphin connected successfully."
|
||||
CONNECTION_INITIAL_STATUS = "Dolphin connection has not been initiated."
|
||||
|
||||
|
||||
# This address is used to check/set the player's health for DeathLink.
|
||||
CURR_HEALTH_ADDR = 0x803C4C0A
|
||||
|
||||
# These addresses are used for the Moblin's Letter check.
|
||||
LETTER_BASE_ADDR = 0x803C4C8E
|
||||
LETTER_OWND_ADDR = 0x803C4C98
|
||||
|
||||
# These addresses are used to check flags for locations.
|
||||
CHARTS_BITFLD_ADDR = 0x803C4CFC
|
||||
BASE_CHESTS_BITFLD_ADDR = 0x803C4F88
|
||||
BASE_SWITCHES_BITFLD_ADDR = 0x803C4F8C
|
||||
BASE_PICKUPS_BITFLD_ADDR = 0x803C4F9C
|
||||
CURR_STAGE_CHESTS_BITFLD_ADDR = 0x803C5380
|
||||
CURR_STAGE_SWITCHES_BITFLD_ADDR = 0x803C5384
|
||||
CURR_STAGE_PICKUPS_BITFLD_ADDR = 0x803C5394
|
||||
|
||||
# The expected index for the following item that should be received. Uses event bits 0x60 and 0x61.
|
||||
EXPECTED_INDEX_ADDR = 0x803C528C
|
||||
|
||||
# These bytes contain whether the player has been rewarded for finding a particular Tingle statue.
|
||||
TINGLE_STATUE_1_ADDR = 0x803C523E # 0x40 is the bit for the Dragon Tingle statue.
|
||||
TINGLE_STATUE_2_ADDR = 0x803C5249 # 0x0F are the bits for the remaining Tingle statues.
|
||||
|
||||
# This address contains the current stage ID.
|
||||
CURR_STAGE_ID_ADDR = 0x803C53A4
|
||||
|
||||
# This address is used to check the stage name to verify that the player is in-game before sending items.
|
||||
CURR_STAGE_NAME_ADDR = 0x803C9D3C
|
||||
|
||||
# This is an array of length 0x10 where each element is a byte and contains item IDs for items to give the player.
|
||||
# 0xFF represents no item. The array is read and cleared every frame.
|
||||
GIVE_ITEM_ARRAY_ADDR = 0x803FE87C
|
||||
|
||||
# This is the address that holds the player's slot name.
|
||||
# This way, the player does not have to manually authenticate their slot name.
|
||||
SLOT_NAME_ADDR = 0x803FE8A0
|
||||
|
||||
# This address is the start of an array that we use to inform us of which charts lead where.
|
||||
# The array is of length 49, and each element is two bytes. The index represents the chart's original destination, and
|
||||
# the value represents the new destination.
|
||||
# The chart name is inferrable from the chart's original destination.
|
||||
CHARTS_MAPPING_ADDR = 0x803FE8E0
|
||||
|
||||
# This address contains the most recent spawn ID from which the player spawned.
|
||||
MOST_RECENT_SPAWN_ID_ADDR = 0x803C9D44
|
||||
|
||||
# This address contains the most recent room number the player spawned in.
|
||||
MOST_RECENT_ROOM_NUMBER_ADDR = 0x803C9D46
|
||||
|
||||
# Values used to detect exiting onto the highest isle in Cliff Plateau Isles.
|
||||
# 42. Starting at 1 and going left to right, top to bottom, Cliff Plateau Isles is the 42nd square in the sea stage.
|
||||
CLIFF_PLATEAU_ISLES_ROOM_NUMBER = 0x2A
|
||||
CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_SPAWN_ID = 1 # As a note, the lower isle's spawn ID is 2.
|
||||
# The dummy stage name used to identify the highest isle in Cliff Plateau Isles.
|
||||
CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_DUMMY_STAGE_NAME = "CliPlaH"
|
||||
|
||||
# Data storage key
|
||||
AP_VISITED_STAGE_NAMES_KEY_FORMAT = "tww_visited_stages_%i"
|
||||
|
||||
|
||||
class TWWCommandProcessor(ClientCommandProcessor):
|
||||
"""
|
||||
Command Processor for The Wind Waker client commands.
|
||||
|
||||
This class handles commands specific to The Wind Waker.
|
||||
"""
|
||||
|
||||
def __init__(self, ctx: CommonContext):
|
||||
"""
|
||||
Initialize the command processor with the provided context.
|
||||
|
||||
:param ctx: Context for the client.
|
||||
"""
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_dolphin(self) -> None:
|
||||
"""
|
||||
Display the current Dolphin emulator connection status.
|
||||
"""
|
||||
if isinstance(self.ctx, TWWContext):
|
||||
logger.info(f"Dolphin Status: {self.ctx.dolphin_status}")
|
||||
|
||||
|
||||
class TWWContext(CommonContext):
|
||||
"""
|
||||
The context for The Wind Waker client.
|
||||
|
||||
This class manages all interactions with the Dolphin emulator and the Archipelago server for The Wind Waker.
|
||||
"""
|
||||
|
||||
command_processor = TWWCommandProcessor
|
||||
game: str = "The Wind Waker"
|
||||
items_handling: int = 0b111
|
||||
|
||||
def __init__(self, server_address: Optional[str], password: Optional[str]) -> None:
|
||||
"""
|
||||
Initialize the TWW context.
|
||||
|
||||
:param server_address: Address of the Archipelago server.
|
||||
:param password: Password for server authentication.
|
||||
"""
|
||||
|
||||
super().__init__(server_address, password)
|
||||
self.dolphin_sync_task: Optional[asyncio.Task[None]] = None
|
||||
self.dolphin_status: str = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom: bool = False
|
||||
self.has_send_death: bool = False
|
||||
|
||||
# Bitfields used for checking locations.
|
||||
self.charts_bitfield: int
|
||||
self.chests_bitfields: dict[int, int]
|
||||
self.switches_bitfields: dict[int, int]
|
||||
self.pickups_bitfields: dict[int, int]
|
||||
self.curr_stage_chests_bitfield: int
|
||||
self.curr_stage_switches_bitfield: int
|
||||
self.curr_stage_pickups_bitfield: int
|
||||
|
||||
# Keep track of whether the player has yet received their first progressive magic meter.
|
||||
self.received_magic: bool = False
|
||||
|
||||
# A dictionary that maps salvage locations to their sunken treasure bit.
|
||||
self.salvage_locations_map: dict[str, int] = {}
|
||||
|
||||
# Name of the current stage as read from the game's memory. Sent to trackers whenever its value changes to
|
||||
# facilitate automatically switching to the map of the current stage.
|
||||
self.current_stage_name: str = ""
|
||||
|
||||
# Set of visited stages. A dictionary (used as a set) of all visited stages is set in the server's data storage
|
||||
# and updated when the player visits a new stage for the first time. To track which stages are new and need to
|
||||
# cause the server's data storage to update, the TWW AP Client keeps track of the visited stages in a set.
|
||||
# Trackers can request the dictionary from data storage to see which stages the player has visited.
|
||||
# It starts as `None` until it has been read from the server.
|
||||
self.visited_stage_names: Optional[set[str]] = None
|
||||
|
||||
# Length of the item get array in memory.
|
||||
self.len_give_item_array: int = 0x10
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False) -> None:
|
||||
"""
|
||||
Disconnect the client from the server and reset game state variables.
|
||||
|
||||
:param allow_autoreconnect: Allow the client to auto-reconnect to the server. Defaults to `False`.
|
||||
|
||||
"""
|
||||
self.auth = None
|
||||
self.salvage_locations_map = {}
|
||||
self.current_stage_name = ""
|
||||
self.visited_stage_names = None
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
async def server_auth(self, password_requested: bool = False) -> None:
|
||||
"""
|
||||
Authenticate with the Archipelago server.
|
||||
|
||||
:param password_requested: Whether the server requires a password. Defaults to `False`.
|
||||
"""
|
||||
if password_requested and not self.password:
|
||||
await super().server_auth(password_requested)
|
||||
if not self.auth:
|
||||
if self.awaiting_rom:
|
||||
return
|
||||
self.awaiting_rom = True
|
||||
logger.info("Awaiting connection to Dolphin to get player information.")
|
||||
return
|
||||
await self.send_connect()
|
||||
|
||||
def on_package(self, cmd: str, args: dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle incoming packages from the server.
|
||||
|
||||
:param cmd: The command received from the server.
|
||||
:param args: The command arguments.
|
||||
"""
|
||||
if cmd == "Connected":
|
||||
self.update_salvage_locations_map()
|
||||
if "death_link" in args["slot_data"]:
|
||||
Utils.async_start(self.update_death_link(bool(args["slot_data"]["death_link"])))
|
||||
# Request the connected slot's dictionary (used as a set) of visited stages.
|
||||
visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot
|
||||
Utils.async_start(self.send_msgs([{"cmd": "Get", "keys": [visited_stages_key]}]))
|
||||
elif cmd == "Retrieved":
|
||||
requested_keys_dict = args["keys"]
|
||||
# Read the connected slot's dictionary (used as a set) of visited stages.
|
||||
if self.slot is not None:
|
||||
visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot
|
||||
if visited_stages_key in requested_keys_dict:
|
||||
visited_stages = requested_keys_dict[visited_stages_key]
|
||||
# If it has not been set before, the value in the response will be `None`.
|
||||
visited_stage_names = set() if visited_stages is None else set(visited_stages.keys())
|
||||
# If the current stage name is not in the set, send a message to update the dictionary on the
|
||||
# server.
|
||||
current_stage_name = self.current_stage_name
|
||||
if current_stage_name and current_stage_name not in visited_stage_names:
|
||||
visited_stage_names.add(current_stage_name)
|
||||
Utils.async_start(self.update_visited_stages(current_stage_name))
|
||||
self.visited_stage_names = visited_stage_names
|
||||
|
||||
def on_deathlink(self, data: dict[str, Any]) -> None:
|
||||
"""
|
||||
Handle a DeathLink event.
|
||||
|
||||
:param data: The data associated with the DeathLink event.
|
||||
"""
|
||||
super().on_deathlink(data)
|
||||
_give_death(self)
|
||||
|
||||
def make_gui(self) -> type["kvui.GameManager"]:
|
||||
"""
|
||||
Initialize the GUI for The Wind Waker client.
|
||||
|
||||
:return: The client's GUI.
|
||||
"""
|
||||
ui = super().make_gui()
|
||||
ui.base_title = "Archipelago The Wind Waker Client"
|
||||
return ui
|
||||
|
||||
async def update_visited_stages(self, newly_visited_stage_name: str) -> None:
|
||||
"""
|
||||
Update the server's data storage of the visited stage names to include the newly visited stage name.
|
||||
|
||||
:param newly_visited_stage_name: The name of the stage recently visited.
|
||||
"""
|
||||
if self.slot is not None:
|
||||
visited_stages_key = AP_VISITED_STAGE_NAMES_KEY_FORMAT % self.slot
|
||||
await self.send_msgs(
|
||||
[
|
||||
{
|
||||
"cmd": "Set",
|
||||
"key": visited_stages_key,
|
||||
"default": {},
|
||||
"want_reply": False,
|
||||
"operations": [{"operation": "update", "value": {newly_visited_stage_name: True}}],
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def update_salvage_locations_map(self) -> None:
|
||||
"""
|
||||
Update the client's mapping of salvage locations to their bitfield bit.
|
||||
|
||||
This is necessary for the client to handle randomized charts correctly.
|
||||
"""
|
||||
self.salvage_locations_map = {}
|
||||
for offset in range(49):
|
||||
island_name = ISLAND_NUMBER_TO_NAME[offset + 1]
|
||||
salvage_bit = ISLAND_NAME_TO_SALVAGE_BIT[island_name]
|
||||
|
||||
shuffled_island_number = read_short(CHARTS_MAPPING_ADDR + offset * 2)
|
||||
shuffled_island_name = ISLAND_NUMBER_TO_NAME[shuffled_island_number]
|
||||
salvage_location_name = f"{shuffled_island_name} - Sunken Treasure"
|
||||
|
||||
self.salvage_locations_map[salvage_location_name] = salvage_bit
|
||||
|
||||
|
||||
def read_short(console_address: int) -> int:
|
||||
"""
|
||||
Read a 2-byte short from Dolphin memory.
|
||||
|
||||
:param console_address: Address to read from.
|
||||
:return: The value read from memory.
|
||||
"""
|
||||
return int.from_bytes(dolphin_memory_engine.read_bytes(console_address, 2), byteorder="big")
|
||||
|
||||
|
||||
def write_short(console_address: int, value: int) -> None:
|
||||
"""
|
||||
Write a 2-byte short to Dolphin memory.
|
||||
|
||||
:param console_address: Address to write to.
|
||||
:param value: Value to write.
|
||||
"""
|
||||
dolphin_memory_engine.write_bytes(console_address, value.to_bytes(2, byteorder="big"))
|
||||
|
||||
|
||||
def read_string(console_address: int, strlen: int) -> str:
|
||||
"""
|
||||
Read a string from Dolphin memory.
|
||||
|
||||
:param console_address: Address to start reading from.
|
||||
:param strlen: Length of the string to read.
|
||||
:return: The string.
|
||||
"""
|
||||
return dolphin_memory_engine.read_bytes(console_address, strlen).split(b"\0", 1)[0].decode()
|
||||
|
||||
|
||||
def _give_death(ctx: TWWContext) -> None:
|
||||
"""
|
||||
Trigger the player's death in-game by setting their current health to zero.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
"""
|
||||
if (
|
||||
ctx.slot is not None
|
||||
and dolphin_memory_engine.is_hooked()
|
||||
and ctx.dolphin_status == CONNECTION_CONNECTED_STATUS
|
||||
and check_ingame()
|
||||
):
|
||||
ctx.has_send_death = True
|
||||
write_short(CURR_HEALTH_ADDR, 0)
|
||||
|
||||
|
||||
def _give_item(ctx: TWWContext, item_name: str) -> bool:
|
||||
"""
|
||||
Give an item to the player in-game.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
:param item_name: Name of the item to give.
|
||||
:return: Whether the item was successfully given.
|
||||
"""
|
||||
if not check_ingame() or dolphin_memory_engine.read_byte(CURR_STAGE_ID_ADDR) == 0xFF:
|
||||
return False
|
||||
|
||||
item_id = ITEM_TABLE[item_name].item_id
|
||||
|
||||
# Loop through the item array, placing the item in an empty slot.
|
||||
for idx in range(ctx.len_give_item_array):
|
||||
slot = dolphin_memory_engine.read_byte(GIVE_ITEM_ARRAY_ADDR + idx)
|
||||
if slot == 0xFF:
|
||||
# Special case: Use a different item ID for the second progressive magic meter.
|
||||
if item_name == "Progressive Magic Meter":
|
||||
if ctx.received_magic:
|
||||
item_id = 0xB2
|
||||
else:
|
||||
ctx.received_magic = True
|
||||
dolphin_memory_engine.write_byte(GIVE_ITEM_ARRAY_ADDR + idx, item_id)
|
||||
return True
|
||||
|
||||
# If unable to place the item in the array, return `False`.
|
||||
return False
|
||||
|
||||
|
||||
async def give_items(ctx: TWWContext) -> None:
|
||||
"""
|
||||
Give the player all outstanding items they have yet to receive.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
"""
|
||||
if check_ingame() and dolphin_memory_engine.read_byte(CURR_STAGE_ID_ADDR) != 0xFF:
|
||||
# Read the expected index of the player, which is the index of the next item they're expecting to receive.
|
||||
# The expected index starts at 0 for a fresh save file.
|
||||
expected_idx = read_short(EXPECTED_INDEX_ADDR)
|
||||
|
||||
# Check if there are new items.
|
||||
received_items = ctx.items_received
|
||||
if len(received_items) <= expected_idx:
|
||||
# There are no new items.
|
||||
return
|
||||
|
||||
# Loop through items to give.
|
||||
# Give the player all items at an index greater than or equal to the expected index.
|
||||
for idx, item in enumerate(received_items[expected_idx:], start=expected_idx):
|
||||
# Attempt to give the item and increment the expected index.
|
||||
while not _give_item(ctx, LOOKUP_ID_TO_NAME[item.item]):
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# Increment the expected index.
|
||||
write_short(EXPECTED_INDEX_ADDR, idx + 1)
|
||||
|
||||
|
||||
def check_special_location(location_name: str, data: TWWLocationData) -> bool:
|
||||
"""
|
||||
Check that the player has checked a given location.
|
||||
This function handles locations that require special logic.
|
||||
|
||||
:param location_name: The name of the location.
|
||||
:param data: The data associated with the location.
|
||||
:raises NotImplementedError: If an unknown location name is provided.
|
||||
"""
|
||||
checked = False
|
||||
|
||||
# For "Windfall Island - Lenzo's House - Become Lenzo's Assistant"
|
||||
# 0x6 is delivered the final picture for Lenzo, 0x7 is a day has passed since becoming his assistant
|
||||
# Either is fine for sending the check, so check both conditions.
|
||||
if location_name == "Windfall Island - Lenzo's House - Become Lenzo's Assistant":
|
||||
checked = (
|
||||
dolphin_memory_engine.read_byte(data.address) & 0x6 == 0x6
|
||||
or dolphin_memory_engine.read_byte(data.address) & 0x7 == 0x7
|
||||
)
|
||||
|
||||
# The "Windfall Island - Maggie - Delivery Reward" flag remains unknown.
|
||||
# However, as a temporary workaround, we can check if the player had Moblin's letter at some point, but it's no
|
||||
# longer in their Delivery Bag.
|
||||
elif location_name == "Windfall Island - Maggie - Delivery Reward":
|
||||
was_moblins_owned = (dolphin_memory_engine.read_word(LETTER_OWND_ADDR) >> 15) & 1
|
||||
dbag_contents = [dolphin_memory_engine.read_byte(LETTER_BASE_ADDR + offset) for offset in range(8)]
|
||||
checked = was_moblins_owned and 0x9B not in dbag_contents
|
||||
|
||||
# For Letter from Hoskit's Girlfriend, we need to check two bytes.
|
||||
# 0x1 = Golden Feathers delivered, 0x2 = Mail sent by Hoskit's Girlfriend, 0x3 = Mail read by Link
|
||||
elif location_name == "Mailbox - Letter from Hoskit's Girlfriend":
|
||||
checked = dolphin_memory_engine.read_byte(data.address) & 0x3 == 0x3
|
||||
|
||||
# For Letter from Baito's Mother, we need to check two bytes.
|
||||
# 0x1 = Note to Mom sent, 0x2 = Mail sent by Baito's Mother, 0x3 = Mail read by Link
|
||||
elif location_name == "Mailbox - Letter from Baito's Mother":
|
||||
checked = dolphin_memory_engine.read_byte(data.address) & 0x3 == 0x3
|
||||
|
||||
# For Letter from Grandma, we need to check two bytes.
|
||||
# 0x1 = Grandma saved, 0x2 = Mail sent by Grandma, 0x3 = Mail read by Link
|
||||
elif location_name == "Mailbox - Letter from Grandma":
|
||||
checked = dolphin_memory_engine.read_byte(data.address) & 0x3 == 0x3
|
||||
|
||||
# We check if the bits for turning all five statues are set for the Ankle's reward.
|
||||
# For some reason, the bit for the Dragon Tingle Statue is separate from the rest.
|
||||
elif location_name == "Tingle Island - Ankle - Reward for All Tingle Statues":
|
||||
dragon_tingle_statue_rewarded = dolphin_memory_engine.read_byte(TINGLE_STATUE_1_ADDR) & 0x40 == 0x40
|
||||
other_tingle_statues_rewarded = dolphin_memory_engine.read_byte(TINGLE_STATUE_2_ADDR) & 0x0F == 0x0F
|
||||
checked = dragon_tingle_statue_rewarded and other_tingle_statues_rewarded
|
||||
|
||||
else:
|
||||
raise NotImplementedError(f"Unknown special location: {location_name}")
|
||||
|
||||
return checked
|
||||
|
||||
|
||||
def check_regular_location(ctx: TWWContext, curr_stage_id: int, data: TWWLocationData) -> bool:
|
||||
"""
|
||||
Check that the player has checked a given location.
|
||||
This function handles locations that only require checking that a particular bit is set.
|
||||
|
||||
The check looks at the saved data for the stage at which the location is located and the data for the current stage.
|
||||
In the latter case, this data includes data that has not yet been written to the saved data.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
:param curr_stage_id: The current stage at which the player is.
|
||||
:param data: The data associated with the location.
|
||||
:raises NotImplementedError: If a location with an unknown type is provided.
|
||||
"""
|
||||
checked = False
|
||||
|
||||
# Check the saved bitfields for the stage.
|
||||
if data.type == TWWLocationType.CHEST:
|
||||
checked = bool((ctx.chests_bitfields[data.stage_id] >> data.bit) & 1)
|
||||
elif data.type == TWWLocationType.SWTCH:
|
||||
checked = bool((ctx.switches_bitfields[data.stage_id] >> data.bit) & 1)
|
||||
elif data.type == TWWLocationType.PCKUP:
|
||||
checked = bool((ctx.pickups_bitfields[data.stage_id] >> data.bit) & 1)
|
||||
else:
|
||||
raise NotImplementedError(f"Unknown location type: {data.type}")
|
||||
|
||||
# If the location is in the current stage, check the bitfields for the current stage as well.
|
||||
if not checked and curr_stage_id == data.stage_id:
|
||||
if data.type == TWWLocationType.CHEST:
|
||||
checked = bool((ctx.curr_stage_chests_bitfield >> data.bit) & 1)
|
||||
elif data.type == TWWLocationType.SWTCH:
|
||||
checked = bool((ctx.curr_stage_switches_bitfield >> data.bit) & 1)
|
||||
elif data.type == TWWLocationType.PCKUP:
|
||||
checked = bool((ctx.curr_stage_pickups_bitfield >> data.bit) & 1)
|
||||
else:
|
||||
raise NotImplementedError(f"Unknown location type: {data.type}")
|
||||
|
||||
return checked
|
||||
|
||||
|
||||
async def check_locations(ctx: TWWContext) -> None:
|
||||
"""
|
||||
Iterate through all locations and check whether the player has checked each location.
|
||||
|
||||
Update the server with all newly checked locations since the last update. If the player has completed the goal,
|
||||
notify the server.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
"""
|
||||
# Read the bitfield for sunken treasure locations.
|
||||
ctx.charts_bitfield = int.from_bytes(dolphin_memory_engine.read_bytes(CHARTS_BITFLD_ADDR, 8), byteorder="big")
|
||||
|
||||
# Read the bitfields once before the loop to speed things up a bit.
|
||||
ctx.chests_bitfields = {}
|
||||
ctx.switches_bitfields = {}
|
||||
ctx.pickups_bitfields = {}
|
||||
for stage_id in range(0xE):
|
||||
chest_bitfield_addr = BASE_CHESTS_BITFLD_ADDR + (0x24 * stage_id)
|
||||
switches_bitfield_addr = BASE_SWITCHES_BITFLD_ADDR + (0x24 * stage_id)
|
||||
pickups_bitfield_addr = BASE_PICKUPS_BITFLD_ADDR + (0x24 * stage_id)
|
||||
|
||||
ctx.chests_bitfields[stage_id] = int(dolphin_memory_engine.read_word(chest_bitfield_addr))
|
||||
ctx.switches_bitfields[stage_id] = int.from_bytes(
|
||||
dolphin_memory_engine.read_bytes(switches_bitfield_addr, 10), byteorder="big"
|
||||
)
|
||||
ctx.pickups_bitfields[stage_id] = int(dolphin_memory_engine.read_word(pickups_bitfield_addr))
|
||||
|
||||
ctx.curr_stage_chests_bitfield = int(dolphin_memory_engine.read_word(CURR_STAGE_CHESTS_BITFLD_ADDR))
|
||||
ctx.curr_stage_switches_bitfield = int.from_bytes(
|
||||
dolphin_memory_engine.read_bytes(CURR_STAGE_SWITCHES_BITFLD_ADDR, 10), byteorder="big"
|
||||
)
|
||||
ctx.curr_stage_pickups_bitfield = int(dolphin_memory_engine.read_word(CURR_STAGE_PICKUPS_BITFLD_ADDR))
|
||||
|
||||
# We check which locations are currently checked on the current stage.
|
||||
curr_stage_id = dolphin_memory_engine.read_byte(CURR_STAGE_ID_ADDR)
|
||||
|
||||
# Loop through all locations to see if each has been checked.
|
||||
for location, data in LOCATION_TABLE.items():
|
||||
checked = False
|
||||
if data.type == TWWLocationType.CHART:
|
||||
assert location in ctx.salvage_locations_map, f'Location "{location}" salvage bit not set!'
|
||||
salvage_bit = ctx.salvage_locations_map[location]
|
||||
checked = bool((ctx.charts_bitfield >> salvage_bit) & 1)
|
||||
elif data.type == TWWLocationType.BOCTO:
|
||||
assert data.address is not None
|
||||
checked = bool((read_short(data.address) >> data.bit) & 1)
|
||||
elif data.type == TWWLocationType.EVENT:
|
||||
checked = bool((dolphin_memory_engine.read_byte(data.address) >> data.bit) & 1)
|
||||
elif data.type == TWWLocationType.SPECL:
|
||||
checked = check_special_location(location, data)
|
||||
else:
|
||||
checked = check_regular_location(ctx, curr_stage_id, data)
|
||||
|
||||
if checked:
|
||||
if data.code is None:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
else:
|
||||
ctx.locations_checked.add(TWWLocation.get_apid(data.code))
|
||||
|
||||
# Send the list of newly-checked locations to the server.
|
||||
locations_checked = ctx.locations_checked.difference(ctx.checked_locations)
|
||||
if locations_checked:
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations_checked}])
|
||||
|
||||
|
||||
async def check_current_stage_changed(ctx: TWWContext) -> None:
|
||||
"""
|
||||
Check if the player has moved to a new stage.
|
||||
If so, update all trackers with the new stage name.
|
||||
If the stage has never been visited, additionally update the server.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
"""
|
||||
new_stage_name = read_string(CURR_STAGE_NAME_ADDR, 8)
|
||||
|
||||
# Special handling is required for the Cliff Plateau Isles Inner Cave exit, which exits out onto the sea stage
|
||||
# rather than a unique stage.
|
||||
if (
|
||||
new_stage_name == "sea"
|
||||
and dolphin_memory_engine.read_byte(MOST_RECENT_ROOM_NUMBER_ADDR) == CLIFF_PLATEAU_ISLES_ROOM_NUMBER
|
||||
and read_short(MOST_RECENT_SPAWN_ID_ADDR) == CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_SPAWN_ID
|
||||
):
|
||||
new_stage_name = CLIFF_PLATEAU_ISLES_HIGHEST_ISLE_DUMMY_STAGE_NAME
|
||||
|
||||
current_stage_name = ctx.current_stage_name
|
||||
if new_stage_name != current_stage_name:
|
||||
ctx.current_stage_name = new_stage_name
|
||||
# Send a Bounced message containing the new stage name to all trackers connected to the current slot.
|
||||
data_to_send = {"tww_stage_name": new_stage_name}
|
||||
message = {
|
||||
"cmd": "Bounce",
|
||||
"slots": [ctx.slot],
|
||||
"data": data_to_send,
|
||||
}
|
||||
await ctx.send_msgs([message])
|
||||
|
||||
# If the stage has never been visited before, update the server's data storage to indicate that it has been
|
||||
# visited.
|
||||
visited_stage_names = ctx.visited_stage_names
|
||||
if visited_stage_names is not None and new_stage_name not in visited_stage_names:
|
||||
visited_stage_names.add(new_stage_name)
|
||||
await ctx.update_visited_stages(new_stage_name)
|
||||
|
||||
|
||||
async def check_alive() -> bool:
|
||||
"""
|
||||
Check if the player is currently alive in-game.
|
||||
|
||||
:return: `True` if the player is alive, otherwise `False`.
|
||||
"""
|
||||
cur_health = read_short(CURR_HEALTH_ADDR)
|
||||
return cur_health > 0
|
||||
|
||||
|
||||
async def check_death(ctx: TWWContext) -> None:
|
||||
"""
|
||||
Check if the player is currently dead in-game.
|
||||
If DeathLink is on, notify the server of the player's death.
|
||||
|
||||
:return: `True` if the player is dead, otherwise `False`.
|
||||
"""
|
||||
if ctx.slot is not None and check_ingame():
|
||||
cur_health = read_short(CURR_HEALTH_ADDR)
|
||||
if cur_health <= 0:
|
||||
if not ctx.has_send_death and time.time() >= ctx.last_death_link + 3:
|
||||
ctx.has_send_death = True
|
||||
await ctx.send_death(ctx.player_names[ctx.slot] + " ran out of hearts.")
|
||||
else:
|
||||
ctx.has_send_death = False
|
||||
|
||||
|
||||
def check_ingame() -> bool:
|
||||
"""
|
||||
Check if the player is currently in-game.
|
||||
|
||||
:return: `True` if the player is in-game, otherwise `False`.
|
||||
"""
|
||||
return read_string(CURR_STAGE_NAME_ADDR, 8) not in ["", "sea_T", "Name"]
|
||||
|
||||
|
||||
async def dolphin_sync_task(ctx: TWWContext) -> None:
|
||||
"""
|
||||
The task loop for managing the connection to Dolphin.
|
||||
|
||||
While connected, read the emulator's memory to look for any relevant changes made by the player in the game.
|
||||
|
||||
:param ctx: The Wind Waker client context.
|
||||
"""
|
||||
logger.info("Starting Dolphin connector. Use /dolphin for status information.")
|
||||
sleep_time = 0.0
|
||||
while not ctx.exit_event.is_set():
|
||||
if sleep_time > 0.0:
|
||||
try:
|
||||
# ctx.watcher_event gets set when receiving ReceivedItems or LocationInfo, or when shutting down.
|
||||
await asyncio.wait_for(ctx.watcher_event.wait(), sleep_time)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
sleep_time = 0.0
|
||||
ctx.watcher_event.clear()
|
||||
|
||||
try:
|
||||
if dolphin_memory_engine.is_hooked() and ctx.dolphin_status == CONNECTION_CONNECTED_STATUS:
|
||||
if not check_ingame():
|
||||
# Reset the give item array while not in the game.
|
||||
dolphin_memory_engine.write_bytes(GIVE_ITEM_ARRAY_ADDR, bytes([0xFF] * ctx.len_give_item_array))
|
||||
sleep_time = 0.1
|
||||
continue
|
||||
if ctx.slot is not None:
|
||||
if "DeathLink" in ctx.tags:
|
||||
await check_death(ctx)
|
||||
await give_items(ctx)
|
||||
await check_locations(ctx)
|
||||
await check_current_stage_changed(ctx)
|
||||
else:
|
||||
if not ctx.auth:
|
||||
ctx.auth = read_string(SLOT_NAME_ADDR, 0x40)
|
||||
if ctx.awaiting_rom:
|
||||
await ctx.server_auth()
|
||||
sleep_time = 0.1
|
||||
else:
|
||||
if ctx.dolphin_status == CONNECTION_CONNECTED_STATUS:
|
||||
logger.info("Connection to Dolphin lost, reconnecting...")
|
||||
ctx.dolphin_status = CONNECTION_LOST_STATUS
|
||||
logger.info("Attempting to connect to Dolphin...")
|
||||
dolphin_memory_engine.hook()
|
||||
if dolphin_memory_engine.is_hooked():
|
||||
if dolphin_memory_engine.read_bytes(0x80000000, 6) != b"GZLE99":
|
||||
logger.info(CONNECTION_REFUSED_GAME_STATUS)
|
||||
ctx.dolphin_status = CONNECTION_REFUSED_GAME_STATUS
|
||||
dolphin_memory_engine.un_hook()
|
||||
sleep_time = 5
|
||||
else:
|
||||
logger.info(CONNECTION_CONNECTED_STATUS)
|
||||
ctx.dolphin_status = CONNECTION_CONNECTED_STATUS
|
||||
ctx.locations_checked = set()
|
||||
else:
|
||||
logger.info("Connection to Dolphin failed, attempting again in 5 seconds...")
|
||||
ctx.dolphin_status = CONNECTION_LOST_STATUS
|
||||
await ctx.disconnect()
|
||||
sleep_time = 5
|
||||
continue
|
||||
except Exception:
|
||||
dolphin_memory_engine.un_hook()
|
||||
logger.info("Connection to Dolphin failed, attempting again in 5 seconds...")
|
||||
logger.error(traceback.format_exc())
|
||||
ctx.dolphin_status = CONNECTION_LOST_STATUS
|
||||
await ctx.disconnect()
|
||||
sleep_time = 5
|
||||
continue
|
||||
|
||||
|
||||
def main(connect: Optional[str] = None, password: Optional[str] = None) -> None:
|
||||
"""
|
||||
Run the main async loop for the Wind Waker client.
|
||||
|
||||
:param connect: Address of the Archipelago server.
|
||||
:param password: Password for server authentication.
|
||||
"""
|
||||
Utils.init_logging("The Wind Waker Client")
|
||||
|
||||
async def _main(connect: Optional[str], password: Optional[str]) -> None:
|
||||
ctx = TWWContext(connect, password)
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
ctx.dolphin_sync_task = asyncio.create_task(dolphin_sync_task(ctx), name="DolphinSync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
# Wake the sync task, if it is currently sleeping, so it can start shutting down when it sees that the
|
||||
# exit_event is set.
|
||||
ctx.watcher_event.set()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.dolphin_sync_task:
|
||||
await ctx.dolphin_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
asyncio.run(_main(connect, password))
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = get_base_parser()
|
||||
args = parser.parse_args()
|
||||
main(args.connect, args.password)
|
598
worlds/tww/__init__.py
Normal file
598
worlds/tww/__init__.py
Normal file
@@ -0,0 +1,598 @@
|
||||
import os
|
||||
import zipfile
|
||||
from base64 import b64encode
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, ClassVar
|
||||
|
||||
import yaml
|
||||
|
||||
from BaseClasses import Item
|
||||
from BaseClasses import ItemClassification as IC
|
||||
from BaseClasses import MultiWorld, Region, Tutorial
|
||||
from Options import Toggle
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from worlds.Files import APContainer, AutoPatchRegister
|
||||
from worlds.generic.Rules import add_item_rule
|
||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, icon_paths, launch_subprocess
|
||||
|
||||
from .Items import ISLAND_NUMBER_TO_CHART_NAME, ITEM_TABLE, TWWItem, item_name_groups
|
||||
from .Locations import LOCATION_TABLE, TWWFlag, TWWLocation
|
||||
from .Options import TWWOptions, tww_option_groups
|
||||
from .Presets import tww_options_presets
|
||||
from .randomizers.Charts import ISLAND_NUMBER_TO_NAME, ChartRandomizer
|
||||
from .randomizers.Dungeons import Dungeon, create_dungeons
|
||||
from .randomizers.Entrances import ALL_EXITS, BOSS_EXIT_TO_DUNGEON, MINIBOSS_EXIT_TO_DUNGEON, EntranceRandomizer
|
||||
from .randomizers.ItemPool import generate_itempool
|
||||
from .randomizers.RequiredBosses import RequiredBossesRandomizer
|
||||
from .Rules import set_rules
|
||||
|
||||
VERSION: tuple[int, int, int] = (3, 0, 0)
|
||||
|
||||
|
||||
def run_client() -> None:
|
||||
"""
|
||||
Launch the The Wind Waker client.
|
||||
"""
|
||||
print("Running The Wind Waker Client")
|
||||
from .TWWClient import main
|
||||
|
||||
launch_subprocess(main, name="TheWindWakerClient")
|
||||
|
||||
|
||||
components.append(
|
||||
Component(
|
||||
"The Wind Waker Client",
|
||||
func=run_client,
|
||||
component_type=Type.CLIENT,
|
||||
file_identifier=SuffixIdentifier(".aptww"),
|
||||
icon="The Wind Waker",
|
||||
)
|
||||
)
|
||||
icon_paths["The Wind Waker"] = "ap:worlds.tww/assets/icon.png"
|
||||
|
||||
|
||||
class TWWContainer(APContainer, metaclass=AutoPatchRegister):
|
||||
"""
|
||||
This class defines the container file for The Wind Waker.
|
||||
"""
|
||||
|
||||
game: str = "The Wind Waker"
|
||||
patch_file_ending: str = ".aptww"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
if "data" in kwargs:
|
||||
self.data = kwargs["data"]
|
||||
del kwargs["data"]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
"""
|
||||
Write the contents of the container file.
|
||||
"""
|
||||
super().write_contents(opened_zipfile)
|
||||
|
||||
# Record the data for the game under the key `plando`.
|
||||
opened_zipfile.writestr("plando", b64encode(bytes(yaml.safe_dump(self.data, sort_keys=False), "utf-8")))
|
||||
|
||||
|
||||
class TWWWeb(WebWorld):
|
||||
"""
|
||||
This class handles the web interface for The Wind Waker.
|
||||
|
||||
The web interface includes the setup guide and the options page for generating YAMLs.
|
||||
"""
|
||||
|
||||
tutorials = [
|
||||
Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to setting up the Archipelago The Wind Waker software on your computer.",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["tanjo3", "Lunix"],
|
||||
)
|
||||
]
|
||||
theme = "ocean"
|
||||
options_presets = tww_options_presets
|
||||
option_groups = tww_option_groups
|
||||
rich_text_options_doc = True
|
||||
|
||||
|
||||
class TWWWorld(World):
|
||||
"""
|
||||
Legend has it that whenever evil has appeared, a hero named Link has arisen to defeat it. The legend continues on
|
||||
the surface of a vast and mysterious sea as Link sets sail in his most epic, awe-inspiring adventure yet. Aided by a
|
||||
magical conductor's baton called the Wind Waker, he will face unimaginable monsters, explore puzzling dungeons, and
|
||||
meet a cast of unforgettable characters as he searches for his kidnapped sister.
|
||||
"""
|
||||
|
||||
options_dataclass = TWWOptions
|
||||
options: TWWOptions
|
||||
|
||||
game: ClassVar[str] = "The Wind Waker"
|
||||
topology_present: bool = True
|
||||
|
||||
item_name_to_id: ClassVar[dict[str, int]] = {
|
||||
name: TWWItem.get_apid(data.code) for name, data in ITEM_TABLE.items() if data.code is not None
|
||||
}
|
||||
location_name_to_id: ClassVar[dict[str, int]] = {
|
||||
name: TWWLocation.get_apid(data.code) for name, data in LOCATION_TABLE.items() if data.code is not None
|
||||
}
|
||||
|
||||
item_name_groups: ClassVar[dict[str, set[str]]] = item_name_groups
|
||||
|
||||
required_client_version: tuple[int, int, int] = (0, 5, 1)
|
||||
|
||||
web: ClassVar[TWWWeb] = TWWWeb()
|
||||
|
||||
origin_region_name: str = "The Great Sea"
|
||||
|
||||
create_items = generate_itempool
|
||||
|
||||
logic_rematch_bosses_skipped: bool
|
||||
logic_in_swordless_mode: bool
|
||||
logic_in_required_bosses_mode: bool
|
||||
logic_obscure_1: bool
|
||||
logic_obscure_2: bool
|
||||
logic_obscure_3: bool
|
||||
logic_precise_1: bool
|
||||
logic_precise_2: bool
|
||||
logic_precise_3: bool
|
||||
logic_tuner_logic_enabled: bool
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.progress_locations: set[str] = set()
|
||||
self.nonprogress_locations: set[str] = set()
|
||||
|
||||
self.dungeon_local_item_names: set[str] = set()
|
||||
self.dungeon_specific_item_names: set[str] = set()
|
||||
self.dungeons: dict[str, Dungeon] = {}
|
||||
|
||||
self.item_classification_overrides: dict[str, IC] = {}
|
||||
|
||||
self.useful_pool: list[str] = []
|
||||
self.filler_pool: list[str] = []
|
||||
|
||||
self.charts = ChartRandomizer(self)
|
||||
self.entrances = EntranceRandomizer(self)
|
||||
self.boss_reqs = RequiredBossesRandomizer(self)
|
||||
|
||||
def _determine_item_classification_overrides(self) -> None:
|
||||
"""
|
||||
Determine item classification overrides. The classification of an item may be affected by which options are
|
||||
enabled or disabled.
|
||||
"""
|
||||
options = self.options
|
||||
item_classification_overrides = self.item_classification_overrides
|
||||
|
||||
# Override certain items to be filler depending on user options.
|
||||
# TODO: Calculate filler items dynamically
|
||||
override_as_filler = []
|
||||
if not options.progression_dungeons:
|
||||
override_as_filler.extend(item_name_groups["Small Keys"] | item_name_groups["Big Keys"])
|
||||
override_as_filler.extend(("Command Melody", "Earth God's Lyric", "Wind God's Aria"))
|
||||
if not options.progression_short_sidequests:
|
||||
override_as_filler.extend(("Maggie's Letter", "Moblin's Letter"))
|
||||
if not (options.progression_short_sidequests or options.progression_long_sidequests):
|
||||
override_as_filler.append("Progressive Picto Box")
|
||||
if not options.progression_spoils_trading:
|
||||
override_as_filler.append("Spoils Bag")
|
||||
if not options.progression_triforce_charts:
|
||||
override_as_filler.extend(item_name_groups["Triforce Charts"])
|
||||
if not options.progression_treasure_charts:
|
||||
override_as_filler.extend(item_name_groups["Treasure Charts"])
|
||||
if not options.progression_misc:
|
||||
override_as_filler.extend(item_name_groups["Tingle Statues"])
|
||||
|
||||
for item_name in override_as_filler:
|
||||
item_classification_overrides[item_name] = IC.filler
|
||||
|
||||
# Override certain items to be useful depending on user options.
|
||||
# TODO: Calculate useful items dynamically
|
||||
override_as_useful = []
|
||||
if not options.progression_big_octos_gunboats:
|
||||
override_as_useful.append("Quiver Capacity Upgrade")
|
||||
if options.sword_mode in ("swords_optional", "swordless"):
|
||||
override_as_useful.append("Progressive Sword")
|
||||
if not options.enable_tuner_logic:
|
||||
override_as_useful.append("Tingle Tuner")
|
||||
|
||||
for item_name in override_as_useful:
|
||||
item_classification_overrides[item_name] = IC.useful
|
||||
|
||||
def _determine_progress_and_nonprogress_locations(self) -> tuple[set[str], set[str]]:
|
||||
"""
|
||||
Determine which locations are progress and nonprogress in the world based on the player's options.
|
||||
|
||||
:return: A tuple of two sets, the first containing the names of the progress locations and the second containing
|
||||
the names of the nonprogress locations.
|
||||
"""
|
||||
|
||||
def add_flag(option: Toggle, flag: TWWFlag) -> TWWFlag:
|
||||
return flag if option else TWWFlag.ALWAYS
|
||||
|
||||
options = self.options
|
||||
|
||||
enabled_flags = TWWFlag.ALWAYS
|
||||
enabled_flags |= add_flag(options.progression_dungeons, TWWFlag.DUNGEON | TWWFlag.BOSS)
|
||||
enabled_flags |= add_flag(options.progression_tingle_chests, TWWFlag.TNGL_CT)
|
||||
enabled_flags |= add_flag(options.progression_dungeon_secrets, TWWFlag.DG_SCRT)
|
||||
enabled_flags |= add_flag(options.progression_puzzle_secret_caves, TWWFlag.PZL_CVE)
|
||||
enabled_flags |= add_flag(options.progression_combat_secret_caves, TWWFlag.CBT_CVE)
|
||||
enabled_flags |= add_flag(options.progression_savage_labyrinth, TWWFlag.SAVAGE)
|
||||
enabled_flags |= add_flag(options.progression_great_fairies, TWWFlag.GRT_FRY)
|
||||
enabled_flags |= add_flag(options.progression_short_sidequests, TWWFlag.SHRT_SQ)
|
||||
enabled_flags |= add_flag(options.progression_long_sidequests, TWWFlag.LONG_SQ)
|
||||
enabled_flags |= add_flag(options.progression_spoils_trading, TWWFlag.SPOILS)
|
||||
enabled_flags |= add_flag(options.progression_minigames, TWWFlag.MINIGME)
|
||||
enabled_flags |= add_flag(options.progression_battlesquid, TWWFlag.SPLOOSH)
|
||||
enabled_flags |= add_flag(options.progression_free_gifts, TWWFlag.FREE_GF)
|
||||
enabled_flags |= add_flag(options.progression_mail, TWWFlag.MAILBOX)
|
||||
enabled_flags |= add_flag(options.progression_platforms_rafts, TWWFlag.PLTFRMS)
|
||||
enabled_flags |= add_flag(options.progression_submarines, TWWFlag.SUBMRIN)
|
||||
enabled_flags |= add_flag(options.progression_eye_reef_chests, TWWFlag.EYE_RFS)
|
||||
enabled_flags |= add_flag(options.progression_big_octos_gunboats, TWWFlag.BG_OCTO)
|
||||
enabled_flags |= add_flag(options.progression_expensive_purchases, TWWFlag.XPENSVE)
|
||||
enabled_flags |= add_flag(options.progression_island_puzzles, TWWFlag.ISLND_P)
|
||||
enabled_flags |= add_flag(options.progression_misc, TWWFlag.MISCELL)
|
||||
|
||||
progress_locations: set[str] = set()
|
||||
nonprogress_locations: set[str] = set()
|
||||
for location, data in LOCATION_TABLE.items():
|
||||
if data.flags & enabled_flags == data.flags:
|
||||
progress_locations.add(location)
|
||||
else:
|
||||
nonprogress_locations.add(location)
|
||||
assert progress_locations.isdisjoint(nonprogress_locations)
|
||||
|
||||
return progress_locations, nonprogress_locations
|
||||
|
||||
@staticmethod
|
||||
def _get_classification_name(classification: IC) -> str:
|
||||
"""
|
||||
Return a string representation of the item's highest-order classification.
|
||||
|
||||
:param classification: The item's classification.
|
||||
:return: A string representation of the item's highest classification. The order of classification is
|
||||
progression > trap > useful > filler.
|
||||
"""
|
||||
|
||||
if IC.progression in classification:
|
||||
return "progression"
|
||||
elif IC.trap in classification:
|
||||
return "trap"
|
||||
elif IC.useful in classification:
|
||||
return "useful"
|
||||
else:
|
||||
return "filler"
|
||||
|
||||
def generate_early(self) -> None:
|
||||
"""
|
||||
Run before any general steps of the MultiWorld other than options.
|
||||
"""
|
||||
options = self.options
|
||||
|
||||
# Only randomize secret cave inner entrances if both puzzle secret caves and combat secret caves are enabled.
|
||||
if not (options.progression_puzzle_secret_caves and options.progression_combat_secret_caves):
|
||||
options.randomize_secret_cave_inner_entrances.value = False
|
||||
|
||||
# Determine which locations are progression and which are not from options.
|
||||
self.progress_locations, self.nonprogress_locations = self._determine_progress_and_nonprogress_locations()
|
||||
|
||||
for dungeon_item in ["randomize_smallkeys", "randomize_bigkeys", "randomize_mapcompass"]:
|
||||
option = getattr(options, dungeon_item)
|
||||
if option == "local":
|
||||
options.local_items.value |= self.item_name_groups[option.item_name_group]
|
||||
elif option.in_dungeon:
|
||||
self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group]
|
||||
if option == "dungeon":
|
||||
self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group]
|
||||
else:
|
||||
options.local_items.value |= self.dungeon_local_item_names
|
||||
|
||||
# Resolve logic options and set them onto the world instance for faster lookup in logic rules.
|
||||
self.logic_rematch_bosses_skipped = bool(options.skip_rematch_bosses.value)
|
||||
self.logic_in_swordless_mode = options.sword_mode in ("swords_optional", "swordless")
|
||||
self.logic_in_required_bosses_mode = bool(options.required_bosses.value)
|
||||
self.logic_obscure_3 = options.logic_obscurity == "very_hard"
|
||||
self.logic_obscure_2 = self.logic_obscure_3 or options.logic_obscurity == "hard"
|
||||
self.logic_obscure_1 = self.logic_obscure_2 or options.logic_obscurity == "normal"
|
||||
self.logic_precise_3 = options.logic_precision == "very_hard"
|
||||
self.logic_precise_2 = self.logic_precise_3 or options.logic_precision == "hard"
|
||||
self.logic_precise_1 = self.logic_precise_2 or options.logic_precision == "normal"
|
||||
self.logic_tuner_logic_enabled = bool(options.enable_tuner_logic.value)
|
||||
|
||||
# Determine any item classification overrides based on user options.
|
||||
self._determine_item_classification_overrides()
|
||||
|
||||
def create_regions(self) -> None:
|
||||
"""
|
||||
Create and connect regions for the The Wind Waker world.
|
||||
|
||||
This method first randomizes the charts and picks the required bosses if these options are enabled.
|
||||
It then loops through all the world's progress locations and creates the locations, assigning dungeon locations
|
||||
to their respective dungeons.
|
||||
Finally, the flags for sunken treasure locations are updated as appropriate, and the entrances are randomized
|
||||
if that option is enabled.
|
||||
"""
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
options = self.options
|
||||
|
||||
# "The Great Sea" region contains all locations that are not in a randomizable region.
|
||||
great_sea_region = Region("The Great Sea", player, multiworld)
|
||||
multiworld.regions.append(great_sea_region)
|
||||
|
||||
# Add all randomizable regions.
|
||||
for _exit in ALL_EXITS:
|
||||
multiworld.regions.append(Region(_exit.unique_name, player, multiworld))
|
||||
|
||||
# Set up sunken treasure locations, randomizing the charts if necessary.
|
||||
self.charts.setup_progress_sunken_treasure_locations()
|
||||
|
||||
# Select required bosses.
|
||||
if options.required_bosses:
|
||||
self.boss_reqs.randomize_required_bosses()
|
||||
self.progress_locations -= self.boss_reqs.banned_locations
|
||||
self.nonprogress_locations |= self.boss_reqs.banned_locations
|
||||
|
||||
# Create the dungeon classes.
|
||||
create_dungeons(self)
|
||||
|
||||
# Assign each location to their region.
|
||||
# Progress locations are sorted for deterministic results.
|
||||
for location_name in sorted(self.progress_locations):
|
||||
data = LOCATION_TABLE[location_name]
|
||||
|
||||
region = self.get_region(data.region)
|
||||
location = TWWLocation(player, location_name, region, data)
|
||||
|
||||
# Additionally, assign dungeon locations to the appropriate dungeon.
|
||||
if region.name in self.dungeons:
|
||||
location.dungeon = self.dungeons[region.name]
|
||||
elif region.name in MINIBOSS_EXIT_TO_DUNGEON and not options.randomize_miniboss_entrances:
|
||||
location.dungeon = self.dungeons[MINIBOSS_EXIT_TO_DUNGEON[region.name]]
|
||||
elif region.name in BOSS_EXIT_TO_DUNGEON and not options.randomize_boss_entrances:
|
||||
location.dungeon = self.dungeons[BOSS_EXIT_TO_DUNGEON[region.name]]
|
||||
elif location.name in [
|
||||
"Forsaken Fortress - Phantom Ganon",
|
||||
"Forsaken Fortress - Chest Outside Upper Jail Cell",
|
||||
"Forsaken Fortress - Chest Inside Lower Jail Cell",
|
||||
"Forsaken Fortress - Chest Guarded By Bokoblin",
|
||||
"Forsaken Fortress - Chest on Bed",
|
||||
]:
|
||||
location.dungeon = self.dungeons["Forsaken Fortress"]
|
||||
region.locations.append(location)
|
||||
|
||||
# Correct the flags of the sunken treasure locations if the charts are randomized.
|
||||
self.charts.update_chart_location_flags()
|
||||
|
||||
# Connect the regions in the multiworld. Randomize entrances to exits if the option is set.
|
||||
self.entrances.randomize_entrances()
|
||||
|
||||
def set_rules(self) -> None:
|
||||
"""
|
||||
Set access and item rules on locations.
|
||||
"""
|
||||
# Set the access rules for all progression locations.
|
||||
set_rules(self)
|
||||
|
||||
# Ban the Bait Bag slot from having bait.
|
||||
# Beedle's shop does not work correctly if the same item is in multiple slots in the same shop.
|
||||
if "The Great Sea - Beedle's Shop Ship - 20 Rupee Item" in self.progress_locations:
|
||||
beedle_20 = self.get_location("The Great Sea - Beedle's Shop Ship - 20 Rupee Item")
|
||||
add_item_rule(beedle_20, lambda item: item.name not in ["All-Purpose Bait", "Hyoi Pear"])
|
||||
|
||||
# For the same reason, the same item should not appear more than once on the Rock Spire Isle shop ship.
|
||||
# All non-TWW items use the same item (Father's Letter), so at most one non-TWW item can appear in the shop.
|
||||
# The rest must be (unique, but not necessarily local) TWW items.
|
||||
locations = [f"Rock Spire Isle - Beedle's Special Shop Ship - {v} Rupee Item" for v in [500, 950, 900]]
|
||||
if all(loc in self.progress_locations for loc in locations):
|
||||
rock_spire_shop_ship_locations = [self.get_location(location_name) for location_name in locations]
|
||||
|
||||
for i in range(len(rock_spire_shop_ship_locations)):
|
||||
curr_loc = rock_spire_shop_ship_locations[i]
|
||||
other_locs = rock_spire_shop_ship_locations[:i] + rock_spire_shop_ship_locations[i + 1:]
|
||||
|
||||
add_item_rule(
|
||||
curr_loc,
|
||||
lambda item, locations=other_locs: (
|
||||
item.game == "The Wind Waker"
|
||||
and all(location.item is None or item.name != location.item.name for location in locations)
|
||||
)
|
||||
or (
|
||||
item.game != "The Wind Waker"
|
||||
and all(
|
||||
location.item is None or location.item.game == "The Wind Waker" for location in locations
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def stage_set_rules(cls, multiworld: MultiWorld) -> None:
|
||||
"""
|
||||
Class method used to modify the rules for The Wind Waker dungeon locations.
|
||||
|
||||
:param multiworld: The MultiWorld.
|
||||
"""
|
||||
from .randomizers.Dungeons import modify_dungeon_location_rules
|
||||
|
||||
# Set additional rules on dungeon locations as necessary.
|
||||
modify_dungeon_location_rules(multiworld)
|
||||
|
||||
@classmethod
|
||||
def stage_pre_fill(cls, multiworld: MultiWorld) -> None:
|
||||
"""
|
||||
Class method used to correctly place dungeon items for The Wind Waker worlds.
|
||||
|
||||
:param multiworld: The MultiWorld.
|
||||
"""
|
||||
from .randomizers.Dungeons import fill_dungeons_restrictive
|
||||
|
||||
fill_dungeons_restrictive(multiworld)
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
"""
|
||||
Create the output APTWW file that is used to randomize the ISO.
|
||||
|
||||
:param output_directory: The output directory for the APTWW file.
|
||||
"""
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
|
||||
# Determine the current arrangement for charts.
|
||||
# Create a list where the original island number is the index, and the value is the new island number.
|
||||
# Without randomized charts, this array would be just an ordered list of the numbers 1 to 49.
|
||||
# With randomized charts, the new island number is where the chart for the original island now leads.
|
||||
chart_name_to_island_number = {
|
||||
chart_name: island_number for island_number, chart_name in self.charts.island_number_to_chart_name.items()
|
||||
}
|
||||
charts_mapping: list[int] = []
|
||||
for i in range(1, 49 + 1):
|
||||
original_chart_name = ISLAND_NUMBER_TO_CHART_NAME[i]
|
||||
new_island_number = chart_name_to_island_number[original_chart_name]
|
||||
charts_mapping.append(new_island_number)
|
||||
|
||||
# Output seed name and slot number to seed RNG in randomizer client.
|
||||
output_data = {
|
||||
"Version": list(VERSION),
|
||||
"Seed": multiworld.seed_name,
|
||||
"Slot": player,
|
||||
"Name": self.player_name,
|
||||
"Options": self.options.as_dict(*self.options_dataclass.type_hints),
|
||||
"Required Bosses": self.boss_reqs.required_boss_item_locations,
|
||||
"Locations": {},
|
||||
"Entrances": {},
|
||||
"Charts": charts_mapping,
|
||||
}
|
||||
|
||||
# Output which item has been placed at each location.
|
||||
output_locations = output_data["Locations"]
|
||||
locations = multiworld.get_locations(player)
|
||||
for location in locations:
|
||||
if location.name != "Defeat Ganondorf":
|
||||
if location.item:
|
||||
item_info = {
|
||||
"player": location.item.player,
|
||||
"name": location.item.name,
|
||||
"game": location.item.game,
|
||||
"classification": self._get_classification_name(location.item.classification),
|
||||
}
|
||||
else:
|
||||
item_info = {"name": "Nothing", "game": "The Wind Waker", "classification": "filler"}
|
||||
output_locations[location.name] = item_info
|
||||
|
||||
# Output the mapping of entrances to exits.
|
||||
output_entrances = output_data["Entrances"]
|
||||
for zone_entrance, zone_exit in self.entrances.done_entrances_to_exits.items():
|
||||
output_entrances[zone_entrance.entrance_name] = zone_exit.unique_name
|
||||
|
||||
# Output the plando details to file.
|
||||
aptww = TWWContainer(
|
||||
path=os.path.join(
|
||||
output_directory, f"{multiworld.get_out_file_name_base(player)}{TWWContainer.patch_file_ending}"
|
||||
),
|
||||
player=player,
|
||||
player_name=self.player_name,
|
||||
data=output_data,
|
||||
)
|
||||
aptww.write()
|
||||
|
||||
def extend_hint_information(self, hint_data: dict[int, dict[int, str]]) -> None:
|
||||
"""
|
||||
Fill in additional information text into locations, displayed when hinted.
|
||||
|
||||
:param hint_data: A dictionary of mapping a player ID to a dictionary mapping location IDs to the extra hint
|
||||
information text. This dictionary should be modified as a side-effect of this method.
|
||||
"""
|
||||
# Create a mapping of island names to numbers for sunken treasure hints.
|
||||
island_name_to_number = {v: k for k, v in ISLAND_NUMBER_TO_NAME.items()}
|
||||
|
||||
hint_data[self.player] = {}
|
||||
for location in self.multiworld.get_locations(self.player):
|
||||
if location.address is not None and location.item is not None:
|
||||
# Regardless of ER settings, always hint at the outermost entrance for every "interior" location.
|
||||
zone_exit = self.entrances.get_zone_exit_for_item_location(location.name)
|
||||
if zone_exit is not None:
|
||||
outermost_entrance = self.entrances.get_outermost_entrance_for_exit(zone_exit)
|
||||
assert outermost_entrance is not None and outermost_entrance.island_name is not None
|
||||
hint_data[self.player][location.address] = outermost_entrance.island_name
|
||||
|
||||
# Hint at which chart leads to the sunken treasure for these locations.
|
||||
if location.name.endswith(" - Sunken Treasure"):
|
||||
island_name = location.name.removesuffix(" - Sunken Treasure")
|
||||
island_number = island_name_to_number[island_name]
|
||||
chart_name = self.charts.island_number_to_chart_name[island_number]
|
||||
hint_data[self.player][location.address] = chart_name
|
||||
|
||||
def create_item(self, name: str) -> TWWItem:
|
||||
"""
|
||||
Create an item for this world type and player.
|
||||
|
||||
:param name: The name of the item to create.
|
||||
:raises KeyError: If an invalid item name is provided.
|
||||
"""
|
||||
if name in ITEM_TABLE:
|
||||
return TWWItem(name, self.player, ITEM_TABLE[name], self.item_classification_overrides.get(name))
|
||||
raise KeyError(f"Invalid item name: {name}")
|
||||
|
||||
def get_filler_item_name(self, strict: bool = True) -> str:
|
||||
"""
|
||||
This method is called when the item pool needs to be filled with additional items to match the location count.
|
||||
|
||||
:param strict: Whether the item should be one strictly classified as filler. Defaults to `True`.
|
||||
:return: The name of a filler item from this world.
|
||||
"""
|
||||
# If there are still useful items to place, place those first.
|
||||
if not strict and len(self.useful_pool) > 0:
|
||||
return self.useful_pool.pop()
|
||||
|
||||
# If there are still vanilla filler items to place, place those first.
|
||||
if len(self.filler_pool) > 0:
|
||||
return self.filler_pool.pop()
|
||||
|
||||
# Use the same weights for filler items used in the base randomizer.
|
||||
filler_consumables = ["Yellow Rupee", "Red Rupee", "Purple Rupee", "Joy Pendant"]
|
||||
filler_weights = [3, 7, 10, 3]
|
||||
if not strict:
|
||||
filler_consumables.append("Orange Rupee")
|
||||
filler_weights.append(15)
|
||||
return self.multiworld.random.choices(filler_consumables, weights=filler_weights, k=1)[0]
|
||||
|
||||
def get_pre_fill_items(self) -> list[Item]:
|
||||
"""
|
||||
Return items that need to be collected when creating a fresh `all_state` but don't exist in the multiworld's
|
||||
item pool.
|
||||
|
||||
:return: A list of pre-fill items.
|
||||
"""
|
||||
res = []
|
||||
if self.dungeon_local_item_names:
|
||||
for dungeon in self.dungeons.values():
|
||||
for item in dungeon.all_items:
|
||||
if item.name in self.dungeon_local_item_names:
|
||||
res.append(item)
|
||||
return res
|
||||
|
||||
def fill_slot_data(self) -> Mapping[str, Any]:
|
||||
"""
|
||||
Return the `slot_data` field that will be in the `Connected` network package.
|
||||
|
||||
This is a way the generator can give custom data to the client.
|
||||
The client will receive this as JSON in the `Connected` response.
|
||||
|
||||
:return: A dictionary to be sent to the client when it connects to the server.
|
||||
"""
|
||||
slot_data = self.options.as_dict(*self.options_dataclass.type_hints)
|
||||
|
||||
# Add entrances to `slot_data`. This is the same data that is written to the .aptww file.
|
||||
entrances = {
|
||||
zone_entrance.entrance_name: zone_exit.unique_name
|
||||
for zone_entrance, zone_exit in self.entrances.done_entrances_to_exits.items()
|
||||
}
|
||||
slot_data["entrances"] = entrances
|
||||
|
||||
return slot_data
|
BIN
worlds/tww/assets/icon.ico
Normal file
BIN
worlds/tww/assets/icon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 216 KiB |
BIN
worlds/tww/assets/icon.png
Normal file
BIN
worlds/tww/assets/icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
120
worlds/tww/docs/en_The Wind Waker.md
Normal file
120
worlds/tww/docs/en_The Wind Waker.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# The Wind Waker
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
The [player options page for this game](../player-options) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Items get shuffled between the different locations in the game, so each playthrough is unique. Randomized locations
|
||||
include chests, items received from NPC, and treasure salvaged from the ocean floor. The randomizer also includes
|
||||
quality-of-life features such as a fully opened world, removing many cutscenes, increased sailing speed, and more.
|
||||
|
||||
## Which locations get shuffled?
|
||||
|
||||
Only locations put into logic by the world's settings will be randomized. The remaining locations in the game will have
|
||||
a yellow Rupee, which includes a message that the location is not randomized.
|
||||
|
||||
## What is the goal of The Wind Waker?
|
||||
|
||||
Reach and defeat Ganondorf atop Ganon's Tower. This will require all eight shards of the Triforce of Courage, the
|
||||
fully-powered Master Sword (unless it's swordless mode), Light Arrows, and any other items necessary to reach Ganondorf.
|
||||
|
||||
## What does another world's item look like in TWW?
|
||||
|
||||
Items belonging to other non-TWW worlds are represented by Father's Letter (the letter Medli gives you to give to
|
||||
Komali), an unused item in the randomizer.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, it will automatically be added to Link's inventory. Unlike many other Zelda
|
||||
randomizers, Link **will not** hold the item above his head.
|
||||
|
||||
## I need help! What do I do?
|
||||
|
||||
Refer to the [FAQ](https://lagolunatic.github.io/wwrando/faq/) first. Then, try the troubleshooting steps in the
|
||||
[setup guide](/tutorial/The%20Wind%20Waker/setup/en). If you are still stuck, please ask in the Wind Waker channel in
|
||||
the Archipelago server.
|
||||
|
||||
## Known issues
|
||||
|
||||
- Randomized freestanding rupees, spoils, and bait will also be given to the player picking up the item. The item will
|
||||
be sent properly, but the collecting player will receive an extra copy.
|
||||
- Demo items (items which are held over Link's head) which are **not** randomized, such as rupees from salvages from
|
||||
random light rings or rewards from minigames, will not work.
|
||||
- Item get messages for progressive items received on locations that send earlier than intended will be incorrect. This
|
||||
does not affect gameplay.
|
||||
- The Heart Piece count in item get messages will be off by one. This does not affect gameplay.
|
||||
- It has been reported that item links can be buggy. Nothing game-breaking, but do be aware of it.
|
||||
|
||||
Feel free to report any other issues or suggest improvements in the Wind Waker channel in the Archipelago server!
|
||||
|
||||
## Tips and Tricks
|
||||
|
||||
### Where are dungeon secrets found in the dungeons?
|
||||
|
||||
[This document](https://docs.google.com/document/d/1LrjGr6W9970XEA-pzl8OhwnqMqTbQaxCX--M-kdsLos/edit?usp=sharing) has
|
||||
images of each of the dungeon secrets.
|
||||
|
||||
### What exactly do the obscure and precise difficulty options do?
|
||||
|
||||
The `logic_obscurity` and `logic_precision` options modify the randomizer's logic to put various tricks and techniques
|
||||
into logic.
|
||||
[This document](https://docs.google.com/spreadsheets/d/14ToE1SvNr9yRRqU4GK2qxIsuDUs9Edegik3wUbLtzH8/edit?usp=sharing)
|
||||
neatly lists the changes that are made. The options are progressive, so, for instance, hard obscure difficulty includes
|
||||
both normal and hard obscure tricks. Some changes require a combination of both options. For example, to put having the
|
||||
Forsaken Fortress cannons blow the door up for you into logic requires both obscure and precise difficulty to be set to
|
||||
at least normal.
|
||||
|
||||
### What are the different options presets?
|
||||
|
||||
A few presets are available on the [player options page](../player-options) for your convenience.
|
||||
|
||||
- **Tournament S7**: These are (as close to as possible) the settings used in the WWR Racing Server's
|
||||
[Season 7 Tournament](https://docs.google.com/document/d/1mJj7an-DvpYilwNt-DdlFOy1fz5_NMZaPZvHeIekplc).
|
||||
The preset features 3 required bosses and hard obscurity difficulty, and while the list of enabled progression options
|
||||
may seem intimidating, the preset also excludes several locations.
|
||||
- **Miniblins 2025**: These are (as close to as possible) the settings used in the WWR Racing Server's
|
||||
[2025 Season of Minblins](https://docs.google.com/document/d/19vT68eU6PepD2BD2ZjR9ikElfqs8pXfqQucZ-TcscV8). This
|
||||
preset is great if you're new to Wind Waker! There aren't too many locations in the world, and you only need to
|
||||
complete two dungeons. You also start with many convenience items, such as double magic, a capacity upgrade for your
|
||||
bow and bombs, and six hearts.
|
||||
- **Mixed Pools**: These are the settings used in the WWR Racing Server's
|
||||
[Mixed Pools Co-op Tournament](https://docs.google.com/document/d/1YGPTtEgP978TIi0PUAD792OtZbE2jBQpI8XCAy63qpg). This
|
||||
preset features full entrance rando and includes many locations behind a randomized entrance. There are also a bunch
|
||||
of overworld locations, as these settings were intended to be played in a two-person co-op team. The preset also has 6
|
||||
required bosses, but since entrance pools are randomized, the bosses could be found anywhere! Check your Sea Chart to
|
||||
find out which island the bosses are on.
|
||||
|
||||
## Planned Features
|
||||
|
||||
- Dynamic CTMC based on enabled options
|
||||
- Hint implementation from base randomizer (hint placement options and hint types)
|
||||
- Integration with Archipelago's hint system (e.g., auction hints)
|
||||
- EnergyLink support
|
||||
- Swift Sail logic as an option
|
||||
- Continued bugfixes
|
||||
|
||||
## Credits
|
||||
|
||||
This randomizer would not be possible without the help from:
|
||||
|
||||
- BigSharkZ: (icon artwork)
|
||||
- Celeste (Maëlle): (logic and typo fixes, additional programming)
|
||||
- Chavu: (logic difficulty document)
|
||||
- CrainWWR: (multiworld and Dolphin memory assistance, additional programming)
|
||||
- Cyb3R: (reference for `TWWClient`)
|
||||
- DeamonHunter: (additional programming)
|
||||
- Dev5ter: (initial TWW AP implmentation)
|
||||
- Gamma / SageOfMirrors: (additional programming)
|
||||
- LagoLunatic: (base randomizer, additional assistance)
|
||||
- Lunix: (Linux support, additional programming)
|
||||
- Mysteryem: (tracker support, additional programming)
|
||||
- Necrofitz: (additional documentation)
|
||||
- Ouro: (tracker support)
|
||||
- tal (matzahTalSoup): (dungeon secrets guide)
|
||||
- Tubamann: (additional programming)
|
||||
|
||||
The Archipelago logo © 2022 by Krista Corkos and Christopher Wilson, licensed under
|
||||
[CC BY-NC 4.0](http://creativecommons.org/licenses/by-nc/4.0/).
|
67
worlds/tww/docs/setup_en.md
Normal file
67
worlds/tww/docs/setup_en.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Setup Guide for The Wind Waker Archipelago
|
||||
|
||||
Welcome to The Wind Waker Archipelago! This guide will help you set up the randomizer and play your first multiworld.
|
||||
If you're playing The Wind Waker, you must follow a few simple steps to get started.
|
||||
|
||||
## Requirements
|
||||
|
||||
You'll need the following components to be able to play with The Wind Waker:
|
||||
* Install [Dolphin Emulator](https://dolphin-emu.org/download/). **We recommend using the latest release.**
|
||||
* For Linux users, you can use the flatpak package
|
||||
[available on Flathub](https://flathub.org/apps/org.DolphinEmu.dolphin-emu).
|
||||
* The 2.5.0 version of the [TWW AP Randomizer Build](https://github.com/tanjo3/wwrando/releases/tag/ap_2.5.0).
|
||||
* A The Wind Waker ISO (North American version), probably named "Legend of Zelda, The - The Wind Waker (USA).iso".
|
||||
|
||||
Optionally, you can also download:
|
||||
* [Wind Waker Tracker](https://github.com/Mysteryem/ww-poptracker/releases/latest)
|
||||
* Requires [PopTracker](https://github.com/black-sliver/PopTracker/releases)
|
||||
* [Custom Wind Waker Player Models](https://github.com/Sage-of-Mirrors/Custom-Wind-Waker-Player-Models)
|
||||
|
||||
## Setting Up a YAML
|
||||
|
||||
All players playing The Wind Waker must provide the room host with a YAML file containing the settings for their world.
|
||||
Visit the [The Wind Waker options page](/games/The%20Wind%20Waker/player-options) to generate a YAML with your desired
|
||||
options. Only locations categorized under the options enabled under "Progression Locations" will be randomized in your
|
||||
world. Once you're happy with your settings, provide the room host with your YAML file and proceed to the next step.
|
||||
|
||||
## Connecting to a Room
|
||||
|
||||
The multiworld host will provide you a link to download your `aptww` file or a zip file containing everyone's files. The
|
||||
`aptww` file should be named `P#_<name>_XXXXX.aptww`, where `#` is your player ID, `<name>` is your player name, and
|
||||
`XXXXX` is the room ID. The host should also provide you with the room's server name and port number.
|
||||
|
||||
Once you do, follow these steps to connect to the room:
|
||||
1. Run the TWW AP Randomizer Build. If this is the first time you've opened the randomizer, you'll need to specify the
|
||||
path to your The Wind Waker ISO and the output folder for the randomized ISO. These will be saved for the next time you
|
||||
open the program.
|
||||
2. Modify any cosmetic convenience tweaks and player customization options as desired.
|
||||
3. For the APTWW file, browse and locate the path to your `aptww` file.
|
||||
4. Click `Randomize` at the bottom-right. This randomizes the ISO and puts it in the output folder you specified. The
|
||||
file will be named `TWW AP_YYYYY_P# (<name>).iso`, where `YYYYY` is the seed name, `#` is your player ID, and `<name>`
|
||||
is your player (slot) name. Verify that the values are correct for the multiworld.
|
||||
5. Open Dolphin and use it to open the randomized ISO.
|
||||
6. Start `ArchipelagoLauncher.exe` (without `.exe` on Linux) and choose `The Wind Waker Client`, which will open the
|
||||
text client. If Dolphin is not already open, or you have yet to start a new file, you will be prompted to do so.
|
||||
* Once you've opened the ISO in Dolphin, the client should say "Dolphin connected successfully.".
|
||||
7. Connect to the room by entering the server name and port number at the top and pressing `Connect`. For rooms hosted
|
||||
on the website, this will be `archipelago.gg:<port>`, where `<port>` is the port number. If a game is hosted from the
|
||||
`ArchipelagoServer.exe` (without `.exe` on Linux), the port number will default to `38281` but may be changed in the
|
||||
`host.yaml`.
|
||||
8. If you've opened a ROM corresponding to the multiworld to which you are connected, it should authenticate your slot
|
||||
name automatically when you start a new save file.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
* Ensure you are running the same version of Archipelago on which the multiworld was generated.
|
||||
* Ensure `tww.apworld` is not in your Archipelago installation's `custom_worlds` folder.
|
||||
* Ensure you are using the correct randomizer build for the version of Archipelago you are using. The build should
|
||||
provide an error message directing you to the correct version. You can also look at the release notes of TWW AP builds
|
||||
[here](https://github.com/tanjo3/wwrando/releases) to see which versions of Archipelago each build is compatible with.
|
||||
* If you encounter issues with authenticating, ensure that the randomized ROM is open in Dolphin and corresponds to the
|
||||
multiworld to which you are connecting.
|
||||
* Ensure that you do not have any Dolphin cheats or codes enabled. Some cheats or codes can unexpectedly interfere with
|
||||
emulation and make troubleshooting errors difficult.
|
||||
* If you get an error message, ensure that `Enable Emulated Memory Size Override` in Dolphin (under `Options` >
|
||||
`Configuration` > `Advanced`) is **disabled**.
|
||||
* If you run with a custom GC boot menu, you'll need to skip it by going to `Options` > `Configuration` > `GameCube`
|
||||
and checking `Skip Main Menu`.
|
125
worlds/tww/randomizers/Charts.py
Normal file
125
worlds/tww/randomizers/Charts.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ..Items import ISLAND_NUMBER_TO_CHART_NAME
|
||||
from ..Locations import TWWFlag, TWWLocation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import TWWWorld
|
||||
|
||||
ISLAND_NUMBER_TO_NAME: dict[int, str] = {
|
||||
1: "Forsaken Fortress Sector",
|
||||
2: "Star Island",
|
||||
3: "Northern Fairy Island",
|
||||
4: "Gale Isle",
|
||||
5: "Crescent Moon Island",
|
||||
6: "Seven-Star Isles",
|
||||
7: "Overlook Island",
|
||||
8: "Four-Eye Reef",
|
||||
9: "Mother and Child Isles",
|
||||
10: "Spectacle Island",
|
||||
11: "Windfall Island",
|
||||
12: "Pawprint Isle",
|
||||
13: "Dragon Roost Island",
|
||||
14: "Flight Control Platform",
|
||||
15: "Western Fairy Island",
|
||||
16: "Rock Spire Isle",
|
||||
17: "Tingle Island",
|
||||
18: "Northern Triangle Island",
|
||||
19: "Eastern Fairy Island",
|
||||
20: "Fire Mountain",
|
||||
21: "Star Belt Archipelago",
|
||||
22: "Three-Eye Reef",
|
||||
23: "Greatfish Isle",
|
||||
24: "Cyclops Reef",
|
||||
25: "Six-Eye Reef",
|
||||
26: "Tower of the Gods Sector",
|
||||
27: "Eastern Triangle Island",
|
||||
28: "Thorned Fairy Island",
|
||||
29: "Needle Rock Isle",
|
||||
30: "Islet of Steel",
|
||||
31: "Stone Watcher Island",
|
||||
32: "Southern Triangle Island",
|
||||
33: "Private Oasis",
|
||||
34: "Bomb Island",
|
||||
35: "Bird's Peak Rock",
|
||||
36: "Diamond Steppe Island",
|
||||
37: "Five-Eye Reef",
|
||||
38: "Shark Island",
|
||||
39: "Southern Fairy Island",
|
||||
40: "Ice Ring Isle",
|
||||
41: "Forest Haven",
|
||||
42: "Cliff Plateau Isles",
|
||||
43: "Horseshoe Island",
|
||||
44: "Outset Island",
|
||||
45: "Headstone Island",
|
||||
46: "Two-Eye Reef",
|
||||
47: "Angular Isles",
|
||||
48: "Boating Course",
|
||||
49: "Five-Star Isles",
|
||||
}
|
||||
|
||||
|
||||
class ChartRandomizer:
|
||||
"""
|
||||
This class handles the randomization of charts.
|
||||
Each chart points to a specific island on the map, and this randomizer shuffles these mappings.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
"""
|
||||
|
||||
def __init__(self, world: "TWWWorld") -> None:
|
||||
self.world = world
|
||||
self.multiworld = world.multiworld
|
||||
|
||||
self.island_number_to_chart_name = ISLAND_NUMBER_TO_CHART_NAME.copy()
|
||||
|
||||
def setup_progress_sunken_treasure_locations(self) -> None:
|
||||
"""
|
||||
Create the locations for sunken treasure locations and update them as progression and non-progression
|
||||
appropriately. If the option is enabled, randomize which charts point to which sector.
|
||||
"""
|
||||
options = self.world.options
|
||||
|
||||
original_item_names = list(self.island_number_to_chart_name.values())
|
||||
|
||||
# Shuffles the list of island numbers if charts are randomized.
|
||||
# The shuffled island numbers determine which sector each chart points to.
|
||||
shuffled_island_numbers = list(self.island_number_to_chart_name.keys())
|
||||
if options.randomize_charts:
|
||||
self.world.random.shuffle(shuffled_island_numbers)
|
||||
|
||||
for original_item_name in reversed(original_item_names):
|
||||
# Assign each chart to its new island.
|
||||
shuffled_island_number = shuffled_island_numbers.pop()
|
||||
self.island_number_to_chart_name[shuffled_island_number] = original_item_name
|
||||
|
||||
# Additionally, determine if that location is a progress location or not.
|
||||
island_name = ISLAND_NUMBER_TO_NAME[shuffled_island_number]
|
||||
island_location = f"{island_name} - Sunken Treasure"
|
||||
if options.progression_triforce_charts or options.progression_treasure_charts:
|
||||
if original_item_name.startswith("Triforce Chart "):
|
||||
if options.progression_triforce_charts:
|
||||
self.world.progress_locations.add(island_location)
|
||||
self.world.nonprogress_locations.remove(island_location)
|
||||
else:
|
||||
if options.progression_treasure_charts:
|
||||
self.world.progress_locations.add(island_location)
|
||||
self.world.nonprogress_locations.remove(island_location)
|
||||
else:
|
||||
self.world.nonprogress_locations.add(island_location)
|
||||
|
||||
def update_chart_location_flags(self) -> None:
|
||||
"""
|
||||
Update the flags for sunken treasure locations based on the current chart mappings.
|
||||
"""
|
||||
for shuffled_island_number, item_name in self.island_number_to_chart_name.items():
|
||||
island_name = ISLAND_NUMBER_TO_NAME[shuffled_island_number]
|
||||
island_location_str = f"{island_name} - Sunken Treasure"
|
||||
|
||||
if island_location_str in self.world.progress_locations:
|
||||
island_location = self.world.get_location(island_location_str)
|
||||
assert isinstance(island_location, TWWLocation)
|
||||
if item_name.startswith("Triforce Chart "):
|
||||
island_location.flags = TWWFlag.TRI_CHT
|
||||
else:
|
||||
island_location.flags = TWWFlag.TRE_CHT
|
284
worlds/tww/randomizers/Dungeons.py
Normal file
284
worlds/tww/randomizers/Dungeons.py
Normal file
@@ -0,0 +1,284 @@
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from BaseClasses import CollectionState, Item, Location, MultiWorld
|
||||
from Fill import fill_restrictive
|
||||
from worlds.generic.Rules import add_item_rule
|
||||
|
||||
from ..Items import item_factory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import TWWWorld
|
||||
|
||||
|
||||
class Dungeon:
|
||||
"""
|
||||
This class represents a dungeon in The Wind Waker, including its dungeon items.
|
||||
|
||||
:param name: The name of the dungeon.
|
||||
:param big_key: The big key item for the dungeon.
|
||||
:param small_keys: A list of small key items for the dungeon.
|
||||
:param dungeon_items: A list of other items specific to the dungeon.
|
||||
:param player: The ID of the player associated with the dungeon.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
big_key: Optional[Item],
|
||||
small_keys: list[Item],
|
||||
dungeon_items: list[Item],
|
||||
player: int,
|
||||
):
|
||||
self.name = name
|
||||
self.big_key = big_key
|
||||
self.small_keys = small_keys
|
||||
self.dungeon_items = dungeon_items
|
||||
self.player = player
|
||||
|
||||
@property
|
||||
def keys(self) -> list[Item]:
|
||||
"""
|
||||
Retrieve all the keys for the dungeon.
|
||||
|
||||
:return: A list of Small Keys and the Big Key (if it exists).
|
||||
"""
|
||||
return self.small_keys + ([self.big_key] if self.big_key else [])
|
||||
|
||||
@property
|
||||
def all_items(self) -> list[Item]:
|
||||
"""
|
||||
Retrieve all items associated with the dungeon.
|
||||
|
||||
:return: A list of all items associated with the dungeon.
|
||||
"""
|
||||
return self.dungeon_items + self.keys
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""
|
||||
Check equality between this dungeon and another object.
|
||||
|
||||
:param other: The object to compare.
|
||||
:return: `True` if the other object is a Dungeon with the same name and player, `False` otherwise.
|
||||
"""
|
||||
if isinstance(other, Dungeon):
|
||||
return self.name == other.name and self.player == other.player
|
||||
return False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Provide a string representation of the dungeon.
|
||||
|
||||
:return: A string representing the dungeon.
|
||||
"""
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""
|
||||
Convert the dungeon to a human-readable string.
|
||||
|
||||
:return: A string in the format "<name> (Player <player>)".
|
||||
"""
|
||||
return f"{self.name} (Player {self.player})"
|
||||
|
||||
|
||||
def create_dungeons(world: "TWWWorld") -> None:
|
||||
"""
|
||||
Create and assign dungeons to the given world based on game options.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
"""
|
||||
player = world.player
|
||||
options = world.options
|
||||
|
||||
def make_dungeon(name: str, big_key: Optional[Item], small_keys: list[Item], dungeon_items: list[Item]) -> Dungeon:
|
||||
dungeon = Dungeon(name, big_key, small_keys, dungeon_items, player)
|
||||
for item in dungeon.all_items:
|
||||
item.dungeon = dungeon
|
||||
return dungeon
|
||||
|
||||
if options.progression_dungeons:
|
||||
if not options.required_bosses or "Dragon Roost Cavern" in world.boss_reqs.required_dungeons:
|
||||
world.dungeons["Dragon Roost Cavern"] = make_dungeon(
|
||||
"Dragon Roost Cavern",
|
||||
item_factory("DRC Big Key", world),
|
||||
item_factory(["DRC Small Key"] * 4, world),
|
||||
item_factory(["DRC Dungeon Map", "DRC Compass"], world),
|
||||
)
|
||||
|
||||
if not options.required_bosses or "Forbidden Woods" in world.boss_reqs.required_dungeons:
|
||||
world.dungeons["Forbidden Woods"] = make_dungeon(
|
||||
"Forbidden Woods",
|
||||
item_factory("FW Big Key", world),
|
||||
item_factory(["FW Small Key"] * 1, world),
|
||||
item_factory(["FW Dungeon Map", "FW Compass"], world),
|
||||
)
|
||||
|
||||
if not options.required_bosses or "Tower of the Gods" in world.boss_reqs.required_dungeons:
|
||||
world.dungeons["Tower of the Gods"] = make_dungeon(
|
||||
"Tower of the Gods",
|
||||
item_factory("TotG Big Key", world),
|
||||
item_factory(["TotG Small Key"] * 2, world),
|
||||
item_factory(["TotG Dungeon Map", "TotG Compass"], world),
|
||||
)
|
||||
|
||||
if not options.required_bosses or "Forsaken Fortress" in world.boss_reqs.required_dungeons:
|
||||
world.dungeons["Forsaken Fortress"] = make_dungeon(
|
||||
"Forsaken Fortress",
|
||||
None,
|
||||
[],
|
||||
item_factory(["FF Dungeon Map", "FF Compass"], world),
|
||||
)
|
||||
|
||||
if not options.required_bosses or "Earth Temple" in world.boss_reqs.required_dungeons:
|
||||
world.dungeons["Earth Temple"] = make_dungeon(
|
||||
"Earth Temple",
|
||||
item_factory("ET Big Key", world),
|
||||
item_factory(["ET Small Key"] * 3, world),
|
||||
item_factory(["ET Dungeon Map", "ET Compass"], world),
|
||||
)
|
||||
|
||||
if not options.required_bosses or "Wind Temple" in world.boss_reqs.required_dungeons:
|
||||
world.dungeons["Wind Temple"] = make_dungeon(
|
||||
"Wind Temple",
|
||||
item_factory("WT Big Key", world),
|
||||
item_factory(["WT Small Key"] * 2, world),
|
||||
item_factory(["WT Dungeon Map", "WT Compass"], world),
|
||||
)
|
||||
|
||||
|
||||
def get_dungeon_item_pool(multiworld: MultiWorld) -> list[Item]:
|
||||
"""
|
||||
Retrieve the item pool for all The Wind Waker dungeons in the multiworld.
|
||||
|
||||
:param multiworld: The MultiWorld instance.
|
||||
:return: List of dungeon items across all The Wind Waker dungeons.
|
||||
"""
|
||||
return [
|
||||
item for world in multiworld.get_game_worlds("The Wind Waker") for item in get_dungeon_item_pool_player(world)
|
||||
]
|
||||
|
||||
|
||||
def get_dungeon_item_pool_player(world: "TWWWorld") -> list[Item]:
|
||||
"""
|
||||
Retrieve the item pool for all dungeons specific to a player.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
:return: List of items in the player's dungeons.
|
||||
"""
|
||||
return [item for dungeon in world.dungeons.values() for item in dungeon.all_items]
|
||||
|
||||
|
||||
def get_unfilled_dungeon_locations(multiworld: MultiWorld) -> list[Location]:
|
||||
"""
|
||||
Retrieve all unfilled The Wind Waker dungeon locations in the multiworld.
|
||||
|
||||
:param multiworld: The MultiWorld instance.
|
||||
:return: List of unfilled The Wind Waker dungeon locations.
|
||||
"""
|
||||
return [
|
||||
location
|
||||
for world in multiworld.get_game_worlds("The Wind Waker")
|
||||
for location in multiworld.get_locations(world.player)
|
||||
if location.dungeon and not location.item
|
||||
]
|
||||
|
||||
|
||||
def modify_dungeon_location_rules(multiworld: MultiWorld) -> None:
|
||||
"""
|
||||
Modify the rules for The Wind Waker dungeon locations based on specific player-requested constraints.
|
||||
|
||||
:param multiworld: The MultiWorld instance.
|
||||
"""
|
||||
localized: set[tuple[int, str]] = set()
|
||||
dungeon_specific: set[tuple[int, str]] = set()
|
||||
for subworld in multiworld.get_game_worlds("The Wind Waker"):
|
||||
player = subworld.player
|
||||
if player not in multiworld.groups:
|
||||
localized |= {(player, item_name) for item_name in subworld.dungeon_local_item_names}
|
||||
dungeon_specific |= {(player, item_name) for item_name in subworld.dungeon_specific_item_names}
|
||||
|
||||
if localized:
|
||||
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
|
||||
if in_dungeon_items:
|
||||
locations = [location for location in get_unfilled_dungeon_locations(multiworld)]
|
||||
|
||||
for location in locations:
|
||||
if dungeon_specific:
|
||||
# Special case: If Dragon Roost Cavern has its own small keys, then ensure the first chest isn't the
|
||||
# Big Key. This is to avoid placing the Big Key there during fill and resulting in a costly swap.
|
||||
if location.name == "Dragon Roost Cavern - First Room":
|
||||
add_item_rule(
|
||||
location,
|
||||
lambda item: item.name != "DRC Big Key"
|
||||
or (item.player, "DRC Small Key") in dungeon_specific,
|
||||
)
|
||||
|
||||
# Add item rule to ensure dungeon items are in their own dungeon when they should be.
|
||||
add_item_rule(
|
||||
location,
|
||||
lambda item, dungeon=location.dungeon: not (item.player, item.name) in dungeon_specific
|
||||
or item.dungeon is dungeon,
|
||||
)
|
||||
|
||||
|
||||
def fill_dungeons_restrictive(multiworld: MultiWorld) -> None:
|
||||
"""
|
||||
Correctly fill The Wind Waker dungeons in the multiworld.
|
||||
|
||||
:param multiworld: The MultiWorld instance.
|
||||
"""
|
||||
localized: set[tuple[int, str]] = set()
|
||||
dungeon_specific: set[tuple[int, str]] = set()
|
||||
for subworld in multiworld.get_game_worlds("The Wind Waker"):
|
||||
player = subworld.player
|
||||
if player not in multiworld.groups:
|
||||
localized |= {(player, item_name) for item_name in subworld.dungeon_local_item_names}
|
||||
dungeon_specific |= {(player, item_name) for item_name in subworld.dungeon_specific_item_names}
|
||||
|
||||
if localized:
|
||||
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
|
||||
if in_dungeon_items:
|
||||
locations = [location for location in get_unfilled_dungeon_locations(multiworld)]
|
||||
multiworld.random.shuffle(locations)
|
||||
|
||||
# Dungeon-locked items have to be placed first so as not to run out of space for dungeon-locked items.
|
||||
# Subsort in the order Big Key, Small Key, Other before placing dungeon items.
|
||||
sort_order = {"Big Key": 3, "Small Key": 2}
|
||||
in_dungeon_items.sort(
|
||||
key=lambda item: sort_order.get(item.type, 1)
|
||||
+ (5 if (item.player, item.name) in dungeon_specific else 0)
|
||||
)
|
||||
|
||||
# Construct a partial `all_state` that contains only the items from `get_pre_fill_items` that aren't in a
|
||||
# dungeon.
|
||||
in_dungeon_player_ids = {item.player for item in in_dungeon_items}
|
||||
all_state_base = CollectionState(multiworld)
|
||||
for item in multiworld.itempool:
|
||||
multiworld.worlds[item.player].collect(all_state_base, item)
|
||||
pre_fill_items = []
|
||||
for player in in_dungeon_player_ids:
|
||||
pre_fill_items += multiworld.worlds[player].get_pre_fill_items()
|
||||
for item in in_dungeon_items:
|
||||
try:
|
||||
pre_fill_items.remove(item)
|
||||
except ValueError:
|
||||
# `pre_fill_items` should be a subset of `in_dungeon_items`, but just in case.
|
||||
pass
|
||||
for item in pre_fill_items:
|
||||
multiworld.worlds[item.player].collect(all_state_base, item)
|
||||
all_state_base.sweep_for_advancements()
|
||||
|
||||
# Remove the completion condition so that minimal-accessibility words place keys correctly.
|
||||
for player in (item.player for item in in_dungeon_items):
|
||||
if all_state_base.has("Victory", player):
|
||||
all_state_base.remove(multiworld.worlds[player].create_item("Victory"))
|
||||
|
||||
fill_restrictive(
|
||||
multiworld,
|
||||
all_state_base,
|
||||
locations,
|
||||
in_dungeon_items,
|
||||
lock=True,
|
||||
allow_excluded=True,
|
||||
name="TWW Dungeon Items",
|
||||
)
|
878
worlds/tww/randomizers/Entrances.py
Normal file
878
worlds/tww/randomizers/Entrances.py
Normal file
@@ -0,0 +1,878 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, ClassVar, Optional
|
||||
|
||||
from Fill import FillError
|
||||
from Options import OptionError
|
||||
|
||||
from .. import Macros
|
||||
from ..Locations import LOCATION_TABLE, TWWFlag, split_location_name_by_zone
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import TWWWorld
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ZoneEntrance:
|
||||
"""
|
||||
A data class that encapsulates information about a zone entrance.
|
||||
"""
|
||||
|
||||
entrance_name: str
|
||||
island_name: Optional[str] = None
|
||||
nested_in: Optional["ZoneExit"] = None
|
||||
|
||||
@property
|
||||
def is_nested(self) -> bool:
|
||||
"""
|
||||
Determine if this entrance is nested within another entrance.
|
||||
|
||||
:return: `True` if the entrance is nested, `False` otherwise.
|
||||
"""
|
||||
return self.nested_in is not None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Provide a string representation of the zone exit.
|
||||
|
||||
:return: A string representing the zone exit.
|
||||
"""
|
||||
return f"ZoneEntrance('{self.entrance_name}')"
|
||||
|
||||
all: ClassVar[dict[str, "ZoneEntrance"]] = {}
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
ZoneEntrance.all[self.entrance_name] = self
|
||||
|
||||
# Must be an island entrance XOR must be a nested entrance.
|
||||
assert (self.island_name is None) ^ (self.nested_in is None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ZoneExit:
|
||||
"""
|
||||
A data class that encapsulates information about a zone exit.
|
||||
"""
|
||||
|
||||
unique_name: str
|
||||
zone_name: Optional[str] = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Provide a string representation of the zone exit.
|
||||
|
||||
:return: A string representing the zone exit.
|
||||
"""
|
||||
return f"ZoneExit('{self.unique_name}')"
|
||||
|
||||
all: ClassVar[dict[str, "ZoneExit"]] = {}
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
ZoneExit.all[self.unique_name] = self
|
||||
|
||||
|
||||
DUNGEON_ENTRANCES: list[ZoneEntrance] = [
|
||||
ZoneEntrance("Dungeon Entrance on Dragon Roost Island", "Dragon Roost Island"),
|
||||
ZoneEntrance("Dungeon Entrance in Forest Haven Sector", "Forest Haven"),
|
||||
ZoneEntrance("Dungeon Entrance in Tower of the Gods Sector", "Tower of the Gods Sector"),
|
||||
ZoneEntrance("Dungeon Entrance on Headstone Island", "Headstone Island"),
|
||||
ZoneEntrance("Dungeon Entrance on Gale Isle", "Gale Isle"),
|
||||
]
|
||||
DUNGEON_EXITS: list[ZoneExit] = [
|
||||
ZoneExit("Dragon Roost Cavern", "Dragon Roost Cavern"),
|
||||
ZoneExit("Forbidden Woods", "Forbidden Woods"),
|
||||
ZoneExit("Tower of the Gods", "Tower of the Gods"),
|
||||
ZoneExit("Earth Temple", "Earth Temple"),
|
||||
ZoneExit("Wind Temple", "Wind Temple"),
|
||||
]
|
||||
|
||||
MINIBOSS_ENTRANCES: list[ZoneEntrance] = [
|
||||
ZoneEntrance("Miniboss Entrance in Forbidden Woods", nested_in=ZoneExit.all["Forbidden Woods"]),
|
||||
ZoneEntrance("Miniboss Entrance in Tower of the Gods", nested_in=ZoneExit.all["Tower of the Gods"]),
|
||||
ZoneEntrance("Miniboss Entrance in Earth Temple", nested_in=ZoneExit.all["Earth Temple"]),
|
||||
ZoneEntrance("Miniboss Entrance in Wind Temple", nested_in=ZoneExit.all["Wind Temple"]),
|
||||
ZoneEntrance("Miniboss Entrance in Hyrule Castle", "Tower of the Gods Sector"),
|
||||
]
|
||||
MINIBOSS_EXITS: list[ZoneExit] = [
|
||||
ZoneExit("Forbidden Woods Miniboss Arena"),
|
||||
ZoneExit("Tower of the Gods Miniboss Arena"),
|
||||
ZoneExit("Earth Temple Miniboss Arena"),
|
||||
ZoneExit("Wind Temple Miniboss Arena"),
|
||||
ZoneExit("Master Sword Chamber"),
|
||||
]
|
||||
|
||||
BOSS_ENTRANCES: list[ZoneEntrance] = [
|
||||
ZoneEntrance("Boss Entrance in Dragon Roost Cavern", nested_in=ZoneExit.all["Dragon Roost Cavern"]),
|
||||
ZoneEntrance("Boss Entrance in Forbidden Woods", nested_in=ZoneExit.all["Forbidden Woods"]),
|
||||
ZoneEntrance("Boss Entrance in Tower of the Gods", nested_in=ZoneExit.all["Tower of the Gods"]),
|
||||
ZoneEntrance("Boss Entrance in Forsaken Fortress", "Forsaken Fortress Sector"),
|
||||
ZoneEntrance("Boss Entrance in Earth Temple", nested_in=ZoneExit.all["Earth Temple"]),
|
||||
ZoneEntrance("Boss Entrance in Wind Temple", nested_in=ZoneExit.all["Wind Temple"]),
|
||||
]
|
||||
BOSS_EXITS: list[ZoneExit] = [
|
||||
ZoneExit("Gohma Boss Arena"),
|
||||
ZoneExit("Kalle Demos Boss Arena"),
|
||||
ZoneExit("Gohdan Boss Arena"),
|
||||
ZoneExit("Helmaroc King Boss Arena"),
|
||||
ZoneExit("Jalhalla Boss Arena"),
|
||||
ZoneExit("Molgera Boss Arena"),
|
||||
]
|
||||
|
||||
SECRET_CAVE_ENTRANCES: list[ZoneEntrance] = [
|
||||
ZoneEntrance("Secret Cave Entrance on Outset Island", "Outset Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Dragon Roost Island", "Dragon Roost Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Fire Mountain", "Fire Mountain"),
|
||||
ZoneEntrance("Secret Cave Entrance on Ice Ring Isle", "Ice Ring Isle"),
|
||||
ZoneEntrance("Secret Cave Entrance on Private Oasis", "Private Oasis"),
|
||||
ZoneEntrance("Secret Cave Entrance on Needle Rock Isle", "Needle Rock Isle"),
|
||||
ZoneEntrance("Secret Cave Entrance on Angular Isles", "Angular Isles"),
|
||||
ZoneEntrance("Secret Cave Entrance on Boating Course", "Boating Course"),
|
||||
ZoneEntrance("Secret Cave Entrance on Stone Watcher Island", "Stone Watcher Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Overlook Island", "Overlook Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Bird's Peak Rock", "Bird's Peak Rock"),
|
||||
ZoneEntrance("Secret Cave Entrance on Pawprint Isle", "Pawprint Isle"),
|
||||
ZoneEntrance("Secret Cave Entrance on Pawprint Isle Side Isle", "Pawprint Isle"),
|
||||
ZoneEntrance("Secret Cave Entrance on Diamond Steppe Island", "Diamond Steppe Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Bomb Island", "Bomb Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Rock Spire Isle", "Rock Spire Isle"),
|
||||
ZoneEntrance("Secret Cave Entrance on Shark Island", "Shark Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Cliff Plateau Isles", "Cliff Plateau Isles"),
|
||||
ZoneEntrance("Secret Cave Entrance on Horseshoe Island", "Horseshoe Island"),
|
||||
ZoneEntrance("Secret Cave Entrance on Star Island", "Star Island"),
|
||||
]
|
||||
SECRET_CAVE_EXITS: list[ZoneExit] = [
|
||||
ZoneExit("Savage Labyrinth", zone_name="Outset Island"),
|
||||
ZoneExit("Dragon Roost Island Secret Cave", zone_name="Dragon Roost Island"),
|
||||
ZoneExit("Fire Mountain Secret Cave", zone_name="Fire Mountain"),
|
||||
ZoneExit("Ice Ring Isle Secret Cave", zone_name="Ice Ring Isle"),
|
||||
ZoneExit("Cabana Labyrinth", zone_name="Private Oasis"),
|
||||
ZoneExit("Needle Rock Isle Secret Cave", zone_name="Needle Rock Isle"),
|
||||
ZoneExit("Angular Isles Secret Cave", zone_name="Angular Isles"),
|
||||
ZoneExit("Boating Course Secret Cave", zone_name="Boating Course"),
|
||||
ZoneExit("Stone Watcher Island Secret Cave", zone_name="Stone Watcher Island"),
|
||||
ZoneExit("Overlook Island Secret Cave", zone_name="Overlook Island"),
|
||||
ZoneExit("Bird's Peak Rock Secret Cave", zone_name="Bird's Peak Rock"),
|
||||
ZoneExit("Pawprint Isle Chuchu Cave", zone_name="Pawprint Isle"),
|
||||
ZoneExit("Pawprint Isle Wizzrobe Cave"),
|
||||
ZoneExit("Diamond Steppe Island Warp Maze Cave", zone_name="Diamond Steppe Island"),
|
||||
ZoneExit("Bomb Island Secret Cave", zone_name="Bomb Island"),
|
||||
ZoneExit("Rock Spire Isle Secret Cave", zone_name="Rock Spire Isle"),
|
||||
ZoneExit("Shark Island Secret Cave", zone_name="Shark Island"),
|
||||
ZoneExit("Cliff Plateau Isles Secret Cave", zone_name="Cliff Plateau Isles"),
|
||||
ZoneExit("Horseshoe Island Secret Cave", zone_name="Horseshoe Island"),
|
||||
ZoneExit("Star Island Secret Cave", zone_name="Star Island"),
|
||||
]
|
||||
|
||||
SECRET_CAVE_INNER_ENTRANCES: list[ZoneEntrance] = [
|
||||
ZoneEntrance("Inner Entrance in Ice Ring Isle Secret Cave", nested_in=ZoneExit.all["Ice Ring Isle Secret Cave"]),
|
||||
ZoneEntrance(
|
||||
"Inner Entrance in Cliff Plateau Isles Secret Cave", nested_in=ZoneExit.all["Cliff Plateau Isles Secret Cave"]
|
||||
),
|
||||
]
|
||||
SECRET_CAVE_INNER_EXITS: list[ZoneExit] = [
|
||||
ZoneExit("Ice Ring Isle Inner Cave"),
|
||||
ZoneExit("Cliff Plateau Isles Inner Cave"),
|
||||
]
|
||||
|
||||
FAIRY_FOUNTAIN_ENTRANCES: list[ZoneEntrance] = [
|
||||
ZoneEntrance("Fairy Fountain Entrance on Outset Island", "Outset Island"),
|
||||
ZoneEntrance("Fairy Fountain Entrance on Thorned Fairy Island", "Thorned Fairy Island"),
|
||||
ZoneEntrance("Fairy Fountain Entrance on Eastern Fairy Island", "Eastern Fairy Island"),
|
||||
ZoneEntrance("Fairy Fountain Entrance on Western Fairy Island", "Western Fairy Island"),
|
||||
ZoneEntrance("Fairy Fountain Entrance on Southern Fairy Island", "Southern Fairy Island"),
|
||||
ZoneEntrance("Fairy Fountain Entrance on Northern Fairy Island", "Northern Fairy Island"),
|
||||
]
|
||||
FAIRY_FOUNTAIN_EXITS: list[ZoneExit] = [
|
||||
ZoneExit("Outset Fairy Fountain"),
|
||||
ZoneExit("Thorned Fairy Fountain", zone_name="Thorned Fairy Island"),
|
||||
ZoneExit("Eastern Fairy Fountain", zone_name="Eastern Fairy Island"),
|
||||
ZoneExit("Western Fairy Fountain", zone_name="Western Fairy Island"),
|
||||
ZoneExit("Southern Fairy Fountain", zone_name="Southern Fairy Island"),
|
||||
ZoneExit("Northern Fairy Fountain", zone_name="Northern Fairy Island"),
|
||||
]
|
||||
|
||||
DUNGEON_INNER_EXITS: list[ZoneExit] = (
|
||||
MINIBOSS_EXITS
|
||||
+ BOSS_EXITS
|
||||
)
|
||||
|
||||
ALL_ENTRANCES: list[ZoneEntrance] = (
|
||||
DUNGEON_ENTRANCES
|
||||
+ MINIBOSS_ENTRANCES
|
||||
+ BOSS_ENTRANCES
|
||||
+ SECRET_CAVE_ENTRANCES
|
||||
+ SECRET_CAVE_INNER_ENTRANCES
|
||||
+ FAIRY_FOUNTAIN_ENTRANCES
|
||||
)
|
||||
ALL_EXITS: list[ZoneExit] = (
|
||||
DUNGEON_EXITS
|
||||
+ MINIBOSS_EXITS
|
||||
+ BOSS_EXITS
|
||||
+ SECRET_CAVE_EXITS
|
||||
+ SECRET_CAVE_INNER_EXITS
|
||||
+ FAIRY_FOUNTAIN_EXITS
|
||||
)
|
||||
|
||||
ENTRANCE_RANDOMIZABLE_ITEM_LOCATION_TYPES: list[TWWFlag] = [
|
||||
TWWFlag.DUNGEON,
|
||||
TWWFlag.PZL_CVE,
|
||||
TWWFlag.CBT_CVE,
|
||||
TWWFlag.SAVAGE,
|
||||
TWWFlag.GRT_FRY,
|
||||
]
|
||||
ITEM_LOCATION_NAME_TO_EXIT_OVERRIDES: dict[str, ZoneExit] = {
|
||||
"Forbidden Woods - Mothula Miniboss Room": ZoneExit.all["Forbidden Woods Miniboss Arena"],
|
||||
"Tower of the Gods - Darknut Miniboss Room": ZoneExit.all["Tower of the Gods Miniboss Arena"],
|
||||
"Earth Temple - Stalfos Miniboss Room": ZoneExit.all["Earth Temple Miniboss Arena"],
|
||||
"Wind Temple - Wizzrobe Miniboss Room": ZoneExit.all["Wind Temple Miniboss Arena"],
|
||||
"Hyrule - Master Sword Chamber": ZoneExit.all["Master Sword Chamber"],
|
||||
|
||||
"Dragon Roost Cavern - Gohma Heart Container": ZoneExit.all["Gohma Boss Arena"],
|
||||
"Forbidden Woods - Kalle Demos Heart Container": ZoneExit.all["Kalle Demos Boss Arena"],
|
||||
"Tower of the Gods - Gohdan Heart Container": ZoneExit.all["Gohdan Boss Arena"],
|
||||
"Forsaken Fortress - Helmaroc King Heart Container": ZoneExit.all["Helmaroc King Boss Arena"],
|
||||
"Earth Temple - Jalhalla Heart Container": ZoneExit.all["Jalhalla Boss Arena"],
|
||||
"Wind Temple - Molgera Heart Container": ZoneExit.all["Molgera Boss Arena"],
|
||||
|
||||
"Pawprint Isle - Wizzrobe Cave": ZoneExit.all["Pawprint Isle Wizzrobe Cave"],
|
||||
|
||||
"Ice Ring Isle - Inner Cave - Chest": ZoneExit.all["Ice Ring Isle Inner Cave"],
|
||||
"Cliff Plateau Isles - Highest Isle": ZoneExit.all["Cliff Plateau Isles Inner Cave"],
|
||||
|
||||
"Outset Island - Great Fairy": ZoneExit.all["Outset Fairy Fountain"],
|
||||
}
|
||||
|
||||
MINIBOSS_EXIT_TO_DUNGEON: dict[str, str] = {
|
||||
"Forbidden Woods Miniboss Arena": "Forbidden Woods",
|
||||
"Tower of the Gods Miniboss Arena": "Tower of the Gods",
|
||||
"Earth Temple Miniboss Arena": "Earth Temple",
|
||||
"Wind Temple Miniboss Arena": "Wind Temple",
|
||||
}
|
||||
|
||||
BOSS_EXIT_TO_DUNGEON: dict[str, str] = {
|
||||
"Gohma Boss Arena": "Dragon Roost Cavern",
|
||||
"Kalle Demos Boss Arena": "Forbidden Woods",
|
||||
"Gohdan Boss Arena": "Tower of the Gods",
|
||||
"Helmaroc King Boss Arena": "Forsaken Fortress",
|
||||
"Jalhalla Boss Arena": "Earth Temple",
|
||||
"Molgera Boss Arena": "Wind Temple",
|
||||
}
|
||||
|
||||
VANILLA_ENTRANCES_TO_EXITS: dict[str, str] = {
|
||||
"Dungeon Entrance on Dragon Roost Island": "Dragon Roost Cavern",
|
||||
"Dungeon Entrance in Forest Haven Sector": "Forbidden Woods",
|
||||
"Dungeon Entrance in Tower of the Gods Sector": "Tower of the Gods",
|
||||
"Dungeon Entrance on Headstone Island": "Earth Temple",
|
||||
"Dungeon Entrance on Gale Isle": "Wind Temple",
|
||||
|
||||
"Miniboss Entrance in Forbidden Woods": "Forbidden Woods Miniboss Arena",
|
||||
"Miniboss Entrance in Tower of the Gods": "Tower of the Gods Miniboss Arena",
|
||||
"Miniboss Entrance in Earth Temple": "Earth Temple Miniboss Arena",
|
||||
"Miniboss Entrance in Wind Temple": "Wind Temple Miniboss Arena",
|
||||
"Miniboss Entrance in Hyrule Castle": "Master Sword Chamber",
|
||||
|
||||
"Boss Entrance in Dragon Roost Cavern": "Gohma Boss Arena",
|
||||
"Boss Entrance in Forbidden Woods": "Kalle Demos Boss Arena",
|
||||
"Boss Entrance in Tower of the Gods": "Gohdan Boss Arena",
|
||||
"Boss Entrance in Forsaken Fortress": "Helmaroc King Boss Arena",
|
||||
"Boss Entrance in Earth Temple": "Jalhalla Boss Arena",
|
||||
"Boss Entrance in Wind Temple": "Molgera Boss Arena",
|
||||
|
||||
"Secret Cave Entrance on Outset Island": "Savage Labyrinth",
|
||||
"Secret Cave Entrance on Dragon Roost Island": "Dragon Roost Island Secret Cave",
|
||||
"Secret Cave Entrance on Fire Mountain": "Fire Mountain Secret Cave",
|
||||
"Secret Cave Entrance on Ice Ring Isle": "Ice Ring Isle Secret Cave",
|
||||
"Secret Cave Entrance on Private Oasis": "Cabana Labyrinth",
|
||||
"Secret Cave Entrance on Needle Rock Isle": "Needle Rock Isle Secret Cave",
|
||||
"Secret Cave Entrance on Angular Isles": "Angular Isles Secret Cave",
|
||||
"Secret Cave Entrance on Boating Course": "Boating Course Secret Cave",
|
||||
"Secret Cave Entrance on Stone Watcher Island": "Stone Watcher Island Secret Cave",
|
||||
"Secret Cave Entrance on Overlook Island": "Overlook Island Secret Cave",
|
||||
"Secret Cave Entrance on Bird's Peak Rock": "Bird's Peak Rock Secret Cave",
|
||||
"Secret Cave Entrance on Pawprint Isle": "Pawprint Isle Chuchu Cave",
|
||||
"Secret Cave Entrance on Pawprint Isle Side Isle": "Pawprint Isle Wizzrobe Cave",
|
||||
"Secret Cave Entrance on Diamond Steppe Island": "Diamond Steppe Island Warp Maze Cave",
|
||||
"Secret Cave Entrance on Bomb Island": "Bomb Island Secret Cave",
|
||||
"Secret Cave Entrance on Rock Spire Isle": "Rock Spire Isle Secret Cave",
|
||||
"Secret Cave Entrance on Shark Island": "Shark Island Secret Cave",
|
||||
"Secret Cave Entrance on Cliff Plateau Isles": "Cliff Plateau Isles Secret Cave",
|
||||
"Secret Cave Entrance on Horseshoe Island": "Horseshoe Island Secret Cave",
|
||||
"Secret Cave Entrance on Star Island": "Star Island Secret Cave",
|
||||
|
||||
"Inner Entrance in Ice Ring Isle Secret Cave": "Ice Ring Isle Inner Cave",
|
||||
"Inner Entrance in Cliff Plateau Isles Secret Cave": "Cliff Plateau Isles Inner Cave",
|
||||
|
||||
"Fairy Fountain Entrance on Outset Island": "Outset Fairy Fountain",
|
||||
"Fairy Fountain Entrance on Thorned Fairy Island": "Thorned Fairy Fountain",
|
||||
"Fairy Fountain Entrance on Eastern Fairy Island": "Eastern Fairy Fountain",
|
||||
"Fairy Fountain Entrance on Western Fairy Island": "Western Fairy Fountain",
|
||||
"Fairy Fountain Entrance on Southern Fairy Island": "Southern Fairy Fountain",
|
||||
"Fairy Fountain Entrance on Northern Fairy Island": "Northern Fairy Fountain",
|
||||
}
|
||||
|
||||
|
||||
class EntranceRandomizer:
|
||||
"""
|
||||
This class handles the logic for The Wind Waker entrance randomizer.
|
||||
|
||||
We reference the logic from the base randomizer with some modifications to suit it for Archipelago.
|
||||
Reference: https://github.com/LagoLunatic/wwrando/blob/master/randomizers/entrances.py
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
"""
|
||||
|
||||
def __init__(self, world: "TWWWorld"):
|
||||
self.world = world
|
||||
self.multiworld = world.multiworld
|
||||
self.player = world.player
|
||||
|
||||
self.item_location_to_containing_zone_exit: dict[str, ZoneExit] = {}
|
||||
self.zone_exit_to_logically_dependent_item_locations: dict[ZoneExit, list[str]] = defaultdict(list)
|
||||
self.register_mappings_between_item_locations_and_zone_exits()
|
||||
|
||||
self.done_entrances_to_exits: dict[ZoneEntrance, ZoneExit] = {}
|
||||
self.done_exits_to_entrances: dict[ZoneExit, ZoneEntrance] = {}
|
||||
|
||||
for entrance_name, exit_name in VANILLA_ENTRANCES_TO_EXITS.items():
|
||||
zone_entrance = ZoneEntrance.all[entrance_name]
|
||||
zone_exit = ZoneExit.all[exit_name]
|
||||
self.done_entrances_to_exits[zone_entrance] = zone_exit
|
||||
self.done_exits_to_entrances[zone_exit] = zone_entrance
|
||||
|
||||
self.banned_exits: list[ZoneExit] = []
|
||||
self.islands_with_a_banned_dungeon: set[str] = set()
|
||||
|
||||
def randomize_entrances(self) -> None:
|
||||
"""
|
||||
Randomize entrances for The Wind Waker.
|
||||
"""
|
||||
self.init_banned_exits()
|
||||
|
||||
for relevant_entrances, relevant_exits in self.get_all_entrance_sets_to_be_randomized():
|
||||
self.randomize_one_set_of_entrances(relevant_entrances, relevant_exits)
|
||||
|
||||
self.finalize_all_randomized_sets_of_entrances()
|
||||
|
||||
def init_banned_exits(self) -> None:
|
||||
"""
|
||||
Initialize the list of banned exits for the randomizer.
|
||||
|
||||
Dungeon exits in banned dungeons should be prohibited from being randomized.
|
||||
Additionally, if dungeon entrances are not randomized, we can now note which island holds these banned dungeons.
|
||||
"""
|
||||
options = self.world.options
|
||||
|
||||
if options.required_bosses:
|
||||
for zone_exit in BOSS_EXITS:
|
||||
assert zone_exit.unique_name.endswith(" Boss Arena")
|
||||
boss_name = zone_exit.unique_name.removesuffix(" Boss Arena")
|
||||
if boss_name in self.world.boss_reqs.banned_bosses:
|
||||
self.banned_exits.append(zone_exit)
|
||||
for zone_exit in DUNGEON_EXITS:
|
||||
dungeon_name = zone_exit.unique_name
|
||||
if dungeon_name in self.world.boss_reqs.banned_dungeons:
|
||||
self.banned_exits.append(zone_exit)
|
||||
for zone_exit in MINIBOSS_EXITS:
|
||||
if zone_exit == ZoneExit.all["Master Sword Chamber"]:
|
||||
# Hyrule cannot be chosen as a banned dungeon.
|
||||
continue
|
||||
assert zone_exit.unique_name.endswith(" Miniboss Arena")
|
||||
dungeon_name = zone_exit.unique_name.removesuffix(" Miniboss Arena")
|
||||
if dungeon_name in self.world.boss_reqs.banned_dungeons:
|
||||
self.banned_exits.append(zone_exit)
|
||||
|
||||
if not options.randomize_dungeon_entrances:
|
||||
# If dungeon entrances are not randomized, `islands_with_a_banned_dungeon` can be initialized early since
|
||||
# it's preset and won't be updated later since we won't randomize the dungeon entrances.
|
||||
for en in DUNGEON_ENTRANCES:
|
||||
if self.done_entrances_to_exits[en].unique_name in self.world.boss_reqs.banned_dungeons:
|
||||
assert en.island_name is not None
|
||||
self.islands_with_a_banned_dungeon.add(en.island_name)
|
||||
|
||||
def randomize_one_set_of_entrances(
|
||||
self, relevant_entrances: list[ZoneEntrance], relevant_exits: list[ZoneExit]
|
||||
) -> None:
|
||||
"""
|
||||
Randomize a single set of entrances and their corresponding exits.
|
||||
|
||||
:param relevant_entrances: A list of entrances to be randomized.
|
||||
:param relevant_exits: A list of exits corresponding to the entrances.
|
||||
"""
|
||||
# Keep miniboss and boss entrances vanilla in non-required bosses' dungeons.
|
||||
for zone_entrance in relevant_entrances.copy():
|
||||
zone_exit = self.done_entrances_to_exits[zone_entrance]
|
||||
if zone_exit in self.banned_exits and zone_exit in DUNGEON_INNER_EXITS:
|
||||
relevant_entrances.remove(zone_entrance)
|
||||
else:
|
||||
del self.done_entrances_to_exits[zone_entrance]
|
||||
for zone_exit in relevant_exits.copy():
|
||||
if zone_exit in self.banned_exits and zone_exit in DUNGEON_INNER_EXITS:
|
||||
relevant_exits.remove(zone_exit)
|
||||
else:
|
||||
del self.done_exits_to_entrances[zone_exit]
|
||||
|
||||
self.multiworld.random.shuffle(relevant_entrances)
|
||||
|
||||
# We calculate which exits are terminal (the end of a nested chain) per set instead of for all entrances.
|
||||
# This is so that, for example, Ice Ring Isle counts as terminal when its inner cave is not being randomized.
|
||||
non_terminal_exits = []
|
||||
for en in relevant_entrances:
|
||||
if en.nested_in is not None and en.nested_in not in non_terminal_exits:
|
||||
non_terminal_exits.append(en.nested_in)
|
||||
terminal_exits = {ex for ex in relevant_exits if ex not in non_terminal_exits}
|
||||
|
||||
remaining_entrances = relevant_entrances.copy()
|
||||
remaining_exits = relevant_exits.copy()
|
||||
|
||||
nonprogress_entrances, nonprogress_exits = self.split_nonprogress_entrances_and_exits(
|
||||
remaining_entrances, remaining_exits
|
||||
)
|
||||
if nonprogress_entrances:
|
||||
for en in nonprogress_entrances:
|
||||
remaining_entrances.remove(en)
|
||||
for ex in nonprogress_exits:
|
||||
remaining_exits.remove(ex)
|
||||
self.randomize_one_set_of_exits(nonprogress_entrances, nonprogress_exits, terminal_exits)
|
||||
|
||||
self.randomize_one_set_of_exits(remaining_entrances, remaining_exits, terminal_exits)
|
||||
|
||||
def check_if_one_exit_is_progress(self, zone_exit: ZoneExit) -> bool:
|
||||
"""
|
||||
Determine if the zone exit leads to progress locations in the world.
|
||||
|
||||
:param zone_exit: The zone exit to check.
|
||||
:return: Whether the zone exit leads to progress locations.
|
||||
"""
|
||||
locs_for_exit = self.zone_exit_to_logically_dependent_item_locations[zone_exit]
|
||||
assert locs_for_exit, f"Could not find any item locations corresponding to zone exit: {zone_exit.unique_name}"
|
||||
|
||||
# Banned required bosses mode dungeons still technically count as progress locations, so filter them out
|
||||
# separately first.
|
||||
nonbanned_locs = [loc for loc in locs_for_exit if loc not in self.world.boss_reqs.banned_locations]
|
||||
progress_locs = [loc for loc in nonbanned_locs if loc not in self.world.nonprogress_locations]
|
||||
return bool(progress_locs)
|
||||
|
||||
def split_nonprogress_entrances_and_exits(
|
||||
self, relevant_entrances: list[ZoneEntrance], relevant_exits: list[ZoneExit]
|
||||
) -> tuple[list[ZoneEntrance], list[ZoneExit]]:
|
||||
"""
|
||||
Splits the entrance and exit lists into two pairs: ones that should be considered nonprogress on this seed (will
|
||||
never lead to any progress items) and ones that should be regarded as potentially required.
|
||||
|
||||
This is so we can effectively randomize these two pairs separately without convoluted logic to ensure they don't
|
||||
connect.
|
||||
|
||||
:param relevant_entrances: A list of entrances.
|
||||
:param relevant_exits: A list of exits corresponding to the entrances.
|
||||
:raises FillError: If the number of randomizable entrances does not equal the number of randomizable exits.
|
||||
"""
|
||||
nonprogress_exits = [ex for ex in relevant_exits if not self.check_if_one_exit_is_progress(ex)]
|
||||
nonprogress_entrances = [
|
||||
en
|
||||
for en in relevant_entrances
|
||||
if en.nested_in is not None
|
||||
and (
|
||||
(en.nested_in in nonprogress_exits)
|
||||
# The area this entrance is nested in is not randomized, but we still need to determine whether it's
|
||||
# progression.
|
||||
or (en.nested_in not in relevant_exits and not self.check_if_one_exit_is_progress(en.nested_in))
|
||||
)
|
||||
]
|
||||
|
||||
# At this point, `nonprogress_entrances` includes only the inner entrances nested inside the main exits, not any
|
||||
# island entrances on the sea. So, we need to select `N` random island entrances to allow all of the nonprogress
|
||||
# exits to be accessible, where `N` is the difference between the number of entrances and exits we currently
|
||||
# have.
|
||||
possible_island_entrances = [en for en in relevant_entrances if en.island_name is not None]
|
||||
|
||||
# We need special logic to handle Forsaken Fortress, as it is the only island entrance inside a dungeon.
|
||||
ff_boss_entrance = ZoneEntrance.all["Boss Entrance in Forsaken Fortress"]
|
||||
if ff_boss_entrance in possible_island_entrances:
|
||||
if self.world.options.progression_dungeons:
|
||||
if "Forsaken Fortress" in self.world.boss_reqs.banned_dungeons:
|
||||
ff_progress = False
|
||||
else:
|
||||
ff_progress = True
|
||||
else:
|
||||
ff_progress = False
|
||||
|
||||
if ff_progress:
|
||||
# If it's progress, don't allow it to be randomly chosen to lead to nonprogress exits.
|
||||
possible_island_entrances.remove(ff_boss_entrance)
|
||||
else:
|
||||
# If it's not progress, manually mark it as such, and don't allow it to be chosen randomly.
|
||||
nonprogress_entrances.append(ff_boss_entrance)
|
||||
possible_island_entrances.remove(ff_boss_entrance)
|
||||
|
||||
num_island_entrances_needed = len(nonprogress_exits) - len(nonprogress_entrances)
|
||||
if num_island_entrances_needed > len(possible_island_entrances):
|
||||
raise FillError("Not enough island entrances left to split entrances.")
|
||||
|
||||
for _ in range(num_island_entrances_needed):
|
||||
# Note: `relevant_entrances` is already shuffled, so we can just take the first result from
|
||||
# `possible_island_entrances`—it's the same as picking one randomly.
|
||||
nonprogress_island_entrance = possible_island_entrances.pop(0)
|
||||
nonprogress_entrances.append(nonprogress_island_entrance)
|
||||
|
||||
assert len(nonprogress_entrances) == len(nonprogress_exits)
|
||||
|
||||
return nonprogress_entrances, nonprogress_exits
|
||||
|
||||
def randomize_one_set_of_exits(
|
||||
self, relevant_entrances: list[ZoneEntrance], relevant_exits: list[ZoneExit], terminal_exits: set[ZoneExit]
|
||||
) -> None:
|
||||
"""
|
||||
Randomize a single set of entrances and their corresponding exits.
|
||||
|
||||
:param relevant_entrances: A list of entrances to be randomized.
|
||||
:param relevant_exits: A list of exits corresponding to the entrances.
|
||||
:param terminal_exits: A set of exits which do not contain any entrances.
|
||||
:raises FillError: If there are no valid exits to assign to an entrance.
|
||||
"""
|
||||
options = self.world.options
|
||||
|
||||
remaining_entrances = relevant_entrances.copy()
|
||||
remaining_exits = relevant_exits.copy()
|
||||
|
||||
doing_banned = False
|
||||
if any(ex in self.banned_exits for ex in relevant_exits):
|
||||
doing_banned = True
|
||||
|
||||
if options.required_bosses and not doing_banned:
|
||||
# Prioritize entrances that share an island with an entrance randomized to lead into a
|
||||
# required-bosses-mode-banned dungeon. (e.g., DRI, Pawprint, Outset, TotG sector.)
|
||||
# This is because we need to prevent these islands from having a required boss or anything that could lead
|
||||
# to a required boss. If we don't do this first, we can get backed into a corner where there is no other
|
||||
# option left.
|
||||
entrances_not_on_unique_islands = []
|
||||
for zone_entrance in relevant_entrances:
|
||||
if zone_entrance.is_nested:
|
||||
continue
|
||||
if zone_entrance.island_name in self.islands_with_a_banned_dungeon:
|
||||
# This island was already used on a previous call to `randomize_one_set_of_exits`.
|
||||
entrances_not_on_unique_islands.append(zone_entrance)
|
||||
continue
|
||||
for zone_entrance in entrances_not_on_unique_islands:
|
||||
remaining_entrances.remove(zone_entrance)
|
||||
remaining_entrances = entrances_not_on_unique_islands + remaining_entrances
|
||||
|
||||
while remaining_entrances:
|
||||
# Filter out boss entrances that aren't yet accessible from the sea.
|
||||
# We don't want to connect these to anything yet or we risk creating an infinite loop.
|
||||
possible_remaining_entrances = [
|
||||
en for en in remaining_entrances if self.get_outermost_entrance_for_entrance(en) is not None
|
||||
]
|
||||
zone_entrance = possible_remaining_entrances.pop(0)
|
||||
remaining_entrances.remove(zone_entrance)
|
||||
|
||||
possible_remaining_exits = remaining_exits.copy()
|
||||
|
||||
if len(possible_remaining_entrances) == 0 and len(remaining_entrances) > 0:
|
||||
# If this is the last entrance we have left to attach exits to, we can't place a terminal exit here.
|
||||
# Terminal exits do not create another entrance, so one would leave us with no possible way to continue
|
||||
# placing the remaining exits on future loops.
|
||||
possible_remaining_exits = [ex for ex in possible_remaining_exits if ex not in terminal_exits]
|
||||
|
||||
if options.required_bosses and zone_entrance.island_name is not None and not doing_banned:
|
||||
# Prevent required bosses (and non-terminal exits, which could lead to required bosses) from appearing
|
||||
# on islands where we already placed a banned boss or dungeon.
|
||||
# This can happen with DRI and Pawprint, as these islands have two entrances. This would be bad because
|
||||
# the required bosses mode's dungeon markers only tell you what island the required dungeons are on, not
|
||||
# which of the two entrances to enter.
|
||||
# So, if a banned dungeon is placed on DRI's main entrance, we will have to fill DRI's pit entrance with
|
||||
# either a miniboss or one of the caves that does not have a nested entrance inside. We allow multiple
|
||||
# banned and required dungeons on a single island.
|
||||
if zone_entrance.island_name in self.islands_with_a_banned_dungeon:
|
||||
possible_remaining_exits = [
|
||||
ex
|
||||
for ex in possible_remaining_exits
|
||||
if ex in terminal_exits and ex not in (DUNGEON_EXITS + BOSS_EXITS)
|
||||
]
|
||||
|
||||
if not possible_remaining_exits:
|
||||
raise FillError(f"No valid exits to place for entrance: {zone_entrance.entrance_name}")
|
||||
|
||||
zone_exit = self.multiworld.random.choice(possible_remaining_exits)
|
||||
remaining_exits.remove(zone_exit)
|
||||
|
||||
self.done_entrances_to_exits[zone_entrance] = zone_exit
|
||||
self.done_exits_to_entrances[zone_exit] = zone_entrance
|
||||
|
||||
if zone_exit in self.banned_exits:
|
||||
# Keep track of which islands have a required bosses mode banned dungeon to avoid marker overlap.
|
||||
if zone_exit in DUNGEON_EXITS + BOSS_EXITS:
|
||||
# We only keep track of dungeon exits and boss exits, not miniboss exits.
|
||||
# Banned miniboss exits can share an island with required dungeons/bosses.
|
||||
outer_entrance = self.get_outermost_entrance_for_entrance(zone_entrance)
|
||||
|
||||
# Because we filter above so that we always assign entrances from the sea inwards, we can assume
|
||||
# that when we assign an entrance, it has a path back to the sea.
|
||||
# If we're assigning a non-terminal entrance, any nested entrances will get assigned after this one,
|
||||
# and we'll run through this code again (so we can reason based on `zone_exit` only instead of
|
||||
# having to recurse through the nested exits to find banned dungeons/bosses).
|
||||
assert outer_entrance and outer_entrance.island_name is not None
|
||||
self.islands_with_a_banned_dungeon.add(outer_entrance.island_name)
|
||||
|
||||
def finalize_all_randomized_sets_of_entrances(self) -> None:
|
||||
"""
|
||||
Finalize all randomized entrance sets.
|
||||
|
||||
For all entrance-exit pairs, this function adds a connection with the appropriate access rule to the world.
|
||||
"""
|
||||
|
||||
def get_access_rule(entrance: ZoneEntrance) -> str:
|
||||
snake_case_region = entrance.entrance_name.lower().replace("'", "").replace(" ", "_")
|
||||
return getattr(Macros, f"can_access_{snake_case_region}")
|
||||
|
||||
# Connect each entrance-exit pair in the multiworld with the access rule for the entrance.
|
||||
# The Great Sea is the parent_region for many entrances, so get it in advance.
|
||||
great_sea_region = self.world.get_region("The Great Sea")
|
||||
for zone_entrance, zone_exit in self.done_entrances_to_exits.items():
|
||||
# Get the parent region of the entrance.
|
||||
if zone_entrance.island_name is not None:
|
||||
# Entrances with an `island_name` are found in The Great Sea.
|
||||
parent_region = great_sea_region
|
||||
else:
|
||||
# All other entrances must be nested within some other region.
|
||||
parent_region = self.world.get_region(zone_entrance.nested_in.unique_name)
|
||||
exit_region_name = zone_exit.unique_name
|
||||
exit_region = self.world.get_region(exit_region_name)
|
||||
parent_region.connect(
|
||||
exit_region,
|
||||
# The default name uses the "parent_region -> connecting_region", but the parent_region would not be
|
||||
# useful for spoiler paths or debugging, so use the entrance name at the start.
|
||||
f"{zone_entrance.entrance_name} -> {exit_region_name}",
|
||||
rule=lambda state, rule=get_access_rule(zone_entrance): rule(state, self.player),
|
||||
)
|
||||
|
||||
if __debug__ and self.world.options.required_bosses:
|
||||
# Ensure we didn't accidentally place a banned boss and a required boss on the same island.
|
||||
banned_island_names = set(
|
||||
self.get_entrance_zone_for_boss(boss_name) for boss_name in self.world.boss_reqs.banned_bosses
|
||||
)
|
||||
required_island_names = set(
|
||||
self.get_entrance_zone_for_boss(boss_name) for boss_name in self.world.boss_reqs.required_bosses
|
||||
)
|
||||
assert not banned_island_names & required_island_names
|
||||
|
||||
def register_mappings_between_item_locations_and_zone_exits(self) -> None:
|
||||
"""
|
||||
Map item locations to their corresponding zone exits.
|
||||
"""
|
||||
for loc_name in list(LOCATION_TABLE.keys()):
|
||||
zone_exit = self.get_zone_exit_for_item_location(loc_name)
|
||||
if zone_exit is not None:
|
||||
self.item_location_to_containing_zone_exit[loc_name] = zone_exit
|
||||
self.zone_exit_to_logically_dependent_item_locations[zone_exit].append(loc_name)
|
||||
|
||||
if loc_name == "The Great Sea - Withered Trees":
|
||||
# This location isn't inside a zone exit, but it does logically require the player to be able to reach
|
||||
# a different item location inside one.
|
||||
sub_zone_exit = self.get_zone_exit_for_item_location("Cliff Plateau Isles - Highest Isle")
|
||||
if sub_zone_exit is not None:
|
||||
self.zone_exit_to_logically_dependent_item_locations[sub_zone_exit].append(loc_name)
|
||||
|
||||
def get_all_entrance_sets_to_be_randomized(
|
||||
self,
|
||||
) -> Generator[tuple[list[ZoneEntrance], list[ZoneExit]], None, None]:
|
||||
"""
|
||||
Retrieve all entrance-exit pairs that need to be randomized.
|
||||
|
||||
:raises OptionError: If an invalid randomization option is set in the world's options.
|
||||
:return: A generator that yields sets of entrances and exits to be randomized.
|
||||
"""
|
||||
options = self.world.options
|
||||
|
||||
dungeons = bool(options.randomize_dungeon_entrances)
|
||||
minibosses = bool(options.randomize_miniboss_entrances)
|
||||
bosses = bool(options.randomize_boss_entrances)
|
||||
secret_caves = bool(options.randomize_secret_cave_entrances)
|
||||
inner_caves = bool(options.randomize_secret_cave_inner_entrances)
|
||||
fountains = bool(options.randomize_fairy_fountain_entrances)
|
||||
|
||||
mix_entrances = options.mix_entrances
|
||||
if mix_entrances == "separate_pools":
|
||||
if dungeons:
|
||||
yield self.get_one_entrance_set(dungeons=dungeons)
|
||||
if minibosses:
|
||||
yield self.get_one_entrance_set(minibosses=minibosses)
|
||||
if bosses:
|
||||
yield self.get_one_entrance_set(bosses=bosses)
|
||||
if secret_caves:
|
||||
yield self.get_one_entrance_set(caves=secret_caves)
|
||||
if inner_caves:
|
||||
yield self.get_one_entrance_set(inner_caves=inner_caves)
|
||||
if fountains:
|
||||
yield self.get_one_entrance_set(fountains=fountains)
|
||||
elif mix_entrances == "mix_pools":
|
||||
yield self.get_one_entrance_set(
|
||||
dungeons=dungeons,
|
||||
minibosses=minibosses,
|
||||
bosses=bosses,
|
||||
caves=secret_caves,
|
||||
inner_caves=inner_caves,
|
||||
fountains=fountains,
|
||||
)
|
||||
else:
|
||||
raise OptionError(f"Invalid entrance randomization option: {mix_entrances}")
|
||||
|
||||
def get_one_entrance_set(
|
||||
self,
|
||||
*,
|
||||
dungeons: bool = False,
|
||||
caves: bool = False,
|
||||
minibosses: bool = False,
|
||||
bosses: bool = False,
|
||||
inner_caves: bool = False,
|
||||
fountains: bool = False,
|
||||
) -> tuple[list[ZoneEntrance], list[ZoneExit]]:
|
||||
"""
|
||||
Retrieve a single set of entrance-exit pairs that need to be randomized.
|
||||
|
||||
:param dungeons: Whether to include dungeon entrances and exits. Defaults to `False`.
|
||||
:param caves: Whether to include secret cave entrances and exits. Defaults to `False`.
|
||||
:param minibosses: Whether to include miniboss entrances and exits. Defaults to `False`.
|
||||
:param bosses: Whether to include boss entrances and exits. Defaults to `False`.
|
||||
:param inner_caves: Whether to include inner cave entrances and exits. Defaults to `False`.
|
||||
:param fountains: Whether to include fairy fountain entrances and exits. Defaults to `False`.
|
||||
:return: A tuple of lists of entrances and exits that should be randomized together.
|
||||
"""
|
||||
relevant_entrances: list[ZoneEntrance] = []
|
||||
relevant_exits: list[ZoneExit] = []
|
||||
if dungeons:
|
||||
relevant_entrances += DUNGEON_ENTRANCES
|
||||
relevant_exits += DUNGEON_EXITS
|
||||
if minibosses:
|
||||
relevant_entrances += MINIBOSS_ENTRANCES
|
||||
relevant_exits += MINIBOSS_EXITS
|
||||
if bosses:
|
||||
relevant_entrances += BOSS_ENTRANCES
|
||||
relevant_exits += BOSS_EXITS
|
||||
if caves:
|
||||
relevant_entrances += SECRET_CAVE_ENTRANCES
|
||||
relevant_exits += SECRET_CAVE_EXITS
|
||||
if inner_caves:
|
||||
relevant_entrances += SECRET_CAVE_INNER_ENTRANCES
|
||||
relevant_exits += SECRET_CAVE_INNER_EXITS
|
||||
if fountains:
|
||||
relevant_entrances += FAIRY_FOUNTAIN_ENTRANCES
|
||||
relevant_exits += FAIRY_FOUNTAIN_EXITS
|
||||
return relevant_entrances, relevant_exits
|
||||
|
||||
def get_outermost_entrance_for_exit(self, zone_exit: ZoneExit) -> Optional[ZoneEntrance]:
|
||||
"""
|
||||
Unrecurses nested dungeons to determine a given exit's outermost (island) entrance.
|
||||
|
||||
:param zone_exit: The given exit.
|
||||
:return: The outermost (island) entrance for the exit, or `None` if entrances have yet to be randomized.
|
||||
"""
|
||||
zone_entrance = self.done_exits_to_entrances[zone_exit]
|
||||
return self.get_outermost_entrance_for_entrance(zone_entrance)
|
||||
|
||||
def get_outermost_entrance_for_entrance(self, zone_entrance: ZoneEntrance) -> Optional[ZoneEntrance]:
|
||||
"""
|
||||
Unrecurses nested dungeons to determine a given entrance's outermost (island) entrance.
|
||||
|
||||
:param zone_exit: The given entrance.
|
||||
:return: The outermost (island) entrance for the entrance, or `None` if entrances have yet to be randomized.
|
||||
"""
|
||||
seen_entrances = self.get_all_entrances_on_path_to_entrance(zone_entrance)
|
||||
if seen_entrances is None:
|
||||
# Undecided.
|
||||
return None
|
||||
outermost_entrance = seen_entrances[-1]
|
||||
return outermost_entrance
|
||||
|
||||
def get_all_entrances_on_path_to_entrance(self, zone_entrance: ZoneEntrance) -> Optional[list[ZoneEntrance]]:
|
||||
"""
|
||||
Unrecurses nested dungeons to build a list of all entrances leading to a given entrance.
|
||||
|
||||
:param zone_exit: The given entrance.
|
||||
:return: A list of entrances leading to the given entrance, or `None` if entrances have yet to be randomized.
|
||||
"""
|
||||
seen_entrances: list[ZoneEntrance] = []
|
||||
while zone_entrance.is_nested:
|
||||
if zone_entrance in seen_entrances:
|
||||
path_str = ", ".join([e.entrance_name for e in seen_entrances])
|
||||
raise FillError(f"Entrances are in an infinite loop: {path_str}")
|
||||
seen_entrances.append(zone_entrance)
|
||||
if zone_entrance.nested_in not in self.done_exits_to_entrances:
|
||||
# Undecided.
|
||||
return None
|
||||
zone_entrance = self.done_exits_to_entrances[zone_entrance.nested_in]
|
||||
seen_entrances.append(zone_entrance)
|
||||
return seen_entrances
|
||||
|
||||
def is_item_location_behind_randomizable_entrance(self, location_name: str) -> bool:
|
||||
"""
|
||||
Determine if the location is behind a randomizable entrance.
|
||||
|
||||
:param location_name: The location to check.
|
||||
:return: `True` if the location is behind a randomizable entrance, `False` otherwise.
|
||||
"""
|
||||
loc_zone_name, _ = split_location_name_by_zone(location_name)
|
||||
if loc_zone_name in ["Ganon's Tower", "Mailbox"]:
|
||||
# Ganon's Tower and the handful of Mailbox locations that depend on beating dungeon bosses are considered
|
||||
# "Dungeon" location types by the logic, but the entrance randomizer does not need to consider them.
|
||||
# Although the mail locations are technically locked behind dungeons, we can still ignore them here because
|
||||
# if all of the locations in the dungeon itself are nonprogress, then any mail depending on that dungeon
|
||||
# should also be enforced as nonprogress by other parts of the code.
|
||||
return False
|
||||
|
||||
types = LOCATION_TABLE[location_name].flags
|
||||
is_boss = TWWFlag.BOSS in types
|
||||
if loc_zone_name == "Forsaken Fortress" and not is_boss:
|
||||
# Special case. FF is a dungeon that is not randomized, except for the boss arena.
|
||||
return False
|
||||
|
||||
is_big_octo = TWWFlag.BG_OCTO in types
|
||||
if is_big_octo:
|
||||
# The Big Octo Great Fairy is the only Great Fairy location that is not also a Fairy Fountain.
|
||||
return False
|
||||
|
||||
# In the general case, we check if the location has a type corresponding to exits that can be randomized.
|
||||
if any(t in types for t in ENTRANCE_RANDOMIZABLE_ITEM_LOCATION_TYPES):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_zone_exit_for_item_location(self, location_name: str) -> Optional[ZoneExit]:
|
||||
"""
|
||||
Retrieve the zone exit for a given location.
|
||||
|
||||
:param location_name: The name of the location.
|
||||
:raises Exception: If a location exit override should be used instead.
|
||||
:return: The zone exit for the location or `None` if the location is not behind a randomizable entrance.
|
||||
"""
|
||||
if not self.is_item_location_behind_randomizable_entrance(location_name):
|
||||
return None
|
||||
|
||||
zone_exit = ITEM_LOCATION_NAME_TO_EXIT_OVERRIDES.get(location_name, None)
|
||||
if zone_exit is not None:
|
||||
return zone_exit
|
||||
|
||||
loc_zone_name, _ = split_location_name_by_zone(location_name)
|
||||
possible_exits = [ex for ex in ZoneExit.all.values() if ex.zone_name == loc_zone_name]
|
||||
if len(possible_exits) == 0:
|
||||
return None
|
||||
elif len(possible_exits) == 1:
|
||||
return possible_exits[0]
|
||||
else:
|
||||
raise Exception(
|
||||
f"Multiple zone exits share the same zone name: {loc_zone_name!r}. "
|
||||
"Use a location exit override instead."
|
||||
)
|
||||
|
||||
def get_entrance_zone_for_boss(self, boss_name: str) -> str:
|
||||
"""
|
||||
Retrieve the entrance zone for a given boss.
|
||||
|
||||
:param boss_name: The name of the boss.
|
||||
:return: The name of the island on which the boss is located.
|
||||
"""
|
||||
boss_arena_name = f"{boss_name} Boss Arena"
|
||||
zone_exit = ZoneExit.all[boss_arena_name]
|
||||
outermost_entrance = self.get_outermost_entrance_for_exit(zone_exit)
|
||||
assert outermost_entrance is not None and outermost_entrance.island_name is not None
|
||||
return outermost_entrance.island_name
|
205
worlds/tww/randomizers/ItemPool.py
Normal file
205
worlds/tww/randomizers/ItemPool.py
Normal file
@@ -0,0 +1,205 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from BaseClasses import ItemClassification as IC
|
||||
from Fill import FillError
|
||||
|
||||
from ..Items import ITEM_TABLE, item_factory
|
||||
from ..Options import DungeonItem
|
||||
from .Dungeons import get_dungeon_item_pool_player
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import TWWWorld
|
||||
|
||||
VANILLA_DUNGEON_ITEM_LOCATIONS: dict[str, list[str]] = {
|
||||
"DRC Small Key": [
|
||||
"Dragon Roost Cavern - First Room",
|
||||
"Dragon Roost Cavern - Boarded Up Chest",
|
||||
"Dragon Roost Cavern - Rat Room Boarded Up Chest",
|
||||
"Dragon Roost Cavern - Bird's Nest",
|
||||
],
|
||||
"FW Small Key": [
|
||||
"Forbidden Woods - Vine Maze Right Chest"
|
||||
],
|
||||
"TotG Small Key": [
|
||||
"Tower of the Gods - Hop Across Floating Boxes",
|
||||
"Tower of the Gods - Floating Platforms Room"
|
||||
],
|
||||
"ET Small Key": [
|
||||
"Earth Temple - Transparent Chest in First Crypt",
|
||||
"Earth Temple - Casket in Second Crypt",
|
||||
"Earth Temple - End of Foggy Room With Floormasters",
|
||||
],
|
||||
"WT Small Key": [
|
||||
"Wind Temple - Spike Wall Room - First Chest",
|
||||
"Wind Temple - Chest Behind Seven Armos"
|
||||
],
|
||||
|
||||
"DRC Big Key": ["Dragon Roost Cavern - Big Key Chest"],
|
||||
"FW Big Key": ["Forbidden Woods - Big Key Chest"],
|
||||
"TotG Big Key": ["Tower of the Gods - Big Key Chest"],
|
||||
"ET Big Key": ["Earth Temple - Big Key Chest"],
|
||||
"WT Big Key": ["Wind Temple - Big Key Chest"],
|
||||
|
||||
"DRC Dungeon Map": ["Dragon Roost Cavern - Alcove With Water Jugs"],
|
||||
"FW Dungeon Map": ["Forbidden Woods - First Room"],
|
||||
"TotG Dungeon Map": ["Tower of the Gods - Chest Behind Bombable Walls"],
|
||||
"FF Dungeon Map": ["Forsaken Fortress - Chest Outside Upper Jail Cell"],
|
||||
"ET Dungeon Map": ["Earth Temple - Transparent Chest In Warp Pot Room"],
|
||||
"WT Dungeon Map": ["Wind Temple - Chest In Many Cyclones Room"],
|
||||
|
||||
"DRC Compass": ["Dragon Roost Cavern - Rat Room"],
|
||||
"FW Compass": ["Forbidden Woods - Vine Maze Left Chest"],
|
||||
"TotG Compass": ["Tower of the Gods - Skulls Room Chest"],
|
||||
"FF Compass": ["Forsaken Fortress - Chest Guarded By Bokoblin"],
|
||||
"ET Compass": ["Earth Temple - Chest In Three Blocks Room"],
|
||||
"WT Compass": ["Wind Temple - Chest In Middle Of Hub Room"],
|
||||
}
|
||||
|
||||
|
||||
def generate_itempool(world: "TWWWorld") -> None:
|
||||
"""
|
||||
Generate the item pool for the world.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
"""
|
||||
multiworld = world.multiworld
|
||||
|
||||
# Get the core pool of items.
|
||||
pool, precollected_items = get_pool_core(world)
|
||||
|
||||
# Add precollected items to the multiworld's `precollected_items` list.
|
||||
for item in precollected_items:
|
||||
multiworld.push_precollected(item_factory(item, world))
|
||||
|
||||
# Place a "Victory" item on "Defeat Ganondorf" for the spoiler log.
|
||||
world.get_location("Defeat Ganondorf").place_locked_item(item_factory("Victory", world))
|
||||
|
||||
# Create the pool of the remaining shuffled items.
|
||||
items = item_factory(pool, world)
|
||||
world.random.shuffle(items)
|
||||
|
||||
multiworld.itempool += items
|
||||
|
||||
# Dungeon items should already be created, so handle those separately.
|
||||
handle_dungeon_items(world)
|
||||
|
||||
|
||||
def get_pool_core(world: "TWWWorld") -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Get the core pool of items and precollected items for the world.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
:return: A tuple of the item pool and precollected items.
|
||||
"""
|
||||
pool: list[str] = []
|
||||
precollected_items: list[str] = []
|
||||
|
||||
# Split items into three different pools: progression, useful, and filler.
|
||||
progression_pool: list[str] = []
|
||||
useful_pool: list[str] = []
|
||||
filler_pool: list[str] = []
|
||||
for item, data in ITEM_TABLE.items():
|
||||
if data.type == "Item":
|
||||
adjusted_classification = world.item_classification_overrides.get(item)
|
||||
classification = data.classification if adjusted_classification is None else adjusted_classification
|
||||
|
||||
if classification & IC.progression:
|
||||
progression_pool.extend([item] * data.quantity)
|
||||
elif classification & IC.useful:
|
||||
useful_pool.extend([item] * data.quantity)
|
||||
else:
|
||||
filler_pool.extend([item] * data.quantity)
|
||||
|
||||
# Assign useful and filler items to item pools in the world.
|
||||
world.random.shuffle(useful_pool)
|
||||
world.random.shuffle(filler_pool)
|
||||
world.useful_pool = useful_pool
|
||||
world.filler_pool = filler_pool
|
||||
|
||||
# Add filler items to place into excluded locations.
|
||||
pool.extend([world.get_filler_item_name() for _ in world.options.exclude_locations])
|
||||
|
||||
# The remaining of items left to place should be the same as the number of non-excluded locations in the world.
|
||||
nonexcluded_locations = [
|
||||
location
|
||||
for location in world.multiworld.get_locations(world.player)
|
||||
if location.name not in world.options.exclude_locations
|
||||
]
|
||||
num_items_left_to_place = len(nonexcluded_locations) - 1
|
||||
|
||||
# Account for the dungeon items that have already been created.
|
||||
for dungeon in world.dungeons.values():
|
||||
num_items_left_to_place -= len(dungeon.all_items)
|
||||
|
||||
# All progression items are added to the item pool.
|
||||
if len(progression_pool) > num_items_left_to_place:
|
||||
raise FillError(
|
||||
"There are insufficient locations to place progression items! "
|
||||
f"Trying to place {len(progression_pool)} items in only {num_items_left_to_place} locations."
|
||||
)
|
||||
pool.extend(progression_pool)
|
||||
num_items_left_to_place -= len(progression_pool)
|
||||
|
||||
# If the player starts with a sword, add one to the precollected items list and remove one from the item pool.
|
||||
if world.options.sword_mode == "start_with_sword":
|
||||
precollected_items.append("Progressive Sword")
|
||||
num_items_left_to_place += 1
|
||||
pool.remove("Progressive Sword")
|
||||
# Or, if it's swordless mode, remove all swords from the item pool.
|
||||
elif world.options.sword_mode == "swordless":
|
||||
while "Progressive Sword" in pool:
|
||||
num_items_left_to_place += 1
|
||||
pool.remove("Progressive Sword")
|
||||
|
||||
# Place useful items, then filler items to fill out the remaining locations.
|
||||
pool.extend([world.get_filler_item_name(strict=False) for _ in range(num_items_left_to_place)])
|
||||
|
||||
return pool, precollected_items
|
||||
|
||||
|
||||
def handle_dungeon_items(world: "TWWWorld") -> None:
|
||||
"""
|
||||
Handle the placement of dungeon items in the world.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
"""
|
||||
player = world.player
|
||||
multiworld = world.multiworld
|
||||
options = world.options
|
||||
|
||||
dungeon_items = [
|
||||
item
|
||||
for item in get_dungeon_item_pool_player(world)
|
||||
if item.name not in multiworld.worlds[player].dungeon_local_item_names
|
||||
]
|
||||
|
||||
for x in range(len(dungeon_items) - 1, -1, -1):
|
||||
item = dungeon_items[x]
|
||||
|
||||
# Consider dungeon items in non-required dungeons as filler.
|
||||
if item.dungeon.name in world.boss_reqs.banned_dungeons:
|
||||
item.classification = IC.filler
|
||||
|
||||
option: DungeonItem
|
||||
if item.type == "Big Key":
|
||||
option = options.randomize_bigkeys
|
||||
elif item.type == "Small Key":
|
||||
option = options.randomize_smallkeys
|
||||
else:
|
||||
option = options.randomize_mapcompass
|
||||
|
||||
if option == "startwith":
|
||||
dungeon_items.pop(x)
|
||||
multiworld.push_precollected(item)
|
||||
multiworld.itempool.append(item_factory(world.get_filler_item_name(), world))
|
||||
elif option == "vanilla":
|
||||
for location_name in VANILLA_DUNGEON_ITEM_LOCATIONS[item.name]:
|
||||
location = world.get_location(location_name)
|
||||
if location.item is None:
|
||||
dungeon_items.pop(x)
|
||||
location.place_locked_item(item)
|
||||
break
|
||||
else:
|
||||
raise FillError(f"Could not place dungeon item in vanilla location: {item}")
|
||||
|
||||
multiworld.itempool.extend([item for item in dungeon_items])
|
121
worlds/tww/randomizers/RequiredBosses.py
Normal file
121
worlds/tww/randomizers/RequiredBosses.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from Options import OptionError
|
||||
|
||||
from ..Locations import DUNGEON_NAMES, LOCATION_TABLE, TWWFlag, split_location_name_by_zone
|
||||
from ..Options import TWWOptions
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import TWWWorld
|
||||
|
||||
|
||||
class RequiredBossesRandomizer:
|
||||
"""
|
||||
This class handles the randomization of the required bosses in The Wind Waker game based on user options.
|
||||
|
||||
If the option is on, the required bosses must be defeated as part of the unlock condition of Puppet Ganon's door.
|
||||
The quadrants in which the bosses are located are marked on the player's Sea Chart.
|
||||
|
||||
:param world: The Wind Waker game world.
|
||||
"""
|
||||
|
||||
def __init__(self, world: "TWWWorld"):
|
||||
self.world = world
|
||||
self.multiworld = world.multiworld
|
||||
|
||||
self.required_boss_item_locations: list[str] = []
|
||||
self.required_dungeons: set[str] = set()
|
||||
self.required_bosses: list[str] = []
|
||||
self.banned_locations: set[str] = set()
|
||||
self.banned_dungeons: set[str] = set()
|
||||
self.banned_bosses: list[str] = []
|
||||
|
||||
def validate_boss_options(self, options: TWWOptions) -> None:
|
||||
"""
|
||||
Validate the user-defined boss options to ensure logical consistency.
|
||||
|
||||
:param options: The game options set by the user.
|
||||
:raises OptionError: If the boss options are inconsistent.
|
||||
"""
|
||||
if not options.progression_dungeons:
|
||||
raise OptionError("You cannot make bosses required when progression dungeons are disabled.")
|
||||
|
||||
if len(options.included_dungeons.value & options.excluded_dungeons.value) != 0:
|
||||
raise OptionError(
|
||||
"A conflict was found in the lists of required and banned dungeons for required bosses mode."
|
||||
)
|
||||
|
||||
def randomize_required_bosses(self) -> None:
|
||||
"""
|
||||
Randomize the required bosses based on user-defined constraints and options.
|
||||
|
||||
:raises OptionError: If the randomization fails to meet user-defined constraints.
|
||||
"""
|
||||
options = self.world.options
|
||||
|
||||
# Validate constraints on required bosses options.
|
||||
self.validate_boss_options(options)
|
||||
|
||||
# If the user enforces a dungeon location to be priority, consider that when selecting required bosses.
|
||||
dungeon_names = set(DUNGEON_NAMES)
|
||||
required_dungeons = options.included_dungeons.value
|
||||
for location_name in options.priority_locations.value:
|
||||
dungeon_name, _ = split_location_name_by_zone(location_name)
|
||||
if dungeon_name in dungeon_names:
|
||||
required_dungeons.add(dungeon_name)
|
||||
|
||||
# Ensure we aren't prioritizing more dungeon locations than the requested number of required bosses.
|
||||
num_required_bosses = options.num_required_bosses
|
||||
if len(required_dungeons) > num_required_bosses:
|
||||
raise OptionError(
|
||||
"Could not select required bosses to satisfy options set by the user. "
|
||||
"There are more dungeons with priority locations than the desired number of required bosses."
|
||||
)
|
||||
|
||||
# Ensure that after removing excluded dungeons, we still have enough to satisfy user options.
|
||||
num_remaining = num_required_bosses - len(required_dungeons)
|
||||
remaining_dungeon_options = dungeon_names - required_dungeons - options.excluded_dungeons.value
|
||||
if len(remaining_dungeon_options) < num_remaining:
|
||||
raise OptionError(
|
||||
"Could not select required bosses to satisfy options set by the user. "
|
||||
"After removing the excluded dungeons, there are not enough to meet the desired number of required "
|
||||
"bosses."
|
||||
)
|
||||
|
||||
# Finish selecting required bosses.
|
||||
required_dungeons.update(self.world.random.sample(sorted(remaining_dungeon_options), num_remaining))
|
||||
|
||||
# Exclude locations that are not in the dungeon of a required boss.
|
||||
banned_dungeons = dungeon_names - required_dungeons
|
||||
for location_name, location_data in LOCATION_TABLE.items():
|
||||
dungeon_name, _ = split_location_name_by_zone(location_name)
|
||||
if dungeon_name in banned_dungeons and TWWFlag.DUNGEON in location_data.flags:
|
||||
self.banned_locations.add(location_name)
|
||||
elif location_name == "Mailbox - Letter from Orca" and "Forbidden Woods" in banned_dungeons:
|
||||
self.banned_locations.add(location_name)
|
||||
elif location_name == "Mailbox - Letter from Baito" and "Earth Temple" in banned_dungeons:
|
||||
self.banned_locations.add(location_name)
|
||||
elif location_name == "Mailbox - Letter from Aryll" and "Forsaken Fortress" in banned_dungeons:
|
||||
self.banned_locations.add(location_name)
|
||||
elif location_name == "Mailbox - Letter from Tingle" and "Forsaken Fortress" in banned_dungeons:
|
||||
self.banned_locations.add(location_name)
|
||||
for location_name in self.banned_locations:
|
||||
self.world.nonprogress_locations.add(location_name)
|
||||
|
||||
# Record the item location names for required bosses.
|
||||
self.required_boss_item_locations = []
|
||||
self.required_bosses = []
|
||||
self.banned_bosses = []
|
||||
possible_boss_item_locations = [loc for loc, data in LOCATION_TABLE.items() if TWWFlag.BOSS in data.flags]
|
||||
for location_name in possible_boss_item_locations:
|
||||
dungeon_name, specific_location_name = split_location_name_by_zone(location_name)
|
||||
assert specific_location_name.endswith(" Heart Container")
|
||||
boss_name = specific_location_name.removesuffix(" Heart Container")
|
||||
|
||||
if dungeon_name in required_dungeons:
|
||||
self.required_boss_item_locations.append(location_name)
|
||||
self.required_bosses.append(boss_name)
|
||||
else:
|
||||
self.banned_bosses.append(boss_name)
|
||||
self.required_dungeons = required_dungeons
|
||||
self.banned_dungeons = banned_dungeons
|
1
worlds/tww/requirements.txt
Normal file
1
worlds/tww/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
dolphin-memory-engine>=1.3.0
|
Reference in New Issue
Block a user