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:
Jonathan Tan
2025-03-22 19:42:17 -04:00
committed by GitHub
parent 8891f07362
commit cf0ae5e31b
21 changed files with 8308 additions and 0 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@
*.apmc
*.apz5
*.aptloz
*.aptww
*.apemerald
*.pyc
*.pyd

View File

@@ -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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

1114
worlds/tww/Macros.py Normal file

File diff suppressed because it is too large Load Diff

854
worlds/tww/Options.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

739
worlds/tww/TWWClient.py Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

BIN
worlds/tww/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View 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/).

View 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`.

View 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

View 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",
)

View 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

View 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])

View 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

View File

@@ -0,0 +1 @@
dolphin-memory-engine>=1.3.0