Compare commits

..

3 Commits

Author SHA1 Message Date
Chris Wilson
73ebb23d52 Apply feedback from native French speakers 2024-04-06 19:41:52 -04:00
Chris Wilson
e1738a03cc Merge branch 'main' into stardew-french 2024-04-06 19:28:26 -04:00
Chris Wilson
cb00cf79ba Add French setup guide for Stardew Valley, authored by Firzohche 2024-03-24 22:43:46 -04:00
486 changed files with 11314 additions and 41291 deletions

View File

@@ -1,27 +0,0 @@
{
"include": [
"type_check.py",
"../worlds/AutoSNIClient.py",
"../Patch.py"
],
"exclude": [
"**/__pycache__"
],
"stubPath": "../typings",
"typeCheckingMode": "strict",
"reportImplicitOverride": "error",
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.8",
"pythonPlatform": "Windows",
"executionEnvironments": [
{
"root": ".."
}
]
}

15
.github/type_check.py vendored
View File

@@ -1,15 +0,0 @@
from pathlib import Path
import subprocess
config = Path(__file__).parent / "pyright-config.json"
command = ("pyright", "-p", str(config))
print(" ".join(command))
try:
result = subprocess.run(command)
except FileNotFoundError as e:
print(f"{e} - Is pyright installed?")
exit(1)
exit(result.returncode)

View File

@@ -1,33 +0,0 @@
name: type check
on:
pull_request:
paths:
- "**.py"
- ".github/pyright-config.json"
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
push:
paths:
- "**.py"
- ".github/pyright-config.json"
- ".github/workflows/strict-type-check.yml"
- "**.pyi"
jobs:
pyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: "Install dependencies"
run: |
python -m pip install --upgrade pip pyright==1.1.358
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "pyright: strict check on specific files"
run: python .github/type_check.py

View File

@@ -1,8 +0,0 @@
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch()

View File

@@ -11,8 +11,8 @@ from argparse import Namespace
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
TypedDict, Union, Type, ClassVar
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
Type, ClassVar
import NetUtils
import Options
@@ -51,6 +51,10 @@ class ThreadBarrierProxy:
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
difficulty_requirements: dict
required_medallions: dict
dark_room_logic: Dict[int, str]
restrict_dungeon_item_on_boss: Dict[int, bool]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
@@ -133,6 +137,7 @@ class MultiWorld():
self.random = ThreadBarrierProxy(random.Random())
self.players = players
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
self.glitch_triforce = False
self.algorithm = 'balanced'
self.groups = {}
self.regions = self.RegionManager(players)
@@ -155,14 +160,61 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
self.fix_palaceofdarkness_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
self.fix_trock_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeons_simple'])
for player in range(1, players + 1):
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open')
set_player_attr('difficulty', 'normal')
set_player_attr('item_functionality', 'normal')
set_player_attr('timer', False)
set_player_attr('goal', 'ganon')
set_player_attr('required_medallions', ['Ether', 'Quake'])
set_player_attr('swamp_patch_required', False)
set_player_attr('powder_patch_required', False)
set_player_attr('ganon_at_pyramid', True)
set_player_attr('ganonstower_vanilla', True)
set_player_attr('can_access_trock_eyebridge', None)
set_player_attr('can_access_trock_front', None)
set_player_attr('can_access_trock_big_chest', None)
set_player_attr('can_access_trock_middle', None)
set_player_attr('fix_fake_world', True)
set_player_attr('difficulty_requirements', None)
set_player_attr('boss_shuffle', 'none')
set_player_attr('enemy_health', 'default')
set_player_attr('enemy_damage', 'default')
set_player_attr('beemizer_total_chance', 0)
set_player_attr('beemizer_trap_chance', 0)
set_player_attr('escape_assist', [])
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0)
set_player_attr('clock_mode', False)
set_player_attr('countdown_start_time', 10)
set_player_attr('red_clock_time', -2)
set_player_attr('blue_clock_time', 2)
set_player_attr('green_clock_time', 4)
set_player_attr('can_take_damage', True)
set_player_attr('triforce_pieces_available', 30)
set_player_attr('triforce_pieces_required', 20)
set_player_attr('shop_shuffle', 'off')
set_player_attr('shuffle_prizes', "g")
set_player_attr('sprite_pool', [])
set_player_attr('dark_room_logic', "lamp")
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('game', "Archipelago")
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
@@ -393,7 +445,7 @@ class MultiWorld():
location.item = item
item.location = location
if collect:
self.state.collect(item, location.advancement, location)
self.state.collect(item, location.event, location)
logging.debug('Placed %s at %s', item, location)
@@ -540,7 +592,8 @@ class MultiWorld():
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
if location.progress_type != LocationProgressType.EXCLUDED \
and (location.player in players["locations"] or location.advancement):
and (location.player in players["locations"] or location.event
or (location.item and location.item.advancement)):
return True
return False
@@ -685,7 +738,7 @@ class CollectionState():
locations = self.multiworld.get_filled_locations()
reachable_events = True
# since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.advancement and location not in self.events and
locations = {location for location in locations if location.event and location not in self.events and
not key_only or getattr(location.item, "locked_dungeon_item", False)}
while reachable_events:
reachable_events = {location for location in locations if location.can_reach(self)}
@@ -707,49 +760,15 @@ class CollectionState():
"""Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[player][item] for item in items)
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if each item name is in the state at least as many times as specified."""
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if at least one item name is in the state at least as many times as specified."""
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
def count(self, item: str, player: int) -> int:
return self.prog_items[player][item]
def has_from_list(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list."""
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
found += player_prog_items[item_name]
if found >= count:
return True
return False
def has_from_list_exclusive(self, items: Iterable[str], player: int, count: int) -> bool:
"""Returns True if the state contains at least `count` items matching any of the item names from a list.
Ignores duplicates of the same item."""
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in items:
found += player_prog_items[item_name] > 0
if found >= count:
return True
return False
def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state."""
return sum(self.prog_items[player][item_name] for item_name in items)
def count_from_list_exclusive(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
def item_count(self, item: str, player: int) -> int:
Utils.deprecate("Use count instead.")
return self.count(item, player)
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
"""Returns True if the state contains at least `count` items present in a specified item group."""
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
@@ -758,34 +777,12 @@ class CollectionState():
return True
return False
def has_group_exclusive(self, item_name_group: str, player: int, count: int = 1) -> bool:
"""Returns True if the state contains at least `count` items present in a specified item group.
Ignores duplicates of the same item.
"""
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += player_prog_items[item_name] > 0
if found >= count:
return True
return False
def count_group(self, item_name_group: str, player: int) -> int:
"""Returns the cumulative count of items from an item group present in state."""
found: int = 0
player_prog_items = self.prog_items[player]
return sum(
player_prog_items[item_name]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
)
def count_group_exclusive(self, item_name_group: str, player: int) -> int:
"""Returns the cumulative count of items from an item group present in state.
Ignores duplicates of the same item."""
player_prog_items = self.prog_items[player]
return sum(
player_prog_items[item_name] > 0
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]
)
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += player_prog_items[item_name]
return found
# Item related
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
@@ -1031,6 +1028,7 @@ class Location:
name: str
address: Optional[int]
parent_region: Optional[Region]
event: bool = False
locked: bool = False
show_in_spoiler: bool = True
progress_type: LocationProgressType = LocationProgressType.DEFAULT
@@ -1046,7 +1044,7 @@ class Location:
self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
@@ -1061,6 +1059,7 @@ class Location:
raise Exception(f"Location {self} already filled.")
self.item = item
item.location = self
self.event = item.advancement
self.locked = True
def __repr__(self):
@@ -1076,15 +1075,6 @@ class Location:
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
@property
def advancement(self) -> bool:
return self.item is not None and self.item.advancement
@property
def is_event(self) -> bool:
"""Returns True if the address of this location is None, denoting it is an Event Location."""
return self.address is None
@property
def native_item(self) -> bool:
"""Returns True if the item in this location matches game."""
@@ -1242,7 +1232,7 @@ class Spoiler:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
else:
@@ -1362,15 +1352,12 @@ class Spoiler:
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str) -> None:
from itertools import chain
from worlds import AutoWorld
from Options import Visibility
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
res = getattr(self.multiworld.worlds[player].options, option_key)
if res.visibility & Visibility.spoiler:
display_name = getattr(option_obj, "display_name", option_key)
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
display_name = getattr(option_obj, "display_name", option_key)
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write(
@@ -1401,14 +1388,6 @@ class Spoiler:
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
precollected_items = [f"{item.name} ({self.multiworld.get_player_name(item.player)})"
if self.multiworld.players > 1
else item.name
for item in chain.from_iterable(self.multiworld.precollected_items.values())]
if precollected_items:
outfile.write("\n\nStarting Items:\n\n")
outfile.write("\n".join([item for item in precollected_items]))
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
for location in self.multiworld.get_locations() if location.show_in_spoiler]
outfile.write('\n\nLocations:\n\n')

View File

@@ -193,7 +193,6 @@ class CommonContext:
server_version: Version = Version(0, 0, 0)
generator_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
max_size: int = 16*1024*1024 # 16 MB of max incoming packet size
last_death_link: float = time.time() # last send/received death link on AP layer
@@ -207,8 +206,6 @@ class CommonContext:
finished_game: bool
ready: bool
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str]
seed_name: typing.Optional[str]
@@ -654,8 +651,7 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
try:
port = server_url.port or 38281 # raises ValueError if invalid
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None,
ssl=get_ssl_context() if address.startswith("wss://") else None,
max_size=ctx.max_size)
ssl=get_ssl_context() if address.startswith("wss://") else None)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)

95
Fill.py
View File

@@ -19,12 +19,11 @@ def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
new_state.sweep_for_events(locations=locations)
new_state.sweep_for_events()
return new_state
@@ -35,8 +34,8 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
"""
:param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool, gets mutated by removing locations that get filled.
:param item_pool: Items to fill into the locations, gets mutated by removing items that get placed.
:param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end
@@ -67,8 +66,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
item_pool.pop(p)
break
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
if single_player_placement else None)
base_state, item_pool + unplaced_items)
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
@@ -114,9 +112,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool,
multiworld.get_filled_locations(item.player)
if single_player_placement else None)
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
@@ -163,6 +159,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
multiworld.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
@@ -174,9 +171,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
if cleanup_required:
# validate all placements and remove invalid ones
state = sweep_from_pool(
base_state, [], multiworld.get_filled_locations(item.player)
if single_player_placement else None)
state = sweep_from_pool(base_state, [])
for placement in placements:
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
placement.item.location = None
@@ -220,8 +215,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item],
name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False) -> None:
name: str = "Remaining") -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
@@ -285,21 +279,13 @@ def remaining_fill(multiworld: MultiWorld,
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
if move_unplaceable_to_start_inventory:
last_batch = []
for item in unplaced_items:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
last_batch.append(multiworld.worlds[item.player].create_filler())
remaining_fill(multiworld, locations, unplaced_items, name + " Start Inventory Retry")
else:
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}")
raise FillError(f"No more spots to place {len(unplaced_items)} items. Remaining locations are invalid.\n"
f"Unplaced items:\n"
f"{', '.join(str(item) for item in unplaced_items)}\n"
f"Unfilled locations:\n"
f"{', '.join(str(location) for location in locations)}\n"
f"Already placed {len(placements)}:\n"
f"{', '.join(str(place) for place in placements)}")
itempool.extend(unplaced_items)
@@ -324,6 +310,7 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo
pool.append(location.item)
state.remove(location.item)
location.item = None
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
@@ -429,8 +416,7 @@ def distribute_early_items(multiworld: MultiWorld,
return fill_locations, itempool
def distribute_items_restrictive(multiworld: MultiWorld,
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
def distribute_items_restrictive(multiworld: MultiWorld) -> None:
fill_locations = sorted(multiworld.get_unfilled_locations())
multiworld.random.shuffle(fill_locations)
# get items to distribute
@@ -472,37 +458,14 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations:
# "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
name="Priority")
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "advancement/progression fill"
if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=True,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False, allow_partial=True,
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
if progitempool:
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")
multiworld.push_precollected(item)
filleritempool.append(multiworld.worlds[item.player].create_filler())
logging.warning(f"{len(progitempool)} items moved to start inventory,"
f" due to failure in Progression fill step.")
progitempool[:] = []
else:
raise ValueError(f"Generator Panic Method {panic_method} not recognized.")
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f"Not enough locations for progression items. "
@@ -517,9 +480,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. "
@@ -528,8 +489,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
restitempool = filleritempool + usefulitempool
remaining_fill(multiworld, defaultlocations, restitempool,
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
remaining_fill(multiworld, defaultlocations, restitempool)
unplaced = restitempool
unfilled = defaultlocations
@@ -537,9 +497,10 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if unplaced or unfilled:
logging.warning(
f"Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}")
items_counter = Counter(location.item.player for location in multiworld.get_filled_locations())
items_counter = Counter(location.item.player for location in multiworld.get_locations() if location.item)
locations_counter = Counter(location.player for location in multiworld.get_locations())
items_counter.update(item.player for item in unplaced)
locations_counter.update(location.player for location in unfilled)
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})")
@@ -698,7 +659,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
if location.advancement:
if location.event:
balancing_state.collect(location.item, True, location)
player = location.item.player
# only replace items that end up in another player's world
@@ -755,7 +716,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# 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.advancement and not l.locked)
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
multiworld.random.shuffle(items_to_replace)
@@ -786,7 +747,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
sphere_locations.add(location)
for location in sphere_locations:
if location.advancement:
if location.event:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
@@ -807,6 +768,7 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item, location_1.item = location_1.item, location_2.item
location_1.item.location = location_1
location_2.item.location = location_2
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(multiworld: MultiWorld) -> None:
@@ -1003,6 +965,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
placement['force'])
for (item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:

View File

@@ -9,7 +9,6 @@ import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain
import ModuleUpdate
@@ -22,6 +21,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp import Options as LttPOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
@@ -121,7 +121,7 @@ def main(args=None, callback=ERmain):
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
# sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items():
if filename not in {args.meta_file_path, args.weights_file_path}:
for yaml in yaml_data:
@@ -148,6 +148,7 @@ def main(args=None, callback=ERmain):
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.glitch_triforce = options.generator.glitch_triforce_room
erargs.spoiler = args.spoiler
erargs.race = args.race
erargs.outputname = seed_name
@@ -310,6 +311,13 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name
def prefer_int(input_data: str) -> Union[str, int]:
try:
return int(input_data)
except:
return input_data
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
@@ -320,34 +328,18 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
for option in new_weights:
option_name = option.lstrip("+-")
option_name = option.lstrip("+")
if option.startswith("+") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
if isinstance(new_value, (set, dict)):
cleaned_value.update(new_value)
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
elif option.startswith("-") and option_name in weights:
cleaned_value = weights[option_name]
new_value = new_weights[option]
if isinstance(new_value, set):
cleaned_value.difference_update(new_value)
elif isinstance(new_value, list):
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
cleaned_weights[option_name] = cleaned_value
else:
cleaned_weights[option_name] = new_weights[option]
new_options = set(cleaned_weights) - set(weights)
@@ -370,7 +362,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict:
@@ -395,7 +387,7 @@ def roll_linked_options(weights: dict) -> dict:
return weights
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
def roll_triggers(weights: dict, triggers: list) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = Utils.__version__
for i, option_set in enumerate(triggers):
@@ -418,7 +410,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
valid_keys.add(key)
except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is invalid. "
f"Please fix your triggers.") from e
@@ -426,29 +418,27 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
try:
if option_key in game_weights:
if option_key in game_weights:
try:
if not option.supports_weighting:
player_option = option.from_any(game_weights[option_key])
else:
player_option = option.from_any(get_choice(option_key, game_weights))
del game_weights[option_key]
setattr(ret, option_key, player_option)
except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else:
player_option = option.from_any(option.default) # call the from_any here to support default "random"
setattr(ret, option_key, player_option)
except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
else:
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
if "linked_options" in weights:
weights = roll_linked_options(weights)
valid_trigger_names = set()
if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_trigger_names)
weights = roll_triggers(weights, weights["triggers"])
requirements = weights.get("requires", {})
if requirements:
@@ -483,14 +473,12 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game]
for weight in chain(game_weights, weights):
if weight.startswith("+"):
raise Exception(f"Merge tag cannot be used outside of trigger contexts. Found {weight}")
if weight.startswith("-"):
raise Exception(f"Remove tag cannot be used outside of trigger contexts. Found {weight}")
if any(weight.startswith("+") for weight in game_weights) or \
any(weight.startswith("+") for weight in weights):
raise Exception(f"Merge tag cannot be used outside of trigger contexts.")
if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"], valid_trigger_names)
weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
@@ -499,10 +487,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options)
for option_key in game_weights:
if option_key in {"triggers", *valid_trigger_names}:
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "A Link to the Past":

View File

@@ -102,7 +102,7 @@ components.extend([
Component("Open Patch", func=open_patch),
Component("Generate Template Options", func=generate_yamls),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
])
@@ -259,7 +259,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif not args:
args = {}
if args.get("Patch|Game|Component", None) is not None:
if "Patch|Game|Component" in args:
file, component = identify(args["Patch|Game|Component"])
if file:
args['file'] = file

31
Main.py
View File

@@ -13,7 +13,7 @@ import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings
from Utils import __version__, output_path, version_tuple
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -36,13 +36,38 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options
multiworld.shuffle = args.shuffle.copy()
multiworld.logic = args.logic.copy()
multiworld.mode = args.mode.copy()
multiworld.difficulty = args.difficulty.copy()
multiworld.item_functionality = args.item_functionality.copy()
multiworld.timer = args.timer.copy()
multiworld.goal = args.goal.copy()
multiworld.boss_shuffle = args.shufflebosses.copy()
multiworld.enemy_health = args.enemy_health.copy()
multiworld.enemy_damage = args.enemy_damage.copy()
multiworld.beemizer_total_chance = args.beemizer_total_chance.copy()
multiworld.beemizer_trap_chance = args.beemizer_trap_chance.copy()
multiworld.countdown_start_time = args.countdown_start_time.copy()
multiworld.red_clock_time = args.red_clock_time.copy()
multiworld.blue_clock_time = args.blue_clock_time.copy()
multiworld.green_clock_time = args.green_clock_time.copy()
multiworld.dungeon_counters = args.dungeon_counters.copy()
multiworld.triforce_pieces_available = args.triforce_pieces_available.copy()
multiworld.triforce_pieces_required = args.triforce_pieces_required.copy()
multiworld.shop_shuffle = args.shop_shuffle.copy()
multiworld.shuffle_prizes = args.shuffle_prizes.copy()
multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.dark_room_logic = args.dark_room_logic.copy()
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.required_medallions = args.required_medallions.copy()
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
multiworld.sprite_pool = args.sprite_pool.copy()
multiworld.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
multiworld.set_options(args)
multiworld.set_item_links()
@@ -272,7 +297,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if multiworld.algorithm == 'flood':
flood_items(multiworld) # different algo, biased towards early game progress items
elif multiworld.algorithm == 'balanced':
distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
distribute_items_restrictive(multiworld)
AutoWorld.call_all(multiworld, 'post_fill')

View File

@@ -70,7 +70,7 @@ def install_pkg_resources(yes=False):
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes: bool = False, force: bool = False) -> None:
def update(yes=False, force=False):
global update_ran
if not update_ran:
update_ran = True

View File

@@ -175,13 +175,11 @@ class Context:
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
logger: logging.Logger
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger
log_network: bool = False):
super(Context, self).__init__()
self.slot_info = {}
self.log_network = log_network
@@ -289,12 +287,12 @@ class Context:
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
self.logger.exception(f"Exception during send_msgs, could not send {msg}")
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
self.logger.info(f"Outgoing message: {msg}")
logging.info(f"Outgoing message: {msg}")
return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
@@ -303,12 +301,12 @@ class Context:
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
self.logger.exception("Exception during send_encoded_msgs")
logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint)
return False
else:
if self.log_network:
self.logger.info(f"Outgoing message: {msg}")
logging.info(f"Outgoing message: {msg}")
return True
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
@@ -319,11 +317,11 @@ class Context:
try:
websockets.broadcast(sockets, msg)
except RuntimeError:
self.logger.exception("Exception during broadcast_send_encoded_msgs")
logging.exception("Exception during broadcast_send_encoded_msgs")
return False
else:
if self.log_network:
self.logger.info(f"Outgoing broadcast: {msg}")
logging.info(f"Outgoing broadcast: {msg}")
return True
def broadcast_all(self, msgs: typing.List[dict]):
@@ -332,7 +330,7 @@ class Context:
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text)
logging.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]):
@@ -354,7 +352,7 @@ class Context:
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth:
return
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
@@ -453,7 +451,7 @@ class Context:
for game_name, data in decoded_obj.get("datapackage", {}).items():
if game_name in game_data_packages:
data = game_data_packages[game_name]
self.logger.info(f"Loading embedded data package for game {game_name}")
logging.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
if "location_name_groups" in data:
@@ -485,7 +483,7 @@ class Context:
with open(self.save_filename, "wb") as f:
f.write(zlib.compress(encoded_save))
except Exception as e:
self.logger.exception(e)
logging.exception(e)
return False
else:
return True
@@ -503,12 +501,12 @@ class Context:
save_data = restricted_loads(zlib.decompress(f.read()))
self.set_save(save_data)
except FileNotFoundError:
self.logger.error('No save data found, starting a new game')
logging.error('No save data found, starting a new game')
except Exception as e:
self.logger.exception(e)
logging.exception(e)
self._start_async_saving()
def _start_async_saving(self, atexit_save: bool = True):
def _start_async_saving(self):
if not self.auto_saver_thread:
def save_regularly():
# time.time() is platform dependent, so using the expensive datetime method instead
@@ -522,19 +520,18 @@ class Context:
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
time.sleep(max(1.0, next_wakeup))
if self.save_dirty:
self.logger.debug("Saving via thread.")
logging.debug("Saving via thread.")
self._save()
except OperationalError as e:
self.logger.exception(e)
self.logger.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
logging.exception(e)
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
else:
self.save_dirty = False
self.auto_saver_thread = threading.Thread(target=save_regularly, daemon=True)
self.auto_saver_thread.start()
if atexit_save:
import atexit
atexit.register(self._save, True) # make sure we save on exit too
import atexit
atexit.register(self._save, True) # make sure we save on exit too
def get_save(self) -> dict:
self.recheck_hints()
@@ -589,7 +586,7 @@ class Context:
self.location_check_points = savedata["game_options"]["location_check_points"]
self.server_password = savedata["game_options"]["server_password"]
self.password = savedata["game_options"]["password"]
self.release_mode = savedata["game_options"]["release_mode"]
self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal"))
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.item_cheat = savedata["game_options"]["item_cheat"]
@@ -601,7 +598,7 @@ class Context:
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for items_handling = remote
self.logger.info(
logging.info(
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
f'for {sum(k[2] for k in self.received_items)} players')
@@ -634,6 +631,8 @@ class Context:
def _set_options(self, server_options: dict):
for key, value in server_options.items():
if key == "forfeit_mode":
key = "release_mode"
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
@@ -643,13 +642,13 @@ class Context:
try:
raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e:
self.logger.exception(e)
self.logger.debug(f"Setting server option {key} to {value} from supplied multidata")
logging.exception(e)
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value)
elif key == "disable_item_cheat":
self.item_cheat = not bool(value)
else:
self.logger.debug(f"Unrecognized server option {key}")
logging.debug(f"Unrecognized server option {key}")
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
@@ -683,7 +682,7 @@ class Context:
self.hints[team, player].add(hint)
new_hint_events.add(player)
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
@@ -691,7 +690,7 @@ class Context:
clients = self.clients[team].get(slot)
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
@@ -742,21 +741,21 @@ async def server(websocket, path: str = "/", ctx: Context = None):
try:
if ctx.log_network:
ctx.logger.info("Incoming connection")
logging.info("Incoming connection")
await on_client_connected(ctx, client)
if ctx.log_network:
ctx.logger.info("Sent Room Info")
logging.info("Sent Room Info")
async for data in websocket:
if ctx.log_network:
ctx.logger.info(f"Incoming message: {data}")
logging.info(f"Incoming message: {data}")
for msg in decode(data):
await process_client_cmd(ctx, client, msg)
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
ctx.logger.exception(e)
logging.exception(e)
finally:
if ctx.log_network:
ctx.logger.info("Disconnected")
logging.info("Disconnected")
await ctx.disconnect(client)
@@ -806,25 +805,14 @@ async def on_client_disconnected(ctx: Context, client: Client):
await on_client_left(ctx, client)
_non_game_messages = {"HintGame": "hinting", "Tracker": "tracking", "TextOnly": "viewing"}
""" { tag: ui_message } """
async def on_client_joined(ctx: Context, client: Client):
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
for tag, verb in _non_game_messages.items():
if tag in client.tags:
final_verb = verb
break
else:
final_verb = "playing"
verb = "tracking" if "Tracker" in client.tags else "playing"
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"{final_verb} {ctx.games[client.slot]} has joined. "
f"{verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}.",
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
ctx.notify_client(client, "Now that you are connected, "
@@ -839,19 +827,8 @@ async def on_client_left(ctx: Context, client: Client):
if len(ctx.clients[client.team][client.slot]) < 1:
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
version_str = '.'.join(str(x) for x in client.version)
for tag, verb in _non_game_messages.items():
if tag in client.tags:
final_verb = f"stopped {verb}"
break
else:
final_verb = "left"
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has {final_verb} the game. "
f"Client({version_str}), {client.tags}.",
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
{"type": "Part", "team": client.team, "slot": client.slot})
@@ -988,7 +965,7 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
ctx.player_names[(team, target_player)], ctx.location_names[location]))
info_text = json_format_send_event(new_item, target_player)
@@ -1370,7 +1347,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
@mark_raw
def _cmd_missing(self, filter_text="") -> bool:
"""List all missing location checks from the server's perspective.
Can be given text, which will be used as filter."""
@@ -1380,11 +1356,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if locations:
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name
names = [name for name in names if name in location_groups[filter_text]]
else:
names = [name for name in names if filter_text in name]
names = [name for name in names if filter_text in name]
texts = [f'Missing: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
@@ -1395,7 +1367,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output("No missing location checks found.")
return True
@mark_raw
def _cmd_checked(self, filter_text="") -> bool:
"""List all done location checks from the server's perspective.
Can be given text, which will be used as filter."""
@@ -1405,11 +1376,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if locations:
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
location_groups = self.ctx.location_name_groups[self.ctx.games[self.client.slot]]
if filter_text in location_groups: # location group name
names = [name for name in names if name in location_groups[filter_text]]
else:
names = [name for name in names if filter_text in name]
names = [name for name in names if filter_text in name]
texts = [f'Checked: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
@@ -1532,13 +1499,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = list(set(hints) - new_hints)
if old_hints and not new_hints:
self.ctx.notify_hints(self.client.team, old_hints)
self.output("Hint was previously used, no points deducted.")
old_hints = set(hints) - new_hints
if old_hints:
self.ctx.notify_hints(self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
@@ -1550,7 +1519,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints + old_hints
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
@@ -1560,7 +1529,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
self.ctx.notify_hints(self.client.team, hints)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
@@ -1574,6 +1542,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
self.ctx.notify_hints(self.client.team, hints)
self.ctx.save()
return True
@@ -1628,7 +1597,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd: str = args["cmd"]
except:
ctx.logger.exception(f"Could not get command from {args}")
logging.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"text": f"Could not get command from {args} at `cmd`"}])
raise
@@ -1654,9 +1623,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
ignore_game = not args.get("game") and any(tag in _non_game_messages for tag in args["tags"])
ignore_game = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
if not ignore_game and args['game'] != game:
errors.add('InvalidGame')
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
@@ -1671,7 +1638,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion')
if errors:
ctx.logger.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else:
team, slot = ctx.connect_names[args['name']]
@@ -1872,11 +1839,6 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
if new_status == ClientStatus.CLIENT_GOAL:
ctx.on_goal_achieved(client)
# if player has yet to ever connect to the server, they will not be in client_game_state
if all(player in ctx.client_game_state and ctx.client_game_state[player] == ClientStatus.CLIENT_GOAL
for player in ctx.player_names
if player[0] == client.team and player[1] != client.slot):
ctx.broadcast_text_all(f"Team #{client.team + 1} has completed all of their games! Congratulations!")
ctx.client_game_state[client.team, client.slot] = new_status
ctx.on_client_status_change(client.team, client.slot)
@@ -1930,7 +1892,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_alias(self, player_name_then_alias_name):
"""Set a player's alias, by listing their base name and then their intended alias."""
player_name, _, alias_name = player_name_then_alias_name.partition(" ")
player_name, alias_name = player_name_then_alias_name.split(" ", 1)
player_name, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
for (team, slot), name in self.ctx.player_names.items():
@@ -2130,8 +2092,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
elif game in self.ctx.all_location_and_group_names:
location, usable, response = get_intended_text(full_name, self.ctx.all_location_and_group_names[game])
elif self.ctx.location_names_for_game(game) is not None:
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
else:
self.output("Can't look up location for unknown game. Hint for ID instead.")
return False
@@ -2139,11 +2101,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints:
@@ -2159,47 +2116,32 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
return False
def _cmd_option(self, option_name: str, option_value: str):
"""Set an option for the server."""
value_type = self.ctx.simple_options.get(option_name, None)
if not value_type:
known_options = (f"{option}: {option_type}" for option, option_type in self.ctx.simple_options.items())
self.output(f"Unrecognized option '{option_name}', known: {', '.join(known_options)}")
def _cmd_option(self, option_name: str, option: str):
"""Set options for the server."""
attrtype = self.ctx.simple_options.get(option_name, None)
if attrtype:
if attrtype == bool:
def attrtype(input_text: str):
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
elif attrtype == str and option_name.endswith("password"):
def attrtype(input_text: str):
if input_text.lower() in {"null", "none", '""', "''"}:
return None
return input_text
setattr(self.ctx, option_name, attrtype(option))
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
return True
else:
known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
self.output(f"Unrecognized Option {option_name}, known: "
f"{', '.join(known)}")
return False
if value_type == bool:
def value_type(input_text: str):
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
setattr(self.ctx, option_name, value_type(option_value))
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
return True
def _cmd_datastore(self):
"""Debug Tool: list writable datastorage keys and approximate the size of their values with pickle."""
total: int = 0
texts = []
for key, value in self.ctx.stored_data.items():
size = len(pickle.dumps(value))
total += size
texts.append(f"Key: {key} | Size: {size}B")
texts.insert(0, f"Found {len(self.ctx.stored_data)} keys, "
f"approximately totaling {Utils.format_SI_prefix(total, power=1024)}B")
self.output("\n".join(texts))
async def console(ctx: Context):
import sys
@@ -2223,7 +2165,7 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
defaults = Utils.get_settings()["server_options"].as_dict()
defaults = Utils.get_options()["server_options"].as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
@@ -2289,7 +2231,7 @@ async def auto_shutdown(ctx, to_cancel=None):
if to_cancel:
for task in to_cancel:
task.cancel()
ctx.logger.info("Shutting down due to inactivity.")
logging.info("Shutting down due to inactivity.")
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():

View File

@@ -7,7 +7,6 @@ import math
import numbers
import random
import typing
import enum
from copy import deepcopy
from dataclasses import dataclass
@@ -21,19 +20,6 @@ if typing.TYPE_CHECKING:
import pathlib
class OptionError(ValueError):
pass
class Visibility(enum.IntFlag):
none = 0b0000
template = 0b0001
simple_ui = 0b0010 # show option in simple menus, such as player-options
complex_ui = 0b0100 # show option in complex menus, such as weighted-options
spoiler = 0b1000
all = 0b1111
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
@@ -116,7 +102,6 @@ T = typing.TypeVar('T')
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
default: typing.ClassVar[typing.Any] # something that __init__ will be able to convert to the correct type
visibility = Visibility.all
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
@@ -140,6 +125,12 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def current_key(self) -> str:
return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
f" use current_option_name instead. Worlds should use {self}.current_key"))
return self.current_option_name
@property
def current_option_name(self) -> str:
"""For display purposes. Worlds should be using current_key."""
@@ -382,8 +373,7 @@ class Toggle(NumericOption):
default = 0
def __init__(self, value: int):
# if user puts in an invalid value, make it valid
value = int(bool(value))
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
self.value = value
@classmethod
@@ -744,9 +734,39 @@ class NamedRange(Range):
return super().from_text(text)
class SpecialRange(NamedRange):
special_range_cutoff = 0
# TODO: remove class SpecialRange, earliest 3 releases after 0.4.3
def __new__(cls, value: int) -> SpecialRange:
from Utils import deprecate
deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. "
"Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In "
"NamedRange, range_start specifies the lower end of the regular range, while special values can be "
"placed anywhere (below, inside, or above the regular range).")
return super().__new__(cls)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.special_range_cutoff))
elif text == "random-high":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end, cls.range_end))
elif text == "random-middle":
return cls(cls.triangular(cls.special_range_cutoff, cls.range_end))
elif text.startswith("random-range-"):
return cls.custom_range(text)
elif text == "random":
return cls(random.randint(cls.special_range_cutoff, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. "
f"Acceptable values are: random, random-high, random-middle, random-low, "
f"random-range-low-<min>-<max>, random-range-middle-<min>-<max>, "
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs):
assert not "_valid_keys" in attrs, "'_valid_keys' gets set by FreezeValidKeys, define 'valid_keys' instead."
if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
@@ -948,7 +968,7 @@ class CommonOptions(metaclass=OptionsMetaProperty):
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
"""
@@ -1093,18 +1113,6 @@ class ItemLinks(OptionList):
raise Exception(f"item_link {link['name']} has {intersection} "
f"items in both its local_items and non_local_items pool.")
link.setdefault("link_replacement", None)
link["item_pool"] = list(pool)
class Removed(FreeText):
"""This Option has been Removed."""
default = ""
visibility = Visibility.none
def __init__(self, value: str):
if value:
raise Exception("Option removed, please update your options file.")
super().__init__(value)
@dataclass
@@ -1124,14 +1132,6 @@ class DeathLinkMixin:
death_link: DeathLink
class OptionGroup(typing.NamedTuple):
"""Define a grouping of options."""
name: str
"""Name of the group to categorize these options in for display on the WebHost and in generated YAMLS."""
options: typing.List[typing.Type[Option[typing.Any]]]
"""Options to be in the defined group."""
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
import os
@@ -1170,21 +1170,12 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = {option: option_group.name
for option_group in world.web.option_groups
for option in option_group.options}
ordered_groups = ["Game Options"]
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
grouped_options = {group: {} for group in ordered_groups}
for option_name, option in world.options_dataclass.type_hints.items():
if option.visibility >= Visibility.template:
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
option_groups=grouped_options,
options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range,
)

View File

@@ -65,11 +65,6 @@ Currently, the following games are supported:
* Castlevania 64
* A Short Hike
* Yoshi's Island
* Mario & Luigi: Superstar Saga
* Bomb Rush Cyberfunk
* Aquaria
* Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
* A Hat in Time
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

@@ -85,7 +85,6 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
"""Close connection to a currently connected snes"""
self.ctx.snes_reconnect_address = None
self.ctx.cancel_snes_autoreconnect()
self.ctx.snes_state = SNESState.SNES_DISCONNECTED
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
async_start(self.ctx.snes_socket.close())
return True
@@ -282,7 +281,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None:
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
sni_path = Utils.get_options()["sni_options"]["sni_path"]
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
@@ -565,12 +564,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int,
PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try:
for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
while data:
# Divide the write into packets of 256 bytes.
PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data[:256])
address += 256
data = data[256:]
else:
snes_logger.warning(f"Could not send data to SNES: {data}")
except ConnectionClosed:
return False
@@ -654,7 +657,7 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
Utils.get_options()["sni_options"].get("snes_rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -46,7 +46,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.4.6"
__version__ = "0.4.5"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -101,7 +101,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
@functools.wraps(function)
def wrap(self: S, arg: T) -> RetType:
cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None)
cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]],
getattr(self, cache_name, None))
if cache is None:
res = function(self, arg)
setattr(self, cache_name, {arg: res})
@@ -200,7 +201,7 @@ def cache_path(*path: str) -> str:
def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_settings()["general_options"]["output_path"])
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
path = os.path.join(output_path.cached_path, *path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
@@ -208,11 +209,10 @@ def output_path(*path: str) -> str:
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if is_windows:
os.startfile(filename) # type: ignore
os.startfile(filename)
else:
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename])
@@ -300,21 +300,21 @@ def get_options() -> Settings:
return get_settings()
def persistent_store(category: str, key: str, value: typing.Any):
def persistent_store(category: str, key: typing.Any, value: typing.Any):
path = user_path("_persistent_storage.yaml")
storage = persistent_load()
category_dict = storage.setdefault(category, {})
category_dict[key] = value
storage: dict = persistent_load()
category = storage.setdefault(category, {})
category[key] = value
with open(path, "wt") as f:
f.write(dump(storage, Dumper=Dumper))
def persistent_load() -> Dict[str, Dict[str, Any]]:
storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None)
def persistent_load() -> typing.Dict[str, dict]:
storage = getattr(persistent_load, "storage", None)
if storage:
return storage
path = user_path("_persistent_storage.yaml")
storage = {}
storage: dict = {}
if os.path.exists(path):
try:
with open(path, "r") as f:
@@ -323,7 +323,7 @@ def persistent_load() -> Dict[str, Dict[str, Any]]:
logging.debug(f"Could not read store: {e}")
if storage is None:
storage = {}
setattr(persistent_load, "storage", storage)
persistent_load.storage = storage
return storage
@@ -365,7 +365,6 @@ def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> N
except Exception as e:
logging.debug(f"Could not store data package: {e}")
def get_default_adjuster_settings(game_name: str) -> Namespace:
import LttPAdjuster
adjuster_settings = Namespace()
@@ -384,9 +383,7 @@ def get_adjuster_settings(game_name: str) -> Namespace:
default_settings = get_default_adjuster_settings(game_name)
# Fill in any arguments from the argparser that we haven't seen before
return Namespace(**vars(adjuster_settings), **{
k: v for k, v in vars(default_settings).items() if k not in vars(adjuster_settings)
})
return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)})
@cache_argsless
@@ -410,13 +407,13 @@ safe_builtins = frozenset((
class RestrictedUnpickler(pickle.Unpickler):
generic_properties_module: Optional[object]
def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args, **kwargs):
super(RestrictedUnpickler, self).__init__(*args, **kwargs)
self.options_module = importlib.import_module("Options")
self.net_utils_module = importlib.import_module("NetUtils")
self.generic_properties_module = None
def find_class(self, module: str, name: str) -> type:
def find_class(self, module, name):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by MultiServer -> savegame/multidata
@@ -440,7 +437,7 @@ class RestrictedUnpickler(pickle.Unpickler):
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
def restricted_loads(s: bytes) -> Any:
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
@@ -496,7 +493,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
file_handler.setFormatter(logging.Formatter(log_format))
class Filter(logging.Filter):
def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None:
def __init__(self, filter_name, condition):
super().__init__(filter_name)
self.condition = condition
@@ -547,7 +544,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
)
def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
def stream_input(stream, queue):
def queuer():
while 1:
try:
@@ -575,7 +572,7 @@ class VersionException(Exception):
pass
def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str:
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
text = ""
max_label = len(labels) - 1
while index > max_label:
@@ -598,7 +595,7 @@ def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]:
import jellyfish
@@ -606,23 +603,22 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
limit = limit if limit else len(word_list)
limit: int = limit if limit else len(wordlist)
return list(
map(
lambda container: (container[0], int(container[1]*100)), # convert up to limit to int %
sorted(
map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list),
map(lambda candidate:
(candidate, get_fuzzy_ratio(input_word, candidate)),
wordlist),
key=lambda element: element[1],
reverse=True
)[0:limit]
reverse=True)[0:limit]
)
)
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
@@ -736,7 +732,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.update()
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = frozenset(("a", "the"))):
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)):
@@ -790,7 +786,7 @@ class DeprecateDict(dict):
log_message: str
should_error: bool
def __init__(self, message: str, error: bool = False) -> None:
def __init__(self, message, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()

View File

@@ -23,6 +23,7 @@ def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
register()
app = raw_app
if os.path.exists(configpath) and not app.config["TESTING"]:
import yaml
@@ -33,7 +34,6 @@ def get_app():
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
register()
cache.init_app(app)
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
@@ -117,7 +117,7 @@ if __name__ == "__main__":
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.autolauncher import autohost, autogen, stop
from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.options import create as create_options_files
try:
@@ -138,11 +138,3 @@ if __name__ == "__main__":
else:
from waitress import serve
serve(app, port=app.config["PORT"], threads=app.config["WAITRESS_THREADS"])
else:
from time import sleep
try:
while True:
sleep(1) # wait for process to be killed
except (SystemExit, KeyboardInterrupt):
pass
stop() # stop worker threads

View File

@@ -23,7 +23,6 @@ app.jinja_env.filters['all'] = all
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["HOSTERS"] = 8 # maximum concurrent room hosters
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
@@ -52,7 +51,6 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "SimpleCache"
app.config["HOST_ADDRESS"] = ""
app.config["ASSET_RIGHTS"] = False
cache = Cache()
Compress(app)
@@ -84,6 +82,6 @@ def register():
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
app.register_blueprint(api.api_endpoints)

View File

@@ -2,9 +2,8 @@
from typing import List, Tuple
from uuid import UUID
from flask import Blueprint, abort, url_for
from flask import Blueprint, abort
import worlds.Files
from .. import cache
from ..models import Room, Seed
@@ -22,30 +21,12 @@ def room_info(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
def supports_apdeltapatch(game: str):
return game in worlds.Files.AutoPatchRegister.patch_types
downloads = []
for slot in sorted(room.seed.slots):
if slot.data and not supports_apdeltapatch(slot.game):
slot_download = {
"slot": slot.player_id,
"download": url_for("download_slot_file", room_id=room.id, player_id=slot.player_id)
}
downloads.append(slot_download)
elif slot.data:
slot_download = {
"slot": slot.player_id,
"download": url_for("download_patch", patch_id=slot.id, room_id=room.id)
}
downloads.append(slot_download)
return {
"tracker": room.tracker,
"players": get_players(room.seed),
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout,
"downloads": downloads,
"timeout": room.timeout
}

View File

@@ -3,25 +3,26 @@ from __future__ import annotations
import json
import logging
import multiprocessing
import threading
import time
import typing
from datetime import timedelta, datetime
from threading import Event, Thread
from uuid import UUID
from datetime import timedelta, datetime
from pony.orm import db_session, select, commit
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
_stop_event = Event()
def launch_room(room: Room, config: dict):
# requires db_session!
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout):
multiworld = multiworlds.get(room.id, None)
if not multiworld:
multiworld = MultiworldInstance(room, config)
def stop():
"""Stops previously launched threads"""
global _stop_event
stop_event = _stop_event
_stop_event = Event() # new event for new threads
stop_event.set()
multiworld.start()
def handle_generation_success(seed_id):
@@ -58,50 +59,39 @@ def init_db(pony_config: dict):
db.generate_mapping()
def cleanup():
"""delete unowned user-content"""
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
def autohost(config: dict):
def keep_running():
stop_event = _stop_event
try:
with Locker("autohost"):
cleanup()
hosters = []
for x in range(config["HOSTERS"]):
hoster = MultiworldInstance(config, x)
hosters.append(hoster)
hoster.start()
while not stop_event.wait(0.1):
# delete unowned user-content
with db_session:
# >>> bool(uuid.UUID(int=0))
# True
rooms = Room.select(lambda room: room.owner == UUID(int=0)).delete(bulk=True)
seeds = Seed.select(lambda seed: seed.owner == UUID(int=0) and not seed.rooms).delete(bulk=True)
slots = Slot.select(lambda slot: not slot.seed).delete(bulk=True)
# Command gets deleted by ponyorm Cascade Delete, as Room is Required
if rooms or seeds or slots:
logging.info(f"{rooms} Rooms, {seeds} Seeds and {slots} Slots have been deleted.")
run_guardian()
while 1:
time.sleep(0.1)
with db_session:
rooms = select(
room for room in Room if
room.last_activity >= datetime.utcnow() - timedelta(days=3))
for room in rooms:
# we have to filter twice, as the per-room timeout can't currently be PonyORM transpiled.
if room.last_activity >= datetime.utcnow() - timedelta(seconds=room.timeout + 5):
hosters[room.id.int % len(hosters)].start_room(room.id)
launch_room(room, config)
except AlreadyRunningException:
logging.info("Autohost reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autohost").start()
import threading
threading.Thread(target=keep_running, name="AP_Autohost").start()
def autogen(config: dict):
def keep_running():
stop_event = _stop_event
try:
with Locker("autogen"):
@@ -122,7 +112,8 @@ def autogen(config: dict):
commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
while not stop_event.wait(0.1):
while 1:
time.sleep(0.1)
with db_session:
# for update locks the database row(s) during transaction, preventing writes from elsewhere
to_start = select(
@@ -133,45 +124,37 @@ def autogen(config: dict):
except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.")
Thread(target=keep_running, name="AP_Autogen").start()
import threading
threading.Thread(target=keep_running, name="AP_Autogen").start()
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
class MultiworldInstance():
def __init__(self, config: dict, id: int):
self.room_ids = set()
def __init__(self, room: Room, config: dict):
self.room_id = room.id
self.process: typing.Optional[multiprocessing.Process] = None
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
self.rooms_to_start = multiprocessing.Queue()
self.rooms_shutting_down = multiprocessing.Queue()
self.name = f"MultiHoster{id}"
def start(self):
if self.process and self.process.is_alive():
return False
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.name, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host,
self.rooms_to_start, self.rooms_shutting_down),
name=self.name)
args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.
self.process = process
def start_room(self, room_id):
while not self.rooms_shutting_down.empty():
self.room_ids.remove(self.rooms_shutting_down.get(block=True, timeout=None))
if room_id in self.room_ids:
pass # should already be hosted currently.
else:
self.room_ids.add(room_id)
self.rooms_to_start.put(room_id)
def stop(self):
if self.process:
self.process.terminate()
@@ -185,6 +168,40 @@ class MultiworldInstance():
self.process = None
guardian = None
guardian_lock = threading.Lock()
def run_guardian():
global guardian
global multiworlds
with guardian_lock:
if not guardian:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
def guard():
while 1:
time.sleep(1)
done = []
with guardian_lock:
for key, instance in multiworlds.items():
if instance.done():
instance.collect()
done.append(key)
for key in done:
del (multiworlds[key])
guardian = threading.Thread(name="Guardian", target=guard)
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed, Slot
from .customserver import run_server_process, get_static_server_data
from .generate import gen_game

View File

@@ -108,10 +108,7 @@ def roll_options(options: Dict[str, Union[dict, str]],
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
except Exception as e:
if e.__cause__:
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"
else:
results[filename] = f"Failed to generate options in {filename}: {e}"
results[filename] = f"Failed to generate options in {filename}: {e}"
else:
results[filename] = True
return results, rolled_results

View File

@@ -5,7 +5,6 @@ import collections
import datetime
import functools
import logging
import multiprocessing
import pickle
import random
import socket
@@ -54,19 +53,17 @@ del MultiServer
class DBCommandProcessor(ServerCommandProcessor):
def output(self, text: str):
self.ctx.logger.info(text)
logging.info(text)
class WebHostContext(Context):
room_id: int
def __init__(self, static_server_data: dict, logger: logging.Logger):
def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1,
40, True, "enabled", "enabled",
"enabled", 0, 2, logger=logger)
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
del self.static_server_data
self.main_loop = asyncio.get_running_loop()
self.video = {}
@@ -74,7 +71,6 @@ class WebHostContext(Context):
def _load_game_data(self):
for key, value in self.static_server_data.items():
# NOTE: attributes are mutable and shared, so they will have to be copied before being modified
setattr(self, key, value)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
@@ -102,37 +98,18 @@ class WebHostContext(Context):
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
static_gamespackage = self.gamespackage # this is shared across all rooms
static_item_name_groups = self.item_name_groups
static_location_name_groups = self.location_name_groups
self.gamespackage = {"Archipelago": static_gamespackage["Archipelago"]} # this may be modified by _load
self.item_name_groups = {}
self.location_name_groups = {}
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata and use static data
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups
self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True)
@db_session
@@ -142,7 +119,7 @@ class WebHostContext(Context):
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False)
self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
@@ -182,125 +159,72 @@ def get_static_server_data() -> dict:
return data
def set_up_logging(room_id) -> logging.Logger:
import os
# logger setup
logger = logging.getLogger(f"RoomLogger {room_id}")
# this *should* be empty, but just in case.
for handler in logger.handlers[:]:
logger.removeHandler(handler)
handler.close()
file_handler = logging.FileHandler(
os.path.join(Utils.user_path("logs"), f"{room_id}.txt"),
"a",
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter("[%(asctime)s]: %(message)s"))
logger.setLevel(logging.INFO)
logger.addHandler(file_handler)
return logger
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
Utils.init_logging(name)
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# Each Server is another file handle, so request as many as we can from the system
file_limit = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
# set soft limit to hard limit
resource.setrlimit(resource.RLIMIT_NOFILE, (file_limit, file_limit))
del resource, file_limit
host: str):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
async def main():
if "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
del cert_file, cert_key_file, ponyconfig
gc.collect() # free intermediate objects used during setup
import gc
Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
gc.collect() # free intermediate objects used during setup
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
loop = asyncio.get_event_loop()
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
async def start_room(room_id):
with Locker(f"RoomLocker {room_id}"):
try:
logger = set_up_logging(room_id)
ctx = WebHostContext(static_server_data, logger)
ctx.load(room_id)
ctx.init_save()
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
# ensure auto launch is on the same page in regard to room activity.
with db_session:
room: Room = Room.get(id=ctx.room_id)
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
port = socketname[1]
if port:
ctx.logger.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
logging.info("Shutting down")
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
except Exception as e:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
logger.exception(e)
raise
else:
if ctx.saving:
ctx._save()
finally:
try:
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout)
logging.info(f"Shutting down room {room_id} on {name}.")
finally:
await asyncio.sleep(5)
rooms_shutting_down.put(room_id)
class Starter(threading.Thread):
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
logging.info(f"Starting room {next_room} on {name}.")
starter = Starter()
starter.daemon = True
starter.start()
loop.run_forever()
with Locker(room_id):
try:
asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
with db_session:
room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except Exception:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
raise

View File

@@ -70,41 +70,37 @@ def generate(race=False):
flash(options)
else:
meta = get_meta(request.form, race)
return start_generation(options, meta)
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race, version=__version__)
def start_generation(options: Dict[str, Union[dict, str]], meta: Dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
elif len(gen_options) > app.config["MAX_ROLL"]:
flash(f"Sorry, generating of multiworlds is limited to {app.config['MAX_ROLL']} players. "
f"If you have a larger group, please generate it yourself and upload it.")
elif len(gen_options) >= app.config["JOB_THRESHOLD"]:
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps(meta),
state=STATE_QUEUED,
owner=session["_id"])
commit()
return redirect(url_for("wait_seed", seed=gen.id))
else:
try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int)
except BaseException as e:
from .autolauncher import handle_generation_failure
handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e)))
return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}

View File

@@ -37,6 +37,25 @@ def start_playing():
return render_template(f"startPlaying.html")
# TODO for back compat. remove around 0.4.5
@app.route("/weighted-settings")
def weighted_settings():
return redirect("weighted-options", 301)
@app.route("/weighted-options")
@cache.cached()
def weighted_options():
return render_template("weighted-options.html")
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_template("player-options.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
@@ -131,7 +150,6 @@ def host_room(room: UUID):
if cmd:
Command(room=room, commandtext=cmd)
commit()
return redirect(url_for("host_room", room=room.id))
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port

View File

@@ -1,226 +1,188 @@
import collections.abc
import json
import logging
import os
from textwrap import dedent
from typing import Dict, Union
import yaml
from flask import redirect, render_template, request, Response
import typing
import Options
from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations", "priority_locations"}
def create() -> None:
def create():
target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs")
Options.generate_yaml_templates(yaml_folder)
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
def get_world_theme(game_name: str) -> str:
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
weighted_options = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "",
"game": {},
},
"games": {},
}
for game_name, world in AutoWorldRegister.world_types.items():
def render_options_page(template: str, world_name: str, is_complex: bool = False) -> Union[Response, str]:
visibility_flag = Options.Visibility.complex_ui if is_complex else Options.Visibility.simple_ui
world = AutoWorldRegister.world_types[world_name]
if world.hidden or world.web.options_page is False:
return redirect("games")
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
option_groups = {option: option_group.name
for option_group in world.web.option_groups
for option in option_group.options}
ordered_groups = ["Game Options", *[group.name for group in world.web.option_groups]]
grouped_options = {group: {} for group in ordered_groups}
for option_name, option in world.options_dataclass.type_hints.items():
# Exclude settings from options pages if their visibility is disabled
if visibility_flag in option.visibility:
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
return render_template(
template,
world_name=world_name,
world=world,
option_groups=grouped_options,
issubclass=issubclass,
Options=Options,
theme=get_world_theme(world_name),
)
def generate_game(options: Dict[str, Union[dict, str]]) -> Union[Response, str]:
from .generate import start_generation
return start_generation(options, {"plando_options": ["items", "connections", "texts", "bosses"]})
def send_yaml(player_name: str, formatted_options: dict) -> Response:
response = Response(yaml.dump(formatted_options, sort_keys=False))
response.headers["Content-Type"] = "text/yaml"
response.headers["Content-Disposition"] = f"attachment; filename={player_name}.yaml"
return response
@app.template_filter("dedent")
def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ")
@app.template_test("ordered")
def test_ordered(obj):
return isinstance(obj, collections.abc.Sequence)
@app.route("/games/<string:game>/option-presets", methods=["GET"])
@cache.cached()
def option_presets(game: str) -> Response:
world = AutoWorldRegister.world_types[game]
class SetEncoder(json.JSONEncoder):
def default(self, obj):
from collections.abc import Set
if isinstance(obj, Set):
return list(obj)
return json.JSONEncoder.default(self, obj)
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
response = Response(json_data)
response.headers["Content-Type"] = "application/json"
return response
@app.route("/weighted-options")
def weighted_options_old():
return redirect("games", 301)
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
def generate_weighted_yaml(game: str):
if request.method == "POST":
intent_generate = False
options = {}
for key, val in request.form.items():
if "||" not in key:
if len(str(val)) == 0:
continue
options[key] = val
else:
if int(val) == 0:
continue
[option, setting] = key.split("||")
options.setdefault(option, {})[setting] = int(val)
# Error checking
if "name" not in options:
return "Player name is required."
# Remove POST data irrelevant to YAML
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
# Properly format YAML output
player_name = options["name"]
del options["name"]
formatted_options = {
"name": player_name,
"game": game,
"description": f"Generated by https://archipelago.gg/ for {game}",
game: options,
# Generate JSON files for player-options pages
player_options = {
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
"name": "",
},
}
if intent_generate:
return generate_game({player_name: formatted_options})
game_options = {}
for option_name, option in all_options.items():
if option_name in handled_in_js:
pass
else:
return send_yaml(player_name, formatted_options)
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": None,
"options": []
}
for sub_option_id, sub_option_name in option.name_lookup.items():
if sub_option_name != "random":
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
if not this_option["defaultValue"]:
this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range):
game_options[option_name] = {
"type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
if issubclass(option, Options.NamedRange):
game_options[option_name]["type"] = 'named_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif issubclass(option, Options.ItemSet):
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.LocationSet):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"options": list(option.valid_keys),
"defaultValue": list(option.default) if hasattr(option, "default") else []
}
# YAML generator for player-options
@app.route("/games/<string:game>/generate-yaml", methods=["POST"])
def generate_yaml(game: str):
if request.method == "POST":
options = {}
intent_generate = False
for key, val in request.form.items(multi=True):
if key in options:
if not isinstance(options[key], list):
options[key] = [options[key]]
options[key].append(val)
else:
options[key] = val
logging.debug(f"{option} not exported to Web Options.")
# Detect and build ItemDict options from their name pattern
for key, val in options.copy().items():
key_parts = key.rsplit("||", 2)
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}
if val != "0":
options[key_parts[0]][key_parts[1]] = int(val)
del options[key]
player_options["gameOptions"] = game_options
# Detect random-* keys and set their options accordingly
for key, val in options.copy().items():
if key.startswith("random-"):
options[key.removeprefix("random-")] = "random"
del options[key]
player_options["presetOptions"] = {}
for preset_name, preset in world.web.options_presets.items():
player_options["presetOptions"][preset_name] = {}
for option_name, option_value in preset.items():
# Random range type settings are not valid.
assert (not str(option_value).startswith("random-")), \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
f"values are not supported for presets."
# Error checking
if not options["name"]:
return "Player name is required."
# Normal random is supported, but needs to be handled explicitly.
if option_value == "random":
player_options["presetOptions"][preset_name][option_name] = option_value
continue
# Remove POST data irrelevant to YAML
preset_name = 'default'
if "intent-generate" in options:
intent_generate = True
del options["intent-generate"]
if "intent-export" in options:
del options["intent-export"]
if "game-options-preset" in options:
preset_name = options["game-options-preset"]
del options["game-options-preset"]
option = world.options_dataclass.type_hints[option_name].from_any(option_value)
if isinstance(option, Options.NamedRange) and isinstance(option_value, str):
assert option_value in option.special_range_names, \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
# Properly format YAML output
player_name = options["name"]
del options["name"]
# Still use the true value for the option, not the name.
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option, Options.Range):
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option_value, str):
# For Choice and Toggle options, the value should be the name of the option. This is to prevent
# setting a preset for an option with an overridden from_text method that would normally be okay,
# but would not be okay for the webhost's current implementation of player options UI.
assert option.name_lookup[option.value] == option_value, \
f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
player_options["presetOptions"][preset_name][option_name] = option.current_key
else:
# int and bool values are fine, just resolve them to the current key for webhost.
player_options["presetOptions"][preset_name][option_name] = option.current_key
description = f"Generated by https://archipelago.gg/ for {game}"
if preset_name != 'default' and preset_name != 'custom':
description += f" using {preset_name} preset"
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
formatted_options = {
"name": player_name,
"game": game,
"description": description,
game: options,
}
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
json.dump(player_options, f, indent=2, separators=(',', ': '))
if intent_generate:
return generate_game({player_name: formatted_options})
if not world.hidden and world.web.options_page is True:
# Add the random option to Choice, TextChoice, and Toggle options
for option in game_options.values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_options["baseOptions"]["game"][game_name] = 0
weighted_options["games"][game_name] = {
"gameSettings": game_options,
"gameItems": tuple(world.item_names),
"gameItemGroups": [
group for group in world.item_name_groups.keys() if group != "Everything"
],
"gameItemDescriptions": world.item_descriptions,
"gameLocations": tuple(world.location_names),
"gameLocationGroups": [
group for group in world.location_name_groups.keys() if group != "Everywhere"
],
"gameLocationDescriptions": world.location_descriptions,
}
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
json.dump(weighted_options, f, indent=2, separators=(',', ': '))
else:
return send_yaml(player_name, formatted_options)

View File

@@ -1,14 +0,0 @@
from WebHostLib import app
from flask import abort
from . import cache
@cache.cached()
@app.route('/robots.txt')
def robots():
# If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]:
return app.send_static_file('robots.txt')
# Send 404 if the host has affirmed this to be the official WebHost
abort(404)

View File

@@ -0,0 +1,20 @@
window.addEventListener('load', () => {
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item and location trackers
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
document.getElementById('location-table').innerHTML = fakeDOM.getElementById('location-table').innerHTML;
};
ajax.open('GET', url);
ajax.send();
}, 15000)
});

View File

@@ -0,0 +1,523 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-options').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchOptionData().then((results) => {
let optionHash = localStorage.getItem(`${gameName}-hash`);
if (!optionHash) {
// If no hash data has been set before, set it now
optionHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, optionHash);
localStorage.removeItem(gameName);
}
if (optionHash !== md5(JSON.stringify(results))) {
showUserMessage(
'Your options are out of date! Click here to update them! Be aware this will reset them all to default.'
);
document.getElementById('user-message').addEventListener('click', resetOptions);
}
// Page setup
createDefaultOptions(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-options').addEventListener('click', () => exportOptions());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerOptions = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
nameInput.value = playerOptions.name;
// Presets
const presetSelect = document.getElementById('game-options-preset');
presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
for (const preset in results['presetOptions']) {
const presetOption = document.createElement('option');
presetOption.innerText = preset;
presetSelect.appendChild(presetOption);
}
presetSelect.value = localStorage.getItem(`${gameName}-preset`);
results['presetOptions']['__default'] = {};
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`);
localStorage.removeItem(`${gameName}-preset`);
window.location.reload();
};
const fetchOptionData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
ajax.send();
});
const createDefaultOptions = (optionData) => {
if (!localStorage.getItem(gameName)) {
const newOptions = {
[gameName]: {},
};
for (let baseOption of Object.keys(optionData.baseOptions)){
newOptions[baseOption] = optionData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(optionData.gameOptions)){
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};
const buildUI = (optionData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(optionData.gameOptions).forEach((key, index) => {
if (index < Object.keys(optionData.gameOptions).length / 2) {
leftGameOpts[key] = optionData.gameOptions[key];
} else {
rightGameOpts[key] = optionData.gameOptions[key];
}
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (options, romOpts = false) => {
const currentOptions = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(options).forEach((option) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${options[option].displayName}: `;
label.setAttribute('for', option);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', options[option].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(options[option].type) {
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', option);
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
options[option].options.forEach((opt) => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', opt.value);
optionElement.innerText = opt.name;
if ((isNaN(currentOptions[gameName][option]) &&
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
(opt.value === currentOptions[gameName][option]))
{
optionElement.selected = true;
}
select.appendChild(optionElement);
});
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('id', option);
range.setAttribute('type', 'range');
range.setAttribute('data-key', option);
range.setAttribute('min', options[option].min);
range.setAttribute('max', options[option].max);
range.value = currentOptions[gameName][option];
range.addEventListener('change', (event) => {
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${option}-value`);
rangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'named_range':
element = document.createElement('div');
element.classList.add('named-range-container');
// Build the select element
let namedRangeSelect = document.createElement('select');
namedRangeSelect.setAttribute('data-key', option);
Object.keys(options[option].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = options[option].value_names[presetName];
const words = presetOption.innerText.split('_');
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(' ');
namedRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
namedRangeSelect.appendChild(customOption);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
namedRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
let namedRangeWrapper = document.createElement('div');
namedRangeWrapper.classList.add('named-range-wrapper');
let namedRange = document.createElement('input');
namedRange.setAttribute('type', 'range');
namedRange.setAttribute('data-key', option);
namedRange.setAttribute('min', options[option].min);
namedRange.setAttribute('max', options[option].max);
namedRange.value = currentOptions[gameName][option];
// Build rage value element
let namedRangeVal = document.createElement('span');
namedRangeVal.classList.add('range-value');
namedRangeVal.setAttribute('id', `${option}-value`);
namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
// Configure select event listener
namedRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
namedRange.value = event.target.value;
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
namedRange.addEventListener('change', (event) => {
// Update select element
namedRangeSelect.value =
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(namedRangeSelect);
namedRangeWrapper.appendChild(namedRange);
namedRangeWrapper.appendChild(namedRangeVal);
element.appendChild(namedRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, namedRange, namedRangeSelect)
);
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
namedRange.disabled = true;
namedRangeSelect.disabled = true;
}
namedRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const setPresets = (optionsData, presetName) => {
const defaults = optionsData['gameOptions'];
const preset = optionsData['presetOptions'][presetName];
localStorage.setItem(`${gameName}-preset`, presetName);
if (!preset) {
console.error(`No presets defined for preset name: '${presetName}'`);
return;
}
const updateOptionElement = (option, presetValue) => {
const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
optionElement.disabled = true;
updateGameOption(randomElement, false);
} else {
optionElement.value = presetValue;
randomElement.classList.remove('active');
optionElement.disabled = undefined;
updateGameOption(optionElement, false);
}
};
for (const option in defaults) {
let presetValue = preset[option];
if (presetValue === undefined) {
// Using the default value if not set in presets.
presetValue = defaults[option]['defaultValue'];
}
switch (defaults[option].type) {
case 'range':
const numberElement = document.querySelector(`#${option}-value`);
if (presetValue === 'random') {
numberElement.innerText = defaults[option]['defaultValue'] === 'random'
? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
: defaults[option]['defaultValue'];
} else {
numberElement.innerText = presetValue;
}
updateOptionElement(option, presetValue);
break;
case 'select': {
updateOptionElement(option, presetValue);
break;
}
case 'named_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
selectElement.disabled = true;
rangeElement.disabled = true;
updateGameOption(randomElement, false);
} else {
rangeElement.value = presetValue;
selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
parseInt(presetValue) : 'custom';
document.getElementById(`${option}-value`).innerText = presetValue;
randomElement.classList.remove('active');
selectElement.disabled = undefined;
rangeElement.disabled = undefined;
updateGameOption(rangeElement, false);
}
break;
}
default:
console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
break;
}
}
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameOption(active ? inputElement : randomButton);
};
const updateBaseOption = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (toggleCustomPreset) {
localStorage.setItem(`${gameName}-preset`, '__custom');
const presetElement = document.getElementById('game-options-preset');
presetElement.value = '__custom';
}
if (optionElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][optionElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
optionElement.value : parseInt(optionElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
const preset = localStorage.getItem(`${gameName}-preset`);
switch (preset) {
case '__default':
options['description'] = `Generated by https://archipelago.gg with the default preset.`;
break;
case '__custom':
options['description'] = `Generated by https://archipelago.gg.`;
break;
default:
options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
}
if (!options.name || options.name.toString().trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: options },
presetData: { player: options },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,335 +0,0 @@
let presets = {};
window.addEventListener('load', async () => {
// Load settings from localStorage, if available
loadSettings();
// Fetch presets if available
await fetchPresets();
// Handle changes to range inputs
document.querySelectorAll('input[type=range]').forEach((range) => {
const optionName = range.getAttribute('id');
range.addEventListener('change', () => {
document.getElementById(`${optionName}-value`).innerText = range.value;
// Handle updating named range selects to "custom" if appropriate
const select = document.querySelector(`select[data-option-name=${optionName}]`);
if (select) {
let updated = false;
select?.childNodes.forEach((option) => {
if (option.value === range.value) {
select.value = range.value;
updated = true;
}
});
if (!updated) {
select.value = 'custom';
}
}
});
});
// Handle changes to named range selects
document.querySelectorAll('.named-range-container select').forEach((select) => {
const optionName = select.getAttribute('data-option-name');
select.addEventListener('change', (evt) => {
document.getElementById(optionName).value = evt.target.value;
document.getElementById(`${optionName}-value`).innerText = evt.target.value;
});
});
// Handle changes to randomize checkboxes
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
checkbox.addEventListener('change', () => {
const optionInput = document.getElementById(optionName);
const namedRangeSelect = document.querySelector(`select[data-option-name=${optionName}]`);
const customInput = document.getElementById(`${optionName}-custom`);
if (checkbox.checked) {
optionInput.setAttribute('disabled', '1');
namedRangeSelect?.setAttribute('disabled', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
} else {
optionInput.removeAttribute('disabled');
namedRangeSelect?.removeAttribute('disabled');
if (customInput) {
customInput.removeAttribute('disabled');
}
}
});
});
// Handle changes to TextChoice input[type=text]
document.querySelectorAll('.text-choice-container input[type=text]').forEach((input) => {
const optionName = input.getAttribute('data-option-name');
input.addEventListener('input', () => {
const select = document.getElementById(optionName);
const optionValues = [];
select.childNodes.forEach((option) => optionValues.push(option.value));
select.value = (optionValues.includes(input.value)) ? input.value : 'custom';
});
});
// Handle changes to TextChoice select
document.querySelectorAll('.text-choice-container select').forEach((select) => {
const optionName = select.getAttribute('id');
select.addEventListener('change', () => {
document.getElementById(`${optionName}-custom`).value = '';
});
});
// Update the "Option Preset" select to read "custom" when changes are made to relevant inputs
const presetSelect = document.getElementById('game-options-preset');
document.querySelectorAll('input, select').forEach((input) => {
if ( // Ignore inputs which have no effect on yaml generation
(input.id === 'player-name') ||
(input.id === 'game-options-preset') ||
(input.classList.contains('group-toggle')) ||
(input.type === 'submit')
) {
return;
}
input.addEventListener('change', () => {
presetSelect.value = 'custom';
});
});
// Handle changes to presets select
document.getElementById('game-options-preset').addEventListener('change', choosePreset);
// Save settings to localStorage when form is submitted
document.getElementById('options-form').addEventListener('submit', (evt) => {
const playerName = document.getElementById('player-name');
if (!playerName.value.trim()) {
evt.preventDefault();
window.scrollTo(0, 0);
showUserMessage('You must enter a player name!');
}
saveSettings();
});
});
// Save all settings to localStorage
const saveSettings = () => {
const options = {
inputs: {},
checkboxes: {},
};
document.querySelectorAll('input, select').forEach((input) => {
if (input.type === 'submit') {
// Ignore submit inputs
}
else if (input.type === 'checkbox') {
options.checkboxes[input.id] = input.checked;
}
else {
options.inputs[input.id] = input.value
}
});
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.setItem(game, JSON.stringify(options));
};
// Load all options from localStorage
const loadSettings = () => {
const game = document.getElementById('player-options').getAttribute('data-game');
const options = JSON.parse(localStorage.getItem(game));
if (options) {
if (!options.inputs || !options.checkboxes) {
localStorage.removeItem(game);
return;
}
// Restore value-based inputs and selects
Object.keys(options.inputs).forEach((key) => {
try{
document.getElementById(key).value = options.inputs[key];
const rangeValue = document.getElementById(`${key}-value`);
if (rangeValue) {
rangeValue.innerText = options.inputs[key];
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
// Restore checkboxes
Object.keys(options.checkboxes).forEach((key) => {
try{
if (options.checkboxes[key]) {
document.getElementById(key).setAttribute('checked', '1');
}
} catch (err) {
console.error(`Unable to restore value to input with id ${key}`);
}
});
}
// Ensure any input for which the randomize checkbox is checked by default, the relevant inputs are disabled
document.querySelectorAll('.randomize-checkbox').forEach((checkbox) => {
const optionName = checkbox.getAttribute('data-option-name');
if (checkbox.checked) {
const input = document.getElementById(optionName);
if (input) {
input.setAttribute('disabled', '1');
}
const customInput = document.getElementById(`${optionName}-custom`);
if (customInput) {
customInput.setAttribute('disabled', '1');
}
}
});
};
/**
* Fetch the preset data for this game and apply the presets if localStorage indicates one was previously chosen
* @returns {Promise<void>}
*/
const fetchPresets = async () => {
const response = await fetch('option-presets');
presets = await response.json();
const presetSelect = document.getElementById('game-options-preset');
presetSelect.removeAttribute('disabled');
const game = document.getElementById('player-options').getAttribute('data-game');
const presetToApply = localStorage.getItem(`${game}-preset`);
const playerName = localStorage.getItem(`${game}-player`);
if (presetToApply) {
localStorage.removeItem(`${game}-preset`);
presetSelect.value = presetToApply;
applyPresets(presetToApply);
}
if (playerName) {
document.getElementById('player-name').value = playerName;
localStorage.removeItem(`${game}-player`);
}
};
/**
* Clear the localStorage for this game and set a preset to be loaded upon page reload
* @param evt
*/
const choosePreset = (evt) => {
if (evt.target.value === 'custom') { return; }
const game = document.getElementById('player-options').getAttribute('data-game');
localStorage.removeItem(game);
localStorage.setItem(`${game}-player`, document.getElementById('player-name').value);
if (evt.target.value !== 'default') {
localStorage.setItem(`${game}-preset`, evt.target.value);
}
document.querySelectorAll('#options-form input, #options-form select').forEach((input) => {
if (input.id === 'player-name') { return; }
input.removeAttribute('value');
});
window.location.replace(window.location.href);
};
const applyPresets = (presetName) => {
// Ignore the "default" preset, because it gets set automatically by Jinja
if (presetName === 'default') {
saveSettings();
return;
}
if (!presets[presetName]) {
console.error(`Unknown preset ${presetName} chosen`);
return;
}
const preset = presets[presetName];
Object.keys(preset).forEach((optionName) => {
const optionValue = preset[optionName];
// Handle List and Set options
if (Array.isArray(optionValue)) {
document.querySelectorAll(`input[type=checkbox][name=${optionName}]`).forEach((checkbox) => {
if (optionValue.includes(checkbox.value)) {
checkbox.setAttribute('checked', '1');
} else {
checkbox.removeAttribute('checked');
}
});
return;
}
// Handle Dict options
if (typeof(optionValue) === 'object' && optionValue !== null) {
const itemNames = Object.keys(optionValue);
document.querySelectorAll(`input[type=number][data-option-name=${optionName}]`).forEach((input) => {
const itemName = input.getAttribute('data-item-name');
input.value = (itemNames.includes(itemName)) ? optionValue[itemName] : 0
});
return;
}
// Identify all possible elements
const normalInput = document.getElementById(optionName);
const customInput = document.getElementById(`${optionName}-custom`);
const rangeValue = document.getElementById(`${optionName}-value`);
const randomizeInput = document.getElementById(`random-${optionName}`);
const namedRangeSelect = document.getElementById(`${optionName}-select`);
// It is possible for named ranges to use name of a value rather than the value itself. This is accounted for here
let trueValue = optionValue;
if (namedRangeSelect) {
namedRangeSelect.querySelectorAll('option').forEach((opt) => {
if (opt.innerText.startsWith(optionValue)) {
trueValue = opt.value;
}
});
namedRangeSelect.value = trueValue;
}
// Handle options whose presets are "random"
if (optionValue === 'random') {
normalInput.setAttribute('disabled', '1');
randomizeInput.setAttribute('checked', '1');
if (customInput) {
customInput.setAttribute('disabled', '1');
}
if (rangeValue) {
rangeValue.innerText = normalInput.value;
}
if (namedRangeSelect) {
namedRangeSelect.setAttribute('disabled', '1');
}
return;
}
// Handle normal (text, number, select, etc.) and custom inputs (custom inputs exist with TextChoice only)
normalInput.value = trueValue;
normalInput.removeAttribute('disabled');
randomizeInput.removeAttribute('checked');
if (customInput) {
document.getElementById(`${optionName}-custom`).removeAttribute('disabled');
}
if (rangeValue) {
rangeValue.innerText = trueValue;
}
});
saveSettings();
};
const showUserMessage = (text) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = text;
userMessage.addEventListener('click', hideUserMessage);
userMessage.style.display = 'block';
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.removeEventListener('click', hideUserMessage);
userMessage.style.display = 'none';
};

View File

@@ -1,16 +1,18 @@
window.addEventListener('load', () => {
// Add toggle listener to all elements with .collapse-toggle
const toggleButtons = document.querySelectorAll('details');
const toggleButtons = document.querySelectorAll('.collapse-toggle');
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
// Handle game filter input
const gameSearch = document.getElementById('game-search');
gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) {
// If input is empty, display all games as collapsed
// If input is empty, display all collapsed games
return toggleButtons.forEach((header) => {
header.style.display = null;
header.removeAttribute('open');
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
});
}
@@ -19,10 +21,12 @@ window.addEventListener('load', () => {
// If the game name includes the search string, display the game. If not, hide it
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null;
header.setAttribute('open', '1');
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
} else {
header.style.display = 'none';
header.removeAttribute('open');
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}
});
});
@@ -31,14 +35,30 @@ window.addEventListener('load', () => {
document.getElementById('collapse-all').addEventListener('click', collapseAll);
});
const toggleCollapse = (evt) => {
const gameArrow = evt.target.firstElementChild;
const gameInfo = evt.target.nextElementSibling;
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
};
const expandAll = () => {
document.querySelectorAll('details').forEach((detail) => {
detail.setAttribute('open', '1');
document.querySelectorAll('.collapse-toggle').forEach((header) => {
if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
});
};
const collapseAll = () => {
document.querySelectorAll('details').forEach((detail) => {
detail.removeAttribute('open');
document.querySelectorAll('.collapse-toggle').forEach((header) => {
if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,223 +0,0 @@
let deletedOptions = {};
window.addEventListener('load', () => {
const worldName = document.querySelector('#weighted-options').getAttribute('data-game');
// Generic change listener. Detecting unique qualities and acting on them here reduces initial JS initialisation time
// and handles dynamically created elements
document.addEventListener('change', (evt) => {
// Handle updates to range inputs
if (evt.target.type === 'range') {
// Update span containing range value. All ranges have a corresponding `{rangeId}-value` span
document.getElementById(`${evt.target.id}-value`).innerText = evt.target.value;
// If the changed option was the name of a game, determine whether to show or hide that game's div
if (evt.target.id.startsWith('game||')) {
const gameName = evt.target.id.split('||')[1];
const gameDiv = document.getElementById(`${gameName}-container`);
if (evt.target.value > 0) {
gameDiv.classList.remove('hidden');
} else {
gameDiv.classList.add('hidden');
}
}
}
});
// Generic click listener
document.addEventListener('click', (evt) => {
// Handle creating new rows for Range options
if (evt.target.classList.contains('add-range-option-button')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
// Handle deleting range rows
if (evt.target.classList.contains('range-option-delete')) {
const targetRow = document.querySelector(`tr[data-row="${evt.target.getAttribute('data-target')}"]`);
setDeletedOption(
targetRow.getAttribute('data-option-name'),
targetRow.getAttribute('data-value'),
);
targetRow.parentElement.removeChild(targetRow);
}
});
// Listen for enter presses on inputs intended to add range rows
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
}
if (evt.key === 'Enter' && evt.target.classList.contains('range-option-value')) {
const optionName = evt.target.getAttribute('data-option');
addRangeRow(optionName);
}
});
// Detect form submission
document.getElementById('weighted-options-form').addEventListener('submit', (evt) => {
// Save data to localStorage
const weightedOptions = {};
document.querySelectorAll('input[name]').forEach((input) => {
const keys = input.getAttribute('name').split('||');
// Determine keys
const optionName = keys[0] ?? null;
const subOption = keys[1] ?? null;
// Ensure keys exist
if (!weightedOptions[optionName]) { weightedOptions[optionName] = {}; }
if (subOption && !weightedOptions[optionName][subOption]) {
weightedOptions[optionName][subOption] = null;
}
if (subOption) { return weightedOptions[optionName][subOption] = determineValue(input); }
if (optionName) { return weightedOptions[optionName] = determineValue(input); }
});
localStorage.setItem(`${worldName}-weights`, JSON.stringify(weightedOptions));
localStorage.setItem(`${worldName}-deletedOptions`, JSON.stringify(deletedOptions));
});
// Remove all deleted values as specified by localStorage
deletedOptions = JSON.parse(localStorage.getItem(`${worldName}-deletedOptions`) || '{}');
Object.keys(deletedOptions).forEach((optionName) => {
deletedOptions[optionName].forEach((value) => {
const targetRow = document.querySelector(`tr[data-row="${value}-row"]`);
targetRow.parentElement.removeChild(targetRow);
});
});
// Populate all settings from localStorage on page initialisation
const previousSettingsJson = localStorage.getItem(`${worldName}-weights`);
if (previousSettingsJson) {
const previousSettings = JSON.parse(previousSettingsJson);
Object.keys(previousSettings).forEach((option) => {
if (typeof previousSettings[option] === 'string') {
return document.querySelector(`input[name="${option}"]`).value = previousSettings[option];
}
Object.keys(previousSettings[option]).forEach((value) => {
const input = document.querySelector(`input[name="${option}||${value}"]`);
if (!input?.type) {
return console.error(`Unable to populate option with name ${option}||${value}.`);
}
switch (input.type) {
case 'checkbox':
input.checked = (parseInt(previousSettings[option][value], 10) === 1);
break;
case 'range':
input.value = parseInt(previousSettings[option][value], 10);
break;
case 'number':
input.value = previousSettings[option][value].toString();
break;
default:
console.error(`Found unsupported input type: ${input.type}`);
}
});
});
}
});
const addRangeRow = (optionName) => {
const inputQuery = `input[type=number][data-option="${optionName}"].range-option-value`;
const inputTarget = document.querySelector(inputQuery);
const newValue = inputTarget.value;
if (!/^-?\d+$/.test(newValue)) {
alert('Range values must be a positive or negative integer!');
return;
}
inputTarget.value = '';
const tBody = document.querySelector(`table[data-option="${optionName}"].range-rows tbody`);
const tr = document.createElement('tr');
tr.setAttribute('data-row', `${optionName}-${newValue}-row`);
tr.setAttribute('data-option-name', optionName);
tr.setAttribute('data-value', newValue);
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
const label = document.createElement('label');
label.setAttribute('for', `${optionName}||${newValue}`);
label.innerText = newValue.toString();
tdLeft.appendChild(label);
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('min', '0');
range.setAttribute('max', '50');
range.setAttribute('value', '0');
range.setAttribute('id', `${optionName}||${newValue}`);
range.setAttribute('name', `${optionName}||${newValue}`);
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.classList.add('td-right');
const valueSpan = document.createElement('span');
valueSpan.setAttribute('id', `${optionName}||${newValue}-value`);
valueSpan.innerText = '0';
tdRight.appendChild(valueSpan);
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
const deleteSpan = document.createElement('span');
deleteSpan.classList.add('range-option-delete');
deleteSpan.classList.add('js-required');
deleteSpan.setAttribute('data-target', `${optionName}-${newValue}-row`);
deleteSpan.innerText = '❌';
tdDelete.appendChild(deleteSpan);
tr.appendChild(tdDelete);
tBody.appendChild(tr);
// Remove this option from the set of deleted options if it exists
unsetDeletedOption(optionName, newValue);
};
/**
* Determines the value of an input element, or returns a 1 or 0 if the element is a checkbox
*
* @param {object} input - The input element.
* @returns {number} The value of the input element.
*/
const determineValue = (input) => {
switch (input.type) {
case 'checkbox':
return (input.checked ? 1 : 0);
case 'range':
return parseInt(input.value, 10);
default:
return input.value;
}
};
/**
* Sets the deleted option value for a given world and option name.
* If the world or option does not exist, it creates the necessary entries.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be set for the deleted option.
* @returns {void}
*/
const setDeletedOption = (optionName, value) => {
deletedOptions[optionName] = deletedOptions[optionName] || [];
deletedOptions[optionName].push(`${optionName}-${value}`);
};
/**
* Removes a specific value from the deletedOptions object.
*
* @param {string} optionName - The name of the option.
* @param {*} value - The value to be removed
* @returns {void}
*/
const unsetDeletedOption = (optionName, value) => {
if (!deletedOptions.hasOwnProperty(optionName)) { return; }
if (deletedOptions[optionName].includes(`${optionName}-${value}`)) {
deletedOptions[optionName].splice(deletedOptions[optionName].indexOf(`${optionName}-${value}`), 1);
}
if (deletedOptions[optionName].length === 0) {
delete deletedOptions[optionName];
}
};

View File

@@ -1,20 +0,0 @@
User-agent: Googlebot
Disallow: /
User-agent: APIs-Google
Disallow: /
User-agent: AdsBot-Google-Mobile
Disallow: /
User-agent: AdsBot-Google-Mobile
Disallow: /
User-agent: Mediapartners-Google
Disallow: /
User-agent: Google-Safety
Disallow: /
User-agent: *
Disallow: /

View File

@@ -44,7 +44,7 @@ a{
font-family: LexendDeca-Regular, sans-serif;
}
button, input[type=submit]{
button{
font-weight: 500;
font-size: 0.9rem;
padding: 10px 17px 11px 16px; /* top right bottom left */
@@ -57,7 +57,7 @@ button, input[type=submit]{
cursor: pointer;
}
button:active, input[type=submit]:active{
button:active{
border-right: 1px solid rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(0, 0, 0, 0.5);
padding-right: 16px;
@@ -66,11 +66,11 @@ button:active, input[type=submit]:active{
margin-bottom: 2px;
}
button.button-grass, input[type=submit].button-grass{
button.button-grass{
border: 1px solid black;
}
button.button-dirt, input[type=submit].button-dirt{
button.button-dirt{
border: 1px solid black;
}
@@ -111,4 +111,4 @@ h5, h6{
.interactive{
color: #ffef00;
}
}

View File

@@ -0,0 +1,75 @@
#player-tracker-wrapper{
margin: 0;
font-family: LexendDeca-Light, sans-serif;
color: white;
font-size: 14px;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 284px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(75%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table img.powder-fix{
width: 35px;
height: 35px;
}
#location-table{
width: 284px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
}
#location-table th{
vertical-align: middle;
text-align: center;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
padding-right: 5px;
line-height: 20px;
}
#location-table td.counter{
padding-right: 8px;
text-align: right;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}

View File

@@ -23,7 +23,7 @@
.markdown a{}
.markdown h1, .markdown details summary.h1{
.markdown h1{
font-size: 52px;
font-weight: normal;
font-family: LondrinaSolid-Regular, sans-serif;
@@ -33,7 +33,7 @@
text-shadow: 1px 1px 4px #000000;
}
.markdown h2, .markdown details summary.h2{
.markdown h2{
font-size: 38px;
font-weight: normal;
font-family: LondrinaSolid-Light, sans-serif;
@@ -45,7 +45,7 @@
text-shadow: 1px 1px 2px #000000;
}
.markdown h3, .markdown details summary.h3{
.markdown h3{
font-size: 26px;
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
@@ -55,7 +55,7 @@
margin-bottom: 0.5rem;
}
.markdown h4, .markdown details summary.h4{
.markdown h4{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 24px;
@@ -63,21 +63,21 @@
margin-bottom: 24px;
}
.markdown h5, .markdown details summary.h5{
.markdown h5{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 22px;
cursor: pointer;
}
.markdown h6, .markdown details summary.h6{
.markdown h6{
font-family: LexendDeca-Regular, sans-serif;
text-transform: none;
font-size: 20px;
cursor: pointer;;
}
.markdown h4, .markdown h5, .markdown h6{
.markdown h4, .markdown h5,.markdown h6{
margin-bottom: 0.5rem;
}

View File

@@ -0,0 +1,244 @@
html{
background-image: url('../static/backgrounds/grass.png');
background-repeat: repeat;
background-size: 650px 650px;
}
#player-options{
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#player-options #player-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-options code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
#player-options #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#player-options #user-message.visible{
display: block;
cursor: pointer;
}
#player-options h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-shadow: 1px 1px 4px #000000;
}
#player-options h2{
font-size: 40px;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-options h3, #player-options h4, #player-options h5, #player-options h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-options input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-options input:not([type]):focus{
border: 1px solid #ffffff;
}
#player-options select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
}
#player-options #game-options, #player-options #rom-options{
display: flex;
flex-direction: row;
}
#player-options #meta-options {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
}
#player-options div {
display: flex;
flex-grow: 1;
}
#player-options #meta-options label {
display: inline-block;
min-width: 180px;
flex-grow: 1;
}
#player-options #meta-options input,
#player-options #meta-options select {
box-sizing: border-box;
min-width: 150px;
width: 50%;
}
#player-options .left, #player-options .right{
flex-grow: 1;
}
#player-options .left{
margin-right: 10px;
}
#player-options .right{
margin-left: 10px;
}
#player-options table{
margin-bottom: 30px;
width: 100%;
}
#player-options table .select-container{
display: flex;
flex-direction: row;
}
#player-options table .select-container select{
min-width: 200px;
flex-grow: 1;
}
#player-options table select:disabled{
background-color: lightgray;
}
#player-options table .range-container{
display: flex;
flex-direction: row;
}
#player-options table .range-container input[type=range]{
flex-grow: 1;
}
#player-options table .range-value{
min-width: 20px;
margin-left: 0.25rem;
}
#player-options table .named-range-container{
display: flex;
flex-direction: column;
}
#player-options table .named-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-options table .named-range-wrapper input[type=range]{
flex-grow: 1;
}
#player-options table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
margin: 0 0 0 0.25rem;
font-size: 12px;
border: 1px solid black;
border-radius: 3px;
}
#player-options table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-options table .randomize-button[data-tooltip]::after {
left: unset;
right: 0;
}
#player-options table label{
display: block;
min-width: 200px;
margin-right: 4px;
cursor: default;
}
#player-options th, #player-options td{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
@media all and (max-width: 1024px) {
#player-options {
border-radius: 0;
}
#player-options #meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
#player-options #game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#player-options .left,
#player-options .right {
margin: 0;
}
#game-options table {
margin-bottom: 0;
}
#game-options table label{
display: block;
min-width: 200px;
}
#game-options table tr td {
width: 50%;
}
}

View File

@@ -1,310 +0,0 @@
@import "../markdown.css";
html {
background-image: url("../../static/backgrounds/grass.png");
background-repeat: repeat;
background-size: 650px 650px;
overflow-x: hidden;
}
#player-options {
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-all;
}
#player-options #player-options-header h1 {
margin-bottom: 0;
padding-bottom: 0;
}
#player-options #player-options-header h1:nth-child(2) {
font-size: 1.4rem;
margin-top: -8px;
margin-bottom: 0.5rem;
}
#player-options .js-warning-banner {
width: calc(100% - 1rem);
padding: 0.5rem;
border-radius: 4px;
background-color: #f3f309;
color: #000000;
margin-bottom: 0.5rem;
text-align: center;
}
#player-options .group-container {
padding: 0;
margin: 0;
}
#player-options .group-container h2 {
user-select: none;
cursor: unset;
}
#player-options .group-container h2 label {
cursor: pointer;
}
#player-options #player-options-button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-options #user-message {
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
cursor: pointer;
}
#player-options h1 {
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-shadow: 1px 1px 4px #000000;
}
#player-options h2 {
font-size: 40px;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
#player-options h3, #player-options h4, #player-options h5, #player-options h6 {
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-options input:not([type]) {
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-options input:not([type]):focus {
border: 1px solid #ffffff;
}
#player-options select {
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
text-overflow: ellipsis;
}
#player-options .game-options {
display: flex;
flex-direction: row;
}
#player-options .game-options .left, #player-options .game-options .right {
display: grid;
grid-template-columns: 12rem auto;
grid-row-gap: 0.5rem;
grid-auto-rows: min-content;
align-items: start;
min-width: 480px;
width: 50%;
}
#player-options #meta-options {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
}
#player-options #meta-options input, #player-options #meta-options select {
box-sizing: border-box;
width: 200px;
}
#player-options .left, #player-options .right {
flex-grow: 1;
margin-bottom: 0.5rem;
}
#player-options .left {
margin-right: 20px;
}
#player-options .select-container {
display: flex;
flex-direction: row;
max-width: 270px;
}
#player-options .select-container select {
min-width: 200px;
flex-grow: 1;
}
#player-options .select-container select:disabled {
background-color: lightgray;
}
#player-options .range-container {
display: flex;
flex-direction: row;
max-width: 270px;
}
#player-options .range-container input[type=range] {
flex-grow: 1;
}
#player-options .range-container .range-value {
min-width: 20px;
margin-left: 0.25rem;
}
#player-options .named-range-container {
display: flex;
flex-direction: column;
max-width: 270px;
}
#player-options .named-range-container .named-range-wrapper {
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-options .named-range-container .named-range-wrapper input[type=range] {
flex-grow: 1;
}
#player-options .free-text-container {
display: flex;
flex-direction: column;
max-width: 270px;
}
#player-options .free-text-container input[type=text] {
flex-grow: 1;
}
#player-options .text-choice-container {
display: flex;
flex-direction: column;
max-width: 270px;
}
#player-options .text-choice-container .text-choice-wrapper {
display: flex;
flex-direction: row;
margin-bottom: 0.25rem;
}
#player-options .text-choice-container .text-choice-wrapper select {
flex-grow: 1;
}
#player-options .option-container {
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(20, 20, 20, 0.25);
border-radius: 3px;
color: #ffffff;
max-height: 10rem;
min-width: 14.5rem;
overflow-y: auto;
padding-right: 0.25rem;
padding-left: 0.25rem;
}
#player-options .option-container .option-divider {
width: 100%;
height: 2px;
background-color: rgba(20, 20, 20, 0.25);
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
#player-options .option-container .option-entry {
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 0.125rem;
margin-top: 0.125rem;
user-select: none;
}
#player-options .option-container .option-entry:hover {
background-color: rgba(20, 20, 20, 0.25);
}
#player-options .option-container .option-entry input[type=checkbox] {
margin-right: 0.25rem;
}
#player-options .option-container .option-entry input[type=number] {
max-width: 1.5rem;
max-height: 1rem;
margin-left: 0.125rem;
text-align: center;
/* Hide arrows on input[type=number] fields */
-moz-appearance: textfield;
}
#player-options .option-container .option-entry input[type=number]::-webkit-outer-spin-button, #player-options .option-container .option-entry input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
#player-options .option-container .option-entry label {
flex-grow: 1;
margin-right: 0;
min-width: unset;
display: unset;
}
#player-options .randomize-button {
display: flex;
flex-direction: column;
justify-content: center;
height: 22px;
max-width: 30px;
margin: 0 0 0 0.25rem;
font-size: 14px;
border: 1px solid black;
border-radius: 3px;
background-color: #d3d3d3;
user-select: none;
}
#player-options .randomize-button:hover {
background-color: #c0c0c0;
cursor: pointer;
}
#player-options .randomize-button label {
line-height: 22px;
padding-left: 5px;
padding-right: 2px;
margin-right: 4px;
width: 100%;
height: 100%;
min-width: unset;
}
#player-options .randomize-button label:hover {
cursor: pointer;
}
#player-options .randomize-button input[type=checkbox] {
display: none;
}
#player-options .randomize-button:has(input[type=checkbox]:checked) {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-options .randomize-button:has(input[type=checkbox]:checked):hover {
background-color: #eedd27;
}
#player-options .randomize-button[data-tooltip]::after {
left: unset;
right: 0;
}
#player-options label {
display: block;
margin-right: 4px;
cursor: default;
word-break: break-word;
}
#player-options th, #player-options td {
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
@media all and (max-width: 1024px) {
#player-options {
border-radius: 0;
}
#player-options #meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
#player-options .game-options {
justify-content: flex-start;
flex-wrap: wrap;
}
}
/*# sourceMappingURL=playerOptions.css.map */

View File

@@ -1 +0,0 @@
{"version":3,"sourceRoot":"","sources":["playerOptions.scss"],"names":[],"mappings":"AAAQ;AAER;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;;AAEA;EACI;;AAIR;EACI;EACA;EACA;;AAEA;EACI;EACA;EACA;;AAEA;EACI;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAKZ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AACA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;;AAIR;EACI;IACI;;EAEA;IACI;IACA;IACA;;EAGJ;IACI;IACA","file":"playerOptions.css"}

View File

@@ -1,364 +0,0 @@
@import "../markdown.css";
html{
background-image: url('../../static/backgrounds/grass.png');
background-repeat: repeat;
background-size: 650px 650px;
overflow-x: hidden;
}
#player-options{
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-all;
#player-options-header{
h1{
margin-bottom: 0;
padding-bottom: 0;
}
h1:nth-child(2){
font-size: 1.4rem;
margin-top: -8px;
margin-bottom: 0.5rem;
}
}
.js-warning-banner{
width: calc(100% - 1rem);
padding: 0.5rem;
border-radius: 4px;
background-color: #f3f309;
color: #000000;
margin-bottom: 0.5rem;
text-align: center;
}
.group-container{
padding: 0;
margin: 0;
h2{
user-select: none;
cursor: unset;
label{
cursor: pointer;
}
}
}
#player-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
cursor: pointer;
}
h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-shadow: 1px 1px 4px #000000;
}
h2{
font-size: 40px;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
text-transform: lowercase;
text-shadow: 1px 1px 2px #000000;
}
h3, h4, h5, h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
&:focus{
border: 1px solid #ffffff;
}
}
select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
text-overflow: ellipsis;
}
.game-options{
display: flex;
flex-direction: row;
.left, .right{
display: grid;
grid-template-columns: 12rem auto;
grid-row-gap: 0.5rem;
grid-auto-rows: min-content;
align-items: start;
min-width: 480px;
width: 50%;
}
}
#meta-options{
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
input, select{
box-sizing: border-box;
width: 200px;
}
}
.left, .right{
flex-grow: 1;
margin-bottom: 0.5rem;
}
.left{
margin-right: 20px;
}
.select-container{
display: flex;
flex-direction: row;
max-width: 270px;
select{
min-width: 200px;
flex-grow: 1;
&:disabled{
background-color: lightgray;
}
}
}
.range-container{
display: flex;
flex-direction: row;
max-width: 270px;
input[type=range]{
flex-grow: 1;
}
.range-value{
min-width: 20px;
margin-left: 0.25rem;
}
}
.named-range-container{
display: flex;
flex-direction: column;
max-width: 270px;
.named-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
input[type=range]{
flex-grow: 1;
}
}
}
.free-text-container{
display: flex;
flex-direction: column;
max-width: 270px;
input[type=text]{
flex-grow: 1;
}
}
.text-choice-container{
display: flex;
flex-direction: column;
max-width: 270px;
.text-choice-wrapper{
display: flex;
flex-direction: row;
margin-bottom: 0.25rem;
select{
flex-grow: 1;
}
}
}
.option-container{
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(20, 20, 20, 0.25);
border-radius: 3px;
color: #ffffff;
max-height: 10rem;
min-width: 14.5rem;
overflow-y: auto;
padding-right: 0.25rem;
padding-left: 0.25rem;
.option-divider{
width: 100%;
height: 2px;
background-color: rgba(20, 20, 20, 0.25);
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
.option-entry{
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 0.125rem;
margin-top: 0.125rem;
user-select: none;
&:hover{
background-color: rgba(20, 20, 20, 0.25);
}
input[type=checkbox]{
margin-right: 0.25rem;
}
input[type=number]{
max-width: 1.5rem;
max-height: 1rem;
margin-left: 0.125rem;
text-align: center;
/* Hide arrows on input[type=number] fields */
-moz-appearance: textfield;
&::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
-webkit-appearance: none;
margin: 0;
}
}
label{
flex-grow: 1;
margin-right: 0;
min-width: unset;
display: unset;
}
}
}
.randomize-button{
display: flex;
flex-direction: column;
justify-content: center;
height: 22px;
max-width: 30px;
margin: 0 0 0 0.25rem;
font-size: 14px;
border: 1px solid black;
border-radius: 3px;
background-color: #d3d3d3;
user-select: none;
&:hover{
background-color: #c0c0c0;
cursor: pointer;
}
label{
line-height: 22px;
padding-left: 5px;
padding-right: 2px;
margin-right: 4px;
width: 100%;
height: 100%;
min-width: unset;
&:hover{
cursor: pointer;
}
}
input[type=checkbox]{
display: none;
}
&:has(input[type=checkbox]:checked){
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
&:hover{
background-color: #eedd27;
}
}
&[data-tooltip]::after{
left: unset;
right: 0;
}
}
label{
display: block;
margin-right: 4px;
cursor: default;
word-break: break-word;
}
th, td{
border: none;
padding: 3px;
font-size: 17px;
vertical-align: top;
}
}
@media all and (max-width: 1024px) {
#player-options {
border-radius: 0;
#meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
.game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
}
}

View File

@@ -8,15 +8,30 @@
cursor: unset;
}
#games h1, #games details summary.h1{
#games h1{
font-size: 60px;
cursor: unset;
}
#games h2, #games details summary.h2{
#games h2{
color: #93dcff;
margin-bottom: 2px;
text-transform: none;
}
#games .collapse-toggle{
cursor: pointer;
}
#games h2 .collapse-arrow{
font-size: 20px;
display: inline-block; /* make vertical-align work */
padding-bottom: 9px;
vertical-align: middle;
padding-right: 8px;
}
#games p.collapsed{
display: none;
}
#games a{

View File

@@ -42,7 +42,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{
visibility: visible;
opacity: 1;
word-break: break-word;
}
/** Directional arrow styles */

View File

@@ -1,142 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Lexend+Deca:wght@100..900&display=swap');
.tracker-container {
width: 440px;
box-sizing: border-box;
font-family: "Lexend Deca", Arial, Helvetica, sans-serif;
border: 2px solid black;
border-radius: 4px;
resize: both;
background-color: #42b149;
color: white;
}
.hidden {
visibility: hidden;
}
/** Inventory Grid ****************************************************************************************************/
.inventory-grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
padding: 1rem;
gap: 1rem;
}
.inventory-grid .item {
position: relative;
display: flex;
justify-content: center;
height: 48px;
}
.inventory-grid .dual-item {
display: flex;
justify-content: center;
}
.inventory-grid .missing {
/* Missing items will be in full grayscale to signify "uncollected". */
filter: grayscale(100%) contrast(75%) brightness(75%);
}
.inventory-grid .item img,
.inventory-grid .dual-item img {
display: flex;
align-items: center;
text-align: center;
font-size: 0.8rem;
text-shadow: 0 1px 2px black;
font-weight: bold;
image-rendering: crisp-edges;
background-size: contain;
background-repeat: no-repeat;
}
.inventory-grid .dual-item img {
height: 48px;
margin: 0 -4px;
}
.inventory-grid .dual-item img:first-child {
align-self: flex-end;
}
.inventory-grid .item .quantity {
position: absolute;
bottom: 0;
right: 0;
text-align: right;
font-weight: 600;
font-size: 1.75rem;
line-height: 1.75rem;
text-shadow:
-1px -1px 0 #000,
1px -1px 0 #000,
-1px 1px 0 #000,
1px 1px 0 #000;
user-select: none;
}
/** Regions List ******************************************************************************************************/
.regions-list {
padding: 1rem;
}
.regions-list summary {
list-style: none;
display: flex;
gap: 0.5rem;
cursor: pointer;
}
.regions-list summary::before {
content: "⯈";
width: 1em;
flex-shrink: 0;
}
.regions-list details {
font-weight: 300;
}
.regions-list details[open] > summary::before {
content: "⯆";
}
.regions-list .region {
width: 100%;
display: grid;
grid-template-columns: 20fr 8fr 2fr 2fr;
align-items: center;
gap: 4px;
text-align: center;
font-weight: 300;
box-sizing: border-box;
}
.regions-list .region :first-child {
text-align: left;
font-weight: 500;
}
.regions-list .region.region-header {
margin-left: 24px;
width: calc(100% - 24px);
padding: 2px;
}
.regions-list .location-rows {
border-top: 1px solid white;
display: grid;
grid-template-columns: auto 32px;
font-weight: 300;
padding: 2px 8px;
margin-top: 4px;
font-size: 0.8rem;
}
.regions-list .location-rows :nth-child(even) {
text-align: right;
}

View File

@@ -0,0 +1,315 @@
html{
background-image: url('../static/backgrounds/grass.png');
background-repeat: repeat;
background-size: 650px 650px;
scroll-padding-top: 90px;
}
#weighted-settings{
max-width: 1000px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#weighted-settings #games-wrapper{
width: 100%;
}
#weighted-settings .setting-wrapper{
width: 100%;
margin-bottom: 2rem;
}
#weighted-settings .setting-wrapper .add-option-div{
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-bottom: 1rem;
}
#weighted-settings .setting-wrapper .add-option-div button{
width: auto;
height: auto;
margin: 0 0 0 0.15rem;
padding: 0 0.25rem;
border-radius: 4px;
cursor: default;
}
#weighted-settings .setting-wrapper .add-option-div button:active{
margin-bottom: 1px;
}
#weighted-settings p.setting-description{
margin: 0 0 1rem;
}
#weighted-settings p.hint-text{
margin: 0 0 1rem;
font-style: italic;
}
#weighted-settings .jump-link{
color: #ffef00;
cursor: pointer;
text-decoration: underline;
}
#weighted-settings table{
width: 100%;
}
#weighted-settings table th, #weighted-settings table td{
border: none;
}
#weighted-settings table td{
padding: 5px;
}
#weighted-settings table .td-left{
font-family: LexendDeca-Regular, sans-serif;
padding-right: 1rem;
width: 200px;
}
#weighted-settings table .td-middle{
display: flex;
flex-direction: column;
justify-content: space-evenly;
padding-right: 1rem;
}
#weighted-settings table .td-right{
width: 4rem;
text-align: right;
}
#weighted-settings table .td-delete{
width: 50px;
text-align: right;
}
#weighted-settings table .range-option-delete{
cursor: pointer;
}
#weighted-settings .items-wrapper{
display: flex;
flex-direction: row;
justify-content: space-between;
}
#weighted-settings .items-div h3{
margin-bottom: 0.5rem;
}
#weighted-settings .items-wrapper .item-set-wrapper{
width: 24%;
font-weight: bold;
}
#weighted-settings .item-container{
border: 1px solid #ffffff;
border-radius: 2px;
width: 100%;
height: 300px;
overflow-y: auto;
overflow-x: hidden;
margin-top: 0.125rem;
font-weight: normal;
}
#weighted-settings .item-container .item-div{
padding: 0.125rem 0.5rem;
cursor: pointer;
}
#weighted-settings .item-container .item-div:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .item-container .item-qty-div{
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0.125rem 0.5rem;
cursor: pointer;
}
#weighted-settings .item-container .item-qty-div .item-qty-input-wrapper{
display: flex;
flex-direction: column;
justify-content: space-around;
}
#weighted-settings .item-container .item-qty-div input{
min-width: unset;
width: 1.5rem;
text-align: center;
}
#weighted-settings .item-container .item-qty-div:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .hints-div, #weighted-settings .locations-div{
margin-top: 2rem;
}
#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
margin-bottom: 0.5rem;
}
#weighted-settings .hints-container, #weighted-settings .locations-container{
display: flex;
flex-direction: row;
justify-content: space-between;
}
#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
width: calc(50% - 0.5rem);
font-weight: bold;
}
#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
margin-top: 0.25rem;
height: 300px;
font-weight: normal;
}
#weighted-settings #weighted-settings-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#weighted-settings code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
padding-right: 0.25rem;
color: #000000;
}
#weighted-settings #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#weighted-settings #user-message.visible{
display: block;
cursor: pointer;
}
#weighted-settings h1{
font-size: 2.5rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
#weighted-settings h2{
font-size: 2rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: none;
text-shadow: 1px 1px 2px #000000;
}
#weighted-settings h3, #weighted-settings h4, #weighted-settings h5, #weighted-settings h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
text-transform: none;
}
#weighted-settings a{
color: #ffef00;
cursor: pointer;
}
#weighted-settings input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#weighted-settings input:not([type]):focus{
border: 1px solid #ffffff;
}
#weighted-settings select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
background-color: #ffffff;
}
#weighted-settings .game-options, #weighted-settings .rom-options{
display: flex;
flex-direction: column;
}
#weighted-settings .simple-list{
display: flex;
flex-direction: column;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ffffff;
border-radius: 4px;
}
#weighted-settings .simple-list .list-row label{
display: block;
width: calc(100% - 0.5rem);
padding: 0.0625rem 0.25rem;
}
#weighted-settings .simple-list .list-row label:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .simple-list .list-row label input[type=checkbox]{
margin-right: 0.5rem;
}
#weighted-settings .simple-list hr{
width: calc(100% - 2px);
margin: 2px auto;
border-bottom: 1px solid rgb(255 255 255 / 0.6);
}
#weighted-settings .invisible{
display: none;
}
@media all and (max-width: 1000px), all and (orientation: portrait){
#weighted-settings .game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#game-options table label{
display: block;
min-width: 200px;
}
}

View File

@@ -1,232 +0,0 @@
html {
background-image: url("../../static/backgrounds/grass.png");
background-repeat: repeat;
background-size: 650px 650px;
scroll-padding-top: 90px;
}
#weighted-options {
max-width: 1000px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
}
#weighted-options #weighted-options-header h1 {
margin-bottom: 0;
padding-bottom: 0;
}
#weighted-options #weighted-options-header h1:nth-child(2) {
font-size: 1.4rem;
margin-top: -8px;
margin-bottom: 0.5rem;
}
#weighted-options .js-warning-banner {
width: calc(100% - 1rem);
padding: 0.5rem;
border-radius: 4px;
background-color: #f3f309;
color: #000000;
margin-bottom: 0.5rem;
text-align: center;
}
#weighted-options .option-wrapper {
width: 100%;
margin-bottom: 2rem;
}
#weighted-options .option-wrapper .add-option-div {
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-bottom: 1rem;
}
#weighted-options .option-wrapper .add-option-div button {
width: auto;
height: auto;
margin: 0 0 0 0.15rem;
padding: 0 0.25rem;
border-radius: 4px;
cursor: default;
}
#weighted-options .option-wrapper .add-option-div button:active {
margin-bottom: 1px;
}
#weighted-options p.option-description {
margin: 0 0 1rem;
}
#weighted-options p.hint-text {
margin: 0 0 1rem;
font-style: italic;
}
#weighted-options table {
width: 100%;
margin-top: 0.5rem;
margin-bottom: 1.5rem;
}
#weighted-options table th, #weighted-options table td {
border: none;
}
#weighted-options table td {
padding: 5px;
}
#weighted-options table .td-left {
font-family: LexendDeca-Regular, sans-serif;
padding-right: 1rem;
width: 200px;
}
#weighted-options table .td-middle {
display: flex;
flex-direction: column;
justify-content: space-evenly;
padding-right: 1rem;
}
#weighted-options table .td-right {
width: 4rem;
text-align: right;
}
#weighted-options table .td-delete {
width: 50px;
text-align: right;
}
#weighted-options table .range-option-delete {
cursor: pointer;
}
#weighted-options #weighted-options-button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#weighted-options #user-message {
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
}
#weighted-options #user-message.visible {
display: block;
cursor: pointer;
}
#weighted-options h1 {
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
#weighted-options h2, #weighted-options details summary.h2 {
font-size: 2rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: none;
text-shadow: 1px 1px 2px #000000;
}
#weighted-options h3, #weighted-options h4, #weighted-options h5, #weighted-options h6 {
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
text-transform: none;
cursor: unset;
}
#weighted-options h3.option-group-header {
margin-top: 0.75rem;
font-weight: bold;
}
#weighted-options a {
color: #ffef00;
cursor: pointer;
}
#weighted-options input:not([type]) {
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#weighted-options input:not([type]):focus {
border: 1px solid #ffffff;
}
#weighted-options .invisible {
display: none;
}
#weighted-options .unsupported-option {
margin-top: 0.5rem;
}
#weighted-options .set-container, #weighted-options .dict-container, #weighted-options .list-container {
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(20, 20, 20, 0.25);
border-radius: 3px;
color: #ffffff;
max-height: 15rem;
min-width: 14.5rem;
overflow-y: auto;
padding-right: 0.25rem;
padding-left: 0.25rem;
margin-top: 0.5rem;
}
#weighted-options .set-container .divider, #weighted-options .dict-container .divider, #weighted-options .list-container .divider {
width: 100%;
height: 2px;
background-color: rgba(20, 20, 20, 0.25);
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
#weighted-options .set-container .set-entry, #weighted-options .set-container .dict-entry, #weighted-options .set-container .list-entry, #weighted-options .dict-container .set-entry, #weighted-options .dict-container .dict-entry, #weighted-options .dict-container .list-entry, #weighted-options .list-container .set-entry, #weighted-options .list-container .dict-entry, #weighted-options .list-container .list-entry {
display: flex;
flex-direction: row;
align-items: flex-start;
padding-bottom: 0.25rem;
padding-top: 0.25rem;
user-select: none;
line-height: 1rem;
}
#weighted-options .set-container .set-entry:hover, #weighted-options .set-container .dict-entry:hover, #weighted-options .set-container .list-entry:hover, #weighted-options .dict-container .set-entry:hover, #weighted-options .dict-container .dict-entry:hover, #weighted-options .dict-container .list-entry:hover, #weighted-options .list-container .set-entry:hover, #weighted-options .list-container .dict-entry:hover, #weighted-options .list-container .list-entry:hover {
background-color: rgba(20, 20, 20, 0.25);
}
#weighted-options .set-container .set-entry input[type=checkbox], #weighted-options .set-container .dict-entry input[type=checkbox], #weighted-options .set-container .list-entry input[type=checkbox], #weighted-options .dict-container .set-entry input[type=checkbox], #weighted-options .dict-container .dict-entry input[type=checkbox], #weighted-options .dict-container .list-entry input[type=checkbox], #weighted-options .list-container .set-entry input[type=checkbox], #weighted-options .list-container .dict-entry input[type=checkbox], #weighted-options .list-container .list-entry input[type=checkbox] {
margin-right: 0.25rem;
}
#weighted-options .set-container .set-entry input[type=number], #weighted-options .set-container .dict-entry input[type=number], #weighted-options .set-container .list-entry input[type=number], #weighted-options .dict-container .set-entry input[type=number], #weighted-options .dict-container .dict-entry input[type=number], #weighted-options .dict-container .list-entry input[type=number], #weighted-options .list-container .set-entry input[type=number], #weighted-options .list-container .dict-entry input[type=number], #weighted-options .list-container .list-entry input[type=number] {
max-width: 1.5rem;
max-height: 1rem;
margin-left: 0.125rem;
text-align: center;
/* Hide arrows on input[type=number] fields */
-moz-appearance: textfield;
}
#weighted-options .set-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .set-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .dict-container .list-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .set-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .dict-entry input[type=number]::-webkit-inner-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-outer-spin-button, #weighted-options .list-container .list-entry input[type=number]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
#weighted-options .set-container .set-entry label, #weighted-options .set-container .dict-entry label, #weighted-options .set-container .list-entry label, #weighted-options .dict-container .set-entry label, #weighted-options .dict-container .dict-entry label, #weighted-options .dict-container .list-entry label, #weighted-options .list-container .set-entry label, #weighted-options .list-container .dict-entry label, #weighted-options .list-container .list-entry label {
flex-grow: 1;
margin-right: 0;
min-width: unset;
display: unset;
}
.hidden {
display: none;
}
@media all and (max-width: 1000px), all and (orientation: portrait) {
#weighted-options .game-options {
justify-content: flex-start;
flex-wrap: wrap;
}
#game-options table label {
display: block;
min-width: 200px;
}
}
/*# sourceMappingURL=weightedOptions.css.map */

View File

@@ -1 +0,0 @@
{"version":3,"sourceRoot":"","sources":["weightedOptions.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA;;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGI;EACI;EACA;;AAGJ;EACI;EACA;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAOZ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;;AAIR;EACI;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAIA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;EACA;EACA;EACA;;AAEA;EACI;;AAIR;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;EACA;EACA;EACA;EACA;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;EACA;EACA;AAEA;EACA;;AACA;EACI;EACA;;AAIR;EACI;EACA;EACA;EACA;;;AAMhB;EACI;;;AAGJ;EACI;IACI;IACA;;EAGJ;IACI;IACA","file":"weightedOptions.css"}

View File

@@ -1,274 +0,0 @@
html{
background-image: url('../../static/backgrounds/grass.png');
background-repeat: repeat;
background-size: 650px 650px;
scroll-padding-top: 90px;
}
#weighted-options{
max-width: 1000px;
margin-left: auto;
margin-right: auto;
background-color: rgba(0, 0, 0, 0.15);
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
#weighted-options-header{
h1{
margin-bottom: 0;
padding-bottom: 0;
}
h1:nth-child(2){
font-size: 1.4rem;
margin-top: -8px;
margin-bottom: 0.5rem;
}
}
.js-warning-banner{
width: calc(100% - 1rem);
padding: 0.5rem;
border-radius: 4px;
background-color: #f3f309;
color: #000000;
margin-bottom: 0.5rem;
text-align: center;
}
.option-wrapper{
width: 100%;
margin-bottom: 2rem;
.add-option-div{
display: flex;
flex-direction: row;
justify-content: flex-start;
margin-bottom: 1rem;
button{
width: auto;
height: auto;
margin: 0 0 0 0.15rem;
padding: 0 0.25rem;
border-radius: 4px;
cursor: default;
&:active{
margin-bottom: 1px;
}
}
}
}
p{
&.option-description{
margin: 0 0 1rem;
}
&.hint-text{
margin: 0 0 1rem;
font-style: italic;
};
}
table{
width: 100%;
margin-top: 0.5rem;
margin-bottom: 1.5rem;
th, td{
border: none;
}
td{
padding: 5px;
}
.td-left{
font-family: LexendDeca-Regular, sans-serif;
padding-right: 1rem;
width: 200px;
}
.td-middle{
display: flex;
flex-direction: column;
justify-content: space-evenly;
padding-right: 1rem;
}
.td-right{
width: 4rem;
text-align: right;
}
.td-delete{
width: 50px;
text-align: right;
}
.range-option-delete{
cursor: pointer;
}
}
#weighted-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
border-radius: 4px;
color: #000000;
padding: 4px;
text-align: center;
&.visible{
display: block;
cursor: pointer;
}
}
h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
margin-bottom: 0.5rem;
color: #ffffff;
text-shadow: 1px 1px 4px #000000;
}
h2, details summary.h2{
font-size: 2rem;
font-weight: normal;
border-bottom: 1px solid #ffffff;
width: 100%;
margin-bottom: 0.5rem;
color: #ffe993;
text-transform: none;
text-shadow: 1px 1px 2px #000000;
}
h3, h4, h5, h6{
color: #ffffff;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
text-transform: none;
cursor: unset;
}
h3{
&.option-group-header{
margin-top: 0.75rem;
font-weight: bold;
}
}
a{
color: #ffef00;
cursor: pointer;
}
input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
&:focus{
border: 1px solid #ffffff;
}
}
.invisible{
display: none;
}
.unsupported-option{
margin-top: 0.5rem;
}
.set-container, .dict-container, .list-container{
display: flex;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(20, 20, 20, 0.25);
border-radius: 3px;
color: #ffffff;
max-height: 15rem;
min-width: 14.5rem;
overflow-y: auto;
padding-right: 0.25rem;
padding-left: 0.25rem;
margin-top: 0.5rem;
.divider{
width: 100%;
height: 2px;
background-color: rgba(20, 20, 20, 0.25);
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
.set-entry, .dict-entry, .list-entry{
display: flex;
flex-direction: row;
align-items: flex-start;
padding-bottom: 0.25rem;
padding-top: 0.25rem;
user-select: none;
line-height: 1rem;
&:hover{
background-color: rgba(20, 20, 20, 0.25);
}
input[type=checkbox]{
margin-right: 0.25rem;
}
input[type=number]{
max-width: 1.5rem;
max-height: 1rem;
margin-left: 0.125rem;
text-align: center;
/* Hide arrows on input[type=number] fields */
-moz-appearance: textfield;
&::-webkit-outer-spin-button, &::-webkit-inner-spin-button{
-webkit-appearance: none;
margin: 0;
}
}
label{
flex-grow: 1;
margin-right: 0;
min-width: unset;
display: unset;
}
}
}
}
.hidden{
display: none;
}
@media all and (max-width: 1000px), all and (orientation: portrait){
#weighted-options .game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#game-options table label{
display: block;
min-width: 200px;
}
}

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}"/>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/lttp-tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ bow_url }}" class="{{ 'acquired' if bow_acquired }}" /></td>
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ glove_url }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ sword_url }}" class="{{ 'acquired' if sword_acquired }}" /></td>
<td><img src="{{ shield_url }}" class="{{ 'acquired' if shield_acquired }}" /></td>
<td><img src="{{ mail_url }}" class="acquired" /></td>
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
</tr>
</table>
<table id="location-table">
<tr>
<th></th>
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
{% if key_locations and "Universal" not in key_locations %}
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
{% endif %}
{% if big_key_locations %}
<th><img src="{{ icons["Big Key"] }}" /></th>
{% endif %}
</tr>
{% for area in sp_areas %}
<tr>
<td>{{ area }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
{% if key_locations and "Universal" not in key_locations %}
<td class="counter">
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
</td>
{% endif %}
{% if big_key_locations %}
<td>
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -47,6 +47,9 @@
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>

View File

@@ -6,42 +6,52 @@
{% endblock %}
{# List all tracker-relevant icons. Format: (Name, Image URL) #}
{% set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/thumb/c/c3/FightersShield-ALttP-Sprite.png/100px-FightersShield-ALttP-Sprite.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/thumb/9/9e/FireShield-ALttP-Sprite.png/111px-FireShield-ALttP-Sprite.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/thumb/e/e3/MirrorShield-ALttP-Sprite.png/105px-MirrorShield-ALttP-Sprite.png",
"Progressive Sword": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/c/cc/ALttP_Master_Sword_Sprite.png",
"Progressive Bow": "https://www.zeldadungeon.net/wiki/images/thumb/8/8c/BowArrows-ALttP-Sprite.png/120px-BowArrows-ALttP-Sprite.png",
"Progressive Glove": "https://www.zeldadungeon.net/wiki/images/thumb/4/41/PowerGlove-ALttP-Sprite.png/105px-PowerGlove-ALttP-Sprite.png",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png",
"Flippers": "https://www.zeldadungeon.net/wiki/images/thumb/b/bc/ZoraFlippers-ALttP-Sprite.png/112px-ZoraFlippers-ALttP-Sprite.png",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png",
"Blue Boomerang": "https://www.zeldadungeon.net/wiki/images/thumb/f/f0/Boomerang-ALttP-Sprite.png/86px-Boomerang-ALttP-Sprite.png",
"Red Boomerang": "https://www.zeldadungeon.net/wiki/images/thumb/3/3c/MagicalBoomerang-ALttP-Sprite.png/86px-MagicalBoomerang-ALttP-Sprite.png",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png",
"Magic Powder": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png",
"Bottles": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png",
{%- set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png",
"Fighter Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920",
"Master Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920",
"Tempered Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920",
"Golden Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920",
"Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c",
"Silver Bow": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920",
"Green Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920",
"Blue Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920",
"Red Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920",
"Power Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920",
"Titan Mitts": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Progressive Sword": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
"Progressive Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Flippers": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
"Progressive Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
"Blue Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
"Red Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
"Magic Powder": "https://www.zeldadungeon.net/wiki/images/thumb/6/62/MagicPowder-ALttP-Sprite.png/86px-MagicPowder-ALttP-Sprite.png",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
"Bottle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
"Triforce Piece": "https://www.zeldadungeon.net/wiki/images/thumb/5/54/Triforce_Fragment_-_BS_Zelda.png/62px-Triforce_Fragment_-_BS_Zelda.png",
"Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/38/ALttP_Bomb_Sprite.png",
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png",
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
"Chest": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda",
"Light World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6",
"Dark World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc",
@@ -58,93 +68,33 @@
"Misery Mire": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8",
"Turtle Rock": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be",
"Ganons Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74",
} %}
{% set inventory_order = [
"Progressive Sword",
"Progressive Bow",
"Blue Boomerang",
"Red Boomerang",
"Hookshot",
"Bombs",
"Mushroom",
"Magic Powder",
"Fire Rod",
"Ice Rod",
"Bombos",
"Ether",
"Quake",
"Lamp",
"Hammer",
"Flute",
"Bug Catching Net",
"Book of Mudora",
"Cane of Somaria",
"Cane of Byrna",
"Cape",
"Magic Mirror",
"Shovel",
"Pegasus Boots",
"Flippers",
"Progressive Glove",
"Moon Pearl",
"Bottles",
"Triforce Piece",
"Triforce",
] %}
{% set dungeon_keys = {
"Hyrule Castle": ("Small Key (Hyrule Castle)", "Big Key (Hyrule Castle)"),
"Agahnims Tower": ("Small Key (Agahnims Tower)", "Big Key (Agahnims Tower)"),
"Eastern Palace": ("Small Key (Eastern Palace)", "Big Key (Eastern Palace)"),
"Desert Palace": ("Small Key (Desert Palace)", "Big Key (Desert Palace)"),
"Tower of Hera": ("Small Key (Tower of Hera)", "Big Key (Tower of Hera)"),
"Palace of Darkness": ("Small Key (Palace of Darkness)", "Big Key (Palace of Darkness)"),
"Thieves Town": ("Small Key (Thieves Town)", "Big Key (Thieves Town)"),
"Skull Woods": ("Small Key (Skull Woods)", "Big Key (Skull Woods)"),
"Swamp Palace": ("Small Key (Swamp Palace)", "Big Key (Swamp Palace)"),
"Ice Palace": ("Small Key (Ice Palace)", "Big Key (Ice Palace)"),
"Misery Mire": ("Small Key (Misery Mire)", "Big Key (Misery Mire)"),
"Turtle Rock": ("Small Key (Turtle Rock)", "Big Key (Turtle Rock)"),
"Ganons Tower": ("Small Key (Ganons Tower)", "Big Key (Ganons Tower)"),
} %}
{% set multi_items = [
"Progressive Sword",
"Progressive Glove",
"Progressive Bow",
"Bottles",
"Triforce Piece",
] %}
} -%}
{%- block custom_table_headers %}
{#- macro that creates a table header with display name and image -#}
{%- macro make_header(name, img_src) %}
<th class="center-column">
<img height="24" src="{{ img_src }}" title="{{ name }}" alt="{{ name }}">
</th>
{% endmacro -%}
{#- macro that creates a table header with display name and image -#}
{%- macro make_header(name, img_src) %}
<th class="center-column">
<img height="24" src="{{ img_src }}" title="{{ name }}" alt="{{ name }}" />
</th>
{% endmacro -%}
{#- call the macro to build the table header -#}
{%- for item in inventory_order %}
{%- if item in icons -%}
<th class="center-column">
<img class="icon-sprite" src="{{ icons[item] }}" alt="{{ item | e }}" title="{{ item | e }}">
</th>
{%- endif %}
{% endfor -%}
{#- call the macro to build the table header -#}
{%- for name in tracking_names %}
{%- if name in icons -%}
<th class="center-column">
<img class="icon-sprite" src="{{ icons[name] }}" alt="{{ name | e }}" title="{{ name | e }}" />
</th>
{%- endif %}
{% endfor -%}
{% endblock %}
{# build each row of custom entries #}
{% block custom_table_row scoped %}
{%- for item in inventory_order -%}
{%- if inventories[(team, player)][item] -%}
{%- for id in tracking_ids -%}
{# {{ checks }}#}
{%- if inventories[(team, player)][id] -%}
<td class="center-column item-acquired">
{% if item in multi_items %}
{{ inventories[(team, player)][item] }}
{% else %}
✔️
{% endif %}
{% if id in multi_items %}{{ inventories[(team, player)][id] }}{% else %}✔️{% endif %}
</td>
{%- else -%}
<td></td>
@@ -154,95 +104,102 @@
{% block custom_tables %}
{% for team in total_team_locations %}
<div class="table-wrapper">
<table class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">Name</th>
{% for region in known_regions %}
{% set colspan = 1 %}
{% if region == "Agahnims Tower" %}
{% set colspan = 2 %}
{% elif region in dungeon_keys %}
{% set colspan = 3 %}
{% endif %}
{% if region in icons %}
<th colspan="{{ colspan }}" class="center-column upper-row">
<img class="icon-sprite" src="{{ icons[region] }}" alt="{{ region }}" title="{{ region }}">
</th>
{% else %}
<th colspan="{{ colspan }}" class="center-column">{{ region }}</th>
{% endif %}
{% endfor %}
<th class="center-column">Total</th>
</tr>
<tr>
{% for region in known_regions %}
<th class="center-column lower-row fraction">
<img class="icon-sprite" src="{{ icons["Chest"] }}" alt="Checks" title="Checks Complete">
</th>
{% if region in dungeon_keys %}
<th class="center-column lower-row number">
<img class="icon-sprite" src="{{ icons["Small Key"] }}" alt="Small Key" title="Small Keys">
</th>
{# Special check just for Agahnims Tower, which has no big keys. #}
{% if region != "Agahnims Tower" %}
<th class="center-column lower-row number">
<img class="icon-sprite" src="{{ icons["Big Key"] }}" alt="Big Key" title="Big Keys">
</th>
{% endif %}
{% endif %}
{% endfor %}
{# For "total" checks #}
{% for team, _ in total_team_locations.items() %}
<div class="table-wrapper">
<table id="area-table" class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">Name</th>
{% for area in ordered_areas %}
{% set colspan = 1 %}
{% if area in key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in big_key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in icons %}
<th colspan="{{ colspan }}" class="center-column upper-row">
<img class="icon-sprite" src="{{ icons[area] }}" alt="{{ area }}" title="{{ area }}"></th>
{%- else -%}
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
{%- endif -%}
{%- endfor -%}
<th rowspan="2" class="center-column">&percnt;</th>
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
</tr>
<tr>
{% for area in ordered_areas %}
<th class="center-column lower-row fraction">
<img class="icon-sprite" src="{{ icons["Chest"] }}" alt="Checks" title="Total Checks Complete">
<img class="icon-sprite" src="{{ icons["Chest"] }}" alt="Checks" title="Checks Complete">
</th>
</tr>
</thead>
<tbody>
{% for (player_team, player), player_regions in regions.items() if team == player_team %}
<tr>
<td>
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">
{{ player }}
</a>
</td>
<td>{{ player_names_with_alias[(team, player)] | e }}</td>
{% for region, counts in player_regions.items() %}
{% if area in key_locations %}
<th class="center-column lower-row number">
<img class="icon-sprite" src="{{ icons["Small Key"] }}" alt="Small Key" title="Small Keys">
</th>
{% endif %}
{% if area in big_key_locations %}
<th class="center-column lower-row number">
<img class="icon-sprite" src="{{ icons["Big Key"] }}" alt="Big Key" title="Big Keys">
</th>
{%- endif -%}
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for (checks_team, player), area_checks in checks_done.items() if games[(team, player)] == current_tracker and team == checks_team -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ player }}</a></td>
<td>{{ player_names_with_alias[(team, player)] | e }}</td>
{%- for area in ordered_areas -%}
{% if (team, player) in checks_in_area and area in checks_in_area[(team, player)] %}
{%- set checks_done = area_checks[area] -%}
{%- set checks_total = checks_in_area[(team, player)][area] -%}
{%- if checks_done == checks_total -%}
<td class="item-acquired center-column">
{{ counts.checked }}/{{ counts.total }}
</td>
{{ checks_done }}/{{ checks_total }}</td>
{%- else -%}
<td class="center-column">{{ checks_done }}/{{ checks_total }}</td>
{%- endif -%}
{%- if area in key_locations -%}
<td class="center-column">{{ inventories[(team, player)][small_key_ids[area]] }}</td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column">{% if inventories[(team, player)][big_key_ids[area]] %}✔️{% endif %}</td>
{%- endif -%}
{% else %}
<td class="center-column"></td>
{%- if area in key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{% endif %}
{%- endfor -%}
{% if region in dungeon_keys %}
<td class="center-column">
{{ inventories[(team, player)][dungeon_keys[region][0]] }}
</td>
<td class="center-column">
{% set location_count = locations[(team, player)] | length %}
{%- if locations[(team, player)] | length > 0 -%}
{% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %}
{{ "{0:.2f}".format(percentage_of_completion) }}
{%- else -%}
100.00
{%- endif -%}
</td>
{# Special check just for Agahnims Tower, which has no big keys. #}
{% if region != "Agahnims Tower" %}
<td class="center-column">
{% if inventories[(team, player)][dungeon_keys[region][1]] %}
✔️
{% endif %}
</td>
{% endif %}
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Options</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/player-options.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/player-options.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="player-options" class="markdown" data-game="{{ game }}">
<div id="user-message"></div>
<h1><span id="game-name">Player</span> Options</h1>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download an options file you can use to participate in a MultiWorld.</p>
<p>
A more advanced options configuration for all games can be found on the
<a href="/weighted-options">Weighted options</a> page.
<br />
A list of all games you have generated can be found on the <a href="/user-content">User Content Page</a>.
<br />
You may also download the
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
</p>
<div id="meta-options">
<div>
<label for="player-name">
Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
</label>
<input id="player-name" placeholder="Player" data-key="name" maxlength="16" />
</div>
<div>
<label for="game-options-preset">
Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
</label>
<select id="game-options-preset">
<option value="__default">Defaults</option>
<option value="__custom" hidden>Custom</option>
</select>
</div>
</div>
<h2>Game Options</h2>
<div id="game-options">
<div id="game-options-left" class="left"></div>
<div id="game-options-right" class="right"></div>
</div>
<div id="player-options-button-row">
<button id="export-options">Export Options</button>
<button id="generate-game">Generate Game</button>
<button id="generate-race">Generate Race</button>
</div>
</div>
{% endblock %}

View File

@@ -1,210 +0,0 @@
{% macro Toggle(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="select-container">
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% if option.default == 1 %}
<option value="false">No</option>
<option value="true" selected>Yes</option>
{% else %}
<option value="false" selected>No</option>
<option value="true">Yes</option>
{% endif %}
</select>
{{ RandomizeButton(option_name, option) }}
</div>
{% endmacro %}
{% macro Choice(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="select-container">
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% for id, name in option.name_lookup.items() %}
{% if name != "random" %}
{% if option.default == id %}
<option value="{{ name }}" selected>{{ option.get_option_name(id) }}</option>
{% else %}
<option value="{{ name }}">{{ option.get_option_name(id) }}</option>
{% endif %}
{% endif %}
{% endfor %}
</select>
{{ RandomizeButton(option_name, option) }}
</div>
{% endmacro %}
{% macro Range(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="range-container">
<input
type="range"
id="{{ option_name }}"
name="{{ option_name }}"
min="{{ option.range_start }}"
max="{{ option.range_end }}"
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
{{ "disabled" if option.default == "random" }}
/>
<span id="{{ option_name }}-value" class="range-value js-required">
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
</span>
{{ RandomizeButton(option_name, option) }}
</div>
{% endmacro %}
{% macro NamedRange(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="named-range-container">
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% for key, val in option.special_range_names.items() %}
{% if option.default == val %}
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option>
{% else %}
<option value="{{ val }}">{{ key }} ({{ val }})</option>
{% endif %}
{% endfor %}
<option value="custom" hidden>Custom</option>
</select>
<div class="named-range-wrapper">
<input
type="range"
id="{{ option_name }}"
name="{{ option_name }}"
min="{{ option.range_start }}"
max="{{ option.range_end }}"
value="{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}"
{{ "disabled" if option.default == "random" }}
/>
<span id="{{ option_name }}-value" class="range-value js-required">
{{ option.default | default(option.range_start) if option.default != "random" else option.range_start }}
</span>
{{ RandomizeButton(option_name, option) }}
</div>
</div>
{% endmacro %}
{% macro FreeText(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="free-text-container">
<input type="text" id="{{ option_name }}" name="{{ option_name }}" value="{{ option.default }}" />
</div>
{% endmacro %}
{% macro TextChoice(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="text-choice-container">
<div class="text-choice-wrapper">
<select id="{{ option_name }}" name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% for id, name in option.name_lookup.items()|sort %}
{% if name != "random" %}
{% if option.default == id %}
<option value="{{ name }}" selected>{{ option.get_option_name(id) }}</option>
{% else %}
<option value="{{ name }}">{{ option.get_option_name(id) }}</option>
{% endif %}
{% endif %}
{% endfor %}
<option value="custom" hidden>Custom</option>
</select>
{{ RandomizeButton(option_name, option) }}
</div>
<input type="text" id="{{ option_name }}-custom" name="{{ option_name }}-custom" data-option-name="{{ option_name }}" placeholder="Custom value..." />
</div>
{% endmacro %}
{% macro ItemDict(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro OptionList(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}" value="{{ key }}" {{ "checked" if key in option.default }} />
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro LocationSet(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}" value="{{ group_name }}" {{ "checked" if group_name in option.default }} />
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
</div>
{% endif %}
{% endfor %}
{% if world.location_name_groups.keys()|length > 1 %}
<div class="option-divider">&nbsp;</div>
{% endif %}
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ location_name }}" name="{{ option_name }}" value="{{ location_name }}" {{ "checked" if location_name in option.default }} />
<label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro ItemSet(option_name, option, world) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}" value="{{ group_name }}" {{ "checked" if group_name in option.default }} />
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
</div>
{% endif %}
{% endfor %}
{% if world.item_name_groups.keys()|length > 1 %}
<div class="option-divider">&nbsp;</div>
{% endif %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ item_name }}" name="{{ option_name }}" value="{{ item_name }}" {{ "checked" if item_name in option.default }} />
<label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro OptionSet(option_name, option) %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}" value="{{ key }}" {{ "checked" if key in option.default }} />
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro OptionTitle(option_name, option) %}
<label for="{{ option_name }}">
{{ option.display_name|default(option_name) }}:
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span>
</label>
{% endmacro %}
{% macro RandomizeButton(option_name, option) %}
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
<label for="random-{{ option_name }}">
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
🎲
</label>
</div>
{% endmacro %}

View File

@@ -1,166 +0,0 @@
{% extends 'pageWrapper.html' %}
{% import 'playerOptions/macros.html' as inputs %}
{% block head %}
<title>{{ world_name }} Options</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/playerOptions/playerOptions.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/playerOptions.js") }}"></script>
<noscript>
<style>
.js-required{
display: none;
}
</style>
</noscript>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="player-options" class="markdown" data-game="{{ world_name }}" data-presets="{{ presets }}">
<noscript>
<div class="js-warning-banner">
This page has reduced functionality without JavaScript.
</div>
</noscript>
<div id="user-message">{{ message }}</div>
<div id="player-options-header">
<h1>{{ world_name }}</h1>
<h1>Player Options</h1>
</div>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download an options file you can use to participate in a MultiWorld.</p>
<p>
A more advanced options configuration for all games can be found on the
<a href="weighted-options">Weighted options</a> page.
<br />
A list of all games you have generated can be found on the <a href="/user-content">User Content Page</a>.
<br />
You may also download the
<a href="/static/generated/configs/{{ world_name }}.yaml">template file for this game</a>.
</p>
<form id="options-form" method="post" enctype="application/x-www-form-urlencoded" action="generate-yaml">
<div id="meta-options">
<div>
<label for="player-name">
Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
</label>
<input id="player-name" placeholder="Player" name="name" maxlength="16" />
</div>
<div class="js-required">
<label for="game-options-preset">
Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
</label>
<select id="game-options-preset" name="game-options-preset" disabled>
<option value="default">Default</option>
{% for preset_name in world.web.options_presets %}
<option value="{{ preset_name }}">{{ preset_name }}</option>
{% endfor %}
<option value="custom" hidden>Custom</option>
</select>
</div>
</div>
<div id="option-groups">
{% for group_name, group_options in option_groups.items() %}
<details class="group-container" {% if loop.index == 1 %}open{% endif %}>
<summary class="h2">{{ group_name }}</summary>
<div class="game-options">
<div class="left">
{% for option_name, option in group_options.items() %}
{% if loop.index <= (loop.length / 2)|round(0,"ceil") %}
{% if issubclass(option, Options.Toggle) %}
{{ inputs.Toggle(option_name, option) }}
{% elif issubclass(option, Options.TextChoice) %}
{{ inputs.TextChoice(option_name, option) }}
{% elif issubclass(option, Options.Choice) %}
{{ inputs.Choice(option_name, option) }}
{% elif issubclass(option, Options.NamedRange) %}
{{ inputs.NamedRange(option_name, option) }}
{% elif issubclass(option, Options.Range) %}
{{ inputs.Range(option_name, option) }}
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }}
{% endif %}
{% endif %}
{% endfor %}
</div>
<div class="right">
{% for option_name, option in group_options.items() %}
{% if loop.index > (loop.length / 2)|round(0,"ceil") %}
{% if issubclass(option, Options.Toggle) %}
{{ inputs.Toggle(option_name, option) }}
{% elif issubclass(option, Options.TextChoice) %}
{{ inputs.TextChoice(option_name, option) }}
{% elif issubclass(option, Options.Choice) %}
{{ inputs.Choice(option_name, option) }}
{% elif issubclass(option, Options.NamedRange) %}
{{ inputs.NamedRange(option_name, option) }}
{% elif issubclass(option, Options.Range) %}
{{ inputs.Range(option_name, option) }}
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }}
{% endif %}
{% endif %}
{% endfor %}
</div>
</div>
</details>
{% endfor %}
</div>
<div id="player-options-button-row">
<input type="submit" name="intent-export" value="Export Options" />
<input type="submit" name="intent-generate" value="Generate Single-Player Game">
</div>
</form>
</div>
{% endblock %}

View File

@@ -24,6 +24,7 @@
<li><a href="/games">Supported Games Page</a></li>
<li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="/user-content">User Content</a></li>
<li><a href="/weighted-options">Weighted Options Page</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="/glossary/en">Glossary</a></li>
</ul>
@@ -49,12 +50,8 @@
<ul>
{% for game in games | title_sorted %}
{% if game['has_settings'] %}
<li>{{ game['title'] }}</li>
<ul>
<li><a href="{{ url_for('player_options', game=game['title']) }}">Player Options</a></li>
<li><a href="{{ url_for('weighted_options', game=game['title']) }}">Weighted Options</a></li>
</ul>
{% endif %}
<li><a href="{{ url_for('player_options', game=game['title']) }}">{{ game['title'] }}</a></li>
{% endif %}
{% endfor %}
</ul>
</div>

View File

@@ -18,7 +18,7 @@
<br /><br />
To start playing a game, you'll first need to <a href="/generate">generate a randomized game</a>.
You'll need to upload one or more config files (YAMLs) or a zip file containing one or more config files.
You'll need to upload either a config file or a zip file containing one more config files.
<br /><br />
If you have already generated a game and just need to host it, this site can<br />

View File

@@ -41,28 +41,28 @@
</div>
{% for game_name in worlds | title_sorted %}
{% set world = worlds[game_name] %}
<details data-game="{{ game_name }}">
<summary class="h2">{{ game_name }}</summary>
<h2 class="collapse-toggle" data-game="{{ game_name }}">
<span class="collapse-arrow"></span>{{ game_name }}
</h2>
<p class="collapsed">
{{ world.__doc__ | default("No description provided.", true) }}<br />
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
{% if world.web.tutorials %}
<span class="link-spacer">|</span>
<a href="{{ url_for("tutorial_landing", _anchor = game_name | urlencode) }}">Setup Guides</a>
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
{% endif %}
{% if world.web.options_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.options_page }}">Options Page (External Link)</a>
<a href="{{ world.web.options_page }}">Options Page</a>
{% elif world.web.options_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_options", game=game_name) }}">Options Page</a>
<span class="link-spacer">|</span>
<a href="{{ url_for("weighted_options", game=game_name) }}">Advanced Options</a>
{% endif %}
{% if world.web.bug_report_page %}
<span class="link-spacer">|</span>
<a href="{{ world.web.bug_report_page }}">Report a Bug</a>
{% endif %}
</details>
</p>
{% endfor %}
</div>
{% endblock %}

View File

@@ -1,89 +1,73 @@
{% set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/thumb/c/c3/FightersShield-ALttP-Sprite.png/100px-FightersShield-ALttP-Sprite.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/thumb/9/9e/FireShield-ALttP-Sprite.png/111px-FireShield-ALttP-Sprite.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/thumb/e/e3/MirrorShield-ALttP-Sprite.png/105px-MirrorShield-ALttP-Sprite.png",
"Fighter Sword": "https://upload.wikimedia.org/wikibooks/en/8/8e/Zelda_ALttP_item_L-1_Sword.png",
"Master Sword": "https://upload.wikimedia.org/wikibooks/en/8/87/BS_Zelda_AST_item_L-2_Sword.png",
"Tempered Sword": "https://upload.wikimedia.org/wikibooks/en/c/cc/BS_Zelda_AST_item_L-3_Sword.png",
"Golden Sword": "https://upload.wikimedia.org/wikibooks/en/4/40/BS_Zelda_AST_item_L-4_Sword.png",
"Bow": "https://www.zeldadungeon.net/wiki/images/thumb/8/8c/BowArrows-ALttP-Sprite.png/120px-BowArrows-ALttP-Sprite.png",
"Silver Bow": "https://upload.wikimedia.org/wikibooks/en/6/69/Zelda_ALttP_item_Silver_Arrows.png",
"Green Mail": "https://upload.wikimedia.org/wikibooks/en/d/dd/Zelda_ALttP_item_Green_Mail.png",
"Blue Mail": "https://upload.wikimedia.org/wikibooks/en/b/b5/Zelda_ALttP_item_Blue_Mail.png",
"Red Mail": "https://upload.wikimedia.org/wikibooks/en/d/db/Zelda_ALttP_item_Red_Mail.png",
"Power Glove": "https://www.zeldadungeon.net/wiki/images/thumb/4/41/PowerGlove-ALttP-Sprite.png/105px-PowerGlove-ALttP-Sprite.png",
"Titan Mitts": "https://www.zeldadungeon.net/wiki/images/thumb/7/75/TitanMitt-ALttP-Sprite.png/105px-TitanMitt-ALttP-Sprite.png",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png",
"Flippers": "https://www.zeldadungeon.net/wiki/images/thumb/b/bc/ZoraFlippers-ALttP-Sprite.png/112px-ZoraFlippers-ALttP-Sprite.png",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png",
"Blue Boomerang": "https://www.zeldadungeon.net/wiki/images/thumb/f/f0/Boomerang-ALttP-Sprite.png/86px-Boomerang-ALttP-Sprite.png",
"Red Boomerang": "https://www.zeldadungeon.net/wiki/images/thumb/3/3c/MagicalBoomerang-ALttP-Sprite.png/86px-MagicalBoomerang-ALttP-Sprite.png",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png",
"Magic Powder": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png",
"Bottles": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png",
{%- set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png",
"Fighter Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920",
"Master Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920",
"Tempered Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920",
"Golden Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920",
"Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c",
"Silver Bow": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920",
"Green Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920",
"Blue Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920",
"Red Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920",
"Power Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920",
"Titan Mitts": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Progressive Sword": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
"Progressive Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Flippers": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
"Progressive Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
"Blue Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
"Red Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
"Magic Powder": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png?version=c24e38effbd4f80496d35830ce8ff4ec",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
"Bottle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
"Triforce Piece": "https://www.zeldadungeon.net/wiki/images/thumb/5/54/Triforce_Fragment_-_BS_Zelda.png/62px-Triforce_Fragment_-_BS_Zelda.png",
"Bombs": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/3/38/ALttP_Bomb_Sprite.png",
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png",
} %}
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
"Chest": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda",
"Light World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6",
"Dark World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc",
"Hyrule Castle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d3/ALttP_Ball_and_Chain_Trooper_Sprite.png?version=1768a87c06d29cc8e7ddd80b9fa516be",
"Agahnims Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1e/ALttP_Agahnim_Sprite.png?version=365956e61b0c2191eae4eddbe591dab5",
"Desert Palace": "https://www.zeldadungeon.net/wiki/images/2/25/Lanmola-ALTTP-Sprite.png",
"Eastern Palace": "https://www.zeldadungeon.net/wiki/images/d/dc/RedArmosKnight.png",
"Tower of Hera": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/ALttP_Moldorm_Sprite.png?version=c588257bdc2543468e008a6b30f262a7",
"Palace of Darkness": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Helmasaur_King_Sprite.png?version=ab8a4a1cfd91d4fc43466c56cba30022",
"Swamp Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Arrghus_Sprite.png?version=b098be3122e53f751b74f4a5ef9184b5",
"Skull Woods": "https://alttp-wiki.net/images/6/6a/Mothula.png",
"Thieves Town": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/86/ALttP_Blind_the_Thief_Sprite.png?version=3833021bfcd112be54e7390679047222",
"Ice Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Kholdstare_Sprite.png?version=e5a1b0e8b2298e550d85f90bf97045c0",
"Misery Mire": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8",
"Turtle Rock": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be",
"Ganons Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74",
} -%}
{% set inventory_order = [
"Progressive Bow", "Boomerangs", "Hookshot", "Bombs", "Mushroom", "Magic Powder",
"Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", "Progressive Mail",
"Lamp", "Hammer", "Flute", "Bug Catching Net", "Book of Mudora", "Progressive Shield",
"Bottles", "Cane of Somaria", "Cane of Byrna", "Cape", "Magic Mirror", "Progressive Sword",
"Shovel", "Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Triforce Piece",
] %}
{# Most have a duplicated 0th entry for when we have none of that item to still load the correct icon/name. #}
{% set progressive_order = {
"Progressive Bow": ["Bow", "Bow", "Silver Bow"],
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
"Progressive Shield": ["Blue Shield", "Blue Shield", "Red Shield", "Mirror Shield"],
"Progressive Sword": ["Fighter Sword", "Fighter Sword", "Master Sword", "Tempered Sword", "Golden Sword"],
"Progressive Glove": ["Power Glove", "Power Glove", "Titan Mitts"],
} %}
{% set dungeon_keys = {
"Hyrule Castle": ("Small Key (Hyrule Castle)", "Big Key (Hyrule Castle)"),
"Agahnims Tower": ("Small Key (Agahnims Tower)", "Big Key (Agahnims Tower)"),
"Eastern Palace": ("Small Key (Eastern Palace)", "Big Key (Eastern Palace)"),
"Desert Palace": ("Small Key (Desert Palace)", "Big Key (Desert Palace)"),
"Tower of Hera": ("Small Key (Tower of Hera)", "Big Key (Tower of Hera)"),
"Palace of Darkness": ("Small Key (Palace of Darkness)", "Big Key (Palace of Darkness)"),
"Swamp Palace": ("Small Key (Swamp Palace)", "Big Key (Swamp Palace)"),
"Thieves Town": ("Small Key (Thieves Town)", "Big Key (Thieves Town)"),
"Skull Woods": ("Small Key (Skull Woods)", "Big Key (Skull Woods)"),
"Ice Palace": ("Small Key (Ice Palace)", "Big Key (Ice Palace)"),
"Misery Mire": ("Small Key (Misery Mire)", "Big Key (Misery Mire)"),
"Turtle Rock": ("Small Key (Turtle Rock)", "Big Key (Turtle Rock)"),
"Ganons Tower": ("Small Key (Ganons Tower)", "Big Key (Ganons Tower)"),
} %}
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/tracker__ALinkToThePast.css') }}">
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/lttp-tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
</head>
<body>
@@ -92,128 +76,79 @@
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div class="tracker-container">
{# Inventory Grid #}
<div class="inventory-grid">
{% for item in inventory_order %}
{% if item in progressive_order %}
{% set non_prog_item = progressive_order[item][inventory[item]] %}
<div class="item">
<img
src="{{ icons[non_prog_item] }}"
alt="{{ non_prog_item }}"
title="{{ non_prog_item }}"
{# Progressive Mail gets a special exception, since it starts displaying green mail. #}
class="{{ 'missing' if (item not in inventory or inventory[item] == 0) and item != 'Progressive Mail' }}"
>
</div>
{% elif item == "Boomerangs" %}
<div class="dual-item">
<img
src="{{ icons['Blue Boomerang'] }}"
alt="Blue Boomerang"
title="Blue Boomerang"
class="{{ 'missing' if 'Blue Boomerang' not in inventory }}"
>
<img
src="{{ icons['Red Boomerang'] }}"
alt="Red Boomerang"
title="Red Boomerang"
class="{{ 'missing' if 'Red Boomerang' not in inventory }}"
>
</div>
{% else %}
<div class="item {{ 'hidden' if item == 'Triforce Piece' and inventory['Triforce Piece'] == 0 }}">
<img
src="{{ icons[item] }}"
alt="{{ item }}"
title="{{ item }}"
class="{{ 'missing' if item not in inventory or inventory[item] == 0 }}"
>
{% if item == "Bottles" or item == "Triforce Piece" %}
<div class="quantity">{{ inventory[item] }}</div>
{% endif %}
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ icons[bow_icon] }}" class="{{ 'acquired' if bow_acquired }}" /></td>
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ icons[glove_icon] }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons[sword_icon] }}" class="{{ 'acquired' if sword_acquired }}" /></td>
<td><img src="{{ icons[shield_icon] }}" class="{{ 'acquired' if shield_acquired }}" /></td>
<td><img src="{{ icons[mail_icon] }}" class="acquired" /></td>
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
</tr>
</table>
<table id="location-table">
<tr>
<th></th>
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
{% if key_locations and "Universal" not in key_locations %}
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
{% endif %}
{% endfor %}
</div>
<div class="regions-list">
<div class="region region-header">
<div></div>
<div></div>
<div><img src="{{ icons['Small Key'] }}" alt="SK" title="Small Keys"></div>
<div><img src="{{ icons['Big Key'] }}" alt="BK" title="Big Keys"></div>
</div>
{% for region_name in known_regions %}
{% set region_data = regions[region_name] %}
{% if region_data["locations"] | length > 0 %}
<details class="region-details">
<summary>
{% if region_name in dungeon_keys %}
<div class="region">
<span>{{ region_name }}</span>
<span>{{ region_data["checked"] }} / {{ region_data["locations"] | length }}</span>
<span>{{ inventory[dungeon_keys[region_name][0]] }}</span>
<span>
{% if region_name == "Agahnims Tower" %}
&mdash;
{% elif inventory[dungeon_keys[region_name][1]] %}
{% endif %}
</span>
</div>
{% else %}
<div class="region">
<span>{{ region_name }}</span>
<span>{{ region_data["checked"] }} / {{ region_data["locations"] | length }}</span>
<span>&mdash;</span>
<span>&mdash;</span>
</div>
{% endif %}
</summary>
<div class="location-rows">
{% for location, checked in region_data["locations"] %}
<div>{{ location }}</div>
<div>{% if checked %}✔{% endif %}</div>
{% endfor %}
</div>
</details>
{% if big_key_locations %}
<th><img src="{{ icons["Big Key"] }}" /></th>
{% endif %}
</tr>
{% for area in sp_areas %}
<tr>
<td>{{ area }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
{% if key_locations and "Universal" not in key_locations %}
<td class="counter">
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
</td>
{% endif %}
{% if big_key_locations %}
<td>
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
</td>
{% endif %}
</tr>
{% endfor %}
</div>
</table>
</div>
<script>
const parser = new DOMParser();
const interval = 15_000;
window.addEventListener("load", () => {
setInterval(() => updateTracker()
.then(() => console.log("Refreshed tracker."))
.catch(console.error), interval);
});
async function updateTracker() {
const response = await fetch(`${window.location}`);
if (!response.ok) {
throw new Error(`Failed to fetch tracker update from ${window.location}. Received response: ${response.statusText}`);
}
const fakeDOM = parser.parseFromString(await response.text(), "text/html");
document.querySelector(".inventory-grid").innerHTML = fakeDOM.querySelector(".inventory-grid").innerHTML;
const regionDetailElements = document.querySelectorAll(".region-details");
const fakeDetailElements = fakeDOM.querySelectorAll(".region-details");
for (let i = 0; i < regionDetailElements.length; ++i) {
const isOpen = regionDetailElements[i].open;
regionDetailElements[i].innerHTML = fakeDetailElements[i].innerHTML;
regionDetailElements[i].open = isOpen;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Options</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weighted-options.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weighted-options.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="weighted-settings" class="markdown" data-game="{{ game }}">
<div id="user-message"></div>
<h1>Weighted Options</h1>
<p>Weighted options allow you to choose how likely a particular option is to be used in game generation.
The higher an option is weighted, the more likely the option will be chosen. Think of them like
entries in a raffle.</p>
<p>Choose the games and options you would like to play with! You may generate a single-player game from
this page, or download an options file you can use to participate in a MultiWorld.</p>
<p>A list of all games you have generated can be found on the <a href="/user-content">User Content</a>
page.</p>
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
items if you are playing in a MultiWorld.</label><br />
<input id="player-name" placeholder="Player Name" data-key="name" maxlength="16" />
</p>
<div id="game-choice">
<!-- User chooses games by weight -->
</div>
<!-- To be generated and populated per-game with weight > 0 -->
<div id="games-wrapper">
</div>
<div id="weighted-settings-button-row">
<button id="export-options">Export Options</button>
<button id="generate-game">Generate Game</button>
<button id="generate-race">Generate Race</button>
</div>
</div>
{% endblock %}

View File

@@ -1,249 +0,0 @@
{% macro Toggle(option_name, option) %}
<table>
<tbody>
{{ RangeRow(option_name, option, "No", "false") }}
{{ RangeRow(option_name, option, "Yes", "true") }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
{% macro DefaultOnToggle(option_name, option) %}
<!-- Toggle handles defaults properly, so we just reuse that -->
{{ Toggle(option_name, option) }}
{% endmacro %}
{% macro Choice(option_name, option) %}
<table>
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endfor %}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
{% macro Range(option_name, option) %}
<div class="hint-text js-required">
This is a range option.
<br /><br />
Accepted values:<br />
Normal range: {{ option.range_start }} - {{ option.range_end }}
{% if option.special_range_names %}
<br /><br />
The following values has special meaning, and may fall outside the normal range.
<ul>
{% for name, value in option.special_range_names.items() %}
<li>{{ value }}: {{ name }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="add-option-div">
<input type="number" class="range-option-value" data-option="{{ option_name }}" />
<button class="add-range-option-button" data-option="{{ option_name }}">Add</button>
</div>
</div>
<table class="range-rows" data-option="{{ option_name }}">
<tbody>
{{ RangeRow(option_name, option, option.range_start, option.range_start, True) }}
{% if option.range_start < option.default < option.range_end %}
{{ RangeRow(option_name, option, option.default, option.default, True) }}
{% endif %}
{{ RangeRow(option_name, option, option.range_end, option.range_end, True) }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
{% macro NamedRange(option_name, option) %}
<!-- Range is able to properly handle NamedDRange options -->
{{ Range(option_name, option) }}
{% endmacro %}
{% macro FreeText(option_name, option) %}
<div class="hint-text">
This option allows custom values only. Please enter your desired values below.
<div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button data-option="{{ option_name }}">Add</button>
</div>
<table>
<tbody>
<!-- This table to be filled by JS -->
</tbody>
</table>
</div>
{% endmacro %}
{% macro TextChoice(option_name, option) %}
<div class="hint-text">
Custom values are also allowed for this option. To create one, enter it into the input box below.
<div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button data-option="{{ option_name }}">Add</button>
</div>
</div>
<table>
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endfor %}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
{% macro PlandoBosses(option_name, option) %}
<!-- PlandoBosses is handled by its parent, TextChoice -->
{{ TextChoice(option_name, option) }}
{% endmacro %}
{% macro ItemDict(option_name, option, world) %}
<div class="dict-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input
type="number"
id="{{ option_name }}-{{ item_name }}-qty"
name="{{ option_name }}||{{ item_name }}"
value="0"
/>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro OptionList(option_name, option) %}
<div class="list-container">
{% for key in option.valid_keys|sort %}
<div class="list-entry">
<input
type="checkbox"
id="{{ option_name }}-{{ key }}"
name="{{ option_name }}||{{ key }}"
value="1"
/>
<label for="{{ option_name }}-{{ key }}">
{{ key }}
</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro LocationSet(option_name, option, world) %}
<div class="set-container">
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}||{{ group_name }}" value="1" {{ "checked" if group_name in option.default }} />
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
</div>
{% endif %}
{% endfor %}
{% if world.location_name_groups.keys()|length > 1 %}
<div class="divider">&nbsp;</div>
{% endif %}
{% for location_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.location_names|sort) %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ location_name }}" name="{{ option_name }}||{{ location_name }}" value="1" {{ "checked" if location_name in option.default }} />
<label for="{{ option_name }}-{{ location_name }}">{{ location_name }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro ItemSet(option_name, option, world) %}
<div class="set-container">
{% for group_name in world.item_name_groups.keys()|sort %}
{% if group_name != "Everything" %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}||{{ group_name }}" value="1" {{ "checked" if group_name in option.default }} />
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
</div>
{% endif %}
{% endfor %}
{% if world.item_name_groups.keys()|length > 1 %}
<div class="set-divider">&nbsp;</div>
{% endif %}
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ item_name }}" name="{{ option_name }}||{{ item_name }}" value="1" {{ "checked" if item_name in option.default }} />
<label for="{{ option_name }}-{{ item_name }}">{{ item_name }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro OptionSet(option_name, option) %}
<div class="set-container">
{% for key in option.valid_keys|sort %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
</div>
{% endfor %}
</div>
{% endmacro %}
{% macro OptionTitleTd(option_name, value) %}
<td class="td-left">
<label for="{{ option_name }}||{{ value }}">
{{ option.display_name|default(option_name) }}
</label>
</td>
{% endmacro %}
{% macro RandomRows(option_name, option, extra_column=False) %}
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
{{ RangeRow(option_name, option, key, value) }}
{% endfor %}
{% endmacro %}
{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %}
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
<td class="td-left">
<label for="{{ option_name }}||{{ value }}">
{{ display_value }}
</label>
</td>
<td class="td-middle">
<input
type="range"
id="{{ option_name }}||{{ value }}"
name="{{ option_name }}||{{ value }}"
min="0"
max="50"
{% if option.default == value %}
value="25"
{% else %}
value="0"
{% endif %}
/>
</td>
<td class="td-right">
<span id="{{ option_name }}||{{ value }}-value">
{% if option.default == value %}
25
{% else %}
0
{% endif %}
</span>
</td>
{% if can_delete %}
<td>
<span class="range-option-delete js-required" data-target="{{ option_name }}-{{ value }}-row">
</span>
</td>
{% else %}
<td><!-- This td empty on purpose --></td>
{% endif %}
</tr>
{% endmacro %}

View File

@@ -1,119 +0,0 @@
{% extends 'pageWrapper.html' %}
{% import 'weightedOptions/macros.html' as inputs %}
{% block head %}
<title>{{ world_name }} Weighted Options</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weightedOptions/weightedOptions.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weightedOptions.js") }}"></script>
<noscript>
<style>
.js-required{
display: none;
}
</style>
</noscript>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="weighted-options" class="markdown" data-game="{{ world_name }}">
<noscript>
<div class="js-warning-banner">
This page has reduced functionality without JavaScript.
</div>
</noscript>
<div id="user-message"></div>
<div id="weighted-options-header">
<h1>{{ world_name }}</h1>
<h1>Weighted Options</h1>
</div>
<form id="weighted-options-form" method="post" enctype="application/x-www-form-urlencoded" action="generate-weighted-yaml">
<p>Weighted options allow you to choose how likely a particular option&apos;s value is to be used in game
generation. The higher a value is weighted, the more likely the option will be chosen. Think of them like
entries in a raffle.</p>
<p>Choose the options you would like to play with! You may generate a single-player game from
this page, or download an options file you can use to participate in a MultiWorld.</p>
<p>A list of all games you have generated can be found on the <a href="/user-content">User Content</a>
page.</p>
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
items if you are playing in a MultiWorld.</label><br />
<input id="player-name" placeholder="Player Name" name="name" maxlength="16" />
</p>
<div id="{{ world_name }}-container">
{% for group_name, group_options in option_groups.items() %}
<details {% if loop.index == 1 %}open{% endif %}>
<summary class="h2">{{ group_name }}</summary>
{% for option_name, option in group_options.items() %}
<div class="option-wrapper">
<h4>{{ option.display_name|default(option_name) }}</h4>
<div class="option-description">
{{ option.__doc__ }}
</div>
{% if issubclass(option, Options.Toggle) %}
{{ inputs.Toggle(option_name, option) }}
{% elif issubclass(option, Options.DefaultOnToggle) %}
{{ inputs.DefaultOnToggle(option_name, option) }}
{% elif issubclass(option, Options.PlandoBosses) %}
{{ inputs.PlandoBosses(option_name, option) }}
{% elif issubclass(option, Options.TextChoice) %}
{{ inputs.TextChoice(option_name, option) }}
{% elif issubclass(option, Options.Choice) %}
{{ inputs.Choice(option_name, option) }}
{% elif issubclass(option, Options.NamedRange) %}
{{ inputs.NamedRange(option_name, option) }}
{% elif issubclass(option, Options.Range) %}
{{ inputs.Range(option_name, option) }}
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }}
{% else %}
<div class="unsupported-option">
This option is not supported. Please edit your .yaml file manually.
</div>
{% endif %}
</div>
{% endfor %}
</details>
{% endfor %}
</div>
<div id="weighted-options-button-row">
<input type="submit" name="intent-export" value="Export Options" />
<input type="submit" name="intent-generate" value="Generate Single-Player Game">
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,7 +1,7 @@
import datetime
import collections
from dataclasses import dataclass
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, NamedTuple, Counter
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
from uuid import UUID
from flask import render_template
@@ -422,11 +422,11 @@ from worlds import network_data_package
if "Factorio" in network_data_package["games"]:
def render_Factorio_multiworld_tracker(tracker_data: TrackerData, enabled_trackers: List[str]):
inventories: Dict[TeamPlayer, collections.Counter[str]] = {
(team, player): collections.Counter({
inventories: Dict[TeamPlayer, Dict[int, int]] = {
(team, player): {
tracker_data.item_id_to_name["Factorio"][item_id]: count
for item_id, count in tracker_data.get_player_inventory_counts(team, player).items()
}) for team, players in tracker_data.get_all_slots().items() for player in players
} for team, players in tracker_data.get_all_slots().items() for player in players
if tracker_data.get_player_game(team, player) == "Factorio"
}
@@ -456,111 +456,210 @@ if "Factorio" in network_data_package["games"]:
_multiworld_trackers["Factorio"] = render_Factorio_multiworld_tracker
if "A Link to the Past" in network_data_package["games"]:
# Mapping from non-progressive item to progressive name and max level.
non_progressive_items = {
"Fighter Sword": ("Progressive Sword", 1),
"Master Sword": ("Progressive Sword", 2),
"Tempered Sword": ("Progressive Sword", 3),
"Golden Sword": ("Progressive Sword", 4),
"Power Glove": ("Progressive Glove", 1),
"Titans Mitts": ("Progressive Glove", 2),
"Bow": ("Progressive Bow", 1),
"Silver Bow": ("Progressive Bow", 2),
"Blue Mail": ("Progressive Mail", 1),
"Red Mail": ("Progressive Mail", 2),
"Blue Shield": ("Progressive Shield", 1),
"Red Shield": ("Progressive Shield", 2),
"Mirror Shield": ("Progressive Shield", 3),
}
progressive_item_max = {
"Progressive Sword": 4,
"Progressive Glove": 2,
"Progressive Bow": 2,
"Progressive Mail": 2,
"Progressive Shield": 3,
}
bottle_items = [
"Bottle",
"Bottle (Bee)",
"Bottle (Blue Potion)",
"Bottle (Fairy)",
"Bottle (Good Bee)",
"Bottle (Green Potion)",
"Bottle (Red Potion)",
]
known_regions = [
"Light World", "Dark World", "Hyrule Castle", "Agahnims Tower", "Eastern Palace", "Desert Palace",
"Tower of Hera", "Palace of Darkness", "Swamp Palace", "Thieves Town", "Skull Woods", "Ice Palace",
"Misery Mire", "Turtle Rock", "Ganons Tower"
]
class RegionCounts(NamedTuple):
total: int
checked: int
def prepare_inventories(team: int, player: int, inventory: Counter[str], tracker_data: TrackerData):
for item, (prog_item, level) in non_progressive_items.items():
if item in inventory:
inventory[prog_item] = min(max(inventory[prog_item], level), progressive_item_max[prog_item])
for bottle in bottle_items:
inventory["Bottles"] = min(inventory["Bottles"] + inventory[bottle], 4)
if "Progressive Bow (Alt)" in inventory:
inventory["Progressive Bow"] += inventory["Progressive Bow (Alt)"]
inventory["Progressive Bow"] = min(inventory["Progressive Bow"], progressive_item_max["Progressive Bow"])
# Highlight 'bombs' if we received any bomb upgrades in bombless start.
# In race mode, we'll just assume bombless start for simplicity.
if tracker_data.get_slot_data(team, player).get("bombless_start", True):
inventory["Bombs"] = sum(count for item, count in inventory.items() if item.startswith("Bomb Upgrade"))
else:
inventory["Bombs"] = 1
# Triforce item if we meet goal.
if tracker_data.get_room_client_statuses()[team, player] == ClientStatus.CLIENT_GOAL:
inventory["Triforce"] = 1
def render_ALinkToThePast_multiworld_tracker(tracker_data: TrackerData, enabled_trackers: List[str]):
inventories: Dict[Tuple[int, int], Counter[str]] = {
(team, player): collections.Counter({
tracker_data.item_id_to_name["A Link to the Past"][code]: count
for code, count in tracker_data.get_player_inventory_counts(team, player).items()
})
for team, players in tracker_data.get_all_players().items()
for player in players if tracker_data.get_slot_info(team, player).game == "A Link to the Past"
# Helper objects.
alttp_id_lookup = tracker_data.item_name_to_id["A Link to the Past"]
multi_items = {
alttp_id_lookup[name]
for name in ("Progressive Sword", "Progressive Bow", "Bottle", "Progressive Glove", "Triforce Piece")
}
links = {
"Bow": "Progressive Bow",
"Silver Arrows": "Progressive Bow",
"Silver Bow": "Progressive Bow",
"Progressive Bow (Alt)": "Progressive Bow",
"Bottle (Red Potion)": "Bottle",
"Bottle (Green Potion)": "Bottle",
"Bottle (Blue Potion)": "Bottle",
"Bottle (Fairy)": "Bottle",
"Bottle (Bee)": "Bottle",
"Bottle (Good Bee)": "Bottle",
"Fighter Sword": "Progressive Sword",
"Master Sword": "Progressive Sword",
"Tempered Sword": "Progressive Sword",
"Golden Sword": "Progressive Sword",
"Power Glove": "Progressive Glove",
"Titans Mitts": "Progressive Glove",
}
links = {alttp_id_lookup[key]: alttp_id_lookup[value] for key, value in links.items()}
levels = {
"Fighter Sword": 1,
"Master Sword": 2,
"Tempered Sword": 3,
"Golden Sword": 4,
"Power Glove": 1,
"Titans Mitts": 2,
"Bow": 1,
"Silver Bow": 2,
"Triforce Piece": 90,
}
tracking_names = [
"Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer", "Hookshot", "Magic Mirror", "Flute",
"Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang", "Red Boomerang",
"Bug Catching Net", "Cape", "Shovel", "Lamp", "Mushroom", "Magic Powder", "Cane of Somaria",
"Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", "Bottle", "Triforce Piece", "Triforce",
]
default_locations = {
"Light World": {
1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175,
1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884,
1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836,
60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193,
1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328,
59881, 59761, 59890, 59770, 193020, 212605
},
"Dark World": {
59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095,
1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031
},
"Desert Palace": {1573216, 59842, 59851, 59791, 1573201, 59830},
"Eastern Palace": {1573200, 59827, 59893, 59767, 59833, 59773},
"Hyrule Castle": {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253},
"Agahnims Tower": {60082, 60085},
"Tower of Hera": {1573218, 59878, 59821, 1573202, 59896, 59899},
"Swamp Palace": {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061},
"Thieves Town": {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206},
"Skull Woods": {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806},
"Ice Palace": {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869},
"Misery Mire": {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998},
"Turtle Rock": {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935},
"Palace of Darkness": {
59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995,
59965
},
"Ganons Tower": {
60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118,
60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157
},
"Total": set()
}
key_only_locations = {
"Light World": set(),
"Dark World": set(),
"Desert Palace": {0x140031, 0x14002b, 0x140061, 0x140028},
"Eastern Palace": {0x14005b, 0x140049},
"Hyrule Castle": {0x140037, 0x140034, 0x14000d, 0x14003d},
"Agahnims Tower": {0x140061, 0x140052},
"Tower of Hera": set(),
"Swamp Palace": {0x140019, 0x140016, 0x140013, 0x140010, 0x14000a},
"Thieves Town": {0x14005e, 0x14004f},
"Skull Woods": {0x14002e, 0x14001c},
"Ice Palace": {0x140004, 0x140022, 0x140025, 0x140046},
"Misery Mire": {0x140055, 0x14004c, 0x140064},
"Turtle Rock": {0x140058, 0x140007},
"Palace of Darkness": set(),
"Ganons Tower": {0x140040, 0x140043, 0x14003a, 0x14001f},
"Total": set()
}
location_to_area = {}
for area, locations in default_locations.items():
for location in locations:
location_to_area[location] = area
for area, locations in key_only_locations.items():
for location in locations:
location_to_area[location] = area
# Translate non-progression items to progression items for tracker simplicity.
for (team, player), inventory in inventories.items():
prepare_inventories(team, player, inventory, tracker_data)
checks_in_area = {area: len(checks) for area, checks in default_locations.items()}
checks_in_area["Total"] = 216
ordered_areas = (
"Light World", "Dark World", "Hyrule Castle", "Agahnims Tower", "Eastern Palace", "Desert Palace",
"Tower of Hera", "Palace of Darkness", "Swamp Palace", "Skull Woods", "Thieves Town", "Ice Palace",
"Misery Mire", "Turtle Rock", "Ganons Tower", "Total"
)
regions: Dict[Tuple[int, int], Dict[str, RegionCounts]] = {
player_checks_in_area = {
(team, player): {
region_name: RegionCounts(
total=len(tracker_data._multidata["checks_in_area"][player][region_name]),
checked=sum(
1 for location in tracker_data._multidata["checks_in_area"][player][region_name]
if location in tracker_data.get_player_checked_locations(team, player)
),
)
for region_name in known_regions
area_name: len(tracker_data._multidata["checks_in_area"][player][area_name])
if area_name != "Total" else tracker_data._multidata["checks_in_area"][player]["Total"]
for area_name in ordered_areas
}
for team, players in tracker_data.get_all_players().items()
for player in players if tracker_data.get_slot_info(team, player).game == "A Link to the Past"
for team, players in tracker_data.get_all_slots().items()
for player in players
if tracker_data.get_slot_info(team, player).type != SlotType.group and
tracker_data.get_slot_info(team, player).game == "A Link to the Past"
}
# Get a totals count.
for player, player_regions in regions.items():
total = 0
checked = 0
for region, region_counts in player_regions.items():
total += region_counts.total
checked += region_counts.checked
regions[player]["Total"] = RegionCounts(total, checked)
tracking_ids = []
for item in tracking_names:
tracking_ids.append(alttp_id_lookup[item])
# Can't wait to get this into the apworld. Oof.
from worlds.alttp import Items
small_key_ids = {}
big_key_ids = {}
ids_small_key = {}
ids_big_key = {}
for item_name, data in Items.item_table.items():
if "Key" in item_name:
area = item_name.split("(")[1][:-1]
if "Small" in item_name:
small_key_ids[area] = data[2]
ids_small_key[data[2]] = area
else:
big_key_ids[area] = data[2]
ids_big_key[data[2]] = area
def _get_location_table(checks_table: dict) -> dict:
loc_to_area = {}
for area, locations in checks_table.items():
if area == "Total":
continue
for location in locations:
loc_to_area[location] = area
return loc_to_area
player_location_to_area = {
(team, player): _get_location_table(tracker_data._multidata["checks_in_area"][player])
for team, players in tracker_data.get_all_slots().items()
for player in players
if tracker_data.get_slot_info(team, player).type != SlotType.group and
tracker_data.get_slot_info(team, player).game == "A Link to the Past"
}
checks_done: Dict[TeamPlayer, Dict[str: int]] = {
(team, player): {location_name: 0 for location_name in default_locations}
for team, players in tracker_data.get_all_slots().items()
for player in players
if tracker_data.get_slot_info(team, player).type != SlotType.group and
tracker_data.get_slot_info(team, player).game == "A Link to the Past"
}
inventories: Dict[TeamPlayer, Dict[int, int]] = {}
player_big_key_locations = {(player): set() for player in tracker_data.get_all_slots()[0]}
player_small_key_locations = {player: set() for player in tracker_data.get_all_slots()[0]}
group_big_key_locations = set()
group_key_locations = set()
for (team, player), locations in checks_done.items():
# Check if game complete.
if tracker_data.get_player_client_status(team, player) == ClientStatus.CLIENT_GOAL:
inventories[team, player][106] = 1 # Triforce
# Count number of locations checked.
for location in tracker_data.get_player_checked_locations(team, player):
checks_done[team, player][player_location_to_area[team, player][location]] += 1
checks_done[team, player]["Total"] += 1
# Count keys.
for location, (item, receiving, _) in tracker_data.get_player_locations(team, player).items():
if item in ids_big_key:
player_big_key_locations[receiving].add(ids_big_key[item])
elif item in ids_small_key:
player_small_key_locations[receiving].add(ids_small_key[item])
# Iterate over received items and build inventory/key counts.
inventories[team, player] = collections.Counter()
for network_item in tracker_data.get_player_received_items(team, player):
target_item = links.get(network_item.item, network_item.item)
if network_item.item in levels: # non-progressive
inventories[team, player][target_item] = (max(inventories[team, player][target_item], levels[network_item.item]))
else:
inventories[team, player][target_item] += 1
group_key_locations |= player_small_key_locations[player]
group_big_key_locations |= player_big_key_locations[player]
return render_template(
"multitracker__ALinkToThePast.html",
@@ -583,39 +682,209 @@ if "A Link to the Past" in network_data_package["games"]:
item_id_to_name=tracker_data.item_id_to_name,
location_id_to_name=tracker_data.location_id_to_name,
inventories=inventories,
regions=regions,
known_regions=known_regions,
tracking_names=tracking_names,
tracking_ids=tracking_ids,
multi_items=multi_items,
checks_done=checks_done,
ordered_areas=ordered_areas,
checks_in_area=player_checks_in_area,
key_locations=group_key_locations,
big_key_locations=group_big_key_locations,
small_key_ids=small_key_ids,
big_key_ids=big_key_ids,
)
def render_ALinkToThePast_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
inventory = collections.Counter({
tracker_data.item_id_to_name["A Link to the Past"][code]: count
for code, count in tracker_data.get_player_inventory_counts(team, player).items()
})
# Helper objects.
alttp_id_lookup = tracker_data.item_name_to_id["A Link to the Past"]
# Translate non-progression items to progression items for tracker simplicity.
prepare_inventories(team, player, inventory, tracker_data)
links = {
"Bow": "Progressive Bow",
"Silver Arrows": "Progressive Bow",
"Silver Bow": "Progressive Bow",
"Progressive Bow (Alt)": "Progressive Bow",
"Bottle (Red Potion)": "Bottle",
"Bottle (Green Potion)": "Bottle",
"Bottle (Blue Potion)": "Bottle",
"Bottle (Fairy)": "Bottle",
"Bottle (Bee)": "Bottle",
"Bottle (Good Bee)": "Bottle",
"Fighter Sword": "Progressive Sword",
"Master Sword": "Progressive Sword",
"Tempered Sword": "Progressive Sword",
"Golden Sword": "Progressive Sword",
"Power Glove": "Progressive Glove",
"Titans Mitts": "Progressive Glove",
}
links = {alttp_id_lookup[key]: alttp_id_lookup[value] for key, value in links.items()}
levels = {
"Fighter Sword": 1,
"Master Sword": 2,
"Tempered Sword": 3,
"Golden Sword": 4,
"Power Glove": 1,
"Titans Mitts": 2,
"Bow": 1,
"Silver Bow": 2,
"Triforce Piece": 90,
}
tracking_names = [
"Progressive Sword", "Progressive Bow", "Book of Mudora", "Hammer", "Hookshot", "Magic Mirror", "Flute",
"Pegasus Boots", "Progressive Glove", "Flippers", "Moon Pearl", "Blue Boomerang", "Red Boomerang",
"Bug Catching Net", "Cape", "Shovel", "Lamp", "Mushroom", "Magic Powder", "Cane of Somaria",
"Cane of Byrna", "Fire Rod", "Ice Rod", "Bombos", "Ether", "Quake", "Bottle", "Triforce Piece", "Triforce",
]
default_locations = {
"Light World": {
1572864, 1572865, 60034, 1572867, 1572868, 60037, 1572869, 1572866, 60040, 59788, 60046, 60175,
1572880, 60049, 60178, 1572883, 60052, 60181, 1572885, 60055, 60184, 191256, 60058, 60187, 1572884,
1572886, 1572887, 1572906, 60202, 60205, 59824, 166320, 1010170, 60208, 60211, 60214, 60217, 59836,
60220, 60223, 59839, 1573184, 60226, 975299, 1573188, 1573189, 188229, 60229, 60232, 1573193,
1573194, 60235, 1573187, 59845, 59854, 211407, 60238, 59857, 1573185, 1573186, 1572882, 212328,
59881, 59761, 59890, 59770, 193020, 212605
},
"Dark World": {
59776, 59779, 975237, 1572870, 60043, 1572881, 60190, 60193, 60196, 60199, 60840, 1573190, 209095,
1573192, 1573191, 60241, 60244, 60247, 60250, 59884, 59887, 60019, 60022, 60028, 60031
},
"Desert Palace": {1573216, 59842, 59851, 59791, 1573201, 59830},
"Eastern Palace": {1573200, 59827, 59893, 59767, 59833, 59773},
"Hyrule Castle": {60256, 60259, 60169, 60172, 59758, 59764, 60025, 60253},
"Agahnims Tower": {60082, 60085},
"Tower of Hera": {1573218, 59878, 59821, 1573202, 59896, 59899},
"Swamp Palace": {60064, 60067, 60070, 59782, 59785, 60073, 60076, 60079, 1573204, 60061},
"Thieves Town": {59905, 59908, 59911, 59914, 59917, 59920, 59923, 1573206},
"Skull Woods": {59809, 59902, 59848, 59794, 1573205, 59800, 59803, 59806},
"Ice Palace": {59872, 59875, 59812, 59818, 59860, 59797, 1573207, 59869},
"Misery Mire": {60001, 60004, 60007, 60010, 60013, 1573208, 59866, 59998},
"Turtle Rock": {59938, 59941, 59944, 1573209, 59947, 59950, 59953, 59956, 59926, 59929, 59932, 59935},
"Palace of Darkness": {
59968, 59971, 59974, 59977, 59980, 59983, 59986, 1573203, 59989, 59959, 59992, 59962, 59995,
59965
},
"Ganons Tower": {
60160, 60163, 60166, 60088, 60091, 60094, 60097, 60100, 60103, 60106, 60109, 60112, 60115, 60118,
60121, 60124, 60127, 1573217, 60130, 60133, 60136, 60139, 60142, 60145, 60148, 60151, 60157
},
"Total": set()
}
key_only_locations = {
"Light World": set(),
"Dark World": set(),
"Desert Palace": {0x140031, 0x14002b, 0x140061, 0x140028},
"Eastern Palace": {0x14005b, 0x140049},
"Hyrule Castle": {0x140037, 0x140034, 0x14000d, 0x14003d},
"Agahnims Tower": {0x140061, 0x140052},
"Tower of Hera": set(),
"Swamp Palace": {0x140019, 0x140016, 0x140013, 0x140010, 0x14000a},
"Thieves Town": {0x14005e, 0x14004f},
"Skull Woods": {0x14002e, 0x14001c},
"Ice Palace": {0x140004, 0x140022, 0x140025, 0x140046},
"Misery Mire": {0x140055, 0x14004c, 0x140064},
"Turtle Rock": {0x140058, 0x140007},
"Palace of Darkness": set(),
"Ganons Tower": {0x140040, 0x140043, 0x14003a, 0x14001f},
"Total": set()
}
location_to_area = {}
for area, locations in default_locations.items():
for checked_location in locations:
location_to_area[checked_location] = area
for area, locations in key_only_locations.items():
for checked_location in locations:
location_to_area[checked_location] = area
regions = {
region_name: {
"checked": sum(
1 for location in tracker_data._multidata["checks_in_area"][player][region_name]
if location in tracker_data.get_player_checked_locations(team, player)
),
"locations": [
(
tracker_data.location_id_to_name["A Link to the Past"][location],
location in tracker_data.get_player_checked_locations(team, player)
)
for location in tracker_data._multidata["checks_in_area"][player][region_name]
],
}
for region_name in known_regions
checks_in_area = {area: len(checks) for area, checks in default_locations.items()}
checks_in_area["Total"] = 216
ordered_areas = (
"Light World", "Dark World", "Hyrule Castle", "Agahnims Tower", "Eastern Palace", "Desert Palace",
"Tower of Hera", "Palace of Darkness", "Swamp Palace", "Skull Woods", "Thieves Town", "Ice Palace",
"Misery Mire", "Turtle Rock", "Ganons Tower", "Total"
)
tracking_ids = []
for item in tracking_names:
tracking_ids.append(alttp_id_lookup[item])
# Can't wait to get this into the apworld. Oof.
from worlds.alttp import Items
small_key_ids = {}
big_key_ids = {}
ids_small_key = {}
ids_big_key = {}
for item_name, data in Items.item_table.items():
if "Key" in item_name:
area = item_name.split("(")[1][:-1]
if "Small" in item_name:
small_key_ids[area] = data[2]
ids_small_key[data[2]] = area
else:
big_key_ids[area] = data[2]
ids_big_key[data[2]] = area
inventory = collections.Counter()
checks_done = {loc_name: 0 for loc_name in default_locations}
player_big_key_locations = set()
player_small_key_locations = set()
player_locations = tracker_data.get_player_locations(team, player)
for checked_location in tracker_data.get_player_checked_locations(team, player):
if checked_location in player_locations:
area_name = location_to_area.get(checked_location, None)
if area_name:
checks_done[area_name] += 1
checks_done["Total"] += 1
for received_item in tracker_data.get_player_received_items(team, player):
target_item = links.get(received_item.item, received_item.item)
if received_item.item in levels: # non-progressive
inventory[target_item] = max(inventory[target_item], levels[received_item.item])
else:
inventory[target_item] += 1
for location, (item_id, _, _) in player_locations.items():
if item_id in ids_big_key:
player_big_key_locations.add(ids_big_key[item_id])
elif item_id in ids_small_key:
player_small_key_locations.add(ids_small_key[item_id])
# Note the presence of the triforce item
if tracker_data.get_player_client_status(team, player) == ClientStatus.CLIENT_GOAL:
inventory[106] = 1 # Triforce
# Progressive items need special handling for icons and class
progressive_items = {
"Progressive Sword": 94,
"Progressive Glove": 97,
"Progressive Bow": 100,
"Progressive Mail": 96,
"Progressive Shield": 95,
}
progressive_names = {
"Progressive Sword": [None, "Fighter Sword", "Master Sword", "Tempered Sword", "Golden Sword"],
"Progressive Glove": [None, "Power Glove", "Titan Mitts"],
"Progressive Bow": [None, "Bow", "Silver Bow"],
"Progressive Mail": ["Green Mail", "Blue Mail", "Red Mail"],
"Progressive Shield": [None, "Blue Shield", "Red Shield", "Mirror Shield"]
}
# Sort locations in regions by name
for region in regions:
regions[region]["locations"].sort()
# Determine which icon to use
display_data = {}
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
acquired = True
if not display_name:
acquired = False
display_name = progressive_names[item_name][level + 1]
base_name = item_name.split(maxsplit=1)[1].lower()
display_data[base_name + "_acquired"] = acquired
display_data[base_name + "_icon"] = display_name
# The single player tracker doesn't care about overworld, underworld, and total checks. Maybe it should?
sp_areas = ordered_areas[2:15]
return render_template(
template_name_or_list="tracker__ALinkToThePast.html",
@@ -624,8 +893,15 @@ if "A Link to the Past" in network_data_package["games"]:
player=player,
inventory=inventory,
player_name=tracker_data.get_player_name(team, player),
regions=regions,
known_regions=known_regions,
checks_done=checks_done,
checks_in_area=checks_in_area,
acquired_items={tracker_data.item_id_to_name["A Link to the Past"][id] for id in inventory},
sp_areas=sp_areas,
small_key_ids=small_key_ids,
key_locations=player_small_key_locations,
big_key_ids=big_key_ids,
big_key_locations=player_big_key_locations,
**display_data,
)
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker

View File

@@ -63,13 +63,12 @@ def process_multidata(compressed_multidata, files={}):
game_data = games_package_schema.validate(game_data)
game_data = {key: value for key, value in sorted(game_data.items())}
game_data["checksum"] = data_package_checksum(game_data)
game_data_package = GameDataPackage(checksum=game_data["checksum"],
data=pickle.dumps(game_data))
if original_checksum != game_data["checksum"]:
raise Exception(f"Original checksum {original_checksum} != "
f"calculated checksum {game_data['checksum']} "
f"for game {game}.")
game_data_package = GameDataPackage(checksum=game_data["checksum"],
data=pickle.dumps(game_data))
decompressed_multidata["datapackage"][game] = {
"version": game_data.get("version", 0),
"checksum": game_data["checksum"],
@@ -193,8 +192,6 @@ def uploads():
res = upload_zip_to_db(zfile)
except VersionException:
flash(f"Could not load multidata. Wrong Version detected.")
except Exception as e:
flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})")
else:
if res is str:
return res

View File

@@ -27,9 +27,14 @@ local mmbn3Socket = nil
local frame = 0
-- States
local ITEMSTATE_NONINITIALIZED = "Game Not Yet Started" -- Game has not yet started
local ITEMSTATE_NONITEM = "Non-Itemable State" -- Do not send item now. RAM is not capable of holding
local ITEMSTATE_IDLE = "Item State Ready" -- Ready for the next item if there are any
local itemState = ITEMSTATE_NONITEM
local ITEMSTATE_SENT = "Item Sent Not Claimed" -- The ItemBit is set, but the dialog has not been closed yet
local itemState = ITEMSTATE_NONINITIALIZED
local itemQueued = nil
local itemQueueCounter = 120
local debugEnabled = false
local game_complete = false
@@ -99,24 +104,21 @@ end
local IsInBattle = function()
return memory.read_u8(0x020097F8) == 0x08
end
local IsItemQueued = function()
return memory.read_u8(0x2000224) == 0x01
end
-- This function actually determines when you're on ANY full-screen menu (navi cust, link battle, etc.) but we
-- don't want to check any locations there either so it's fine.
local IsOnTitle = function()
return bit.band(memory.read_u8(0x020097F8),0x04) == 0
end
local IsItemable = function()
return not IsInMenu() and not IsInTransition() and not IsInDialog() and not IsInBattle() and not IsOnTitle()
return not IsInMenu() and not IsInTransition() and not IsInDialog() and not IsInBattle() and not IsOnTitle() and not IsItemQueued()
end
local is_game_complete = function()
-- If the Cannary Byte is 0xFF, then the save RAM is untrustworthy
if memory.read_u8(canary_byte) == 0xFF then
return game_complete
end
-- If on the title screen don't read RAM, RAM can't be trusted yet
if IsOnTitle() then return game_complete end
if IsOnTitle() or itemState == ITEMSTATE_NONINITIALIZED then return game_complete end
-- If the game is already marked complete, do not read memory
if game_complete then return true end
@@ -175,6 +177,14 @@ local Check_Progressive_Undernet_ID = function()
end
return 9
end
local GenerateTextBytes = function(message)
bytes = {}
for i = 1, #message do
local c = message:sub(i,i)
table.insert(bytes, charDict[c])
end
return bytes
end
-- Item Message Generation functions
local Next_Progressive_Undernet_ID = function(index)
@@ -186,6 +196,150 @@ local Next_Progressive_Undernet_ID = function(index)
item_index=ordered_IDs[index]
return item_index
end
local Extra_Progressive_Undernet = function()
fragBytes = int32ToByteList_le(20)
bytes = {
0xF6, 0x50, fragBytes[1], fragBytes[2], fragBytes[3], fragBytes[4], 0xFF, 0xFF, 0xFF
}
bytes = TableConcat(bytes, GenerateTextBytes("The extra data\ndecompiles into:\n\"20 BugFrags\"!!"))
return bytes
end
local GenerateChipGet = function(chip, code, amt)
chipBytes = int16ToByteList_le(chip)
bytes = {
0xF6, 0x10, chipBytes[1], chipBytes[2], code, amt,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict[' '], charDict['c'], charDict['h'], charDict['i'], charDict['p'], charDict[' '], charDict['f'], charDict['o'], charDict['r'], charDict['\n'],
}
if chip < 256 then
bytes = TableConcat(bytes, {
charDict['\"'], 0xF9,0x00,chipBytes[1],0x01,0x00,0xF9,0x00,code,0x03, charDict['\"'],charDict['!'],charDict['!']
})
else
bytes = TableConcat(bytes, {
charDict['\"'], 0xF9,0x00,chipBytes[1],0x02,0x00,0xF9,0x00,code,0x03, charDict['\"'],charDict['!'],charDict['!']
})
end
return bytes
end
local GenerateKeyItemGet = function(item, amt)
bytes = {
0xF6, 0x00, item, amt,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'],
charDict['\"'], 0xF9, 0x00, item, 0x00, charDict['\"'],charDict['!'],charDict['!']
}
return bytes
end
local GenerateSubChipGet = function(subchip, amt)
-- SubChips have an extra bit of trouble. If you have too many, they're supposed to skip to another text bank that doesn't give you the item
-- Instead, I'm going to just let it get eaten
bytes = {
0xF6, 0x20, subchip, amt, 0xFF, 0xFF, 0xFF,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'],
charDict['S'], charDict['u'], charDict['b'], charDict['C'], charDict['h'], charDict['i'], charDict['p'], charDict[' '], charDict['f'], charDict['o'], charDict['r'], charDict['\n'],
charDict['\"'], 0xF9, 0x00, subchip, 0x00, charDict['\"'],charDict['!'],charDict['!']
}
return bytes
end
local GenerateZennyGet = function(amt)
zennyBytes = int32ToByteList_le(amt)
bytes = {
0xF6, 0x30, zennyBytes[1], zennyBytes[2], zennyBytes[3], zennyBytes[4], 0xFF, 0xFF, 0xFF,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict['\n'], charDict['\"']
}
-- The text needs to be added one char at a time, so we need to convert the number to a string then iterate through it
zennyStr = tostring(amt)
for i = 1, #zennyStr do
local c = zennyStr:sub(i,i)
table.insert(bytes, charDict[c])
end
bytes = TableConcat(bytes, {
charDict[' '], charDict['Z'], charDict['e'], charDict['n'], charDict['n'], charDict['y'], charDict['s'], charDict['\"'],charDict['!'],charDict['!']
})
return bytes
end
local GenerateProgramGet = function(program, color, amt)
bytes = {
0xF6, 0x40, (program * 4), amt, color,
charDict['G'], charDict['o'], charDict['t'], charDict[' '], charDict['a'], charDict[' '], charDict['N'], charDict['a'], charDict['v'], charDict['i'], charDict['\n'],
charDict['C'], charDict['u'], charDict['s'], charDict['t'], charDict['o'], charDict['m'], charDict['i'], charDict['z'], charDict['e'], charDict['r'], charDict[' '], charDict['P'], charDict['r'], charDict['o'], charDict['g'], charDict['r'], charDict['a'], charDict['m'], charDict[':'], charDict['\n'],
charDict['\"'], 0xF9, 0x00, program, 0x05, charDict['\"'],charDict['!'],charDict['!']
}
return bytes
end
local GenerateBugfragGet = function(amt)
fragBytes = int32ToByteList_le(amt)
bytes = {
0xF6, 0x50, fragBytes[1], fragBytes[2], fragBytes[3], fragBytes[4], 0xFF, 0xFF, 0xFF,
charDict['G'], charDict['o'], charDict['t'], charDict[':'], charDict['\n'], charDict['\"']
}
-- The text needs to be added one char at a time, so we need to convert the number to a string then iterate through it
bugFragStr = tostring(amt)
for i = 1, #bugFragStr do
local c = bugFragStr:sub(i,i)
table.insert(bytes, charDict[c])
end
bytes = TableConcat(bytes, {
charDict[' '], charDict['B'], charDict['u'], charDict['g'], charDict['F'], charDict['r'], charDict['a'], charDict['g'], charDict['s'], charDict['\"'],charDict['!'],charDict['!']
})
return bytes
end
local GenerateGetMessageFromItem = function(item)
--Special case for progressive undernet
if item["type"] == "undernet" then
undernet_id = Check_Progressive_Undernet_ID()
if undernet_id > 8 then
return Extra_Progressive_Undernet()
end
return GenerateKeyItemGet(Next_Progressive_Undernet_ID(undernet_id),1)
elseif item["type"] == "chip" then
return GenerateChipGet(item["itemID"], item["subItemID"], item["count"])
elseif item["type"] == "key" then
return GenerateKeyItemGet(item["itemID"], item["count"])
elseif item["type"] == "subchip" then
return GenerateSubChipGet(item["itemID"], item["count"])
elseif item["type"] == "zenny" then
return GenerateZennyGet(item["count"])
elseif item["type"] == "program" then
return GenerateProgramGet(item["itemID"], item["subItemID"], item["count"])
elseif item["type"] == "bugfrag" then
return GenerateBugfragGet(item["count"])
end
return GenerateTextBytes("Empty Message")
end
local GetMessage = function(item)
startBytes = {0x02, 0x00}
playerLockBytes = {0xF8,0x00, 0xF8, 0x10}
msgOpenBytes = {0xF1, 0x02}
textBytes = GenerateTextBytes("Receiving\ndata from\n"..item["sender"]..".")
dotdotWaitBytes = {0xEA,0x00,0x0A,0x00,0x4D,0xEA,0x00,0x0A,0x00,0x4D}
continueBytes = {0xEB, 0xE9}
-- continueBytes = {0xE9}
playReceiveAnimationBytes = {0xF8,0x04,0x18}
chipGiveBytes = GenerateGetMessageFromItem(item)
playerFinishBytes = {0xF8, 0x0C}
playerUnlockBytes={0xEB, 0xF8, 0x08}
-- playerUnlockBytes={0xF8, 0x08}
endMessageBytes = {0xF8, 0x10, 0xE7}
bytes = {}
bytes = TableConcat(bytes,startBytes)
bytes = TableConcat(bytes,playerLockBytes)
bytes = TableConcat(bytes,msgOpenBytes)
bytes = TableConcat(bytes,textBytes)
bytes = TableConcat(bytes,dotdotWaitBytes)
bytes = TableConcat(bytes,continueBytes)
bytes = TableConcat(bytes,playReceiveAnimationBytes)
bytes = TableConcat(bytes,chipGiveBytes)
bytes = TableConcat(bytes,playerFinishBytes)
bytes = TableConcat(bytes,playerUnlockBytes)
bytes = TableConcat(bytes,endMessageBytes)
return bytes
end
local getChipCodeIndex = function(chip_id, chip_code)
chipCodeArrayStartAddress = 0x8011510 + (0x20 * chip_id)
@@ -199,10 +353,6 @@ local getChipCodeIndex = function(chip_id, chip_code)
end
local getProgramColorIndex = function(program_id, program_color)
-- For whatever reason, OilBody (ID 24) does not follow the rules and should be color index 3
if program_id == 24 then
return 3
end
-- The general case, most programs use white pink or yellow. This is the values the enums already have
if program_id >= 20 and program_id <= 47 then
return program_color-1
@@ -251,11 +401,11 @@ local changeZenny = function(val)
return 0
end
if memory.read_u32_le(0x20018F4) <= math.abs(tonumber(val)) and tonumber(val) < 0 then
memory.write_u32_le(0x20018F4, 0)
memory.write_u32_le(0x20018f4, 0)
val = 0
return "empty"
end
memory.write_u32_le(0x20018F4, memory.read_u32_le(0x20018F4) + tonumber(val))
memory.write_u32_le(0x20018f4, memory.read_u32_le(0x20018F4) + tonumber(val))
if memory.read_u32_le(0x20018F4) > 999999 then
memory.write_u32_le(0x20018F4, 999999)
end
@@ -267,17 +417,30 @@ local changeFrags = function(val)
return 0
end
if memory.read_u16_le(0x20018F8) <= math.abs(tonumber(val)) and tonumber(val) < 0 then
memory.write_u16_le(0x20018F8, 0)
memory.write_u16_le(0x20018f8, 0)
val = 0
return "empty"
end
memory.write_u16_le(0x20018F8, memory.read_u16_le(0x20018F8) + tonumber(val))
memory.write_u16_le(0x20018f8, memory.read_u16_le(0x20018F8) + tonumber(val))
if memory.read_u16_le(0x20018F8) > 9999 then
memory.write_u16_le(0x20018F8, 9999)
end
return val
end
-- Fix Health Pools
local fix_hp = function()
-- Current Health fix
if IsInBattle() and not (memory.read_u16_le(0x20018A0) == memory.read_u16_le(0x2037294)) then
memory.write_u16_le(0x20018A0, memory.read_u16_le(0x2037294))
end
-- Max Health Fix
if IsInBattle() and not (memory.read_u16_le(0x20018A2) == memory.read_u16_le(0x2037296)) then
memory.write_u16_le(0x20018A2, memory.read_u16_le(0x2037296))
end
end
local changeRegMemory = function(amt)
regMemoryAddress = 0x02001897
currentRegMem = memory.read_u8(regMemoryAddress)
@@ -285,18 +448,34 @@ local changeRegMemory = function(amt)
end
local changeMaxHealth = function(val)
if val == nil then
fix_hp()
if val == nil then
fix_hp()
return 0
end
if math.abs(tonumber(val)) >= memory.read_u16_le(0x20018A2) and tonumber(val) < 0 then
memory.write_u16_le(0x20018A2, 0)
if IsInBattle() then
memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2))
if memory.read_u16_le(0x2037296) >= memory.read_u16_le(0x20018A2) then
memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2))
end
end
fix_hp()
return "lethal"
end
memory.write_u16_le(0x20018A2, memory.read_u16_le(0x20018A2) + tonumber(val))
if memory.read_u16_le(0x20018A2) > 9999 then
memory.write_u16_le(0x20018A2, 9999)
end
if IsInBattle() then
memory.write_u16_le(0x2037296, memory.read_u16_le(0x20018A2))
end
fix_hp()
return val
end
local SendItemToGame = function(item)
local SendItem = function(item)
if item["type"] == "undernet" then
undernet_id = Check_Progressive_Undernet_ID()
if undernet_id > 8 then
@@ -374,6 +553,13 @@ local OpenShortcuts = function()
end
end
local RestoreItemRam = function()
if backup_bytes ~= nil then
memory.write_bytes_as_array(0x203fe10, backup_bytes)
end
backup_bytes = nil
end
local process_block = function(block)
-- Sometimes the block is nothing, if this is the case then quietly stop processing
if block == nil then
@@ -388,7 +574,14 @@ local process_block = function(block)
end
local itemStateMachineProcess = function()
if itemState == ITEMSTATE_NONITEM then
if itemState == ITEMSTATE_NONINITIALIZED then
itemQueueCounter = 120
-- Only exit this state the first time a dialog window pops up. This way we know for sure that we're ready to receive
if not IsInMenu() and (IsInDialog() or IsInTransition()) then
itemState = ITEMSTATE_NONITEM
end
elseif itemState == ITEMSTATE_NONITEM then
itemQueueCounter = 120
-- Always attempt to restore the previously stored memory in this state
-- Exit this state whenever the game is in an itemable status
if IsItemable() then
@@ -399,11 +592,26 @@ local itemStateMachineProcess = function()
if not IsItemable() then
itemState = ITEMSTATE_NONITEM
end
if #itemsReceived > loadItemIndexFromRAM() then
itemQueued = itemsReceived[loadItemIndexFromRAM()+1]
SendItemToGame(itemQueued)
saveItemIndexToRAM(itemQueued["itemIndex"])
if itemQueueCounter == 0 then
if #itemsReceived > loadItemIndexFromRAM() and not IsItemQueued() then
itemQueued = itemsReceived[loadItemIndexFromRAM()+1]
SendItem(itemQueued)
itemState = ITEMSTATE_SENT
end
else
itemQueueCounter = itemQueueCounter - 1
end
elseif itemState == ITEMSTATE_SENT then
-- Once the item is sent, wait for the dialog to close. Then clear the item bit and be ready for the next item.
if IsInTransition() or IsInMenu() or IsOnTitle() then
itemState = ITEMSTATE_NONITEM
itemQueued = nil
RestoreItemRam()
elseif not IsInDialog() then
itemState = ITEMSTATE_IDLE
saveItemIndexToRAM(itemQueued["itemIndex"])
itemQueued = nil
RestoreItemRam()
end
end
end
@@ -494,8 +702,18 @@ function main()
-- Handle the debug data display
gui.cleartext()
if debugEnabled then
gui.text(0,0,itemState)
gui.text(0,16,"Item Index: "..loadItemIndexFromRAM())
-- gui.text(0,0,"Item Queued: "..tostring(IsItemQueued()))
-- gui.text(0,16,"In Battle: "..tostring(IsInBattle()))
-- gui.text(0,32,"In Dialog: "..tostring(IsInDialog()))
-- gui.text(0,48,"In Menu: "..tostring(IsInMenu()))
gui.text(0,48,"Item Wait Time: "..tostring(itemQueueCounter))
gui.text(0,64,itemState)
if itemQueued == nil then
gui.text(0,80,"No item queued")
else
gui.text(0,80,itemQueued["type"].." "..itemQueued["itemID"])
end
gui.text(0,96,"Item Index: "..loadItemIndexFromRAM())
end
emu.frameadvance()

View File

@@ -45,10 +45,7 @@ requires:
{% endmacro %}
{{ game }}:
{%- for group_name, group_options in option_groups.items() %}
# {{ group_name }}
{%- for option_key, option in group_options.items() %}
{%- for option_key, option in options.items() %}
{{ option_key }}:
{%- if option.__doc__ %}
# {{ option.__doc__
@@ -86,4 +83,3 @@ requires:
{%- endif -%}
{{ "\n" }}
{%- endfor %}
{%- endfor %}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -13,15 +13,9 @@
# Adventure
/worlds/adventure/ @JusticePS
# A Hat in Time
/worlds/ahit/ @CookieCat45
# A Link to the Past
/worlds/alttp/ @Berserker66
# Aquaria
/worlds/aquaria/ @tioui
# ArchipIDLE
/worlds/archipidle/ @LegendaryLinux
@@ -31,9 +25,6 @@
# Blasphemous
/worlds/blasphemous/ @TRPG0
# Bomb Rush Cyberfunk
/worlds/bomb_rush_cyberfunk/ @TRPG0
# Bumper Stickers
/worlds/bumpstik/ @FelicitusNeko
@@ -44,7 +35,7 @@
/worlds/celeste64/ @PoryGone
# ChecksFinder
/worlds/checksfinder/ @SunCatMC
/worlds/checksfinder/ @jonloveslegos
# Clique
/worlds/clique/ @ThePhar
@@ -101,9 +92,6 @@
/worlds/lufia2ac/ @el-u
/worlds/lufia2ac/docs/ @wordfcuk @el-u
# Mario & Luigi: Superstar Saga
/worlds/mlss/ @jamesbrq
# Meritous
/worlds/meritous/ @FelicitusNeko
@@ -206,9 +194,6 @@
# Yoshi's Island
/worlds/yoshisisland/ @PinkSwitch
#Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
/worlds/yugioh06/ @Rensen3
# Zillion
/worlds/zillion/ @beauxq

View File

@@ -85,25 +85,6 @@ class ExampleWorld(World):
options: ExampleGameOptions
```
### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the
player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options"
group.
```python
from worlds.AutoWorld import WebWorld
from Options import OptionGroup
class MyWorldWeb(WebWorld):
option_groups = [
OptionGroup('Color Options', [
Options.ColorblindMode,
Options.FlashReduction,
Options.UIColors,
]),
]
```
### Option Checking
Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after
world instantiation. These are created as attributes on the MultiWorld and can be accessed with
@@ -174,12 +155,10 @@ Gives the player starting hints for where the items defined here are.
Gives the player starting hints for the items on locations defined here.
### ExcludeLocations
Marks locations given here as `LocationProgressType.Excluded` so that neither progression nor useful items can be
placed on them.
Marks locations given here as `LocationProgressType.Excluded` so that progression items can't be placed on them.
### PriorityLocations
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
the pool.
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them.
### ItemLinks
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between

View File

@@ -17,14 +17,13 @@ Then run any of the starting point scripts, like Generate.py, and the included M
required modules and after pressing enter proceed to install everything automatically.
After this, you should be able to run the programs.
* `Launcher.py` gives access to many components, including clients registered in `worlds/LauncherComponents.py`.
* The Launcher button "Generate Template Options" will generate default yamls for all worlds.
* With yaml(s) in the `Players` folder, `Generate.py` will generate the multiworld archive.
* `MultiServer.py`, with the filename of the generated archive as a command line parameter, will host the multiworld locally.
* `--log_network` is a command line parameter useful for debugging.
* `WebHost.py` will host the website on your computer.
* You can copy `docs/webhost configuration sample.yaml` to `config.yaml`
to change WebHost options (like the web hosting port number).
* As a side effect, `WebHost.py` creates the template yamls for all the games in `WebHostLib/static/generated`.
## Windows

View File

@@ -17,15 +17,6 @@
* Use type annotations where possible for function signatures and class members.
* Use type annotations where appropriate for local variables (e.g. `var: List[int] = []`, or when the
type is hard or impossible to deduce.) Clear annotations help developers look up and validate API calls.
* If a line ends with an open bracket/brace/parentheses, the matching closing bracket should be at the
beginning of a line at the same indentation as the beginning of the line with the open bracket.
```python
stuff = {
x: y
for x, y in thing
if y > 2
}
```
* New classes, attributes, and methods in core code should have docstrings that follow
[reST style](https://peps.python.org/pep-0287/).
* Worlds that do not follow PEP8 should still have a consistent style across its files to make reading easier.

View File

@@ -1,4 +1,4 @@
# This is a sample configuration for the Web host.
# This is a sample configuration for the Web host.
# If you wish to change any of these, rename this file to config.yaml
# Default values are shown here. Uncomment and change the values as desired.
@@ -25,7 +25,7 @@
# Secret key used to determine important things like cookie authentication of room/seed page ownership.
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
# SECRET_KEY: "Your secret key here"
# SECRET_KEY: "Your secret key here"
# TODO
#JOB_THRESHOLD: 2
@@ -38,16 +38,15 @@
# provider: "sqlite"
# filename: "ap.db3" # This MUST be the ABSOLUTE PATH to the file.
# create_db: true
# Maximum number of players that are allowed to be rolled on the server. After this limit, one should roll locally and upload the results.
#MAX_ROLL: 20
# TODO
#CACHE_TYPE: "simple"
# TODO
#JSON_AS_ASCII: false
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
#HOST_ADDRESS: archipelago.gg
# Asset redistribution rights. If true, the host affirms they have been given explicit permission to redistribute
# the proprietary assets in WebHostLib
#ASSET_RIGHTS: false

View File

@@ -121,53 +121,6 @@ class RLWeb(WebWorld):
# ...
```
* `location_descriptions` (optional) WebWorlds can provide a map that contains human-friendly descriptions of locations
or location groups.
```python
# locations.py
location_descriptions = {
"Red Potion #6": "In a secret destructible block under the second stairway",
"L2 Spaceship": """
The group of all items in the spaceship in Level 2.
This doesn't include the item on the spaceship door, since it can be
accessed without the Spaceship Key.
"""
}
# __init__.py
from worlds.AutoWorld import WebWorld
from .locations import location_descriptions
class MyGameWeb(WebWorld):
location_descriptions = location_descriptions
```
* `item_descriptions` (optional) WebWorlds can provide a map that contains human-friendly descriptions of items or item
groups.
```python
# items.py
item_descriptions = {
"Red Potion": "A standard health potion",
"Spaceship Key": """
The key to the spaceship in Level 2.
This is necessary to get to the Star Realm.
""",
}
# __init__.py
from worlds.AutoWorld import WebWorld
from .items import item_descriptions
class MyGameWeb(WebWorld):
item_descriptions = item_descriptions
```
### MultiWorld Object
The `MultiWorld` object references the whole multiworld (all items and locations for all players) and is accessible
@@ -225,6 +178,37 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
required, and will prevent progression and useful items from being placed at excluded locations.
#### Documenting Locations
Worlds can optionally provide a `location_descriptions` map which contains human-friendly descriptions of locations and
location groups. These descriptions will show up in location-selection options on the Weighted Options page. Extra
indentation and single newlines will be collapsed into spaces.
```python
# locations.py
location_descriptions = {
"Red Potion #6": "In a secret destructible block under the second stairway",
"L2 Spaceship":
"""
The group of all items in the spaceship in Level 2.
This doesn't include the item on the spaceship door, since it can be accessed without the Spaceship Key.
"""
}
```
```python
# __init__.py
from worlds.AutoWorld import World
from .locations import location_descriptions
class MyGameWorld(World):
location_descriptions = location_descriptions
```
### Items
Items are all things that can "drop" for your game. This may be RPG items like weapons, or technologies you normally
@@ -249,6 +233,37 @@ Other classifications include:
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
#### Documenting Items
Worlds can optionally provide an `item_descriptions` map which contains human-friendly descriptions of items and item
groups. These descriptions will show up in item-selection options on the Weighted Options page. Extra indentation and
single newlines will be collapsed into spaces.
```python
# items.py
item_descriptions = {
"Red Potion": "A standard health potion",
"Spaceship Key":
"""
The key to the spaceship in Level 2.
This is necessary to get to the Star Realm.
"""
}
```
```python
# __init__.py
from worlds.AutoWorld import World
from .items import item_descriptions
class MyGameWorld(World):
item_descriptions = item_descriptions
```
### Events
An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
@@ -365,6 +380,11 @@ from BaseClasses import Location
class MyGameLocation(Location):
game: str = "My Game"
# override constructor to automatically mark event locations as such
def __init__(self, player: int, name="", code=None, parent=None) -> None:
super(MyGameLocation, self).__init__(player, name, code, parent)
self.event = code is None
```
in your `__init__.py` or your `locations.py`.

View File

@@ -169,11 +169,6 @@ Root: HKCR; Subkey: "{#MyAppName}pkmnepatch"; ValueData: "Ar
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmlss"; ValueData: "{#MyAppName}mlsspatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mlsspatch"; ValueData: "Archipelago Mario & Luigi Superstar Saga Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mlsspatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mlsspatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apcv64"; ValueData: "{#MyAppName}cv64patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Archipelago Castlevania 64 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
@@ -199,11 +194,6 @@ Root: HKCR; Subkey: "{#MyAppName}yipatch"; ValueData: "Archi
Root: HKCR; Subkey: "{#MyAppName}yipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}yipatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apygo06"; ValueData: "{#MyAppName}ygo06patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ygo06patch"; ValueData: "Archipelago Yu-Gi-Oh 2006 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";

18
kvui.py
View File

@@ -740,17 +740,15 @@ class KivyJSONtoTextParser(JSONtoTextParser):
def _handle_item_name(self, node: JSONMessagePart):
flags = node.get("flags", 0)
item_types = []
if flags & 0b001: # advancement
item_types.append("progression")
if flags & 0b010: # useful
item_types.append("useful")
if flags & 0b100: # trap
item_types.append("trap")
if not item_types:
item_types.append("normal")
node.setdefault("refs", []).append("Item Class: " + ", ".join(item_types))
itemtype = "progression"
elif flags & 0b010: # useful
itemtype = "useful"
elif flags & 0b100: # trap
itemtype = "trap"
else:
itemtype = "normal"
node.setdefault("refs", []).append("Item Class: " + itemtype)
return super(KivyJSONtoTextParser, self)._handle_item_name(node)
def _handle_player_id(self, node: JSONMessagePart):

View File

@@ -200,7 +200,7 @@ class Group:
def _dump_value(cls, value: Any, f: TextIO, indent: str) -> None:
"""Write a single yaml line to f"""
from Utils import dump, Dumper as BaseDumper
yaml_line: str = dump(value, Dumper=cast(BaseDumper, cls._dumper), width=2**31-1)
yaml_line: str = dump(value, Dumper=cast(BaseDumper, cls._dumper))
assert yaml_line.count("\n") == 1, f"Unexpected input for yaml dumper: {value}"
f.write(f"{indent}{yaml_line}")
@@ -665,23 +665,15 @@ class GeneratorOptions(Group):
OFF = 0
ON = 1
class PanicMethod(str):
"""
What to do if the current item placements appear unsolvable.
raise -> Raise an exception and abort.
swap -> Attempt to fix it by swapping prior placements around. (Default)
start_inventory -> Move remaining items to start_inventory, generate additional filler items to fill locations.
"""
enemizer_path: EnemizerPath = EnemizerPath("EnemizerCLI/EnemizerCLI.Core") # + ".exe" is implied on Windows
player_files_path: PlayerFilesPath = PlayerFilesPath("Players")
players: Players = Players(0)
weights_file_path: WeightsFilePath = WeightsFilePath("weights.yaml")
meta_file_path: MetaFilePath = MetaFilePath("meta.yaml")
spoiler: Spoiler = Spoiler(3)
glitch_triforce_room: GlitchTriforceRoom = GlitchTriforceRoom(1) # why is this here?
race: Race = Race(0)
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
panic_method: PanicMethod = PanicMethod("swap")
class SNIOptions(Group):

View File

@@ -21,7 +21,7 @@ from pathlib import Path
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
try:
requirement = 'cx-Freeze>=7.0.0'
requirement = 'cx-Freeze>=6.15.10'
import pkg_resources
try:
pkg_resources.require(requirement)
@@ -228,8 +228,8 @@ class BuildCommand(setuptools.command.build.build):
# Override cx_Freeze's build_exe command for pre and post build steps
class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
user_options = cx_Freeze.command.build_exe.build_exe.user_options + [
class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
user_options = cx_Freeze.command.build_exe.BuildEXE.user_options + [
('yes', 'y', 'Answer "yes" to all questions.'),
('extra-data=', None, 'Additional files to add.'),
]

View File

@@ -221,7 +221,7 @@ class WorldTestBase(unittest.TestCase):
if isinstance(items, Item):
items = (items,)
for item in items:
if item.location and item.advancement and item.location in self.multiworld.state.events:
if item.location and item.location.event and item.location in self.multiworld.state.events:
self.multiworld.state.events.remove(item.location)
self.multiworld.state.remove(item)

View File

@@ -1,7 +1,7 @@
from argparse import Namespace
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
@@ -17,21 +17,19 @@ def setup_solo_multiworld(
:param steps: The gen steps that should be called on the generated multiworld before returning. Default calls
steps through pre_fill
:param seed: The seed to be used when creating this multiworld
:return: The generated multiworld
"""
return setup_multiworld(world_type, steps, seed)
def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple[str, ...] = gen_steps,
seed: Optional[int] = None) -> MultiWorld:
seed: Optional[int] = None) -> MultiWorld:
"""
Creates a multiworld with a player for each provided world type, allowing duplicates, setting default options, and
calling the provided gen steps.
:param worlds: Type/s of worlds to generate a multiworld for
:param steps: Gen steps that should be called before returning. Default calls through pre_fill
:param worlds: type/s of worlds to generate a multiworld for
:param steps: gen steps that should be called before returning. Default calls through pre_fill
:param seed: The seed to be used when creating this multiworld
:return: The generated multiworld
"""
if not isinstance(worlds, list):
worlds = [worlds]
@@ -51,59 +49,3 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
for step in steps:
call_all(multiworld, step)
return multiworld
class TestWorld(World):
game = f"Test Game"
item_name_to_id = {}
location_name_to_id = {}
hidden = True
def generate_test_multiworld(players: int = 1) -> MultiWorld:
"""
Generates a multiworld using a special Test Case World class, and seed of 0.
:param players: Number of players to generate the multiworld for
:return: The generated test multiworld
"""
multiworld = setup_multiworld([TestWorld] * players, seed=0)
multiworld.regions += [Region("Menu", player_id + 1, multiworld) for player_id in range(players)]
return multiworld
def generate_locations(count: int, player_id: int, region: Region, address: Optional[int] = None,
tag: str = "") -> List[Location]:
"""
Generates the specified amount of locations for the player and adds them to the specified region.
:param count: Number of locations to create
:param player_id: ID of the player to create the locations for
:param address: Address for the specified locations. They will all share the same address if multiple are created
:param region: Parent region to add these locations to
:param tag: Tag to add to the name of the generated locations
:return: List containing the created locations
"""
prefix = f"player{player_id}{tag}_location"
locations = [Location(player_id, f"{prefix}{i}", address, region) for i in range(count)]
region.locations += locations
return locations
def generate_items(count: int, player_id: int, advancement: bool = False, code: int = None) -> List[Item]:
"""
Generates the specified amount of items for the target player.
:param count: The amount of items to create
:param player_id: ID of the player to create the items for
:param advancement: Whether the created items should be advancement
:param code: The code the items should be created with
:return: List containing the created items
"""
item_type = "prog" if advancement else ""
classification = ItemClassification.progression if advancement else ItemClassification.filler
items = [Item(f"player{player_id}_{item_type}item{i}", classification, code, player_id) for i in range(count)]
return items

View File

@@ -1,15 +1,41 @@
from typing import List, Iterable
import unittest
import Options
from Options import Accessibility
from test.general import generate_items, generate_locations, generate_test_multiworld
from worlds.AutoWorld import World
from Fill import FillError, balance_multiworld_progression, fill_restrictive, \
distribute_early_items, distribute_items_restrictive
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, Item, Location, \
ItemClassification
ItemClassification, CollectionState
from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule
def generate_multiworld(players: int = 1) -> MultiWorld:
multiworld = MultiWorld(players)
multiworld.set_seed(0)
multiworld.player_name = {}
multiworld.state = CollectionState(multiworld)
for i in range(players):
player_id = i+1
world = World(multiworld, player_id)
multiworld.game[player_id] = f"Game {player_id}"
multiworld.worlds[player_id] = world
multiworld.player_name[player_id] = "Test Player " + str(player_id)
region = Region("Menu", player_id, multiworld, "Menu Region Hint")
multiworld.regions.append(region)
for option_key, option in Options.PerGameCommonOptions.type_hints.items():
if hasattr(multiworld, option_key):
getattr(multiworld, option_key).setdefault(player_id, option.from_any(getattr(option, "default")))
else:
setattr(multiworld, option_key, {player_id: option.from_any(getattr(option, "default"))})
# TODO - remove this loop once all worlds use options dataclasses
world.options = world.options_dataclass(**{option_key: getattr(multiworld, option_key)[player_id]
for option_key in world.options_dataclass.type_hints})
return multiworld
class PlayerDefinition(object):
multiworld: MultiWorld
id: int
@@ -29,12 +55,12 @@ class PlayerDefinition(object):
self.regions = [menu]
def generate_region(self, parent: Region, size: int, access_rule: CollectionRule = lambda state: True) -> Region:
region_tag = f"_region{len(self.regions)}"
region_name = f"player{self.id}{region_tag}"
region = Region(f"player{self.id}{region_tag}", self.id, self.multiworld)
self.locations += generate_locations(size, self.id, region, None, region_tag)
region_tag = "_region" + str(len(self.regions))
region_name = "player" + str(self.id) + region_tag
region = Region("player" + str(self.id) + region_tag, self.id, self.multiworld)
self.locations += generate_locations(size, self.id, None, region, region_tag)
entrance = Entrance(self.id, f"{region_name}_entrance", parent)
entrance = Entrance(self.id, region_name + "_entrance", parent)
parent.exits.append(entrance)
entrance.connect(region)
entrance.access_rule = access_rule
@@ -54,6 +80,7 @@ def fill_region(multiworld: MultiWorld, region: Region, items: List[Item]) -> Li
return items
item = items.pop(0)
multiworld.push_item(location, item, False)
location.event = item.advancement
return items
@@ -68,7 +95,7 @@ def region_contains(region: Region, item: Item) -> bool:
def generate_player_data(multiworld: MultiWorld, player_id: int, location_count: int = 0, prog_item_count: int = 0, basic_item_count: int = 0) -> PlayerDefinition:
menu = multiworld.get_region("Menu", player_id)
locations = generate_locations(location_count, player_id, menu, None)
locations = generate_locations(location_count, player_id, None, menu)
prog_items = generate_items(prog_item_count, player_id, True)
multiworld.itempool += prog_items
basic_items = generate_items(basic_item_count, player_id, False)
@@ -77,6 +104,28 @@ def generate_player_data(multiworld: MultiWorld, player_id: int, location_count:
return PlayerDefinition(multiworld, player_id, menu, locations, prog_items, basic_items)
def generate_locations(count: int, player_id: int, address: int = None, region: Region = None, tag: str = "") -> List[Location]:
locations = []
prefix = "player" + str(player_id) + tag + "_location"
for i in range(count):
name = prefix + str(i)
location = Location(player_id, name, address, region)
locations.append(location)
region.locations.append(location)
return locations
def generate_items(count: int, player_id: int, advancement: bool = False, code: int = None) -> List[Item]:
items = []
item_type = "prog" if advancement else ""
for i in range(count):
name = "player" + str(player_id) + "_" + item_type + "item" + str(i)
items.append(Item(name,
ItemClassification.progression if advancement else ItemClassification.filler,
code, player_id))
return items
def names(objs: list) -> Iterable[str]:
return map(lambda o: o.name, objs)
@@ -84,7 +133,7 @@ def names(objs: list) -> Iterable[str]:
class TestFillRestrictive(unittest.TestCase):
def test_basic_fill(self):
"""Tests `fill_restrictive` fills and removes the locations and items from their respective lists"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
item0 = player1.prog_items[0]
@@ -102,7 +151,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_ordered_fill(self):
"""Tests `fill_restrictive` fulfills set rules"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
items = player1.prog_items
locations = player1.locations
@@ -119,7 +168,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_partial_fill(self):
"""Tests that `fill_restrictive` returns unfilled locations"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 3, 2)
item0 = player1.prog_items[0]
@@ -145,7 +194,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_minimal_fill(self):
"""Test that fill for minimal player can have unreachable items"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
items = player1.prog_items
@@ -170,7 +219,7 @@ class TestFillRestrictive(unittest.TestCase):
the non-minimal player get all items.
"""
multiworld = generate_test_multiworld(2)
multiworld = generate_multiworld(2)
player1 = generate_player_data(multiworld, 1, 3, 3)
player2 = generate_player_data(multiworld, 2, 3, 3)
@@ -197,11 +246,11 @@ class TestFillRestrictive(unittest.TestCase):
# all of player2's locations and items should be accessible (not all of player1's)
for item in player2.prog_items:
self.assertTrue(multiworld.state.has(item.name, player2.id),
f"{item} is unreachable in {item.location}")
f'{item} is unreachable in {item.location}')
def test_reversed_fill(self):
"""Test a different set of rules can be satisfied"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
item0 = player1.prog_items[0]
@@ -220,7 +269,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_multi_step_fill(self):
"""Test that fill is able to satisfy multiple spheres"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 4, 4)
items = player1.prog_items
@@ -245,7 +294,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_impossible_fill(self):
"""Test that fill raises an error when it can't place any items"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
items = player1.prog_items
locations = player1.locations
@@ -262,7 +311,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_circular_fill(self):
"""Test that fill raises an error when it can't place all items"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 3, 3)
item0 = player1.prog_items[0]
@@ -283,7 +332,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_competing_fill(self):
"""Test that fill raises an error when it can't place items in a way to satisfy the conditions"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
item0 = player1.prog_items[0]
@@ -300,7 +349,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_multiplayer_fill(self):
"""Test that items can be placed across worlds"""
multiworld = generate_test_multiworld(2)
multiworld = generate_multiworld(2)
player1 = generate_player_data(multiworld, 1, 2, 2)
player2 = generate_player_data(multiworld, 2, 2, 2)
@@ -321,7 +370,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_multiplayer_rules_fill(self):
"""Test that fill across worlds satisfies the rules"""
multiworld = generate_test_multiworld(2)
multiworld = generate_multiworld(2)
player1 = generate_player_data(multiworld, 1, 2, 2)
player2 = generate_player_data(multiworld, 2, 2, 2)
@@ -345,7 +394,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_restrictive_progress(self):
"""Test that various spheres with different requirements can be filled"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, prog_item_count=25)
items = player1.prog_items.copy()
multiworld.completion_condition[player1.id] = lambda state: state.has_all(
@@ -369,7 +418,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_swap_to_earlier_location_with_item_rule(self):
"""Test that item swap happens and works as intended"""
# test for PR#1109
multiworld = generate_test_multiworld(1)
multiworld = generate_multiworld(1)
player1 = generate_player_data(multiworld, 1, 4, 4)
locations = player1.locations[:] # copy required
items = player1.prog_items[:] # copy required
@@ -394,7 +443,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_swap_to_earlier_location_with_item_rule2(self):
"""Test that swap works before all items are placed"""
multiworld = generate_test_multiworld(1)
multiworld = generate_multiworld(1)
player1 = generate_player_data(multiworld, 1, 5, 5)
locations = player1.locations[:] # copy required
items = player1.prog_items[:] # copy required
@@ -436,10 +485,11 @@ class TestFillRestrictive(unittest.TestCase):
def test_double_sweep(self):
"""Test that sweep doesn't duplicate Event items when sweeping"""
# test for PR1114
multiworld = generate_test_multiworld(1)
multiworld = generate_multiworld(1)
player1 = generate_player_data(multiworld, 1, 1, 1)
location = player1.locations[0]
location.address = None
location.event = True
item = player1.prog_items[0]
item.code = None
location.place_locked_item(item)
@@ -450,7 +500,7 @@ class TestFillRestrictive(unittest.TestCase):
def test_correct_item_instance_removed_from_pool(self):
"""Test that a placed item gets removed from the submitted pool"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(multiworld, 1, 2, 2)
player1.prog_items[0].name = "Different_item_instance_but_same_item_name"
@@ -467,7 +517,7 @@ class TestFillRestrictive(unittest.TestCase):
class TestDistributeItemsRestrictive(unittest.TestCase):
def test_basic_distribute(self):
"""Test that distribute_items_restrictive is deterministic"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -477,17 +527,17 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
distribute_items_restrictive(multiworld)
self.assertEqual(locations[0].item, basic_items[1])
self.assertFalse(locations[0].advancement)
self.assertFalse(locations[0].event)
self.assertEqual(locations[1].item, prog_items[0])
self.assertTrue(locations[1].advancement)
self.assertTrue(locations[1].event)
self.assertEqual(locations[2].item, prog_items[1])
self.assertTrue(locations[2].advancement)
self.assertTrue(locations[2].event)
self.assertEqual(locations[3].item, basic_items[0])
self.assertFalse(locations[3].advancement)
self.assertFalse(locations[3].event)
def test_excluded_distribute(self):
"""Test that distribute_items_restrictive doesn't put advancement items on excluded locations"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -502,7 +552,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_non_excluded_item_distribute(self):
"""Test that useful items aren't placed on excluded locations"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -517,7 +567,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_too_many_excluded_distribute(self):
"""Test that fill fails if it can't place all progression items due to too many excluded locations"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -530,7 +580,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_non_excluded_item_must_distribute(self):
"""Test that fill fails if it can't place useful items due to too many excluded locations"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -545,7 +595,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_priority_distribute(self):
"""Test that priority locations receive advancement items"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -560,7 +610,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_excess_priority_distribute(self):
"""Test that if there's more priority locations than advancement items, they can still fill"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
locations = player1.locations
@@ -575,7 +625,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_multiple_world_priority_distribute(self):
"""Test that priority fill can be satisfied for multiple worlds"""
multiworld = generate_test_multiworld(3)
multiworld = generate_multiworld(3)
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
player2 = generate_player_data(
@@ -605,7 +655,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_can_remove_locations_in_fill_hook(self):
"""Test that distribute_items_restrictive calls the fill hook and allows for item and location removal"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
@@ -625,12 +675,12 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_seed_robust_to_item_order(self):
"""Test deterministic fill"""
mw1 = generate_test_multiworld()
mw1 = generate_multiworld()
gen1 = generate_player_data(
mw1, 1, 4, prog_item_count=2, basic_item_count=2)
distribute_items_restrictive(mw1)
mw2 = generate_test_multiworld()
mw2 = generate_multiworld()
gen2 = generate_player_data(
mw2, 1, 4, prog_item_count=2, basic_item_count=2)
mw2.itempool.append(mw2.itempool.pop(0))
@@ -643,12 +693,12 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_seed_robust_to_location_order(self):
"""Test deterministic fill even if locations in a region are reordered"""
mw1 = generate_test_multiworld()
mw1 = generate_multiworld()
gen1 = generate_player_data(
mw1, 1, 4, prog_item_count=2, basic_item_count=2)
distribute_items_restrictive(mw1)
mw2 = generate_test_multiworld()
mw2 = generate_multiworld()
gen2 = generate_player_data(
mw2, 1, 4, prog_item_count=2, basic_item_count=2)
reg = mw2.get_region("Menu", gen2.id)
@@ -662,7 +712,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_can_reserve_advancement_items_for_general_fill(self):
"""Test that priority locations fill still satisfies item rules"""
multiworld = generate_test_multiworld()
multiworld = generate_multiworld()
player1 = generate_player_data(
multiworld, 1, location_count=5, prog_item_count=5)
items = player1.prog_items
@@ -679,7 +729,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
def test_non_excluded_local_items(self):
"""Test that local items get placed locally in a multiworld"""
multiworld = generate_test_multiworld(2)
multiworld = generate_multiworld(2)
player1 = generate_player_data(
multiworld, 1, location_count=5, basic_item_count=5)
player2 = generate_player_data(
@@ -696,11 +746,11 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
for item in multiworld.get_items():
self.assertEqual(item.player, item.location.player)
self.assertFalse(item.location.advancement, False)
self.assertFalse(item.location.event, False)
def test_early_items(self) -> None:
"""Test that the early items API successfully places items early"""
mw = generate_test_multiworld(2)
mw = generate_multiworld(2)
player1 = generate_player_data(mw, 1, location_count=5, basic_item_count=5)
player2 = generate_player_data(mw, 2, location_count=5, basic_item_count=5)
mw.early_items[1][player1.basic_items[0].name] = 1
@@ -755,11 +805,11 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
if location.item and location.item == item:
return True
self.fail(f"Expected {region.name} to contain {item.name}.\n"
f"Contains{list(map(lambda location: location.item, region.locations))}")
self.fail("Expected " + region.name + " to contain " + item.name +
"\n Contains" + str(list(map(lambda location: location.item, region.locations))))
def setUp(self) -> None:
multiworld = generate_test_multiworld(2)
multiworld = generate_multiworld(2)
self.multiworld = multiworld
player1 = generate_player_data(
multiworld, 1, prog_item_count=2, basic_item_count=40)

View File

@@ -1,24 +1,18 @@
import os
import unittest
from io import StringIO
from tempfile import TemporaryFile
from typing import Any, Dict, List, cast
from settings import Settings
import Utils
from settings import Settings, Group
class TestIDs(unittest.TestCase):
yaml_options: Dict[Any, Any]
@classmethod
def setUpClass(cls) -> None:
with TemporaryFile("w+", encoding="utf-8") as f:
Settings(None).dump(f)
f.seek(0, os.SEEK_SET)
yaml_options = Utils.parse_yaml(f.read())
assert isinstance(yaml_options, dict)
cls.yaml_options = yaml_options
cls.yaml_options = Utils.parse_yaml(f.read())
def test_utils_in_yaml(self) -> None:
"""Tests that the auto generated host.yaml has default settings in it"""
@@ -36,47 +30,3 @@ class TestIDs(unittest.TestCase):
self.assertIn(option_key, utils_options)
for sub_option_key in option_set:
self.assertIn(sub_option_key, utils_options[option_key])
class TestSettingsDumper(unittest.TestCase):
def test_string_format(self) -> None:
"""Test that dumping a string will yield the expected output"""
# By default, pyyaml has automatic line breaks in strings and quoting is optional.
# What we want for consistency instead is single-line strings and always quote them.
# Line breaks have to become \n in that quoting style.
class AGroup(Group):
key: str = " ".join(["x"] * 60) + "\n" # more than 120 chars, contains spaces and a line break
with StringIO() as writer:
AGroup().dump(writer, 0)
expected_value = AGroup.key.replace("\n", "\\n")
self.assertEqual(writer.getvalue(), f"key: \"{expected_value}\"\n",
"dumped string has unexpected formatting")
def test_indentation(self) -> None:
"""Test that dumping items will add indentation"""
# NOTE: we don't care how many spaces there are, but it has to be a multiple of level
class AList(List[Any]):
__doc__ = None # make sure we get no doc string
class AGroup(Group):
key: AList = cast(AList, ["a", "b", [1]])
for level in range(3):
with StringIO() as writer:
AGroup().dump(writer, level)
lines = writer.getvalue().split("\n", 5)
key_line = lines[0]
key_spaces = len(key_line) - len(key_line.lstrip(" "))
value_lines = lines[1:-1]
value_spaces = [len(value_line) - len(value_line.lstrip(" ")) for value_line in value_lines]
if level == 0:
self.assertEqual(key_spaces, 0)
else:
self.assertGreaterEqual(key_spaces, level)
self.assertEqual(key_spaces % level, 0)
self.assertGreaterEqual(value_spaces[0], key_spaces) # a
self.assertEqual(value_spaces[1], value_spaces[0]) # b
self.assertEqual(value_spaces[2], value_spaces[0]) # start of sub-list
self.assertGreater(value_spaces[3], value_spaces[0],
f"{value_lines[3]} should have more indentation than {value_lines[0]} in {lines}")

View File

@@ -3,7 +3,6 @@ import unittest
from Fill import distribute_items_restrictive
from NetUtils import encode
from worlds.AutoWorld import AutoWorldRegister, call_all
from worlds import failed_world_loads
from . import setup_solo_multiworld
@@ -48,7 +47,3 @@ class TestImplemented(unittest.TestCase):
for key, data in multiworld.worlds[1].fill_slot_data().items():
self.assertIsInstance(key, str, "keys in slot data must be a string")
self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.")
def test_no_failed_world_loads(self):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")

View File

@@ -25,8 +25,6 @@ class TestBase(unittest.TestCase):
{"medallions", "stones", "rewards", "logic_bottles"},
"Starcraft 2":
{"Missions", "WoL Missions"},
"Yu-Gi-Oh! 2006":
{"Campaign Boss Beaten"}
}
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game_name, game_name=game_name):
@@ -64,6 +62,15 @@ class TestBase(unittest.TestCase):
for item in multiworld.itempool:
self.assertIn(item.name, world_type.item_name_to_id)
def test_item_descriptions_have_valid_names(self):
"""Ensure all item descriptions match an item name or item group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
self.assertIn(name, valid_names,
"All item descriptions must match defined item names")
def test_itempool_not_modified(self):
"""Test that worlds don't modify the itempool after `create_items`"""
gen_steps = ("generate_early", "create_regions", "create_items")

View File

@@ -66,3 +66,12 @@ class TestBase(unittest.TestCase):
for location in locations:
self.assertIn(location, world_type.location_name_to_id)
self.assertNotIn(group_name, world_type.location_name_to_id)
def test_location_descriptions_have_valid_names(self):
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
self.assertIn(name, valid_names,
"All location descriptions must match defined location names")

View File

@@ -1,7 +1,4 @@
import unittest
from BaseClasses import PlandoOptions
from Options import ItemLinks
from worlds.AutoWorld import AutoWorldRegister
@@ -20,30 +17,3 @@ class TestOptions(unittest.TestCase):
with self.subTest(game=gamename):
self.assertFalse(hasattr(world_type, "options"),
f"Unexpected assignment to {world_type.__name__}.options!")
def test_item_links_name_groups(self):
"""Tests that item links successfully unfold item_name_groups"""
item_link_groups = [
[{
"name": "ItemLinkGroup",
"item_pool": ["Everything"],
"link_replacement": False,
"replacement_item": None,
}],
[{
"name": "ItemLinkGroup",
"item_pool": ["Hammer", "Bow"],
"link_replacement": False,
"replacement_item": None,
}]
]
# we really need some sort of test world but generic doesn't have enough items for this
world = AutoWorldRegister.world_types["A Link to the Past"]
plando_options = PlandoOptions.from_option_string("bosses")
item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])]
for link in item_links:
link.verify(world, "tester", plando_options)
self.assertIn("Hammer", link.value[0]["item_pool"])
self.assertIn("Bow", link.value[0]["item_pool"])
# TODO test that the group created using these options has the items

View File

@@ -31,7 +31,7 @@ class TestPlayerOptions(unittest.TestCase):
self.assertEqual(new_weights["list_2"], ["string_3"])
self.assertEqual(new_weights["list_1"], ["string", "string_2"])
self.assertEqual(new_weights["dict_1"]["option_a"], 50)
self.assertEqual(new_weights["dict_1"]["option_b"], 50)
self.assertEqual(new_weights["dict_1"]["option_b"], 0)
self.assertEqual(new_weights["dict_1"]["option_c"], 50)
self.assertNotIn("option_f", new_weights["dict_2"])
self.assertEqual(new_weights["dict_2"]["option_g"], 50)

View File

@@ -1,23 +0,0 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
class TestWebDescriptions(unittest.TestCase):
def test_item_descriptions_have_valid_names(self) -> None:
"""Ensure all item descriptions match an item name or item group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.web.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
self.assertIn(name, valid_names,
"All item descriptions must match defined item names")
def test_location_descriptions_have_valid_names(self) -> None:
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.web.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
self.assertIn(name, valid_names,
"All location descriptions must match defined location names")

View File

@@ -1,7 +1,7 @@
""" FillType_* is not a real kivy type - just something to fill unknown typing. """
from typing import Any, Optional, Protocol
from ..graphics.texture import FillType_Drawable, FillType_Vec
from ..graphics import FillType_Drawable, FillType_Vec
class FillType_BindCallback(Protocol):

View File

@@ -3,20 +3,19 @@ from __future__ import annotations
import hashlib
import logging
import pathlib
import random
import re
import sys
import time
from random import Random
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping,
Optional, Set, TextIO, Tuple, TYPE_CHECKING, Type, Union)
from Options import (
ExcludeLocations, ItemLinks, LocalItems, NonLocalItems, OptionGroup, PerGameCommonOptions,
PriorityLocations, StartHints, StartInventory, StartInventoryPool, StartLocationHints
)
from Options import PerGameCommonOptions
from BaseClasses import CollectionState
if TYPE_CHECKING:
import random
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
from . import GamesPackage
from settings import Group
@@ -54,12 +53,17 @@ class AutoWorldRegister(type):
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["item_descriptions"] = {name: _normalize_description(description) for name, description
in dct.get("item_descriptions", {}).items()}
dct["item_descriptions"]["Everything"] = "All items in the entire game."
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
dct["location_descriptions"] = {name: _normalize_description(description) for name, description
in dct.get("location_descriptions", {}).items()}
dct["location_descriptions"]["Everywhere"] = "All locations in the entire game."
# move away from get_required_client_version function
if "game" in dct:
@@ -114,33 +118,6 @@ class AutoLogicRegister(type):
return new_class
class WebWorldRegister(type):
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> WebWorldRegister:
# don't allow an option to appear in multiple groups, allow "Item & Location Options" to appear anywhere by the
# dev, putting it at the end if they don't define options in it
option_groups: List[OptionGroup] = dct.get("option_groups", [])
item_and_loc_options = [LocalItems, NonLocalItems, StartInventory, StartInventoryPool, StartHints,
StartLocationHints, ExcludeLocations, PriorityLocations, ItemLinks]
seen_options = []
item_group_in_list = False
for group in option_groups:
assert group.name != "Game Options", "Game Options is a pre-determined group and can not be defined."
if group.name == "Item & Location Options":
group.options.extend(item_and_loc_options)
item_group_in_list = True
else:
for option in group.options:
assert option not in item_and_loc_options, \
f"{option} cannot be moved out of the \"Item & Location Options\" Group"
assert len(group.options) == len(set(group.options)), f"Duplicate options in option group {group.name}"
for option in group.options:
assert option not in seen_options, f"{option} found in two option groups"
seen_options.append(option)
if not item_group_in_list:
option_groups.append(OptionGroup("Item & Location Options", item_and_loc_options))
return super().__new__(mcs, name, bases, dct)
def _timed_call(method: Callable[..., Any], *args: Any,
multiworld: Optional["MultiWorld"] = None, player: Optional[int] = None) -> Any:
start = time.perf_counter()
@@ -195,7 +172,7 @@ def call_stage(multiworld: "MultiWorld", method_name: str, *args: Any) -> None:
_timed_call(stage_callable, multiworld, *args)
class WebWorld(metaclass=WebWorldRegister):
class WebWorld:
"""Webhost integration"""
options_page: Union[bool, str] = True
@@ -217,15 +194,6 @@ class WebWorld(metaclass=WebWorldRegister):
options_presets: Dict[str, Dict[str, Any]] = {}
"""A dictionary containing a collection of developer-defined game option presets."""
option_groups: ClassVar[List[OptionGroup]] = []
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""
location_descriptions: Dict[str, str] = {}
"""An optional map from location names (or location group names) to brief descriptions for users."""
item_descriptions: Dict[str, str] = {}
"""An optional map from item names (or item group names) to brief descriptions for users."""
class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
@@ -238,8 +206,8 @@ class World(metaclass=AutoWorldRegister):
game: ClassVar[str]
"""name the game"""
topology_present: bool = False
"""indicate if this world has any meaningful layout/pathing"""
topology_present: ClassVar[bool] = False
"""indicate if world type has any meaningful layout/pathing"""
all_item_and_group_names: ClassVar[FrozenSet[str]] = frozenset()
"""gets automatically populated with all item and item group names"""
@@ -252,9 +220,23 @@ class World(metaclass=AutoWorldRegister):
item_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}"""
item_descriptions: ClassVar[Dict[str, str]] = {}
"""An optional map from item names (or item group names) to brief descriptions for users.
Individual newlines and indentation will be collapsed into spaces before these descriptions are
displayed. This may cover only a subset of items.
"""
location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
location_descriptions: ClassVar[Dict[str, str]] = {}
"""An optional map from location names (or location group names) to brief descriptions for users.
Individual newlines and indentation will be collapsed into spaces before these descriptions are
displayed. This may cover only a subset of locations.
"""
data_version: ClassVar[int] = 0
"""
Increment this every time something in your world's names/id mappings changes.
@@ -301,7 +283,7 @@ class World(metaclass=AutoWorldRegister):
location_names: ClassVar[Set[str]]
"""set of all potential location names"""
random: Random
random: random.Random
"""This world's random object. Should be used for any randomization needed in world for this player slot."""
settings_key: ClassVar[str]
@@ -318,7 +300,7 @@ class World(metaclass=AutoWorldRegister):
assert multiworld is not None
self.multiworld = multiworld
self.player = player
self.random = Random(multiworld.random.getrandbits(64))
self.random = random.Random(multiworld.random.getrandbits(64))
multiworld.per_slot_randoms[player] = self.random
def __getattr__(self, item: str) -> Any:
@@ -522,10 +504,6 @@ class World(metaclass=AutoWorldRegister):
def get_region(self, region_name: str) -> "Region":
return self.multiworld.get_region(region_name, self.player)
@property
def player_name(self) -> str:
return self.multiworld.get_player_name(self.player)
@classmethod
def get_data_package_data(cls) -> "GamesPackage":
sorted_item_name_groups = {
@@ -558,3 +536,18 @@ def data_package_checksum(data: "GamesPackage") -> str:
assert sorted(data) == list(data), "Data not ordered"
from NetUtils import encode
return hashlib.sha1(encode(data).encode()).hexdigest()
def _normalize_description(description):
"""
Normalizes a description in item_descriptions or location_descriptions.
This allows authors to write descritions with nice indentation and line lengths in their world
definitions without having it affect the rendered format.
"""
# First, collapse the whitespace around newlines and the ends of the description.
description = re.sub(r' *\n *', '\n', description.strip())
# Next, condense individual newlines into spaces.
description = re.sub(r'(?<!\n)\n(?!\n)', ' ', description)
return description

View File

@@ -64,7 +64,7 @@ class SuffixIdentifier:
def __init__(self, *args: str):
self.suffixes = args
def __call__(self, path: str) -> bool:
def __call__(self, path: str):
if isinstance(path, str):
for suffix in self.suffixes:
if path.endswith(suffix):

View File

@@ -103,7 +103,7 @@ async def connect(ctx: BizHawkContext) -> bool:
return True
except (TimeoutError, ConnectionRefusedError):
continue
# No ports worked
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED

View File

@@ -2,6 +2,7 @@
A module containing the BizHawkClient base class and metaclass
"""
from __future__ import annotations
import abc
@@ -11,13 +12,14 @@ from worlds.LauncherComponents import Component, SuffixIdentifier, Type, compone
if TYPE_CHECKING:
from .context import BizHawkClientContext
else:
BizHawkClientContext = object
def launch_client(*args) -> None:
from .context import launch
launch_subprocess(launch, name="BizHawkClient")
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
file_identifier=SuffixIdentifier())
components.append(component)
@@ -54,7 +56,7 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
return new_class
@staticmethod
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
async def get_handler(ctx: BizHawkClientContext, system: str) -> Optional[BizHawkClient]:
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
if system in systems:
for handler in handlers.values():
@@ -75,7 +77,7 @@ class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
"""The file extension(s) this client is meant to open and patch (e.g. ".apz3")"""
@abc.abstractmethod
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
async def validate_rom(self, ctx: BizHawkClientContext) -> bool:
"""Should return whether the currently loaded ROM should be handled by this client. You might read the game name
from the ROM header, for example. This function will only be asked to validate ROMs from the system set by the
client class, so you do not need to check the system yourself.
@@ -84,18 +86,18 @@ class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
as necessary (such as setting `ctx.game = self.game`, modifying `ctx.items_handling`, etc...)."""
...
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
async def set_auth(self, ctx: BizHawkClientContext) -> None:
"""Should set ctx.auth in anticipation of sending a `Connected` packet. You may override this if you store slot
name in your patched ROM. If ctx.auth is not set after calling, the player will be prompted to enter their
username."""
pass
@abc.abstractmethod
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
async def game_watcher(self, ctx: BizHawkClientContext) -> None:
"""Runs on a loop with the approximate interval `ctx.watcher_timeout`. The currently loaded ROM is guaranteed
to have passed your validator when this function is called, and the emulator is very likely to be connected."""
...
def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict) -> None:
def on_package(self, ctx: BizHawkClientContext, cmd: str, args: dict) -> None:
"""For handling packages from the server. Called from `BizHawkClientContext.on_package`."""
pass

View File

@@ -3,6 +3,7 @@ A module containing context and functions relevant to running the client. This m
checking or launching the client, otherwise it will probably cause circular import issues.
"""
import asyncio
import enum
import subprocess
@@ -76,7 +77,7 @@ class BizHawkClientContext(CommonContext):
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
async def server_auth(self, password_requested: bool=False):
async def server_auth(self, password_requested: bool = False):
self.password_requested = password_requested
if self.bizhawk_ctx.connection_status != ConnectionStatus.CONNECTED:
@@ -102,7 +103,7 @@ class BizHawkClientContext(CommonContext):
await self.send_connect()
self.auth_status = AuthStatus.PENDING
async def disconnect(self, allow_autoreconnect: bool=False):
async def disconnect(self, allow_autoreconnect: bool = False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED
await super().disconnect(allow_autoreconnect)
@@ -147,8 +148,7 @@ async def _game_watcher(ctx: BizHawkClientContext):
script_version = await get_script_version(ctx.bizhawk_ctx)
if script_version != EXPECTED_SCRIPT_VERSION:
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but "
f"got {script_version}. Disconnecting.")
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but got {script_version}. Disconnecting.")
disconnect(ctx.bizhawk_ctx)
continue
@@ -234,11 +234,8 @@ async def _run_game(rom: str):
async def _patch_and_run_game(patch_file: str):
try:
metadata, output_file = Patch.create_rom_file(patch_file)
Utils.async_start(_run_game(output_file))
except Exception as exc:
logger.exception(exc)
metadata, output_file = Patch.create_rom_file(patch_file)
Utils.async_start(_run_game(output_file))
def launch() -> None:

View File

@@ -19,9 +19,9 @@ class WorldPosition:
def get_position(self, random):
if self.room_x is None or self.room_y is None:
return self.room_id, random.choice(standard_positions)
return random.choice(standard_positions)
else:
return self.room_id, (self.room_x, self.room_y)
return self.room_x, self.room_y
class LocationData:
@@ -46,26 +46,24 @@ class LocationData:
self.needs_bat_logic: int = needs_bat_logic
self.local_item: int = None
def get_random_position(self, random):
x: int = None
y: int = None
if self.world_positions is None or len(self.world_positions) == 0:
if self.room_id is None:
return None
x, y = random.choice(standard_positions)
return self.room_id, x, y
else:
selected_pos = random.choice(self.world_positions)
room_id, (x, y) = selected_pos.get_position(random)
return self.get_random_room_id(random), x, y
def get_random_room_id(self, random):
def get_position(self, random):
if self.world_positions is None or len(self.world_positions) == 0:
if self.room_id is None:
return None
self.room_x, self.room_y = random.choice(standard_positions)
if self.room_id is None:
selected_pos = random.choice(self.world_positions)
return selected_pos.room_id
self.room_id = selected_pos.room_id
self.room_x, self.room_y = selected_pos.get_position(random)
return self.room_x, self.room_y
def get_room_id(self, random):
if self.world_positions is None or len(self.world_positions) == 0:
return None
if self.room_id is None:
selected_pos = random.choice(self.world_positions)
self.room_id = selected_pos.room_id
self.room_x, self.room_y = selected_pos.get_position(random)
return self.room_id
@@ -99,7 +97,7 @@ def get_random_room_in_regions(regions: [str], random) -> int:
possible_rooms = {}
for locname in location_table:
if location_table[locname].region in regions:
room = location_table[locname].get_random_room_id(random)
room = location_table[locname].get_room_id(random)
if room is not None:
possible_rooms[room] = location_table[locname].room_id
return random.choice(list(possible_rooms.keys()))

View File

@@ -241,4 +241,4 @@ adventure_option_definitions: Dict[str, type(Option)] = {
"difficulty_switch_b": DifficultySwitchB,
"start_castle": StartCastle,
}
}

View File

@@ -25,6 +25,8 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
for name, locdata in location_table.items():
locdata.get_position(multiworld.random)
menu = Region("Menu", player, multiworld)

View File

@@ -371,9 +371,8 @@ class AdventureWorld(World):
if location.item.player == self.player and \
location.item.name == "nothing":
location_data = location_table[location.name]
room_id = location_data.get_random_room_id(self.random)
auto_collect_locations.append(AdventureAutoCollectLocation(location_data.short_location_id,
room_id))
location_data.room_id))
# standard Adventure items, which are placed in the rom
elif location.item.player == self.player and \
location.item.name != "nothing" and \
@@ -384,18 +383,14 @@ class AdventureWorld(World):
item_ram_address = item_ram_addresses[item_table[location.item.name].table_index]
item_position_data_start = item_position_table + item_ram_address - items_ram_start
location_data = location_table[location.name]
(room_id, room_x, room_y) = \
location_data.get_random_position(self.random)
room_x, room_y = location_data.get_position(self.multiworld.per_slot_randoms[self.player])
if location_data.needs_bat_logic and bat_logic == 0x0:
copied_location = copy.copy(location_data)
copied_location.local_item = item_ram_address
copied_location.room_id = room_id
copied_location.room_x = room_x
copied_location.room_y = room_y
bat_no_touch_locs.append(copied_location)
del unplaced_local_items[location.item.name]
rom_deltas[item_position_data_start] = room_id
rom_deltas[item_position_data_start] = location_data.room_id
rom_deltas[item_position_data_start + 1] = room_x
rom_deltas[item_position_data_start + 2] = room_y
local_item_to_location[item_table_offset] = self.location_name_to_id[location.name] \
@@ -403,20 +398,14 @@ class AdventureWorld(World):
# items from other worlds, and non-standard Adventure items handled by script, like difficulty switches
elif location.item.code is not None:
if location.item.code != nothing_item_id:
location_data = copy.copy(location_table[location.name])
(room_id, room_x, room_y) = \
location_data.get_random_position(self.random)
location_data.room_id = room_id
location_data.room_x = room_x
location_data.room_y = room_y
location_data = location_table[location.name]
foreign_item_locations.append(location_data)
if location_data.needs_bat_logic and bat_logic == 0x0:
bat_no_touch_locs.append(location_data)
else:
location_data = location_table[location.name]
room_id = location_data.get_random_room_id(self.random)
auto_collect_locations.append(AdventureAutoCollectLocation(location_data.short_location_id,
room_id))
location_data.room_id))
# Adventure items that are in another world get put in an invalid room until needed
for unplaced_item_name, unplaced_item in unplaced_local_items.items():
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)

View File

@@ -1,232 +0,0 @@
import asyncio
import Utils
import websockets
import functools
from copy import deepcopy
from typing import List, Any, Iterable
from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem
from MultiServer import Endpoint
from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, get_base_parser
DEBUG = False
class AHITJSONToTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
return self._handle_text(node) # No colors for the in-game text
class AHITCommandProcessor(ClientCommandProcessor):
def _cmd_ahit(self):
"""Check AHIT Connection State"""
if isinstance(self.ctx, AHITContext):
logger.info(f"AHIT Status: {self.ctx.get_ahit_status()}")
class AHITContext(CommonContext):
command_processor = AHITCommandProcessor
game = "A Hat in Time"
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.proxy = None
self.proxy_task = None
self.gamejsontotext = AHITJSONToTextParser(self)
self.autoreconnect_task = None
self.endpoint = None
self.items_handling = 0b111
self.room_info = None
self.connected_msg = None
self.game_connected = False
self.awaiting_info = False
self.full_inventory: List[Any] = []
self.server_msgs: List[Any] = []
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(AHITContext, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
def get_ahit_status(self) -> str:
if not self.is_proxy_connected():
return "Not connected to A Hat in Time"
return "Connected to A Hat in Time"
async def send_msgs_proxy(self, msgs: Iterable[dict]) -> bool:
""" `msgs` JSON serializable """
if not self.endpoint or not self.endpoint.socket.open or self.endpoint.socket.closed:
return False
if DEBUG:
logger.info(f"Outgoing message: {msgs}")
await self.endpoint.socket.send(msgs)
return True
async def disconnect(self, allow_autoreconnect: bool = False):
await super().disconnect(allow_autoreconnect)
async def disconnect_proxy(self):
if self.endpoint and not self.endpoint.socket.closed:
await self.endpoint.socket.close()
if self.proxy_task is not None:
await self.proxy_task
def is_connected(self) -> bool:
return self.server and self.server.socket.open
def is_proxy_connected(self) -> bool:
return self.endpoint and self.endpoint.socket.open
def on_print_json(self, args: dict):
text = self.gamejsontotext(deepcopy(args["data"]))
msg = {"cmd": "PrintJSON", "data": [{"text": text}], "type": "Chat"}
self.server_msgs.append(encode([msg]))
if self.ui:
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
def update_items(self):
# just to be safe - we might still have an inventory from a different room
if not self.is_connected():
return
self.server_msgs.append(encode([{"cmd": "ReceivedItems", "index": 0, "items": self.full_inventory}]))
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.connected_msg = encode([args])
if self.awaiting_info:
self.server_msgs.append(self.room_info)
self.update_items()
self.awaiting_info = False
elif cmd == "ReceivedItems":
if args["index"] == 0:
self.full_inventory.clear()
for item in args["items"]:
self.full_inventory.append(NetworkItem(*item))
self.server_msgs.append(encode([args]))
elif cmd == "RoomInfo":
self.seed_name = args["seed_name"]
self.room_info = encode([args])
else:
if cmd != "PrintJSON":
self.server_msgs.append(encode([args]))
def run_gui(self):
from kvui import GameManager
class AHITManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago A Hat in Time Client"
self.ui = AHITManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
ctx.endpoint = Endpoint(websocket)
try:
await on_client_connected(ctx)
if ctx.is_proxy_connected():
async for data in websocket:
if DEBUG:
logger.info(f"Incoming message: {data}")
for msg in decode(data):
if msg["cmd"] == "Connect":
# Proxy is connecting, make sure it is valid
if msg["game"] != "A Hat in Time":
logger.info("Aborting proxy connection: game is not A Hat in Time")
await ctx.disconnect_proxy()
break
if ctx.seed_name:
seed_name = msg.get("seed_name", "")
if seed_name != "" and seed_name != ctx.seed_name:
logger.info("Aborting proxy connection: seed mismatch from save file")
logger.info(f"Expected: {ctx.seed_name}, got: {seed_name}")
text = encode([{"cmd": "PrintJSON",
"data": [{"text": "Connection aborted - save file to seed mismatch"}]}])
await ctx.send_msgs_proxy(text)
await ctx.disconnect_proxy()
break
if ctx.connected_msg and ctx.is_connected():
await ctx.send_msgs_proxy(ctx.connected_msg)
ctx.update_items()
continue
if not ctx.is_proxy_connected():
break
await ctx.send_msgs([msg])
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logger.exception(e)
finally:
await ctx.disconnect_proxy()
async def on_client_connected(ctx: AHITContext):
if ctx.room_info and ctx.is_connected():
await ctx.send_msgs_proxy(ctx.room_info)
else:
ctx.awaiting_info = True
async def proxy_loop(ctx: AHITContext):
try:
while not ctx.exit_event.is_set():
if len(ctx.server_msgs) > 0:
for msg in ctx.server_msgs:
await ctx.send_msgs_proxy(msg)
ctx.server_msgs.clear()
await asyncio.sleep(0.1)
except Exception as e:
logger.exception(e)
logger.info("Aborting AHIT Proxy Client due to errors")
def launch():
async def main():
parser = get_base_parser()
args = parser.parse_args()
ctx = AHITContext(args.connect, args.password)
logger.info("Starting A Hat in Time proxy server")
ctx.proxy = websockets.serve(functools.partial(proxy, ctx=ctx),
host="localhost", port=11311, ping_timeout=999999, ping_interval=999999)
ctx.proxy_task = asyncio.create_task(proxy_loop(ctx), name="ProxyLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.proxy
await ctx.proxy_task
await ctx.exit_event.wait()
Utils.init_logging("AHITClient")
# options = Utils.get_options()
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,243 +0,0 @@
from .Types import HatInTimeLocation, HatInTimeItem
from .Regions import create_region
from BaseClasses import Region, LocationProgressType, ItemClassification
from worlds.generic.Rules import add_rule
from typing import List, TYPE_CHECKING
from .Locations import death_wishes
from .Options import EndGoal
if TYPE_CHECKING:
from . import HatInTimeWorld
dw_prereqs = {
"So You're Back From Outer Space": ["Beat the Heat"],
"Snatcher's Hit List": ["Beat the Heat"],
"Snatcher Coins in Mafia Town": ["So You're Back From Outer Space"],
"Rift Collapse: Mafia of Cooks": ["So You're Back From Outer Space"],
"Collect-a-thon": ["So You're Back From Outer Space"],
"She Speedran from Outer Space": ["Rift Collapse: Mafia of Cooks"],
"Mafia's Jumps": ["She Speedran from Outer Space"],
"Vault Codes in the Wind": ["Collect-a-thon", "She Speedran from Outer Space"],
"Encore! Encore!": ["Collect-a-thon"],
"Security Breach": ["Beat the Heat"],
"Rift Collapse: Dead Bird Studio": ["Security Breach"],
"The Great Big Hootenanny": ["Security Breach"],
"10 Seconds until Self-Destruct": ["The Great Big Hootenanny"],
"Killing Two Birds": ["Rift Collapse: Dead Bird Studio", "10 Seconds until Self-Destruct"],
"Community Rift: Rhythm Jump Studio": ["10 Seconds until Self-Destruct"],
"Snatcher Coins in Battle of the Birds": ["The Great Big Hootenanny"],
"Zero Jumps": ["Rift Collapse: Dead Bird Studio"],
"Snatcher Coins in Nyakuza Metro": ["Killing Two Birds"],
"Speedrun Well": ["Beat the Heat"],
"Rift Collapse: Sleepy Subcon": ["Speedrun Well"],
"Boss Rush": ["Speedrun Well"],
"Quality Time with Snatcher": ["Rift Collapse: Sleepy Subcon"],
"Breaching the Contract": ["Boss Rush", "Quality Time with Snatcher"],
"Community Rift: Twilight Travels": ["Quality Time with Snatcher"],
"Snatcher Coins in Subcon Forest": ["Rift Collapse: Sleepy Subcon"],
"Bird Sanctuary": ["Beat the Heat"],
"Snatcher Coins in Alpine Skyline": ["Bird Sanctuary"],
"Wound-Up Windmill": ["Bird Sanctuary"],
"Rift Collapse: Alpine Skyline": ["Bird Sanctuary"],
"Camera Tourist": ["Rift Collapse: Alpine Skyline"],
"Community Rift: The Mountain Rift": ["Rift Collapse: Alpine Skyline"],
"The Illness has Speedrun": ["Rift Collapse: Alpine Skyline", "Wound-Up Windmill"],
"The Mustache Gauntlet": ["Wound-Up Windmill"],
"No More Bad Guys": ["The Mustache Gauntlet"],
"Seal the Deal": ["Encore! Encore!", "Killing Two Birds",
"Breaching the Contract", "No More Bad Guys"],
"Rift Collapse: Deep Sea": ["Rift Collapse: Mafia of Cooks", "Rift Collapse: Dead Bird Studio",
"Rift Collapse: Sleepy Subcon", "Rift Collapse: Alpine Skyline"],
"Cruisin' for a Bruisin'": ["Rift Collapse: Deep Sea"],
}
dw_candles = [
"Snatcher's Hit List",
"Zero Jumps",
"Camera Tourist",
"Snatcher Coins in Mafia Town",
"Snatcher Coins in Battle of the Birds",
"Snatcher Coins in Subcon Forest",
"Snatcher Coins in Alpine Skyline",
"Snatcher Coins in Nyakuza Metro",
]
annoying_dws = [
"Vault Codes in the Wind",
"Boss Rush",
"Camera Tourist",
"The Mustache Gauntlet",
"Rift Collapse: Deep Sea",
"Cruisin' for a Bruisin'",
"Seal the Deal", # Non-excluded if goal
]
# includes the above as well
annoying_bonuses = [
"So You're Back From Outer Space",
"Encore! Encore!",
"Snatcher's Hit List",
"Vault Codes in the Wind",
"10 Seconds until Self-Destruct",
"Killing Two Birds",
"Zero Jumps",
"Boss Rush",
"Bird Sanctuary",
"The Mustache Gauntlet",
"Wound-Up Windmill",
"Camera Tourist",
"Rift Collapse: Deep Sea",
"Cruisin' for a Bruisin'",
"Seal the Deal",
]
dw_classes = {
"Beat the Heat": "Hat_SnatcherContract_DeathWish_HeatingUpHarder",
"So You're Back From Outer Space": "Hat_SnatcherContract_DeathWish_BackFromSpace",
"Snatcher's Hit List": "Hat_SnatcherContract_DeathWish_KillEverybody",
"Collect-a-thon": "Hat_SnatcherContract_DeathWish_PonFrenzy",
"Rift Collapse: Mafia of Cooks": "Hat_SnatcherContract_DeathWish_RiftCollapse_MafiaTown",
"Encore! Encore!": "Hat_SnatcherContract_DeathWish_MafiaBossEX",
"She Speedran from Outer Space": "Hat_SnatcherContract_DeathWish_Speedrun_MafiaAlien",
"Mafia's Jumps": "Hat_SnatcherContract_DeathWish_NoAPresses_MafiaAlien",
"Vault Codes in the Wind": "Hat_SnatcherContract_DeathWish_MovingVault",
"Snatcher Coins in Mafia Town": "Hat_SnatcherContract_DeathWish_Tokens_MafiaTown",
"Security Breach": "Hat_SnatcherContract_DeathWish_DeadBirdStudioMoreGuards",
"The Great Big Hootenanny": "Hat_SnatcherContract_DeathWish_DifficultParade",
"Rift Collapse: Dead Bird Studio": "Hat_SnatcherContract_DeathWish_RiftCollapse_Birds",
"10 Seconds until Self-Destruct": "Hat_SnatcherContract_DeathWish_TrainRushShortTime",
"Killing Two Birds": "Hat_SnatcherContract_DeathWish_BirdBossEX",
"Snatcher Coins in Battle of the Birds": "Hat_SnatcherContract_DeathWish_Tokens_Birds",
"Zero Jumps": "Hat_SnatcherContract_DeathWish_NoAPresses",
"Speedrun Well": "Hat_SnatcherContract_DeathWish_Speedrun_SubWell",
"Rift Collapse: Sleepy Subcon": "Hat_SnatcherContract_DeathWish_RiftCollapse_Subcon",
"Boss Rush": "Hat_SnatcherContract_DeathWish_BossRush",
"Quality Time with Snatcher": "Hat_SnatcherContract_DeathWish_SurvivalOfTheFittest",
"Breaching the Contract": "Hat_SnatcherContract_DeathWish_SnatcherEX",
"Snatcher Coins in Subcon Forest": "Hat_SnatcherContract_DeathWish_Tokens_Subcon",
"Bird Sanctuary": "Hat_SnatcherContract_DeathWish_NiceBirdhouse",
"Rift Collapse: Alpine Skyline": "Hat_SnatcherContract_DeathWish_RiftCollapse_Alps",
"Wound-Up Windmill": "Hat_SnatcherContract_DeathWish_FastWindmill",
"The Illness has Speedrun": "Hat_SnatcherContract_DeathWish_Speedrun_Illness",
"Snatcher Coins in Alpine Skyline": "Hat_SnatcherContract_DeathWish_Tokens_Alps",
"Camera Tourist": "Hat_SnatcherContract_DeathWish_CameraTourist_1",
"The Mustache Gauntlet": "Hat_SnatcherContract_DeathWish_HardCastle",
"No More Bad Guys": "Hat_SnatcherContract_DeathWish_MuGirlEX",
"Seal the Deal": "Hat_SnatcherContract_DeathWish_BossRushEX",
"Rift Collapse: Deep Sea": "Hat_SnatcherContract_DeathWish_RiftCollapse_Cruise",
"Cruisin' for a Bruisin'": "Hat_SnatcherContract_DeathWish_EndlessTasks",
"Community Rift: Rhythm Jump Studio": "Hat_SnatcherContract_DeathWish_CommunityRift_RhythmJump",
"Community Rift: Twilight Travels": "Hat_SnatcherContract_DeathWish_CommunityRift_TwilightTravels",
"Community Rift: The Mountain Rift": "Hat_SnatcherContract_DeathWish_CommunityRift_MountainRift",
"Snatcher Coins in Nyakuza Metro": "Hat_SnatcherContract_DeathWish_Tokens_Metro",
}
def create_dw_regions(world: "HatInTimeWorld"):
if world.options.DWExcludeAnnoyingContracts:
for name in annoying_dws:
world.excluded_dws.append(name)
if not world.options.DWEnableBonus or world.options.DWAutoCompleteBonuses:
for name in death_wishes:
world.excluded_bonuses.append(name)
elif world.options.DWExcludeAnnoyingBonuses:
for name in annoying_bonuses:
world.excluded_bonuses.append(name)
if world.options.DWExcludeCandles:
for name in dw_candles:
if name not in world.excluded_dws:
world.excluded_dws.append(name)
spaceship = world.multiworld.get_region("Spaceship", world.player)
dw_map: Region = create_region(world, "Death Wish Map")
entrance = spaceship.connect(dw_map, "-> Death Wish Map")
add_rule(entrance, lambda state: state.has("Time Piece", world.player, world.options.DWTimePieceRequirement))
if world.options.DWShuffle:
# Connect Death Wishes randomly to one another in a linear sequence
dw_list: List[str] = []
for name in death_wishes.keys():
# Don't shuffle excluded or invalid Death Wishes
if not world.is_dlc2() and name == "Snatcher Coins in Nyakuza Metro" or world.is_dw_excluded(name):
continue
dw_list.append(name)
world.random.shuffle(dw_list)
count = world.random.randint(world.options.DWShuffleCountMin.value, world.options.DWShuffleCountMax.value)
dw_shuffle: List[str] = []
total = min(len(dw_list), count)
for i in range(total):
dw_shuffle.append(dw_list[i])
# Seal the Deal is always last if it's the goal
if world.options.EndGoal == EndGoal.option_seal_the_deal:
if "Seal the Deal" in dw_shuffle:
dw_shuffle.remove("Seal the Deal")
dw_shuffle.append("Seal the Deal")
world.dw_shuffle = dw_shuffle
prev_dw = dw_map
for death_wish_name in dw_shuffle:
dw = create_region(world, death_wish_name)
prev_dw.connect(dw)
create_dw_locations(world, dw)
prev_dw = dw
else:
# DWShuffle is disabled, use vanilla connections
for key in death_wishes.keys():
if key == "Snatcher Coins in Nyakuza Metro" and not world.is_dlc2():
world.excluded_dws.append(key)
continue
dw = create_region(world, key)
if key == "Beat the Heat":
dw_map.connect(dw, f"{dw_map.name} -> Beat the Heat")
elif key in dw_prereqs.keys():
for name in dw_prereqs[key]:
parent = world.multiworld.get_region(name, world.player)
parent.connect(dw, f"{parent.name} -> {key}")
create_dw_locations(world, dw)
def create_dw_locations(world: "HatInTimeWorld", dw: Region):
loc_id = death_wishes[dw.name]
main_objective = HatInTimeLocation(world.player, f"{dw.name} - Main Objective", loc_id, dw)
full_clear = HatInTimeLocation(world.player, f"{dw.name} - All Clear", loc_id + 1, dw)
main_stamp = HatInTimeLocation(world.player, f"Main Stamp - {dw.name}", None, dw)
bonus_stamps = HatInTimeLocation(world.player, f"Bonus Stamps - {dw.name}", None, dw)
main_stamp.show_in_spoiler = False
bonus_stamps.show_in_spoiler = False
dw.locations.append(main_stamp)
dw.locations.append(bonus_stamps)
main_stamp.place_locked_item(HatInTimeItem(f"1 Stamp - {dw.name}",
ItemClassification.progression, None, world.player))
bonus_stamps.place_locked_item(HatInTimeItem(f"2 Stamp - {dw.name}",
ItemClassification.progression, None, world.player))
if dw.name in world.excluded_dws:
main_objective.progress_type = LocationProgressType.EXCLUDED
full_clear.progress_type = LocationProgressType.EXCLUDED
elif world.is_bonus_excluded(dw.name):
full_clear.progress_type = LocationProgressType.EXCLUDED
dw.locations.append(main_objective)
dw.locations.append(full_clear)

View File

@@ -1,462 +0,0 @@
from worlds.AutoWorld import CollectionState
from .Rules import can_use_hat, can_use_hookshot, can_hit, zipline_logic, get_difficulty, has_paintings
from .Types import HatType, Difficulty, HatInTimeLocation, HatInTimeItem, LocData, HitType
from .DeathWishLocations import dw_prereqs, dw_candles
from BaseClasses import Entrance, Location, ItemClassification
from worlds.generic.Rules import add_rule, set_rule
from typing import List, Callable, TYPE_CHECKING
from .Locations import death_wishes
from .Options import EndGoal
if TYPE_CHECKING:
from . import HatInTimeWorld
# Any speedruns expect the player to have Sprint Hat
dw_requirements = {
"Beat the Heat": LocData(hit_type=HitType.umbrella),
"So You're Back From Outer Space": LocData(hookshot=True),
"Mafia's Jumps": LocData(required_hats=[HatType.ICE]),
"Vault Codes in the Wind": LocData(required_hats=[HatType.SPRINT]),
"Security Breach": LocData(hit_type=HitType.umbrella_or_brewing),
"10 Seconds until Self-Destruct": LocData(hookshot=True),
"Community Rift: Rhythm Jump Studio": LocData(required_hats=[HatType.ICE]),
"Speedrun Well": LocData(hookshot=True, hit_type=HitType.umbrella_or_brewing),
"Boss Rush": LocData(hit_type=HitType.umbrella, hookshot=True),
"Community Rift: Twilight Travels": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
"Bird Sanctuary": LocData(hookshot=True),
"Wound-Up Windmill": LocData(hookshot=True),
"The Illness has Speedrun": LocData(hookshot=True),
"Community Rift: The Mountain Rift": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
"Camera Tourist": LocData(misc_required=["Camera Badge"]),
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
"Rift Collapse - Deep Sea": LocData(hookshot=True),
}
# Includes main objective requirements
dw_bonus_requirements = {
# Some One-Hit Hero requirements need badge pins as well because of Hookshot
"So You're Back From Outer Space": LocData(required_hats=[HatType.SPRINT]),
"Encore! Encore!": LocData(misc_required=["One-Hit Hero Badge"]),
"10 Seconds until Self-Destruct": LocData(misc_required=["One-Hit Hero Badge", "Badge Pin"]),
"Boss Rush": LocData(misc_required=["One-Hit Hero Badge", "Badge Pin"]),
"Community Rift: Twilight Travels": LocData(required_hats=[HatType.BREWING]),
"Bird Sanctuary": LocData(misc_required=["One-Hit Hero Badge", "Badge Pin"], required_hats=[HatType.DWELLER]),
"Wound-Up Windmill": LocData(misc_required=["One-Hit Hero Badge", "Badge Pin"]),
"The Illness has Speedrun": LocData(required_hats=[HatType.SPRINT]),
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
"Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]),
}
dw_stamp_costs = {
"So You're Back From Outer Space": 2,
"Collect-a-thon": 5,
"She Speedran from Outer Space": 8,
"Encore! Encore!": 10,
"Security Breach": 4,
"The Great Big Hootenanny": 7,
"10 Seconds until Self-Destruct": 15,
"Killing Two Birds": 25,
"Snatcher Coins in Nyakuza Metro": 30,
"Speedrun Well": 10,
"Boss Rush": 15,
"Quality Time with Snatcher": 20,
"Breaching the Contract": 40,
"Bird Sanctuary": 15,
"Wound-Up Windmill": 30,
"The Illness has Speedrun": 35,
"The Mustache Gauntlet": 35,
"No More Bad Guys": 50,
"Seal the Deal": 70,
}
required_snatcher_coins = {
"Snatcher Coins in Mafia Town": ["Snatcher Coin - Top of HQ", "Snatcher Coin - Top of Tower",
"Snatcher Coin - Under Ruined Tower"],
"Snatcher Coins in Battle of the Birds": ["Snatcher Coin - Top of Red House", "Snatcher Coin - Train Rush",
"Snatcher Coin - Picture Perfect"],
"Snatcher Coins in Subcon Forest": ["Snatcher Coin - Swamp Tree", "Snatcher Coin - Manor Roof",
"Snatcher Coin - Giant Time Piece"],
"Snatcher Coins in Alpine Skyline": ["Snatcher Coin - Goat Village Top", "Snatcher Coin - Lava Cake",
"Snatcher Coin - Windmill"],
"Snatcher Coins in Nyakuza Metro": ["Snatcher Coin - Green Clean Tower", "Snatcher Coin - Bluefin Cat Train",
"Snatcher Coin - Pink Paw Fence"],
}
def set_dw_rules(world: "HatInTimeWorld"):
if "Snatcher's Hit List" not in world.excluded_dws or "Camera Tourist" not in world.excluded_dws:
set_enemy_rules(world)
dw_list: List[str] = []
if world.options.DWShuffle:
dw_list = world.dw_shuffle
else:
for name in death_wishes.keys():
dw_list.append(name)
for name in dw_list:
if name == "Snatcher Coins in Nyakuza Metro" and not world.is_dlc2():
continue
dw = world.multiworld.get_region(name, world.player)
if not world.options.DWShuffle and name in dw_stamp_costs.keys():
for entrance in dw.entrances:
add_rule(entrance, lambda state, n=name: state.has("Stamps", world.player, dw_stamp_costs[n]))
main_objective = world.multiworld.get_location(f"{name} - Main Objective", world.player)
all_clear = world.multiworld.get_location(f"{name} - All Clear", world.player)
main_stamp = world.multiworld.get_location(f"Main Stamp - {name}", world.player)
bonus_stamps = world.multiworld.get_location(f"Bonus Stamps - {name}", world.player)
if not world.options.DWEnableBonus:
# place nothing, but let the locations exist still, so we can use them for bonus stamp rules
all_clear.address = None
all_clear.place_locked_item(HatInTimeItem("Nothing", ItemClassification.filler, None, world.player))
all_clear.show_in_spoiler = False
# No need for rules if excluded - stamps will be auto-granted
if world.is_dw_excluded(name):
continue
modify_dw_rules(world, name)
add_dw_rules(world, main_objective)
add_dw_rules(world, all_clear)
add_rule(main_stamp, main_objective.access_rule)
add_rule(all_clear, main_objective.access_rule)
# Only set bonus stamp rules if we don't auto complete bonuses
if not world.options.DWAutoCompleteBonuses and not world.is_bonus_excluded(all_clear.name):
add_rule(bonus_stamps, all_clear.access_rule)
if world.options.DWShuffle:
for i in range(len(world.dw_shuffle)-1):
name = world.dw_shuffle[i+1]
prev_dw = world.multiworld.get_region(world.dw_shuffle[i], world.player)
entrance = world.multiworld.get_entrance(f"{prev_dw.name} -> {name}", world.player)
add_rule(entrance, lambda state, n=prev_dw.name: state.has(f"1 Stamp - {n}", world.player))
else:
for key, reqs in dw_prereqs.items():
if key == "Snatcher Coins in Nyakuza Metro" and not world.is_dlc2():
continue
access_rules: List[Callable[[CollectionState], bool]] = []
entrances: List[Entrance] = []
for parent in reqs:
entrance = world.multiworld.get_entrance(f"{parent} -> {key}", world.player)
entrances.append(entrance)
if not world.is_dw_excluded(parent):
access_rules.append(lambda state, n=parent: state.has(f"1 Stamp - {n}", world.player))
for entrance in entrances:
for rule in access_rules:
add_rule(entrance, rule)
if world.options.EndGoal == EndGoal.option_seal_the_deal:
world.multiworld.completion_condition[world.player] = lambda state: \
state.has("1 Stamp - Seal the Deal", world.player)
def add_dw_rules(world: "HatInTimeWorld", loc: Location):
bonus: bool = "All Clear" in loc.name
if not bonus:
data = dw_requirements.get(loc.name)
else:
data = dw_bonus_requirements.get(loc.name)
if data is None:
return
if data.hookshot:
add_rule(loc, lambda state: can_use_hookshot(state, world))
for hat in data.required_hats:
add_rule(loc, lambda state, h=hat: can_use_hat(state, world, h))
for misc in data.misc_required:
add_rule(loc, lambda state, item=misc: state.has(item, world.player))
if data.paintings > 0 and world.options.ShuffleSubconPaintings:
add_rule(loc, lambda state, paintings=data.paintings: has_paintings(state, world, paintings))
if data.hit_type is not HitType.none and world.options.UmbrellaLogic:
if data.hit_type == HitType.umbrella:
add_rule(loc, lambda state: state.has("Umbrella", world.player))
elif data.hit_type == HitType.umbrella_or_brewing:
add_rule(loc, lambda state: state.has("Umbrella", world.player)
or can_use_hat(state, world, HatType.BREWING))
elif data.hit_type == HitType.dweller_bell:
add_rule(loc, lambda state: state.has("Umbrella", world.player)
or can_use_hat(state, world, HatType.BREWING)
or can_use_hat(state, world, HatType.DWELLER))
def modify_dw_rules(world: "HatInTimeWorld", name: str):
difficulty: Difficulty = get_difficulty(world)
main_objective = world.multiworld.get_location(f"{name} - Main Objective", world.player)
full_clear = world.multiworld.get_location(f"{name} - All Clear", world.player)
if name == "The Illness has Speedrun":
# All stamps with hookshot only in Expert
if difficulty >= Difficulty.EXPERT:
set_rule(full_clear, lambda state: True)
else:
add_rule(main_objective, lambda state: state.has("Umbrella", world.player))
elif name == "The Mustache Gauntlet":
add_rule(main_objective, lambda state: state.has("Umbrella", world.player)
or can_use_hat(state, world, HatType.ICE) or can_use_hat(state, world, HatType.BREWING))
elif name == "Vault Codes in the Wind":
# Sprint is normally expected here
if difficulty >= Difficulty.HARD:
set_rule(main_objective, lambda state: True)
elif name == "Speedrun Well":
# All stamps with nothing :)
if difficulty >= Difficulty.EXPERT:
set_rule(main_objective, lambda state: True)
elif name == "Mafia's Jumps":
if difficulty >= Difficulty.HARD:
set_rule(main_objective, lambda state: True)
set_rule(full_clear, lambda state: True)
elif name == "So You're Back from Outer Space":
# Without Hookshot
if difficulty >= Difficulty.HARD:
set_rule(main_objective, lambda state: True)
elif name == "Wound-Up Windmill":
# No badge pin required. Player can switch to One Hit Hero after the checkpoint and do level without it.
if difficulty >= Difficulty.MODERATE:
set_rule(full_clear, lambda state: can_use_hookshot(state, world)
and state.has("One-Hit Hero Badge", world.player))
if name in dw_candles:
set_candle_dw_rules(name, world)
def set_candle_dw_rules(name: str, world: "HatInTimeWorld"):
main_objective = world.multiworld.get_location(f"{name} - Main Objective", world.player)
full_clear = world.multiworld.get_location(f"{name} - All Clear", world.player)
if name == "Zero Jumps":
add_rule(main_objective, lambda state: state.has("Zero Jumps", world.player))
add_rule(full_clear, lambda state: state.has("Zero Jumps", world.player, 4)
and state.has("Train Rush (Zero Jumps)", world.player) and can_use_hat(state, world, HatType.ICE))
# No Ice Hat/painting required in Expert for Toilet Zero Jumps
# This painting wall can only be skipped via cherry hover.
if get_difficulty(world) < Difficulty.EXPERT or world.options.NoPaintingSkips:
set_rule(world.multiworld.get_location("Toilet of Doom (Zero Jumps)", world.player),
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
and has_paintings(state, world, 1, False))
else:
set_rule(world.multiworld.get_location("Toilet of Doom (Zero Jumps)", world.player),
lambda state: can_use_hookshot(state, world) and can_hit(state, world))
set_rule(world.multiworld.get_location("Contractual Obligations (Zero Jumps)", world.player),
lambda state: has_paintings(state, world, 1, False))
elif name == "Snatcher's Hit List":
add_rule(main_objective, lambda state: state.has("Mafia Goon", world.player))
add_rule(full_clear, lambda state: state.has("Enemy", world.player, 12))
elif name == "Camera Tourist":
add_rule(main_objective, lambda state: state.has("Enemy", world.player, 8))
add_rule(full_clear, lambda state: state.has("Boss", world.player, 6)
and state.has("Triple Enemy Photo", world.player))
elif "Snatcher Coins" in name:
coins: List[str] = []
for coin in required_snatcher_coins[name]:
coins.append(coin)
add_rule(full_clear, lambda state, c=coin: state.has(c, world.player))
# any coin works for the main objective
add_rule(main_objective, lambda state: state.has(coins[0], world.player)
or state.has(coins[1], world.player)
or state.has(coins[2], world.player))
def create_enemy_events(world: "HatInTimeWorld"):
no_tourist = "Camera Tourist" in world.excluded_dws
for enemy, regions in hit_list.items():
if no_tourist and enemy in bosses:
continue
for area in regions:
if (area == "Bon Voyage!" or area == "Time Rift - Deep Sea") and not world.is_dlc1():
continue
if area == "Time Rift - Tour" and (not world.is_dlc1() or world.options.ExcludeTour):
continue
if area == "Bluefin Tunnel" and not world.is_dlc2():
continue
if world.options.DWShuffle and area in death_wishes.keys() and area not in world.dw_shuffle:
continue
region = world.multiworld.get_region(area, world.player)
event = HatInTimeLocation(world.player, f"{enemy} - {area}", None, region)
event.place_locked_item(HatInTimeItem(enemy, ItemClassification.progression, None, world.player))
region.locations.append(event)
event.show_in_spoiler = False
for name in triple_enemy_locations:
if name == "Time Rift - Tour" and (not world.is_dlc1() or world.options.ExcludeTour):
continue
if world.options.DWShuffle and name in death_wishes.keys() and name not in world.dw_shuffle:
continue
region = world.multiworld.get_region(name, world.player)
event = HatInTimeLocation(world.player, f"Triple Enemy Photo - {name}", None, region)
event.place_locked_item(HatInTimeItem("Triple Enemy Photo", ItemClassification.progression, None, world.player))
region.locations.append(event)
event.show_in_spoiler = False
if name == "The Mustache Gauntlet":
add_rule(event, lambda state: can_use_hookshot(state, world) and can_use_hat(state, world, HatType.DWELLER))
def set_enemy_rules(world: "HatInTimeWorld"):
no_tourist = "Camera Tourist" in world.excluded_dws or "Camera Tourist" in world.excluded_bonuses
for enemy, regions in hit_list.items():
if no_tourist and enemy in bosses:
continue
for area in regions:
if (area == "Bon Voyage!" or area == "Time Rift - Deep Sea") and not world.is_dlc1():
continue
if area == "Time Rift - Tour" and (not world.is_dlc1() or world.options.ExcludeTour):
continue
if area == "Bluefin Tunnel" and not world.is_dlc2():
continue
if world.options.DWShuffle and area in death_wishes and area not in world.dw_shuffle:
continue
event = world.multiworld.get_location(f"{enemy} - {area}", world.player)
if enemy == "Toxic Flower":
add_rule(event, lambda state: can_use_hookshot(state, world))
if area == "The Illness has Spread":
add_rule(event, lambda state: not zipline_logic(world) or
state.has("Zipline Unlock - The Birdhouse Path", world.player)
or state.has("Zipline Unlock - The Lava Cake Path", world.player)
or state.has("Zipline Unlock - The Windmill Path", world.player))
elif enemy == "Director":
if area == "Dead Bird Studio Basement":
add_rule(event, lambda state: can_use_hookshot(state, world))
elif enemy == "Snatcher" or enemy == "Mustache Girl":
if area == "Boss Rush":
# need to be able to kill toilet and snatcher
add_rule(event, lambda state: can_hit(state, world) and can_use_hookshot(state, world))
if enemy == "Mustache Girl":
add_rule(event, lambda state: can_hit(state, world, True) and can_use_hookshot(state, world))
elif area == "The Finale" and enemy == "Mustache Girl":
add_rule(event, lambda state: can_use_hookshot(state, world)
and can_use_hat(state, world, HatType.DWELLER))
elif enemy == "Shock Squid" or enemy == "Ninja Cat":
if area == "Time Rift - Deep Sea":
add_rule(event, lambda state: can_use_hookshot(state, world))
# Enemies for Snatcher's Hit List/Camera Tourist, and where to find them
hit_list = {
"Mafia Goon": ["Mafia Town Area", "Time Rift - Mafia of Cooks", "Time Rift - Tour",
"Bon Voyage!", "The Mustache Gauntlet", "Rift Collapse: Mafia of Cooks",
"So You're Back From Outer Space"],
"Sleepy Raccoon": ["She Came from Outer Space", "Down with the Mafia!", "The Twilight Bell",
"She Speedran from Outer Space", "Mafia's Jumps", "The Mustache Gauntlet",
"Time Rift - Sleepy Subcon", "Rift Collapse: Sleepy Subcon"],
"UFO": ["Picture Perfect", "So You're Back From Outer Space", "Community Rift: Rhythm Jump Studio"],
"Rat": ["Down with the Mafia!", "Bluefin Tunnel"],
"Shock Squid": ["Bon Voyage!", "Time Rift - Sleepy Subcon", "Time Rift - Deep Sea",
"Rift Collapse: Sleepy Subcon"],
"Shromb Egg": ["The Birdhouse", "Bird Sanctuary"],
"Spider": ["Subcon Forest Area", "The Mustache Gauntlet", "Speedrun Well",
"The Lava Cake", "The Windmill"],
"Crow": ["Mafia Town Area", "The Birdhouse", "Time Rift - Tour", "Bird Sanctuary",
"Time Rift - Alpine Skyline", "Rift Collapse: Alpine Skyline"],
"Pompous Crow": ["The Birdhouse", "Time Rift - The Lab", "Bird Sanctuary", "The Mustache Gauntlet"],
"Fiery Crow": ["The Finale", "The Lava Cake", "The Mustache Gauntlet"],
"Express Owl": ["The Finale", "Time Rift - The Owl Express", "Time Rift - Deep Sea"],
"Ninja Cat": ["The Birdhouse", "The Windmill", "Bluefin Tunnel", "The Mustache Gauntlet",
"Time Rift - Curly Tail Trail", "Time Rift - Alpine Skyline", "Time Rift - Deep Sea",
"Rift Collapse: Alpine Skyline"],
# Bosses
"Mafia Boss": ["Down with the Mafia!", "Encore! Encore!", "Boss Rush"],
"Conductor": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
"Toilet": ["Toilet of Doom", "Boss Rush"],
"Snatcher": ["Your Contract has Expired", "Breaching the Contract", "Boss Rush",
"Quality Time with Snatcher"],
"Toxic Flower": ["The Illness has Spread", "The Illness has Speedrun"],
"Mustache Girl": ["The Finale", "Boss Rush", "No More Bad Guys"],
}
# Camera Tourist has a bonus that requires getting three different types of enemies in one photo.
triple_enemy_locations = [
"She Came from Outer Space",
"She Speedran from Outer Space",
"Mafia's Jumps",
"The Mustache Gauntlet",
"The Birdhouse",
"Bird Sanctuary",
"Time Rift - Tour",
]
bosses = [
"Mafia Boss",
"Conductor",
"Toilet",
"Snatcher",
"Toxic Flower",
"Mustache Girl",
]

View File

@@ -1,302 +0,0 @@
from BaseClasses import Item, ItemClassification
from .Types import HatDLC, HatType, hat_type_to_item, Difficulty, ItemData, HatInTimeItem
from .Locations import get_total_locations
from .Rules import get_difficulty
from .Options import get_total_time_pieces, CTRLogic
from typing import List, Dict, TYPE_CHECKING
if TYPE_CHECKING:
from . import HatInTimeWorld
def create_itempool(world: "HatInTimeWorld") -> List[Item]:
itempool: List[Item] = []
if world.has_yarn():
yarn_pool: List[Item] = create_multiple_items(world, "Yarn",
world.options.YarnAvailable.value,
ItemClassification.progression_skip_balancing)
for i in range(int(len(yarn_pool) * (0.01 * world.options.YarnBalancePercent))):
yarn_pool[i].classification = ItemClassification.progression
itempool += yarn_pool
for name in item_table.keys():
if name == "Yarn":
continue
if not item_dlc_enabled(world, name):
continue
if not world.options.HatItems and name in hat_type_to_item.values():
continue
item_type: ItemClassification = item_table.get(name).classification
if world.is_dw_only():
if item_type is ItemClassification.progression \
or item_type is ItemClassification.progression_skip_balancing:
continue
else:
if name == "Scooter Badge":
if world.options.CTRLogic is CTRLogic.option_scooter or get_difficulty(world) >= Difficulty.MODERATE:
item_type = ItemClassification.progression
elif name == "No Bonk Badge" and world.is_dw():
item_type = ItemClassification.progression
# some death wish bonuses require one hit hero + hookshot
if world.is_dw() and name == "Badge Pin" and not world.is_dw_only():
item_type = ItemClassification.progression
if item_type is ItemClassification.filler or item_type is ItemClassification.trap:
continue
if name in act_contracts.keys() and not world.options.ShuffleActContracts:
continue
if name in alps_hooks.keys() and not world.options.ShuffleAlpineZiplines:
continue
if name == "Progressive Painting Unlock" and not world.options.ShuffleSubconPaintings:
continue
if world.options.StartWithCompassBadge and name == "Compass Badge":
continue
if name == "Time Piece":
tp_list: List[Item] = create_multiple_items(world, name, get_total_time_pieces(world), item_type)
for i in range(int(len(tp_list) * (0.01 * world.options.TimePieceBalancePercent))):
tp_list[i].classification = ItemClassification.progression
itempool += tp_list
continue
itempool += create_multiple_items(world, name, item_frequencies.get(name, 1), item_type)
itempool += create_junk_items(world, get_total_locations(world) - len(itempool))
return itempool
def calculate_yarn_costs(world: "HatInTimeWorld"):
min_yarn_cost = int(min(world.options.YarnCostMin.value, world.options.YarnCostMax.value))
max_yarn_cost = int(max(world.options.YarnCostMin.value, world.options.YarnCostMax.value))
max_cost = 0
for i in range(5):
hat: HatType = HatType(i)
if not world.is_hat_precollected(hat):
cost: int = world.random.randint(min_yarn_cost, max_yarn_cost)
world.hat_yarn_costs[hat] = cost
max_cost += cost
else:
world.hat_yarn_costs[hat] = 0
available_yarn: int = world.options.YarnAvailable.value
if max_cost > available_yarn:
world.options.YarnAvailable.value = max_cost
available_yarn = max_cost
extra_yarn = max_cost + world.options.MinExtraYarn - available_yarn
if extra_yarn > 0:
world.options.YarnAvailable.value += extra_yarn
def item_dlc_enabled(world: "HatInTimeWorld", name: str) -> bool:
data = item_table[name]
if data.dlc_flags == HatDLC.none:
return True
elif data.dlc_flags == HatDLC.dlc1 and world.is_dlc1():
return True
elif data.dlc_flags == HatDLC.dlc2 and world.is_dlc2():
return True
elif data.dlc_flags == HatDLC.death_wish and world.is_dw():
return True
return False
def create_item(world: "HatInTimeWorld", name: str) -> Item:
data = item_table[name]
return HatInTimeItem(name, data.classification, data.code, world.player)
def create_multiple_items(world: "HatInTimeWorld", name: str, count: int = 1,
item_type: ItemClassification = ItemClassification.progression) -> List[Item]:
data = item_table[name]
itemlist: List[Item] = []
for i in range(count):
itemlist += [HatInTimeItem(name, item_type, data.code, world.player)]
return itemlist
def create_junk_items(world: "HatInTimeWorld", count: int) -> List[Item]:
trap_chance = world.options.TrapChance.value
junk_pool: List[Item] = []
junk_list: Dict[str, int] = {}
trap_list: Dict[str, int] = {}
ic: ItemClassification
for name in item_table.keys():
ic = item_table[name].classification
if ic == ItemClassification.filler:
if world.is_dw_only() and "Pons" in name:
continue
junk_list[name] = junk_weights.get(name)
elif trap_chance > 0 and ic == ItemClassification.trap:
if name == "Baby Trap":
trap_list[name] = world.options.BabyTrapWeight.value
elif name == "Laser Trap":
trap_list[name] = world.options.LaserTrapWeight.value
elif name == "Parade Trap":
trap_list[name] = world.options.ParadeTrapWeight.value
for i in range(count):
if trap_chance > 0 and world.random.randint(1, 100) <= trap_chance:
junk_pool.append(world.create_item(
world.random.choices(list(trap_list.keys()), weights=list(trap_list.values()), k=1)[0]))
else:
junk_pool.append(world.create_item(
world.random.choices(list(junk_list.keys()), weights=list(junk_list.values()), k=1)[0]))
return junk_pool
def get_shop_trap_name(world: "HatInTimeWorld") -> str:
rand = world.random.randint(1, 9)
name = ""
if rand == 1:
name = "Time Plece"
elif rand == 2:
name = "Time Piece (Trust me bro)"
elif rand == 3:
name = "TimePiece"
elif rand == 4:
name = "Time Piece?"
elif rand == 5:
name = "Time Pizza"
elif rand == 6:
name = "Time piece"
elif rand == 7:
name = "TIme Piece"
elif rand == 8:
name = "Time Piece (maybe)"
elif rand == 9:
name = "Time Piece ;)"
return name
ahit_items = {
"Yarn": ItemData(2000300001, ItemClassification.progression_skip_balancing),
"Time Piece": ItemData(2000300002, ItemClassification.progression_skip_balancing),
# for HatItems option
"Sprint Hat": ItemData(2000300049, ItemClassification.progression),
"Brewing Hat": ItemData(2000300050, ItemClassification.progression),
"Ice Hat": ItemData(2000300051, ItemClassification.progression),
"Dweller Mask": ItemData(2000300052, ItemClassification.progression),
"Time Stop Hat": ItemData(2000300053, ItemClassification.progression),
# Badges
"Projectile Badge": ItemData(2000300024, ItemClassification.useful),
"Fast Hatter Badge": ItemData(2000300025, ItemClassification.useful),
"Hover Badge": ItemData(2000300026, ItemClassification.useful),
"Hookshot Badge": ItemData(2000300027, ItemClassification.progression),
"Item Magnet Badge": ItemData(2000300028, ItemClassification.useful),
"No Bonk Badge": ItemData(2000300029, ItemClassification.useful),
"Compass Badge": ItemData(2000300030, ItemClassification.useful),
"Scooter Badge": ItemData(2000300031, ItemClassification.useful),
"One-Hit Hero Badge": ItemData(2000300038, ItemClassification.progression, HatDLC.death_wish),
"Camera Badge": ItemData(2000300042, ItemClassification.progression, HatDLC.death_wish),
# Relics
"Relic (Burger Patty)": ItemData(2000300006, ItemClassification.progression),
"Relic (Burger Cushion)": ItemData(2000300007, ItemClassification.progression),
"Relic (Mountain Set)": ItemData(2000300008, ItemClassification.progression),
"Relic (Train)": ItemData(2000300009, ItemClassification.progression),
"Relic (UFO)": ItemData(2000300010, ItemClassification.progression),
"Relic (Cow)": ItemData(2000300011, ItemClassification.progression),
"Relic (Cool Cow)": ItemData(2000300012, ItemClassification.progression),
"Relic (Tin-foil Hat Cow)": ItemData(2000300013, ItemClassification.progression),
"Relic (Crayon Box)": ItemData(2000300014, ItemClassification.progression),
"Relic (Red Crayon)": ItemData(2000300015, ItemClassification.progression),
"Relic (Blue Crayon)": ItemData(2000300016, ItemClassification.progression),
"Relic (Green Crayon)": ItemData(2000300017, ItemClassification.progression),
# DLC
"Relic (Cake Stand)": ItemData(2000300018, ItemClassification.progression, HatDLC.dlc1),
"Relic (Shortcake)": ItemData(2000300019, ItemClassification.progression, HatDLC.dlc1),
"Relic (Chocolate Cake Slice)": ItemData(2000300020, ItemClassification.progression, HatDLC.dlc1),
"Relic (Chocolate Cake)": ItemData(2000300021, ItemClassification.progression, HatDLC.dlc1),
"Relic (Necklace Bust)": ItemData(2000300022, ItemClassification.progression, HatDLC.dlc2),
"Relic (Necklace)": ItemData(2000300023, ItemClassification.progression, HatDLC.dlc2),
# Garbage items
"25 Pons": ItemData(2000300034, ItemClassification.filler),
"50 Pons": ItemData(2000300035, ItemClassification.filler),
"100 Pons": ItemData(2000300036, ItemClassification.filler),
"Health Pon": ItemData(2000300037, ItemClassification.filler),
"Random Cosmetic": ItemData(2000300044, ItemClassification.filler),
# Traps
"Baby Trap": ItemData(2000300039, ItemClassification.trap),
"Laser Trap": ItemData(2000300040, ItemClassification.trap),
"Parade Trap": ItemData(2000300041, ItemClassification.trap),
# Other
"Badge Pin": ItemData(2000300043, ItemClassification.useful),
"Umbrella": ItemData(2000300033, ItemClassification.progression),
"Progressive Painting Unlock": ItemData(2000300003, ItemClassification.progression),
# DLC
"Metro Ticket - Yellow": ItemData(2000300045, ItemClassification.progression, HatDLC.dlc2),
"Metro Ticket - Green": ItemData(2000300046, ItemClassification.progression, HatDLC.dlc2),
"Metro Ticket - Blue": ItemData(2000300047, ItemClassification.progression, HatDLC.dlc2),
"Metro Ticket - Pink": ItemData(2000300048, ItemClassification.progression, HatDLC.dlc2),
}
act_contracts = {
"Snatcher's Contract - The Subcon Well": ItemData(2000300200, ItemClassification.progression),
"Snatcher's Contract - Toilet of Doom": ItemData(2000300201, ItemClassification.progression),
"Snatcher's Contract - Queen Vanessa's Manor": ItemData(2000300202, ItemClassification.progression),
"Snatcher's Contract - Mail Delivery Service": ItemData(2000300203, ItemClassification.progression),
}
alps_hooks = {
"Zipline Unlock - The Birdhouse Path": ItemData(2000300204, ItemClassification.progression),
"Zipline Unlock - The Lava Cake Path": ItemData(2000300205, ItemClassification.progression),
"Zipline Unlock - The Windmill Path": ItemData(2000300206, ItemClassification.progression),
"Zipline Unlock - The Twilight Bell Path": ItemData(2000300207, ItemClassification.progression),
}
relic_groups = {
"Burger": {"Relic (Burger Patty)", "Relic (Burger Cushion)"},
"Train": {"Relic (Mountain Set)", "Relic (Train)"},
"UFO": {"Relic (UFO)", "Relic (Cow)", "Relic (Cool Cow)", "Relic (Tin-foil Hat Cow)"},
"Crayon": {"Relic (Crayon Box)", "Relic (Red Crayon)", "Relic (Blue Crayon)", "Relic (Green Crayon)"},
"Cake": {"Relic (Cake Stand)", "Relic (Chocolate Cake)", "Relic (Chocolate Cake Slice)", "Relic (Shortcake)"},
"Necklace": {"Relic (Necklace Bust)", "Relic (Necklace)"},
}
item_frequencies = {
"Badge Pin": 2,
"Progressive Painting Unlock": 3,
}
junk_weights = {
"25 Pons": 50,
"50 Pons": 25,
"100 Pons": 10,
"Health Pon": 35,
"Random Cosmetic": 35,
}
item_table = {
**ahit_items,
**act_contracts,
**alps_hooks,
}

Some files were not shown because too many files have changed in this diff Show More