Skip to content

Commit

Permalink
Core: move PlandoConnections and PlandoTexts to the options system (A…
Browse files Browse the repository at this point in the history
…rchipelagoMW#2904)

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
  • Loading branch information
5 people authored and wu4 committed Jun 6, 2024
1 parent 40c6c0a commit e2cfd06
Show file tree
Hide file tree
Showing 19 changed files with 767 additions and 71 deletions.
29 changes: 2 additions & 27 deletions Generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
from worlds.generic import PlandoConnection
from worlds import failed_world_loads


Expand Down Expand Up @@ -506,35 +504,12 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
if PlandoOptions.connections in plando_options:
ret.plando_connections = []
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
roll_alttp_settings(ret, game_weights)

return ret


def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):

ret.plando_texts = {}
if PlandoOptions.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
at = str(get_choice_legacy("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))

def roll_alttp_settings(ret: argparse.Namespace, weights):
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
Expand Down
226 changes: 225 additions & 1 deletion Options.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from dataclasses import dataclass

from schema import And, Optional, Or, Schema
from typing_extensions import Self

from Utils import get_fuzzy_results, is_iterable_except_str

Expand Down Expand Up @@ -896,6 +897,228 @@ class ItemSet(OptionSet):
convert_name_groups = True


class PlandoText(typing.NamedTuple):
at: str
text: typing.List[str]
percentage: int = 100


PlandoTextsFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoText, typing.Any]], typing.Any
]


class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
default = ()
supports_weighting = False
display_name = "Plando Texts"

def __init__(self, value: typing.Iterable[PlandoText]) -> None:
self.value = list(deepcopy(value))
super().__init__()

def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.texts & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando texts module is turned off, "
f"so text for {player_name} will be ignored.")

@classmethod
def from_any(cls, data: PlandoTextsFromAnyType) -> Self:
texts: typing.List[PlandoText] = []
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None)
if at is not None:
given_text = text.get("text", [])
if isinstance(given_text, str):
given_text = [given_text]
texts.append(PlandoText(
at,
given_text,
text.get("percentage", 100)
))
elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
cls.verify_keys([text.at for text in texts])
return cls(texts)
else:
raise NotImplementedError(f"Cannot Convert from non-list, got {type(data)}")

@classmethod
def get_option_name(cls, value: typing.List[PlandoText]) -> str:
return str({text.at: " ".join(text.text) for text in value})

def __iter__(self) -> typing.Iterator[PlandoText]:
yield from self.value

def __getitem__(self, index: typing.SupportsIndex) -> PlandoText:
return self.value.__getitem__(index)

def __len__(self) -> int:
return self.value.__len__()


class ConnectionsMeta(AssembleOptions):
def __new__(mcs, name: str, bases: tuple[type, ...], attrs: dict[str, typing.Any]):
if name != "PlandoConnections":
assert "entrances" in attrs, f"Please define valid entrances for {name}"
attrs["entrances"] = frozenset((connection.lower() for connection in attrs["entrances"]))
assert "exits" in attrs, f"Please define valid exits for {name}"
attrs["exits"] = frozenset((connection.lower() for connection in attrs["exits"]))
if "__doc__" not in attrs:
attrs["__doc__"] = PlandoConnections.__doc__
cls = super().__new__(mcs, name, bases, attrs)
return cls


class PlandoConnection(typing.NamedTuple):
class Direction:
entrance = "entrance"
exit = "exit"
both = "both"

entrance: str
exit: str
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
percentage: int = 100


PlandoConFromAnyType = typing.Union[
typing.Iterable[typing.Union[typing.Mapping[str, typing.Any], PlandoConnection, typing.Any]], typing.Any
]


class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=ConnectionsMeta):
"""Generic connections plando. Format is:
- entrance: "Entrance Name"
exit: "Exit Name"
direction: "Direction"
percentage: 100
Direction must be one of 'entrance', 'exit', or 'both', and defaults to 'both' if omitted.
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""

display_name = "Plando Connections"

default = ()
supports_weighting = False

entrances: typing.ClassVar[typing.AbstractSet[str]]
exits: typing.ClassVar[typing.AbstractSet[str]]

duplicate_exits: bool = False
"""Whether or not exits should be allowed to be duplicate."""

def __init__(self, value: typing.Iterable[PlandoConnection]):
self.value = list(deepcopy(value))
super(PlandoConnections, self).__init__()

@classmethod
def validate_entrance_name(cls, entrance: str) -> bool:
return entrance.lower() in cls.entrances

@classmethod
def validate_exit_name(cls, exit: str) -> bool:
return exit.lower() in cls.exits

@classmethod
def can_connect(cls, entrance: str, exit: str) -> bool:
"""Checks that a given entrance can connect to a given exit.
By default, this will always return true unless overridden."""
return True

@classmethod
def validate_plando_connections(cls, connections: typing.Iterable[PlandoConnection]) -> None:
used_entrances: typing.List[str] = []
used_exits: typing.List[str] = []
for connection in connections:
entrance = connection.entrance
exit = connection.exit
direction = connection.direction
if direction not in (PlandoConnection.Direction.entrance,
PlandoConnection.Direction.exit,
PlandoConnection.Direction.both):
raise ValueError(f"Unknown direction: {direction}")
if entrance in used_entrances:
raise ValueError(f"Duplicate Entrance {entrance} not allowed.")
if not cls.duplicate_exits and exit in used_exits:
raise ValueError(f"Duplicate Exit {exit} not allowed.")
used_entrances.append(entrance)
used_exits.append(exit)
if not cls.validate_entrance_name(entrance):
raise ValueError(f"{entrance.title()} is not a valid entrance.")
if not cls.validate_exit_name(exit):
raise ValueError(f"{exit.title()} is not a valid exit.")
if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")

@classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self:
if not isinstance(data, typing.Iterable):
raise Exception(f"Cannot create plando connections from non-List value, got {type(data)}.")

value: typing.List[PlandoConnection] = []
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
exit = connection.get("exit", None)
if is_iterable_except_str(exit):
exit = random.choice(sorted(exit))
direction = connection.get("direction", "both")

if not entrance or not exit:
raise Exception("Plando connection must have an entrance and an exit.")
value.append(PlandoConnection(
entrance,
exit,
direction,
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
cls.validate_plando_connections(value)
return cls(value)

def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
from BaseClasses import PlandoOptions
if self.value and not (PlandoOptions.connections & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando connections module is turned off, "
f"so connections for {player_name} will be ignored.")

@classmethod
def get_option_name(cls, value: typing.List[PlandoConnection]) -> str:
return ", ".join(["%s %s %s" % (connection.entrance,
"<=>" if connection.direction == PlandoConnection.Direction.both else
"<=" if connection.direction == PlandoConnection.Direction.exit else
"=>",
connection.exit) for connection in value])

def __getitem__(self, index: typing.SupportsIndex) -> PlandoConnection:
return self.value.__getitem__(index)

def __iter__(self) -> typing.Iterator[PlandoConnection]:
yield from self.value

def __len__(self) -> int:
return len(self.value)


class Accessibility(Choice):
"""Set rules for reachability of your items/locations.
Locations: ensure everything can be reached and acquired.
Expand Down Expand Up @@ -1049,7 +1272,8 @@ class ItemLinks(OptionList):
])

@staticmethod
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world, allow_item_groups: bool = True) -> typing.Set:
def verify_items(items: typing.List[str], item_link: str, pool_name: str, world,
allow_item_groups: bool = True) -> typing.Set:
pool = set()
for item_name in items:
if item_name not in world.item_names and (not allow_item_groups or item_name not in world.item_name_groups):
Expand Down
27 changes: 25 additions & 2 deletions worlds/alttp/Options.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import typing

from BaseClasses import MultiWorld
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool, PlandoBosses,\
FreeText, Removed
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink, \
StartInventoryPool, PlandoBosses, PlandoConnections, PlandoTexts, FreeText, Removed
from .EntranceShuffle import default_connections, default_dungeon_connections, \
inverted_default_connections, inverted_default_dungeon_connections
from .Text import TextTable


class GlitchesRequired(Choice):
Expand Down Expand Up @@ -721,7 +724,27 @@ class AllowCollect(DefaultOnToggle):
display_name = "Allow Collection of checks for other players"


class ALttPPlandoConnections(PlandoConnections):
entrances = set([connection[0] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)])
exits = set([connection[1] for connection in (
*default_connections, *default_dungeon_connections, *inverted_default_connections,
*inverted_default_dungeon_connections)])


class ALttPPlandoTexts(PlandoTexts):
"""Text plando. Format is:
- text: 'This is your text'
at: text_key
percentage: 100
Percentage is an integer from 1 to 100, and defaults to 100 when omitted."""
valid_keys = TextTable.valid_keys


alttp_options: typing.Dict[str, type(Option)] = {
"plando_connections": ALttPPlandoConnections,
"plando_texts": ALttPPlandoTexts,
"start_inventory_from_pool": StartInventoryPool,
"goal": Goal,
"mode": Mode,
Expand Down
4 changes: 2 additions & 2 deletions worlds/alttp/Rom.py
Original file line number Diff line number Diff line change
Expand Up @@ -2538,12 +2538,12 @@ def hint_text(dest, ped_hint=False):
tt['menu_start_2'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n{CHOICE3}"
tt['menu_start_3'] = "{MENU}\n{SPEED0}\n≥@'s house\n Dark Chapel\n Mountain Cave\n{CHOICE2}"

for at, text in world.plando_texts[player].items():
for at, text, _ in world.plando_texts[player]:

if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
else:
tt[at] = text
tt[at] = "\n".join(text)

rom.write_bytes(0xE0000, tt.getBytes())

Expand Down
10 changes: 6 additions & 4 deletions worlds/alttp/Shops.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@

from BaseClasses import CollectionState
from .SubClasses import ALttPLocation
from .EntranceShuffle import door_addresses

from .Items import item_name_groups
from .Options import small_key_shuffle, RandomizeShopInventories

from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows

logger = logging.getLogger("Shops")
Expand Down Expand Up @@ -66,6 +66,7 @@ def item_count(self) -> int:
return 0

def get_bytes(self) -> List[int]:
from .EntranceShuffle import door_addresses
# [id][roomID-low][roomID-high][doorID][zero][shop_config][shopkeeper_config][sram_index]
entrances = self.region.entrances
config = self.item_count
Expand Down Expand Up @@ -181,7 +182,7 @@ def push_shop_inventories(multiworld):


def create_shops(multiworld, player: int):

from .Options import RandomizeShopInventories
player_shop_table = shop_table.copy()
if multiworld.include_witch_hut[player]:
player_shop_table["Potion Shop"] = player_shop_table["Potion Shop"]._replace(locked=False)
Expand Down Expand Up @@ -304,6 +305,7 @@ class ShopData(NamedTuple):


def set_up_shops(multiworld, player: int):
from .Options import small_key_shuffle
# TODO: move hard+ mode changes for shields here, utilizing the new shops

if multiworld.retro_bow[player]:
Expand Down Expand Up @@ -426,7 +428,7 @@ def get_price_modifier(item):

def get_price(multiworld, item, player: int, price_type=None):
"""Converts a raw Rupee price into a special price type"""

from .Options import small_key_shuffle
if price_type:
price_types = [price_type]
else:
Expand Down
Loading

0 comments on commit e2cfd06

Please sign in to comment.