mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-10 17:43:53 -07:00
Compare commits
126 Commits
0.4.6
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3d7f247d1 | ||
|
|
d09b214309 | ||
|
|
860ab10b0b | ||
|
|
3f8c348a49 | ||
|
|
e1ff5073b5 | ||
|
|
8b6eae0a14 | ||
|
|
89d0dae299 | ||
|
|
56d01f3913 | ||
|
|
a43e294786 | ||
|
|
92392c0e65 | ||
|
|
893a157b23 | ||
|
|
02d3fdf2a6 | ||
|
|
cd160842ba | ||
|
|
93f63a3e31 | ||
|
|
b4fec93c82 | ||
|
|
1ae0a9b76f | ||
|
|
0ea20f3929 | ||
|
|
20134d3b1e | ||
|
|
a1c2e8715e | ||
|
|
61be79b7ea | ||
|
|
e7544d835c | ||
|
|
62e68ba1cc | ||
|
|
9441cc31b7 | ||
|
|
5c66681233 | ||
|
|
92b1f3cd19 | ||
|
|
514ad69f44 | ||
|
|
461f5db35a | ||
|
|
fe7bc8784d | ||
|
|
c792ae76ca | ||
|
|
bfe215d5a7 | ||
|
|
5910b94deb | ||
|
|
14ffd1c70c | ||
|
|
754fc11c1b | ||
|
|
12cde88f95 | ||
|
|
e0b6889634 | ||
|
|
14321d6ba2 | ||
|
|
e978109410 | ||
|
|
019dfb8242 | ||
|
|
8e9a050889 | ||
|
|
2801e21296 | ||
|
|
e97eddcdaf | ||
|
|
d3f4ee4994 | ||
|
|
cf34f125d6 | ||
|
|
663b50b33e | ||
|
|
230a9e620b | ||
|
|
1b6fb7b090 | ||
|
|
0e893889c7 | ||
|
|
2bc345504e | ||
|
|
5e3c5dedf3 | ||
|
|
5fb0126754 | ||
|
|
b4c263fc9d | ||
|
|
013862b068 | ||
|
|
280b67f996 | ||
|
|
9ae7083bfc | ||
|
|
bd18018852 | ||
|
|
b4b79bcd78 | ||
|
|
539ee1c5da | ||
|
|
5fb1d0f98a | ||
|
|
89a2a3c35b | ||
|
|
7900e4c9a4 | ||
|
|
3dbdd048cd | ||
|
|
68323b46a9 | ||
|
|
2447be92d8 | ||
|
|
88dd27eb3a | ||
|
|
6d8ac5d054 | ||
|
|
5a2d839412 | ||
|
|
4bd4a2c541 | ||
|
|
705cb2e816 | ||
|
|
467bbd7754 | ||
|
|
4da9cdd91c | ||
|
|
6576b069f2 | ||
|
|
b78781ab3e | ||
|
|
9a82edc931 | ||
|
|
77cce68c08 | ||
|
|
f38655d6b6 | ||
|
|
701fbab837 | ||
|
|
af83050b75 | ||
|
|
8db3e40094 | ||
|
|
d48f2ab1b4 | ||
|
|
0f1b16d640 | ||
|
|
76962b8b3b | ||
|
|
e04db57dce | ||
|
|
12b8fef1aa | ||
|
|
0ac8844f6f | ||
|
|
23eca7d747 | ||
|
|
1a563a14fc | ||
|
|
5935093615 | ||
|
|
2aa3ef372d | ||
|
|
d94cf8dcb2 | ||
|
|
5fae1c087e | ||
|
|
7e61211365 | ||
|
|
7603b4a88f | ||
|
|
005fc4e864 | ||
|
|
28262a31b8 | ||
|
|
660b068f5a | ||
|
|
879c3407d8 | ||
|
|
d5683c4326 | ||
|
|
f27d1d635b | ||
|
|
298c9fc159 | ||
|
|
26188230b7 | ||
|
|
b68be7360c | ||
|
|
255e52642e | ||
|
|
49862dca1f | ||
|
|
0d586a4467 | ||
|
|
8c8b29ae92 | ||
|
|
9d478ba2bc | ||
|
|
3cc434cd78 | ||
|
|
31a5696526 | ||
|
|
7bdf9a643c | ||
|
|
c64c80aac0 | ||
|
|
07d9d6165e | ||
|
|
fc571ba356 | ||
|
|
ea6235e0d9 | ||
|
|
6f8b8fc9c9 | ||
|
|
0ed0de3daa | ||
|
|
487a067d10 | ||
|
|
fc4e6adff5 | ||
|
|
9cdc90513b | ||
|
|
9afe45166c | ||
|
|
9e20fa48e1 | ||
|
|
e76ba928a8 | ||
|
|
4f1e696243 | ||
|
|
4756c76541 | ||
|
|
2f78860d8c | ||
|
|
cca9778871 | ||
|
|
bb16fe284a |
8
AHITClient.py
Normal file
8
AHITClient.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from worlds.ahit.Client import launch
|
||||
import Utils
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
if __name__ == "__main__":
|
||||
Utils.init_logging("AHITClient", exception_logger="Client")
|
||||
launch()
|
||||
@@ -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, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
|
||||
Type, ClassVar
|
||||
from typing import Any, Callable, Dict, Iterable, Iterator, List, Mapping, NamedTuple, Optional, Set, Tuple, \
|
||||
TypedDict, Union, Type, ClassVar
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
@@ -707,15 +707,49 @@ 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 item_count(self, item: str, player: int) -> int:
|
||||
Utils.deprecate("Use count instead.")
|
||||
return self.count(item, player)
|
||||
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)
|
||||
|
||||
# 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]:
|
||||
@@ -724,12 +758,34 @@ class CollectionState():
|
||||
return True
|
||||
return False
|
||||
|
||||
def count_group(self, item_name_group: str, player: int) -> int:
|
||||
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]
|
||||
return found
|
||||
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."""
|
||||
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]
|
||||
)
|
||||
|
||||
# Item related
|
||||
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
|
||||
@@ -990,7 +1046,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.non_local_items[item.player])
|
||||
return ((self.always_allow(state, item) and item.name not in state.multiworld.worlds[item.player].options.non_local_items)
|
||||
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))))
|
||||
@@ -1186,7 +1242,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.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
if any([multiworld.worlds[location.item.player].options.accessibility != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
|
||||
@@ -207,6 +207,8 @@ class CommonContext:
|
||||
|
||||
finished_game: bool
|
||||
ready: bool
|
||||
team: typing.Optional[int]
|
||||
slot: typing.Optional[int]
|
||||
auth: typing.Optional[str]
|
||||
seed_name: typing.Optional[str]
|
||||
|
||||
|
||||
82
Fill.py
82
Fill.py
@@ -19,11 +19,12 @@ 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()) -> CollectionState:
|
||||
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple(),
|
||||
locations: typing.Optional[typing.List[Location]] = None) -> CollectionState:
|
||||
new_state = base_state.copy()
|
||||
for item in itempool:
|
||||
new_state.collect(item, True)
|
||||
new_state.sweep_for_events()
|
||||
new_state.sweep_for_events(locations=locations)
|
||||
return new_state
|
||||
|
||||
|
||||
@@ -34,8 +35,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
|
||||
:param item_pool: Items to fill into the locations
|
||||
: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 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
|
||||
@@ -66,7 +67,8 @@ 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)
|
||||
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
|
||||
has_beaten_game = multiworld.has_beaten_game(maximum_exploration_state)
|
||||
|
||||
@@ -112,7 +114,9 @@ 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)
|
||||
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)
|
||||
# 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.
|
||||
@@ -170,7 +174,9 @@ 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, [])
|
||||
state = sweep_from_pool(
|
||||
base_state, [], multiworld.get_filled_locations(item.player)
|
||||
if single_player_placement else None)
|
||||
for placement in placements:
|
||||
if multiworld.worlds[placement.item.player].options.accessibility != "minimal" and not placement.can_reach(state):
|
||||
placement.item.location = None
|
||||
@@ -214,7 +220,8 @@ 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") -> None:
|
||||
name: str = "Remaining",
|
||||
move_unplaceable_to_start_inventory: bool = False) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
|
||||
@@ -278,13 +285,21 @@ def remaining_fill(multiworld: MultiWorld,
|
||||
|
||||
if unplaced_items and locations:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
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)}")
|
||||
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)}")
|
||||
|
||||
itempool.extend(unplaced_items)
|
||||
|
||||
@@ -414,7 +429,8 @@ def distribute_early_items(multiworld: MultiWorld,
|
||||
return fill_locations, itempool
|
||||
|
||||
|
||||
def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
||||
def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
panic_method: typing.Literal["swap", "raise", "start_inventory"] = "swap") -> None:
|
||||
fill_locations = sorted(multiworld.get_unfilled_locations())
|
||||
multiworld.random.shuffle(fill_locations)
|
||||
# get items to distribute
|
||||
@@ -456,14 +472,37 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
||||
|
||||
if prioritylocations:
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
single_player_placement=multiworld.players == 1, swap=False, on_place=mark_for_locking,
|
||||
name="Priority")
|
||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
if progitempool:
|
||||
# "advancement/progression fill"
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, name="Progression")
|
||||
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.")
|
||||
if progitempool:
|
||||
raise FillError(
|
||||
f"Not enough locations for progression items. "
|
||||
@@ -478,7 +517,9 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
||||
|
||||
inaccessible_location_rules(multiworld, multiworld.state, defaultlocations)
|
||||
|
||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded")
|
||||
remaining_fill(multiworld, excludedlocations, filleritempool, "Remaining Excluded",
|
||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
||||
|
||||
if excludedlocations:
|
||||
raise FillError(
|
||||
f"Not enough filler items for excluded locations. "
|
||||
@@ -487,7 +528,8 @@ def distribute_items_restrictive(multiworld: MultiWorld) -> None:
|
||||
|
||||
restitempool = filleritempool + usefulitempool
|
||||
|
||||
remaining_fill(multiworld, defaultlocations, restitempool)
|
||||
remaining_fill(multiworld, defaultlocations, restitempool,
|
||||
move_unplaceable_to_start_inventory=panic_method=="start_inventory")
|
||||
|
||||
unplaced = restitempool
|
||||
unfilled = defaultlocations
|
||||
|
||||
61
Generate.py
61
Generate.py
@@ -9,6 +9,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Tuple, Union
|
||||
from itertools import chain
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -120,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())}
|
||||
weights_cache = {key: value for key, value in sorted(weights_cache.items(), key=lambda k: k[0].casefold())}
|
||||
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:
|
||||
@@ -319,18 +320,34 @@ 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, dict)):
|
||||
if isinstance(new_value, set):
|
||||
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)
|
||||
@@ -353,7 +370,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 Exception(f"Error generating meta option {option_key} for {game}.")
|
||||
raise Options.OptionError(f"Error generating meta option {option_key} for {game}.")
|
||||
|
||||
|
||||
def roll_linked_options(weights: dict) -> dict:
|
||||
@@ -378,7 +395,7 @@ def roll_linked_options(weights: dict) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> 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):
|
||||
@@ -401,7 +418,7 @@ def roll_triggers(weights: dict, triggers: list) -> 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
|
||||
@@ -409,27 +426,29 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
try:
|
||||
if option_key in game_weights:
|
||||
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))
|
||||
setattr(ret, option_key, player_option)
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
|
||||
del game_weights[option_key]
|
||||
else:
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
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
|
||||
else:
|
||||
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
|
||||
|
||||
|
||||
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"])
|
||||
weights = roll_triggers(weights, weights["triggers"], valid_trigger_names)
|
||||
|
||||
requirements = weights.get("requires", {})
|
||||
if requirements:
|
||||
@@ -464,12 +483,14 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
||||
world_type = AutoWorldRegister.world_types[ret.game]
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
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.")
|
||||
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 "triggers" in game_weights:
|
||||
weights = roll_triggers(weights, game_weights["triggers"])
|
||||
weights = roll_triggers(weights, game_weights["triggers"], valid_trigger_names)
|
||||
game_weights = weights[ret.game]
|
||||
|
||||
ret.name = get_choice('name', weights)
|
||||
@@ -478,6 +499,10 @@ 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":
|
||||
|
||||
@@ -259,7 +259,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
|
||||
elif not args:
|
||||
args = {}
|
||||
|
||||
if "Patch|Game|Component" in args:
|
||||
if args.get("Patch|Game|Component", None) is not None:
|
||||
file, component = identify(args["Patch|Game|Component"])
|
||||
if file:
|
||||
args['file'] = file
|
||||
|
||||
4
Main.py
4
Main.py
@@ -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
|
||||
from Utils import __version__, output_path, version_tuple, get_settings
|
||||
from settings import get_settings
|
||||
from worlds import AutoWorld
|
||||
from worlds.generic.Rules import exclusion_rules, locality_rules
|
||||
@@ -272,7 +272,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)
|
||||
distribute_items_restrictive(multiworld, get_settings().generator.panic_method)
|
||||
|
||||
AutoWorld.call_all(multiworld, 'post_fill')
|
||||
|
||||
|
||||
119
MultiServer.py
119
MultiServer.py
@@ -175,11 +175,13 @@ 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):
|
||||
log_network: bool = False, logger: logging.Logger = logging.getLogger()):
|
||||
self.logger = logger
|
||||
super(Context, self).__init__()
|
||||
self.slot_info = {}
|
||||
self.log_network = log_network
|
||||
@@ -287,12 +289,12 @@ class Context:
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception(f"Exception during send_msgs, could not send {msg}")
|
||||
self.logger.exception(f"Exception during send_msgs, could not send {msg}")
|
||||
await self.disconnect(endpoint)
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
self.logger.info(f"Outgoing message: {msg}")
|
||||
return True
|
||||
|
||||
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
|
||||
@@ -301,12 +303,12 @@ class Context:
|
||||
try:
|
||||
await endpoint.socket.send(msg)
|
||||
except websockets.ConnectionClosed:
|
||||
logging.exception("Exception during send_encoded_msgs")
|
||||
self.logger.exception("Exception during send_encoded_msgs")
|
||||
await self.disconnect(endpoint)
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing message: {msg}")
|
||||
self.logger.info(f"Outgoing message: {msg}")
|
||||
return True
|
||||
|
||||
async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint], msg: str) -> bool:
|
||||
@@ -317,11 +319,11 @@ class Context:
|
||||
try:
|
||||
websockets.broadcast(sockets, msg)
|
||||
except RuntimeError:
|
||||
logging.exception("Exception during broadcast_send_encoded_msgs")
|
||||
self.logger.exception("Exception during broadcast_send_encoded_msgs")
|
||||
return False
|
||||
else:
|
||||
if self.log_network:
|
||||
logging.info(f"Outgoing broadcast: {msg}")
|
||||
self.logger.info(f"Outgoing broadcast: {msg}")
|
||||
return True
|
||||
|
||||
def broadcast_all(self, msgs: typing.List[dict]):
|
||||
@@ -330,7 +332,7 @@ class Context:
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
|
||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||
logging.info("Notice (all): %s" % text)
|
||||
self.logger.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]):
|
||||
@@ -352,7 +354,7 @@ class Context:
|
||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
return
|
||||
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
self.logger.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 = {}):
|
||||
@@ -451,7 +453,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]
|
||||
logging.info(f"Loading embedded data package for game {game_name}")
|
||||
self.logger.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:
|
||||
@@ -483,7 +485,7 @@ class Context:
|
||||
with open(self.save_filename, "wb") as f:
|
||||
f.write(zlib.compress(encoded_save))
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
self.logger.exception(e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
@@ -501,12 +503,12 @@ class Context:
|
||||
save_data = restricted_loads(zlib.decompress(f.read()))
|
||||
self.set_save(save_data)
|
||||
except FileNotFoundError:
|
||||
logging.error('No save data found, starting a new game')
|
||||
self.logger.error('No save data found, starting a new game')
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
self.logger.exception(e)
|
||||
self._start_async_saving()
|
||||
|
||||
def _start_async_saving(self):
|
||||
def _start_async_saving(self, atexit_save: bool = True):
|
||||
if not self.auto_saver_thread:
|
||||
def save_regularly():
|
||||
# time.time() is platform dependent, so using the expensive datetime method instead
|
||||
@@ -520,18 +522,19 @@ class Context:
|
||||
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
|
||||
time.sleep(max(1.0, next_wakeup))
|
||||
if self.save_dirty:
|
||||
logging.debug("Saving via thread.")
|
||||
self.logger.debug("Saving via thread.")
|
||||
self._save()
|
||||
except OperationalError as e:
|
||||
logging.exception(e)
|
||||
logging.info(f"Saving failed. Retry in {self.auto_save_interval} seconds.")
|
||||
self.logger.exception(e)
|
||||
self.logger.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()
|
||||
|
||||
import atexit
|
||||
atexit.register(self._save, True) # make sure we save on exit too
|
||||
if atexit_save:
|
||||
import atexit
|
||||
atexit.register(self._save, True) # make sure we save on exit too
|
||||
|
||||
def get_save(self) -> dict:
|
||||
self.recheck_hints()
|
||||
@@ -598,7 +601,7 @@ class Context:
|
||||
if "stored_data" in savedata:
|
||||
self.stored_data = savedata["stored_data"]
|
||||
# count items and slots from lists for items_handling = remote
|
||||
logging.info(
|
||||
self.logger.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')
|
||||
|
||||
@@ -640,13 +643,13 @@ class Context:
|
||||
try:
|
||||
raise Exception(f"Could not set server option {key}, skipping.") from e
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
|
||||
self.logger.exception(e)
|
||||
self.logger.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:
|
||||
logging.debug(f"Unrecognized server option {key}")
|
||||
self.logger.debug(f"Unrecognized server option {key}")
|
||||
|
||||
def get_aliased_name(self, team: int, slot: int):
|
||||
if (team, slot) in self.name_aliases:
|
||||
@@ -680,7 +683,7 @@ class Context:
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
|
||||
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||
self.logger.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():
|
||||
@@ -688,7 +691,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))
|
||||
|
||||
@@ -739,21 +742,21 @@ async def server(websocket, path: str = "/", ctx: Context = None):
|
||||
|
||||
try:
|
||||
if ctx.log_network:
|
||||
logging.info("Incoming connection")
|
||||
ctx.logger.info("Incoming connection")
|
||||
await on_client_connected(ctx, client)
|
||||
if ctx.log_network:
|
||||
logging.info("Sent Room Info")
|
||||
ctx.logger.info("Sent Room Info")
|
||||
async for data in websocket:
|
||||
if ctx.log_network:
|
||||
logging.info(f"Incoming message: {data}")
|
||||
ctx.logger.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):
|
||||
logging.exception(e)
|
||||
ctx.logger.exception(e)
|
||||
finally:
|
||||
if ctx.log_network:
|
||||
logging.info("Disconnected")
|
||||
ctx.logger.info("Disconnected")
|
||||
await ctx.disconnect(client)
|
||||
|
||||
|
||||
@@ -803,14 +806,25 @@ 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)
|
||||
verb = "tracking" if "Tracker" in client.tags else "playing"
|
||||
|
||||
for tag, verb in _non_game_messages.items():
|
||||
if tag in client.tags:
|
||||
final_verb = verb
|
||||
break
|
||||
else:
|
||||
final_verb = "playing"
|
||||
|
||||
ctx.broadcast_text_all(
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
|
||||
f"{verb} {ctx.games[client.slot]} has joined. "
|
||||
f"{final_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, "
|
||||
@@ -825,8 +839,19 @@ 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(
|
||||
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has {final_verb} the game. "
|
||||
f"Client({version_str}), {client.tags}.",
|
||||
{"type": "Part", "team": client.team, "slot": client.slot})
|
||||
|
||||
|
||||
@@ -963,7 +988,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)
|
||||
|
||||
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||
ctx.logger.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)
|
||||
@@ -1507,15 +1532,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
if hints:
|
||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||
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.")
|
||||
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.")
|
||||
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:
|
||||
@@ -1527,7 +1550,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
|
||||
hints = found_hints + old_hints
|
||||
while can_pay > 0:
|
||||
if not not_found_hints:
|
||||
break
|
||||
@@ -1537,6 +1560,7 @@ 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(
|
||||
@@ -1550,7 +1574,6 @@ 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
|
||||
|
||||
@@ -1605,7 +1628,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
try:
|
||||
cmd: str = args["cmd"]
|
||||
except:
|
||||
logging.exception(f"Could not get command from {args}")
|
||||
ctx.logger.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
|
||||
@@ -1631,7 +1654,9 @@ 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 = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
|
||||
|
||||
ignore_game = not args.get("game") and any(tag in _non_game_messages for tag in args["tags"])
|
||||
|
||||
if not ignore_game and args['game'] != game:
|
||||
errors.add('InvalidGame')
|
||||
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
|
||||
@@ -1646,7 +1671,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:
|
||||
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
|
||||
ctx.logger.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']]
|
||||
@@ -2198,7 +2223,7 @@ async def console(ctx: Context):
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
defaults = Utils.get_options()["server_options"].as_dict()
|
||||
defaults = Utils.get_settings()["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)
|
||||
@@ -2264,7 +2289,7 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
if to_cancel:
|
||||
for task in to_cancel:
|
||||
task.cancel()
|
||||
logging.info("Shutting down due to inactivity.")
|
||||
ctx.logger.info("Shutting down due to inactivity.")
|
||||
|
||||
while not ctx.exit_event.is_set():
|
||||
if not ctx.client_activity_timers.values():
|
||||
|
||||
68
Options.py
68
Options.py
@@ -21,6 +21,10 @@ if typing.TYPE_CHECKING:
|
||||
import pathlib
|
||||
|
||||
|
||||
class OptionError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class Visibility(enum.IntFlag):
|
||||
none = 0b0000
|
||||
template = 0b0001
|
||||
@@ -136,12 +140,6 @@ 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."""
|
||||
@@ -746,39 +744,9 @@ 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)
|
||||
@@ -980,7 +948,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`
|
||||
"""
|
||||
@@ -1156,6 +1124,14 @@ 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
|
||||
|
||||
@@ -1194,15 +1170,21 @@ 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:
|
||||
all_options: typing.Dict[str, AssembleOptions] = {
|
||||
option_name: option for option_name, option in world.options_dataclass.type_hints.items()
|
||||
if option.visibility & Visibility.template
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
with open(local_path("data", "options.yaml")) as f:
|
||||
file_data = f.read()
|
||||
res = Template(file_data).render(
|
||||
options=all_options,
|
||||
option_groups=grouped_options,
|
||||
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
|
||||
dictify_range=dictify_range,
|
||||
)
|
||||
|
||||
@@ -65,6 +65,11 @@ 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
|
||||
|
||||
@@ -282,7 +282,7 @@ class SNESState(enum.IntEnum):
|
||||
|
||||
|
||||
def launch_sni() -> None:
|
||||
sni_path = Utils.get_options()["sni_options"]["sni_path"]
|
||||
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
||||
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
@@ -565,7 +565,7 @@ 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(min(len(data), 256))[2:]]
|
||||
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)
|
||||
@@ -654,7 +654,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_options()["sni_options"].get("snes_rom_start", True))
|
||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
60
Utils.py
60
Utils.py
@@ -101,8 +101,7 @@ 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]] = typing.cast(Optional[Dict[T, RetType]],
|
||||
getattr(self, cache_name, None))
|
||||
cache: Optional[Dict[T, RetType]] = getattr(self, cache_name, None)
|
||||
if cache is None:
|
||||
res = function(self, arg)
|
||||
setattr(self, cache_name, {arg: res})
|
||||
@@ -201,7 +200,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_options()["general_options"]["output_path"])
|
||||
output_path.cached_path = user_path(get_settings()["general_options"]["output_path"])
|
||||
path = os.path.join(output_path.cached_path, *path)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
return path
|
||||
@@ -209,10 +208,11 @@ def output_path(*path: str) -> str:
|
||||
|
||||
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
||||
if is_windows:
|
||||
os.startfile(filename)
|
||||
os.startfile(filename) # type: ignore
|
||||
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: typing.Any, value: typing.Any):
|
||||
def persistent_store(category: str, key: str, value: typing.Any):
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = persistent_load()
|
||||
category = storage.setdefault(category, {})
|
||||
category[key] = value
|
||||
storage = persistent_load()
|
||||
category_dict = storage.setdefault(category, {})
|
||||
category_dict[key] = value
|
||||
with open(path, "wt") as f:
|
||||
f.write(dump(storage, Dumper=Dumper))
|
||||
|
||||
|
||||
def persistent_load() -> typing.Dict[str, dict]:
|
||||
storage = getattr(persistent_load, "storage", None)
|
||||
def persistent_load() -> Dict[str, Dict[str, Any]]:
|
||||
storage: Union[Dict[str, Dict[str, Any]], None] = getattr(persistent_load, "storage", None)
|
||||
if storage:
|
||||
return storage
|
||||
path = user_path("_persistent_storage.yaml")
|
||||
storage: dict = {}
|
||||
storage = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
@@ -323,7 +323,7 @@ def persistent_load() -> typing.Dict[str, dict]:
|
||||
logging.debug(f"Could not read store: {e}")
|
||||
if storage is None:
|
||||
storage = {}
|
||||
persistent_load.storage = storage
|
||||
setattr(persistent_load, "storage", storage)
|
||||
return storage
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ 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()
|
||||
@@ -383,7 +384,9 @@ 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
|
||||
@@ -407,13 +410,13 @@ safe_builtins = frozenset((
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
generic_properties_module: Optional[object]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
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, name):
|
||||
def find_class(self, module: str, name: str) -> type:
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by MultiServer -> savegame/multidata
|
||||
@@ -437,7 +440,7 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||
|
||||
|
||||
def restricted_loads(s):
|
||||
def restricted_loads(s: bytes) -> Any:
|
||||
"""Helper function analogous to pickle.loads()."""
|
||||
return RestrictedUnpickler(io.BytesIO(s)).load()
|
||||
|
||||
@@ -493,7 +496,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, condition):
|
||||
def __init__(self, filter_name: str, condition: typing.Callable[[logging.LogRecord], bool]) -> None:
|
||||
super().__init__(filter_name)
|
||||
self.condition = condition
|
||||
|
||||
@@ -544,7 +547,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
)
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
|
||||
def queuer():
|
||||
while 1:
|
||||
try:
|
||||
@@ -572,7 +575,7 @@ class VersionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
|
||||
def chaining_prefix(index: int, labels: typing.Sequence[str]) -> str:
|
||||
text = ""
|
||||
max_label = len(labels) - 1
|
||||
while index > max_label:
|
||||
@@ -595,7 +598,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, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
|
||||
def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit: typing.Optional[int] = None) \
|
||||
-> typing.List[typing.Tuple[str, int]]:
|
||||
import jellyfish
|
||||
|
||||
@@ -603,22 +606,23 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
|
||||
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
|
||||
/ max(len(word1), len(word2)))
|
||||
|
||||
limit: int = limit if limit else len(wordlist)
|
||||
limit = limit if limit else len(word_list)
|
||||
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)),
|
||||
wordlist),
|
||||
map(lambda candidate: (candidate, get_fuzzy_ratio(input_word, candidate)), word_list),
|
||||
key=lambda element: element[1],
|
||||
reverse=True)[0:limit]
|
||||
reverse=True
|
||||
)[0:limit]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \
|
||||
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[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
|
||||
|
||||
@@ -732,7 +736,7 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
root.update()
|
||||
|
||||
|
||||
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
|
||||
def title_sorted(data: typing.Iterable, key=None, ignore: typing.AbstractSet[str] = 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)):
|
||||
@@ -786,7 +790,7 @@ class DeprecateDict(dict):
|
||||
log_message: str
|
||||
should_error: bool
|
||||
|
||||
def __init__(self, message, error: bool = False) -> None:
|
||||
def __init__(self, message: str, error: bool = False) -> None:
|
||||
self.log_message = message
|
||||
self.should_error = error
|
||||
super().__init__()
|
||||
|
||||
10
WebHost.py
10
WebHost.py
@@ -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
|
||||
from WebHostLib.autolauncher import autohost, autogen, stop
|
||||
from WebHostLib.options import create as create_options_files
|
||||
|
||||
try:
|
||||
@@ -138,3 +138,11 @@ 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
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
@@ -83,6 +84,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
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
|
||||
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -3,26 +3,25 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
from uuid import UUID
|
||||
from datetime import timedelta, datetime
|
||||
from threading import Event, Thread
|
||||
from uuid import UUID
|
||||
|
||||
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)
|
||||
|
||||
multiworld.start()
|
||||
def stop():
|
||||
"""Stops previously launched threads"""
|
||||
global _stop_event
|
||||
stop_event = _stop_event
|
||||
_stop_event = Event() # new event for new threads
|
||||
stop_event.set()
|
||||
|
||||
|
||||
def handle_generation_success(seed_id):
|
||||
@@ -59,39 +58,50 @@ 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"):
|
||||
# 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)
|
||||
cleanup()
|
||||
hosters = []
|
||||
for x in range(config["HOSTERS"]):
|
||||
hoster = MultiworldInstance(config, x)
|
||||
hosters.append(hoster)
|
||||
hoster.start()
|
||||
|
||||
while not stop_event.wait(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:
|
||||
launch_room(room, config)
|
||||
# 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)
|
||||
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autohost reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running, name="AP_Autohost").start()
|
||||
Thread(target=keep_running, name="AP_Autohost").start()
|
||||
|
||||
|
||||
def autogen(config: dict):
|
||||
def keep_running():
|
||||
stop_event = _stop_event
|
||||
try:
|
||||
with Locker("autogen"):
|
||||
|
||||
@@ -112,8 +122,7 @@ def autogen(config: dict):
|
||||
commit()
|
||||
select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
|
||||
|
||||
while 1:
|
||||
time.sleep(0.1)
|
||||
while not stop_event.wait(0.1):
|
||||
with db_session:
|
||||
# for update locks the database row(s) during transaction, preventing writes from elsewhere
|
||||
to_start = select(
|
||||
@@ -124,37 +133,45 @@ def autogen(config: dict):
|
||||
except AlreadyRunningException:
|
||||
logging.info("Autogen reports as already running, not starting another.")
|
||||
|
||||
import threading
|
||||
threading.Thread(target=keep_running, name="AP_Autogen").start()
|
||||
Thread(target=keep_running, name="AP_Autogen").start()
|
||||
|
||||
|
||||
multiworlds: typing.Dict[type(Room.id), MultiworldInstance] = {}
|
||||
|
||||
|
||||
class MultiworldInstance():
|
||||
def __init__(self, room: Room, config: dict):
|
||||
self.room_id = room.id
|
||||
def __init__(self, config: dict, id: int):
|
||||
self.room_ids = set()
|
||||
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.room_id, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key, self.host),
|
||||
name="MultiHost")
|
||||
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)
|
||||
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()
|
||||
@@ -168,40 +185,6 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@ import collections
|
||||
import datetime
|
||||
import functools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import pickle
|
||||
import random
|
||||
import socket
|
||||
@@ -53,17 +54,19 @@ del MultiServer
|
||||
|
||||
class DBCommandProcessor(ServerCommandProcessor):
|
||||
def output(self, text: str):
|
||||
logging.info(text)
|
||||
self.ctx.logger.info(text)
|
||||
|
||||
|
||||
class WebHostContext(Context):
|
||||
room_id: int
|
||||
|
||||
def __init__(self, static_server_data: dict):
|
||||
def __init__(self, static_server_data: dict, logger: logging.Logger):
|
||||
# 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)
|
||||
super(WebHostContext, self).__init__("", 0, "", "", 1,
|
||||
40, True, "enabled", "enabled",
|
||||
"enabled", 0, 2, logger=logger)
|
||||
del self.static_server_data
|
||||
self.main_loop = asyncio.get_running_loop()
|
||||
self.video = {}
|
||||
@@ -71,6 +74,7 @@ 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)
|
||||
|
||||
@@ -98,18 +102,37 @@ 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 self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata
|
||||
if static_gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
|
||||
# non-custom. remove from multidata and use static data
|
||||
# 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
|
||||
@@ -119,7 +142,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()
|
||||
self._start_async_saving(atexit_save=False)
|
||||
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
|
||||
|
||||
@db_session
|
||||
@@ -159,72 +182,125 @@ def get_static_server_data() -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
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,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
|
||||
host: 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
|
||||
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
|
||||
async def main():
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
if "worlds" in sys.modules:
|
||||
raise Exception("Worlds system should not be loaded in the custom server.")
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
await ctx.server
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
# 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
|
||||
except OSError: # likely port in use
|
||||
ctx.server = websockets.serve(
|
||||
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
|
||||
|
||||
logging.info("Shutting down")
|
||||
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
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
@@ -70,37 +70,41 @@ def generate(race=False):
|
||||
flash(options)
|
||||
else:
|
||||
meta = get_meta(request.form, race)
|
||||
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 start_generation(options, meta)
|
||||
|
||||
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] = {}
|
||||
|
||||
@@ -37,25 +37,6 @@ 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()
|
||||
@@ -150,6 +131,7 @@ 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
|
||||
|
||||
@@ -1,205 +1,226 @@
|
||||
import collections.abc
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
from textwrap import dedent
|
||||
from typing import Dict, Union
|
||||
|
||||
import yaml
|
||||
from flask import redirect, render_template, request, Response
|
||||
|
||||
import Options
|
||||
from Utils import local_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
|
||||
"exclude_locations", "priority_locations"}
|
||||
from . import app, cache
|
||||
|
||||
|
||||
def create():
|
||||
def create() -> None:
|
||||
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()
|
||||
|
||||
weighted_options = {
|
||||
"baseOptions": {
|
||||
"description": "Generated by https://archipelago.gg/",
|
||||
"name": "",
|
||||
"game": {},
|
||||
},
|
||||
"games": {},
|
||||
}
|
||||
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'
|
||||
|
||||
for game_name, world in AutoWorldRegister.world_types.items():
|
||||
|
||||
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
|
||||
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")
|
||||
|
||||
# Generate JSON files for player-options pages
|
||||
player_options = {
|
||||
"baseOptions": {
|
||||
"description": f"Generated by https://archipelago.gg/ for {game_name}",
|
||||
"game": game_name,
|
||||
"name": "",
|
||||
},
|
||||
}
|
||||
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
|
||||
|
||||
game_options = {}
|
||||
visible: typing.Set[str] = set()
|
||||
visible_weighted: typing.Set[str] = set()
|
||||
return render_template(
|
||||
template,
|
||||
world_name=world_name,
|
||||
world=world,
|
||||
option_groups=grouped_options,
|
||||
issubclass=issubclass,
|
||||
Options=Options,
|
||||
theme=get_world_theme(world_name),
|
||||
)
|
||||
|
||||
for option_name, option in all_options.items():
|
||||
if option.visibility & Options.Visibility.simple_ui:
|
||||
visible.add(option_name)
|
||||
if option.visibility & Options.Visibility.complex_ui:
|
||||
visible_weighted.add(option_name)
|
||||
|
||||
if option_name in handled_in_js:
|
||||
pass
|
||||
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"]})
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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,
|
||||
}
|
||||
@app.template_filter("dedent")
|
||||
def filter_dedent(text: str) -> str:
|
||||
return dedent(text).strip("\n ")
|
||||
|
||||
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)
|
||||
}
|
||||
@app.template_test("ordered")
|
||||
def test_ordered(obj):
|
||||
return isinstance(obj, collections.abc.Sequence)
|
||||
|
||||
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 []
|
||||
}
|
||||
@app.route("/games/<string:game>/option-presets", methods=["GET"])
|
||||
@cache.cached()
|
||||
def option_presets(game: str) -> Response:
|
||||
world = AutoWorldRegister.world_types[game]
|
||||
|
||||
else:
|
||||
logging.debug(f"{option} not exported to Web Options.")
|
||||
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)
|
||||
|
||||
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."
|
||||
json_data = json.dumps(world.web.options_presets, cls=SetEncoder)
|
||||
response = Response(json_data)
|
||||
response.headers["Content-Type"] = "application/json"
|
||||
return response
|
||||
|
||||
# Normal random is supported, but needs to be handled explicitly.
|
||||
if option_value == "random":
|
||||
player_options["presetOptions"][preset_name][option_name] = option_value
|
||||
|
||||
@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
|
||||
|
||||
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}."
|
||||
options[key] = val
|
||||
else:
|
||||
if int(val) == 0:
|
||||
continue
|
||||
|
||||
# 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
|
||||
[option, setting] = key.split("||")
|
||||
options.setdefault(option, {})[setting] = int(val)
|
||||
|
||||
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
|
||||
# Error checking
|
||||
if "name" not in options:
|
||||
return "Player name is required."
|
||||
|
||||
filtered_player_options = player_options
|
||||
filtered_player_options["gameOptions"] = {
|
||||
option_name: option_data for option_name, option_data in game_options.items()
|
||||
if option_name in visible
|
||||
# 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,
|
||||
}
|
||||
|
||||
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
|
||||
json.dump(filtered_player_options, f, indent=2, separators=(',', ': '))
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
|
||||
filtered_player_options["gameOptions"] = {
|
||||
option_name: option_data for option_name, option_data in game_options.items()
|
||||
if option_name in visible_weighted
|
||||
else:
|
||||
return send_yaml(player_name, formatted_options)
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
# 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]
|
||||
|
||||
# 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]
|
||||
|
||||
# Error checking
|
||||
if not options["name"]:
|
||||
return "Player name is required."
|
||||
|
||||
# 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"]
|
||||
|
||||
# Properly format YAML output
|
||||
player_name = options["name"]
|
||||
del options["name"]
|
||||
|
||||
description = f"Generated by https://archipelago.gg/ for {game}"
|
||||
if preset_name != 'default' and preset_name != 'custom':
|
||||
description += f" using {preset_name} preset"
|
||||
|
||||
formatted_options = {
|
||||
"name": player_name,
|
||||
"game": game,
|
||||
"description": description,
|
||||
game: 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 filtered_player_options["gameOptions"].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": filtered_player_options["gameOptions"],
|
||||
"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=(',', ': '))
|
||||
if intent_generate:
|
||||
return generate_game({player_name: formatted_options})
|
||||
|
||||
else:
|
||||
return send_yaml(player_name, formatted_options)
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
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);
|
||||
};
|
||||
335
WebHostLib/static/assets/playerOptions.js
Normal file
335
WebHostLib/static/assets/playerOptions.js
Normal file
@@ -0,0 +1,335 @@
|
||||
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';
|
||||
};
|
||||
@@ -1,18 +1,16 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Add toggle listener to all elements with .collapse-toggle
|
||||
const toggleButtons = document.querySelectorAll('.collapse-toggle');
|
||||
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
|
||||
const toggleButtons = document.querySelectorAll('details');
|
||||
|
||||
// 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 collapsed games
|
||||
// If input is empty, display all games as collapsed
|
||||
return toggleButtons.forEach((header) => {
|
||||
header.style.display = null;
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
header.removeAttribute('open');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,12 +19,10 @@ 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.firstElementChild.innerText = '▼';
|
||||
header.nextElementSibling.classList.remove('collapsed');
|
||||
header.setAttribute('open', '1');
|
||||
} else {
|
||||
header.style.display = 'none';
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
header.removeAttribute('open');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -35,30 +31,14 @@ 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('.collapse-toggle').forEach((header) => {
|
||||
if (header.style.display === 'none') { return; }
|
||||
header.firstElementChild.innerText = '▼';
|
||||
header.nextElementSibling.classList.remove('collapsed');
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.setAttribute('open', '1');
|
||||
});
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
document.querySelectorAll('.collapse-toggle').forEach((header) => {
|
||||
if (header.style.display === 'none') { return; }
|
||||
header.firstElementChild.innerText = '▶';
|
||||
header.nextElementSibling.classList.add('collapsed');
|
||||
document.querySelectorAll('details').forEach((detail) => {
|
||||
detail.removeAttribute('open');
|
||||
});
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
223
WebHostLib/static/assets/weightedOptions.js
Normal file
223
WebHostLib/static/assets/weightedOptions.js
Normal file
@@ -0,0 +1,223 @@
|
||||
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];
|
||||
}
|
||||
};
|
||||
@@ -44,7 +44,7 @@ a{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
}
|
||||
|
||||
button{
|
||||
button, input[type=submit]{
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
padding: 10px 17px 11px 16px; /* top right bottom left */
|
||||
@@ -57,7 +57,7 @@ button{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:active{
|
||||
button:active, input[type=submit]: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{
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
button.button-grass{
|
||||
button.button-grass, input[type=submit].button-grass{
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
button.button-dirt{
|
||||
button.button-dirt, input[type=submit].button-dirt{
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
@@ -111,4 +111,4 @@ h5, h6{
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
.markdown a{}
|
||||
|
||||
.markdown h1{
|
||||
.markdown h1, .markdown details summary.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 h2, .markdown details summary.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 h3, .markdown details summary.h3{
|
||||
font-size: 26px;
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
@@ -55,7 +55,7 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.markdown h4{
|
||||
.markdown h4, .markdown details summary.h4{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 24px;
|
||||
@@ -63,21 +63,21 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown h5{
|
||||
.markdown h5, .markdown details summary.h5{
|
||||
font-family: LexendDeca-Regular, sans-serif;
|
||||
text-transform: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markdown h6{
|
||||
.markdown h6, .markdown details summary.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
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%;
|
||||
}
|
||||
}
|
||||
310
WebHostLib/static/styles/playerOptions/playerOptions.css
Normal file
310
WebHostLib/static/styles/playerOptions/playerOptions.css
Normal file
@@ -0,0 +1,310 @@
|
||||
@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 */
|
||||
@@ -0,0 +1 @@
|
||||
{"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"}
|
||||
364
WebHostLib/static/styles/playerOptions/playerOptions.scss
Normal file
364
WebHostLib/static/styles/playerOptions/playerOptions.scss
Normal file
@@ -0,0 +1,364 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,30 +8,15 @@
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#games h1{
|
||||
#games h1, #games details summary.h1{
|
||||
font-size: 60px;
|
||||
cursor: unset;
|
||||
}
|
||||
|
||||
#games h2{
|
||||
#games h2, #games details summary.h2{
|
||||
color: #93dcff;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
#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;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
#games a{
|
||||
|
||||
@@ -42,6 +42,7 @@ 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 */
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
232
WebHostLib/static/styles/weightedOptions/weightedOptions.css
Normal file
232
WebHostLib/static/styles/weightedOptions/weightedOptions.css
Normal file
@@ -0,0 +1,232 @@
|
||||
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 */
|
||||
@@ -0,0 +1 @@
|
||||
{"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"}
|
||||
274
WebHostLib/static/styles/weightedOptions/weightedOptions.scss
Normal file
274
WebHostLib/static/styles/weightedOptions/weightedOptions.scss
Normal file
@@ -0,0 +1,274 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
{% 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 %}
|
||||
210
WebHostLib/templates/playerOptions/macros.html
Normal file
210
WebHostLib/templates/playerOptions/macros.html
Normal file
@@ -0,0 +1,210 @@
|
||||
{% 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"> </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"> </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 %}
|
||||
166
WebHostLib/templates/playerOptions/playerOptions.html
Normal file
166
WebHostLib/templates/playerOptions/playerOptions.html
Normal file
@@ -0,0 +1,166 @@
|
||||
{% 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 %}
|
||||
@@ -24,7 +24,6 @@
|
||||
<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>
|
||||
@@ -50,8 +49,12 @@
|
||||
<ul>
|
||||
{% for game in games | title_sorted %}
|
||||
{% if game['has_settings'] %}
|
||||
<li><a href="{{ url_for('player_options', game=game['title']) }}">{{ game['title'] }}</a></li>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -41,28 +41,28 @@
|
||||
</div>
|
||||
{% for game_name in worlds | title_sorted %}
|
||||
{% set world = worlds[game_name] %}
|
||||
<h2 class="collapse-toggle" data-game="{{ game_name }}">
|
||||
<span class="collapse-arrow">▶</span>{{ game_name }}
|
||||
</h2>
|
||||
<p class="collapsed">
|
||||
<details data-game="{{ game_name }}">
|
||||
<summary class="h2">{{ game_name }}</summary>
|
||||
{{ 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") }}#{{ game_name }}">Setup Guides</a>
|
||||
<a href="{{ url_for("tutorial_landing", _anchor = game_name | urlencode) }}">Setup Guides</a>
|
||||
{% endif %}
|
||||
{% if world.web.options_page is string %}
|
||||
<span class="link-spacer">|</span>
|
||||
<a href="{{ world.web.options_page }}">Options Page</a>
|
||||
<a href="{{ world.web.options_page }}">Options Page (External Link)</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 %}
|
||||
</p>
|
||||
</details>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
{% 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 %}
|
||||
249
WebHostLib/templates/weightedOptions/macros.html
Normal file
249
WebHostLib/templates/weightedOptions/macros.html
Normal file
@@ -0,0 +1,249 @@
|
||||
{% 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"> </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"> </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 %}
|
||||
119
WebHostLib/templates/weightedOptions/weightedOptions.html
Normal file
119
WebHostLib/templates/weightedOptions/weightedOptions.html
Normal file
@@ -0,0 +1,119 @@
|
||||
{% 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'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 %}
|
||||
@@ -63,12 +63,13 @@ 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"],
|
||||
@@ -192,6 +193,8 @@ 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
|
||||
|
||||
@@ -110,6 +110,11 @@ local IsItemable = function()
|
||||
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
|
||||
|
||||
|
||||
@@ -45,7 +45,10 @@ requires:
|
||||
{% endmacro %}
|
||||
|
||||
{{ game }}:
|
||||
{%- for option_key, option in options.items() %}
|
||||
{%- for group_name, group_options in option_groups.items() %}
|
||||
# {{ group_name }}
|
||||
|
||||
{%- for option_key, option in group_options.items() %}
|
||||
{{ option_key }}:
|
||||
{%- if option.__doc__ %}
|
||||
# {{ option.__doc__
|
||||
@@ -83,3 +86,4 @@ requires:
|
||||
{%- endif -%}
|
||||
{{ "\n" }}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
|
||||
BIN
data/yatta.ico
Normal file
BIN
data/yatta.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
BIN
data/yatta.png
Normal file
BIN
data/yatta.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -13,9 +13,15 @@
|
||||
# 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
|
||||
|
||||
@@ -25,6 +31,9 @@
|
||||
# Blasphemous
|
||||
/worlds/blasphemous/ @TRPG0
|
||||
|
||||
# Bomb Rush Cyberfunk
|
||||
/worlds/bomb_rush_cyberfunk/ @TRPG0
|
||||
|
||||
# Bumper Stickers
|
||||
/worlds/bumpstik/ @FelicitusNeko
|
||||
|
||||
@@ -92,6 +101,9 @@
|
||||
/worlds/lufia2ac/ @el-u
|
||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||
|
||||
# Mario & Luigi: Superstar Saga
|
||||
/worlds/mlss/ @jamesbrq
|
||||
|
||||
# Meritous
|
||||
/worlds/meritous/ @FelicitusNeko
|
||||
|
||||
@@ -194,6 +206,9 @@
|
||||
# Yoshi's Island
|
||||
/worlds/yoshisisland/ @PinkSwitch
|
||||
|
||||
#Yu-Gi-Oh! Ultimate Masters: World Championship Tournament 2006
|
||||
/worlds/yugioh06/ @Rensen3
|
||||
|
||||
# Zillion
|
||||
/worlds/zillion/ @beauxq
|
||||
|
||||
|
||||
@@ -85,6 +85,25 @@ 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
|
||||
@@ -155,10 +174,12 @@ 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 progression items can't be placed on them.
|
||||
Marks locations given here as `LocationProgressType.Excluded` so that neither progression nor useful items can be
|
||||
placed on them.
|
||||
|
||||
### PriorityLocations
|
||||
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them.
|
||||
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
|
||||
the pool.
|
||||
|
||||
### ItemLinks
|
||||
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between
|
||||
|
||||
@@ -17,13 +17,14 @@ 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
|
||||
|
||||
@@ -45,9 +45,6 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -121,6 +121,53 @@ 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
|
||||
@@ -178,37 +225,6 @@ 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
|
||||
@@ -233,37 +249,6 @@ 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
|
||||
|
||||
@@ -169,6 +169,11 @@ 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: "";
|
||||
@@ -194,6 +199,11 @@ 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: "";
|
||||
|
||||
@@ -665,6 +665,14 @@ 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)
|
||||
@@ -673,6 +681,7 @@ class GeneratorOptions(Group):
|
||||
spoiler: Spoiler = Spoiler(3)
|
||||
race: Race = Race(0)
|
||||
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
|
||||
panic_method: PanicMethod = PanicMethod("swap")
|
||||
|
||||
|
||||
class SNIOptions(Group):
|
||||
|
||||
6
setup.py
6
setup.py
@@ -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>=6.15.16,<7'
|
||||
requirement = 'cx-Freeze>=7.0.0'
|
||||
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.BuildEXE):
|
||||
user_options = cx_Freeze.command.build_exe.BuildEXE.user_options + [
|
||||
class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
user_options = cx_Freeze.command.build_exe.build_exe.user_options + [
|
||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||
('extra-data=', None, 'Additional files to add.'),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from argparse import Namespace
|
||||
from typing import List, Optional, Tuple, Type, Union
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld
|
||||
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
|
||||
from worlds.AutoWorld import World, call_all
|
||||
|
||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
||||
@@ -17,19 +17,21 @@ 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]
|
||||
@@ -49,3 +51,59 @@ 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
|
||||
|
||||
@@ -1,41 +1,15 @@
|
||||
from typing import List, Iterable
|
||||
import unittest
|
||||
|
||||
import Options
|
||||
from Options import Accessibility
|
||||
from worlds.AutoWorld import World
|
||||
from test.general import generate_items, generate_locations, generate_test_multiworld
|
||||
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, CollectionState
|
||||
ItemClassification
|
||||
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
|
||||
@@ -55,12 +29,12 @@ class PlayerDefinition(object):
|
||||
self.regions = [menu]
|
||||
|
||||
def generate_region(self, parent: Region, size: int, access_rule: CollectionRule = lambda state: True) -> Region:
|
||||
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)
|
||||
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)
|
||||
|
||||
entrance = Entrance(self.id, region_name + "_entrance", parent)
|
||||
entrance = Entrance(self.id, f"{region_name}_entrance", parent)
|
||||
parent.exits.append(entrance)
|
||||
entrance.connect(region)
|
||||
entrance.access_rule = access_rule
|
||||
@@ -94,7 +68,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, None, menu)
|
||||
locations = generate_locations(location_count, player_id, menu, None)
|
||||
prog_items = generate_items(prog_item_count, player_id, True)
|
||||
multiworld.itempool += prog_items
|
||||
basic_items = generate_items(basic_item_count, player_id, False)
|
||||
@@ -103,28 +77,6 @@ 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)
|
||||
|
||||
@@ -132,7 +84,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
|
||||
item0 = player1.prog_items[0]
|
||||
@@ -150,7 +102,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_ordered_fill(self):
|
||||
"""Tests `fill_restrictive` fulfills set rules"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
items = player1.prog_items
|
||||
locations = player1.locations
|
||||
@@ -167,7 +119,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_partial_fill(self):
|
||||
"""Tests that `fill_restrictive` returns unfilled locations"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 3, 2)
|
||||
|
||||
item0 = player1.prog_items[0]
|
||||
@@ -193,7 +145,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_minimal_fill(self):
|
||||
"""Test that fill for minimal player can have unreachable items"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
|
||||
items = player1.prog_items
|
||||
@@ -218,7 +170,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
the non-minimal player get all items.
|
||||
"""
|
||||
|
||||
multiworld = generate_multiworld(2)
|
||||
multiworld = generate_test_multiworld(2)
|
||||
player1 = generate_player_data(multiworld, 1, 3, 3)
|
||||
player2 = generate_player_data(multiworld, 2, 3, 3)
|
||||
|
||||
@@ -245,11 +197,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
|
||||
item0 = player1.prog_items[0]
|
||||
@@ -268,7 +220,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_multi_step_fill(self):
|
||||
"""Test that fill is able to satisfy multiple spheres"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 4, 4)
|
||||
|
||||
items = player1.prog_items
|
||||
@@ -293,7 +245,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
items = player1.prog_items
|
||||
locations = player1.locations
|
||||
@@ -310,7 +262,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 3, 3)
|
||||
|
||||
item0 = player1.prog_items[0]
|
||||
@@ -331,7 +283,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
|
||||
item0 = player1.prog_items[0]
|
||||
@@ -348,7 +300,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_multiplayer_fill(self):
|
||||
"""Test that items can be placed across worlds"""
|
||||
multiworld = generate_multiworld(2)
|
||||
multiworld = generate_test_multiworld(2)
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
player2 = generate_player_data(multiworld, 2, 2, 2)
|
||||
|
||||
@@ -369,7 +321,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_multiplayer_rules_fill(self):
|
||||
"""Test that fill across worlds satisfies the rules"""
|
||||
multiworld = generate_multiworld(2)
|
||||
multiworld = generate_test_multiworld(2)
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
player2 = generate_player_data(multiworld, 2, 2, 2)
|
||||
|
||||
@@ -393,7 +345,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
|
||||
def test_restrictive_progress(self):
|
||||
"""Test that various spheres with different requirements can be filled"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_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(
|
||||
@@ -417,7 +369,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_multiworld(1)
|
||||
multiworld = generate_test_multiworld(1)
|
||||
player1 = generate_player_data(multiworld, 1, 4, 4)
|
||||
locations = player1.locations[:] # copy required
|
||||
items = player1.prog_items[:] # copy required
|
||||
@@ -442,7 +394,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_multiworld(1)
|
||||
multiworld = generate_test_multiworld(1)
|
||||
player1 = generate_player_data(multiworld, 1, 5, 5)
|
||||
locations = player1.locations[:] # copy required
|
||||
items = player1.prog_items[:] # copy required
|
||||
@@ -484,7 +436,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
def test_double_sweep(self):
|
||||
"""Test that sweep doesn't duplicate Event items when sweeping"""
|
||||
# test for PR1114
|
||||
multiworld = generate_multiworld(1)
|
||||
multiworld = generate_test_multiworld(1)
|
||||
player1 = generate_player_data(multiworld, 1, 1, 1)
|
||||
location = player1.locations[0]
|
||||
location.address = None
|
||||
@@ -498,7 +450,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(multiworld, 1, 2, 2)
|
||||
|
||||
player1.prog_items[0].name = "Different_item_instance_but_same_item_name"
|
||||
@@ -515,7 +467,7 @@ class TestFillRestrictive(unittest.TestCase):
|
||||
class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
def test_basic_distribute(self):
|
||||
"""Test that distribute_items_restrictive is deterministic"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -535,7 +487,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_excluded_distribute(self):
|
||||
"""Test that distribute_items_restrictive doesn't put advancement items on excluded locations"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -550,7 +502,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_non_excluded_item_distribute(self):
|
||||
"""Test that useful items aren't placed on excluded locations"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -565,7 +517,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -578,7 +530,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -593,7 +545,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_priority_distribute(self):
|
||||
"""Test that priority locations receive advancement items"""
|
||||
multiworld = generate_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -608,7 +560,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
locations = player1.locations
|
||||
@@ -623,7 +575,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_multiple_world_priority_distribute(self):
|
||||
"""Test that priority fill can be satisfied for multiple worlds"""
|
||||
multiworld = generate_multiworld(3)
|
||||
multiworld = generate_test_multiworld(3)
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
player2 = generate_player_data(
|
||||
@@ -653,7 +605,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
|
||||
@@ -673,12 +625,12 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_seed_robust_to_item_order(self):
|
||||
"""Test deterministic fill"""
|
||||
mw1 = generate_multiworld()
|
||||
mw1 = generate_test_multiworld()
|
||||
gen1 = generate_player_data(
|
||||
mw1, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
distribute_items_restrictive(mw1)
|
||||
|
||||
mw2 = generate_multiworld()
|
||||
mw2 = generate_test_multiworld()
|
||||
gen2 = generate_player_data(
|
||||
mw2, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
mw2.itempool.append(mw2.itempool.pop(0))
|
||||
@@ -691,12 +643,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_multiworld()
|
||||
mw1 = generate_test_multiworld()
|
||||
gen1 = generate_player_data(
|
||||
mw1, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
distribute_items_restrictive(mw1)
|
||||
|
||||
mw2 = generate_multiworld()
|
||||
mw2 = generate_test_multiworld()
|
||||
gen2 = generate_player_data(
|
||||
mw2, 1, 4, prog_item_count=2, basic_item_count=2)
|
||||
reg = mw2.get_region("Menu", gen2.id)
|
||||
@@ -710,7 +662,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_multiworld()
|
||||
multiworld = generate_test_multiworld()
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, location_count=5, prog_item_count=5)
|
||||
items = player1.prog_items
|
||||
@@ -727,7 +679,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_non_excluded_local_items(self):
|
||||
"""Test that local items get placed locally in a multiworld"""
|
||||
multiworld = generate_multiworld(2)
|
||||
multiworld = generate_test_multiworld(2)
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, location_count=5, basic_item_count=5)
|
||||
player2 = generate_player_data(
|
||||
@@ -748,7 +700,7 @@ class TestDistributeItemsRestrictive(unittest.TestCase):
|
||||
|
||||
def test_early_items(self) -> None:
|
||||
"""Test that the early items API successfully places items early"""
|
||||
mw = generate_multiworld(2)
|
||||
mw = generate_test_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
|
||||
@@ -803,11 +755,11 @@ class TestBalanceMultiworldProgression(unittest.TestCase):
|
||||
if location.item and location.item == item:
|
||||
return True
|
||||
|
||||
self.fail("Expected " + region.name + " to contain " + item.name +
|
||||
"\n Contains" + str(list(map(lambda location: location.item, region.locations))))
|
||||
self.fail(f"Expected {region.name} to contain {item.name}.\n"
|
||||
f"Contains{list(map(lambda location: location.item, region.locations))}")
|
||||
|
||||
def setUp(self) -> None:
|
||||
multiworld = generate_multiworld(2)
|
||||
multiworld = generate_test_multiworld(2)
|
||||
self.multiworld = multiworld
|
||||
player1 = generate_player_data(
|
||||
multiworld, 1, prog_item_count=2, basic_item_count=40)
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
|
||||
|
||||
@@ -47,3 +48,7 @@ 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}")
|
||||
|
||||
@@ -25,6 +25,8 @@ 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):
|
||||
@@ -62,15 +64,6 @@ 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")
|
||||
|
||||
@@ -66,12 +66,3 @@ 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")
|
||||
|
||||
@@ -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"], 0)
|
||||
self.assertEqual(new_weights["dict_1"]["option_b"], 50)
|
||||
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)
|
||||
|
||||
23
test/webhost/test_descriptions.py
Normal file
23
test/webhost/test_descriptions.py
Normal file
@@ -0,0 +1,23 @@
|
||||
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")
|
||||
@@ -3,19 +3,20 @@ 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 PerGameCommonOptions
|
||||
from Options import (
|
||||
ExcludeLocations, ItemLinks, LocalItems, NonLocalItems, OptionGroup, PerGameCommonOptions,
|
||||
PriorityLocations, StartHints, StartInventory, StartInventoryPool, StartLocationHints
|
||||
)
|
||||
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
|
||||
@@ -53,17 +54,12 @@ 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:
|
||||
@@ -118,6 +114,33 @@ 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()
|
||||
@@ -172,7 +195,7 @@ def call_stage(multiworld: "MultiWorld", method_name: str, *args: Any) -> None:
|
||||
_timed_call(stage_callable, multiworld, *args)
|
||||
|
||||
|
||||
class WebWorld:
|
||||
class WebWorld(metaclass=WebWorldRegister):
|
||||
"""Webhost integration"""
|
||||
|
||||
options_page: Union[bool, str] = True
|
||||
@@ -194,6 +217,15 @@ class WebWorld:
|
||||
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.
|
||||
@@ -206,8 +238,8 @@ class World(metaclass=AutoWorldRegister):
|
||||
|
||||
game: ClassVar[str]
|
||||
"""name the game"""
|
||||
topology_present: ClassVar[bool] = False
|
||||
"""indicate if world type has any meaningful layout/pathing"""
|
||||
topology_present: bool = False
|
||||
"""indicate if this world has any meaningful layout/pathing"""
|
||||
|
||||
all_item_and_group_names: ClassVar[FrozenSet[str]] = frozenset()
|
||||
"""gets automatically populated with all item and item group names"""
|
||||
@@ -220,23 +252,9 @@ 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.
|
||||
@@ -283,7 +301,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]
|
||||
@@ -300,7 +318,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
assert multiworld is not None
|
||||
self.multiworld = multiworld
|
||||
self.player = player
|
||||
self.random = random.Random(multiworld.random.getrandbits(64))
|
||||
self.random = Random(multiworld.random.getrandbits(64))
|
||||
multiworld.per_slot_randoms[player] = self.random
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
@@ -504,6 +522,10 @@ 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 = {
|
||||
@@ -536,18 +558,3 @@ 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
A module containing the BizHawkClient base class and metaclass
|
||||
"""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
@@ -12,14 +11,13 @@ 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)
|
||||
@@ -56,7 +54,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():
|
||||
@@ -77,7 +75,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.
|
||||
@@ -86,18 +84,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
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -77,7 +76,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:
|
||||
@@ -103,7 +102,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)
|
||||
|
||||
@@ -148,7 +147,8 @@ 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 got {script_version}. Disconnecting.")
|
||||
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but "
|
||||
f"got {script_version}. Disconnecting.")
|
||||
disconnect(ctx.bizhawk_ctx)
|
||||
continue
|
||||
|
||||
|
||||
@@ -241,4 +241,4 @@ adventure_option_definitions: Dict[str, type(Option)] = {
|
||||
"difficulty_switch_b": DifficultySwitchB,
|
||||
"start_castle": StartCastle,
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
232
worlds/ahit/Client.py
Normal file
232
worlds/ahit/Client.py
Normal file
@@ -0,0 +1,232 @@
|
||||
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()
|
||||
243
worlds/ahit/DeathWishLocations.py
Normal file
243
worlds/ahit/DeathWishLocations.py
Normal file
@@ -0,0 +1,243 @@
|
||||
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)
|
||||
462
worlds/ahit/DeathWishRules.py
Normal file
462
worlds/ahit/DeathWishRules.py
Normal file
@@ -0,0 +1,462 @@
|
||||
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",
|
||||
]
|
||||
302
worlds/ahit/Items.py
Normal file
302
worlds/ahit/Items.py
Normal file
@@ -0,0 +1,302 @@
|
||||
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,
|
||||
}
|
||||
1057
worlds/ahit/Locations.py
Normal file
1057
worlds/ahit/Locations.py
Normal file
File diff suppressed because it is too large
Load Diff
770
worlds/ahit/Options.py
Normal file
770
worlds/ahit/Options.py
Normal file
@@ -0,0 +1,770 @@
|
||||
from typing import List, TYPE_CHECKING, Dict, Any
|
||||
from schema import Schema, Optional
|
||||
from dataclasses import dataclass
|
||||
from worlds.AutoWorld import PerGameCommonOptions
|
||||
from Options import Range, Toggle, DeathLink, Choice, OptionDict, DefaultOnToggle, OptionGroup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import HatInTimeWorld
|
||||
|
||||
|
||||
def create_option_groups() -> List[OptionGroup]:
|
||||
option_group_list: List[OptionGroup] = []
|
||||
for name, options in ahit_option_groups.items():
|
||||
option_group_list.append(OptionGroup(name=name, options=options))
|
||||
|
||||
return option_group_list
|
||||
|
||||
|
||||
def adjust_options(world: "HatInTimeWorld"):
|
||||
if world.options.HighestChapterCost < world.options.LowestChapterCost:
|
||||
world.options.HighestChapterCost.value, world.options.LowestChapterCost.value = \
|
||||
world.options.LowestChapterCost.value, world.options.HighestChapterCost.value
|
||||
|
||||
if world.options.FinalChapterMaxCost < world.options.FinalChapterMinCost:
|
||||
world.options.FinalChapterMaxCost.value, world.options.FinalChapterMinCost.value = \
|
||||
world.options.FinalChapterMinCost.value, world.options.FinalChapterMaxCost.value
|
||||
|
||||
if world.options.BadgeSellerMaxItems < world.options.BadgeSellerMinItems:
|
||||
world.options.BadgeSellerMaxItems.value, world.options.BadgeSellerMinItems.value = \
|
||||
world.options.BadgeSellerMinItems.value, world.options.BadgeSellerMaxItems.value
|
||||
|
||||
if world.options.NyakuzaThugMaxShopItems < world.options.NyakuzaThugMinShopItems:
|
||||
world.options.NyakuzaThugMaxShopItems.value, world.options.NyakuzaThugMinShopItems.value = \
|
||||
world.options.NyakuzaThugMinShopItems.value, world.options.NyakuzaThugMaxShopItems.value
|
||||
|
||||
if world.options.DWShuffleCountMax < world.options.DWShuffleCountMin:
|
||||
world.options.DWShuffleCountMax.value, world.options.DWShuffleCountMin.value = \
|
||||
world.options.DWShuffleCountMin.value, world.options.DWShuffleCountMax.value
|
||||
|
||||
total_tps: int = get_total_time_pieces(world)
|
||||
if world.options.HighestChapterCost > total_tps-5:
|
||||
world.options.HighestChapterCost.value = min(45, total_tps-5)
|
||||
|
||||
if world.options.LowestChapterCost > total_tps-5:
|
||||
world.options.LowestChapterCost.value = min(45, total_tps-5)
|
||||
|
||||
if world.options.FinalChapterMaxCost > total_tps:
|
||||
world.options.FinalChapterMaxCost.value = min(50, total_tps)
|
||||
|
||||
if world.options.FinalChapterMinCost > total_tps:
|
||||
world.options.FinalChapterMinCost.value = min(50, total_tps)
|
||||
|
||||
if world.is_dlc1() and world.options.ShipShapeCustomTaskGoal <= 0:
|
||||
# automatically determine task count based on Tasksanity settings
|
||||
if world.options.Tasksanity:
|
||||
world.options.ShipShapeCustomTaskGoal.value = world.options.TasksanityCheckCount * world.options.TasksanityTaskStep
|
||||
else:
|
||||
world.options.ShipShapeCustomTaskGoal.value = 18
|
||||
|
||||
# Don't allow Rush Hour goal if DLC2 content is disabled
|
||||
if world.options.EndGoal == EndGoal.option_rush_hour and not world.options.EnableDLC2:
|
||||
world.options.EndGoal.value = EndGoal.option_finale
|
||||
|
||||
# Don't allow Seal the Deal goal if Death Wish content is disabled
|
||||
if world.options.EndGoal == EndGoal.option_seal_the_deal and not world.is_dw():
|
||||
world.options.EndGoal.value = EndGoal.option_finale
|
||||
|
||||
if world.options.DWEnableBonus:
|
||||
world.options.DWAutoCompleteBonuses.value = 0
|
||||
|
||||
if world.is_dw_only():
|
||||
world.options.EndGoal.value = EndGoal.option_seal_the_deal
|
||||
world.options.ActRandomizer.value = 0
|
||||
world.options.ShuffleAlpineZiplines.value = 0
|
||||
world.options.ShuffleSubconPaintings.value = 0
|
||||
world.options.ShuffleStorybookPages.value = 0
|
||||
world.options.ShuffleActContracts.value = 0
|
||||
world.options.EnableDLC1.value = 0
|
||||
world.options.LogicDifficulty.value = LogicDifficulty.option_normal
|
||||
world.options.DWTimePieceRequirement.value = 0
|
||||
|
||||
|
||||
def get_total_time_pieces(world: "HatInTimeWorld") -> int:
|
||||
count: int = 40
|
||||
if world.is_dlc1():
|
||||
count += 6
|
||||
|
||||
if world.is_dlc2():
|
||||
count += 10
|
||||
|
||||
return min(40+world.options.MaxExtraTimePieces, count)
|
||||
|
||||
|
||||
class EndGoal(Choice):
|
||||
"""The end goal required to beat the game.
|
||||
Finale: Reach Time's End and beat Mustache Girl. The Finale will be in its vanilla location.
|
||||
|
||||
Rush Hour: Reach and complete Rush Hour. The level will be in its vanilla location and Chapter 7
|
||||
will be the final chapter. You also must find Nyakuza Metro itself and complete all of its levels.
|
||||
Requires DLC2 content to be enabled.
|
||||
|
||||
Seal the Deal: Reach and complete the Seal the Deal death wish main objective.
|
||||
Requires Death Wish content to be enabled."""
|
||||
display_name = "End Goal"
|
||||
option_finale = 1
|
||||
option_rush_hour = 2
|
||||
option_seal_the_deal = 3
|
||||
default = 1
|
||||
|
||||
|
||||
class ActRandomizer(Choice):
|
||||
"""If enabled, shuffle the game's Acts between each other.
|
||||
Light will cause Time Rifts to only be shuffled amongst each other,
|
||||
and Blue Time Rifts and Purple Time Rifts to be shuffled separately."""
|
||||
display_name = "Shuffle Acts"
|
||||
option_false = 0
|
||||
option_light = 1
|
||||
option_insanity = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class ActPlando(OptionDict):
|
||||
"""Plando acts onto other acts. For example, \"Train Rush\": \"Alpine Free Roam\" will place Alpine Free Roam
|
||||
at Train Rush."""
|
||||
display_name = "Act Plando"
|
||||
schema = Schema({
|
||||
Optional(str): str
|
||||
})
|
||||
|
||||
|
||||
class ActBlacklist(OptionDict):
|
||||
"""Blacklist acts from being shuffled onto other acts. Multiple can be listed per act.
|
||||
For example, \"Barrel Battle\": [\"The Big Parade\", \"Dead Bird Studio\"]
|
||||
will prevent The Big Parade and Dead Bird Studio from being shuffled onto Barrel Battle."""
|
||||
display_name = "Act Blacklist"
|
||||
schema = Schema({
|
||||
Optional(str): list
|
||||
})
|
||||
|
||||
|
||||
class FinaleShuffle(Toggle):
|
||||
"""If enabled, chapter finales will only be shuffled amongst each other in act shuffle."""
|
||||
display_name = "Finale Shuffle"
|
||||
|
||||
|
||||
class LogicDifficulty(Choice):
|
||||
"""Choose the difficulty setting for logic.
|
||||
For an exhaustive list of all logic tricks for each difficulty, see this Google Doc:
|
||||
https://docs.google.com/document/d/1x9VLSQ5davfx1KGamR9T0mD5h69_lDXJ6H7Gq7knJRI/edit?usp=sharing"""
|
||||
display_name = "Logic Difficulty"
|
||||
option_normal = -1
|
||||
option_moderate = 0
|
||||
option_hard = 1
|
||||
option_expert = 2
|
||||
default = -1
|
||||
|
||||
|
||||
class CTRLogic(Choice):
|
||||
"""Choose how you want to logically clear Cheating the Race."""
|
||||
display_name = "Cheating the Race Logic"
|
||||
option_time_stop_only = 0
|
||||
option_scooter = 1
|
||||
option_sprint = 2
|
||||
option_nothing = 3
|
||||
default = 0
|
||||
|
||||
|
||||
class RandomizeHatOrder(Choice):
|
||||
"""Randomize the order that hats are stitched in.
|
||||
Time Stop Last will force Time Stop to be the last hat in the sequence."""
|
||||
display_name = "Randomize Hat Order"
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
option_time_stop_last = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class YarnBalancePercent(Range):
|
||||
"""How much (in percentage) of the yarn in the pool that will be progression balanced."""
|
||||
display_name = "Yarn Balance Percentage"
|
||||
default = 20
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
|
||||
|
||||
class TimePieceBalancePercent(Range):
|
||||
"""How much (in percentage) of time pieces in the pool that will be progression balanced."""
|
||||
display_name = "Time Piece Balance Percentage"
|
||||
default = 35
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
|
||||
|
||||
class StartWithCompassBadge(DefaultOnToggle):
|
||||
"""If enabled, start with the Compass Badge. In Archipelago, the Compass Badge will track all items in the world
|
||||
(instead of just Relics). Recommended if you're not familiar with where item locations are."""
|
||||
display_name = "Start with Compass Badge"
|
||||
|
||||
|
||||
class CompassBadgeMode(Choice):
|
||||
"""closest - Compass Badge points to the closest item regardless of classification
|
||||
important_only - Compass Badge points to progression/useful items only
|
||||
important_first - Compass Badge points to progression/useful items first, then it will point to junk items"""
|
||||
display_name = "Compass Badge Mode"
|
||||
option_closest = 1
|
||||
option_important_only = 2
|
||||
option_important_first = 3
|
||||
default = 1
|
||||
|
||||
|
||||
class UmbrellaLogic(Toggle):
|
||||
"""Makes Hat Kid's default punch attack do absolutely nothing, making the Umbrella much more relevant and useful"""
|
||||
display_name = "Umbrella Logic"
|
||||
|
||||
|
||||
class ShuffleStorybookPages(DefaultOnToggle):
|
||||
"""If enabled, each storybook page in the purple Time Rifts is an item check.
|
||||
The Compass Badge can track these down for you."""
|
||||
display_name = "Shuffle Storybook Pages"
|
||||
|
||||
|
||||
class ShuffleActContracts(DefaultOnToggle):
|
||||
"""If enabled, shuffle Snatcher's act contracts into the pool as items"""
|
||||
display_name = "Shuffle Contracts"
|
||||
|
||||
|
||||
class ShuffleAlpineZiplines(Toggle):
|
||||
"""If enabled, Alpine's zipline paths leading to the peaks will be locked behind items."""
|
||||
display_name = "Shuffle Alpine Ziplines"
|
||||
|
||||
|
||||
class ShuffleSubconPaintings(Toggle):
|
||||
"""If enabled, shuffle items into the pool that unlock Subcon Forest fire spirit paintings.
|
||||
These items are progressive, with the order of Village-Swamp-Courtyard."""
|
||||
display_name = "Shuffle Subcon Paintings"
|
||||
|
||||
|
||||
class NoPaintingSkips(Toggle):
|
||||
"""If enabled, prevent Subcon fire wall skips from being in logic on higher difficulty settings."""
|
||||
display_name = "No Subcon Fire Wall Skips"
|
||||
|
||||
|
||||
class StartingChapter(Choice):
|
||||
"""Determines which chapter you will be guaranteed to be able to enter at the beginning of the game."""
|
||||
display_name = "Starting Chapter"
|
||||
option_1 = 1
|
||||
option_2 = 2
|
||||
option_3 = 3
|
||||
option_4 = 4
|
||||
default = 1
|
||||
|
||||
|
||||
class ChapterCostIncrement(Range):
|
||||
"""Lower values mean chapter costs increase slower. Higher values make the cost differences more steep."""
|
||||
display_name = "Chapter Cost Increment"
|
||||
range_start = 1
|
||||
range_end = 8
|
||||
default = 4
|
||||
|
||||
|
||||
class ChapterCostMinDifference(Range):
|
||||
"""The minimum difference between chapter costs."""
|
||||
display_name = "Minimum Chapter Cost Difference"
|
||||
range_start = 1
|
||||
range_end = 8
|
||||
default = 4
|
||||
|
||||
|
||||
class LowestChapterCost(Range):
|
||||
"""Value determining the lowest possible cost for a chapter.
|
||||
Chapter costs will, progressively, be calculated based on this value (except for the final chapter)."""
|
||||
display_name = "Lowest Possible Chapter Cost"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 5
|
||||
|
||||
|
||||
class HighestChapterCost(Range):
|
||||
"""Value determining the highest possible cost for a chapter.
|
||||
Chapter costs will, progressively, be calculated based on this value (except for the final chapter)."""
|
||||
display_name = "Highest Possible Chapter Cost"
|
||||
range_start = 15
|
||||
range_end = 45
|
||||
default = 25
|
||||
|
||||
|
||||
class FinalChapterMinCost(Range):
|
||||
"""Minimum Time Pieces required to enter the final chapter. This is part of your goal."""
|
||||
display_name = "Final Chapter Minimum Time Piece Cost"
|
||||
range_start = 0
|
||||
range_end = 50
|
||||
default = 30
|
||||
|
||||
|
||||
class FinalChapterMaxCost(Range):
|
||||
"""Maximum Time Pieces required to enter the final chapter. This is part of your goal."""
|
||||
display_name = "Final Chapter Maximum Time Piece Cost"
|
||||
range_start = 0
|
||||
range_end = 50
|
||||
default = 35
|
||||
|
||||
|
||||
class MaxExtraTimePieces(Range):
|
||||
"""Maximum number of extra Time Pieces from the DLCs.
|
||||
Arctic Cruise will add up to 6. Nyakuza Metro will add up to 10. The absolute maximum is 56."""
|
||||
display_name = "Max Extra Time Pieces"
|
||||
range_start = 0
|
||||
range_end = 16
|
||||
default = 16
|
||||
|
||||
|
||||
class YarnCostMin(Range):
|
||||
"""The minimum possible yarn needed to stitch a hat."""
|
||||
display_name = "Minimum Yarn Cost"
|
||||
range_start = 1
|
||||
range_end = 12
|
||||
default = 4
|
||||
|
||||
|
||||
class YarnCostMax(Range):
|
||||
"""The maximum possible yarn needed to stitch a hat."""
|
||||
display_name = "Maximum Yarn Cost"
|
||||
range_start = 1
|
||||
range_end = 12
|
||||
default = 8
|
||||
|
||||
|
||||
class YarnAvailable(Range):
|
||||
"""How much yarn is available to collect in the item pool."""
|
||||
display_name = "Yarn Available"
|
||||
range_start = 30
|
||||
range_end = 80
|
||||
default = 50
|
||||
|
||||
|
||||
class MinExtraYarn(Range):
|
||||
"""The minimum number of extra yarn in the item pool.
|
||||
There must be at least this much more yarn over the total number of yarn needed to craft all hats.
|
||||
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
|
||||
there must be at least 50 yarn in the pool."""
|
||||
display_name = "Max Extra Yarn"
|
||||
range_start = 5
|
||||
range_end = 15
|
||||
default = 10
|
||||
|
||||
|
||||
class HatItems(Toggle):
|
||||
"""Removes all yarn from the pool and turns the hats into individual items instead."""
|
||||
display_name = "Hat Items"
|
||||
|
||||
|
||||
class MinPonCost(Range):
|
||||
"""The minimum number of Pons that any item in the Badge Seller's shop can cost."""
|
||||
display_name = "Minimum Shop Pon Cost"
|
||||
range_start = 10
|
||||
range_end = 800
|
||||
default = 75
|
||||
|
||||
|
||||
class MaxPonCost(Range):
|
||||
"""The maximum number of Pons that any item in the Badge Seller's shop can cost."""
|
||||
display_name = "Maximum Shop Pon Cost"
|
||||
range_start = 10
|
||||
range_end = 800
|
||||
default = 300
|
||||
|
||||
|
||||
class BadgeSellerMinItems(Range):
|
||||
"""The smallest number of items that the Badge Seller can have for sale."""
|
||||
display_name = "Badge Seller Minimum Items"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 4
|
||||
|
||||
|
||||
class BadgeSellerMaxItems(Range):
|
||||
"""The largest number of items that the Badge Seller can have for sale."""
|
||||
display_name = "Badge Seller Maximum Items"
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
default = 8
|
||||
|
||||
|
||||
class EnableDLC1(Toggle):
|
||||
"""Shuffle content from The Arctic Cruise (Chapter 6) into the game. This also includes the Tour time rift.
|
||||
DO NOT ENABLE THIS OPTION IF YOU DO NOT HAVE SEAL THE DEAL DLC INSTALLED!!!"""
|
||||
display_name = "Shuffle Chapter 6"
|
||||
|
||||
|
||||
class Tasksanity(Toggle):
|
||||
"""If enabled, Ship Shape tasks will become checks. Requires DLC1 content to be enabled."""
|
||||
display_name = "Tasksanity"
|
||||
|
||||
|
||||
class TasksanityTaskStep(Range):
|
||||
"""How many tasks the player must complete in Tasksanity to send a check."""
|
||||
display_name = "Tasksanity Task Step"
|
||||
range_start = 1
|
||||
range_end = 3
|
||||
default = 1
|
||||
|
||||
|
||||
class TasksanityCheckCount(Range):
|
||||
"""How many Tasksanity checks there will be in total."""
|
||||
display_name = "Tasksanity Check Count"
|
||||
range_start = 1
|
||||
range_end = 30
|
||||
default = 18
|
||||
|
||||
|
||||
class ExcludeTour(Toggle):
|
||||
"""Removes the Tour time rift from the game. This option is recommended if you don't want to deal with
|
||||
important levels being shuffled onto the Tour time rift, or important items being shuffled onto Tour pages
|
||||
when your goal is Time's End."""
|
||||
display_name = "Exclude Tour Time Rift"
|
||||
|
||||
|
||||
class ShipShapeCustomTaskGoal(Range):
|
||||
"""Change the number of tasks required to complete Ship Shape. If this option's value is 0, the number of tasks
|
||||
required will be TasksanityTaskStep x TasksanityCheckCount, if Tasksanity is enabled. If Tasksanity is disabled,
|
||||
it will use the game's default of 18.
|
||||
This option will not affect Cruisin' for a Bruisin'."""
|
||||
display_name = "Ship Shape Custom Task Goal"
|
||||
range_start = 0
|
||||
range_end = 90
|
||||
default = 0
|
||||
|
||||
|
||||
class EnableDLC2(Toggle):
|
||||
"""Shuffle content from Nyakuza Metro (Chapter 7) into the game.
|
||||
DO NOT ENABLE THIS OPTION IF YOU DO NOT HAVE NYAKUZA METRO DLC INSTALLED!!!"""
|
||||
display_name = "Shuffle Chapter 7"
|
||||
|
||||
|
||||
class MetroMinPonCost(Range):
|
||||
"""The cheapest an item can be in any Nyakuza Metro shop. Includes ticket booths."""
|
||||
display_name = "Metro Shops Minimum Pon Cost"
|
||||
range_start = 10
|
||||
range_end = 800
|
||||
default = 50
|
||||
|
||||
|
||||
class MetroMaxPonCost(Range):
|
||||
"""The most expensive an item can be in any Nyakuza Metro shop. Includes ticket booths."""
|
||||
display_name = "Metro Shops Maximum Pon Cost"
|
||||
range_start = 10
|
||||
range_end = 800
|
||||
default = 200
|
||||
|
||||
|
||||
class NyakuzaThugMinShopItems(Range):
|
||||
"""The smallest number of items that the thugs in Nyakuza Metro can have for sale."""
|
||||
display_name = "Nyakuza Thug Minimum Shop Items"
|
||||
range_start = 0
|
||||
range_end = 5
|
||||
default = 2
|
||||
|
||||
|
||||
class NyakuzaThugMaxShopItems(Range):
|
||||
"""The largest number of items that the thugs in Nyakuza Metro can have for sale."""
|
||||
display_name = "Nyakuza Thug Maximum Shop Items"
|
||||
range_start = 0
|
||||
range_end = 5
|
||||
default = 4
|
||||
|
||||
|
||||
class NoTicketSkips(Choice):
|
||||
"""Prevent metro gate skips from being in logic on higher difficulties.
|
||||
Rush Hour option will only consider the ticket skips for Rush Hour in logic."""
|
||||
display_name = "No Ticket Skips"
|
||||
option_false = 0
|
||||
option_true = 1
|
||||
option_rush_hour = 2
|
||||
|
||||
|
||||
class BaseballBat(Toggle):
|
||||
"""Replace the Umbrella with the baseball bat from Nyakuza Metro.
|
||||
DLC2 content does not have to be shuffled for this option but Nyakuza Metro still needs to be installed."""
|
||||
display_name = "Baseball Bat"
|
||||
|
||||
|
||||
class EnableDeathWish(Toggle):
|
||||
"""Shuffle Death Wish contracts into the game. Each contract by default will have 1 check granted upon completion.
|
||||
DO NOT ENABLE THIS OPTION IF YOU DO NOT HAVE SEAL THE DEAL DLC INSTALLED!!!"""
|
||||
display_name = "Enable Death Wish"
|
||||
|
||||
|
||||
class DeathWishOnly(Toggle):
|
||||
"""An alternative gameplay mode that allows you to exclusively play Death Wish in a seed.
|
||||
This has the following effects:
|
||||
- Death Wish is instantly unlocked from the start
|
||||
- All hats and other progression items are instantly given to you
|
||||
- Useful items such as Fast Hatter Badge will still be in the item pool instead of in your inventory at the start
|
||||
- All chapters and their levels are unlocked, act shuffle is forced off
|
||||
- Any checks other than Death Wish contracts are completely removed
|
||||
- All Pons in the item pool are replaced with Health Pons or random cosmetics
|
||||
- The EndGoal option is forced to complete Seal the Deal"""
|
||||
display_name = "Death Wish Only"
|
||||
|
||||
|
||||
class DWShuffle(Toggle):
|
||||
"""An alternative mode for Death Wish where each contract is unlocked one by one, in a random order.
|
||||
Stamp requirements to unlock contracts is removed. Any excluded contracts will not be shuffled into the sequence.
|
||||
If Seal the Deal is the end goal, it will always be the last Death Wish in the sequence.
|
||||
Disabling candles is highly recommended."""
|
||||
display_name = "Death Wish Shuffle"
|
||||
|
||||
|
||||
class DWShuffleCountMin(Range):
|
||||
"""The minimum number of Death Wishes that can be in the Death Wish shuffle sequence.
|
||||
The final result is clamped at the number of non-excluded Death Wishes."""
|
||||
display_name = "Death Wish Shuffle Minimum Count"
|
||||
range_start = 5
|
||||
range_end = 38
|
||||
default = 18
|
||||
|
||||
|
||||
class DWShuffleCountMax(Range):
|
||||
"""The maximum number of Death Wishes that can be in the Death Wish shuffle sequence.
|
||||
The final result is clamped at the number of non-excluded Death Wishes."""
|
||||
display_name = "Death Wish Shuffle Maximum Count"
|
||||
range_start = 5
|
||||
range_end = 38
|
||||
default = 25
|
||||
|
||||
|
||||
class DWEnableBonus(Toggle):
|
||||
"""In Death Wish, add a location for completing all of a DW contract's bonuses,
|
||||
in addition to the location for completing the DW contract normally.
|
||||
WARNING!! Only for the brave! This option can create VERY DIFFICULT SEEDS!
|
||||
ONLY turn this on if you know what you are doing to yourself and everyone else in the multiworld!
|
||||
Using Peace and Tranquility to auto-complete the bonuses will NOT count!"""
|
||||
display_name = "Shuffle Death Wish Full Completions"
|
||||
|
||||
|
||||
class DWAutoCompleteBonuses(DefaultOnToggle):
|
||||
"""If enabled, auto complete all bonus stamps after completing the main objective in a Death Wish.
|
||||
This option will have no effect if bonus checks (DWEnableBonus) are turned on."""
|
||||
display_name = "Auto Complete Bonus Stamps"
|
||||
|
||||
|
||||
class DWExcludeAnnoyingContracts(DefaultOnToggle):
|
||||
"""Exclude Death Wish contracts from the pool that are particularly tedious or take a long time to reach/clear.
|
||||
Excluded Death Wishes are automatically completed as soon as they are unlocked.
|
||||
This option currently excludes the following contracts:
|
||||
- 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, but the checks are still excluded)"""
|
||||
display_name = "Exclude Annoying Death Wish Contracts"
|
||||
|
||||
|
||||
class DWExcludeAnnoyingBonuses(DefaultOnToggle):
|
||||
"""If Death Wish full completions are shuffled in, exclude tedious Death Wish full completions from the pool.
|
||||
Excluded bonus Death Wishes automatically reward their bonus stamps upon completion of the main objective.
|
||||
This option currently excludes the following bonuses:
|
||||
- So You're Back From Outer Space
|
||||
- Encore! Encore!
|
||||
- Snatcher's Hit List
|
||||
- 10 Seconds until Self-Destruct
|
||||
- Killing Two Birds
|
||||
- Zero Jumps
|
||||
- Bird Sanctuary
|
||||
- Wound-Up Windmill
|
||||
- Vault Codes in the Wind
|
||||
- Boss Rush
|
||||
- Camera Tourist
|
||||
- The Mustache Gauntlet
|
||||
- Rift Collapse: Deep Sea
|
||||
- Cruisin' for a Bruisin'
|
||||
- Seal the Deal"""
|
||||
display_name = "Exclude Annoying Death Wish Full Completions"
|
||||
|
||||
|
||||
class DWExcludeCandles(DefaultOnToggle):
|
||||
"""If enabled, exclude all candle Death Wishes."""
|
||||
display_name = "Exclude Candle Death Wishes"
|
||||
|
||||
|
||||
class DWTimePieceRequirement(Range):
|
||||
"""How many Time Pieces that will be required to unlock Death Wish."""
|
||||
display_name = "Death Wish Time Piece Requirement"
|
||||
range_start = 0
|
||||
range_end = 35
|
||||
default = 15
|
||||
|
||||
|
||||
class TrapChance(Range):
|
||||
"""The chance for any junk item in the pool to be replaced by a trap."""
|
||||
display_name = "Trap Chance"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
default = 0
|
||||
|
||||
|
||||
class BabyTrapWeight(Range):
|
||||
"""The weight of Baby Traps in the trap pool.
|
||||
Baby Traps place a multitude of the Conductor's grandkids into Hat Kid's hands, causing her to lose her balance."""
|
||||
display_name = "Baby Trap Weight"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
default = 40
|
||||
|
||||
|
||||
class LaserTrapWeight(Range):
|
||||
"""The weight of Laser Traps in the trap pool.
|
||||
Laser Traps will spawn multiple giant lasers (from Snatcher's boss fight) at Hat Kid's location."""
|
||||
display_name = "Laser Trap Weight"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
default = 40
|
||||
|
||||
|
||||
class ParadeTrapWeight(Range):
|
||||
"""The weight of Parade Traps in the trap pool.
|
||||
Parade Traps will summon multiple Express Band owls with knives that chase Hat Kid by mimicking her movement."""
|
||||
display_name = "Parade Trap Weight"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
default = 20
|
||||
|
||||
|
||||
@dataclass
|
||||
class AHITOptions(PerGameCommonOptions):
|
||||
EndGoal: EndGoal
|
||||
ActRandomizer: ActRandomizer
|
||||
ActPlando: ActPlando
|
||||
ActBlacklist: ActBlacklist
|
||||
ShuffleAlpineZiplines: ShuffleAlpineZiplines
|
||||
FinaleShuffle: FinaleShuffle
|
||||
LogicDifficulty: LogicDifficulty
|
||||
YarnBalancePercent: YarnBalancePercent
|
||||
TimePieceBalancePercent: TimePieceBalancePercent
|
||||
RandomizeHatOrder: RandomizeHatOrder
|
||||
UmbrellaLogic: UmbrellaLogic
|
||||
StartWithCompassBadge: StartWithCompassBadge
|
||||
CompassBadgeMode: CompassBadgeMode
|
||||
ShuffleStorybookPages: ShuffleStorybookPages
|
||||
ShuffleActContracts: ShuffleActContracts
|
||||
ShuffleSubconPaintings: ShuffleSubconPaintings
|
||||
NoPaintingSkips: NoPaintingSkips
|
||||
StartingChapter: StartingChapter
|
||||
CTRLogic: CTRLogic
|
||||
|
||||
EnableDLC1: EnableDLC1
|
||||
Tasksanity: Tasksanity
|
||||
TasksanityTaskStep: TasksanityTaskStep
|
||||
TasksanityCheckCount: TasksanityCheckCount
|
||||
ExcludeTour: ExcludeTour
|
||||
ShipShapeCustomTaskGoal: ShipShapeCustomTaskGoal
|
||||
|
||||
EnableDeathWish: EnableDeathWish
|
||||
DWShuffle: DWShuffle
|
||||
DWShuffleCountMin: DWShuffleCountMin
|
||||
DWShuffleCountMax: DWShuffleCountMax
|
||||
DeathWishOnly: DeathWishOnly
|
||||
DWEnableBonus: DWEnableBonus
|
||||
DWAutoCompleteBonuses: DWAutoCompleteBonuses
|
||||
DWExcludeAnnoyingContracts: DWExcludeAnnoyingContracts
|
||||
DWExcludeAnnoyingBonuses: DWExcludeAnnoyingBonuses
|
||||
DWExcludeCandles: DWExcludeCandles
|
||||
DWTimePieceRequirement: DWTimePieceRequirement
|
||||
|
||||
EnableDLC2: EnableDLC2
|
||||
BaseballBat: BaseballBat
|
||||
MetroMinPonCost: MetroMinPonCost
|
||||
MetroMaxPonCost: MetroMaxPonCost
|
||||
NyakuzaThugMinShopItems: NyakuzaThugMinShopItems
|
||||
NyakuzaThugMaxShopItems: NyakuzaThugMaxShopItems
|
||||
NoTicketSkips: NoTicketSkips
|
||||
|
||||
LowestChapterCost: LowestChapterCost
|
||||
HighestChapterCost: HighestChapterCost
|
||||
ChapterCostIncrement: ChapterCostIncrement
|
||||
ChapterCostMinDifference: ChapterCostMinDifference
|
||||
MaxExtraTimePieces: MaxExtraTimePieces
|
||||
|
||||
FinalChapterMinCost: FinalChapterMinCost
|
||||
FinalChapterMaxCost: FinalChapterMaxCost
|
||||
|
||||
YarnCostMin: YarnCostMin
|
||||
YarnCostMax: YarnCostMax
|
||||
YarnAvailable: YarnAvailable
|
||||
MinExtraYarn: MinExtraYarn
|
||||
HatItems: HatItems
|
||||
|
||||
MinPonCost: MinPonCost
|
||||
MaxPonCost: MaxPonCost
|
||||
BadgeSellerMinItems: BadgeSellerMinItems
|
||||
BadgeSellerMaxItems: BadgeSellerMaxItems
|
||||
|
||||
TrapChance: TrapChance
|
||||
BabyTrapWeight: BabyTrapWeight
|
||||
LaserTrapWeight: LaserTrapWeight
|
||||
ParadeTrapWeight: ParadeTrapWeight
|
||||
|
||||
death_link: DeathLink
|
||||
|
||||
|
||||
ahit_option_groups: Dict[str, List[Any]] = {
|
||||
"General Options": [EndGoal, ShuffleStorybookPages, ShuffleAlpineZiplines, ShuffleSubconPaintings,
|
||||
ShuffleActContracts, MinPonCost, MaxPonCost, BadgeSellerMinItems, BadgeSellerMaxItems,
|
||||
LogicDifficulty, NoPaintingSkips, CTRLogic],
|
||||
|
||||
"Act Options": [ActRandomizer, StartingChapter, LowestChapterCost, HighestChapterCost,
|
||||
ChapterCostIncrement, ChapterCostMinDifference, FinalChapterMinCost, FinalChapterMaxCost,
|
||||
FinaleShuffle, ActPlando, ActBlacklist],
|
||||
|
||||
"Item Options": [StartWithCompassBadge, CompassBadgeMode, RandomizeHatOrder, YarnAvailable, YarnCostMin,
|
||||
YarnCostMax, MinExtraYarn, HatItems, UmbrellaLogic, MaxExtraTimePieces, YarnBalancePercent,
|
||||
TimePieceBalancePercent],
|
||||
|
||||
"Arctic Cruise Options": [EnableDLC1, Tasksanity, TasksanityTaskStep, TasksanityCheckCount,
|
||||
ShipShapeCustomTaskGoal, ExcludeTour],
|
||||
|
||||
"Nyakuza Metro Options": [EnableDLC2, MetroMinPonCost, MetroMaxPonCost, NyakuzaThugMinShopItems,
|
||||
NyakuzaThugMaxShopItems, BaseballBat, NoTicketSkips],
|
||||
|
||||
"Death Wish Options": [EnableDeathWish, DWTimePieceRequirement, DWShuffle, DWShuffleCountMin, DWShuffleCountMax,
|
||||
DWEnableBonus, DWAutoCompleteBonuses, DWExcludeAnnoyingContracts, DWExcludeAnnoyingBonuses,
|
||||
DWExcludeCandles, DeathWishOnly],
|
||||
|
||||
"Trap Options": [TrapChance, BabyTrapWeight, LaserTrapWeight, ParadeTrapWeight]
|
||||
}
|
||||
|
||||
|
||||
slot_data_options: List[str] = [
|
||||
"EndGoal",
|
||||
"ActRandomizer",
|
||||
"ShuffleAlpineZiplines",
|
||||
"LogicDifficulty",
|
||||
"CTRLogic",
|
||||
"RandomizeHatOrder",
|
||||
"UmbrellaLogic",
|
||||
"StartWithCompassBadge",
|
||||
"CompassBadgeMode",
|
||||
"ShuffleStorybookPages",
|
||||
"ShuffleActContracts",
|
||||
"ShuffleSubconPaintings",
|
||||
"NoPaintingSkips",
|
||||
"HatItems",
|
||||
|
||||
"EnableDLC1",
|
||||
"Tasksanity",
|
||||
"TasksanityTaskStep",
|
||||
"TasksanityCheckCount",
|
||||
"ShipShapeCustomTaskGoal",
|
||||
"ExcludeTour",
|
||||
|
||||
"EnableDeathWish",
|
||||
"DWShuffle",
|
||||
"DeathWishOnly",
|
||||
"DWEnableBonus",
|
||||
"DWAutoCompleteBonuses",
|
||||
"DWTimePieceRequirement",
|
||||
|
||||
"EnableDLC2",
|
||||
"MetroMinPonCost",
|
||||
"MetroMaxPonCost",
|
||||
"BaseballBat",
|
||||
"NoTicketSkips",
|
||||
|
||||
"MinPonCost",
|
||||
"MaxPonCost",
|
||||
|
||||
"death_link",
|
||||
]
|
||||
1025
worlds/ahit/Regions.py
Normal file
1025
worlds/ahit/Regions.py
Normal file
File diff suppressed because it is too large
Load Diff
959
worlds/ahit/Rules.py
Normal file
959
worlds/ahit/Rules.py
Normal file
@@ -0,0 +1,959 @@
|
||||
from worlds.AutoWorld import CollectionState
|
||||
from worlds.generic.Rules import add_rule, set_rule
|
||||
from .Locations import location_table, zipline_unlocks, is_location_valid, contract_locations, \
|
||||
shop_locations, event_locs
|
||||
from .Types import HatType, ChapterIndex, hat_type_to_item, Difficulty, HitType
|
||||
from BaseClasses import Location, Entrance, Region
|
||||
from typing import TYPE_CHECKING, List, Callable, Union, Dict
|
||||
from .Options import EndGoal, CTRLogic, NoTicketSkips
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import HatInTimeWorld
|
||||
|
||||
|
||||
act_connections = {
|
||||
"Mafia Town - Act 2": ["Mafia Town - Act 1"],
|
||||
"Mafia Town - Act 3": ["Mafia Town - Act 1"],
|
||||
"Mafia Town - Act 4": ["Mafia Town - Act 2", "Mafia Town - Act 3"],
|
||||
"Mafia Town - Act 6": ["Mafia Town - Act 4"],
|
||||
"Mafia Town - Act 7": ["Mafia Town - Act 4"],
|
||||
"Mafia Town - Act 5": ["Mafia Town - Act 6", "Mafia Town - Act 7"],
|
||||
|
||||
"Battle of the Birds - Act 2": ["Battle of the Birds - Act 1"],
|
||||
"Battle of the Birds - Act 3": ["Battle of the Birds - Act 1"],
|
||||
"Battle of the Birds - Act 4": ["Battle of the Birds - Act 2", "Battle of the Birds - Act 3"],
|
||||
"Battle of the Birds - Act 5": ["Battle of the Birds - Act 2", "Battle of the Birds - Act 3"],
|
||||
"Battle of the Birds - Finale A": ["Battle of the Birds - Act 4", "Battle of the Birds - Act 5"],
|
||||
"Battle of the Birds - Finale B": ["Battle of the Birds - Finale A"],
|
||||
|
||||
"Subcon Forest - Finale": ["Subcon Forest - Act 1", "Subcon Forest - Act 2",
|
||||
"Subcon Forest - Act 3", "Subcon Forest - Act 4",
|
||||
"Subcon Forest - Act 5"],
|
||||
|
||||
"The Arctic Cruise - Act 2": ["The Arctic Cruise - Act 1"],
|
||||
"The Arctic Cruise - Finale": ["The Arctic Cruise - Act 2"],
|
||||
}
|
||||
|
||||
|
||||
def can_use_hat(state: CollectionState, world: "HatInTimeWorld", hat: HatType) -> bool:
|
||||
if world.options.HatItems:
|
||||
return state.has(hat_type_to_item[hat], world.player)
|
||||
|
||||
if world.hat_yarn_costs[hat] <= 0: # this means the hat was put into starting inventory
|
||||
return True
|
||||
|
||||
return state.has("Yarn", world.player, get_hat_cost(world, hat))
|
||||
|
||||
|
||||
def get_hat_cost(world: "HatInTimeWorld", hat: HatType) -> int:
|
||||
cost = 0
|
||||
for h in world.hat_craft_order:
|
||||
cost += world.hat_yarn_costs[h]
|
||||
if h == hat:
|
||||
break
|
||||
|
||||
return cost
|
||||
|
||||
|
||||
def painting_logic(world: "HatInTimeWorld") -> bool:
|
||||
return bool(world.options.ShuffleSubconPaintings)
|
||||
|
||||
|
||||
# -1 = Normal, 0 = Moderate, 1 = Hard, 2 = Expert
|
||||
def get_difficulty(world: "HatInTimeWorld") -> Difficulty:
|
||||
return Difficulty(world.options.LogicDifficulty)
|
||||
|
||||
|
||||
def has_paintings(state: CollectionState, world: "HatInTimeWorld", count: int, allow_skip: bool = True) -> bool:
|
||||
if not painting_logic(world):
|
||||
return True
|
||||
|
||||
if not world.options.NoPaintingSkips and allow_skip:
|
||||
# In Moderate there is a very easy trick to skip all the walls, except for the one guarding the boss arena
|
||||
if get_difficulty(world) >= Difficulty.MODERATE:
|
||||
return True
|
||||
|
||||
return state.has("Progressive Painting Unlock", world.player, count)
|
||||
|
||||
|
||||
def zipline_logic(world: "HatInTimeWorld") -> bool:
|
||||
return bool(world.options.ShuffleAlpineZiplines)
|
||||
|
||||
|
||||
def can_use_hookshot(state: CollectionState, world: "HatInTimeWorld"):
|
||||
return state.has("Hookshot Badge", world.player)
|
||||
|
||||
|
||||
def can_hit(state: CollectionState, world: "HatInTimeWorld", umbrella_only: bool = False):
|
||||
if not world.options.UmbrellaLogic:
|
||||
return True
|
||||
|
||||
return state.has("Umbrella", world.player) or not umbrella_only and can_use_hat(state, world, HatType.BREWING)
|
||||
|
||||
|
||||
def has_relic_combo(state: CollectionState, world: "HatInTimeWorld", relic: str) -> bool:
|
||||
return state.has_group(relic, world.player, len(world.item_name_groups[relic]))
|
||||
|
||||
|
||||
def get_relic_count(state: CollectionState, world: "HatInTimeWorld", relic: str) -> int:
|
||||
return state.count_group(relic, world.player)
|
||||
|
||||
|
||||
# This is used to determine if the player can clear an act that's required to unlock a Time Rift
|
||||
def can_clear_required_act(state: CollectionState, world: "HatInTimeWorld", act_entrance: str) -> bool:
|
||||
entrance: Entrance = world.multiworld.get_entrance(act_entrance, world.player)
|
||||
if not state.can_reach(entrance.connected_region, "Region", world.player):
|
||||
return False
|
||||
|
||||
if "Free Roam" in entrance.connected_region.name:
|
||||
return True
|
||||
|
||||
name: str = f"Act Completion ({entrance.connected_region.name})"
|
||||
return world.multiworld.get_location(name, world.player).access_rule(state)
|
||||
|
||||
|
||||
def can_clear_alpine(state: CollectionState, world: "HatInTimeWorld") -> bool:
|
||||
return state.has("Birdhouse Cleared", world.player) and state.has("Lava Cake Cleared", world.player) \
|
||||
and state.has("Windmill Cleared", world.player) and state.has("Twilight Bell Cleared", world.player)
|
||||
|
||||
|
||||
def can_clear_metro(state: CollectionState, world: "HatInTimeWorld") -> bool:
|
||||
return state.has("Nyakuza Intro Cleared", world.player) \
|
||||
and state.has("Yellow Overpass Station Cleared", world.player) \
|
||||
and state.has("Yellow Overpass Manhole Cleared", world.player) \
|
||||
and state.has("Green Clean Station Cleared", world.player) \
|
||||
and state.has("Green Clean Manhole Cleared", world.player) \
|
||||
and state.has("Bluefin Tunnel Cleared", world.player) \
|
||||
and state.has("Pink Paw Station Cleared", world.player) \
|
||||
and state.has("Pink Paw Manhole Cleared", world.player)
|
||||
|
||||
|
||||
def set_rules(world: "HatInTimeWorld"):
|
||||
# First, chapter access
|
||||
starting_chapter = ChapterIndex(world.options.StartingChapter)
|
||||
world.chapter_timepiece_costs[starting_chapter] = 0
|
||||
|
||||
# Chapter costs increase progressively. Randomly decide the chapter order, except for Finale
|
||||
chapter_list: List[ChapterIndex] = [ChapterIndex.MAFIA, ChapterIndex.BIRDS,
|
||||
ChapterIndex.SUBCON, ChapterIndex.ALPINE]
|
||||
|
||||
final_chapter = ChapterIndex.FINALE
|
||||
if world.options.EndGoal == EndGoal.option_rush_hour:
|
||||
final_chapter = ChapterIndex.METRO
|
||||
chapter_list.append(ChapterIndex.FINALE)
|
||||
elif world.options.EndGoal == EndGoal.option_seal_the_deal:
|
||||
final_chapter = None
|
||||
chapter_list.append(ChapterIndex.FINALE)
|
||||
|
||||
if world.is_dlc1():
|
||||
chapter_list.append(ChapterIndex.CRUISE)
|
||||
|
||||
if world.is_dlc2() and final_chapter is not ChapterIndex.METRO:
|
||||
chapter_list.append(ChapterIndex.METRO)
|
||||
|
||||
chapter_list.remove(starting_chapter)
|
||||
world.random.shuffle(chapter_list)
|
||||
|
||||
# Make sure Alpine is unlocked before any DLC chapters are, as the Alpine door needs to be open to access them
|
||||
if starting_chapter is not ChapterIndex.ALPINE and (world.is_dlc1() or world.is_dlc2()):
|
||||
index1 = 69
|
||||
index2 = 69
|
||||
pos: int
|
||||
lowest_index: int
|
||||
chapter_list.remove(ChapterIndex.ALPINE)
|
||||
|
||||
if world.is_dlc1():
|
||||
index1 = chapter_list.index(ChapterIndex.CRUISE)
|
||||
|
||||
if world.is_dlc2() and final_chapter is not ChapterIndex.METRO:
|
||||
index2 = chapter_list.index(ChapterIndex.METRO)
|
||||
|
||||
lowest_index = min(index1, index2)
|
||||
if lowest_index == 0:
|
||||
pos = 0
|
||||
else:
|
||||
pos = world.random.randint(0, lowest_index)
|
||||
|
||||
chapter_list.insert(pos, ChapterIndex.ALPINE)
|
||||
|
||||
lowest_cost: int = world.options.LowestChapterCost.value
|
||||
highest_cost: int = world.options.HighestChapterCost.value
|
||||
cost_increment: int = world.options.ChapterCostIncrement.value
|
||||
min_difference: int = world.options.ChapterCostMinDifference.value
|
||||
last_cost = 0
|
||||
|
||||
for i, chapter in enumerate(chapter_list):
|
||||
min_range: int = lowest_cost + (cost_increment * i)
|
||||
if min_range >= highest_cost:
|
||||
min_range = highest_cost-1
|
||||
|
||||
value: int = world.random.randint(min_range, min(highest_cost, max(lowest_cost, last_cost + cost_increment)))
|
||||
cost = world.random.randint(value, min(value + cost_increment, highest_cost))
|
||||
if i >= 1:
|
||||
if last_cost + min_difference > cost:
|
||||
cost = last_cost + min_difference
|
||||
|
||||
cost = min(cost, highest_cost)
|
||||
world.chapter_timepiece_costs[chapter] = cost
|
||||
last_cost = cost
|
||||
|
||||
if final_chapter is not None:
|
||||
final_chapter_cost: int
|
||||
if world.options.FinalChapterMinCost == world.options.FinalChapterMaxCost:
|
||||
final_chapter_cost = world.options.FinalChapterMaxCost.value
|
||||
else:
|
||||
final_chapter_cost = world.random.randint(world.options.FinalChapterMinCost.value,
|
||||
world.options.FinalChapterMaxCost.value)
|
||||
|
||||
world.chapter_timepiece_costs[final_chapter] = final_chapter_cost
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Mafia Town", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.MAFIA]))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Battle of the Birds", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.BIRDS]))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Subcon Forest", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.SUBCON]))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Alpine Skyline", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.ALPINE]))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Time's End", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.FINALE])
|
||||
and can_use_hat(state, world, HatType.BREWING) and can_use_hat(state, world, HatType.DWELLER))
|
||||
|
||||
if world.is_dlc1():
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Arctic Cruise", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.ALPINE])
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.CRUISE]))
|
||||
|
||||
if world.is_dlc2():
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Nyakuza Metro", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.ALPINE])
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.METRO])
|
||||
and can_use_hat(state, world, HatType.DWELLER) and can_use_hat(state, world, HatType.ICE))
|
||||
|
||||
if not world.options.ActRandomizer:
|
||||
set_default_rift_rules(world)
|
||||
|
||||
table = {**location_table, **event_locs}
|
||||
for (key, data) in table.items():
|
||||
if not is_location_valid(world, key):
|
||||
continue
|
||||
|
||||
if key in contract_locations.keys():
|
||||
continue
|
||||
|
||||
loc = world.multiworld.get_location(key, world.player)
|
||||
|
||||
for hat in data.required_hats:
|
||||
add_rule(loc, lambda state, h=hat: can_use_hat(state, world, h))
|
||||
|
||||
if data.hookshot:
|
||||
add_rule(loc, lambda state: can_use_hookshot(state, world))
|
||||
|
||||
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))
|
||||
|
||||
for misc in data.misc_required:
|
||||
add_rule(loc, lambda state, item=misc: state.has(item, world.player))
|
||||
|
||||
set_specific_rules(world)
|
||||
|
||||
# Putting all of this here, so it doesn't get overridden by anything
|
||||
# Illness starts the player past the intro
|
||||
alpine_entrance = world.multiworld.get_entrance("AFR -> Alpine Skyline Area", world.player)
|
||||
add_rule(alpine_entrance, lambda state: can_use_hookshot(state, world))
|
||||
if world.options.UmbrellaLogic:
|
||||
add_rule(alpine_entrance, lambda state: state.has("Umbrella", world.player))
|
||||
|
||||
if zipline_logic(world):
|
||||
add_rule(world.multiworld.get_entrance("-> The Birdhouse", world.player),
|
||||
lambda state: state.has("Zipline Unlock - The Birdhouse Path", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> The Lava Cake", world.player),
|
||||
lambda state: state.has("Zipline Unlock - The Lava Cake Path", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> The Windmill", world.player),
|
||||
lambda state: state.has("Zipline Unlock - The Windmill Path", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> The Twilight Bell", world.player),
|
||||
lambda state: state.has("Zipline Unlock - The Twilight Bell Path", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_location("Act Completion (The Illness has Spread)", world.player),
|
||||
lambda state: state.has("Zipline Unlock - The Birdhouse Path", world.player)
|
||||
and state.has("Zipline Unlock - The Lava Cake Path", world.player)
|
||||
and state.has("Zipline Unlock - The Windmill Path", world.player))
|
||||
|
||||
if zipline_logic(world):
|
||||
for (loc, zipline) in zipline_unlocks.items():
|
||||
add_rule(world.multiworld.get_location(loc, world.player),
|
||||
lambda state, z=zipline: state.has(z, world.player))
|
||||
|
||||
dummy_entrances: List[Entrance] = []
|
||||
|
||||
for (key, acts) in act_connections.items():
|
||||
if "Arctic Cruise" in key and not world.is_dlc1():
|
||||
continue
|
||||
|
||||
entrance: Entrance = world.multiworld.get_entrance(key, world.player)
|
||||
region: Region = entrance.connected_region
|
||||
access_rules: List[Callable[[CollectionState], bool]] = []
|
||||
dummy_entrances.append(entrance)
|
||||
|
||||
# Entrances to this act that we have to set access_rules on
|
||||
entrances: List[Entrance] = []
|
||||
|
||||
for i, act in enumerate(acts, start=1):
|
||||
act_entrance: Entrance = world.multiworld.get_entrance(act, world.player)
|
||||
access_rules.append(act_entrance.access_rule)
|
||||
required_region = act_entrance.connected_region
|
||||
name: str = f"{key}: Connection {i}"
|
||||
new_entrance: Entrance = required_region.connect(region, name)
|
||||
entrances.append(new_entrance)
|
||||
|
||||
# Copy access rules from act completions
|
||||
if "Free Roam" not in required_region.name:
|
||||
rule: Callable[[CollectionState], bool]
|
||||
name = f"Act Completion ({required_region.name})"
|
||||
rule = world.multiworld.get_location(name, world.player).access_rule
|
||||
access_rules.append(rule)
|
||||
|
||||
for e in entrances:
|
||||
for rules in access_rules:
|
||||
add_rule(e, rules)
|
||||
|
||||
for e in dummy_entrances:
|
||||
set_rule(e, lambda state: False)
|
||||
|
||||
set_event_rules(world)
|
||||
|
||||
if world.options.EndGoal == EndGoal.option_finale:
|
||||
world.multiworld.completion_condition[world.player] = lambda state: state.has("Time Piece Cluster", world.player)
|
||||
elif world.options.EndGoal == EndGoal.option_rush_hour:
|
||||
world.multiworld.completion_condition[world.player] = lambda state: state.has("Rush Hour Cleared", world.player)
|
||||
|
||||
|
||||
def set_specific_rules(world: "HatInTimeWorld"):
|
||||
add_rule(world.multiworld.get_location("Mafia Boss Shop Item", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, 12)
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.BIRDS]))
|
||||
|
||||
set_mafia_town_rules(world)
|
||||
set_botb_rules(world)
|
||||
set_subcon_rules(world)
|
||||
set_alps_rules(world)
|
||||
|
||||
if world.is_dlc1():
|
||||
set_dlc1_rules(world)
|
||||
|
||||
if world.is_dlc2():
|
||||
set_dlc2_rules(world)
|
||||
|
||||
difficulty: Difficulty = get_difficulty(world)
|
||||
|
||||
if difficulty >= Difficulty.MODERATE:
|
||||
set_moderate_rules(world)
|
||||
|
||||
if difficulty >= Difficulty.HARD:
|
||||
set_hard_rules(world)
|
||||
|
||||
if difficulty >= Difficulty.EXPERT:
|
||||
set_expert_rules(world)
|
||||
|
||||
|
||||
def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
# Moderate: Gallery without Brewing Hat
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Gallery)", world.player), lambda state: True)
|
||||
|
||||
# Moderate: Above Boats via Ice Hat Sliding
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.ICE), "or")
|
||||
|
||||
# Moderate: Clock Tower Chest + Ruined Tower with nothing
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True)
|
||||
|
||||
# Moderate: enter and clear The Subcon Well without Hookshot and without hitting the bell
|
||||
for loc in world.multiworld.get_region("The Subcon Well", world.player).locations:
|
||||
set_rule(loc, lambda state: has_paintings(state, world, 1))
|
||||
|
||||
# Moderate: Vanessa Manor with nothing
|
||||
for loc in world.multiworld.get_region("Queen Vanessa's Manor", world.player).locations:
|
||||
set_rule(loc, lambda state: has_paintings(state, world, 1))
|
||||
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Manor Rooftop", world.player),
|
||||
lambda state: has_paintings(state, world, 1))
|
||||
|
||||
# Moderate: Village Time Rift with nothing IF umbrella logic is off
|
||||
if not world.options.UmbrellaLogic:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Village)", world.player), lambda state: True)
|
||||
|
||||
# Moderate: get to Birdhouse/Yellow Band Hills without Brewing Hat
|
||||
set_rule(world.multiworld.get_entrance("-> The Birdhouse", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
set_rule(world.multiworld.get_location("Alpine Skyline - Yellow Band Hills", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
# Moderate: The Birdhouse - Dweller Platforms Relic with only Birdhouse access
|
||||
set_rule(world.multiworld.get_location("Alpine Skyline - The Birdhouse: Dweller Platforms Relic", world.player),
|
||||
lambda state: True)
|
||||
|
||||
# Moderate: Twilight Path without Dweller Mask
|
||||
set_rule(world.multiworld.get_location("Alpine Skyline - The Twilight Path", world.player), lambda state: True)
|
||||
|
||||
# Moderate: Mystifying Time Mesa time trial without hats
|
||||
set_rule(world.multiworld.get_location("Alpine Skyline - Mystifying Time Mesa: Zipline", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
# Moderate: Goat Refinery from TIHS with Sprint only
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||
lambda state: state.has("TIHS Access", world.player)
|
||||
and can_use_hat(state, world, HatType.SPRINT), "or")
|
||||
|
||||
# Moderate: Finale Telescope with only Ice Hat
|
||||
add_rule(world.multiworld.get_entrance("Telescope -> Time's End", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.FINALE])
|
||||
and can_use_hat(state, world, HatType.ICE), "or")
|
||||
|
||||
# Moderate: Finale without Hookshot
|
||||
set_rule(world.multiworld.get_location("Act Completion (The Finale)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.DWELLER))
|
||||
|
||||
if world.is_dlc1():
|
||||
# Moderate: clear Rock the Boat without Ice Hat
|
||||
add_rule(world.multiworld.get_location("Rock the Boat - Post Captain Rescue", world.player), lambda state: True)
|
||||
add_rule(world.multiworld.get_location("Act Completion (Rock the Boat)", world.player), lambda state: True)
|
||||
|
||||
# Moderate: clear Deep Sea without Ice Hat
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Deep Sea)", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_use_hat(state, world, HatType.DWELLER))
|
||||
|
||||
# There is a glitched fall damage volume near the Yellow Overpass time piece that warps the player to Pink Paw.
|
||||
# Yellow Overpass time piece can also be reached without Hookshot quite easily.
|
||||
if world.is_dlc2():
|
||||
# No Hookshot
|
||||
set_rule(world.multiworld.get_location("Act Completion (Yellow Overpass Station)", world.player),
|
||||
lambda state: True)
|
||||
|
||||
# No Dweller, Hookshot, or Time Stop for these
|
||||
set_rule(world.multiworld.get_location("Pink Paw Station - Cat Vacuum", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Pink Paw Station - Behind Fan", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Pink Paw Station - Pink Ticket Booth", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Act Completion (Pink Paw Station)", world.player), lambda state: True)
|
||||
for key in shop_locations.keys():
|
||||
if "Pink Paw Station Thug" in key and is_location_valid(world, key):
|
||||
set_rule(world.multiworld.get_location(key, world.player), lambda state: True)
|
||||
|
||||
# Moderate: clear Rush Hour without Hookshot
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
lambda state: state.has("Metro Ticket - Pink", world.player)
|
||||
and state.has("Metro Ticket - Yellow", world.player)
|
||||
and state.has("Metro Ticket - Blue", world.player)
|
||||
and can_use_hat(state, world, HatType.ICE)
|
||||
and can_use_hat(state, world, HatType.BREWING))
|
||||
|
||||
# Moderate: Bluefin Tunnel + Pink Paw Station without tickets
|
||||
if not world.options.NoTicketSkips:
|
||||
set_rule(world.multiworld.get_entrance("-> Pink Paw Station", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_entrance("-> Bluefin Tunnel", world.player), lambda state: True)
|
||||
|
||||
|
||||
def set_hard_rules(world: "HatInTimeWorld"):
|
||||
# Hard: clear Time Rift - The Twilight Bell with Sprint+Scooter only
|
||||
add_rule(world.multiworld.get_location("Act Completion (Time Rift - The Twilight Bell)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT)
|
||||
and state.has("Scooter Badge", world.player), "or")
|
||||
|
||||
# No Dweller Mask required
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Dweller Floating Rocks", world.player),
|
||||
lambda state: has_paintings(state, world, 3))
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player),
|
||||
lambda state: has_paintings(state, world, 3))
|
||||
|
||||
# Cherry bridge over boss arena gap (painting still expected)
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
||||
lambda state: has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
|
||||
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player),
|
||||
lambda state: has_paintings(state, world, 2, True))
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player),
|
||||
lambda state: has_paintings(state, world, 2, True))
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Tall Tree Hookshot Swing", world.player),
|
||||
lambda state: has_paintings(state, world, 3, True))
|
||||
|
||||
# SDJ
|
||||
add_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT) and has_paintings(state, world, 2), "or")
|
||||
|
||||
add_rule(world.multiworld.get_location("Act Completion (Time Rift - Curly Tail Trail)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT), "or")
|
||||
|
||||
# Hard: Goat Refinery from TIHS with nothing
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||
lambda state: state.has("TIHS Access", world.player), "or")
|
||||
|
||||
if world.is_dlc1():
|
||||
# Hard: clear Deep Sea without Dweller Mask
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Deep Sea)", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
if world.is_dlc2():
|
||||
# Hard: clear Green Clean Manhole without Dweller Mask
|
||||
set_rule(world.multiworld.get_location("Act Completion (Green Clean Manhole)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.ICE))
|
||||
|
||||
# Hard: clear Rush Hour with Brewing Hat only
|
||||
if world.options.NoTicketSkips is not NoTicketSkips.option_true:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.BREWING))
|
||||
else:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.BREWING)
|
||||
and state.has("Metro Ticket - Yellow", world.player)
|
||||
and state.has("Metro Ticket - Blue", world.player)
|
||||
and state.has("Metro Ticket - Pink", world.player))
|
||||
|
||||
|
||||
def set_expert_rules(world: "HatInTimeWorld"):
|
||||
# Finale Telescope with no hats
|
||||
set_rule(world.multiworld.get_entrance("Telescope -> Time's End", world.player),
|
||||
lambda state: state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.FINALE]))
|
||||
|
||||
# Expert: Mafia Town - Above Boats, Top of Lighthouse, and Hot Air Balloon with nothing
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Lighthouse", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player), lambda state: True)
|
||||
|
||||
# Expert: Clear Dead Bird Studio with nothing
|
||||
for loc in world.multiworld.get_region("Dead Bird Studio - Post Elevator Area", world.player).locations:
|
||||
set_rule(loc, lambda state: True)
|
||||
|
||||
set_rule(world.multiworld.get_location("Act Completion (Dead Bird Studio)", world.player), lambda state: True)
|
||||
|
||||
# Expert: Clear Dead Bird Studio Basement without Hookshot
|
||||
for loc in world.multiworld.get_region("Dead Bird Studio Basement", world.player).locations:
|
||||
set_rule(loc, lambda state: True)
|
||||
|
||||
# Expert: get to and clear Twilight Bell without Dweller Mask.
|
||||
# Dweller Mask OR Sprint Hat OR Brewing Hat OR Time Stop + Umbrella required to complete act.
|
||||
add_rule(world.multiworld.get_entrance("-> The Twilight Bell", world.player),
|
||||
lambda state: can_use_hookshot(state, world), "or")
|
||||
|
||||
add_rule(world.multiworld.get_location("Act Completion (The Twilight Bell)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.BREWING)
|
||||
or can_use_hat(state, world, HatType.DWELLER)
|
||||
or can_use_hat(state, world, HatType.SPRINT)
|
||||
or (can_use_hat(state, world, HatType.TIME_STOP) and state.has("Umbrella", world.player)))
|
||||
|
||||
# Expert: Time Rift - Curly Tail Trail with nothing
|
||||
# Time Rift - Twilight Bell and Time Rift - Village with nothing
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Curly Tail Trail)", world.player),
|
||||
lambda state: True)
|
||||
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Village)", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - The Twilight Bell)", world.player),
|
||||
lambda state: True)
|
||||
|
||||
# Expert: Cherry Hovering
|
||||
subcon_area = world.multiworld.get_region("Subcon Forest Area", world.player)
|
||||
yche = world.multiworld.get_region("Your Contract has Expired", world.player)
|
||||
entrance = yche.connect(subcon_area, "Subcon Forest Entrance YCHE")
|
||||
|
||||
if world.options.NoPaintingSkips:
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 1))
|
||||
|
||||
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
||||
and has_paintings(state, world, 1, True))
|
||||
|
||||
# Set painting rules only. Skipping paintings is determined in has_paintings
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
||||
lambda state: has_paintings(state, world, 1, True))
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player),
|
||||
lambda state: has_paintings(state, world, 3, True))
|
||||
|
||||
# You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him
|
||||
subcon_area.connect(yche, "Snatcher Hover")
|
||||
set_rule(world.multiworld.get_location("Act Completion (Your Contract has Expired)", world.player),
|
||||
lambda state: True)
|
||||
|
||||
if world.is_dlc2():
|
||||
# Expert: clear Rush Hour with nothing
|
||||
if not world.options.NoTicketSkips:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), lambda state: True)
|
||||
else:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
lambda state: state.has("Metro Ticket - Yellow", world.player)
|
||||
and state.has("Metro Ticket - Blue", world.player)
|
||||
and state.has("Metro Ticket - Pink", world.player))
|
||||
|
||||
# Expert: Yellow/Green Manhole with nothing using a Boop Clip
|
||||
set_rule(world.multiworld.get_location("Act Completion (Yellow Overpass Manhole)", world.player),
|
||||
lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Act Completion (Green Clean Manhole)", world.player),
|
||||
lambda state: True)
|
||||
|
||||
|
||||
def set_mafia_town_rules(world: "HatInTimeWorld"):
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Behind HQ Chest", world.player),
|
||||
lambda state: state.can_reach("Act Completion (Heating Up Mafia Town)", "Location", world.player)
|
||||
or state.can_reach("Down with the Mafia!", "Region", world.player)
|
||||
or state.can_reach("Cheating the Race", "Region", world.player)
|
||||
or state.can_reach("The Golden Vault", "Region", world.player))
|
||||
|
||||
# Old guys don't appear in SCFOS
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Old Man (Steel Beams)", world.player),
|
||||
lambda state: state.can_reach("Welcome to Mafia Town", "Region", world.player)
|
||||
or state.can_reach("Barrel Battle", "Region", world.player)
|
||||
or state.can_reach("Cheating the Race", "Region", world.player)
|
||||
or state.can_reach("The Golden Vault", "Region", world.player)
|
||||
or state.can_reach("Down with the Mafia!", "Region", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Old Man (Seaside Spaghetti)", world.player),
|
||||
lambda state: state.can_reach("Welcome to Mafia Town", "Region", world.player)
|
||||
or state.can_reach("Barrel Battle", "Region", world.player)
|
||||
or state.can_reach("Cheating the Race", "Region", world.player)
|
||||
or state.can_reach("The Golden Vault", "Region", world.player)
|
||||
or state.can_reach("Down with the Mafia!", "Region", world.player))
|
||||
|
||||
# Only available outside She Came from Outer Space
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Mafia Geek Platform", world.player),
|
||||
lambda state: state.can_reach("Welcome to Mafia Town", "Region", world.player)
|
||||
or state.can_reach("Barrel Battle", "Region", world.player)
|
||||
or state.can_reach("Down with the Mafia!", "Region", world.player)
|
||||
or state.can_reach("Cheating the Race", "Region", world.player)
|
||||
or state.can_reach("Heating Up Mafia Town", "Region", world.player)
|
||||
or state.can_reach("The Golden Vault", "Region", world.player))
|
||||
|
||||
# Only available outside Down with the Mafia! (for some reason)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - On Scaffolding", world.player),
|
||||
lambda state: state.can_reach("Welcome to Mafia Town", "Region", world.player)
|
||||
or state.can_reach("Barrel Battle", "Region", world.player)
|
||||
or state.can_reach("She Came from Outer Space", "Region", world.player)
|
||||
or state.can_reach("Cheating the Race", "Region", world.player)
|
||||
or state.can_reach("Heating Up Mafia Town", "Region", world.player)
|
||||
or state.can_reach("The Golden Vault", "Region", world.player))
|
||||
|
||||
# For some reason, the brewing crate is removed in HUMT
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Secret Cave", world.player),
|
||||
lambda state: state.has("HUMT Access", world.player), "or")
|
||||
|
||||
# Can bounce across the lava to get this without Hookshot (need to die though)
|
||||
add_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player),
|
||||
lambda state: state.has("HUMT Access", world.player), "or")
|
||||
|
||||
if world.options.CTRLogic == CTRLogic.option_nothing:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Cheating the Race)", world.player), lambda state: True)
|
||||
elif world.options.CTRLogic == CTRLogic.option_sprint:
|
||||
add_rule(world.multiworld.get_location("Act Completion (Cheating the Race)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT), "or")
|
||||
elif world.options.CTRLogic == CTRLogic.option_scooter:
|
||||
add_rule(world.multiworld.get_location("Act Completion (Cheating the Race)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT)
|
||||
and state.has("Scooter Badge", world.player), "or")
|
||||
|
||||
|
||||
def set_botb_rules(world: "HatInTimeWorld"):
|
||||
if not world.options.UmbrellaLogic and get_difficulty(world) < Difficulty.MODERATE:
|
||||
set_rule(world.multiworld.get_location("Dead Bird Studio - DJ Grooves Sign Chest", world.player),
|
||||
lambda state: state.has("Umbrella", world.player) or can_use_hat(state, world, HatType.BREWING))
|
||||
set_rule(world.multiworld.get_location("Dead Bird Studio - Tepee Chest", world.player),
|
||||
lambda state: state.has("Umbrella", world.player) or can_use_hat(state, world, HatType.BREWING))
|
||||
set_rule(world.multiworld.get_location("Dead Bird Studio - Conductor Chest", world.player),
|
||||
lambda state: state.has("Umbrella", world.player) or can_use_hat(state, world, HatType.BREWING))
|
||||
set_rule(world.multiworld.get_location("Act Completion (Dead Bird Studio)", world.player),
|
||||
lambda state: state.has("Umbrella", world.player) or can_use_hat(state, world, HatType.BREWING))
|
||||
|
||||
|
||||
def set_subcon_rules(world: "HatInTimeWorld"):
|
||||
set_rule(world.multiworld.get_location("Act Completion (Time Rift - Village)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player)
|
||||
or can_use_hat(state, world, HatType.DWELLER))
|
||||
|
||||
# You can't skip over the boss arena wall without cherry hover, so these two need to be set this way
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
||||
lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world)
|
||||
and has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
|
||||
|
||||
# The painting wall can't be skipped without cherry hover, which is Expert
|
||||
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
||||
and has_paintings(state, world, 1, False))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Subcon Forest - Act 2", world.player),
|
||||
lambda state: state.has("Snatcher's Contract - The Subcon Well", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Subcon Forest - Act 3", world.player),
|
||||
lambda state: state.has("Snatcher's Contract - Toilet of Doom", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Subcon Forest - Act 4", world.player),
|
||||
lambda state: state.has("Snatcher's Contract - Queen Vanessa's Manor", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Subcon Forest - Act 5", world.player),
|
||||
lambda state: state.has("Snatcher's Contract - Mail Delivery Service", world.player))
|
||||
|
||||
if painting_logic(world):
|
||||
add_rule(world.multiworld.get_location("Act Completion (Contractual Obligations)", world.player),
|
||||
lambda state: has_paintings(state, world, 1, False))
|
||||
|
||||
|
||||
def set_alps_rules(world: "HatInTimeWorld"):
|
||||
add_rule(world.multiworld.get_entrance("-> The Birdhouse", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_use_hat(state, world, HatType.BREWING))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> The Lava Cake", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> The Windmill", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> The Twilight Bell", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_use_hat(state, world, HatType.DWELLER))
|
||||
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Mystifying Time Mesa: Zipline", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT) or can_use_hat(state, world, HatType.TIME_STOP))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Alpine Skyline - Finale", world.player),
|
||||
lambda state: can_clear_alpine(state, world))
|
||||
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||
lambda state: state.has("AFR Access", world.player)
|
||||
and can_use_hookshot(state, world)
|
||||
and can_hit(state, world, True))
|
||||
|
||||
|
||||
def set_dlc1_rules(world: "HatInTimeWorld"):
|
||||
add_rule(world.multiworld.get_entrance("Cruise Ship Entrance BV", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
|
||||
# This particular item isn't present in Act 3 for some reason, yes in vanilla too
|
||||
add_rule(world.multiworld.get_location("The Arctic Cruise - Toilet", world.player),
|
||||
lambda state: state.can_reach("Bon Voyage!", "Region", world.player)
|
||||
or state.can_reach("Ship Shape", "Region", world.player))
|
||||
|
||||
|
||||
def set_dlc2_rules(world: "HatInTimeWorld"):
|
||||
add_rule(world.multiworld.get_entrance("-> Bluefin Tunnel", world.player),
|
||||
lambda state: state.has("Metro Ticket - Green", world.player)
|
||||
or state.has("Metro Ticket - Blue", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("-> Pink Paw Station", world.player),
|
||||
lambda state: state.has("Metro Ticket - Pink", world.player)
|
||||
or state.has("Metro Ticket - Yellow", world.player) and state.has("Metro Ticket - Blue", world.player))
|
||||
|
||||
add_rule(world.multiworld.get_entrance("Nyakuza Metro - Finale", world.player),
|
||||
lambda state: can_clear_metro(state, world))
|
||||
|
||||
add_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
lambda state: state.has("Metro Ticket - Yellow", world.player)
|
||||
and state.has("Metro Ticket - Blue", world.player)
|
||||
and state.has("Metro Ticket - Pink", world.player))
|
||||
|
||||
for key in shop_locations.keys():
|
||||
if "Green Clean Station Thug B" in key and is_location_valid(world, key):
|
||||
add_rule(world.multiworld.get_location(key, world.player),
|
||||
lambda state: state.has("Metro Ticket - Yellow", world.player), "or")
|
||||
|
||||
|
||||
def reg_act_connection(world: "HatInTimeWorld", region: Union[str, Region], unlocked_entrance: Union[str, Entrance]):
|
||||
reg: Region
|
||||
entrance: Entrance
|
||||
if isinstance(region, str):
|
||||
reg = world.multiworld.get_region(region, world.player)
|
||||
else:
|
||||
reg = region
|
||||
|
||||
if isinstance(unlocked_entrance, str):
|
||||
entrance = world.multiworld.get_entrance(unlocked_entrance, world.player)
|
||||
else:
|
||||
entrance = unlocked_entrance
|
||||
|
||||
world.multiworld.register_indirect_condition(reg, entrance)
|
||||
|
||||
|
||||
# See randomize_act_entrances in Regions.py
|
||||
# Called before set_rules
|
||||
def set_rift_rules(world: "HatInTimeWorld", regions: Dict[str, Region]):
|
||||
|
||||
# This is accessing the regions in place of these time rifts, so we can set the rules on all the entrances.
|
||||
for entrance in regions["Time Rift - Gallery"].entrances:
|
||||
add_rule(entrance, lambda state: can_use_hat(state, world, HatType.BREWING)
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.BIRDS]))
|
||||
|
||||
for entrance in regions["Time Rift - The Lab"].entrances:
|
||||
add_rule(entrance, lambda state: can_use_hat(state, world, HatType.DWELLER)
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.ALPINE]))
|
||||
|
||||
for entrance in regions["Time Rift - Sewers"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Mafia Town - Act 4"))
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Mafia Town - Act 4",
|
||||
world.player).connected_region, entrance)
|
||||
|
||||
for entrance in regions["Time Rift - Bazaar"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Mafia Town - Act 6"))
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Mafia Town - Act 6",
|
||||
world.player).connected_region, entrance)
|
||||
|
||||
for entrance in regions["Time Rift - Mafia of Cooks"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Burger"))
|
||||
|
||||
for entrance in regions["Time Rift - The Owl Express"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 2"))
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 3"))
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Battle of the Birds - Act 2",
|
||||
world.player).connected_region, entrance)
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Battle of the Birds - Act 3",
|
||||
world.player).connected_region, entrance)
|
||||
|
||||
for entrance in regions["Time Rift - The Moon"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 4"))
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 5"))
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Battle of the Birds - Act 4",
|
||||
world.player).connected_region, entrance)
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Battle of the Birds - Act 5",
|
||||
world.player).connected_region, entrance)
|
||||
|
||||
for entrance in regions["Time Rift - Dead Bird Studio"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Train"))
|
||||
|
||||
for entrance in regions["Time Rift - Pipe"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 2"))
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Subcon Forest - Act 2",
|
||||
world.player).connected_region, entrance)
|
||||
if painting_logic(world):
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 2))
|
||||
|
||||
for entrance in regions["Time Rift - Village"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 4"))
|
||||
reg_act_connection(world, world.multiworld.get_entrance("Subcon Forest - Act 4",
|
||||
world.player).connected_region, entrance)
|
||||
|
||||
if painting_logic(world):
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 2))
|
||||
|
||||
for entrance in regions["Time Rift - Sleepy Subcon"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "UFO"))
|
||||
if painting_logic(world):
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 3))
|
||||
|
||||
for entrance in regions["Time Rift - Curly Tail Trail"].entrances:
|
||||
add_rule(entrance, lambda state: state.has("Windmill Cleared", world.player))
|
||||
|
||||
for entrance in regions["Time Rift - The Twilight Bell"].entrances:
|
||||
add_rule(entrance, lambda state: state.has("Twilight Bell Cleared", world.player))
|
||||
|
||||
for entrance in regions["Time Rift - Alpine Skyline"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in regions["Time Rift - Balcony"].entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "The Arctic Cruise - Finale"))
|
||||
|
||||
for entrance in regions["Time Rift - Deep Sea"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Cake"))
|
||||
|
||||
if world.is_dlc2():
|
||||
for entrance in regions["Time Rift - Rumbi Factory"].entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Necklace"))
|
||||
|
||||
|
||||
# Basically the same as above, but without the need of the dict since we are just setting defaults
|
||||
# Called if Act Rando is disabled
|
||||
def set_default_rift_rules(world: "HatInTimeWorld"):
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Gallery", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_use_hat(state, world, HatType.BREWING)
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.BIRDS]))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - The Lab", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_use_hat(state, world, HatType.DWELLER)
|
||||
and state.has("Time Piece", world.player, world.chapter_timepiece_costs[ChapterIndex.ALPINE]))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Sewers", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Mafia Town - Act 4"))
|
||||
reg_act_connection(world, "Down with the Mafia!", entrance.name)
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Bazaar", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Mafia Town - Act 6"))
|
||||
reg_act_connection(world, "Heating Up Mafia Town", entrance.name)
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Mafia of Cooks", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Burger"))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - The Owl Express", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 2"))
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 3"))
|
||||
reg_act_connection(world, "Murder on the Owl Express", entrance.name)
|
||||
reg_act_connection(world, "Picture Perfect", entrance.name)
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - The Moon", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 4"))
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Battle of the Birds - Act 5"))
|
||||
reg_act_connection(world, "Train Rush", entrance.name)
|
||||
reg_act_connection(world, "The Big Parade", entrance.name)
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Dead Bird Studio", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Train"))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Pipe", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 2"))
|
||||
reg_act_connection(world, "The Subcon Well", entrance.name)
|
||||
if painting_logic(world):
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 2))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Village", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 4"))
|
||||
reg_act_connection(world, "Queen Vanessa's Manor", entrance.name)
|
||||
if painting_logic(world):
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 2))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Sleepy Subcon", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "UFO"))
|
||||
if painting_logic(world):
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 3))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Curly Tail Trail", world.player).entrances:
|
||||
add_rule(entrance, lambda state: state.has("Windmill Cleared", world.player))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - The Twilight Bell", world.player).entrances:
|
||||
add_rule(entrance, lambda state: state.has("Twilight Bell Cleared", world.player))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Alpine Skyline", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Crayon"))
|
||||
|
||||
if world.is_dlc1():
|
||||
for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances:
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "The Arctic Cruise - Finale"))
|
||||
|
||||
for entrance in world.multiworld.get_region("Time Rift - Deep Sea", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Cake"))
|
||||
|
||||
if world.is_dlc2():
|
||||
for entrance in world.multiworld.get_region("Time Rift - Rumbi Factory", world.player).entrances:
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "Necklace"))
|
||||
|
||||
|
||||
def set_event_rules(world: "HatInTimeWorld"):
|
||||
for (name, data) in event_locs.items():
|
||||
if not is_location_valid(world, name):
|
||||
continue
|
||||
|
||||
event: Location = world.multiworld.get_location(name, world.player)
|
||||
|
||||
if data.act_event:
|
||||
add_rule(event, world.multiworld.get_location(f"Act Completion ({data.region})", world.player).access_rule)
|
||||
86
worlds/ahit/Types.py
Normal file
86
worlds/ahit/Types.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from enum import IntEnum, IntFlag
|
||||
from typing import NamedTuple, Optional, List
|
||||
from BaseClasses import Location, Item, ItemClassification
|
||||
|
||||
|
||||
class HatInTimeLocation(Location):
|
||||
game = "A Hat in Time"
|
||||
|
||||
|
||||
class HatInTimeItem(Item):
|
||||
game = "A Hat in Time"
|
||||
|
||||
|
||||
class HatType(IntEnum):
|
||||
SPRINT = 0
|
||||
BREWING = 1
|
||||
ICE = 2
|
||||
DWELLER = 3
|
||||
TIME_STOP = 4
|
||||
|
||||
|
||||
class HitType(IntEnum):
|
||||
none = 0
|
||||
umbrella = 1
|
||||
umbrella_or_brewing = 2
|
||||
dweller_bell = 3
|
||||
|
||||
|
||||
class HatDLC(IntFlag):
|
||||
none = 0b000
|
||||
dlc1 = 0b001
|
||||
dlc2 = 0b010
|
||||
death_wish = 0b100
|
||||
dlc1_dw = 0b101
|
||||
dlc2_dw = 0b110
|
||||
|
||||
|
||||
class ChapterIndex(IntEnum):
|
||||
SPACESHIP = 0
|
||||
MAFIA = 1
|
||||
BIRDS = 2
|
||||
SUBCON = 3
|
||||
ALPINE = 4
|
||||
FINALE = 5
|
||||
CRUISE = 6
|
||||
METRO = 7
|
||||
|
||||
|
||||
class Difficulty(IntEnum):
|
||||
NORMAL = -1
|
||||
MODERATE = 0
|
||||
HARD = 1
|
||||
EXPERT = 2
|
||||
|
||||
|
||||
class LocData(NamedTuple):
|
||||
id: int = 0
|
||||
region: str = ""
|
||||
required_hats: List[HatType] = []
|
||||
hookshot: bool = False
|
||||
dlc_flags: HatDLC = HatDLC.none
|
||||
paintings: int = 0 # Paintings required for Subcon painting shuffle
|
||||
misc_required: List[str] = []
|
||||
|
||||
# For UmbrellaLogic setting only.
|
||||
hit_type: HitType = HitType.none
|
||||
|
||||
# Other
|
||||
act_event: bool = False # Only used for event locations. Copy access rule from act completion
|
||||
nyakuza_thug: str = "" # Name of Nyakuza thug NPC (for metro shops)
|
||||
snatcher_coin: str = "" # Only for Snatcher Coin event locations, name of the Snatcher Coin item
|
||||
|
||||
|
||||
class ItemData(NamedTuple):
|
||||
code: Optional[int]
|
||||
classification: ItemClassification
|
||||
dlc_flags: Optional[HatDLC] = HatDLC.none
|
||||
|
||||
|
||||
hat_type_to_item = {
|
||||
HatType.SPRINT: "Sprint Hat",
|
||||
HatType.BREWING: "Brewing Hat",
|
||||
HatType.ICE: "Ice Hat",
|
||||
HatType.DWELLER: "Dweller Mask",
|
||||
HatType.TIME_STOP: "Time Stop Hat",
|
||||
}
|
||||
374
worlds/ahit/__init__.py
Normal file
374
worlds/ahit/__init__.py
Normal file
@@ -0,0 +1,374 @@
|
||||
from BaseClasses import Item, ItemClassification, Tutorial, Location, MultiWorld
|
||||
from .Items import item_table, create_item, relic_groups, act_contracts, create_itempool, get_shop_trap_name, \
|
||||
calculate_yarn_costs
|
||||
from .Regions import create_regions, randomize_act_entrances, chapter_act_info, create_events, get_shuffled_region
|
||||
from .Locations import location_table, contract_locations, is_location_valid, get_location_names, TASKSANITY_START_ID, \
|
||||
get_total_locations
|
||||
from .Rules import set_rules
|
||||
from .Options import AHITOptions, slot_data_options, adjust_options, RandomizeHatOrder, EndGoal, create_option_groups
|
||||
from .Types import HatType, ChapterIndex, HatInTimeItem, hat_type_to_item
|
||||
from .DeathWishLocations import create_dw_regions, dw_classes, death_wishes
|
||||
from .DeathWishRules import set_dw_rules, create_enemy_events, hit_list, bosses
|
||||
from worlds.AutoWorld import World, WebWorld, CollectionState
|
||||
from typing import List, Dict, TextIO
|
||||
from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type
|
||||
from Utils import local_path
|
||||
|
||||
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_subprocess(launch, name="AHITClient")
|
||||
|
||||
|
||||
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
|
||||
component_type=Type.CLIENT, icon='yatta'))
|
||||
|
||||
icon_paths['yatta'] = local_path('data', 'yatta.png')
|
||||
|
||||
|
||||
class AWebInTime(WebWorld):
|
||||
theme = "partyTime"
|
||||
option_groups = create_option_groups()
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide for setting up A Hat in Time to be played in Archipelago.",
|
||||
"English",
|
||||
"ahit_en.md",
|
||||
"setup/en",
|
||||
["CookieCat"]
|
||||
)]
|
||||
|
||||
|
||||
class HatInTimeWorld(World):
|
||||
"""
|
||||
A Hat in Time is a cute-as-peck 3D platformer featuring a little girl who stitches hats for wicked powers!
|
||||
Freely explore giant worlds and recover Time Pieces to travel to new heights!
|
||||
"""
|
||||
|
||||
game = "A Hat in Time"
|
||||
item_name_to_id = {name: data.code for name, data in item_table.items()}
|
||||
location_name_to_id = get_location_names()
|
||||
options_dataclass = AHITOptions
|
||||
options: AHITOptions
|
||||
item_name_groups = relic_groups
|
||||
web = AWebInTime()
|
||||
|
||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||
super().__init__(multiworld, player)
|
||||
self.act_connections: Dict[str, str] = {}
|
||||
self.shop_locs: List[str] = []
|
||||
|
||||
self.hat_craft_order: List[HatType] = [HatType.SPRINT, HatType.BREWING, HatType.ICE,
|
||||
HatType.DWELLER, HatType.TIME_STOP]
|
||||
|
||||
self.hat_yarn_costs: Dict[HatType, int] = {HatType.SPRINT: -1, HatType.BREWING: -1, HatType.ICE: -1,
|
||||
HatType.DWELLER: -1, HatType.TIME_STOP: -1}
|
||||
|
||||
self.chapter_timepiece_costs: Dict[ChapterIndex, int] = {ChapterIndex.MAFIA: -1,
|
||||
ChapterIndex.BIRDS: -1,
|
||||
ChapterIndex.SUBCON: -1,
|
||||
ChapterIndex.ALPINE: -1,
|
||||
ChapterIndex.FINALE: -1,
|
||||
ChapterIndex.CRUISE: -1,
|
||||
ChapterIndex.METRO: -1}
|
||||
self.excluded_dws: List[str] = []
|
||||
self.excluded_bonuses: List[str] = []
|
||||
self.dw_shuffle: List[str] = []
|
||||
self.nyakuza_thug_items: Dict[str, int] = {}
|
||||
self.badge_seller_count: int = 0
|
||||
|
||||
def generate_early(self):
|
||||
adjust_options(self)
|
||||
|
||||
if self.options.StartWithCompassBadge:
|
||||
self.multiworld.push_precollected(self.create_item("Compass Badge"))
|
||||
|
||||
if self.is_dw_only():
|
||||
return
|
||||
|
||||
# If our starting chapter is 4 and act rando isn't on, force hookshot into inventory
|
||||
# If starting chapter is 3 and painting shuffle is enabled, and act rando isn't, give one free painting unlock
|
||||
start_chapter: ChapterIndex = ChapterIndex(self.options.StartingChapter)
|
||||
|
||||
if start_chapter == ChapterIndex.ALPINE or start_chapter == ChapterIndex.SUBCON:
|
||||
if not self.options.ActRandomizer:
|
||||
if start_chapter == ChapterIndex.ALPINE:
|
||||
self.multiworld.push_precollected(self.create_item("Hookshot Badge"))
|
||||
if self.options.UmbrellaLogic:
|
||||
self.multiworld.push_precollected(self.create_item("Umbrella"))
|
||||
|
||||
if start_chapter == ChapterIndex.SUBCON and self.options.ShuffleSubconPaintings:
|
||||
self.multiworld.push_precollected(self.create_item("Progressive Painting Unlock"))
|
||||
|
||||
def create_regions(self):
|
||||
# noinspection PyClassVar
|
||||
self.topology_present = bool(self.options.ActRandomizer)
|
||||
|
||||
create_regions(self)
|
||||
if self.options.EnableDeathWish:
|
||||
create_dw_regions(self)
|
||||
|
||||
if self.is_dw_only():
|
||||
return
|
||||
|
||||
create_events(self)
|
||||
if self.is_dw():
|
||||
if "Snatcher's Hit List" not in self.excluded_dws or "Camera Tourist" not in self.excluded_dws:
|
||||
create_enemy_events(self)
|
||||
|
||||
# place vanilla contract locations if contract shuffle is off
|
||||
if not self.options.ShuffleActContracts:
|
||||
for name in contract_locations.keys():
|
||||
self.multiworld.get_location(name, self.player).place_locked_item(create_item(self, name))
|
||||
|
||||
def create_items(self):
|
||||
if self.has_yarn():
|
||||
calculate_yarn_costs(self)
|
||||
|
||||
if self.options.RandomizeHatOrder:
|
||||
self.random.shuffle(self.hat_craft_order)
|
||||
if self.options.RandomizeHatOrder == RandomizeHatOrder.option_time_stop_last:
|
||||
self.hat_craft_order.remove(HatType.TIME_STOP)
|
||||
self.hat_craft_order.append(HatType.TIME_STOP)
|
||||
|
||||
# move precollected hats to the start of the list
|
||||
for i in range(5):
|
||||
hat = HatType(i)
|
||||
if self.is_hat_precollected(hat):
|
||||
self.hat_craft_order.remove(hat)
|
||||
self.hat_craft_order.insert(0, hat)
|
||||
|
||||
self.multiworld.itempool += create_itempool(self)
|
||||
|
||||
def set_rules(self):
|
||||
if self.is_dw_only():
|
||||
# we already have all items if this is the case, no need for rules
|
||||
self.multiworld.push_precollected(HatInTimeItem("Death Wish Only Mode", ItemClassification.progression,
|
||||
None, self.player))
|
||||
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("Death Wish Only Mode",
|
||||
self.player)
|
||||
|
||||
if not self.options.DWEnableBonus:
|
||||
for name in death_wishes:
|
||||
if name == "Snatcher Coins in Nyakuza Metro" and not self.is_dlc2():
|
||||
continue
|
||||
|
||||
if self.options.DWShuffle and name not in self.dw_shuffle:
|
||||
continue
|
||||
|
||||
full_clear = self.multiworld.get_location(f"{name} - All Clear", self.player)
|
||||
full_clear.address = None
|
||||
full_clear.place_locked_item(HatInTimeItem("Nothing", ItemClassification.filler, None, self.player))
|
||||
full_clear.show_in_spoiler = False
|
||||
|
||||
return
|
||||
|
||||
if self.options.ActRandomizer:
|
||||
randomize_act_entrances(self)
|
||||
|
||||
set_rules(self)
|
||||
|
||||
if self.is_dw():
|
||||
set_dw_rules(self)
|
||||
|
||||
def create_item(self, name: str) -> Item:
|
||||
return create_item(self, name)
|
||||
|
||||
def fill_slot_data(self) -> dict:
|
||||
slot_data: dict = {"Chapter1Cost": self.chapter_timepiece_costs[ChapterIndex.MAFIA],
|
||||
"Chapter2Cost": self.chapter_timepiece_costs[ChapterIndex.BIRDS],
|
||||
"Chapter3Cost": self.chapter_timepiece_costs[ChapterIndex.SUBCON],
|
||||
"Chapter4Cost": self.chapter_timepiece_costs[ChapterIndex.ALPINE],
|
||||
"Chapter5Cost": self.chapter_timepiece_costs[ChapterIndex.FINALE],
|
||||
"Chapter6Cost": self.chapter_timepiece_costs[ChapterIndex.CRUISE],
|
||||
"Chapter7Cost": self.chapter_timepiece_costs[ChapterIndex.METRO],
|
||||
"BadgeSellerItemCount": self.badge_seller_count,
|
||||
"SeedNumber": str(self.multiworld.seed), # For shop prices
|
||||
"SeedName": self.multiworld.seed_name,
|
||||
"TotalLocations": get_total_locations(self)}
|
||||
|
||||
if self.has_yarn():
|
||||
slot_data.setdefault("SprintYarnCost", self.hat_yarn_costs[HatType.SPRINT])
|
||||
slot_data.setdefault("BrewingYarnCost", self.hat_yarn_costs[HatType.BREWING])
|
||||
slot_data.setdefault("IceYarnCost", self.hat_yarn_costs[HatType.ICE])
|
||||
slot_data.setdefault("DwellerYarnCost", self.hat_yarn_costs[HatType.DWELLER])
|
||||
slot_data.setdefault("TimeStopYarnCost", self.hat_yarn_costs[HatType.TIME_STOP])
|
||||
slot_data.setdefault("Hat1", int(self.hat_craft_order[0]))
|
||||
slot_data.setdefault("Hat2", int(self.hat_craft_order[1]))
|
||||
slot_data.setdefault("Hat3", int(self.hat_craft_order[2]))
|
||||
slot_data.setdefault("Hat4", int(self.hat_craft_order[3]))
|
||||
slot_data.setdefault("Hat5", int(self.hat_craft_order[4]))
|
||||
|
||||
if self.options.ActRandomizer:
|
||||
for name in self.act_connections.keys():
|
||||
slot_data[name] = self.act_connections[name]
|
||||
|
||||
if self.is_dlc2() and not self.is_dw_only():
|
||||
for name in self.nyakuza_thug_items.keys():
|
||||
slot_data[name] = self.nyakuza_thug_items[name]
|
||||
|
||||
if self.is_dw():
|
||||
i = 0
|
||||
for name in self.excluded_dws:
|
||||
if self.options.EndGoal.value == EndGoal.option_seal_the_deal and name == "Seal the Deal":
|
||||
continue
|
||||
|
||||
slot_data[f"excluded_dw{i}"] = dw_classes[name]
|
||||
i += 1
|
||||
|
||||
i = 0
|
||||
if not self.options.DWAutoCompleteBonuses:
|
||||
for name in self.excluded_bonuses:
|
||||
if name in self.excluded_dws:
|
||||
continue
|
||||
|
||||
slot_data[f"excluded_bonus{i}"] = dw_classes[name]
|
||||
i += 1
|
||||
|
||||
if self.options.DWShuffle:
|
||||
shuffled_dws = self.dw_shuffle
|
||||
for i in range(len(shuffled_dws)):
|
||||
slot_data[f"dw_{i}"] = dw_classes[shuffled_dws[i]]
|
||||
|
||||
shop_item_names: Dict[str, str] = {}
|
||||
for name in self.shop_locs:
|
||||
loc: Location = self.multiworld.get_location(name, self.player)
|
||||
assert loc.item
|
||||
item_name: str
|
||||
if loc.item.classification is ItemClassification.trap and loc.item.game == "A Hat in Time":
|
||||
item_name = get_shop_trap_name(self)
|
||||
else:
|
||||
item_name = loc.item.name
|
||||
|
||||
shop_item_names.setdefault(str(loc.address), item_name)
|
||||
|
||||
slot_data["ShopItemNames"] = shop_item_names
|
||||
|
||||
for name, value in self.options.as_dict(*self.options_dataclass.type_hints).items():
|
||||
if name in slot_data_options:
|
||||
slot_data[name] = value
|
||||
|
||||
return slot_data
|
||||
|
||||
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
|
||||
if self.is_dw_only() or not self.options.ActRandomizer:
|
||||
return
|
||||
|
||||
new_hint_data = {}
|
||||
alpine_regions = ["The Birdhouse", "The Lava Cake", "The Windmill",
|
||||
"The Twilight Bell", "Alpine Skyline Area", "Alpine Skyline Area (TIHS)"]
|
||||
|
||||
metro_regions = ["Yellow Overpass Station", "Green Clean Station", "Bluefin Tunnel", "Pink Paw Station"]
|
||||
|
||||
for key, data in location_table.items():
|
||||
if not is_location_valid(self, key):
|
||||
continue
|
||||
|
||||
location = self.multiworld.get_location(key, self.player)
|
||||
region_name: str
|
||||
|
||||
if data.region in alpine_regions:
|
||||
region_name = "Alpine Free Roam"
|
||||
elif data.region in metro_regions:
|
||||
region_name = "Nyakuza Free Roam"
|
||||
elif "Dead Bird Studio - " in data.region:
|
||||
region_name = "Dead Bird Studio"
|
||||
elif data.region in chapter_act_info.keys():
|
||||
region_name = location.parent_region.name
|
||||
else:
|
||||
continue
|
||||
|
||||
new_hint_data[location.address] = get_shuffled_region(self, region_name)
|
||||
|
||||
if self.is_dlc1() and self.options.Tasksanity:
|
||||
ship_shape_region = get_shuffled_region(self, "Ship Shape")
|
||||
id_start: int = TASKSANITY_START_ID
|
||||
for i in range(self.options.TasksanityCheckCount):
|
||||
new_hint_data[id_start+i] = ship_shape_region
|
||||
|
||||
hint_data[self.player] = new_hint_data
|
||||
|
||||
def write_spoiler_header(self, spoiler_handle: TextIO):
|
||||
for i in self.chapter_timepiece_costs:
|
||||
spoiler_handle.write("Chapter %i Cost: %i\n" % (i, self.chapter_timepiece_costs[ChapterIndex(i)]))
|
||||
|
||||
for hat in self.hat_craft_order:
|
||||
spoiler_handle.write("Hat Cost: %s: %i\n" % (hat, self.hat_yarn_costs[hat]))
|
||||
|
||||
def collect(self, state: "CollectionState", item: "Item") -> bool:
|
||||
old_count: int = state.count(item.name, self.player)
|
||||
change = super().collect(state, item)
|
||||
if change and old_count == 0:
|
||||
if "Stamp" in item.name:
|
||||
if "2 Stamp" in item.name:
|
||||
state.prog_items[self.player]["Stamps"] += 2
|
||||
else:
|
||||
state.prog_items[self.player]["Stamps"] += 1
|
||||
elif "(Zero Jumps)" in item.name:
|
||||
state.prog_items[self.player]["Zero Jumps"] += 1
|
||||
elif item.name in hit_list.keys():
|
||||
if item.name not in bosses:
|
||||
state.prog_items[self.player]["Enemy"] += 1
|
||||
else:
|
||||
state.prog_items[self.player]["Boss"] += 1
|
||||
|
||||
return change
|
||||
|
||||
def remove(self, state: "CollectionState", item: "Item") -> bool:
|
||||
old_count: int = state.count(item.name, self.player)
|
||||
change = super().collect(state, item)
|
||||
if change and old_count == 1:
|
||||
if "Stamp" in item.name:
|
||||
if "2 Stamp" in item.name:
|
||||
state.prog_items[self.player]["Stamps"] -= 2
|
||||
else:
|
||||
state.prog_items[self.player]["Stamps"] -= 1
|
||||
elif "(Zero Jumps)" in item.name:
|
||||
state.prog_items[self.player]["Zero Jumps"] -= 1
|
||||
elif item.name in hit_list.keys():
|
||||
if item.name not in bosses:
|
||||
state.prog_items[self.player]["Enemy"] -= 1
|
||||
else:
|
||||
state.prog_items[self.player]["Boss"] -= 1
|
||||
|
||||
return change
|
||||
|
||||
def has_yarn(self) -> bool:
|
||||
return not self.is_dw_only() and not self.options.HatItems
|
||||
|
||||
def is_hat_precollected(self, hat: HatType) -> bool:
|
||||
for item in self.multiworld.precollected_items[self.player]:
|
||||
if item.name == hat_type_to_item[hat]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def is_dlc1(self) -> bool:
|
||||
return bool(self.options.EnableDLC1)
|
||||
|
||||
def is_dlc2(self) -> bool:
|
||||
return bool(self.options.EnableDLC2)
|
||||
|
||||
def is_dw(self) -> bool:
|
||||
return bool(self.options.EnableDeathWish)
|
||||
|
||||
def is_dw_only(self) -> bool:
|
||||
return self.is_dw() and bool(self.options.DeathWishOnly)
|
||||
|
||||
def is_dw_excluded(self, name: str) -> bool:
|
||||
# don't exclude Seal the Deal if it's our goal
|
||||
if self.options.EndGoal.value == EndGoal.option_seal_the_deal and name == "Seal the Deal" \
|
||||
and f"{name} - Main Objective" not in self.options.exclude_locations:
|
||||
return False
|
||||
|
||||
if name in self.excluded_dws:
|
||||
return True
|
||||
|
||||
return f"{name} - Main Objective" in self.options.exclude_locations
|
||||
|
||||
def is_bonus_excluded(self, name: str) -> bool:
|
||||
if self.is_dw_excluded(name) or name in self.excluded_bonuses:
|
||||
return True
|
||||
|
||||
return f"{name} - All Clear" in self.options.exclude_locations
|
||||
53
worlds/ahit/docs/en_A Hat in Time.md
Normal file
53
worlds/ahit/docs/en_A Hat in Time.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# A Hat in Time
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
The [player options page for this game](../player-options) contains all the options you need to configure and export a
|
||||
config file.
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Items which the player would normally acquire throughout the game have been moved around.
|
||||
Chapter costs are randomized in a progressive order based on your options,
|
||||
so for example you could go to Subcon Forest -> Battle of the Birds -> Alpine Skyline, etc. in that order.
|
||||
If act shuffle is turned on, the levels and Time Rifts in these chapters will be randomized as well.
|
||||
|
||||
To unlock and access a chapter's Time Rift in act shuffle,
|
||||
the levels in place of the original acts required to unlock the Time Rift in the vanilla game must be completed,
|
||||
and then you must enter a level that allows you to access that Time Rift.
|
||||
For example, Time Rift: Bazaar requires Heating Up Mafia Town to be completed in the vanilla game.
|
||||
To unlock this Time Rift in act shuffle (and therefore the level it contains)
|
||||
you must complete the level that was shuffled in place of Heating Up Mafia Town
|
||||
and then enter the Time Rift through a Mafia Town level.
|
||||
|
||||
## What items and locations get shuffled?
|
||||
|
||||
Time Pieces, Relics, Yarn, Badges, and most other items are shuffled.
|
||||
Unlike in the vanilla game, yarn is typeless, and hats will be automatically stitched
|
||||
in a set order once you gather enough yarn for each hat.
|
||||
Hats can also optionally be shuffled as individual items instead.
|
||||
Any items in the world, shops, act completions,
|
||||
and optionally storybook pages or Death Wish contracts are locations.
|
||||
|
||||
Any freestanding items that are considered to be progression or useful
|
||||
will have a rainbow streak particle attached to them.
|
||||
Filler items will have a white glow attached to them instead.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
|
||||
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to limit
|
||||
certain items to your own world.
|
||||
|
||||
## What does another world's item look like in A Hat in Time?
|
||||
|
||||
Items belonging to other worlds are represented by a badge with the Archipelago logo on it.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
When the player receives an item, it will play the item collect effect and information about the item
|
||||
will be printed on the screen and in the in-game developer console.
|
||||
|
||||
## Is the DLC required to play A Hat in Time in Archipelago?
|
||||
|
||||
No, the DLC expansions are not required to play. Their content can be enabled through certain options
|
||||
that are disabled by default, but please don't turn them on if you don't own the respective DLC.
|
||||
102
worlds/ahit/docs/setup_en.md
Normal file
102
worlds/ahit/docs/setup_en.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# Setup Guide for A Hat in Time in Archipelago
|
||||
|
||||
## Required Software
|
||||
- [Steam release of A Hat in Time](https://store.steampowered.com/app/253230/A_Hat_in_Time/)
|
||||
|
||||
- [Archipelago Workshop Mod for A Hat in Time](https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601)
|
||||
|
||||
|
||||
## Optional Software
|
||||
- [A Hat in Time Archipelago Map Tracker](https://github.com/Mysteryem/ahit-poptracker/releases), for use with [PopTracker](https://github.com/black-sliver/PopTracker/releases)
|
||||
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Have Steam running. Open the Steam console with this link: [steam://open/console](steam://open/console)
|
||||
This may not work for some browsers. If that's the case, and you're on Windows, open the Run dialog using Win+R,
|
||||
paste the link into the box, and hit Enter.
|
||||
|
||||
|
||||
2. In the Steam console, enter the following command:
|
||||
`download_depot 253230 253232 7770543545116491859`. ***Wait for the console to say the download is finished!***
|
||||
This can take a while to finish (30+ minutes) depending on your connection speed, so please be patient. Additionally,
|
||||
**try to prevent your connection from being interrupted or slowed while Steam is downloading the depot,**
|
||||
or else the download may potentially become corrupted (see first FAQ issue below).
|
||||
|
||||
|
||||
3. Once the download finishes, go to `steamapps/content/app_253230` in Steam's program folder.
|
||||
|
||||
|
||||
4. There should be a folder named `depot_253232`. Rename it to HatinTime_AP and move it to your `steamapps/common` folder.
|
||||
|
||||
|
||||
5. In the HatinTime_AP folder, navigate to `Binaries/Win64` and create a new file: `steam_appid.txt`.
|
||||
In this new text file, input the number **253230** on the first line.
|
||||
|
||||
|
||||
6. Create a shortcut of `HatinTimeGame.exe` from that folder and move it to wherever you'd like.
|
||||
You will use this shortcut to open the Archipelago-compatible version of A Hat in Time.
|
||||
|
||||
|
||||
7. Start up the game using your new shortcut. To confirm if you are on the correct version,
|
||||
go to Settings -> Game Settings. If you don't see an option labelled ***Live Game Events*** you should be running
|
||||
the correct version of the game. In Game Settings, make sure ***Enable Developer Console*** is checked.
|
||||
|
||||
|
||||
## Connecting to the Archipelago server
|
||||
|
||||
To connect to the multiworld server, simply run the **ArchipelagoAHITClient**
|
||||
(or run it from the Launcher if you have the apworld installed) and connect it to the Archipelago server.
|
||||
The game will connect to the client automatically when you create a new save file.
|
||||
|
||||
|
||||
## Console Commands
|
||||
|
||||
Commands will not work on the title screen, you must be in-game to use them. To use console commands,
|
||||
make sure ***Enable Developer Console*** is checked in Game Settings and press the tilde key or TAB while in-game.
|
||||
|
||||
`ap_say <message>` - Send a chat message to the server. Supports commands, such as `!hint` or `!release`.
|
||||
|
||||
`ap_deathlink` - Toggle Death Link.
|
||||
|
||||
|
||||
## FAQ/Common Issues
|
||||
### I followed the setup, but I receive an odd error message upon starting the game or creating a save file!
|
||||
If you receive an error message such as
|
||||
**"Failed to find default engine .ini to retrieve My Documents subdirectory to use. Force quitting."** or
|
||||
**"Failed to load map "hub_spaceship"** after booting up the game or creating a save file respectively, then the depot
|
||||
download was likely corrupted. The only way to fix this is to start the entire download all over again.
|
||||
Unfortunately, this appears to be an underlying issue with Steam's depot downloader. The only way to really prevent this
|
||||
from happening is to ensure that your connection is not interrupted or slowed while downloading.
|
||||
|
||||
### The game keeps crashing on startup after the splash screen!
|
||||
This issue is unfortunately very hard to fix, and the underlying cause is not known. If it does happen however,
|
||||
try the following:
|
||||
|
||||
- Close Steam **entirely**.
|
||||
- Open the downpatched version of the game (with Steam closed) and allow it to load to the titlescreen.
|
||||
- Close the game, and then open Steam again.
|
||||
- After launching the game, the issue should hopefully disappear. If not, repeat the above steps until it does.
|
||||
|
||||
### I followed the setup, but "Live Game Events" still shows up in the options menu!
|
||||
The most common cause of this is the `steam_appid.txt` file. If you're on Windows 10, file extensions are hidden by
|
||||
default (thanks Microsoft). You likely made the mistake of still naming the file `steam_appid.txt`, which, since file
|
||||
extensions are hidden, would result in the file being named `steam_appid.txt.txt`, which is incorrect.
|
||||
To show file extensions in Windows 10, open any folder, click the View tab at the top, and check
|
||||
"File name extensions". Then you can correct the name of the file. If the name of the file is correct,
|
||||
and you're still running into the issue, re-read the setup guide again in case you missed a step.
|
||||
If you still can't get it to work, ask for help in the Discord thread.
|
||||
|
||||
### The game is running on the older version, but it's not connecting when starting a new save!
|
||||
For unknown reasons, the mod will randomly disable itself in the mod menu. To fix this, go to the Mods menu
|
||||
(rocket icon) in-game, and re-enable the mod.
|
||||
|
||||
### Why do relics disappear from the stands in the Spaceship after they're completed?
|
||||
This is intentional behaviour. Because of how randomizer logic works, there is no way to predict the order that
|
||||
a player will place their relics. Since there are a limited amount of relic stands in the Spaceship, relics are removed
|
||||
after being completed to allow for the placement of more relics without being potentially locked out.
|
||||
The level that the relic set unlocked will stay unlocked.
|
||||
|
||||
### When I start a new save file, the intro cinematic doesn't get skipped, Hat Kid's body is missing and the mod doesn't work!
|
||||
There is a bug on older versions of A Hat in Time that causes save file creation to fail to work properly
|
||||
if you have too many save files. Delete them and it should fix the problem.
|
||||
5
worlds/ahit/test/__init__.py
Normal file
5
worlds/ahit/test/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from test.bases import WorldTestBase
|
||||
|
||||
|
||||
class HatInTimeTestBase(WorldTestBase):
|
||||
game = "A Hat in Time"
|
||||
31
worlds/ahit/test/test_acts.py
Normal file
31
worlds/ahit/test/test_acts.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from ..Regions import act_chapters
|
||||
from ..Rules import act_connections
|
||||
from . import HatInTimeTestBase
|
||||
|
||||
|
||||
class TestActs(HatInTimeTestBase):
|
||||
run_default_tests = False
|
||||
|
||||
options = {
|
||||
"ActRandomizer": 2,
|
||||
"EnableDLC1": 1,
|
||||
"EnableDLC2": 1,
|
||||
"ShuffleActContracts": 0,
|
||||
}
|
||||
|
||||
def test_act_shuffle(self):
|
||||
for i in range(300):
|
||||
self.world_setup()
|
||||
self.collect_all_but([""])
|
||||
|
||||
for name in act_chapters.keys():
|
||||
region = self.multiworld.get_region(name, 1)
|
||||
for entrance in region.entrances:
|
||||
if entrance.name in act_connections.keys():
|
||||
continue
|
||||
|
||||
self.assertTrue(self.can_reach_entrance(entrance.name),
|
||||
f"Can't reach {name} from {entrance}\n"
|
||||
f"{entrance.parent_region.entrances[0]} -> {entrance.parent_region} "
|
||||
f"-> {entrance} -> {name}"
|
||||
f" (expected method of access)")
|
||||
@@ -264,7 +264,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
|
||||
if loc in all_state_base.events:
|
||||
all_state_base.events.remove(loc)
|
||||
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True, allow_excluded=True,
|
||||
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, lock=True, allow_excluded=True,
|
||||
name="LttP Dungeon Items")
|
||||
|
||||
|
||||
|
||||
@@ -276,13 +276,14 @@ def generate_itempool(world):
|
||||
|
||||
# set up item pool
|
||||
additional_triforce_pieces = 0
|
||||
treasure_hunt_total = 0
|
||||
if multiworld.custom:
|
||||
pool, placed_items, precollected_items, clock_mode, treasure_hunt_count = (
|
||||
pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = (
|
||||
make_custom_item_pool(multiworld, player))
|
||||
multiworld.rupoor_cost = min(multiworld.customitemarray[67], 9999)
|
||||
else:
|
||||
pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, additional_triforce_pieces = (
|
||||
get_pool_core(multiworld, player))
|
||||
(pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total,
|
||||
additional_triforce_pieces) = get_pool_core(multiworld, player)
|
||||
|
||||
for item in precollected_items:
|
||||
multiworld.push_precollected(item_factory(item, world))
|
||||
@@ -337,7 +338,8 @@ def generate_itempool(world):
|
||||
if clock_mode:
|
||||
world.clock_mode = clock_mode
|
||||
|
||||
multiworld.worlds[player].treasure_hunt_count = treasure_hunt_count % 999
|
||||
multiworld.worlds[player].treasure_hunt_required = treasure_hunt_required % 999
|
||||
multiworld.worlds[player].treasure_hunt_total = treasure_hunt_total
|
||||
|
||||
dungeon_items = [item for item in get_dungeon_item_pool_player(world)
|
||||
if item.name not in multiworld.worlds[player].dungeon_local_item_names]
|
||||
@@ -590,7 +592,8 @@ def get_pool_core(world, player: int):
|
||||
placed_items = {}
|
||||
precollected_items = []
|
||||
clock_mode: str = ""
|
||||
treasure_hunt_count: int = 1
|
||||
treasure_hunt_required: int = 0
|
||||
treasure_hunt_total: int = 0
|
||||
|
||||
diff = difficulties[difficulty]
|
||||
pool.extend(diff.alwaysitems)
|
||||
@@ -679,20 +682,21 @@ def get_pool_core(world, player: int):
|
||||
if 'triforce_hunt' in goal:
|
||||
|
||||
if world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_extra:
|
||||
triforce_pieces = world.triforce_pieces_available[player].value + world.triforce_pieces_extra[player].value
|
||||
treasure_hunt_total = (world.triforce_pieces_available[player].value
|
||||
+ world.triforce_pieces_extra[player].value)
|
||||
elif world.triforce_pieces_mode[player].value == TriforcePiecesMode.option_percentage:
|
||||
percentage = float(world.triforce_pieces_percentage[player].value) / 100
|
||||
triforce_pieces = int(round(world.triforce_pieces_required[player].value * percentage, 0))
|
||||
treasure_hunt_total = int(round(world.triforce_pieces_required[player].value * percentage, 0))
|
||||
else: # available
|
||||
triforce_pieces = world.triforce_pieces_available[player].value
|
||||
treasure_hunt_total = world.triforce_pieces_available[player].value
|
||||
|
||||
triforce_pieces = min(90, max(triforce_pieces, world.triforce_pieces_required[player].value))
|
||||
triforce_pieces = min(90, max(treasure_hunt_total, world.triforce_pieces_required[player].value))
|
||||
|
||||
pieces_in_core = min(extraitems, triforce_pieces)
|
||||
additional_pieces_to_place = triforce_pieces - pieces_in_core
|
||||
pool.extend(["Triforce Piece"] * pieces_in_core)
|
||||
extraitems -= pieces_in_core
|
||||
treasure_hunt_count = world.triforce_pieces_required[player].value
|
||||
treasure_hunt_required = world.triforce_pieces_required[player].value
|
||||
|
||||
for extra in diff.extras:
|
||||
if extraitems >= len(extra):
|
||||
@@ -733,7 +737,7 @@ def get_pool_core(world, player: int):
|
||||
place_item(key_location, "Small Key (Universal)")
|
||||
pool = pool[:-3]
|
||||
|
||||
return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count,
|
||||
return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total,
|
||||
additional_pieces_to_place)
|
||||
|
||||
|
||||
@@ -749,7 +753,8 @@ def make_custom_item_pool(world, player):
|
||||
placed_items = {}
|
||||
precollected_items = []
|
||||
clock_mode: str = ""
|
||||
treasure_hunt_count: int = 1
|
||||
treasure_hunt_required: int = 0
|
||||
treasure_hunt_total: int = 0
|
||||
|
||||
def place_item(loc, item):
|
||||
assert loc not in placed_items, "cannot place item twice"
|
||||
@@ -844,7 +849,7 @@ def make_custom_item_pool(world, player):
|
||||
if "triforce" in world.goal[player]:
|
||||
pool.extend(["Triforce Piece"] * world.triforce_pieces_available[player])
|
||||
itemtotal += world.triforce_pieces_available[player]
|
||||
treasure_hunt_count = world.triforce_pieces_required[player]
|
||||
treasure_hunt_required = world.triforce_pieces_required[player]
|
||||
|
||||
if timer in ['display', 'timed', 'timed_countdown']:
|
||||
clock_mode = 'countdown' if timer == 'timed_countdown' else 'stopwatch'
|
||||
@@ -889,4 +894,4 @@ def make_custom_item_pool(world, player):
|
||||
pool.extend(['Nothing'] * (total_items_to_place - itemtotal))
|
||||
logging.warning(f"Pool was filled up with {total_items_to_place - itemtotal} Nothing's for player {player}")
|
||||
|
||||
return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count)
|
||||
return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_required)
|
||||
|
||||
@@ -18,7 +18,7 @@ import subprocess
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import bsdiff4
|
||||
from typing import Optional, List
|
||||
from typing import Collection, Optional, List, SupportsIndex
|
||||
|
||||
from BaseClasses import CollectionState, Region, Location, MultiWorld
|
||||
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, parse_yaml, read_snes_rom
|
||||
@@ -52,7 +52,7 @@ except:
|
||||
enemizer_logger = logging.getLogger("Enemizer")
|
||||
|
||||
|
||||
class LocalRom(object):
|
||||
class LocalRom:
|
||||
|
||||
def __init__(self, file, patch=True, vanillaRom=None, name=None, hash=None):
|
||||
self.name = name
|
||||
@@ -71,13 +71,13 @@ class LocalRom(object):
|
||||
def read_byte(self, address: int) -> int:
|
||||
return self.buffer[address]
|
||||
|
||||
def read_bytes(self, startaddress: int, length: int) -> bytes:
|
||||
def read_bytes(self, startaddress: int, length: int) -> bytearray:
|
||||
return self.buffer[startaddress:startaddress + length]
|
||||
|
||||
def write_byte(self, address: int, value: int):
|
||||
self.buffer[address] = value
|
||||
|
||||
def write_bytes(self, startaddress: int, values):
|
||||
def write_bytes(self, startaddress: int, values: Collection[SupportsIndex]) -> None:
|
||||
self.buffer[startaddress:startaddress + len(values)] = values
|
||||
|
||||
def encrypt_range(self, startaddress: int, length: int, key: bytes):
|
||||
@@ -433,7 +433,7 @@ def patch_enemizer(world, rom: LocalRom, enemizercli, output_directory):
|
||||
if multiworld.key_drop_shuffle[player]:
|
||||
key_drop_enemies = {
|
||||
0x4DA20, 0x4DA5C, 0x4DB7F, 0x4DD73, 0x4DDC3, 0x4DE07, 0x4E201,
|
||||
0x4E20A, 0x4E326, 0x4E4F7, 0x4E686, 0x4E70C, 0x4E7C8, 0x4E7FA
|
||||
0x4E20A, 0x4E326, 0x4E4F7, 0x4E687, 0x4E70C, 0x4E7C8, 0x4E7FA
|
||||
}
|
||||
for enemy in key_drop_enemies:
|
||||
if rom.read_byte(enemy) == 0x12:
|
||||
@@ -1269,7 +1269,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
rom.write_int32(0x18020C, 0) # starting time (in frames, sint32)
|
||||
|
||||
# set up goals for treasure hunt
|
||||
rom.write_int16(0x180163, local_world.treasure_hunt_count)
|
||||
rom.write_int16(0x180163, local_world.treasure_hunt_required)
|
||||
rom.write_bytes(0x180165, [0x0E, 0x28]) # Triforce Piece Sprite
|
||||
rom.write_byte(0x180194, 1) # Must turn in triforced pieces (instant win not enabled)
|
||||
|
||||
@@ -1859,7 +1859,7 @@ def apply_oof_sfx(rom, oof: str):
|
||||
rom.write_bytes(0x12803A, oof_bytes)
|
||||
rom.write_bytes(0x12803A + len(oof_bytes), [0xEB, 0xEB])
|
||||
|
||||
#Enemizer patch: prevent Enemizer from overwriting $3188 in SPC memory with an unused sound effect ("WHAT")
|
||||
# Enemizer patch: prevent Enemizer from overwriting $3188 in SPC memory with an unused sound effect ("WHAT")
|
||||
rom.write_bytes(0x13000D, [0x00, 0x00, 0x00, 0x08])
|
||||
|
||||
|
||||
@@ -2482,16 +2482,16 @@ def write_strings(rom, world, player):
|
||||
tt['sign_ganon'] = 'Go find the Triforce pieces with your friends... Ganon is invincible!'
|
||||
else:
|
||||
tt['sign_ganon'] = 'Go find the Triforce pieces... Ganon is invincible!'
|
||||
if w.treasure_hunt_count > 1:
|
||||
if w.treasure_hunt_required > 1:
|
||||
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
|
||||
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
|
||||
"hidden in a hollow tree. If you bring\n%d Triforce pieces out of %d, I can reassemble it." % \
|
||||
(w.treasure_hunt_count, world.triforce_pieces_available[player])
|
||||
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||
else:
|
||||
tt['murahdahla'] = "Hello @. I\nam Murahdahla, brother of\nSahasrahla and Aginah. Behold the power of\n" \
|
||||
"invisibility.\n\n\n\n… … …\n\nWait! you can see me? I knew I should have\n" \
|
||||
"hidden in a hollow tree. If you bring\n%d Triforce piece out of %d, I can reassemble it." % \
|
||||
(w.treasure_hunt_count, world.triforce_pieces_available[player])
|
||||
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||
elif world.goal[player] in ['pedestal']:
|
||||
tt['ganon_fall_in_alt'] = 'Why are you even here?\n You can\'t even hurt me! Your goal is at the pedestal.'
|
||||
tt['ganon_phase_3_alt'] = 'Seriously? Go Away, I will not Die.'
|
||||
@@ -2500,20 +2500,20 @@ def write_strings(rom, world, player):
|
||||
tt['ganon_fall_in'] = Ganon1_texts[local_random.randint(0, len(Ganon1_texts) - 1)]
|
||||
tt['ganon_fall_in_alt'] = 'You cannot defeat me until you finish your goal!'
|
||||
tt['ganon_phase_3_alt'] = 'Got wax in\nyour ears?\nI can not die!'
|
||||
if w.treasure_hunt_count > 1:
|
||||
if w.treasure_hunt_required > 1:
|
||||
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
|
||||
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d with your friends to defeat Ganon.' % \
|
||||
(w.treasure_hunt_count, world.triforce_pieces_available[player])
|
||||
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
|
||||
tt['sign_ganon'] = 'You need to find %d Triforce pieces out of %d to defeat Ganon.' % \
|
||||
(w.treasure_hunt_count, world.triforce_pieces_available[player])
|
||||
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||
else:
|
||||
if world.goal[player] == 'ganon_triforce_hunt' and world.players > 1:
|
||||
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d with your friends to defeat Ganon.' % \
|
||||
(w.treasure_hunt_count, world.triforce_pieces_available[player])
|
||||
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||
elif world.goal[player] in ['ganon_triforce_hunt', 'local_ganon_triforce_hunt']:
|
||||
tt['sign_ganon'] = 'You need to find %d Triforce piece out of %d to defeat Ganon.' % \
|
||||
(w.treasure_hunt_count, world.triforce_pieces_available[player])
|
||||
(w.treasure_hunt_required, w.treasure_hunt_total)
|
||||
|
||||
tt['kakariko_tavern_fisherman'] = TavernMan_texts[local_random.randint(0, len(TavernMan_texts) - 1)]
|
||||
|
||||
@@ -3021,7 +3021,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
options = Utils.get_options()
|
||||
options = Utils.get_settings()
|
||||
if not file_name:
|
||||
file_name = options["lttp_options"]["rom_file"]
|
||||
if not os.path.exists(file_name):
|
||||
|
||||
@@ -64,20 +64,24 @@ def set_rules(world):
|
||||
|
||||
if world.glitches_required[player] == 'no_glitches':
|
||||
no_glitches_rules(world, player)
|
||||
forbid_bomb_jump_requirements(world, player)
|
||||
elif world.glitches_required[player] == 'overworld_glitches':
|
||||
# Initially setting no_glitches_rules to set the baseline rules for some
|
||||
# entrances. The overworld_glitches_rules set is primarily additive.
|
||||
no_glitches_rules(world, player)
|
||||
fake_flipper_rules(world, player)
|
||||
overworld_glitches_rules(world, player)
|
||||
forbid_bomb_jump_requirements(world, player)
|
||||
elif world.glitches_required[player] in ['hybrid_major_glitches', 'no_logic']:
|
||||
no_glitches_rules(world, player)
|
||||
fake_flipper_rules(world, player)
|
||||
overworld_glitches_rules(world, player)
|
||||
underworld_glitches_rules(world, player)
|
||||
bomb_jump_requirements(world, player)
|
||||
elif world.glitches_required[player] == 'minor_glitches':
|
||||
no_glitches_rules(world, player)
|
||||
fake_flipper_rules(world, player)
|
||||
forbid_bomb_jump_requirements(world, player)
|
||||
else:
|
||||
raise NotImplementedError(f'Not implemented yet: Logic - {world.glitches_required[player]}')
|
||||
|
||||
@@ -290,6 +294,9 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
lambda state: state.has('Hookshot', player) or state.has('Pegasus Boots', player))
|
||||
set_rule(multiworld.get_location('Hookshot Cave - Bottom Left', player), lambda state: state.has('Hookshot', player))
|
||||
|
||||
set_rule(multiworld.get_location('Hyrule Castle - Map Guard Key Drop', player),
|
||||
lambda state: can_kill_most_things(state, player, 1))
|
||||
|
||||
set_rule(multiworld.get_entrance('Sewers Door', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4) or (
|
||||
multiworld.small_key_shuffle[player] == small_key_shuffle.option_universal and multiworld.mode[
|
||||
@@ -392,8 +399,8 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
set_rule(multiworld.get_entrance('Swamp Palace (North)', player), lambda state: state.has('Hookshot', player) and state._lttp_has_key('Small Key (Swamp Palace)', player, 5))
|
||||
if not multiworld.small_key_shuffle[player] and multiworld.glitches_required[player] not in ['hybrid_major_glitches', 'no_logic']:
|
||||
forbid_item(multiworld.get_location('Swamp Palace - Entrance', player), 'Big Key (Swamp Palace)', player)
|
||||
set_rule(multiworld.get_location('Swamp Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
|
||||
set_rule(multiworld.get_location('Swamp Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
|
||||
add_rule(multiworld.get_location('Swamp Palace - Prize', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
|
||||
add_rule(multiworld.get_location('Swamp Palace - Boss', player), lambda state: state._lttp_has_key('Small Key (Swamp Palace)', player, 6))
|
||||
if multiworld.pot_shuffle[player]:
|
||||
# key can (and probably will) be moved behind bombable wall
|
||||
set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player))
|
||||
@@ -536,6 +543,8 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
set_rule(multiworld.get_location('Ganons Tower - Bob\'s Torch', player), lambda state: state.has('Pegasus Boots', player))
|
||||
set_rule(multiworld.get_entrance('Ganons Tower (Tile Room)', player), lambda state: state.has('Cane of Somaria', player))
|
||||
set_rule(multiworld.get_entrance('Ganons Tower (Hookshot Room)', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
|
||||
if multiworld.pot_shuffle[player]:
|
||||
set_rule(multiworld.get_location('Ganons Tower - Conveyor Cross Pot Key', player), lambda state: state.has('Hammer', player) and (state.has('Hookshot', player) or state.has('Pegasus Boots', player)))
|
||||
set_rule(multiworld.get_entrance('Ganons Tower (Map Room)', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (
|
||||
location_item_name(state, 'Ganons Tower - Map Chest', player) in [('Big Key (Ganons Tower)', player)] and state._lttp_has_key('Small Key (Ganons Tower)', player, 6)))
|
||||
|
||||
@@ -554,8 +563,8 @@ def global_rules(multiworld: MultiWorld, player: int):
|
||||
set_rule(multiworld.get_location('Ganons Tower - Firesnake Room', player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or
|
||||
((item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) or item_name_in_location_names(state, 'Small Key (Ganons Tower)', player, [('Ganons Tower - Firesnake Room', player)])) and state._lttp_has_key('Small Key (Ganons Tower)', player, 5)))
|
||||
for location in randomizer_room_chests:
|
||||
set_rule(multiworld.get_location(location, player), lambda state: state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (
|
||||
item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 6)))
|
||||
set_rule(multiworld.get_location(location, player), lambda state: can_use_bombs(state, player) and (state._lttp_has_key('Small Key (Ganons Tower)', player, 8) or (
|
||||
item_name_in_location_names(state, 'Big Key (Ganons Tower)', player, zip(randomizer_room_chests, [player] * len(randomizer_room_chests))) and state._lttp_has_key('Small Key (Ganons Tower)', player, 6))))
|
||||
|
||||
# Once again it is possible to need more than 7 keys...
|
||||
set_rule(multiworld.get_entrance('Ganons Tower (Tile Room) Key Door', player), lambda state: state.has('Fire Rod', player) and (state._lttp_has_key('Small Key (Ganons Tower)', player, 7) or (
|
||||
@@ -900,7 +909,6 @@ def no_glitches_rules(world, player):
|
||||
|
||||
add_rule(world.get_entrance('Ganons Tower (Double Switch Room)', player), lambda state: state.has('Hookshot', player))
|
||||
set_rule(world.get_entrance('Paradox Cave Push Block Reverse', player), lambda state: False) # no glitches does not require block override
|
||||
forbid_bomb_jump_requirements(world, player)
|
||||
add_conditional_lamps(world, player)
|
||||
|
||||
def fake_flipper_rules(world, player):
|
||||
@@ -928,12 +936,20 @@ def fake_flipper_rules(world, player):
|
||||
set_rule(world.get_entrance('East Dark World River Pier', player), lambda state: state.has('Moon Pearl', player))
|
||||
|
||||
|
||||
def forbid_bomb_jump_requirements(world, player):
|
||||
def bomb_jump_requirements(multiworld, player):
|
||||
DMs_room_chests = ['Ganons Tower - DMs Room - Top Left', 'Ganons Tower - DMs Room - Top Right', 'Ganons Tower - DMs Room - Bottom Left', 'Ganons Tower - DMs Room - Bottom Right']
|
||||
for location in DMs_room_chests:
|
||||
add_rule(world.get_location(location, player), lambda state: state.has('Hookshot', player))
|
||||
set_rule(world.get_entrance('Paradox Cave Bomb Jump', player), lambda state: False)
|
||||
set_rule(world.get_entrance('Skull Woods First Section Bomb Jump', player), lambda state: False)
|
||||
add_rule(multiworld.get_location(location, player), lambda state: can_use_bombs(state, player), combine="or")
|
||||
set_rule(multiworld.get_entrance('Paradox Cave Bomb Jump', player), lambda state: can_use_bombs(state, player))
|
||||
set_rule(multiworld.get_entrance('Skull Woods First Section Bomb Jump', player), lambda state: can_use_bombs(state, player))
|
||||
|
||||
|
||||
def forbid_bomb_jump_requirements(multiworld, player):
|
||||
DMs_room_chests = ['Ganons Tower - DMs Room - Top Left', 'Ganons Tower - DMs Room - Top Right', 'Ganons Tower - DMs Room - Bottom Left', 'Ganons Tower - DMs Room - Bottom Right']
|
||||
for location in DMs_room_chests:
|
||||
add_rule(multiworld.get_location(location, player), lambda state: state.has('Hookshot', player))
|
||||
set_rule(multiworld.get_entrance('Paradox Cave Bomb Jump', player), lambda state: False)
|
||||
set_rule(multiworld.get_entrance('Skull Woods First Section Bomb Jump', player), lambda state: False)
|
||||
|
||||
|
||||
DW_Entrances = ['Bumper Cave (Bottom)',
|
||||
@@ -1012,9 +1028,6 @@ def add_conditional_lamps(world, player):
|
||||
|
||||
def open_rules(world, player):
|
||||
|
||||
set_rule(world.get_location('Hyrule Castle - Map Guard Key Drop', player),
|
||||
lambda state: can_kill_most_things(state, player, 1))
|
||||
|
||||
def basement_key_rule(state):
|
||||
if location_item_name(state, 'Sewers - Key Rat Key Drop', player) == ("Small Key (Hyrule Castle)", player):
|
||||
return state._lttp_has_key("Small Key (Hyrule Castle)", player, 2)
|
||||
@@ -1023,7 +1036,7 @@ def open_rules(world, player):
|
||||
|
||||
set_rule(world.get_location('Hyrule Castle - Boomerang Guard Key Drop', player),
|
||||
lambda state: basement_key_rule(state) and can_kill_most_things(state, player, 2))
|
||||
set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player), basement_key_rule)
|
||||
set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player), lambda state: basement_key_rule(state) and can_kill_most_things(state, player, 1))
|
||||
|
||||
set_rule(world.get_location('Sewers - Key Rat Key Drop', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3) and can_kill_most_things(state, player, 1))
|
||||
@@ -1031,8 +1044,10 @@ def open_rules(world, player):
|
||||
set_rule(world.get_location('Hyrule Castle - Big Key Drop', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4) and can_kill_most_things(state, player, 1))
|
||||
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4) and
|
||||
state.has('Big Key (Hyrule Castle)', player))
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 4)
|
||||
and state.has('Big Key (Hyrule Castle)', player)
|
||||
and (world.enemy_health[player] in ("easy", "default")
|
||||
or can_kill_most_things(state, player, 1)))
|
||||
|
||||
|
||||
def swordless_rules(world, player):
|
||||
@@ -1062,6 +1077,7 @@ def add_connection(parent_name, target_name, entrance_name, world, player):
|
||||
parent.exits.append(connection)
|
||||
connection.connect(target)
|
||||
|
||||
|
||||
def standard_rules(world, player):
|
||||
add_connection('Menu', 'Hyrule Castle Secret Entrance', 'Uncle S&Q', world, player)
|
||||
world.get_entrance('Uncle S&Q', player).hide_path = True
|
||||
@@ -1073,18 +1089,23 @@ def standard_rules(world, player):
|
||||
|
||||
if world.small_key_shuffle[player] != small_key_shuffle.option_universal:
|
||||
set_rule(world.get_location('Hyrule Castle - Boomerang Guard Key Drop', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1))
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1)
|
||||
and can_kill_most_things(state, player, 2))
|
||||
set_rule(world.get_location('Hyrule Castle - Boomerang Chest', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1))
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 1)
|
||||
and can_kill_most_things(state, player, 1))
|
||||
|
||||
set_rule(world.get_location('Hyrule Castle - Big Key Drop', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2))
|
||||
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2) and
|
||||
state.has('Big Key (Hyrule Castle)', player))
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 2)
|
||||
and state.has('Big Key (Hyrule Castle)', player)
|
||||
and (world.enemy_health[player] in ("easy", "default")
|
||||
or can_kill_most_things(state, player, 1)))
|
||||
|
||||
set_rule(world.get_location('Sewers - Key Rat Key Drop', player),
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3))
|
||||
lambda state: state._lttp_has_key('Small Key (Hyrule Castle)', player, 3)
|
||||
and can_kill_most_things(state, player, 1))
|
||||
else:
|
||||
set_rule(world.get_location('Hyrule Castle - Zelda\'s Chest', player),
|
||||
lambda state: state.has('Big Key (Hyrule Castle)', player))
|
||||
|
||||
@@ -30,7 +30,7 @@ def can_shoot_arrows(state: CollectionState, player: int) -> bool:
|
||||
|
||||
|
||||
def has_triforce_pieces(state: CollectionState, player: int) -> bool:
|
||||
count = state.multiworld.worlds[player].treasure_hunt_count
|
||||
count = state.multiworld.worlds[player].treasure_hunt_required
|
||||
return state.count('Triforce Piece', player) + state.count('Power Star', player) >= count
|
||||
|
||||
|
||||
|
||||
@@ -261,7 +261,8 @@ class ALTTPWorld(World):
|
||||
fix_fake_world: bool = True
|
||||
|
||||
clock_mode: str = ""
|
||||
treasure_hunt_count: int = 1
|
||||
treasure_hunt_required: int = 0
|
||||
treasure_hunt_total: int = 0
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.dungeon_local_item_names = set()
|
||||
|
||||
@@ -64,7 +64,8 @@ configuración personal y descargar un fichero "YAML".
|
||||
|
||||
### Configuración YAML avanzada
|
||||
|
||||
Una version mas avanzada del fichero Yaml puede ser creada usando la pagina ["Weighted settings"](/weighted-settings),
|
||||
Una version mas avanzada del fichero Yaml puede ser creada usando la pagina
|
||||
["Weighted settings"](/games/A Link to the Past/weighted-options),
|
||||
la cual te permite tener almacenadas hasta 3 preajustes. La pagina "Weighted Settings" tiene muchas opciones
|
||||
representadas con controles deslizantes. Esto permite elegir cuan probable los valores de una categoría pueden ser
|
||||
elegidos sobre otros de la misma.
|
||||
|
||||
@@ -66,9 +66,10 @@ paramètres personnels et de les exporter vers un fichier YAML.
|
||||
### Configuration avancée du fichier YAML
|
||||
|
||||
Une version plus avancée du fichier YAML peut être créée en utilisant la page
|
||||
des [paramètres de pondération](/weighted-settings), qui vous permet de configurer jusqu'à trois préréglages. Cette page
|
||||
a de nombreuses options qui sont essentiellement représentées avec des curseurs glissants. Cela vous permet de choisir
|
||||
quelles sont les chances qu'une certaine option apparaisse par rapport aux autres disponibles dans une même catégorie.
|
||||
des [paramètres de pondération](/games/A Link to the Past/weighted-options), qui vous permet de configurer jusqu'à
|
||||
trois préréglages. Cette page a de nombreuses options qui sont essentiellement représentées avec des curseurs
|
||||
glissants. Cela vous permet de choisir quelles sont les chances qu'une certaine option apparaisse par rapport aux
|
||||
autres disponibles dans une même catégorie.
|
||||
|
||||
Par exemple, imaginez que le générateur crée un seau étiqueté "Mélange des cartes", et qu'il place un morceau de papier
|
||||
pour chaque sous-option. Imaginez également que la valeur pour "On" est 20 et la valeur pour "Off" est 40.
|
||||
|
||||
@@ -15,7 +15,7 @@ class TestDungeon(LTTPTestBase):
|
||||
self.remove_exits = [] # Block dungeon exits
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
create_regions(self.multiworld, 1)
|
||||
self.multiworld.worlds[1].create_dungeons()
|
||||
create_shops(self.multiworld, 1)
|
||||
|
||||
@@ -33,22 +33,26 @@ class TestGanonsTower(TestDungeon):
|
||||
["Ganons Tower - Randomizer Room - Top Left", False, []],
|
||||
["Ganons Tower - Randomizer Room - Top Left", False, [], ['Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Top Left", False, [], ['Hookshot']],
|
||||
["Ganons Tower - Randomizer Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Top Left", False, [], ['Bomb Upgrade (50)']],
|
||||
["Ganons Tower - Randomizer Room - Top Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Bomb Upgrade (50)']],
|
||||
|
||||
["Ganons Tower - Randomizer Room - Top Right", False, []],
|
||||
["Ganons Tower - Randomizer Room - Top Right", False, [], ['Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Top Right", False, [], ['Hookshot']],
|
||||
["Ganons Tower - Randomizer Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Top Right", False, [], ['Bomb Upgrade (50)']],
|
||||
["Ganons Tower - Randomizer Room - Top Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Bomb Upgrade (50)']],
|
||||
|
||||
["Ganons Tower - Randomizer Room - Bottom Left", False, []],
|
||||
["Ganons Tower - Randomizer Room - Bottom Left", False, [], ['Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Left", False, [], ['Hookshot']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Left", False, [], ['Bomb Upgrade (50)']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Left", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Bomb Upgrade (50)']],
|
||||
|
||||
["Ganons Tower - Randomizer Room - Bottom Right", False, []],
|
||||
["Ganons Tower - Randomizer Room - Bottom Right", False, [], ['Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Right", False, [], ['Hookshot']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Right", False, [], ['Bomb Upgrade (50)']],
|
||||
["Ganons Tower - Randomizer Room - Bottom Right", True, ['Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Small Key (Ganons Tower)', 'Hookshot', 'Hammer', 'Bomb Upgrade (50)']],
|
||||
|
||||
["Ganons Tower - Firesnake Room", False, []],
|
||||
["Ganons Tower - Firesnake Room", False, [], ['Hammer']],
|
||||
|
||||
@@ -16,7 +16,7 @@ class TestInverted(TestBase, LTTPTestBase):
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
self.multiworld.mode[1].value = 2
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
self.world.create_dungeons()
|
||||
create_shops(self.multiworld, 1)
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestInvertedMinor(TestBase, LTTPTestBase):
|
||||
self.multiworld.mode[1].value = 2
|
||||
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("minor_glitches")
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
self.world.create_dungeons()
|
||||
|
||||
@@ -17,7 +17,7 @@ class TestInvertedOWG(TestBase, LTTPTestBase):
|
||||
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("overworld_glitches")
|
||||
self.multiworld.mode[1].value = 2
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
create_inverted_regions(self.multiworld, 1)
|
||||
self.world.create_dungeons()
|
||||
|
||||
@@ -13,7 +13,7 @@ class TestMinor(TestBase, LTTPTestBase):
|
||||
self.world_setup()
|
||||
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("minor_glitches")
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
self.world.er_seed = 0
|
||||
self.world.create_regions()
|
||||
|
||||
@@ -14,7 +14,7 @@ class TestVanillaOWG(TestBase, LTTPTestBase):
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("overworld_glitches")
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
self.multiworld.worlds[1].er_seed = 0
|
||||
self.multiworld.worlds[1].create_regions()
|
||||
self.multiworld.worlds[1].create_items()
|
||||
|
||||
@@ -13,7 +13,7 @@ class TestVanilla(TestBase, LTTPTestBase):
|
||||
self.multiworld.glitches_required[1] = GlitchesRequired.from_any("no_glitches")
|
||||
self.multiworld.worlds[1].difficulty_requirements = difficulties['normal']
|
||||
self.multiworld.bombless_start[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = True
|
||||
self.multiworld.shuffle_capacity_upgrades[1].value = 2
|
||||
self.multiworld.worlds[1].er_seed = 0
|
||||
self.multiworld.worlds[1].create_regions()
|
||||
self.multiworld.worlds[1].create_items()
|
||||
|
||||
214
worlds/aquaria/Items.py
Normal file
214
worlds/aquaria/Items.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Author: Louis M
|
||||
Date: Fri, 15 Mar 2024 18:41:40 +0000
|
||||
Description: Manage items in the Aquaria game multiworld randomizer
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from BaseClasses import Item, ItemClassification
|
||||
|
||||
|
||||
class ItemType(Enum):
|
||||
"""
|
||||
Used to indicate to the multi-world if an item is useful or not
|
||||
"""
|
||||
NORMAL = 0
|
||||
PROGRESSION = 1
|
||||
JUNK = 2
|
||||
|
||||
|
||||
class ItemGroup(Enum):
|
||||
"""
|
||||
Used to group items
|
||||
"""
|
||||
COLLECTIBLE = 0
|
||||
INGREDIENT = 1
|
||||
RECIPE = 2
|
||||
HEALTH = 3
|
||||
UTILITY = 4
|
||||
SONG = 5
|
||||
TURTLE = 6
|
||||
|
||||
|
||||
class AquariaItem(Item):
|
||||
"""
|
||||
A single item in the Aquaria game.
|
||||
"""
|
||||
game: str = "Aquaria"
|
||||
"""The name of the game"""
|
||||
|
||||
def __init__(self, name: str, classification: ItemClassification,
|
||||
code: Optional[int], player: int):
|
||||
"""
|
||||
Initialisation of the Item
|
||||
:param name: The name of the item
|
||||
:param classification: If the item is useful or not
|
||||
:param code: The ID of the item (if None, it is an event)
|
||||
:param player: The ID of the player in the multiworld
|
||||
"""
|
||||
super().__init__(name, classification, code, player)
|
||||
|
||||
|
||||
class ItemData:
|
||||
"""
|
||||
Data of an item.
|
||||
"""
|
||||
id: int
|
||||
count: int
|
||||
type: ItemType
|
||||
group: ItemGroup
|
||||
|
||||
def __init__(self, id: int, count: int, type: ItemType, group: ItemGroup):
|
||||
"""
|
||||
Initialisation of the item data
|
||||
@param id: The item ID
|
||||
@param count: the number of items in the pool
|
||||
@param type: the importance type of the item
|
||||
@param group: the usage of the item in the game
|
||||
"""
|
||||
self.id = id
|
||||
self.count = count
|
||||
self.type = type
|
||||
self.group = group
|
||||
|
||||
|
||||
"""Information data for every (not event) item."""
|
||||
item_table = {
|
||||
# name: ID, Nb, Item Type, Item Group
|
||||
"Anemone": ItemData(698000, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_anemone
|
||||
"Arnassi statue": ItemData(698001, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_arnassi_statue
|
||||
"Big seed": ItemData(698002, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_big_seed
|
||||
"Glowing seed": ItemData(698003, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_bio_seed
|
||||
"Black pearl": ItemData(698004, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_blackpearl
|
||||
"Baby blaster": ItemData(698005, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_blaster
|
||||
"Crab armor": ItemData(698006, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_crab_costume
|
||||
"Baby dumbo": ItemData(698007, 1, ItemType.PROGRESSION, ItemGroup.UTILITY), # collectible_dumbo
|
||||
"Tooth": ItemData(698008, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_boss
|
||||
"Energy statue": ItemData(698009, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_statue
|
||||
"Krotite armor": ItemData(698010, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_energy_temple
|
||||
"Golden starfish": ItemData(698011, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_gold_star
|
||||
"Golden gear": ItemData(698012, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_golden_gear
|
||||
"Jelly beacon": ItemData(698013, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_beacon
|
||||
"Jelly costume": ItemData(698014, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_jelly_costume
|
||||
"Jelly plant": ItemData(698015, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_jelly_plant
|
||||
"Mithalas doll": ItemData(698016, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithala_doll
|
||||
"Mithalan dress": ItemData(698017, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalan_costume
|
||||
"Mithalas banner": ItemData(698018, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_banner
|
||||
"Mithalas pot": ItemData(698019, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mithalas_pot
|
||||
"Mutant costume": ItemData(698020, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_mutant_costume
|
||||
"Baby nautilus": ItemData(698021, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_nautilus
|
||||
"Baby piranha": ItemData(698022, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_piranha
|
||||
"Arnassi Armor": ItemData(698023, 1, ItemType.NORMAL, ItemGroup.UTILITY), # collectible_seahorse_costume
|
||||
"Seed bag": ItemData(698024, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_seed_bag
|
||||
"King's Skull": ItemData(698025, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_skull
|
||||
"Song plant spore": ItemData(698026, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_spore_seed
|
||||
"Stone head": ItemData(698027, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_stone_head
|
||||
"Sun key": ItemData(698028, 1, ItemType.NORMAL, ItemGroup.COLLECTIBLE), # collectible_sun_key
|
||||
"Girl costume": ItemData(698029, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_teen_costume
|
||||
"Odd container": ItemData(698030, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_treasure_chest
|
||||
"Trident": ItemData(698031, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_trident_head
|
||||
"Turtle egg": ItemData(698032, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_turtle_egg
|
||||
"Jelly egg": ItemData(698033, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_upsidedown_seed
|
||||
"Urchin costume": ItemData(698034, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_urchin_costume
|
||||
"Baby walker": ItemData(698035, 1, ItemType.JUNK, ItemGroup.COLLECTIBLE), # collectible_walker
|
||||
"Vedha's Cure-All-All": ItemData(698036, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Vedha'sCure-All
|
||||
"Zuuna's perogi": ItemData(698037, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_Zuuna'sperogi
|
||||
"Arcane poultice": ItemData(698038, 7, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_arcanepoultice
|
||||
"Berry ice cream": ItemData(698039, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_berryicecream
|
||||
"Buttery sea loaf": ItemData(698040, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_butterysealoaf
|
||||
"Cold borscht": ItemData(698041, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_coldborscht
|
||||
"Cold soup": ItemData(698042, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_coldsoup
|
||||
"Crab cake": ItemData(698043, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_crabcake
|
||||
"Divine soup": ItemData(698044, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_divinesoup
|
||||
"Dumbo ice cream": ItemData(698045, 3, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_dumboicecream
|
||||
"Fish oil": ItemData(698046, 2, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishoil
|
||||
"Glowing egg": ItemData(698047, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_glowingegg
|
||||
"Hand roll": ItemData(698048, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_handroll
|
||||
"Healing poultice": ItemData(698049, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_healingpoultice
|
||||
"Hearty soup": ItemData(698050, 5, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_heartysoup
|
||||
"Hot borscht": ItemData(698051, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_hotborscht
|
||||
"Hot soup": ItemData(698052, 3, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_hotsoup
|
||||
"Ice cream": ItemData(698053, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_icecream
|
||||
"Leadership roll": ItemData(698054, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leadershiproll
|
||||
"Leaf poultice": ItemData(698055, 5, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_leafpoultice
|
||||
"Leeching poultice": ItemData(698056, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leechingpoultice
|
||||
"Legendary cake": ItemData(698057, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_legendarycake
|
||||
"Loaf of life": ItemData(698058, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_loafoflife
|
||||
"Long life soup": ItemData(698059, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_longlifesoup
|
||||
"Magic soup": ItemData(698060, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_magicsoup
|
||||
"Mushroom x 2": ItemData(698061, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_mushroom
|
||||
"Perogi": ItemData(698062, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_perogi
|
||||
"Plant leaf": ItemData(698063, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf
|
||||
"Plump perogi": ItemData(698064, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_plumpperogi
|
||||
"Poison loaf": ItemData(698065, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_poisonloaf
|
||||
"Poison soup": ItemData(698066, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_poisonsoup
|
||||
"Rainbow mushroom": ItemData(698067, 4, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_rainbowmushroom
|
||||
"Rainbow soup": ItemData(698068, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_rainbowsoup
|
||||
"Red berry": ItemData(698069, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_redberry
|
||||
"Red bulb x 2": ItemData(698070, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_redbulb
|
||||
"Rotten cake": ItemData(698071, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_rottencake
|
||||
"Rotten loaf x 8": ItemData(698072, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_rottenloaf
|
||||
"Rotten meat": ItemData(698073, 5, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat
|
||||
"Royal soup": ItemData(698074, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_royalsoup
|
||||
"Sea cake": ItemData(698075, 4, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_seacake
|
||||
"Sea loaf": ItemData(698076, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_sealoaf
|
||||
"Shark fin soup": ItemData(698077, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_sharkfinsoup
|
||||
"Sight poultice": ItemData(698078, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_sightpoultice
|
||||
"Small bone x 2": ItemData(698079, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallbone
|
||||
"Small egg": ItemData(698080, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallegg
|
||||
"Small tentacle x 2": ItemData(698081, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smalltentacle
|
||||
"Special bulb": ItemData(698082, 5, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_specialbulb
|
||||
"Special cake": ItemData(698083, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_specialcake
|
||||
"Spicy meat x 2": ItemData(698084, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_spicymeat
|
||||
"Spicy roll": ItemData(698085, 11, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spicyroll
|
||||
"Spicy soup": ItemData(698086, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spicysoup
|
||||
"Spider roll": ItemData(698087, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_spiderroll
|
||||
"Swamp cake": ItemData(698088, 3, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_swampcake
|
||||
"Tasty cake": ItemData(698089, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_tastycake
|
||||
"Tasty roll": ItemData(698090, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_tastyroll
|
||||
"Tough cake": ItemData(698091, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_toughcake
|
||||
"Turtle soup": ItemData(698092, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_turtlesoup
|
||||
"Vedha sea crisp": ItemData(698093, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_vedhaseacrisp
|
||||
"Veggie cake": ItemData(698094, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggiecake
|
||||
"Veggie ice cream": ItemData(698095, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggieicecream
|
||||
"Veggie soup": ItemData(698096, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_veggiesoup
|
||||
"Volcano roll": ItemData(698097, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_volcanoroll
|
||||
"Health upgrade": ItemData(698098, 5, ItemType.NORMAL, ItemGroup.HEALTH), # upgrade_health_?
|
||||
"Wok": ItemData(698099, 1, ItemType.NORMAL, ItemGroup.UTILITY), # upgrade_wok
|
||||
"Eel oil x 2": ItemData(698100, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_eeloil
|
||||
"Fish meat x 2": ItemData(698101, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishmeat
|
||||
"Fish oil x 3": ItemData(698102, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_fishoil
|
||||
"Glowing egg x 2": ItemData(698103, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_glowingegg
|
||||
"Healing poultice x 2": ItemData(698104, 2, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_healingpoultice
|
||||
"Hot soup x 2": ItemData(698105, 1, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_hotsoup
|
||||
"Leadership roll x 2": ItemData(698106, 1, ItemType.NORMAL, ItemGroup.RECIPE), # ingredient_leadershiproll
|
||||
"Leaf poultice x 3": ItemData(698107, 2, ItemType.PROGRESSION, ItemGroup.RECIPE), # ingredient_leafpoultice
|
||||
"Plant leaf x 2": ItemData(698108, 2, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf
|
||||
"Plant leaf x 3": ItemData(698109, 4, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_plantleaf
|
||||
"Rotten meat x 2": ItemData(698110, 1, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat
|
||||
"Rotten meat x 8": ItemData(698111, 1, ItemType.JUNK, ItemGroup.INGREDIENT), # ingredient_rottenmeat
|
||||
"Sea loaf x 2": ItemData(698112, 1, ItemType.JUNK, ItemGroup.RECIPE), # ingredient_sealoaf
|
||||
"Small bone x 3": ItemData(698113, 3, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallbone
|
||||
"Small egg x 2": ItemData(698114, 1, ItemType.NORMAL, ItemGroup.INGREDIENT), # ingredient_smallegg
|
||||
"Li and Li song": ItemData(698115, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_li
|
||||
"Shield song": ItemData(698116, 1, ItemType.NORMAL, ItemGroup.SONG), # song_shield
|
||||
"Beast form": ItemData(698117, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_beast
|
||||
"Sun form": ItemData(698118, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_sun
|
||||
"Nature form": ItemData(698119, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_nature
|
||||
"Energy form": ItemData(698120, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_energy
|
||||
"Bind song": ItemData(698121, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_bind
|
||||
"Fish form": ItemData(698122, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_fish
|
||||
"Spirit form": ItemData(698123, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_spirit
|
||||
"Dual form": ItemData(698124, 1, ItemType.PROGRESSION, ItemGroup.SONG), # song_dual
|
||||
"Transturtle Veil top left": ItemData(698125, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_veil01
|
||||
"Transturtle Veil top right": ItemData(698126, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_veil02
|
||||
"Transturtle Open Water top right": ItemData(698127, 1, ItemType.PROGRESSION,
|
||||
ItemGroup.TURTLE), # transport_openwater03
|
||||
"Transturtle Forest bottom left": ItemData(698128, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest04
|
||||
"Transturtle Home water": ItemData(698129, 1, ItemType.NORMAL, ItemGroup.TURTLE), # transport_mainarea
|
||||
"Transturtle Abyss right": ItemData(698130, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_abyss03
|
||||
"Transturtle Final Boss": ItemData(698131, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_finalboss
|
||||
"Transturtle Simon says": ItemData(698132, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_forest05
|
||||
"Transturtle Arnassi ruins": ItemData(698133, 1, ItemType.PROGRESSION, ItemGroup.TURTLE), # transport_seahorse
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user