Merge branch 'main' into breaking_changes
# Conflicts: # BaseClasses.py
This commit is contained in:
commit
567954a17f
78
Fill.py
78
Fill.py
|
@ -42,9 +42,10 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
|
|||
for item_to_place in items_to_place:
|
||||
perform_access_check = True
|
||||
if world.accessibility[item_to_place.player] == 'none':
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state, item_to_place.player) if single_player_placement else not has_beaten_game
|
||||
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
|
||||
item_to_place.player) if single_player_placement else not has_beaten_game
|
||||
for location in locations:
|
||||
if (not single_player_placement or location.player == item_to_place.player)\
|
||||
if (not single_player_placement or location.player == item_to_place.player) \
|
||||
and location.can_fill(maximum_exploration_state, item_to_place, perform_access_check):
|
||||
spot_to_fill = location
|
||||
break
|
||||
|
@ -70,6 +71,7 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
|
|||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
|
||||
def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None):
|
||||
# If not passed in, then get a shuffled list of locations to fill in
|
||||
if not fill_locations:
|
||||
|
@ -243,15 +245,14 @@ def balance_multiworld_progression(world):
|
|||
else:
|
||||
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
|
||||
state = CollectionState(world)
|
||||
checked_locations = []
|
||||
unchecked_locations = world.get_locations().copy()
|
||||
world.random.shuffle(unchecked_locations)
|
||||
checked_locations = set()
|
||||
unchecked_locations = set(world.get_locations())
|
||||
|
||||
reachable_locations_count = {player: 0 for player in world.player_ids}
|
||||
|
||||
def get_sphere_locations(sphere_state, locations):
|
||||
sphere_state.sweep_for_events(key_only=True, locations=locations)
|
||||
return [loc for loc in locations if sphere_state.can_reach(loc)]
|
||||
return {loc for loc in locations if sphere_state.can_reach(loc)}
|
||||
|
||||
while True:
|
||||
sphere_locations = get_sphere_locations(state, unchecked_locations)
|
||||
|
@ -261,14 +262,14 @@ def balance_multiworld_progression(world):
|
|||
|
||||
if checked_locations:
|
||||
threshold = max(reachable_locations_count.values()) - 20
|
||||
balancing_players = [player for player, reachables in reachable_locations_count.items() if
|
||||
reachables < threshold and player in balanceable_players]
|
||||
balancing_players = {player for player, reachables in reachable_locations_count.items() if
|
||||
reachables < threshold and player in balanceable_players}
|
||||
if balancing_players:
|
||||
balancing_state = state.copy()
|
||||
balancing_unchecked_locations = unchecked_locations.copy()
|
||||
balancing_reachables = reachable_locations_count.copy()
|
||||
balancing_sphere = sphere_locations.copy()
|
||||
candidate_items = collections.defaultdict(list)
|
||||
candidate_items = collections.defaultdict(set)
|
||||
while True:
|
||||
for location in balancing_sphere:
|
||||
if location.event:
|
||||
|
@ -276,7 +277,7 @@ def balance_multiworld_progression(world):
|
|||
player = location.item.player
|
||||
# only replace items that end up in another player's world
|
||||
if not location.locked and player in balancing_players and location.player != player:
|
||||
candidate_items[player].append(location)
|
||||
candidate_items[player].add(location)
|
||||
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
|
||||
for location in balancing_sphere:
|
||||
balancing_unchecked_locations.remove(location)
|
||||
|
@ -286,10 +287,10 @@ def balance_multiworld_progression(world):
|
|||
break
|
||||
elif not balancing_sphere:
|
||||
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
|
||||
unlocked_locations = collections.defaultdict(list)
|
||||
unlocked_locations = collections.defaultdict(set)
|
||||
for l in unchecked_locations:
|
||||
if l not in balancing_unchecked_locations:
|
||||
unlocked_locations[l.player].append(l)
|
||||
unlocked_locations[l.player].add(l)
|
||||
items_to_replace = []
|
||||
for player in balancing_players:
|
||||
locations_to_test = unlocked_locations[player]
|
||||
|
@ -299,7 +300,6 @@ def balance_multiworld_progression(world):
|
|||
reducing_state = state.copy()
|
||||
for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
|
||||
items_to_test):
|
||||
|
||||
reducing_state.collect(location.item, True, location)
|
||||
|
||||
reducing_state.sweep_for_events(locations=locations_to_test)
|
||||
|
@ -313,33 +313,40 @@ def balance_multiworld_progression(world):
|
|||
items_to_replace.append(testing)
|
||||
|
||||
replaced_items = False
|
||||
replacement_locations = [l for l in checked_locations if not l.event and not l.locked]
|
||||
|
||||
# sort then shuffle to maintain deterministic behaviour,
|
||||
# while allowing use of set for better algorithm growth behaviour elsewhere
|
||||
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
|
||||
world.random.shuffle(replacement_locations)
|
||||
items_to_replace.sort()
|
||||
world.random.shuffle(items_to_replace)
|
||||
|
||||
while replacement_locations and items_to_replace:
|
||||
new_location = replacement_locations.pop()
|
||||
old_location = items_to_replace.pop()
|
||||
|
||||
while not new_location.can_fill(state, old_location.item, False) or (
|
||||
new_location.item and not old_location.can_fill(state, new_location.item, False)):
|
||||
replacement_locations.insert(0, new_location)
|
||||
new_location = replacement_locations.pop()
|
||||
|
||||
swap_location_item(old_location, new_location)
|
||||
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
||||
f"displacing {old_location.item} into {old_location}")
|
||||
state.collect(new_location.item, True, new_location)
|
||||
replaced_items = True
|
||||
for new_location in replacement_locations:
|
||||
if new_location.can_fill(state, old_location.item, False) and \
|
||||
old_location.can_fill(state, new_location.item, False):
|
||||
replacement_locations.remove(new_location)
|
||||
swap_location_item(old_location, new_location)
|
||||
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
|
||||
f"displacing {old_location.item} into {old_location}")
|
||||
state.collect(new_location.item, True, new_location)
|
||||
replaced_items = True
|
||||
break
|
||||
else:
|
||||
logging.warning(f"Could not Progression Balance {old_location.item}")
|
||||
|
||||
if replaced_items:
|
||||
unlocked = [fresh for player in balancing_players for fresh in unlocked_locations[player]]
|
||||
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
|
||||
for location in get_sphere_locations(state, unlocked):
|
||||
unchecked_locations.remove(location)
|
||||
reachable_locations_count[location.player] += 1
|
||||
sphere_locations.append(location)
|
||||
sphere_locations.add(location)
|
||||
|
||||
for location in sphere_locations:
|
||||
if location.event:
|
||||
state.collect(location.item, True, location)
|
||||
checked_locations.extend(sphere_locations)
|
||||
checked_locations |= sphere_locations
|
||||
|
||||
if world.has_beaten_game(state):
|
||||
break
|
||||
|
@ -380,7 +387,8 @@ def distribute_planned(world):
|
|||
set(world.player_ids) - {player}) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}", FillError)
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
@ -391,18 +399,22 @@ def distribute_planned(world):
|
|||
set(world.player_ids)) if location.item_rule(item)
|
||||
)
|
||||
if not unfilled:
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}", FillError)
|
||||
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
|
||||
FillError)
|
||||
continue
|
||||
|
||||
target_world = world.random.choice(unfilled).player
|
||||
|
||||
elif type(target_world) == int: # target world by player id
|
||||
if target_world not in range(1, world.players + 1):
|
||||
placement.failed(f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", ValueError)
|
||||
placement.failed(
|
||||
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
|
||||
ValueError)
|
||||
continue
|
||||
else: # find world by name
|
||||
if target_world not in world_name_lookup:
|
||||
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.", ValueError)
|
||||
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
|
||||
ValueError)
|
||||
continue
|
||||
target_world = world_name_lookup[target_world]
|
||||
|
||||
|
|
|
@ -46,6 +46,7 @@ if __name__ == "__main__":
|
|||
player_name = multi_mystery_options["player_name"]
|
||||
meta_file_path = multi_mystery_options["meta_file_path"]
|
||||
weights_file_path = multi_mystery_options["weights_file_path"]
|
||||
pre_roll = multi_mystery_options["pre_roll"]
|
||||
teams = multi_mystery_options["teams"]
|
||||
rom_file = options["general_options"]["rom_file"]
|
||||
host = options["server_options"]["host"]
|
||||
|
@ -104,6 +105,8 @@ if __name__ == "__main__":
|
|||
command += f" --meta {os.path.join(player_files_path, meta_file_path)}"
|
||||
if os.path.exists(weights_file_path):
|
||||
command += f" --weights {weights_file_path}"
|
||||
if pre_roll:
|
||||
command += " --pre_roll"
|
||||
|
||||
logging.info(command)
|
||||
import time
|
||||
|
|
37
Mystery.py
37
Mystery.py
|
@ -37,6 +37,7 @@ def mystery_argparse():
|
|||
parser.add_argument('--teams', default=1, type=lambda value: max(int(value), 1))
|
||||
parser.add_argument('--create_spoiler', action='store_true')
|
||||
parser.add_argument('--skip_playthrough', action='store_true')
|
||||
parser.add_argument('--pre_roll', action='store_true')
|
||||
parser.add_argument('--rom')
|
||||
parser.add_argument('--enemizercli')
|
||||
parser.add_argument('--outputpath')
|
||||
|
@ -180,6 +181,27 @@ def main(args=None, callback=ERmain):
|
|||
settings.sprite):
|
||||
logging.warning(
|
||||
f"Warning: The chosen sprite, \"{settings.sprite}\", for yaml \"{path}\", does not exist.")
|
||||
if args.pre_roll:
|
||||
import yaml
|
||||
if path == args.weights:
|
||||
settings.name = f"Player{player}"
|
||||
elif not settings.name:
|
||||
settings.name = os.path.split(path)[-1].split(".")[0]
|
||||
|
||||
if "-" not in settings.shuffle and settings.shuffle != "vanilla":
|
||||
settings.shuffle += f"-{random.randint(0, 2 ** 64)}"
|
||||
|
||||
pre_rolled = dict()
|
||||
pre_rolled["original_seed_number"] = seed
|
||||
pre_rolled["original_seed_name"] = seedname
|
||||
pre_rolled["pre_rolled"] = vars(settings).copy()
|
||||
if "plando_items" in pre_rolled["pre_rolled"]:
|
||||
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in pre_rolled["pre_rolled"]["plando_items"]]
|
||||
if "plando_connections" in pre_rolled["pre_rolled"]:
|
||||
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in pre_rolled["pre_rolled"]["plando_connections"]]
|
||||
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
|
||||
yaml.dump(pre_rolled, f)
|
||||
for k, v in vars(settings).items():
|
||||
if v is not None:
|
||||
getattr(erargs, k)[player] = v
|
||||
|
@ -349,6 +371,21 @@ def roll_triggers(weights: dict) -> dict:
|
|||
return weights
|
||||
|
||||
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses"))):
|
||||
if "pre_rolled" in weights:
|
||||
pre_rolled = weights["pre_rolled"]
|
||||
|
||||
if "plando_items" in pre_rolled:
|
||||
pre_rolled["plando_items"] = [PlandoItem(item["item"],
|
||||
item["location"],
|
||||
item["world"],
|
||||
item["from_pool"],
|
||||
item["force"]) for item in pre_rolled["plando_items"]]
|
||||
if "plando_connections" in pre_rolled:
|
||||
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
|
||||
connection["exit"],
|
||||
connection["direction"]) for connection in pre_rolled["plando_connections"]]
|
||||
return argparse.Namespace(**pre_rolled)
|
||||
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
|
|
1
Utils.py
1
Utils.py
|
@ -188,6 +188,7 @@ def get_default_options() -> dict:
|
|||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"pre_roll": False,
|
||||
"player_name": "",
|
||||
"create_spoiler": 1,
|
||||
"zip_roms": 0,
|
||||
|
|
|
@ -1602,37 +1602,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"triforcehud": {
|
||||
"keyString": "rom.triforcehud",
|
||||
"friendlyName": "Triforce Hud Options",
|
||||
"description": "Hide the triforce hud in certain circumstances.",
|
||||
"inputType": "range",
|
||||
"subOptions": {
|
||||
"normal": {
|
||||
"keyString": "rom.triforcehud.normal",
|
||||
"friendlyName": "Normal",
|
||||
"description": "Always displays HUD as usual.",
|
||||
"defaultValue": 0
|
||||
},
|
||||
"hide_goal": {
|
||||
"keyString": "rom.triforcehud.hide_goal",
|
||||
"friendlyName": "Hide Goal",
|
||||
"description": "Hide Triforce Hud elements until a single triforce piece is acquired or spoken to Murahadala",
|
||||
"defaultValue": 50
|
||||
},
|
||||
"hide_total": {
|
||||
"keyString": "rom.triforcehud.hide_required",
|
||||
"friendlyName": "Hide Required Total",
|
||||
"description": "Hide total amount needed to win the game (unless spoken to Murahadala)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
"hide_both": {
|
||||
"keyString": "rom.triforcehud.hide_both",
|
||||
"friendlyName": "Hide Both",
|
||||
"description": "Hide both of the above options",
|
||||
"defaultValue": 0
|
||||
}
|
||||
},
|
||||
"reduceflashing": {
|
||||
"keyString": "rom.reduceflashing",
|
||||
"friendlyName": "Full-Screen Flashing Effects",
|
||||
|
@ -1673,6 +1642,38 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"triforcehud": {
|
||||
"keyString": "rom.triforcehud",
|
||||
"friendlyName": "Triforce Hunt HUD Options",
|
||||
"description": "Hide the triforce hud in certain circumstances.",
|
||||
"inputType": "range",
|
||||
"subOptions": {
|
||||
"normal": {
|
||||
"keyString": "rom.triforcehud.normal",
|
||||
"friendlyName": "Always Show",
|
||||
"description": "Always display HUD",
|
||||
"defaultValue": 50
|
||||
},
|
||||
"hide_goal": {
|
||||
"keyString": "rom.triforcehud.hide_goal",
|
||||
"friendlyName": "Hide HUD",
|
||||
"description": "Hide Triforce HUD elements until a single triforce piece is acquired or you speak to Murahadala",
|
||||
"defaultValue": 0
|
||||
},
|
||||
"hide_total": {
|
||||
"keyString": "rom.triforcehud.hide_required",
|
||||
"friendlyName": "Hide Total",
|
||||
"description": "Hide total triforce pieces needed to win the game until you speak with Murahadala",
|
||||
"defaultValue": 0
|
||||
},
|
||||
"hide_both": {
|
||||
"keyString": "rom.triforcehud.hide_both",
|
||||
"friendlyName": "Hide HUD Total",
|
||||
"description": "Combination of Hide HUD and Hide Total",
|
||||
"defaultValue": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"menuspeed": {
|
||||
"keyString": "menuspeed",
|
||||
"friendlyName": "Menu Speed",
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
# For use with the weighted-settings page on the website. Changing this value will cause all users to be prompted
|
||||
# to update their settings. The version number should match the current released version number, and the revision
|
||||
# should be updated manually by whoever edits this file.
|
||||
ws_version: 4.0.1 rev1
|
||||
ws_version: 4.1.0 rev0
|
||||
|
||||
description: Template Name # Used to describe your yaml. Useful if you have multiple files
|
||||
name: YourName # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit
|
||||
|
@ -363,6 +363,11 @@ rom:
|
|||
quickswap: # Enable switching items by pressing the L+R shoulder buttons
|
||||
on: 50
|
||||
off: 0
|
||||
triforcehud: # Disable visibility of the triforce hud unless collecting a piece or speaking to Murahadala
|
||||
normal: 50 # original behavior (always visible)
|
||||
hide_goal: 0 # hide counter until a piece is collected or speaking to Murahadala
|
||||
hide_required: 0 # Always visible, but required amount is invisible until determined by Murahadala
|
||||
hide_both: 0 # Hide both under above circumstances
|
||||
reduceflashing: # Reduces instances of flashing such as lightning attacks, weather, ether and more.
|
||||
on: 50
|
||||
off: 0
|
||||
|
|
|
@ -350,8 +350,9 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
|
||||
# Add items to player inventory
|
||||
for (ms_team, ms_player), locations_checked in room.multisave.get("location_checks", {}):
|
||||
# logging.info(f"{ms_team}, {ms_player}, {locations_checked}")
|
||||
# Skip teams and players not matching the request
|
||||
if ms_team != (team - 1) or ms_player != player:
|
||||
if ms_team != (team - 1):
|
||||
continue
|
||||
|
||||
# If the player does not have the item, do nothing
|
||||
|
@ -360,7 +361,10 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
continue
|
||||
|
||||
item, recipient = locations[location, ms_player]
|
||||
attribute_item_solo(inventory, item)
|
||||
if recipient == player:
|
||||
attribute_item_solo(inventory, item)
|
||||
if ms_player != player:
|
||||
continue
|
||||
checks_done[player_location_to_area[location]] += 1
|
||||
checks_done["Total"] += 1
|
||||
|
||||
|
@ -391,7 +395,7 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
sword_acquired = False
|
||||
sword_names = ['Fighter Sword', 'Master Sword', 'Tempered Sword', 'Golden Sword']
|
||||
if "Progressive Sword" in acquired_items:
|
||||
sword_url = icons[sword_names[inventory[progressive_items["Progressive Sword"]] - 1]]
|
||||
sword_url = icons[sword_names[min(inventory[progressive_items["Progressive Sword"]], 4) - 1]]
|
||||
sword_acquired = True
|
||||
else:
|
||||
for sword in reversed(sword_names):
|
||||
|
@ -404,7 +408,7 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
gloves_acquired = False
|
||||
glove_names = ["Power Glove", "Titan Mitts"]
|
||||
if "Progressive Glove" in acquired_items:
|
||||
gloves_url = icons[glove_names[inventory[progressive_items["Progressive Glove"]] - 1]]
|
||||
gloves_url = icons[glove_names[min(inventory[progressive_items["Progressive Glove"]], 2) - 1]]
|
||||
gloves_acquired = True
|
||||
else:
|
||||
for glove in reversed(glove_names):
|
||||
|
@ -417,7 +421,7 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
bow_acquired = False
|
||||
bow_names = ["Bow", "Silver Bow"]
|
||||
if "Progressive Bow" in acquired_items:
|
||||
bow_url = icons[bow_names[inventory[progressive_items["Progressive Bow"]] - 1]]
|
||||
bow_url = icons[bow_names[min(inventory[progressive_items["Progressive Bow"]], 2) - 1]]
|
||||
bow_acquired = True
|
||||
else:
|
||||
for bow in reversed(bow_names):
|
||||
|
@ -429,7 +433,7 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
mail_url = icons["Green Mail"]
|
||||
mail_names = ["Blue Mail", "Red Mail"]
|
||||
if "Progressive Mail" in acquired_items:
|
||||
mail_url = icons[mail_names[inventory[progressive_items["Progressive Mail"]] - 1]]
|
||||
mail_url = icons[mail_names[min(inventory[progressive_items["Progressive Mail"]], 2) - 1]]
|
||||
else:
|
||||
for mail in reversed(mail_names):
|
||||
if mail in acquired_items:
|
||||
|
@ -440,7 +444,7 @@ def getPlayerTracker(tracker: UUID, team: int, player: int):
|
|||
shield_acquired = False
|
||||
shield_names = ["Blue Shield", "Red Shield", "Mirror Shield"]
|
||||
if "Progressive Shield" in acquired_items:
|
||||
shield_url = icons[shield_names[inventory[progressive_items["Progressive Shield"]] - 1]]
|
||||
shield_url = icons[shield_names[min(inventory[progressive_items["Progressive Shield"]], 3) - 1]]
|
||||
shield_acquired = True
|
||||
else:
|
||||
for shield in reversed(shield_names):
|
||||
|
|
|
@ -64,6 +64,10 @@ multi_mystery_options:
|
|||
weights_file_path: "weights.yaml"
|
||||
# Meta file name, within the stated player_files_path location
|
||||
meta_file_path: "meta.yaml"
|
||||
# Option to pre-roll a yaml that will be used to roll future seeds with the exact same settings every single time.
|
||||
# If using a pre-rolled yaml fails with "Please fix your yaml.", please file a bug report including both the original yaml
|
||||
# as well as the generated pre-rolled yaml.
|
||||
pre_roll: false
|
||||
# Automatically launches {player_name}.yaml's ROM file using the OS's default program once generation completes. (likely your emulator)
|
||||
# Does nothing if the name is not found
|
||||
# Example: player_name = "Berserker"
|
||||
|
|
|
@ -404,10 +404,10 @@ rom:
|
|||
quickswap: # Enable switching items by pressing the L+R shoulder buttons
|
||||
on: 50
|
||||
off: 0
|
||||
triforcehud: # Disable visibility of the triforce hud unless collecting a piece or speaking to Murahalda
|
||||
triforcehud: # Disable visibility of the triforce hud unless collecting a piece or speaking to Murahadala
|
||||
normal: 0 # original behavior (always visible)
|
||||
hide_goal: 50 # hide counter until a piece is collected or speaking to Murahalda
|
||||
hide_required: 0 # Always visible, but required amount is invisible until determined by Murahalda
|
||||
hide_goal: 50 # hide counter until a piece is collected or speaking to Murahadala
|
||||
hide_required: 0 # Always visible, but required amount is invisible until determined by Murahadala
|
||||
hide_both: 0 # Hide both under above circumstances
|
||||
reduceflashing: # Reduces instances of flashing such as lightning attacks, weather, ether and more.
|
||||
on: 50
|
||||
|
|
Loading…
Reference in New Issue