Merge branch 'main' into rework_accessibility

# Conflicts:
#	worlds/alttp/test/dungeons/TestDungeon.py
#	worlds/messenger/rules.py
#	worlds/pokemon_rb/options.py
This commit is contained in:
alwaysintreble
2023-11-25 12:28:12 -06:00
430 changed files with 75044 additions and 14323 deletions

2
.gitignore vendored
View File

@@ -9,12 +9,14 @@
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc

View File

@@ -1,14 +1,15 @@
from __future__ import annotations
import copy
import itertools
import functools
import logging
import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import ChainMap, Counter, deque
from collections.abc import Collection
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
@@ -47,7 +48,6 @@ class ThreadBarrierProxy:
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
_region_cache: Dict[int, Dict[str, Region]]
difficulty_requirements: dict
required_medallions: dict
dark_room_logic: Dict[int, str]
@@ -57,7 +57,7 @@ class MultiWorld():
plando_connections: List
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
regions: List[Region]
regions: RegionManager
itempool: List[Item]
is_race: bool = False
precollected_items: Dict[int, List[Item]]
@@ -92,6 +92,39 @@ class MultiWorld():
def __getitem__(self, player) -> bool:
return self.rule(player)
class RegionManager:
region_cache: Dict[int, Dict[str, Region]]
entrance_cache: Dict[int, Dict[str, Entrance]]
location_cache: Dict[int, Dict[str, Location]]
def __init__(self, players: int):
self.region_cache = {player: {} for player in range(1, players+1)}
self.entrance_cache = {player: {} for player in range(1, players+1)}
self.location_cache = {player: {} for player in range(1, players+1)}
def __iadd__(self, other: Iterable[Region]):
self.extend(other)
return self
def append(self, region: Region):
self.region_cache[region.player][region.name] = region
def extend(self, regions: Iterable[Region]):
for region in regions:
self.region_cache[region.player][region.name] = region
def add_group(self, new_id: int):
self.region_cache[new_id] = {}
self.entrance_cache[new_id] = {}
self.location_cache[new_id] = {}
def __iter__(self) -> Iterator[Region]:
for regions in self.region_cache.values():
yield from regions.values()
def __len__(self):
return sum(len(regions) for regions in self.region_cache.values())
def __init__(self, players: int):
# world-local random state is saved for multiple generations running concurrently
self.random = ThreadBarrierProxy(random.Random())
@@ -100,16 +133,12 @@ class MultiWorld():
self.glitch_triforce = False
self.algorithm = 'balanced'
self.groups = {}
self.regions = []
self.regions = self.RegionManager(players)
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self._cached_entrances = None
self._cached_locations = None
self._entrance_cache = {}
self._location_cache: Dict[Tuple[str, int], Location] = {}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
@@ -137,7 +166,6 @@ class MultiWorld():
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open')
@@ -181,7 +209,6 @@ class MultiWorld():
set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.custom_data = {}
self.worlds = {}
self.per_slot_randoms = {}
self.plando_options = PlandoOptions.none
@@ -198,10 +225,9 @@ class MultiWorld():
return group_id, group
new_id: int = self.players + len(self.groups) + 1
self.regions.add_group(new_id)
self.game[new_id] = game
self.custom_data[new_id] = {}
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
@@ -227,7 +253,6 @@ class MultiWorld():
def set_options(self, args: Namespace) -> None:
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
@@ -306,11 +331,15 @@ class MultiWorld():
def player_ids(self) -> Tuple[int, ...]:
return tuple(range(1, self.players + 1))
@functools.lru_cache()
@Utils.cache_self1
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
return tuple(player for player in self.player_ids if self.game[player] == game_name)
@functools.lru_cache()
@Utils.cache_self1
def get_game_groups(self, game_name: str) -> Tuple[int, ...]:
return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name)
@Utils.cache_self1
def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
@@ -332,41 +361,17 @@ class MultiWorld():
def world_name_lookup(self):
return {self.player_name[player_id]: player_id for player_id in self.player_ids}
def _recache(self):
"""Rebuild world cache"""
self._cached_locations = None
for region in self.regions:
player = region.player
self._region_cache[player][region.name] = region
for exit in region.exits:
self._entrance_cache[exit.name, player] = exit
for r_location in region.locations:
self._location_cache[r_location.name, player] = r_location
def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
return self.regions if player is None else self._region_cache[player].values()
return self.regions if player is None else self.regions.region_cache[player].values()
def get_region(self, regionname: str, player: int) -> Region:
try:
return self._region_cache[player][regionname]
except KeyError:
self._recache()
return self._region_cache[player][regionname]
def get_region(self, region_name: str, player: int) -> Region:
return self.regions.region_cache[player][region_name]
def get_entrance(self, entrance: str, player: int) -> Entrance:
try:
return self._entrance_cache[entrance, player]
except KeyError:
self._recache()
return self._entrance_cache[entrance, player]
def get_entrance(self, entrance_name: str, player: int) -> Entrance:
return self.regions.entrance_cache[player][entrance_name]
def get_location(self, location: str, player: int) -> Location:
try:
return self._location_cache[location, player]
except KeyError:
self._recache()
return self._location_cache[location, player]
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None)
@@ -427,28 +432,22 @@ class MultiWorld():
logging.debug('Placed %s at %s', item, location)
def get_entrances(self) -> List[Entrance]:
if self._cached_entrances is None:
self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances]
return self._cached_entrances
def clear_entrance_cache(self):
self._cached_entrances = None
def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]:
if player is not None:
return self.regions.entrance_cache[player].values()
return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values()
for player in self.regions.entrance_cache))
def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this Entrance,
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self, player: Optional[int] = None) -> List[Location]:
if self._cached_locations is None:
self._cached_locations = [location for region in self.regions for location in region.locations]
def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
if player is not None:
return [location for location in self._cached_locations if location.player == player]
return self._cached_locations
def clear_location_cache(self):
self._cached_locations = None
return self.regions.location_cache[player].values()
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
for player in self.regions.location_cache))
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
return [location for location in self.get_locations(player) if location.item is None]
@@ -470,16 +469,17 @@ class MultiWorld():
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
else:
valid_locations = location_names
relevant_cache = self.regions.location_cache[player]
for location_name in valid_locations:
location = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None:
location = relevant_cache.get(location_name, None)
if location and location.item is None:
yield location
def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy()
temp_state.collect(item, True)
for location in self.get_unfilled_locations():
for location in self.get_unfilled_locations(item.player):
if temp_state.can_reach(location) and not self.state.can_reach(location):
return True
@@ -612,7 +612,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState():
prog_items: typing.Counter[Tuple[str, int]]
prog_items: Dict[int, Counter[str]]
multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
@@ -624,7 +624,7 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld):
self.prog_items = Counter()
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
@@ -672,7 +672,7 @@ class CollectionState():
def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld)
ret.prog_items = self.prog_items.copy()
ret.prog_items = copy.deepcopy(self.prog_items)
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions}
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
@@ -715,37 +715,43 @@ class CollectionState():
assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event)
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[item, player] >= count
return self.prog_items[player][item] >= count
def has_all(self, items: Set[str], player: int) -> bool:
def has_all(self, items: Iterable[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once."""
return all(self.prog_items[item, player] for item in items)
return all(self.prog_items[player][item] for item in items)
def has_any(self, items: Set[str], player: int) -> bool:
def has_any(self, items: Iterable[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[item, player] for item in items)
return any(self.prog_items[player][item] for item in items)
def count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
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)
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
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 += self.prog_items[item_name, player]
found += player_prog_items[item_name]
if found >= count:
return True
return False
def count_group(self, item_name_group: str, player: int) -> int:
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 += self.prog_items[item_name, player]
found += player_prog_items[item_name]
return found
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
# Item related
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -753,7 +759,7 @@ class CollectionState():
changed = self.multiworld.worlds[item.player].collect(self, item)
if not changed and event:
self.prog_items[item.name, item.player] += 1
self.prog_items[item.player][item.name] += 1
changed = True
self.stale[item.player] = True
@@ -820,15 +826,83 @@ class Region:
locations: List[Location]
entrance_type: ClassVar[Type[Entrance]] = Entrance
class Register(MutableSequence):
region_manager: MultiWorld.RegionManager
def __init__(self, region_manager: MultiWorld.RegionManager):
self._list = []
self.region_manager = region_manager
def __getitem__(self, index: int) -> Location:
return self._list.__getitem__(index)
def __setitem__(self, index: int, value: Location) -> None:
raise NotImplementedError()
def __len__(self) -> int:
return self._list.__len__()
# This seems to not be needed, but that's a bit suspicious.
# def __del__(self):
# self.clear()
def copy(self):
return self._list.copy()
class LocationRegister(Register):
def __delitem__(self, index: int) -> None:
location: Location = self._list.__getitem__(index)
self._list.__delitem__(index)
del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None:
self._list.insert(index, value)
self.region_manager.location_cache[value.player][value.name] = value
class EntranceRegister(Register):
def __delitem__(self, index: int) -> None:
entrance: Entrance = self._list.__getitem__(index)
self._list.__delitem__(index)
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None:
self._list.insert(index, value)
self.region_manager.entrance_cache[value.player][value.name] = value
_locations: LocationRegister[Location]
_exits: EntranceRegister[Entrance]
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.entrances = []
self.exits = []
self.locations = []
self._exits = self.EntranceRegister(multiworld.regions)
self._locations = self.LocationRegister(multiworld.regions)
self.multiworld = multiworld
self._hint_text = hint
self.player = player
def get_locations(self):
return self._locations
def set_locations(self, new):
if new is self._locations:
return
self._locations.clear()
self._locations.extend(new)
locations = property(get_locations, set_locations)
def get_exits(self):
return self._exits
def set_exits(self, new):
if new is self._exits:
return
self._exits.clear()
self._exits.extend(new)
exits = property(get_exits, set_exits)
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
@@ -859,7 +933,7 @@ class Region:
self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
"""
Connects this Region to another Region, placing the provided rule on the connection.
@@ -870,6 +944,7 @@ class Region:
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
return exit_
def create_exit(self, name: str) -> Entrance:
"""

View File

@@ -737,7 +737,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif 'InvalidGame' in errors:
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible')
raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem
@@ -758,6 +759,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -836,10 +838,14 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if args["key"].startswith("EnergyLink"):
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints()
elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()

52
Fill.py
View File

@@ -15,6 +15,10 @@ class FillError(RuntimeError):
pass
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:
new_state = base_state.copy()
for item in itempool:
@@ -26,7 +30,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False) -> None:
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
"""
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
@@ -38,16 +42,20 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
:param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
# for progress logging
total = min(len(item_pool), len(locations))
placed = 0
while any(reachable_items.values()) and locations:
# grab one item per player
items_to_place = [items.pop()
@@ -104,7 +112,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
@@ -152,9 +160,15 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
if on_place:
on_place(spot_to_fill)
if total > 1000:
_log_fill_progress(name, placed, total)
if cleanup_required:
# validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
@@ -198,6 +212,8 @@ def remaining_fill(world: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
@@ -247,6 +263,12 @@ def remaining_fill(world: MultiWorld,
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
_log_fill_progress("Remaining", placed, total)
if total > 1000:
_log_fill_progress("Remaining", placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
@@ -282,7 +304,7 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
@@ -352,23 +374,25 @@ def distribute_early_items(world: MultiWorld,
player_local = early_local_rest_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
early_locations += early_priority_locations
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression")
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
@@ -422,13 +446,14 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
if prioritylocations:
# "priority fill"
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
name="Priority")
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool)
# "advancement/progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
@@ -446,7 +471,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = usefulitempool + filleritempool
restitempool = filleritempool + usefulitempool
remaining_fill(world, defaultlocations, restitempool)
@@ -767,6 +792,9 @@ def distribute_planned(world: MultiWorld) -> None:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:

View File

@@ -7,8 +7,8 @@ import random
import string
import urllib.parse
import urllib.request
from collections import ChainMap, Counter
from typing import Any, Callable, Dict, Tuple, Union
from collections import Counter
from typing import Any, Dict, Tuple, Union
import ModuleUpdate
@@ -20,7 +20,7 @@ import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp import Options as LttPOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
@@ -53,6 +53,9 @@ def mystery_argparse():
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.")
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
@@ -127,6 +130,13 @@ def main(args=None, callback=ERmain):
player_id += 1
args.multi = max(player_id - 1, args.multi)
if args.multi == 0:
raise ValueError(
"No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi."
)
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {args.plando}")
@@ -143,6 +153,7 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
@@ -225,7 +236,7 @@ def main(args=None, callback=ERmain):
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
callback(erargs, seed)
return callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -639,6 +650,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
main()
multiworld = main()
if __debug__:
import gc
import sys
import weakref
weak = weakref.ref(multiworld)
del multiworld
gc.collect() # need to collect to deref all hard references
assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
" This would be a memory leak."
# in case of error-free exit should not need confirmation
atexit.unregister(confirmation)

View File

@@ -1,894 +1,8 @@
import os
import asyncio
import ModuleUpdate
import json
import Utils
from pymem import pymem
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
from worlds.kh2.WorldLocations import *
from worlds import network_data_package
if __name__ == "__main__":
Utils.init_logging("KH2Client", exception_logger="Client")
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
from worlds.kh2.Client import launch
ModuleUpdate.update()
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
# class KH2CommandProcessor(ClientCommandProcessor):
class KH2Context(CommonContext):
# command_processor: int = KH2CommandProcessor
game = "Kingdom Hearts 2"
items_handling = 0b101 # Indicates you get items sent from other worlds.
def __init__(self, server_address, password):
super(KH2Context, self).__init__(server_address, password)
self.kh2LocalItems = None
self.ability = None
self.growthlevel = None
self.KH2_sync_task = None
self.syncing = False
self.kh2connected = False
self.serverconneced = False
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
item_dictionary_table.items() if data.code}
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
all_locations.items() if data.code}
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.sending = []
# list used to keep track of locations+items player has. Used for disoneccting
self.kh2seedsave = None
self.slotDataProgressionNames = {}
self.kh2seedname = None
self.kh2slotdata = None
self.itemamount = {}
# sora equipped, valor equipped, master equipped, final equipped
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
self.amountOfPieces = 0
# hooked object
self.kh2 = None
self.ItemIsSafe = False
self.game_connected = False
self.finalxemnas = False
self.worldid = {
# 1: {}, # world of darkness (story cutscenes)
2: TT_Checks,
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
4: HB_Checks,
5: BC_Checks,
6: Oc_Checks,
7: AG_Checks,
8: LoD_Checks,
9: HundredAcreChecks,
10: PL_Checks,
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
12: DC_Checks,
13: TR_Checks,
14: HT_Checks,
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
16: PR_Checks,
17: SP_Checks,
18: TWTNW_Checks,
# 255: {}, # starting screen
}
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
self.sveroom = 0x2A09C00 + 0x41
# 0 not in battle 1 in yellow battle 2 red battle #short
self.inBattle = 0x2A0EAC4 + 0x40
self.onDeath = 0xAB9078
# PC Address anchors
self.Now = 0x0714DB8
self.Save = 0x09A70B0
self.Sys3 = 0x2A59DF0
self.Bt10 = 0x2A74880
self.BtlEnd = 0x2A0D3E0
self.Slot1 = 0x2A20C98
self.chest_set = set(exclusion_table["Chests"])
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
self.equipment_categories = CheckDupingItems["Equipment"]
self.armor_set = set(self.equipment_categories["Armor"])
self.accessories_set = set(self.equipment_categories["Accessories"])
self.all_equipment = self.armor_set.union(self.accessories_set)
self.Equipment_Anchor_Dict = {
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
self.AbilityQuantityDict = {}
self.ability_categories = CheckDupingItems["Abilities"]
self.sora_ability_set = set(self.ability_categories["Sora"])
self.donald_ability_set = set(self.ability_categories["Donald"])
self.goofy_ability_set = set(self.ability_categories["Goofy"])
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
self.boost_set = set(CheckDupingItems["Boosts"])
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
# Growth:[level 1,level 4,slot]
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA],
"Quick Run": [0x62, 0x65, 0x25DC],
"Dodge Roll": [0x234, 0x237, 0x25DE],
"Aerial Dodge": [0x066, 0x069, 0x25E0],
"Glide": [0x6A, 0x6D, 0x25E2]}
self.boost_to_anchor_dict = {
"Power Boost": 0x24F9,
"Magic Boost": 0x24FA,
"Defense Boost": 0x24FB,
"AP Boost": 0x24F8}
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
self.bitmask_item_code = [
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(KH2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname is not None and self.auth is not None:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).connection_closed()
async def disconnect(self, allow_autoreconnect: bool = False):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).disconnect()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).shutdown()
def on_package(self, cmd: str, args: dict):
if cmd in {"RoomInfo"}:
self.kh2seedname = args['seed_name']
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
self.kh2seedsave = {"itemIndex": -1,
# back of soras invo is 0x25E2. Growth should be moved there
# Character: [back of invo, front of invo]
"SoraInvo": [0x25D8, 0x2546],
"DonaldInvo": [0x26F4, 0x2658],
"GoofyInvo": [0x280A, 0x276C],
"AmountInvo": {
"ServerItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
},
"LocalItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0, "Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}},
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
"LocationsChecked": [],
"Levels": {
"SoraLevel": 0,
"ValorLevel": 0,
"WisdomLevel": 0,
"LimitLevel": 0,
"MasterLevel": 0,
"FinalLevel": 0,
},
"SoldEquipment": [],
"SoldBoosts": {"Power Boost": 0,
"Magic Boost": 0,
"Defense Boost": 0,
"AP Boost": 0}
}
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
self.locations_checked = set()
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
self.kh2seedsave = json.load(f)
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
self.serverconneced = True
if cmd in {"Connected"}:
self.kh2slotdata = args['slot_data']
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
logger.info("You are now auto-tracking")
self.kh2connected = True
except Exception as e:
logger.info("Line 247")
if self.kh2connected:
logger.info("Connection Lost")
self.kh2connected = False
logger.info(e)
if cmd in {"ReceivedItems"}:
start_index = args["index"]
if start_index == 0:
# resetting everything that were sent from the server
self.kh2seedsave["SoraInvo"][0] = 0x25D8
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
self.kh2seedsave["GoofyInvo"][0] = 0x280A
self.kh2seedsave["itemIndex"] = - 1
self.kh2seedsave["AmountInvo"]["ServerItems"] = {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}
if start_index > self.kh2seedsave["itemIndex"]:
self.kh2seedsave["itemIndex"] = start_index
for item in args['items']:
asyncio.create_task(self.give_item(item.item))
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
new_locations = set(args["checked_locations"])
# TODO: make this take locations from other players on the same slot so proper coop happens
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
# location_id in self.kh2LocalItems.keys()]
self.checked_locations |= new_locations
async def checkWorldLocations(self):
try:
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
if currentworldint in self.worldid:
curworldid = self.worldid[currentworldint]
for location, data in curworldid.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and (int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex) > 0:
self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 285")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkLevels(self):
try:
for location, data in SoraLevels.items():
currentLevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
self.sending = self.sending + [(int(locationId))]
formDict = {
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
for i in range(5):
for location, data in formDict[i][1].items():
formlevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and formlevel >= data.bitIndex:
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
self.sending = self.sending + [(int(locationId))]
except Exception as e:
logger.info("Line 312")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkSlots(self):
try:
for location, data in weaponSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") > 0:
self.sending = self.sending + [(int(locationId))]
for location, data in formSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
# self.locations_checked
self.sending = self.sending + [(int(locationId))]
except Exception as e:
if self.kh2connected:
logger.info("Line 333")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyChests(self):
try:
for location in self.locations_checked:
locationName = self.lookup_id_to_Location[location]
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
locationData = self.location_name_to_worlddata[locationName]
if int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex == 0:
roomData = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
1), "big")
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
except Exception as e:
if self.kh2connected:
logger.info("Line 350")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyLevel(self):
for leveltype, anchor in {"SoraLevel": 0x24FF,
"ValorLevel": 0x32F6,
"WisdomLevel": 0x332E,
"LimitLevel": 0x3366,
"MasterLevel": 0x339E,
"FinalLevel": 0x33D6}.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
self.kh2seedsave["Levels"][leveltype]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
async def give_item(self, item, ItemType="ServerItems"):
try:
itemname = self.lookup_id_to_item[item]
itemcode = self.item_name_to_data[itemname]
if itemcode.ability:
abilityInvoType = 0
TwilightZone = 2
if ItemType == "LocalItems":
abilityInvoType = 1
TwilightZone = -2
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
return
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
# appending the slot that the ability should be in
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
self.AbilityQuantityDict[itemname]:
if itemname in self.sora_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["SoraInvo"][abilityInvoType])
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
elif itemname in self.donald_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["DonaldInvo"][abilityInvoType])
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
else:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["GoofyInvo"][abilityInvoType])
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
elif itemcode.code in self.bitmask_item_code:
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
elif itemname in self.all_equipment:
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
elif itemname in self.all_weapons:
if itemname in self.keyblade_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
elif itemname in self.staff_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
else:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
elif itemname in self.boost_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
elif itemname in self.stat_increase_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
else:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
except Exception as e:
if self.kh2connected:
logger.info("Line 398")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class KH2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago KH2 Client"
self.ui = KH2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def IsInShop(self, sellable, master_boost):
# journal = 0x741230 shop = 0x741320
# if journal=-1 and shop = 5 then in shop
# if journam !=-1 and shop = 10 then journal
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
# print("your in the shop")
sellable_dict = {}
for itemName in sellable:
itemdata = self.item_name_to_data[itemName]
amount = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
sellable_dict[itemName] = amount
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
await asyncio.sleep(0.5)
for item, amount in sellable_dict.items():
itemdata = self.item_name_to_data[item]
afterShop = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
if afterShop < amount:
if item in master_boost:
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
else:
self.kh2seedsave["SoldEquipment"].append(item)
async def verifyItems(self):
try:
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
master_amount = local_amount | server_amount
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
master_ability = local_ability | server_ability
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
master_bitmask = local_bitmask | server_bitmask
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
master_keyblade = local_keyblade | server_keyblade
master_staff = local_staff | server_staff
master_shield = local_shield | server_shield
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
master_equipment = local_equipment | server_equipment
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
master_magic = local_magic | server_magic
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
master_stat = local_stat | server_stat
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
master_boost = local_boost | server_boost
master_sell = master_equipment | master_staff | master_shield | master_boost
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
for itemName in master_amount:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
if itemName in server_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
if itemName == "Torn Page":
# Torn Pages are handled differently because they can be consumed.
# Will check the progression in 100 acre and - the amount of visits
# amountofitems-amount of visits done
for location, data in tornPageLocks.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
amountOfItems -= 1
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems and amountOfItems >= 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_keyblade:
itemData = self.item_name_to_data[itemName]
# if the inventory slot for that keyblade is less than the amount they should have
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
"big") != 13:
# Checking form anchors for the keyblade
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
else:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_staff:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_shield:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_ability:
itemData = self.item_name_to_data[itemName]
ability_slot = []
if itemName in local_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
if itemName in server_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
for slot in ability_slot:
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current & 0x0FFF
if ability | 0x8000 != (0x8000 + itemData.memaddr):
if current - 0x8000 > 0:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
else:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
# removes the duped ability if client gave faster than the game.
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
# remove the dummy level 1 growths if they are in these invo slots.
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
ability = current & 0x0FFF
if 0x05E <= ability <= 0x06D:
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
for itemName in self.master_growth:
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
if growthLevel > 0:
slot = self.growth_values_dict[itemName][2]
min_growth = self.growth_values_dict[itemName][0]
max_growth = self.growth_values_dict[itemName][1]
if growthLevel > 4:
growthLevel = 4
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current_growth_level & 0x0FFF
# if the player should be getting a growth ability
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
# if it should be level one of that growth
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
# if it is already in the inventory
elif ability | 0x8000 < (0x8000 + max_growth):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
for itemName in master_bitmask:
itemData = self.item_name_to_data[itemName]
itemMemory = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") & 0x1 << itemData.bitmask) == 0:
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
(0).to_bytes(1, 'big'), 1)
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
for itemName in master_equipment:
itemData = self.item_name_to_data[itemName]
isThere = False
if itemName in self.accessories_set:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
else:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
# Checking form anchors for the equipment
for slot in Equipment_Anchor_List:
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
isThere = True
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
break
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_magic:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
if itemName in server_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_stat:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
if itemName in server_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
# 0x130293 is Crit_1's location id for touching the computer
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
"big") >= 5 and int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
"big") > 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_boost:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
if itemName in server_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
amountOfBoostsInInvo = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big")
amountOfUsedBoosts = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
"big")
# Ap Boots start at +50 for some reason
if itemName == "AP Boost":
amountOfUsedBoosts -= 50
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][
itemName] and amountOfBoostsInInvo < 255:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
except Exception as e:
logger.info("Line 573")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def finishedGame(ctx: KH2Context, message):
if ctx.kh2slotdata['FinalXemnas'] == 1:
if 0x1301ED in message[0]["locations"]:
ctx.finalxemnas = True
# three proofs
if ctx.kh2slotdata['Goal'] == 0:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 1:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
ctx.kh2slotdata['LuckyEmblemsRequired']:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 2:
for boss in ctx.kh2slotdata["hitlist"]:
if boss in message[0]["locations"]:
ctx.amountOfPieces += 1
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
async def kh2_watcher(ctx: KH2Context):
while not ctx.exit_event.is_set():
try:
if ctx.kh2connected and ctx.serverconneced:
ctx.sending = []
await asyncio.create_task(ctx.checkWorldLocations())
await asyncio.create_task(ctx.checkLevels())
await asyncio.create_task(ctx.checkSlots())
await asyncio.create_task(ctx.verifyChests())
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
location_ids = []
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
for location in location_ids:
if location not in ctx.locations_checked:
ctx.locations_checked.add(location)
ctx.kh2seedsave["LocationsChecked"].append(location)
if location in ctx.kh2LocalItems:
item = ctx.kh2slotdata["LocalItems"][str(location)]
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game is not open. Disconnecting from Server.")
await ctx.disconnect()
except Exception as e:
logger.info("Line 661")
if ctx.kh2connected:
logger.info("Connection Lost.")
ctx.kh2connected = False
logger.info(e)
await asyncio.sleep(0.5)
if __name__ == '__main__':
async def main(args):
ctx = KH2Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
kh2_watcher(ctx), name="KH2ProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="KH2 Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()
Utils.init_logging("KH2Client", exception_logger="Client")
launch()

View File

@@ -58,7 +58,7 @@ class MMBN3CommandProcessor(ClientCommandProcessor):
class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3"
items_handling = 0b001 # full local
items_handling = 0b101 # full local except starting items
def __init__(self, server_address, password):
super().__init__(server_address, password)

25
Main.py
View File

@@ -13,8 +13,8 @@ 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 settings import get_settings
from Utils import __version__, output_path, version_tuple
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -101,7 +101,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
del item_digits, location_digits, item_count, location_count
AutoWorld.call_stage(world, "assert_generate")
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output:
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(world, "generate_early")
@@ -122,10 +124,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
# All worlds should have finished creating all regions, locations, and entrances.
# Recache to ensure that they are all visible for locality rules.
world._recache()
logger.info('Calculating Access Rules.')
for player in world.player_ids:
@@ -233,7 +231,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
locations = region.locations = []
locations = region.locations
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
@@ -267,10 +265,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()):
world._recache()
world._all_state = None
logger.info("Running Item Plando")
logger.info("Running Item Plando.")
distribute_planned(world)
@@ -292,11 +289,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
logger.info("Progression balancing skipped.")
logger.info(f'Beginning output...')
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
world.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
return world
logger.info(f'Beginning output...')
outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory()
@@ -359,6 +359,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
f" {location}"
assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.name in world.worlds[location.player].options.start_location_hints:

View File

@@ -2,14 +2,15 @@ from __future__ import annotations
import argparse
import asyncio
import copy
import collections
import copy
import datetime
import functools
import hashlib
import inspect
import itertools
import logging
import math
import operator
import pickle
import random
@@ -67,21 +68,25 @@ def update_dict(dictionary, entries):
# functions callable on storable data on the server by clients
modify_functions = {
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
"mod": operator.mod,
"max": max,
"min": min,
# generic:
"replace": lambda old, new: new,
"default": lambda old, new: old,
# numeric:
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
"pow": operator.pow,
"mod": operator.mod,
"floor": lambda value, _: math.floor(value),
"ceil": lambda value, _: math.ceil(value),
"max": max,
"min": min,
# bitwise:
"xor": operator.xor,
"or": operator.or_,
"and": operator.and_,
"left_shift": operator.lshift,
"right_shift": operator.rshift,
# lists/dicts
# lists/dicts:
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_dict,
@@ -412,6 +417,8 @@ class Context:
self.player_name_lookup[slot_info.name] = 0, slot_id
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
list(self.get_rechecked_hints(local_team, local_player))
self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
self.client_game_state[local_team, local_player]
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
@@ -707,6 +714,12 @@ class Context:
"hint_points": get_slot_points(self, team, slot)
}])
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}])
def update_aliases(ctx: Context, team: int):
cmd = ctx.dumper([{"cmd": "RoomUpdate",
@@ -1814,6 +1827,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.on_goal_achieved(client)
ctx.client_game_state[client.team, client.slot] = new_status
ctx.on_client_status_change(client.team, client.slot)
ctx.save()

View File

@@ -696,11 +696,19 @@ class Range(NumericOption):
return int(round(random.triangular(lower, end, tri), 0))
class SpecialRange(Range):
special_range_cutoff = 0
class NamedRange(Range):
special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
def __init__(self, value: int) -> None:
if value < self.range_start and value not in self.special_range_names.values():
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
self.value = value
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
@@ -708,6 +716,19 @@ class SpecialRange(Range):
return cls(cls.special_range_names[text])
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, value)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
@@ -900,7 +921,7 @@ class ItemsAccessibility(Accessibility):
default = 1
class ProgressionBalancing(SpecialRange):
class ProgressionBalancing(NamedRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
@@ -1117,7 +1138,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: typing.Union[Range, SpecialRange]):
def dictify_range(option: Range):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:

View File

@@ -1,382 +0,0 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
and location.address is not None}
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
self.auto_hints = set()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to EmuHawk to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
ret = json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
hints = []
if flags["EventFlag"][280] & 16:
hints.append("Cerulean Bicycle Shop")
if flags["EventFlag"][280] & 32:
hints.append("Route 2 Gate - Oak's Aide")
if flags["EventFlag"][280] & 64:
hints.append("Route 11 Gate 2F - Oak's Aide")
if flags["EventFlag"][280] & 128:
hints.append("Route 15 Gate 2F - Oak's Aide")
if flags["EventFlag"][281] & 1:
hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
"Celadon Prize Corner - Item Prize 3"]
if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
not in ctx.checked_locations):
hints.append("Fossil - Choice B")
elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
not in ctx.checked_locations):
hints.append("Fossil - Choice A")
hints = [
location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
]
if hints:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
ctx.auto_hints.update(hints)
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -51,6 +51,12 @@ Currently, the following games are supported:
* Muse Dash
* DOOM 1993
* Terraria
* Lingo
* Pokémon Emerald
* DOOM II
* Shivers
* Heretic
* Landstalker: The Treasures of King Nole
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -207,12 +207,12 @@ class SNIContext(CommonContext):
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
super(SNIContext, self).on_deathlink(data)
async def handle_deathlink_state(self, currently_dead: bool) -> None:
async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None:
# in this state we only care about triggering a death send
if self.death_state == DeathState.alive:
if currently_dead:
self.death_state = DeathState.dead
await self.send_death()
await self.send_death(death_text)
# in this state we care about confirming a kill, to move state to dead
elif self.death_state == DeathState.killing_player:
# this is being handled in deathlink_kill_player(ctx) already

View File

@@ -27,14 +27,14 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
self.ctx.syncing = True
def _cmd_patch(self):
"""Patch the game."""
"""Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
def _cmd_savepath(self, directory: str):
"""Redirect to proper save data folder. (Use before connecting!)"""
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.save_game_folder = directory
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
@@ -67,7 +67,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
self.output("Patching successful!")
def _cmd_online(self):
"""Makes you no longer able to see other Undertale players."""
"""Toggles seeing other Undertale players."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags:

View File

@@ -5,6 +5,7 @@ import json
import typing
import builtins
import os
import itertools
import subprocess
import sys
import pickle
@@ -46,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.4.3"
__version__ = "0.4.4"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -73,6 +74,8 @@ def snes_to_pc(value: int) -> int:
RetType = typing.TypeVar("RetType")
S = typing.TypeVar("S")
T = typing.TypeVar("T")
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
@@ -90,6 +93,31 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[]
return _wrap
def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]:
"""Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple."""
assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache."
cache_name = f"__cache_{function.__name__}__"
@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))
if cache is None:
res = function(self, arg)
setattr(self, cache_name, {arg: res})
return res
try:
return cache[arg]
except KeyError:
res = function(self, arg)
cache[arg] = res
return res
return wrap
def is_frozen() -> bool:
return typing.cast(bool, getattr(sys, 'frozen', False))
@@ -146,12 +174,16 @@ def user_path(*path: str) -> str:
if user_path.cached_path != local_path():
import filecmp
if not os.path.exists(user_path("manifest.json")) or \
not os.path.exists(local_path("manifest.json")) or \
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
import shutil
for dn in ("Players", "data/sprites"):
for dn in ("Players", "data/sprites", "data/lua"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ("manifest.json",):
shutil.copy2(local_path(fn), user_path(fn))
if not os.path.exists(local_path("manifest.json")):
warnings.warn(f"Upgrading {user_path()} from something that is not a proper install")
else:
shutil.copy2(local_path("manifest.json"), user_path("manifest.json"))
os.makedirs(user_path("worlds"), exist_ok=True)
return os.path.join(user_path.cached_path, *path)
@@ -257,15 +289,13 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
OptionsType = Settings # TODO: remove when removing get_options
@cache_argsless
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
return Settings(None)
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
def get_options() -> Settings:
# TODO: switch to Utils.deprecate after 0.4.4
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
return get_settings()
def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -905,3 +935,17 @@ def visualize_regions(root_region: Region, file_name: str, *,
with open(file_name, "wt", encoding="utf-8") as f:
f.write("\n".join(uml))
class RepeatableChain:
def __init__(self, iterable: typing.Iterable):
self.iterable = iterable
def __iter__(self):
return itertools.chain.from_iterable(self.iterable)
def __bool__(self):
return any(sub_iterable for sub_iterable in self.iterable)
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)

View File

@@ -113,6 +113,9 @@ class WargrooveContext(CommonContext):
async def connection_closed(self):
await super(WargrooveContext, self).connection_closed()
self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
@property
def endpoints(self):
@@ -124,6 +127,9 @@ class WargrooveContext(CommonContext):
async def shutdown(self):
await super(WargrooveContext, self).shutdown()
self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path):
@@ -402,8 +408,10 @@ async def game_watcher(ctx: WargrooveContext):
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1:
victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)

View File

@@ -27,8 +27,10 @@ from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
ctx: WebHostContext
def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
def _cmd_video(self, platform: str, user: str):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream.
Currently, only YouTube and Twitch platforms are supported.
"""
if platform.lower().startswith("t"): # twitch
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
self.ctx.save()

View File

@@ -1,18 +1,18 @@
import concurrent.futures
import json
import os
import pickle
import random
import tempfile
import zipfile
import concurrent.futures
from collections import Counter
from typing import Dict, Optional, Any, Union, List
from typing import Any, Dict, List, Optional, Union
from flask import request, flash, redirect, url_for, session, render_template
from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoOptions
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__
from WebHostLib import app
@@ -131,6 +131,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -3,11 +3,8 @@ import logging
import os
import typing
import yaml
from jinja2 import Template
import Options
from Utils import __version__, local_path
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",
@@ -28,7 +25,7 @@ def create():
weighted_options = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "Player",
"name": "",
"game": {},
},
"games": {},
@@ -43,7 +40,7 @@ def create():
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
"name": "Player",
"name": "",
},
}
@@ -84,8 +81,8 @@ def create():
"max": option.range_end,
}
if issubclass(option, Options.SpecialRange):
game_options[option_name]["type"] = 'special_range'
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
@@ -117,10 +114,46 @@ def create():
}
else:
logging.debug(f"{option} not exported to Web options.")
logging.debug(f"{option} not exported to Web Options.")
player_options["gameOptions"] = game_options
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."
# Normal random is supported, but needs to be handled explicitly.
if option_value == "random":
player_options["presetOptions"][preset_name][option_name] = option_value
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}."
# 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
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
@@ -136,10 +169,20 @@ def create():
option["defaultValue"] = "random"
weighted_options["baseOptions"]["game"][game_name] = 0
weighted_options["games"][game_name] = {}
weighted_options["games"][game_name]["gameSettings"] = game_options
weighted_options["games"][game_name]["gameItems"] = tuple(world.item_names)
weighted_options["games"][game_name]["gameLocations"] = tuple(world.location_names)
weighted_options["games"][game_name] = {
"gameSettings": game_options,
"gameItems": tuple(world.item_names),
"gameItemGroups": [
group for group in world.item_name_groups.keys() if group != "Everything"
],
"gameItemDescriptions": world.item_descriptions,
"gameLocations": tuple(world.location_names),
"gameLocationGroups": [
group for group in world.location_name_groups.keys() if group != "Everywhere"
],
"gameLocationDescriptions": world.location_descriptions,
}
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
json.dump(weighted_options, f, indent=2, separators=(',', ': '))

View File

@@ -2,13 +2,62 @@
## What is a randomizer?
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
game, you might first find item C, then A, then B.
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
the items they encounter in new and interesting ways.
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
play. Putting items in non-standard locations can require the player to think about the game world and the items they
encounter in new and interesting ways.
## What is a multiworld?
While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
two player multiworld, players A and B each get their own randomized version of a game, called a world. In each
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring
players to rely upon each other to complete their game.
## What does multi-game mean?
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?
Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
found in the [Glossary](/glossary/en).
## Does everyone need to be connected at the same time?
There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?
@@ -17,53 +66,15 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
players to randomize any of a number of supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment.
## Can I generate a single-player game with Archipelago?
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
the website is not required to generate them.
## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
There you will find examples of games in the worlds folder
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
There, you will find examples of games in the `worlds` folder:
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
You may also find developer documentation in the docs folder
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -16,8 +16,9 @@ window.addEventListener('load', () => {
}
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.");
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);
}
@@ -36,6 +37,17 @@ window.addEventListener('load', () => {
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);
@@ -45,7 +57,8 @@ window.addEventListener('load', () => {
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`)
localStorage.removeItem(`${gameName}-hash`);
localStorage.removeItem(`${gameName}-preset`);
window.location.reload();
};
@@ -77,6 +90,10 @@ const createDefaultOptions = (optionData) => {
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};
const buildUI = (optionData) => {
@@ -84,8 +101,11 @@ const buildUI = (optionData) => {
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]; }
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));
@@ -120,7 +140,7 @@ const buildOptionsTable = (options, romOpts = false) => {
const randomButton = document.createElement('button');
switch(options[option].type){
switch(options[option].type) {
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
@@ -129,16 +149,17 @@ const buildOptionsTable = (options, romOpts = false) => {
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
options[option].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
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]))
{
option.selected = true;
optionElement.selected = true;
}
select.appendChild(option);
select.appendChild(optionElement);
});
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
@@ -162,6 +183,7 @@ const buildOptionsTable = (options, romOpts = false) => {
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);
@@ -194,74 +216,74 @@ const buildOptionsTable = (options, romOpts = false) => {
element.appendChild(randomButton);
break;
case 'special_range':
case 'named_range':
element = document.createElement('div');
element.classList.add('special-range-container');
element.classList.add('named-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', option);
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("_");
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(" ");
specialRangeSelect.appendChild(presetOption);
presetOption.innerText = words.join(' ');
namedRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
namedRangeSelect.appendChild(customOption);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
specialRangeSelect.value = Number(currentOptions[gameName][option]);
namedRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', option);
specialRange.setAttribute('min', options[option].min);
specialRange.setAttribute('max', options[option].max);
specialRange.value = currentOptions[gameName][option];
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 specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${option}-value`);
specialRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
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
specialRangeSelect.addEventListener('change', (event) => {
namedRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
namedRange.value = event.target.value;
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
namedRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
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(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
element.appendChild(namedRangeSelect);
namedRangeWrapper.appendChild(namedRange);
namedRangeWrapper.appendChild(namedRangeVal);
element.appendChild(namedRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
@@ -269,15 +291,15 @@ const buildOptionsTable = (options, romOpts = false) => {
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, specialRange, specialRangeSelect)
event, namedRange, namedRangeSelect)
);
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
namedRange.disabled = true;
namedRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
namedRangeWrapper.appendChild(randomButton);
break;
default:
@@ -294,6 +316,90 @@ const buildOptionsTable = (options, romOpts = false) => {
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 'special_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;
@@ -321,8 +427,15 @@ const updateBaseOption = (event) => {
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameOption = (optionElement) => {
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';
@@ -336,7 +449,21 @@ const updateGameOption = (optionElement) => {
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
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}:`);

View File

@@ -4,13 +4,20 @@ const adjustTableHeight = () => {
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
const tableWrappers = document.getElementsByClassName('table-wrapper');
for(let i=0; i < tableWrappers.length; i++){
const maxHeight = (window.innerHeight - upperDistance) / 2;
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
for (let i = 0; i < tableWrappers.length; i++) {
// Ensure we are starting from maximum size prior to calculation.
tableWrappers[i].style.height = null;
tableWrappers[i].style.maxHeight = null;
// Set as a reasonable height, but still allows the user to resize element if they desire.
const currentHeight = tableWrappers[i].offsetHeight;
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
if (currentHeight > maxHeight) {
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
}
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
}
};
@@ -55,7 +62,7 @@ window.addEventListener('load', () => {
render: function (data, type, row) {
if (type === "sort" || type === 'type') {
if (data === "None")
return -1;
return Number.MAX_VALUE;
return parseInt(data);
}

View File

@@ -43,7 +43,7 @@ const resetSettings = () => {
};
const fetchSettingData = () => new Promise((resolve, reject) => {
fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => {
fetch(new Request(`${window.location.origin}/static/generated/weighted-options.json`)).then((response) => {
try{ response.json().then((jsonObj) => resolve(jsonObj)); }
catch(error){ reject(error); }
});
@@ -93,9 +93,10 @@ class WeightedSettings {
});
break;
case 'range':
case 'special_range':
case 'named_range':
this.current[game][gameSetting]['random'] = 0;
this.current[game][gameSetting]['random-low'] = 0;
this.current[game][gameSetting]['random-middle'] = 0;
this.current[game][gameSetting]['random-high'] = 0;
if (setting.hasOwnProperty('defaultValue')) {
this.current[game][gameSetting][setting.defaultValue] = 25;
@@ -210,7 +211,11 @@ class WeightedSettings {
let errorMessage = null;
// User must choose a name for their file
if (!settings.name || settings.name.trim().length === 0 || settings.name.toLowerCase().trim() === 'player') {
if (
!settings.name ||
settings.name.toString().trim().length === 0 ||
settings.name.toString().toLowerCase().trim() === 'player'
) {
userMessage.innerText = 'You forgot to set your player name at the top of the page!';
userMessage.classList.add('visible');
userMessage.scrollIntoView({
@@ -256,7 +261,7 @@ class WeightedSettings {
// Remove empty arrays
else if (
['exclude_locations', 'priority_locations', 'local_items',
['exclude_locations', 'priority_locations', 'local_items',
'non_local_items', 'start_hints', 'start_location_hints'].includes(setting) &&
settings[game][setting].length === 0
) {
@@ -428,13 +433,13 @@ class GameSettings {
const weightedSettingsDiv = this.#buildWeightedSettingsDiv();
gameDiv.appendChild(weightedSettingsDiv);
const itemPoolDiv = this.#buildItemsDiv();
const itemPoolDiv = this.#buildItemPoolDiv();
gameDiv.appendChild(itemPoolDiv);
const hintsDiv = this.#buildHintsDiv();
gameDiv.appendChild(hintsDiv);
const locationsDiv = this.#buildLocationsDiv();
const locationsDiv = this.#buildPriorityExclusionDiv();
gameDiv.appendChild(locationsDiv);
collapseButton.addEventListener('click', () => {
@@ -518,178 +523,185 @@ class GameSettings {
break;
case 'range':
case 'special_range':
case 'named_range':
const rangeTable = document.createElement('table');
const rangeTbody = document.createElement('tbody');
if (((setting.max - setting.min) + 1) < 11) {
for (let i=setting.min; i <= setting.max; ++i) {
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = i;
tr.appendChild(tdLeft);
const hintText = document.createElement('p');
hintText.classList.add('hint-text');
hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
`below, then press the "Add" button to add a weight for it.<br /><br />Accepted values:<br />` +
`Normal range: ${setting.min} - ${setting.max}`;
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${this.name}-${settingName}-${i}-range`);
range.setAttribute('data-game', this.name);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', i);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
range.value = this.current[settingName][i] || 0;
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${this.name}-${settingName}-${i}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
rangeTbody.appendChild(tr);
}
} else {
const hintText = document.createElement('p');
hintText.classList.add('hint-text');
hintText.innerHTML = 'This is a range option. You may enter a valid numerical value in the text box ' +
`below, then press the "Add" button to add a weight for it.<br />Minimum value: ${setting.min}<br />` +
`Maximum value: ${setting.max}`;
if (setting.hasOwnProperty('value_names')) {
hintText.innerHTML += '<br /><br />Certain values have special meaning:';
Object.keys(setting.value_names).forEach((specialName) => {
const acceptedValuesOutsideRange = [];
if (setting.hasOwnProperty('value_names')) {
Object.keys(setting.value_names).forEach((specialName) => {
if (
(setting.value_names[specialName] < setting.min) ||
(setting.value_names[specialName] > setting.max)
) {
hintText.innerHTML += `<br />${specialName}: ${setting.value_names[specialName]}`;
});
}
settingWrapper.appendChild(hintText);
const addOptionDiv = document.createElement('div');
addOptionDiv.classList.add('add-option-div');
const optionInput = document.createElement('input');
optionInput.setAttribute('id', `${this.name}-${settingName}-option`);
optionInput.setAttribute('placeholder', `${setting.min} - ${setting.max}`);
addOptionDiv.appendChild(optionInput);
const addOptionButton = document.createElement('button');
addOptionButton.innerText = 'Add';
addOptionDiv.appendChild(addOptionButton);
settingWrapper.appendChild(addOptionDiv);
optionInput.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
acceptedValuesOutsideRange.push(setting.value_names[specialName]);
}
});
addOptionButton.addEventListener('click', () => {
const optionInput = document.getElementById(`${this.name}-${settingName}-option`);
let option = optionInput.value;
if (!option || !option.trim()) { return; }
option = parseInt(option, 10);
if ((option < setting.min) || (option > setting.max)) { return; }
optionInput.value = '';
if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
range.setAttribute('data-game', this.name);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
range.value = this.current[settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
tdDelete.classList.add('td-delete');
const deleteButton = document.createElement('span');
deleteButton.classList.add('range-option-delete');
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
range.dispatchEvent(new Event('change'));
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
// Save new option to settings
range.dispatchEvent(new Event('change'));
});
Object.keys(this.current[settingName]).forEach((option) => {
// These options are statically generated below, and should always appear even if they are deleted
// from localStorage
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
range.setAttribute('data-game', this.name);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
range.value = this.current[settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
tdDelete.classList.add('td-delete');
const deleteButton = document.createElement('span');
deleteButton.classList.add('range-option-delete');
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
const changeEvent = new Event('change');
changeEvent.action = 'rangeDelete';
range.dispatchEvent(changeEvent);
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
hintText.innerHTML += '<br /><br />Certain values have special meaning:';
Object.keys(setting.value_names).forEach((specialName) => {
hintText.innerHTML += `<br />${specialName}: ${setting.value_names[specialName]}`;
});
}
['random', 'random-low', 'random-high'].forEach((option) => {
settingWrapper.appendChild(hintText);
const addOptionDiv = document.createElement('div');
addOptionDiv.classList.add('add-option-div');
const optionInput = document.createElement('input');
optionInput.setAttribute('id', `${this.name}-${settingName}-option`);
let placeholderText = `${setting.min} - ${setting.max}`;
acceptedValuesOutsideRange.forEach((aVal) => placeholderText += `, ${aVal}`);
optionInput.setAttribute('placeholder', placeholderText);
addOptionDiv.appendChild(optionInput);
const addOptionButton = document.createElement('button');
addOptionButton.innerText = 'Add';
addOptionDiv.appendChild(addOptionButton);
settingWrapper.appendChild(addOptionDiv);
optionInput.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') { addOptionButton.dispatchEvent(new Event('click')); }
});
addOptionButton.addEventListener('click', () => {
const optionInput = document.getElementById(`${this.name}-${settingName}-option`);
let option = optionInput.value;
if (!option || !option.trim()) { return; }
option = parseInt(option, 10);
let optionAcceptable = false;
if ((option > setting.min) && (option < setting.max)) {
optionAcceptable = true;
}
if (setting.hasOwnProperty('value_names') && Object.values(setting.value_names).includes(option)){
optionAcceptable = true;
}
if (!optionAcceptable) { return; }
optionInput.value = '';
if (document.getElementById(`${this.name}-${settingName}-${option}-range`)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
if (
setting.hasOwnProperty('value_names') &&
Object.values(setting.value_names).includes(parseInt(option, 10))
) {
const optionName = Object.keys(setting.value_names).find(
(key) => setting.value_names[key] === parseInt(option, 10)
);
tdLeft.innerText += ` [${optionName}]`;
}
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
range.setAttribute('data-game', this.name);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
range.value = this.current[settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
tdDelete.classList.add('td-delete');
const deleteButton = document.createElement('span');
deleteButton.classList.add('range-option-delete');
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
range.dispatchEvent(new Event('change'));
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
// Save new option to settings
range.dispatchEvent(new Event('change'));
});
Object.keys(this.current[settingName]).forEach((option) => {
// These options are statically generated below, and should always appear even if they are deleted
// from localStorage
if (['random', 'random-low', 'random-middle', 'random-high'].includes(option)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
if (
setting.hasOwnProperty('value_names') &&
Object.values(setting.value_names).includes(parseInt(option, 10))
) {
const optionName = Object.keys(setting.value_names).find(
(key) => setting.value_names[key] === parseInt(option, 10)
);
tdLeft.innerText += ` [${optionName}]`;
}
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${this.name}-${settingName}-${option}-range`);
range.setAttribute('data-game', this.name);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', (evt) => this.#updateRangeSetting(evt));
range.value = this.current[settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${this.name}-${settingName}-${option}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdDelete = document.createElement('td');
tdDelete.classList.add('td-delete');
const deleteButton = document.createElement('span');
deleteButton.classList.add('range-option-delete');
deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => {
range.value = 0;
const changeEvent = new Event('change');
changeEvent.action = 'rangeDelete';
range.dispatchEvent(changeEvent);
rangeTbody.removeChild(tr);
});
tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete);
rangeTbody.appendChild(tr);
});
['random', 'random-low', 'random-middle', 'random-high'].forEach((option) => {
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
@@ -700,6 +712,9 @@ class GameSettings {
case 'random-low':
tdLeft.innerText = "Random (Low)";
break;
case 'random-middle':
tdLeft.innerText = 'Random (Middle)';
break;
case 'random-high':
tdLeft.innerText = "Random (High)";
break;
@@ -734,107 +749,17 @@ class GameSettings {
break;
case 'items-list':
const itemsList = document.createElement('div');
itemsList.classList.add('simple-list');
Object.values(this.data.gameItems).forEach((item) => {
const itemRow = document.createElement('div');
itemRow.classList.add('list-row');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${this.name}-${settingName}-${item}`)
const itemCheckbox = document.createElement('input');
itemCheckbox.setAttribute('id', `${this.name}-${settingName}-${item}`);
itemCheckbox.setAttribute('type', 'checkbox');
itemCheckbox.setAttribute('data-game', this.name);
itemCheckbox.setAttribute('data-setting', settingName);
itemCheckbox.setAttribute('data-option', item.toString());
itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
if (this.current[settingName].includes(item)) {
itemCheckbox.setAttribute('checked', '1');
}
const itemName = document.createElement('span');
itemName.innerText = item.toString();
itemLabel.appendChild(itemCheckbox);
itemLabel.appendChild(itemName);
itemRow.appendChild(itemLabel);
itemsList.appendChild((itemRow));
});
const itemsList = this.#buildItemsDiv(settingName);
settingWrapper.appendChild(itemsList);
break;
case 'locations-list':
const locationsList = document.createElement('div');
locationsList.classList.add('simple-list');
Object.values(this.data.gameLocations).forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${this.name}-${settingName}-${location}`)
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('id', `${this.name}-${settingName}-${location}`);
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('data-game', this.name);
locationCheckbox.setAttribute('data-setting', settingName);
locationCheckbox.setAttribute('data-option', location.toString());
locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
if (this.current[settingName].includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
const locationName = document.createElement('span');
locationName.innerText = location.toString();
locationLabel.appendChild(locationCheckbox);
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
locationsList.appendChild((locationRow));
});
const locationsList = this.#buildLocationsDiv(settingName);
settingWrapper.appendChild(locationsList);
break;
case 'custom-list':
const customList = document.createElement('div');
customList.classList.add('simple-list');
Object.values(this.data.gameSettings[settingName].options).forEach((listItem) => {
const customListRow = document.createElement('div');
customListRow.classList.add('list-row');
const customItemLabel = document.createElement('label');
customItemLabel.setAttribute('for', `${this.name}-${settingName}-${listItem}`)
const customItemCheckbox = document.createElement('input');
customItemCheckbox.setAttribute('id', `${this.name}-${settingName}-${listItem}`);
customItemCheckbox.setAttribute('type', 'checkbox');
customItemCheckbox.setAttribute('data-game', this.name);
customItemCheckbox.setAttribute('data-setting', settingName);
customItemCheckbox.setAttribute('data-option', listItem.toString());
customItemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
if (this.current[settingName].includes(listItem)) {
customItemCheckbox.setAttribute('checked', '1');
}
const customItemName = document.createElement('span');
customItemName.innerText = listItem.toString();
customItemLabel.appendChild(customItemCheckbox);
customItemLabel.appendChild(customItemName);
customListRow.appendChild(customItemLabel);
customList.appendChild((customListRow));
});
const customList = this.#buildListDiv(settingName, this.data.gameSettings[settingName].options);
settingWrapper.appendChild(customList);
break;
@@ -849,7 +774,7 @@ class GameSettings {
return settingsWrapper;
}
#buildItemsDiv() {
#buildItemPoolDiv() {
const itemsDiv = document.createElement('div');
itemsDiv.classList.add('items-div');
@@ -1058,35 +983,7 @@ class GameSettings {
itemHintsWrapper.classList.add('hints-wrapper');
itemHintsWrapper.innerText = 'Starting Item Hints';
const itemHintsDiv = document.createElement('div');
itemHintsDiv.classList.add('simple-list');
this.data.gameItems.forEach((item) => {
const itemRow = document.createElement('div');
itemRow.classList.add('list-row');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${this.name}-start_hints-${item}`);
const itemCheckbox = document.createElement('input');
itemCheckbox.setAttribute('type', 'checkbox');
itemCheckbox.setAttribute('id', `${this.name}-start_hints-${item}`);
itemCheckbox.setAttribute('data-game', this.name);
itemCheckbox.setAttribute('data-setting', 'start_hints');
itemCheckbox.setAttribute('data-option', item);
if (this.current.start_hints.includes(item)) {
itemCheckbox.setAttribute('checked', 'true');
}
itemCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
itemLabel.appendChild(itemCheckbox);
const itemName = document.createElement('span');
itemName.innerText = item;
itemLabel.appendChild(itemName);
itemRow.appendChild(itemLabel);
itemHintsDiv.appendChild(itemRow);
});
const itemHintsDiv = this.#buildItemsDiv('start_hints');
itemHintsWrapper.appendChild(itemHintsDiv);
itemHintsContainer.appendChild(itemHintsWrapper);
@@ -1095,35 +992,7 @@ class GameSettings {
locationHintsWrapper.classList.add('hints-wrapper');
locationHintsWrapper.innerText = 'Starting Location Hints';
const locationHintsDiv = document.createElement('div');
locationHintsDiv.classList.add('simple-list');
this.data.gameLocations.forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${this.name}-start_location_hints-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${this.name}-start_location_hints-${location}`);
locationCheckbox.setAttribute('data-game', this.name);
locationCheckbox.setAttribute('data-setting', 'start_location_hints');
locationCheckbox.setAttribute('data-option', location);
if (this.current.start_location_hints.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
locationHintsDiv.appendChild(locationRow);
});
const locationHintsDiv = this.#buildLocationsDiv('start_location_hints');
locationHintsWrapper.appendChild(locationHintsDiv);
itemHintsContainer.appendChild(locationHintsWrapper);
@@ -1131,7 +1000,7 @@ class GameSettings {
return hintsDiv;
}
#buildLocationsDiv() {
#buildPriorityExclusionDiv() {
const locationsDiv = document.createElement('div');
locationsDiv.classList.add('locations-div');
const locationsHeader = document.createElement('h3');
@@ -1151,35 +1020,7 @@ class GameSettings {
priorityLocationsWrapper.classList.add('locations-wrapper');
priorityLocationsWrapper.innerText = 'Priority Locations';
const priorityLocationsDiv = document.createElement('div');
priorityLocationsDiv.classList.add('simple-list');
this.data.gameLocations.forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${this.name}-priority_locations-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${this.name}-priority_locations-${location}`);
locationCheckbox.setAttribute('data-game', this.name);
locationCheckbox.setAttribute('data-setting', 'priority_locations');
locationCheckbox.setAttribute('data-option', location);
if (this.current.priority_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
priorityLocationsDiv.appendChild(locationRow);
});
const priorityLocationsDiv = this.#buildLocationsDiv('priority_locations');
priorityLocationsWrapper.appendChild(priorityLocationsDiv);
locationsContainer.appendChild(priorityLocationsWrapper);
@@ -1188,35 +1029,7 @@ class GameSettings {
excludeLocationsWrapper.classList.add('locations-wrapper');
excludeLocationsWrapper.innerText = 'Exclude Locations';
const excludeLocationsDiv = document.createElement('div');
excludeLocationsDiv.classList.add('simple-list');
this.data.gameLocations.forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${this.name}-exclude_locations-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${this.name}-exclude_locations-${location}`);
locationCheckbox.setAttribute('data-game', this.name);
locationCheckbox.setAttribute('data-setting', 'exclude_locations');
locationCheckbox.setAttribute('data-option', location);
if (this.current.exclude_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
excludeLocationsDiv.appendChild(locationRow);
});
const excludeLocationsDiv = this.#buildLocationsDiv('exclude_locations');
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
locationsContainer.appendChild(excludeLocationsWrapper);
@@ -1224,6 +1037,99 @@ class GameSettings {
return locationsDiv;
}
// Builds a div for a setting whose value is a list of locations.
#buildLocationsDiv(setting) {
return this.#buildListDiv(setting, this.data.gameLocations, {
groups: this.data.gameLocationGroups,
descriptions: this.data.gameLocationDescriptions,
});
}
// Builds a div for a setting whose value is a list of items.
#buildItemsDiv(setting) {
return this.#buildListDiv(setting, this.data.gameItems, {
groups: this.data.gameItemGroups,
descriptions: this.data.gameItemDescriptions
});
}
// Builds a div for a setting named `setting` with a list value that can
// contain `items`.
//
// The `groups` option can be a list of additional options for this list
// (usually `item_name_groups` or `location_name_groups`) that are displayed
// in a special section at the top of the list.
//
// The `descriptions` option can be a map from item names or group names to
// descriptions for the user's benefit.
#buildListDiv(setting, items, {groups = [], descriptions = {}} = {}) {
const div = document.createElement('div');
div.classList.add('simple-list');
groups.forEach((group) => {
const row = this.#addListRow(setting, group, descriptions[group]);
div.appendChild(row);
});
if (groups.length > 0) {
div.appendChild(document.createElement('hr'));
}
items.forEach((item) => {
const row = this.#addListRow(setting, item, descriptions[item]);
div.appendChild(row);
});
return div;
}
// Builds and returns a row for a list of checkboxes.
//
// If `help` is passed, it's displayed as a help tooltip for this list item.
#addListRow(setting, item, help = undefined) {
const row = document.createElement('div');
row.classList.add('list-row');
const label = document.createElement('label');
label.setAttribute('for', `${this.name}-${setting}-${item}`);
const checkbox = document.createElement('input');
checkbox.setAttribute('type', 'checkbox');
checkbox.setAttribute('id', `${this.name}-${setting}-${item}`);
checkbox.setAttribute('data-game', this.name);
checkbox.setAttribute('data-setting', setting);
checkbox.setAttribute('data-option', item);
if (this.current[setting].includes(item)) {
checkbox.setAttribute('checked', '1');
}
checkbox.addEventListener('change', (evt) => this.#updateListSetting(evt));
label.appendChild(checkbox);
const name = document.createElement('span');
name.innerText = item;
if (help) {
const helpSpan = document.createElement('span');
helpSpan.classList.add('interactive');
helpSpan.setAttribute('data-tooltip', help);
helpSpan.innerText = '(?)';
name.innerText += ' ';
name.appendChild(helpSpan);
// Put the first 7 tooltips below their rows. CSS tooltips in scrolling
// containers can't be visible outside those containers, so this helps
// ensure they won't be pushed out the top.
if (helpSpan.parentNode.childNodes.length < 7) {
helpSpan.classList.add('tooltip-bottom');
}
}
label.appendChild(name);
row.appendChild(label);
return row;
}
#updateRangeSetting(evt) {
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');

View File

@@ -90,6 +90,31 @@ html{
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;
}
@@ -135,18 +160,18 @@ html{
margin-left: 0.25rem;
}
#player-options table .special-range-container{
#player-options table .named-range-container{
display: flex;
flex-direction: column;
}
#player-options table .special-range-wrapper{
#player-options table .named-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-options table .special-range-wrapper input[type=range]{
#player-options table .named-range-wrapper input[type=range]{
flex-grow: 1;
}
@@ -188,6 +213,12 @@ html{
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;

View File

@@ -7,138 +7,55 @@
width: calc(100% - 1rem);
}
#tracker-wrapper a{
#tracker-wrapper a {
color: #234ae4;
text-decoration: none;
cursor: pointer;
}
.table-wrapper{
overflow-y: auto;
overflow-x: auto;
margin-bottom: 1rem;
}
#tracker-header-bar{
#tracker-header-bar {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-content: center;
line-height: 20px;
gap: 0.5rem;
margin-bottom: 1rem;
}
#tracker-header-bar .info{
#tracker-header-bar .info {
color: #ffffff;
}
#search{
border: 1px solid #000000;
border-radius: 3px;
padding: 3px;
width: 200px;
margin-bottom: 0.5rem;
margin-right: 1rem;
}
#multi-stream-link{
margin-right: 1rem;
}
div.dataTables_wrapper.no-footer .dataTables_scrollBody{
border: none;
}
table.dataTable{
color: #000000;
}
table.dataTable thead{
font-family: LexendDeca-Regular, sans-serif;
}
table.dataTable tbody, table.dataTable tfoot{
background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif;
}
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{
background-color: #e2eabb;
}
table.dataTable tbody td, table.dataTable tfoot td{
padding: 4px 6px;
}
table.dataTable, table.dataTable.no-footer{
border-left: 1px solid #bba967;
width: calc(100% - 2px) !important;
font-size: 1rem;
}
table.dataTable thead th{
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
top: 0;
}
table.dataTable thead th.upper-row{
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 36px;
top: 0;
}
table.dataTable thead th.lower-row{
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 22px;
top: 46px;
}
table.dataTable tbody td, table.dataTable tfoot td{
border: 1px solid #bba967;
}
table.dataTable tfoot td{
font-weight: bold;
}
div.dataTables_scrollBody{
background-color: inherit !important;
}
table.dataTable .center-column{
text-align: center;
}
img.alttp-sprite {
height: auto;
max-height: 32px;
min-height: 14px;
}
.item-acquired{
background-color: #d3c97d;
padding: 2px;
flex-grow: 1;
align-self: center;
text-align: justify;
}
#tracker-navigation {
display: inline-flex;
display: flex;
flex-wrap: wrap;
margin: 0 0.5rem 0.5rem 0.5rem;
user-select: none;
height: 2rem;
}
.tracker-navigation-bar {
display: flex;
background-color: #b0a77d;
margin: 0.5rem;
border-radius: 4px;
}
.tracker-navigation-button {
display: block;
display: flex;
justify-content: center;
align-items: center;
margin: 4px;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
text-align: center;
font-size: 14px;
color: #000;
color: black !important;
font-weight: lighter;
}
@@ -150,6 +67,100 @@ img.alttp-sprite {
background-color: rgb(220, 226, 189);
}
.table-wrapper {
overflow-y: auto;
overflow-x: auto;
margin-bottom: 1rem;
resize: vertical;
}
#search {
border: 1px solid #000000;
border-radius: 3px;
padding: 3px;
width: 200px;
}
div.dataTables_wrapper.no-footer .dataTables_scrollBody {
border: none;
}
table.dataTable {
color: #000000;
}
table.dataTable thead {
font-family: LexendDeca-Regular, sans-serif;
}
table.dataTable tbody, table.dataTable tfoot {
background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif;
}
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover {
background-color: #e2eabb;
}
table.dataTable tbody td, table.dataTable tfoot td {
padding: 4px 6px;
}
table.dataTable, table.dataTable.no-footer {
border-left: 1px solid #bba967;
width: calc(100% - 2px) !important;
font-size: 1rem;
}
table.dataTable thead th {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
top: 0;
}
table.dataTable thead th.upper-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 36px;
top: 0;
}
table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 22px;
top: 46px;
}
table.dataTable tbody td, table.dataTable tfoot td {
border: 1px solid #bba967;
}
table.dataTable tfoot td {
font-weight: bold;
}
div.dataTables_scrollBody {
background-color: inherit !important;
}
table.dataTable .center-column {
text-align: center;
}
img.icon-sprite {
height: auto;
max-height: 32px;
min-height: 14px;
}
.item-acquired {
background-color: #d3c97d;
}
@media all and (max-width: 1700px) {
table.dataTable thead th.upper-row{
position: -webkit-sticky;
@@ -159,7 +170,7 @@ img.alttp-sprite {
top: 0;
}
table.dataTable thead th.lower-row{
table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -167,11 +178,11 @@ img.alttp-sprite {
top: 37px;
}
table.dataTable, table.dataTable.no-footer{
table.dataTable, table.dataTable.no-footer {
font-size: 0.8rem;
}
img.alttp-sprite {
img.icon-sprite {
height: auto;
max-height: 24px;
min-height: 10px;
@@ -187,7 +198,7 @@ img.alttp-sprite {
top: 0;
}
table.dataTable thead th.lower-row{
table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -195,11 +206,11 @@ img.alttp-sprite {
top: 32px;
}
table.dataTable, table.dataTable.no-footer{
table.dataTable, table.dataTable.no-footer {
font-size: 0.6rem;
}
img.alttp-sprite {
img.icon-sprite {
height: auto;
max-height: 20px;
min-height: 10px;

View File

@@ -292,6 +292,12 @@ html{
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;
}

View File

@@ -1,36 +1,57 @@
{% extends 'tablepage.html' %}
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span>
{% include "header/dirtHeader.html" %}
<div id="tracker-navigation">
<div class="tracker-navigation-bar">
<a
class="tracker-navigation-button"
href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}"
>
🡸 Return to Multiworld Tracker
</a>
{% if game_specific_tracker %}
<a
class="tracker-navigation-button"
href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}"
>
Game-Specific Tracker
</a>
{% endif %}
</div>
</div>
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />
<div class="info">This tracker will automatically update itself periodically.</div>
</div>
<div id="tables-container">
<div class="table-wrapper">
<table id="received-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
<th>Order Received</th>
<th>Last Order Received</th>
</tr>
</thead>
<tbody>
{% for id, count in inventory.items() %}
<tr>
<td>{{ id | item_name }}</td>
<td>{{ count }}</td>
<td>{{received_items[id]}}</td>
</tr>
{% for id, count in inventory.items() if count > 0 %}
<tr>
<td>{{ item_id_to_name[game][id] }}</td>
<td>{{ count }}</td>
<td>{{ received_items[id] }}</td>
</tr>
{%- endfor -%}
</tbody>
@@ -39,24 +60,62 @@
<div class="table-wrapper">
<table id="locations-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Location</th>
<th>Checked</th>
</tr>
<tr>
<th>Location</th>
<th class="center-column">Checked</th>
</tr>
</thead>
<tbody>
{% for name in checked_locations %}
{%- for location in locations -%}
<tr>
<td>{{ name | location_name}}</td>
<td></td>
<td>{{ location_id_to_name[game][location] }}</td>
<td class="center-column">
{% if location in checked_locations %}✔{% endif %}
</td>
</tr>
{%- endfor -%}
{% for name in not_checked_locations %}
{%- endfor -%}
</tbody>
</table>
</div>
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table">
<thead>
<tr>
<td>{{ name | location_name}}</td>
<td></td>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Game</th>
<th>Entrance</th>
<th class="center-column">Found</th>
</tr>
{%- endfor -%}
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}
{% endif %}
</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
<td>{{ games[(team, hint.finding_player)] }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td class="center-column">{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>

View File

@@ -1,28 +0,0 @@
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Entrance</th>
<th>Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>{{ long_player_names[team, hint.finding_player] }}</td>
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
<td>{{ hint.item|item_name }}</td>
<td>{{ hint.location|location_name }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td>{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}

View File

@@ -1,171 +0,0 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>ALttP Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in inventory.items() %}
<div class="table-wrapper">
<table id="inventory-table" class="table unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
{%- for name in tracking_names -%}
{%- if name in icons -%}
<th class="center-column">
<img class="alttp-sprite" src="{{ icons[name] }}" alt="{{ name|e }}">
</th>
{%- else -%}
<th class="center-column">{{ name|e }}</th>
{%- endif -%}
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for player, items in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
{%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%}
<td>
<a target="_blank" href="https://www.twitch.tv/{{ video[(team, loop.index)][1] }}">
{{ player_names[(team, loop.index)] }}
▶️</a></td>
{%- elif video[(team, loop.index)][0] == "Youtube" -%}
<td>
<a target="_blank" href="youtube.com/c/{{ video[(team, loop.index)][1] }}/live">
{{ player_names[(team, loop.index)] }}
▶️</a></td>
{%- endif -%}
{%- else -%}
<td>{{ player_names[(team, loop.index)] }}</td>
{%- endif -%}
{%- for id in tracking_ids -%}
{%- if items[id] -%}
<td class="center-column item-acquired">
{% if id in multi_items %}{{ items[id] }}{% else %}✔️{% endif %}</td>
{%- else -%}
<td></td>
{%- endif -%}
{% endfor %}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">Name</th>
{% for area in ordered_areas %}
{% set colspan = 1 %}
{% if area in key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in big_key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in icons %}
<th colspan="{{ colspan }}" class="center-column upper-row">
<img class="alttp-sprite" src="{{ icons[area] }}" alt="{{ area }}"></th>
{%- else -%}
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
{%- endif -%}
{%- endfor -%}
<th rowspan="2" class="center-column">&percnt;</th>
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
</tr>
<tr>
{% for area in ordered_areas %}
<th class="center-column lower-row fraction">
<img class="alttp-sprite" src="{{ icons["Chest"] }}" alt="Checks">
</th>
{% if area in key_locations %}
<th class="center-column lower-row number">
<img class="alttp-sprite" src="{{ icons["Small Key"] }}" alt="Small Key">
</th>
{% endif %}
{% if area in big_key_locations %}
<th class="center-column lower-row number">
<img class="alttp-sprite" src="{{ icons["Big Key"] }}" alt="Big Key">
</th>
{%- endif -%}
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%}
{% if player in checks_in_area and area in checks_in_area[player] %}
{%- set checks_done = checks[area] -%}
{%- set checks_total = checks_in_area[player][area] -%}
{%- if checks_done == checks_total -%}
<td class="item-acquired center-column">
{{ checks_done }}/{{ checks_total }}</td>
{%- else -%}
<td class="center-column">{{ checks_done }}/{{ checks_total }}</td>
{%- endif -%}
{%- if area in key_locations -%}
<td class="center-column">{{ inventory[team][player][small_key_ids[area]] }}</td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column">{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
{%- endif -%}
{% else %}
<td class="center-column"></td>
{%- if area in key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{% endif %}
{%- endfor -%}
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% include "hintTable.html" with context %}
</div>
</div>
{% endblock %}

View File

@@ -1,86 +0,0 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Game</th>
<th>Status</th>
{% block custom_table_headers %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column hours last-activity">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
<td>{{ games[player] }}</td>
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
{% block custom_table_row scoped %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<td class="center-column" data-sort="{{ checks["Total"] }}">
{{ checks["Total"] }}/{{ locations[player] | length }}
</td>
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[team, player] -%}
<td class="center-column">{{ activity_timers[team, player].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
{% if not self.custom_table_headers() | trim %}
<tfoot>
<tr>
<td></td>
<td>Total</td>
<td>All Games</td>
<td>{{ completed_worlds }}/{{ players|length }} Complete</td>
<td class="center-column">{{ players.values()|sum(attribute='Total') }}/{{ total_locations[team] }}</td>
<td class="center-column">{{ (players.values()|sum(attribute='Total') / total_locations[team] * 100) | int }}</td>
<td class="center-column last-activity"></td>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% endfor %}
{% include "hintTable.html" with context %}
</div>
</div>
{% endblock %}

View File

@@ -1,9 +0,0 @@
{%- if enabled_multiworld_trackers|length > 1 -%}
<div id="tracker-navigation">
{% for enabled_tracker in enabled_multiworld_trackers %}
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %}
<a class="tracker-navigation-button{% if enabled_tracker.current %} selected{% endif %}"
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
{% endfor %}
</div>
{%- endif -%}

View File

@@ -0,0 +1,144 @@
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include "header/dirtHeader.html" %}
{% include "multitrackerNavigation.html" %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />
<div
id="multi-stream-link"
class="tracker-navigation-bar"
{% if not videos %}style="display: none"{% endif %}
>
<a
class="tracker-navigation-button"
href="https://multistream.me/
{%- for platform, link in videos.values() | unique(False, 1) -%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}"
target="_blank"
>
► Multistream
</a>
</div>
<div class="info">
Clicking on a slot&apos;s number will bring up the slot-specific tracker.
This tracker will automatically update itself periodically.
</div>
</div>
<div id="tables-container">
{%- for team, players in room_players.items() -%}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
{% if current_tracker == "Generic" %}<th>Game</th>{% endif %}
<th>Status</th>
{% block custom_table_headers %}
{# Implement this block in game-specific multi-trackers. #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column hours last-activity">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player in players -%}
{%- if current_tracker == "Generic" or games[(team, player)] == current_tracker -%}
<tr>
<td>
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">
{{ player }}
</a>
</td>
<td>{{ player_names_with_alias[(team, player)] | e }}</td>
{%- if current_tracker == "Generic" -%}
<td>{{ games[(team, player)] }}</td>
{%- endif -%}
<td>
{{
{
0: "Disconnected",
5: "Connected",
10: "Ready",
20: "Playing",
30: "Goal Completed"
}.get(states[(team, player)], "Unknown State")
}}
</td>
{% block custom_table_row scoped %}
{# Implement this block in game-specific multi-trackers. #}
{% endblock %}
{% set location_count = locations[(team, player)] | length %}
<td class="center-column" data-sort="{{ locations_complete[(team, player)] }}">
{{ locations_complete[(team, player)] }}/{{ location_count }}
</td>
<td class="center-column">
{%- if locations[(team, player)] | length > 0 -%}
{% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %}
{{ "{0:.2f}".format(percentage_of_completion) }}
{%- else -%}
100.00
{%- endif -%}
</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endif -%}
{%- endfor -%}
</tbody>
{%- if not self.custom_table_headers() | trim -%}
<tfoot>
<tr>
<td colspan="2" style="text-align: right">Total</td>
<td>All Games</td>
<td>{{ completed_worlds[team] }}/{{ players | length }} Complete</td>
<td class="center-column">
{{ total_team_locations_complete[team] }}/{{ total_team_locations[team] }}
</td>
<td class="center-column">
{%- if total_team_locations[team] == 0 -%}
100
{%- else -%}
{{ "{0:.2f}".format(total_team_locations_complete[team] / total_team_locations[team] * 100) }}
{%- endif -%}
</td>
<td class="center-column last-activity"></td>
</tr>
</tfoot>
{%- endif -%}
</table>
</div>
{%- endfor -%}
{% block custom_tables %}
{# Implement this block to create custom tables in game-specific multi-trackers. #}
{% endblock %}
{% include "multitrackerHintTable.html" with context %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Game</th>
<th>Entrance</th>
<th class="center-column">Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
{%-
if current_tracker == "Generic" or (
games[(team, hint.finding_player)] == current_tracker or
games[(team, hint.receiving_player)] == current_tracker
)
-%}
<tr>
<td>{{ player_names_with_alias[(team, hint.finding_player)] }}</td>
<td>{{ player_names_with_alias[(team, hint.receiving_player)] }}</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
<td>{{ games[(team, hint.finding_player)] }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td class="center-column">{% if hint.found %}✔{% endif %}</td>
</tr>
{% endif %}
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}

View File

@@ -0,0 +1,16 @@
{% if enabled_trackers | length > 1 %}
<div id="tracker-navigation">
{# Multitracker game navigation. #}
<div class="tracker-navigation-bar">
{%- for game_tracker in enabled_trackers -%}
{%- set tracker_url = url_for("get_multiworld_tracker", tracker=room.tracker, game=game_tracker) -%}
<a
class="tracker-navigation-button{% if current_tracker == game_tracker %} selected{% endif %}"
href="{{ tracker_url }}"
>
{{ game_tracker }}
</a>
{%- endfor -%}
</div>
</div>
{% endif %}

View File

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

View File

@@ -1,4 +1,4 @@
{% extends "multiTracker.html" %}
{% extends "multitracker.html" %}
{# establish the to be tracked data. Display Name, factorio/AP internal name, display image #}
{%- set science_packs = [
("Logistic Science Pack", "logistic-science-pack",
@@ -14,12 +14,12 @@
("Space Science Pack", "space-science-pack",
"https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"),
] -%}
{%- block custom_table_headers %}
{#- macro that creates a table header with display name and image -#}
{%- macro make_header(name, img_src) %}
<th class="center-column">
<img src="{{ img_src}}"
alt="{{ name }}">
<img class="icon-sprite" src="{{ img_src }}" alt="{{ name }}" title="{{ name }}" />
</th>
{% endmacro -%}
{#- call the macro to build the table header -#}
@@ -27,16 +27,15 @@
{{ make_header(name, img_src) }}
{% endfor -%}
{% endblock %}
{% block custom_table_row scoped %}
{% if games[player] == "Factorio" %}
{%- set player_inventory = named_inventory[team][player] -%}
{%- set player_inventory = inventories[(team, player)] -%}
{%- set prog_science = player_inventory["progressive-science-pack"] -%}
{%- for name, internal_name, img_src in science_packs %}
<td class="center-column">{% if player_inventory[internal_name] or prog_science > loop.index0 %}✔{% endif %}</td>
{% if player_inventory[internal_name] or prog_science > loop.index0 %}
<td class="center-column item-acquired">✔️</td>
{% else %}
<td class="center-column"></td>
{% endif %}
{% endfor -%}
{% else %}
{%- for _ in science_packs %}
<td class="center-column"></td>
{% endfor -%}
{% endif %}
{% endblock%}

View File

@@ -28,10 +28,24 @@
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
</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="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">

View File

@@ -0,0 +1,154 @@
{%- set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png",
"Fighter Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920",
"Master Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920",
"Tempered Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920",
"Golden Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920",
"Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c",
"Silver Bow": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920",
"Green Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920",
"Blue Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920",
"Red Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920",
"Power Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920",
"Titan Mitts": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Progressive Sword": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
"Progressive Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Flippers": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
"Progressive Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
"Blue Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
"Red Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
"Magic Powder": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png?version=c24e38effbd4f80496d35830ce8ff4ec",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
"Bottle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
"Triforce Piece": "https://www.zeldadungeon.net/wiki/images/thumb/5/54/Triforce_Fragment_-_BS_Zelda.png/62px-Triforce_Fragment_-_BS_Zelda.png",
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
"Chest": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda",
"Light World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6",
"Dark World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc",
"Hyrule Castle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d3/ALttP_Ball_and_Chain_Trooper_Sprite.png?version=1768a87c06d29cc8e7ddd80b9fa516be",
"Agahnims Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1e/ALttP_Agahnim_Sprite.png?version=365956e61b0c2191eae4eddbe591dab5",
"Desert Palace": "https://www.zeldadungeon.net/wiki/images/2/25/Lanmola-ALTTP-Sprite.png",
"Eastern Palace": "https://www.zeldadungeon.net/wiki/images/d/dc/RedArmosKnight.png",
"Tower of Hera": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/ALttP_Moldorm_Sprite.png?version=c588257bdc2543468e008a6b30f262a7",
"Palace of Darkness": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Helmasaur_King_Sprite.png?version=ab8a4a1cfd91d4fc43466c56cba30022",
"Swamp Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Arrghus_Sprite.png?version=b098be3122e53f751b74f4a5ef9184b5",
"Skull Woods": "https://alttp-wiki.net/images/6/6a/Mothula.png",
"Thieves Town": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/86/ALttP_Blind_the_Thief_Sprite.png?version=3833021bfcd112be54e7390679047222",
"Ice Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Kholdstare_Sprite.png?version=e5a1b0e8b2298e550d85f90bf97045c0",
"Misery Mire": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8",
"Turtle Rock": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be",
"Ganons Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74",
} -%}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/lttp-tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ icons[bow_icon] }}" class="{{ 'acquired' if bow_acquired }}" /></td>
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ icons[glove_icon] }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons[sword_icon] }}" class="{{ 'acquired' if sword_acquired }}" /></td>
<td><img src="{{ icons[shield_icon] }}" class="{{ 'acquired' if shield_acquired }}" /></td>
<td><img src="{{ icons[mail_icon] }}" class="acquired" /></td>
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
</tr>
</table>
<table id="location-table">
<tr>
<th></th>
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
{% if key_locations and "Universal" not in key_locations %}
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
{% endif %}
{% if big_key_locations %}
<th><img src="{{ icons["Big Key"] }}" /></th>
{% endif %}
</tr>
{% for area in sp_areas %}
<tr>
<td>{{ area }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
{% if key_locations and "Universal" not in key_locations %}
<td class="counter">
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
</td>
{% endif %}
{% if big_key_locations %}
<td>
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -7,6 +7,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr class="column-headers">

View File

@@ -8,13 +8,18 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/ootTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/ootTracker.js') }}"></script>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ ocarina_url }}" class="{{ 'acquired' if 'Ocarina' in acquired_items }}" title="Ocarina" /></td>
<td><img src="{{ icons['Bombs'] }}" class="{{ 'acquired' if 'Bomb Bag' in acquired_items }}" title="Bombs" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Bow' in acquired_items }}" title="Fairy Bow" /></td>
<td><img src="{{ icons['Fire Arrows'] }}" class="{{ 'acquired' if 'Fire Arrows' in acquired_items }}" title="Fire Arrows" /></td>
<td><img src="{{ icons['Kokiri Sword'] }}" class="{{ 'acquired' if 'Kokiri Sword' in acquired_items }}" title="Kokiri Sword" /></td>
<td><img src="{{ icons['Biggoron Sword'] }}" class="{{ 'acquired' if 'Biggoron Sword' in acquired_items }}" title="Biggoron's Sword" /></td>
<td><img src="{{ icons['Mirror Shield'] }}" class="{{ 'acquired' if 'Mirror Shield' in acquired_items }}" title="Mirror Shield" /></td>
</tr>
<tr>
<td><img src="{{ icons['Slingshot'] }}" class="{{ 'acquired' if 'Slingshot' in acquired_items }}" title="Slingshot" /></td>
<td><img src="{{ icons['Bombchus'] }}" class="{{ 'acquired' if has_bombchus }}" title="Bombchus" /></td>
<td>
<div class="counted-item">
<img src="{{ hookshot_url }}" class="{{ 'acquired' if 'Progressive Hookshot' in acquired_items }}" title="Progressive Hookshot" />
<div class="item-count">{{ hookshot_length }}</div>
</div>
</td>
<td><img src="{{ icons['Ice Arrows'] }}" class="{{ 'acquired' if 'Ice Arrows' in acquired_items }}" title="Ice Arrows" /></td>
<td><img src="{{ strength_upgrade_url }}" class="{{ 'acquired' if 'Progressive Strength Upgrade' in acquired_items }}" title="Progressive Strength Upgrade" /></td>
<td><img src="{{ icons['Goron Tunic'] }}" class="{{ 'acquired' if 'Goron Tunic' in acquired_items }}" title="Goron Tunic" /></td>
<td><img src="{{ icons['Zora Tunic'] }}" class="{{ 'acquired' if 'Zora Tunic' in acquired_items }}" title="Zora Tunic" /></td>
</tr>
<tr>
<td><img src="{{ icons['Boomerang'] }}" class="{{ 'acquired' if 'Boomerang' in acquired_items }}" title="Boomerang" /></td>
<td><img src="{{ icons['Lens of Truth'] }}" class="{{ 'acquired' if 'Lens of Truth' in acquired_items }}" title="Lens of Truth" /></td>
<td><img src="{{ icons['Megaton Hammer'] }}" class="{{ 'acquired' if 'Megaton Hammer' in acquired_items }}" title="Megaton Hammer" /></td>
<td><img src="{{ icons['Light Arrows'] }}" class="{{ 'acquired' if 'Light Arrows' in acquired_items }}" title="Light Arrows" /></td>
<td><img src="{{ scale_url }}" class="{{ 'acquired' if 'Progressive Scale' in acquired_items }}" title="Progressive Scale" /></td>
<td><img src="{{ icons['Iron Boots'] }}" class="{{ 'acquired' if 'Iron Boots' in acquired_items }}" title="Iron Boots" /></td>
<td><img src="{{ icons['Hover Boots'] }}" class="{{ 'acquired' if 'Hover Boots' in acquired_items }}" title="Hover Boots" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ bottle_url }}" class="{{ 'acquired' if bottle_count > 0 }}" title="Bottles" />
<div class="item-count">{{ bottle_count if bottle_count > 0 else '' }}</div>
</div>
</td>
<td><img src="{{ icons['Dins Fire'] }}" class="{{ 'acquired' if 'Dins Fire' in acquired_items }}" title="Din's Fire" /></td>
<td><img src="{{ icons['Farores Wind'] }}" class="{{ 'acquired' if 'Farores Wind' in acquired_items }}" title="Farore's Wind" /></td>
<td><img src="{{ icons['Nayrus Love'] }}" class="{{ 'acquired' if 'Nayrus Love' in acquired_items }}" title="Nayru's Love" /></td>
<td>
<div class="counted-item">
<img src="{{ wallet_url }}" class="{{ 'acquired' if 'Progressive Wallet' in acquired_items }}" title="Progressive Wallet" />
<div class="item-count">{{ wallet_size }}</div>
</div>
</td>
<td><img src="{{ magic_meter_url }}" class="{{ 'acquired' if 'Magic Meter' in acquired_items }}" title="Magic Meter" /></td>
<td><img src="{{ icons['Gerudo Membership Card'] }}" class="{{ 'acquired' if 'Gerudo Membership Card' in acquired_items }}" title="Gerudo Membership Card" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Zeldas Lullaby'] }}" class="{{ 'acquired' if 'Zeldas Lullaby' in acquired_items }}" title="Zelda's Lullaby" id="lullaby"/>
<div class="item-count">Zelda</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Eponas Song'] }}" class="{{ 'acquired' if 'Eponas Song' in acquired_items }}" title="Epona's Song" id="epona" />
<div class="item-count">Epona</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Sarias Song'] }}" class="{{ 'acquired' if 'Sarias Song' in acquired_items }}" title="Saria's Song" id="saria"/>
<div class="item-count">Saria</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Suns Song'] }}" class="{{ 'acquired' if 'Suns Song' in acquired_items }}" title="Sun's Song" id="sun"/>
<div class="item-count">Sun</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Time'] }}" class="{{ 'acquired' if 'Song of Time' in acquired_items }}" title="Song of Time" id="time"/>
<div class="item-count">Time</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Storms'] }}" class="{{ 'acquired' if 'Song of Storms' in acquired_items }}" title="Song of Storms" />
<div class="item-count">Storms</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Gold Skulltula Token'] }}" class="{{ 'acquired' if token_count > 0 }}" title="Gold Skulltula Tokens" />
<div class="item-count">{{ token_count }}</div>
</div>
</td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Minuet of Forest'] }}" class="{{ 'acquired' if 'Minuet of Forest' in acquired_items }}" title="Minuet of Forest" />
<div class="item-count">Min</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Bolero of Fire'] }}" class="{{ 'acquired' if 'Bolero of Fire' in acquired_items }}" title="Bolero of Fire" />
<div class="item-count">Bol</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Serenade of Water'] }}" class="{{ 'acquired' if 'Serenade of Water' in acquired_items }}" title="Serenade of Water" />
<div class="item-count">Ser</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Requiem of Spirit'] }}" class="{{ 'acquired' if 'Requiem of Spirit' in acquired_items }}" title="Requiem of Spirit" />
<div class="item-count">Req</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Nocturne of Shadow'] }}" class="{{ 'acquired' if 'Nocturne of Shadow' in acquired_items }}" title="Nocturne of Shadow" />
<div class="item-count">Noc</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Prelude of Light'] }}" class="{{ 'acquired' if 'Prelude of Light' in acquired_items }}" title="Prelude of Light" />
<div class="item-count">Pre</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Triforce'] if game_finished else icons['Triforce Piece'] }}" class="{{ 'acquired' if game_finished or piece_count > 0 }}" title="{{ 'Triforce' if game_finished else 'Triforce Pieces' }}" id=triforce />
<div class="item-count">{{ piece_count if piece_count > 0 else '' }}</div>
</div>
</td>
</tr>
</table>
<table id="location-table">
<tr>
<td></td>
<td><img src="{{ icons['Small Key'] }}" title="Small Keys" /></td>
<td><img src="{{ icons['Boss Key'] }}" title="Boss Key" /></td>
<td class="right-align">Items</td>
</tr>
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="smallkeys">{{ small_key_counts.get(area, '-') }}</td>
<td class="bosskeys">{{ boss_key_counts.get(area, '-') }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td></td>
<td></td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -8,6 +8,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>

View File

@@ -7,6 +7,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>

View File

@@ -7,6 +7,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="inventory-table">
<div class="table-row">
@@ -51,16 +56,16 @@
<div class="C4"><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></div>
{% if 'DownloadableItems' in options %}
<div class="C5"><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></div>
{% endif %}
{% endif %}
</div>
<div class="table-row">
{% if 'DownloadableItems' in options %}
<div class="C1"><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></div>
{% endif %}
{% endif %}
<div class="C2"><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></div>
{% if 'EyeSpy' in options %}
<div class="C3"><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></div>
{% endif %}
{% endif %}
<div class="C4"><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></div>
<div class="C5"><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,12 @@
color: "FFFFFF"
<TabbedPanel>:
tab_width: root.width / app.tab_count
<TooltipLabel>:
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
markup: True
<SelectableLabel>:
canvas.before:
Color:
@@ -24,11 +30,6 @@
Rectangle:
size: self.size
pos: self.pos
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
markup: True
<UILog>:
messages: 1000 # amount of messages stored in client logs.
cols: 1
@@ -44,6 +45,70 @@
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
<HintLabel>:
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1)
Rectangle:
size: self.size
pos: self.pos
height: self.minimum_height
receiving_text: "Receiving Player"
item_text: "Item"
finding_text: "Finding Player"
location_text: "Location"
entrance_text: "Entrance"
found_text: "Found?"
TooltipLabel:
id: receiving
text: root.receiving_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: item
text: root.item_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: finding
text: root.finding_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: location
text: root.location_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: entrance
text: root.entrance_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: found
text: root.found_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
<HintLog>:
cols: 1
viewclass: 'HintLabel'
scroll_y: self.height
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout:
default_size: None, dp(20)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
<ServerLabel>:
text: "Server:"
size_hint_x: None

View File

@@ -249,6 +249,24 @@ Response:
- `err` (`string`): A description of the problem
]]
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
lua_major = tonumber(lua_major)
lua_minor = tonumber(lua_minor)
if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
require("lua_5_3_compat")
end
local base64 = require("base64")
local socket = require("socket")
local json = require("json")
@@ -257,7 +275,9 @@ local json = require("json")
-- Will cause lag due to large console output
local DEBUG = false
local SOCKET_PORT = 43055
local SOCKET_PORT_FIRST = 43055
local SOCKET_PORT_RANGE_SIZE = 5
local SOCKET_PORT_LAST = SOCKET_PORT_FIRST + SOCKET_PORT_RANGE_SIZE
local STATE_NOT_CONNECTED = 0
local STATE_CONNECTED = 1
@@ -277,24 +297,6 @@ local locked = false
local rom_hash = nil
local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
lua_major = tonumber(lua_major)
lua_minor = tonumber(lua_minor)
if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
require("lua_5_3_compat")
end
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
function queue_push (self, value)
self[self.right] = value
self.right = self.right + 1
@@ -435,7 +437,7 @@ function send_receive ()
end
if message == "VERSION" then
local result, err client_socket:send(tostring(SCRIPT_VERSION).."\n")
client_socket:send(tostring(SCRIPT_VERSION).."\n")
else
local res = {}
local data = json.decode(message)
@@ -463,14 +465,45 @@ function send_receive ()
end
end
function main ()
server, err = socket.bind("localhost", SOCKET_PORT)
function initialize_server ()
local err
local port = SOCKET_PORT_FIRST
local res = nil
server, err = socket.socket.tcp4()
while res == nil and port <= SOCKET_PORT_LAST do
res, err = server:bind("localhost", port)
if res == nil and err ~= "address already in use" then
print(err)
return
end
if res == nil then
port = port + 1
end
end
if port > SOCKET_PORT_LAST then
print("Too many instances of connector script already running. Exiting.")
return
end
res, err = server:listen(0)
if err ~= nil then
print(err)
return
end
server:settimeout(0)
end
function main ()
while true do
if server == nil then
initialize_server()
end
current_time = socket.socket.gettime()
timeout_timer = timeout_timer - (current_time - prev_time)
message_timer = message_timer - (current_time - prev_time)
@@ -482,16 +515,16 @@ function main ()
end
if current_state == STATE_NOT_CONNECTED then
if emu.framecount() % 60 == 0 then
server:settimeout(2)
if emu.framecount() % 30 == 0 then
print("Looking for client...")
local client, timeout = server:accept()
if timeout == nil then
print("Client connected")
current_state = STATE_CONNECTED
client_socket = client
server:close()
server = nil
client_socket:settimeout(0)
else
print("No client found. Trying again...")
end
end
else
@@ -527,27 +560,27 @@ else
emu.frameadvance()
end
end
rom_hash = gameinfo.getromhash()
print("Waiting for client to connect. Emulation will freeze intermittently until a client is found.\n")
print("Waiting for client to connect. This may take longer the more instances of this script you have open at once.\n")
local co = coroutine.create(main)
function tick ()
local status, err = coroutine.resume(co)
if not status then
if not status and err ~= "cannot resume dead coroutine" then
print("\nERROR: "..err)
print("Consider reporting this crash.\n")
if server ~= nil then
server:close()
end
co = coroutine.create(main)
end
end
-- Gambatte has a setting which can cause script execution to become
-- misaligned, so for GB and GBC we explicitly set the callback on
-- vblank instead.
@@ -557,7 +590,7 @@ else
else
event.onframeend(tick)
end
while true do
emu.frameadvance()
end

View File

@@ -1,224 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 3
local APIndex = 0x1A6E
local APDeathLinkAddress = 0x00FD
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local DexSanityAddress = 0x1A71
local InGameAddress = 0x1A84
local ClientCompatibilityAddress = 0xFF00
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local deathlink_rec = nil
local deathlink_send = false
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local compat = nil
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
memDomain["rom"] = function() memory.usememorydomain("ROM") end
memDomain["wram"] = function() memory.usememorydomain("WRAM") end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRange(address, bytes)
data = memory.readbyterange(address - 1, bytes + 1)
data[0] = nil
return data
end
function generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = {u8(RodAddress)}
dexsanity = uRange(DexSanityAddress, 19)
data = {}
categories = {events, missables, hiddenitems, rod}
if compat > 1 then
table.insert(categories, dexsanity)
end
for _, category in ipairs(categories) do
for _, v in ipairs(category) do
table.insert(data, v)
end
end
return data
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
function receive()
l, e = gbSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
block = json.decode(l)
if block ~= nil then
local itemsBlock = block["items"]
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
end
end
-- Determine Message to send back
memDomain.rom()
newPlayerName = uRange(0xFFF0, 0x10)
newSeedName = uRange(0xFFDB, 21)
if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
playerName = newPlayerName
seedName = newSeedName
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
if compat == nil then
compat = u8(ClientCompatibilityAddress)
if compat < 2 then
InGameAddress = 0x1A71
end
end
retTable["clientCompatibilityVersion"] = compat
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
retTable["locations"] = generateLocationsChecked()
elseif in_game ~= 0 then
print("Game may have crashed")
curstate = STATE_UNINITIALIZED
return
end
retTable["deathLink"] = deathlink_send
deathlink_send = false
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 17242)
while true do
frame = frame + 1
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
if u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if deathlink_rec == true then
wU8(APDeathLinkAddress, 1)
elseif u8(APDeathLinkAddress) == 3 then
wU8(APDeathLinkAddress, 0)
deathlink_send = true
end
if ItemsReceived[ItemIndex + 1] ~= nil then
item_id = ItemsReceived[ItemIndex + 1] - 172000000
if item_id > 255 then
item_id = item_id - 256
end
wU8(APItemAddress, item_id)
end
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -46,12 +46,18 @@
# DOOM 1993
/worlds/doom_1993/ @Daivuk
# DOOM II
/worlds/doom_ii/ @Daivuk
# Factorio
/worlds/factorio/ @Berserker66
# Final Fantasy
/worlds/ff1/ @jtoyoda
# Heretic
/worlds/heretic/ @Daivuk
# Hollow Knight
/worlds/hk/ @BadMagic100 @ThePhar
@@ -61,6 +67,12 @@
# Kingdom Hearts 2
/worlds/kh2/ @JaredWeakStrike
# Landstalker: The Treasures of King Nole
/worlds/landstalker/ @Dinopony
# Lingo
/worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @zig-for
@@ -92,6 +104,9 @@
# Overcooked! 2
/worlds/overcooked2/ @toasterparty
# Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe
# Pokemon Red and Blue
/worlds/pokemon_rb/ @Alchav
@@ -104,6 +119,9 @@
# Risk of Rain 2
/worlds/ror2/ @kindasneaki
# Shivers
/worlds/shivers/ @GodlFire
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace

View File

@@ -29,6 +29,7 @@ The zip can contain arbitrary files in addition what was specified above.
## Caveats
Imports from other files inside the apworld have to use relative imports.
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions`
Imports from AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
`from worlds.AutoWorld import World`

View File

@@ -1,16 +1,33 @@
# Contributing
Contributions are welcome. We have a few requests of any new contributors.
Contributions are welcome. We have a few requests for new contributors:
* Follow styling as designated in our [styling documentation](/docs/style.md).
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
* Turn on automated github actions in your fork to have github run all the unit tests after pushing. See example below:
* **Follow styling guidelines.**
Please take a look at the [code style documentation](/docs/style.md)
to ensure ease of communication and uniformity.
* **Ensure that critical changes are covered by tests.**
It is strongly recommended that unit tests are used to avoid regression and to ensure everything is still working.
If you wish to contribute by adding a new game, please take a look at the [logic unit test documentation](/docs/world%20api.md#tests).
If you wish to contribute to the website, please take a look at [these tests](/test/webhost).
* **Do not introduce unit test failures/regressions.**
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
your changes. Currently, the oldest supported version is [Python 3.8](https://www.python.org/downloads/release/python-380/).
It is recommended that automated github actions are turned on in your fork to have github run all of the unit tests after pushing.
You can turn them on here:
![Github actions example](./img/github-actions-example.png)
Otherwise, we tend to judge code on a case to case basis.
Other than these requests, we tend to judge code on a case by case basis.
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
channel in our [Discord](https://archipelago.gg/discord).
If you want to merge a new game, please make sure to read the responsibilities as
[world maintainer](/docs/world%20maintainer.md).
For contribution to the website, please refer to the [WebHost README](/WebHostLib/README.md).
If you want to contribute to the core, you will be subject to stricter review on your pull requests. It is recommended
that you get in touch with other core maintainers via the [Discord](https://archipelago.gg/discord).
If you want to add Archipelago support for a new game, please take a look at the [adding games documentation](/docs/adding%20games.md), which details what is required
to implement support for a game, as well as tips for how to get started.
If you want to merge a new game into the main Archipelago repo, please make sure to read the responsibilities as a
[world maintainer](/docs/world%20maintainer.md).
For other questions, feel free to explore the [main documentation folder](/docs/) and ask us questions in the #archipelago-dev channel
of the [Discord](https://archipelago.gg/discord).

View File

@@ -380,11 +380,12 @@ Additional arguments sent in this package will also be added to the [Retrieved](
Some special keys exist with specific return data, all of them have the prefix `_read_`, so `hints_{team}_{slot}` is `_read_hints_{team}_{slot}`.
| Name | Type | Notes |
|-------------------------------|--------------------------|---------------------------------------------------|
| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. |
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| Name | Type | Notes |
|------------------------------|-------------------------------|---------------------------------------------------|
| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. |
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
### Set
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
@@ -415,6 +416,8 @@ The following operations can be applied to a datastorage key
| mul | Multiplies the current value of the key by `value`. |
| pow | Multiplies the current value of the key to the power of `value`. |
| mod | Sets the current value of the key to the remainder after division by `value`. |
| floor | Floors the current value (`value` is ignored). |
| ceil | Ceils the current value (`value` is ignored). |
| max | Sets the current value of the key to `value` if `value` is bigger. |
| min | Sets the current value of the key to `value` if `value` is lower. |
| and | Applies a bitwise AND to the current value of the key with `value`. |
@@ -556,7 +559,7 @@ Color options:
`player` marks owning player id for location/item,
`flags` contains the [NetworkItem](#NetworkItem) flags that belong to the item
### Client States
### ClientStatus
An enumeration containing the possible client states that may be used to inform
the server in [StatusUpdate](#StatusUpdate). The MultiServer automatically sets
the client state to `ClientStatus.CLIENT_CONNECTED` on the first active connection

View File

@@ -31,7 +31,7 @@ As an example, suppose we want an option that lets the user start their game wit
create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass:
```python
# Options.py
# options.py
from dataclasses import dataclass
from Options import Toggle, PerGameCommonOptions
@@ -77,7 +77,33 @@ or if I need a boolean object, such as in my slot_data I can access it as:
```python
start_with_sword = bool(self.options.starting_sword.value)
```
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
```python
# options.py
class Logic(Choice):
option_normal = 0
option_hard = 1
option_challenging = 2
option_extreme = 3
option_insane = 4
alias_extra_hard = 2
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
# __init__.py
from .options import Logic
if self.options.logic:
do_things_for_all_non_normal_logic()
if self.options.logic == 1:
do_hard_things()
elif self.options.logic == "challenging":
do_challenging_things()
elif self.options.logic == Logic.option_extreme:
do_extreme_things()
elif self.options.logic == "crazy":
do_insane_things()
```
## Generic Option Classes
These options are generically available to every game automatically, but can be overridden for slightly different
behavior, if desired. See `worlds/soe/Options.py` for an example.
@@ -144,13 +170,20 @@ A numeric option allowing a variety of integers including the endpoints. Has a d
`range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string
comparisons.
### SpecialRange
### NamedRange
Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value.
`special_range_names` can be used to
- give descriptive names to certain values from within the range
- add option values above or below the regular range, to be associated with a special meaning
For example:
```python
range_start = 1
range_end = 99
special_range_names: {
"normal": 20,
"extreme": 99,
"unlimited": -1,
}
```

View File

@@ -73,6 +73,53 @@ for your world specifically on the webhost:
`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be
prefixed with the same string as defined here. Default already has 'en'.
`options_presets` (optional) A `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names of
the options and the values are the values to be set for that option. These presets will be available for users to select from on the game's options page.
Note: The values must be a non-aliased value for the option type and can only include the following option types:
- If you have a `Range`/`NamedRange` option, the value should be an `int` between the `range_start` and `range_end`
values.
- If you have a `NamedRange` option, the value can alternatively be a `str` that is one of the
`special_range_names` keys.
- If you have a `Choice` option, the value should be a `str` that is one of the `option_<name>` values.
- If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`.
- `random` is also a valid value for any of these option types.
`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on the webhost at this time.
Here is an example of a defined preset:
```python
# presets.py
options_presets = {
"Limited Potential": {
"progression_balancing": 0,
"fairy_chests_per_zone": 2,
"starting_class": "random",
"chests_per_zone": 30,
"vendors": "normal",
"architect": "disabled",
"gold_gain_multiplier": "half",
"number_of_children": 2,
"free_diary_on_generation": False,
"health_pool": 10,
"mana_pool": 10,
"attack_pool": 10,
"magic_damage_pool": 10,
"armor_pool": 5,
"equip_pool": 10,
"crit_chance_pool": 5,
"crit_damage_pool": 5,
}
}
# __init__.py
class RLWeb(WebWorld):
options_presets = options_presets
# ...
```
### MultiWorld Object
The `MultiWorld` object references the whole multiworld (all items and locations
@@ -121,6 +168,38 @@ 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 or location groups. These descriptions
will show up in location-selection options in 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 Spaeship 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
@@ -147,6 +226,37 @@ Other classifications include
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens
#### Documenting Items
Worlds can optionally provide an `item_descriptions` map which contains
human-friendly descriptions of items or item groups. These descriptions will
show up in item-selection options in 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
Events will mark some progress. You define an event location, an
@@ -223,11 +333,11 @@ See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requireme
AP will only import the `__init__.py`. Depending on code size it makes sense to
use multiple files and use relative imports to access them.
e.g. `from .Options import MyGameOptions` from your `__init__.py` will load
`world/[world_name]/Options.py` and make its `MyGameOptions` accessible.
e.g. `from .options import MyGameOptions` from your `__init__.py` will load
`world/[world_name]/options.py` and make its `MyGameOptions` accessible.
When imported names pile up it may be easier to use `from . import Options`
and access the variable as `Options.MyGameOptions`.
When imported names pile up it may be easier to use `from . import options`
and access the variable as `options.MyGameOptions`.
Imports from directories outside your world should use absolute imports.
Correct use of relative / absolute imports is required for zipped worlds to
@@ -248,7 +358,7 @@ class MyGameItem(Item):
game: str = "My Game"
```
By convention this class definition will either be placed in your `__init__.py`
or your `Items.py`. For a more elaborate example see `worlds/oot/Items.py`.
or your `items.py`. For a more elaborate example see `worlds/oot/Items.py`.
### Your location type
@@ -260,15 +370,15 @@ class MyGameLocation(Location):
game: str = "My Game"
# override constructor to automatically mark event locations as such
def __init__(self, player: int, name = "", code = None, parent = None):
def __init__(self, player: int, name = "", code = None, parent = None) -> None:
super(MyGameLocation, self).__init__(player, name, code, parent)
self.event = code is None
```
in your `__init__.py` or your `Locations.py`.
in your `__init__.py` or your `locations.py`.
### Options
By convention options are defined in `Options.py` and will be used when parsing
By convention options are defined in `options.py` and will be used when parsing
the players' yaml files.
Each option has its own class, inherits from a base option type, has a docstring
@@ -284,7 +394,7 @@ For more see `Options.py` in AP's base directory.
#### Toggle, DefaultOnToggle
Those don't need any additional properties defined. After parsing the option,
These don't need any additional properties defined. After parsing the option,
its `value` will either be True or False.
#### Range
@@ -310,7 +420,7 @@ default = 0
#### Sample
```python
# Options.py
# options.py
from dataclasses import dataclass
from Options import Toggle, Range, Choice, PerGameCommonOptions
@@ -349,7 +459,7 @@ class MyGameOptions(PerGameCommonOptions):
# __init__.py
from worlds.AutoWorld import World
from .Options import MyGameOptions # import the options dataclass
from .options import MyGameOptions # import the options dataclass
class MyGameWorld(World):
@@ -366,9 +476,9 @@ class MyGameWorld(World):
import settings
import typing
from .Options import MyGameOptions # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from .options import MyGameOptions # the options we defined earlier
from .items import mygame_items # data used below to add items to the World
from .locations import mygame_locations # same as above
from worlds.AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
@@ -427,7 +537,7 @@ The world has to provide the following things for generation
* additions to the regions list: at least one called "Menu"
* locations placed inside those regions
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
* applying `self.multiworld.push_precollected` for start inventory
* applying `self.multiworld.push_precollected` for world defined start inventory
* `required_client_version: Tuple[int, int, int]`
Optional client version as tuple of 3 ints to make sure the client is compatible to
this world (e.g. implements all required features) when connecting.
@@ -437,31 +547,32 @@ In addition, the following methods can be implemented and are called in this ord
* `stage_assert_generate(cls, multiworld)` is a class method called at the start of
generation to check the existence of prerequisite files, usually a ROM for
games which require one.
* `def generate_early(self)`
called per player before any items or locations are created. You can set
properties on your world here. Already has access to player options and RNG.
* `def create_regions(self)`
* `generate_early(self)`
called per player before any items or locations are created. You can set properties on your world here. Already has
access to player options and RNG. This is the earliest step where the world should start setting up for the current
multiworld as any steps before this, the multiworld itself is still getting set up
* `create_regions(self)`
called to place player's regions and their locations into the MultiWorld's regions list. If it's
hard to separate, this can be done during `generate_early` or `create_items` as well.
* `def create_items(self)`
* `create_items(self)`
called to place player's items into the MultiWorld's itempool. After this step all regions and items have to be in
the MultiWorld's regions and itempool, and these lists should not be modified afterwards.
* `def set_rules(self)`
* `set_rules(self)`
called to set access and item rules on locations and entrances.
Locations have to be defined before this, or rule application can miss them.
* `def generate_basic(self)`
* `generate_basic(self)`
called after the previous steps. Some placement and player specific
randomizations can be done here.
* `pre_fill`, `fill_hook` and `post_fill` are called to modify item placement
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)` are called to modify item placement
before, during and after the regular fill process, before `generate_output`.
If items need to be placed during pre_fill, these items can be determined
and created using `get_prefill_items`
* `def generate_output(self, output_directory: str)` that creates the output
* `generate_output(self, output_directory: str)` that creates the output
files if there is output to be generated. When this is
called, `self.multiworld.get_locations(self.player)` has all locations for the player, with
attribute `item` pointing to the item.
`location.item.player` can be used to see if it's a local item.
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
* `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that
will be used by the server to host the MultiWorld.
@@ -478,9 +589,9 @@ def generate_early(self) -> None:
```python
# we need a way to know if an item provides progress in the game ("key item")
# this can be part of the items definition, or depend on recipe randomization
from .Items import is_progression # this is just a dummy
from .items import is_progression # this is just a dummy
def create_item(self, item: str):
def create_item(self, item: str) -> MyGameItem:
# This is called when AP wants to create an item by name (for plando) or
# when you call it from your own code.
classification = ItemClassification.progression if is_progression(item) else \
@@ -488,7 +599,7 @@ def create_item(self, item: str):
return MyGameItem(item, classification, self.item_name_to_id[item],
self.player)
def create_event(self, event: str):
def create_event(self, event: str) -> MyGameItem:
# while we are at it, we can also add a helper to create events
return MyGameItem(event, True, None, self.player)
```
@@ -580,8 +691,8 @@ def generate_basic(self) -> None:
### Setting Rules
```python
from worlds.generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type
from worlds.generic.Rules import add_rule, set_rule, forbid_item, add_item_rule
from .items import get_item_type
def set_rules(self) -> None:
@@ -607,7 +718,7 @@ def set_rules(self) -> None:
# require one item from an item group
add_rule(self.multiworld.get_location("Chest3", self.player),
lambda state: state.has_group("weapons", self.player))
# state also has .item_count() for items, .has_any() and .has_all() for sets
# state also has .count() for items, .has_any() and .has_all() for multiple
# and .count_group() for groups
# set_rule is likely to be a bit faster than add_rule
@@ -650,12 +761,12 @@ Please do this with caution and only when necessary.
#### Sample
```python
# Logic.py
# logic.py
from worlds.AutoWorld import LogicMixin
class MyGameLogic(LogicMixin):
def mygame_has_key(self, player: int):
def mygame_has_key(self, player: int) -> bool:
# Arguments above are free to choose
# MultiWorld can be accessed through self.multiworld, explicitly passing in
# MyGameWorld instance for easy options access is also a valid approach
@@ -665,11 +776,11 @@ class MyGameLogic(LogicMixin):
# __init__.py
from worlds.generic.Rules import set_rule
import .Logic # apply the mixin by importing its file
import .logic # apply the mixin by importing its file
class MyGameWorld(World):
# ...
def set_rules(self):
def set_rules(self) -> None:
set_rule(self.multiworld.get_location("A Door", self.player),
lambda state: state.mygame_has_key(self.player))
```
@@ -677,10 +788,10 @@ class MyGameWorld(World):
### Generate Output
```python
from .Mod import generate_mod
from .mod import generate_mod
def generate_output(self, output_directory: str):
def generate_output(self, output_directory: str) -> None:
# How to generate the mod or ROM highly depends on the game
# if the mod is written in Lua, Jinja can be used to fill a template
# if the mod reads a json file, `json.dump()` can be used to generate that
@@ -695,12 +806,10 @@ def generate_output(self, output_directory: str):
# make sure to mark as not remote_start_inventory when connecting if stored in rom/mod
"starter_items": [item.name for item
in self.multiworld.precollected_items[self.player]],
"final_boss_hp": self.final_boss_hp,
# store option name "easy", "normal" or "hard" for difficuly
"difficulty": self.options.difficulty.current_key,
# store option value True or False for fixing a glitch
"fix_xyz_glitch": self.options.fix_xyz_glitch.value,
}
# add needed option results to the dictionary
data.update(self.options.as_dict("final_boss_hp", "difficulty", "fix_xyz_glitch"))
# point to a ROM specified by the installation
src = self.settings.rom_file
# or point to worlds/mygame/data/mod_template
@@ -724,7 +833,7 @@ data already exists on the server. The most common usage of slot data is to send
to be aware of.
```python
def fill_slot_data(self):
def fill_slot_data(self) -> Dict[str, Any]:
# in order for our game client to handle the generated seed correctly we need to know what the user selected
# for their difficulty and final boss HP
# a dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting
@@ -776,14 +885,14 @@ from . import MyGameTestBase
class TestChestAccess(MyGameTestBase):
def test_sword_chests(self):
def test_sword_chests(self) -> None:
"""Test locations that require a sword"""
locations = ["Chest1", "Chest2"]
items = [["Sword"]]
# this will test that each location can't be accessed without the "Sword", but can be accessed once obtained.
self.assertAccessDependency(locations, items)
def test_any_weapon_chests(self):
def test_any_weapon_chests(self) -> None:
"""Test locations that require any weapon"""
locations = [f"Chest{i}" for i in range(3, 6)]
items = [["Sword"], ["Axe"], ["Spear"]]

View File

@@ -80,7 +80,10 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC
Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
@@ -140,19 +143,24 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apemerald"; ValueData: "{#MyAppName}pkmnepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch"; ValueData: "Archipelago Pokemon Emerald Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
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: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";

263
kvui.py
View File

@@ -5,12 +5,13 @@ import typing
if sys.platform == "win32":
import ctypes
# kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout
# by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's
try:
ctypes.windll.shcore.SetProcessDpiAwareness(0)
except FileNotFoundError: # shcore may not be found on <= Windows 7
pass # TODO: remove silent except when Python 3.8 is phased out.
pass # TODO: remove silent except when Python 3.8 is phased out.
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
@@ -18,14 +19,15 @@ os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
import Utils
if Utils.is_frozen():
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set('kivy', 'exit_on_escape', '0')
Config.set('graphics', 'multisamples', '0') # multisamples crash old intel drivers
Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
from kivy.app import App
from kivy.core.window import Window
@@ -58,7 +60,6 @@ from kivy.uix.popup import Popup
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
from Utils import async_start
@@ -77,8 +78,8 @@ class HoverBehavior(object):
border_point = ObjectProperty(None)
def __init__(self, **kwargs):
self.register_event_type('on_enter')
self.register_event_type('on_leave')
self.register_event_type("on_enter")
self.register_event_type("on_leave")
Window.bind(mouse_pos=self.on_mouse_pos)
Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs)
@@ -106,7 +107,7 @@ class HoverBehavior(object):
self.dispatch("on_leave")
Factory.register('HoverBehavior', HoverBehavior)
Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(Label):
@@ -121,6 +122,60 @@ class HovererableLabel(HoverBehavior, Label):
pass
class TooltipLabel(HovererableLabel):
tooltip = None
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]")
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:
tx, ty = self.to_widget(*pos, relative=True)
# Why TF is Y flipped *within* the texture?
ty = self.texture_size[1] - ty
hit = False
for uid, zones in self.refs.items():
for zone in zones:
x, y, w, h = zone
if x <= tx <= w and y <= ty <= h:
self.create_tooltip(uid.split("|", 1)[1], *pos)
hit = True
break
if not hit:
self.remove_tooltip()
def on_enter(self):
pass
def on_leave(self):
self.remove_tooltip()
class ServerLabel(HovererableLabel):
def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs)
@@ -189,11 +244,10 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
""" Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Add selection support to the Label """
index = None
selected = BooleanProperty(False)
tooltip = None
def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """
@@ -201,56 +255,6 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']')
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:
tx, ty = self.to_widget(*pos, relative=True)
# Why TF is Y flipped *within* the texture?
ty = self.texture_size[1] - ty
hit = False
for uid, zones in self.refs.items():
for zone in zones:
x, y, w, h = zone
if x <= tx <= w and y <= ty <= h:
self.create_tooltip(uid.split("|", 1)[1], *pos)
hit = True
break
if not hit:
self.remove_tooltip()
def on_enter(self):
pass
def on_leave(self):
self.remove_tooltip()
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
@@ -274,7 +278,7 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
elif not cmdinput.text and text.startswith("Missing: "):
cmdinput.text = text.replace("Missing: ", "!hint_location ")
Clipboard.copy(text.replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']'))
Clipboard.copy(text.replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
@@ -282,9 +286,68 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
self.selected = is_selected
class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
no_select = []
def __init__(self):
super(HintLabel, self).__init__()
self.receiving_text = ""
self.item_text = ""
self.finding_text = ""
self.location_text = ""
self.entrance_text = ""
self.found_text = ""
for child in self.children:
child.bind(texture_size=self.set_height)
def set_height(self, instance, value):
self.height = max([child.texture_size[1] for child in self.children])
def refresh_view_attrs(self, rv, index, data):
self.index = index
if "select" in data and not data["select"] and index not in self.no_select:
self.no_select.append(index)
self.striped = data["striped"]
self.receiving_text = data["receiving"]["text"]
self.item_text = data["item"]["text"]
self.finding_text = data["finding"]["text"]
self.location_text = data["location"]["text"]
self.entrance_text = data["entrance"]["text"]
self.found_text = data["found"]["text"]
self.height = self.minimum_height
return super(HintLabel, self).refresh_view_attrs(rv, index, data)
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(HintLabel, self).on_touch_down(touch):
return True
if self.index not in self.no_select:
if self.collide_point(*touch.pos):
if self.selected:
self.parent.clear_selection()
else:
text = "".join([self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
self.finding_text, "\'s World", (" at " + self.entrance_text)
if self.entrance_text != "Vanilla"
else "", ". (", self.found_text.lower(), ")"])
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
if self.index not in self.no_select:
self.selected = is_selected
class ConnectBarTextInput(TextInput):
def insert_text(self, substring, from_undo=False):
s = substring.replace('\n', '').replace('\r', '')
s = substring.replace("\n", "").replace("\r", "")
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
@@ -302,7 +365,7 @@ class MessageBox(Popup):
def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width)+40),
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
@@ -358,11 +421,14 @@ class GameManager(App):
# top part
server_label = ServerLabel()
self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None,
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
size_hint_y=None,
height=dp(30), multiline=False, write_tab=False)
def connect_bar_validate(sender):
if not self.ctx.server:
self.connect_button_action(sender)
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None)
@@ -383,20 +449,22 @@ class GameManager(App):
bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name)
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
self.tabs.add_widget(panel)
if len(self.logging_pairs) > 1:
# show Archipelago tab if other logging is present
self.tabs.add_widget(panel)
hint_panel = TabbedPanelItem(text="Hints")
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
self.tabs.add_widget(hint_panel)
if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago"
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
self.main_area_container.add_widget(self.tabs)
self.grid.add_widget(self.main_area_container)
if len(self.logging_pairs) == 1:
# Hide Tab selection if only one tab
self.tabs.clear_tabs()
self.tabs.do_default_tab = False
self.tabs.current_tab.height = 0
self.tabs.tab_height = 0
# bottom part
bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
@@ -422,7 +490,7 @@ class GameManager(App):
return self.container
def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], 'fix_heights'):
if hasattr(self.tabs.content.children[0], "fix_heights"):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
@@ -499,6 +567,10 @@ class GameManager(App):
if hasattr(self, "energy_link_label"):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
def update_hints(self):
hints = self.ctx.stored_data[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"]
self.log_panels["Hints"].refresh_hints(hints)
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
def open_settings(self, *largs):
pass
@@ -513,12 +585,12 @@ class LogtoUI(logging.Handler):
def format_compact(record: logging.LogRecord) -> str:
if isinstance(record.msg, Exception):
return str(record.msg)
return (f'{record.exc_info[1]}\n' if record.exc_info else '') + str(record.msg).split("\n")[0]
return (f"{record.exc_info[1]}\n" if record.exc_info else "") + str(record.msg).split("\n")[0]
def handle(self, record: logging.LogRecord) -> None:
if getattr(record, 'skip_gui', False):
if getattr(record, "skip_gui", False):
pass # skip output
elif getattr(record, 'compact_gui', False):
elif getattr(record, "compact_gui", False):
self.on_log(self.format_compact(record))
else:
self.on_log(self.format(record))
@@ -552,6 +624,44 @@ class UILog(RecycleView):
element.height = element.texture_size[1]
class HintLog(RecycleView):
header = {
"receiving": {"text": "[u]Receiving Player[/u]"},
"item": {"text": "[u]Item[/u]"},
"finding": {"text": "[u]Finding Player[/u]"},
"location": {"text": "[u]Location[/u]"},
"entrance": {"text": "[u]Entrance[/u]"},
"found": {"text": "[u]Status[/u]"},
"striped": True,
"select": False,
}
def __init__(self, parser):
super(HintLog, self).__init__()
self.data = [self.header]
self.parser = parser
def refresh_hints(self, hints):
self.data = [self.header]
striped = False
for hint in hints:
self.data.append({
"striped": striped,
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node(
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
"location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})},
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
"color": "blue", "text": hint["entrance"]
if hint["entrance"] else "Vanilla"})},
"found": {
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red",
"text": "Found" if hint["found"] else "Not Found"})},
})
striped = not striped
class E(ExceptionHandler):
logger = logging.getLogger("Client")
@@ -599,7 +709,7 @@ class KivyJSONtoTextParser(JSONtoTextParser):
f"Type: {SlotType(slot_info.type).name}"
if slot_info.group_members:
text += f"<br>Members:<br> " + \
'<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members)
"<br> ".join(self.ctx.player_names[player] for player in slot_info.group_members)
node.setdefault("refs", []).append(text)
return super(KivyJSONtoTextParser, self)._handle_player_id(node)
@@ -627,4 +737,3 @@ user_file = Utils.user_path("data", "user.kv")
if os.path.exists(user_file):
logging.info("Loading user.kv into builder.")
Builder.load_file(user_file)

View File

@@ -1,12 +1,13 @@
colorama>=0.4.5
websockets>=11.0.3
PyYAML>=6.0.1
jellyfish>=1.0.1
jellyfish>=1.0.3
jinja2>=3.1.2
schema>=0.7.5
kivy>=2.2.0
bsdiff4>=1.2.3
platformdirs>=3.9.1
certifi>=2023.7.22
cython>=0.29.35
bsdiff4>=1.2.4
platformdirs>=4.0.0
certifi>=2023.11.17
cython>=3.0.5
cymem>=2.0.8
orjson>=3.9.10

View File

@@ -21,7 +21,7 @@ from pathlib import Path
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
try:
requirement = 'cx-Freeze>=6.15.2'
requirement = 'cx-Freeze>=6.15.10'
import pkg_resources
try:
pkg_resources.require(requirement)
@@ -71,7 +71,6 @@ non_apworlds: set = {
"Clique",
"DLCQuest",
"Final Fantasy",
"Kingdom Hearts 2",
"Lufia II Ancient Cave",
"Meritous",
"Ocarina of Time",
@@ -620,7 +619,7 @@ cx_Freeze.setup(
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas"],
"zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "sc2"],
"zip_exclude_packages": ["worlds", "sc2", "orjson"], # TODO: remove orjson here once we drop py3.8 support
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
"include_msvcr": False,
"replace_paths": ["*."],

View File

@@ -1,7 +1,10 @@
import random
import sys
import typing
import unittest
from argparse import Namespace
from Generate import get_seed_name
from test.general import gen_steps
from worlds import AutoWorld
from worlds.AutoWorld import call_all
@@ -107,11 +110,36 @@ class WorldTestBase(unittest.TestCase):
game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore"
auto_construct: typing.ClassVar[bool] = True
""" automatically set up a world for each test in this class """
memory_leak_tested: typing.ClassVar[bool] = False
""" remember if memory leak test was already done for this class """
def setUp(self) -> None:
if self.auto_construct:
self.world_setup()
def tearDown(self) -> None:
if self.__class__.memory_leak_tested or not self.options or not self.constructed or \
sys.version_info < (3, 11, 0): # the leak check in tearDown fails in py<3.11 for an unknown reason
# only run memory leak test once per class, only for constructed with non-default options
# default options will be tested in test/general
super().tearDown()
return
import gc
import weakref
weak = weakref.ref(self.multiworld)
for attr_name in dir(self): # delete all direct references to MultiWorld and World
attr: object = typing.cast(object, getattr(self, attr_name))
if type(attr) is MultiWorld or isinstance(attr, AutoWorld.World):
delattr(self, attr_name)
state_cache: typing.Optional[typing.Dict[typing.Any, typing.Any]] = getattr(self, "_state_cache", None)
if state_cache is not None: # in case of multiple inheritance with TestBase, we need to clear its cache
state_cache.clear()
gc.collect()
self.__class__.memory_leak_tested = True
self.assertFalse(weak(), f"World {getattr(self, 'game', '')} leaked MultiWorld object")
super().tearDown()
def world_setup(self, seed: typing.Optional[int] = None) -> None:
if type(self) is WorldTestBase or \
(hasattr(WorldTestBase, self._testMethodName)
@@ -126,6 +154,8 @@ class WorldTestBase(unittest.TestCase):
self.multiworld.player_name = {1: "Tester"}
self.multiworld.set_seed(seed)
self.multiworld.state = CollectionState(self.multiworld)
random.seed(self.multiworld.seed)
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items():
setattr(args, name, {
@@ -284,7 +314,7 @@ class WorldTestBase(unittest.TestCase):
# basically a shortened reimplementation of this method from core, in order to force the check is done
def fulfills_accessibility() -> bool:
locations = self.multiworld.get_locations(1).copy()
locations = list(self.multiworld.get_locations(1))
state = CollectionState(self.multiworld)
while locations:
sphere: typing.List[Location] = []

View File

@@ -442,6 +442,47 @@ class TestFillRestrictive(unittest.TestCase):
self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1")
self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1")
def test_swap_to_earlier_location_with_item_rule2(self):
"""Test that swap works before all items are placed"""
multi_world = generate_multi_world(1)
player1 = generate_player_data(multi_world, 1, 5, 5)
locations = player1.locations[:] # copy required
items = player1.prog_items[:] # copy required
# Two items provide access to sphere 2.
# One of them is forbidden in sphere 1, the other is first placed in sphere 4 because of placement order,
# requiring a swap.
# There are spheres in between, so for the swap to work, it'll have to assume all other items are collected.
one_to_two1 = items[4].name
one_to_two2 = items[3].name
three_to_four = items[2].name
two_to_three1 = items[1].name
two_to_three2 = items[0].name
# Sphere 4
set_rule(locations[0], lambda state: ((state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id))
and state.has(two_to_three1, player1.id)
and state.has(two_to_three2, player1.id)
and state.has(three_to_four, player1.id)))
# Sphere 3
set_rule(locations[1], lambda state: ((state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id))
and state.has(two_to_three1, player1.id)
and state.has(two_to_three2, player1.id)))
# Sphere 2
set_rule(locations[2], lambda state: state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id))
# Sphere 1
sphere1_loc1 = locations[3]
sphere1_loc2 = locations[4]
# forbid one_to_two2 in sphere 1 to make the swap happen as described above
add_item_rule(sphere1_loc1, lambda item_to_place: item_to_place.name != one_to_two2)
add_item_rule(sphere1_loc2, lambda item_to_place: item_to_place.name != one_to_two2)
# Now fill should place one_to_two1 in sphere1_loc1 or sphere1_loc2 via swap,
# which it will attempt before two_to_three and three_to_four are placed, testing the behavior.
fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items)
# assert swap happened
self.assertTrue(sphere1_loc1.item and sphere1_loc2.item, "Did not swap required item into Sphere 1")
self.assertTrue(sphere1_loc1.item.name == one_to_two1 or
sphere1_loc2.item.name == one_to_two1, "Wrong item in Sphere 1")
def test_double_sweep(self):
"""Test that sweep doesn't duplicate Event items when sweeping"""
# test for PR1114
@@ -455,8 +496,8 @@ class TestFillRestrictive(unittest.TestCase):
location.place_locked_item(item)
multi_world.state.sweep_for_events()
multi_world.state.sweep_for_events()
self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed")
self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times")
self.assertTrue(multi_world.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
self.assertEqual(multi_world.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
def test_correct_item_instance_removed_from_pool(self):
"""Test that a placed item gets removed from the submitted pool"""

View File

@@ -16,7 +16,7 @@ class TestIDs(unittest.TestCase):
def test_utils_in_yaml(self) -> None:
"""Tests that the auto generated host.yaml has default settings in it"""
for option_key, option_set in Utils.get_default_options().items():
for option_key, option_set in Settings(None).items():
with self.subTest(option_key):
self.assertIn(option_key, self.yaml_options)
for sub_option_key in option_set:
@@ -24,7 +24,7 @@ class TestIDs(unittest.TestCase):
def test_yaml_in_utils(self) -> None:
"""Tests that the auto generated host.yaml shows up in reference calls"""
utils_options = Utils.get_default_options()
utils_options = Settings(None)
for option_key, option_set in self.yaml_options.items():
with self.subTest(option_key):
self.assertIn(option_key, utils_options)

View File

@@ -40,8 +40,8 @@ class TestImplemented(unittest.TestCase):
# has an await for generate_output which isn't being called
if game_name in {"Ocarina of Time", "Zillion"}:
continue
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
for key, data in multiworld.worlds[1].fill_slot_data().items():

View File

@@ -60,3 +60,12 @@ class TestBase(unittest.TestCase):
multiworld = setup_solo_multiworld(world_type)
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")

View File

@@ -36,7 +36,6 @@ class TestBase(unittest.TestCase):
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
multiworld._recache()
region_count = len(multiworld.get_regions())
location_count = len(multiworld.get_locations())
@@ -46,14 +45,12 @@ class TestBase(unittest.TestCase):
self.assertEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during rule creation")
multiworld._recache()
call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic")
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during generate_basic")
multiworld._recache()
call_all(multiworld, "pre_fill")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during pre_fill")
@@ -69,3 +66,12 @@ class TestBase(unittest.TestCase):
for location in locations:
self.assertIn(location, world_type.location_name_to_id)
self.assertNotIn(group_name, world_type.location_name_to_id)
def test_location_descriptions_have_valid_names(self):
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
self.assertIn(name, valid_names,
"All location descriptions must match defined location names")

View File

@@ -0,0 +1,16 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from . import setup_solo_multiworld
class TestWorldMemory(unittest.TestCase):
def test_leak(self):
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
import gc
import weakref
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
weak = weakref.ref(setup_solo_multiworld(world_type))
gc.collect()
self.assertFalse(weak(), "World leaked a reference")

66
test/utils/test_caches.py Normal file
View File

@@ -0,0 +1,66 @@
# Tests for caches in Utils.py
import unittest
from typing import Any
from Utils import cache_argsless, cache_self1
class TestCacheArgless(unittest.TestCase):
def test_cache(self) -> None:
@cache_argsless
def func_argless() -> object:
return object()
self.assertTrue(func_argless() is func_argless())
if __debug__: # assert only available with __debug__
def test_invalid_decorator(self) -> None:
with self.assertRaises(Exception):
@cache_argsless # type: ignore[arg-type]
def func_with_arg(_: Any) -> None:
pass
class TestCacheSelf1(unittest.TestCase):
def test_cache(self) -> None:
class Cls:
@cache_self1
def func(self, _: Any) -> object:
return object()
o1 = Cls()
o2 = Cls()
self.assertTrue(o1.func(1) is o1.func(1))
self.assertFalse(o1.func(1) is o1.func(2))
self.assertFalse(o1.func(1) is o2.func(1))
def test_gc(self) -> None:
# verify that we don't keep a global reference
import gc
import weakref
class Cls:
@cache_self1
def func(self, _: Any) -> object:
return object()
o = Cls()
_ = o.func(o) # keep a hard ref to the result
r = weakref.ref(o) # keep weak ref to the cache
del o # remove hard ref to the cache
gc.collect()
self.assertFalse(r()) # weak ref should be dead now
if __debug__: # assert only available with __debug__
def test_no_self(self) -> None:
with self.assertRaises(Exception):
@cache_self1 # type: ignore[arg-type]
def func() -> Any:
pass
def test_too_many_args(self) -> None:
with self.assertRaises(Exception):
@cache_self1 # type: ignore[arg-type]
def func(_1: Any, _2: Any, _3: Any) -> Any:
pass

View File

@@ -0,0 +1,63 @@
import unittest
from worlds import AutoWorldRegister
from Options import Choice, NamedRange, Toggle, Range
class TestOptionPresets(unittest.TestCase):
def test_option_presets_have_valid_options(self):
"""Test that all predefined option presets are valid options."""
for game_name, world_type in AutoWorldRegister.world_types.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
with self.subTest(game=game_name, preset=preset_name, option=option_name):
try:
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
supported_types = [Choice, Toggle, Range, NamedRange]
if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
f"is not a supported type for webhost. "
f"Supported types: {', '.join([t.__name__ for t in supported_types])}")
except AssertionError as ex:
self.fail(f"Option '{option_name}': '{option_value}' in preset '{preset_name}' for game "
f"'{game_name}' is not valid. Error: {ex}")
except KeyError as ex:
self.fail(f"Option '{option_name}' in preset '{preset_name}' for game '{game_name}' is "
f"not a defined option. Error: {ex}")
def test_option_preset_values_are_explicitly_defined(self):
"""Test that option preset values are not a special flavor of 'random' or use from_text to resolve another
value.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
with self.subTest(game=game_name, preset=preset_name, option=option_name):
# Check for non-standard random values.
self.assertFalse(
str(option_value).startswith("random-"),
f"'{option_name}': '{option_value}' in preset '{preset_name}' for game '{game_name}' "
f"is not supported for webhost. Special random values are not supported for presets."
)
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
# Check for from_text resolving to a different value. ("random" is allowed though.)
if option_value != "random" and isinstance(option_value, str):
# Allow special named values for NamedRange option presets.
if isinstance(option, NamedRange):
self.assertTrue(
option_value in option.special_range_names,
f"Invalid preset '{option_name}': '{option_value}' in preset '{preset_name}' "
f"for game '{game_name}'. Expected {option.special_range_names.keys()} or "
f"{option.range_start}-{option.range_end}."
)
else:
self.assertTrue(
option.name_lookup.get(option.value, None) == option_value,
f"'{option_name}': '{option_value}' in preset '{preset_name}' for game "
f"'{game_name}' is not supported for webhost. Values must not be resolved to a "
f"different option via option.from_text (or an alias)."
)

View File

@@ -3,7 +3,9 @@ from __future__ import annotations
import hashlib
import logging
import pathlib
import re
import sys
import time
from dataclasses import make_dataclass
from typing import Any, Callable, ClassVar, Dict, Set, Tuple, FrozenSet, List, Optional, TYPE_CHECKING, TextIO, Type, \
Union
@@ -17,6 +19,8 @@ if TYPE_CHECKING:
from . import GamesPackage
from settings import Group
perf_logger = logging.getLogger("performance")
class AutoWorldRegister(type):
world_types: Dict[str, Type[World]] = {}
@@ -48,11 +52,17 @@ class AutoWorldRegister(type):
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["item_descriptions"] = {name: _normalize_description(description) for name, description
in dct.get("item_descriptions", {}).items()}
dct["item_descriptions"]["Everything"] = "All items in the entire game."
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
dct["location_descriptions"] = {name: _normalize_description(description) for name, description
in dct.get("location_descriptions", {}).items()}
dct["location_descriptions"]["Everywhere"] = "All locations in the entire game."
# move away from get_required_client_version function
if "game" in dct:
@@ -103,10 +113,24 @@ class AutoLogicRegister(type):
return new_class
def _timed_call(method: Callable[..., Any], *args: Any,
multiworld: Optional["MultiWorld"] = None, player: Optional[int] = None) -> Any:
start = time.perf_counter()
ret = method(*args)
taken = time.perf_counter() - start
if taken > 1.0:
if player and multiworld:
perf_logger.info(f"Took {taken:.4f} seconds in {method.__qualname__} for player {player}, "
f"named {multiworld.player_name[player]}.")
else:
perf_logger.info(f"Took {taken:.4f} seconds in {method.__qualname__}.")
return ret
def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
method = getattr(multiworld.worlds[player], method_name)
try:
ret = method(*args)
ret = _timed_call(method, *args, multiworld=multiworld, player=player)
except Exception as e:
message = f"Exception in {method} for player {player}, named {multiworld.player_name[player]}."
if sys.version_info >= (3, 11, 0):
@@ -132,18 +156,15 @@ def call_all(multiworld: "MultiWorld", method_name: str, *args: Any) -> None:
f"Duplicate item reference of \"{item.name}\" in \"{multiworld.worlds[player].game}\" "
f"of player \"{multiworld.player_name[player]}\". Please make a copy instead.")
for world_type in sorted(world_types, key=lambda world: world.__name__):
stage_callable = getattr(world_type, f"stage_{method_name}", None)
if stage_callable:
stage_callable(multiworld, *args)
call_stage(multiworld, method_name, *args)
def call_stage(multiworld: "MultiWorld", method_name: str, *args: Any) -> None:
world_types = {multiworld.worlds[player].__class__ for player in multiworld.player_ids}
for world_type in world_types:
for world_type in sorted(world_types, key=lambda world: world.__name__):
stage_callable = getattr(world_type, f"stage_{method_name}", None)
if stage_callable:
stage_callable(multiworld, *args)
_timed_call(stage_callable, multiworld, *args)
class WebWorld:
@@ -165,6 +186,9 @@ class WebWorld:
bug_report_page: Optional[str]
"""display a link to a bug report page, most likely a link to a GitHub issue page."""
options_presets: Dict[str, Dict[str, Any]] = {}
"""A dictionary containing a collection of developer-defined game option presets."""
class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
@@ -191,9 +215,23 @@ class World(metaclass=AutoWorldRegister):
item_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}"""
item_descriptions: ClassVar[Dict[str, str]] = {}
"""An optional map from item names (or item group names) to brief descriptions for users.
Individual newlines and indentation will be collapsed into spaces before these descriptions are
displayed. This may cover only a subset of items.
"""
location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
location_descriptions: ClassVar[Dict[str, str]] = {}
"""An optional map from location names (or location group names) to brief descriptions for users.
Individual newlines and indentation will be collapsed into spaces before these descriptions are
displayed. This may cover only a subset of locations.
"""
data_version: ClassVar[int] = 0
"""
Increment this every time something in your world's names/id mappings changes.
@@ -400,16 +438,16 @@ class World(metaclass=AutoWorldRegister):
def collect(self, state: "CollectionState", item: "Item") -> bool:
name = self.collect_item(state, item)
if name:
state.prog_items[name, self.player] += 1
state.prog_items[self.player][name] += 1
return True
return False
def remove(self, state: "CollectionState", item: "Item") -> bool:
name = self.collect_item(state, item, True)
if name:
state.prog_items[name, self.player] -= 1
if state.prog_items[name, self.player] < 1:
del (state.prog_items[name, self.player])
state.prog_items[self.player][name] -= 1
if state.prog_items[self.player][name] < 1:
del (state.prog_items[self.player][name])
return True
return False
@@ -448,3 +486,17 @@ def data_package_checksum(data: "GamesPackage") -> str:
assert sorted(data) == list(data), "Data not ordered"
from NetUtils import encode
return hashlib.sha1(encode(data).encode()).hexdigest()
def _normalize_description(description):
"""Normalizes a description in item_descriptions or location_descriptions.
This allows authors to write descritions with nice indentation and line lengths in their world
definitions without having it affect the rendered format.
"""
# First, collapse the whitespace around newlines and the ends of the description.
description = re.sub(r' *\n *', '\n', description.strip())
# Next, condense individual newlines into spaces.
description = re.sub(r'(?<!\n)\n(?!\n)', ' ', description)
return description

View File

@@ -2,11 +2,18 @@ from __future__ import annotations
import json
import zipfile
import os
import threading
from typing import ClassVar, Dict, Tuple, Any, Optional, Union, BinaryIO
import bsdiff4
semaphore = threading.Semaphore(os.cpu_count() or 4)
del threading
del os
class AutoPatchRegister(type):
patch_types: ClassVar[Dict[str, AutoPatchRegister]] = {}
@@ -57,11 +64,12 @@ class APContainer:
zip_file = file if file else self.path
if not zip_file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(zip_file, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
with semaphore: # TODO: remove semaphore once generate_output has a thread limit
with zipfile.ZipFile(
zip_file, "w", self.compression_method, True, self.compression_level) as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
manifest = self.get_manifest()

View File

@@ -101,8 +101,6 @@ components: List[Component] = [
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# TLoZ
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder
@@ -114,8 +112,6 @@ components: List[Component] = [
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Kingdom Hearts 2
Component('KH2 Client', "KH2Client"),
#MegaMan Battle Network 3
Component('MMBN3 Client', 'MMBN3Client', file_identifier=SuffixIdentifier('.apbn3'))

View File

@@ -1,53 +1,51 @@
import importlib
import os
import sys
import typing
import warnings
import zipimport
from typing import Dict, List, NamedTuple, TypedDict
folder = os.path.dirname(__file__)
from Utils import local_path, user_path
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else None
__all__ = {
"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name",
"network_data_package",
"AutoWorldRegister",
"world_sources",
"folder",
"local_folder",
"user_folder",
"GamesPackage",
"DataPackage",
}
if typing.TYPE_CHECKING:
from .AutoWorld import World
class GamesData(typing.TypedDict):
item_name_groups: typing.Dict[str, typing.List[str]]
item_name_to_id: typing.Dict[str, int]
location_name_groups: typing.Dict[str, typing.List[str]]
location_name_to_id: typing.Dict[str, int]
version: int
class GamesPackage(GamesData, total=False):
class GamesPackage(TypedDict, total=False):
item_name_groups: Dict[str, List[str]]
item_name_to_id: Dict[str, int]
location_name_groups: Dict[str, List[str]]
location_name_to_id: Dict[str, int]
checksum: str
version: int # TODO: Remove support after per game data packages API change.
class DataPackage(typing.TypedDict):
games: typing.Dict[str, GamesPackage]
class DataPackage(TypedDict):
games: Dict[str, GamesPackage]
class WorldSource(typing.NamedTuple):
class WorldSource(NamedTuple):
path: str # typically relative path from this module
is_zip: bool = False
relative: bool = True # relative to regular world import folder
def __repr__(self):
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@property
def resolved_path(self) -> str:
if self.relative:
return os.path.join(folder, self.path)
return os.path.join(local_folder, self.path)
return self.path
def load(self) -> bool:
@@ -56,6 +54,7 @@ class WorldSource(typing.NamedTuple):
importer = zipimport.zipimporter(self.resolved_path)
if hasattr(importer, "find_spec"): # new in Python 3.10
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
assert spec, f"{self.path} is not a loadable module"
mod = importlib.util.module_from_spec(spec)
else: # TODO: remove with 3.8 support
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
@@ -72,7 +71,7 @@ class WorldSource(typing.NamedTuple):
importlib.import_module(f".{self.path}", "worlds")
return True
except Exception as e:
except Exception:
# A single world failing can still mean enough is working for the user, log and carry on
import traceback
import io
@@ -86,40 +85,26 @@ class WorldSource(typing.NamedTuple):
# find potential world containers, currently folders and zip-importable .apworld's
world_sources: typing.List[WorldSource] = []
file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly
for file in os.scandir(folder):
# prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "."
if not file.name.startswith(("_", ".")):
if file.is_dir():
world_sources.append(WorldSource(file.name))
elif file.is_file() and file.name.endswith(".apworld"):
world_sources.append(WorldSource(file.name, is_zip=True))
world_sources: List[WorldSource] = []
for folder in (folder for folder in (user_folder, local_folder) if folder):
relative = folder == local_folder
for entry in os.scandir(folder):
# prevent loading of __pycache__ and allow _* for non-world folders, disable files/folders starting with "."
if not entry.name.startswith(("_", ".")):
file_name = entry.name if relative else os.path.join(folder, entry.name)
if entry.is_dir():
world_sources.append(WorldSource(file_name, relative=relative))
elif entry.is_file() and entry.name.endswith(".apworld"):
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
# import all submodules to trigger AutoWorldRegister
world_sources.sort()
for world_source in world_sources:
world_source.load()
lookup_any_item_id_to_name = {}
lookup_any_location_id_to_name = {}
games: typing.Dict[str, GamesPackage] = {}
# Build the data package for each game.
from .AutoWorld import AutoWorldRegister
# Build the data package for each game.
for world_name, world in AutoWorldRegister.world_types.items():
games[world_name] = world.get_data_package_data()
lookup_any_item_id_to_name.update(world.item_id_to_name)
lookup_any_location_id_to_name.update(world.location_id_to_name)
network_data_package: DataPackage = {
"games": games,
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
}
# Set entire datapackage to version 0 if any of them are set to 0
if any(not world.data_version for world in AutoWorldRegister.world_types.values()):
import logging
logging.warning(f"Datapackage is in custom mode. Custom Worlds: "
f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}")

View File

@@ -9,10 +9,12 @@ import asyncio
import base64
import enum
import json
import sys
import typing
BIZHAWK_SOCKET_PORT = 43055
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
BIZHAWK_SOCKET_PORT_RANGE_SIZE = 5
class ConnectionStatus(enum.IntEnum):
@@ -45,11 +47,13 @@ class BizHawkContext:
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
connection_status: ConnectionStatus
_lock: asyncio.Lock
_port: typing.Optional[int]
def __init__(self) -> None:
self.streams = None
self.connection_status = ConnectionStatus.NOT_CONNECTED
self._lock = asyncio.Lock()
self._port = None
async def _send_message(self, message: str):
async with self._lock:
@@ -86,15 +90,24 @@ class BizHawkContext:
async def connect(ctx: BizHawkContext) -> bool:
"""Attempts to establish a connection with the connector script. Returns True if successful."""
try:
ctx.streams = await asyncio.open_connection("localhost", BIZHAWK_SOCKET_PORT)
ctx.connection_status = ConnectionStatus.TENTATIVE
return True
except (TimeoutError, ConnectionRefusedError):
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
return False
"""Attempts to establish a connection with a connector script. Returns True if successful."""
rotation_steps = 0 if ctx._port is None else ctx._port - BIZHAWK_SOCKET_PORT_RANGE_START
ports = [*range(BIZHAWK_SOCKET_PORT_RANGE_START, BIZHAWK_SOCKET_PORT_RANGE_START + BIZHAWK_SOCKET_PORT_RANGE_SIZE)]
ports = ports[rotation_steps:] + ports[:rotation_steps]
for port in ports:
try:
ctx.streams = await asyncio.open_connection("localhost", port)
ctx.connection_status = ConnectionStatus.TENTATIVE
ctx._port = port
return True
except (TimeoutError, ConnectionRefusedError):
continue
# No ports worked
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
return False
def disconnect(ctx: BizHawkContext) -> None:
@@ -113,7 +126,20 @@ async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[s
"""Sends a list of requests to the BizHawk connector and returns their responses.
It's likely you want to use the wrapper functions instead of this."""
return json.loads(await ctx._send_message(json.dumps(req_list)))
responses = json.loads(await ctx._send_message(json.dumps(req_list)))
errors: typing.List[ConnectorError] = []
for response in responses:
if response["type"] == "ERROR":
errors.append(ConnectorError(response["err"]))
if errors:
if sys.version_info >= (3, 11, 0):
raise ExceptionGroup("Connector script returned errors", errors) # noqa
else:
raise errors[0]
return responses
async def ping(ctx: BizHawkContext) -> None:
@@ -233,7 +259,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[
return None
else:
if item["type"] != "READ_RESPONSE":
raise SyncError(f"Expected response of type READ_RESPONSE or GUARD_RESPONSE but got {res['type']}")
raise SyncError(f"Expected response of type READ_RESPONSE or GUARD_RESPONSE but got {item['type']}")
ret.append(base64.b64decode(item["value"]))
@@ -285,7 +311,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tupl
return False
else:
if item["type"] != "WRITE_RESPONSE":
raise SyncError(f"Expected response of type WRITE_RESPONSE or GUARD_RESPONSE but got {res['type']}")
raise SyncError(f"Expected response of type WRITE_RESPONSE or GUARD_RESPONSE but got {item['type']}")
return True

View File

@@ -5,6 +5,7 @@ checking or launching the client, otherwise it will probably cause circular impo
import asyncio
import enum
import subprocess
import traceback
from typing import Any, Dict, Optional
@@ -21,6 +22,13 @@ from .client import BizHawkClient, AutoBizHawkClientRegister
EXPECTED_SCRIPT_VERSION = 1
class AuthStatus(enum.IntEnum):
NOT_AUTHENTICATED = 0
NEED_INFO = 1
PENDING = 2
AUTHENTICATED = 3
class BizHawkClientCommandProcessor(ClientCommandProcessor):
def _cmd_bh(self):
"""Shows the current status of the client's connection to BizHawk"""
@@ -35,6 +43,8 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
auth_status: AuthStatus
password_requested: bool
client_handler: Optional[BizHawkClient]
slot_data: Optional[Dict[str, Any]] = None
rom_hash: Optional[str] = None
@@ -45,6 +55,8 @@ class BizHawkClientContext(CommonContext):
def __init__(self, server_address: Optional[str], password: Optional[str]):
super().__init__(server_address, password)
self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.password_requested = False
self.client_handler = None
self.bizhawk_ctx = BizHawkContext()
self.watcher_timeout = 0.5
@@ -61,10 +73,41 @@ class BizHawkClientContext(CommonContext):
def on_package(self, cmd, args):
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
self.auth_status = AuthStatus.AUTHENTICATED
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
async def server_auth(self, password_requested: bool = False):
self.password_requested = password_requested
if self.bizhawk_ctx.connection_status != ConnectionStatus.CONNECTED:
logger.info("Awaiting connection to BizHawk before authenticating")
return
if self.client_handler is None:
return
# Ask handler to set auth
if self.auth is None:
self.auth_status = AuthStatus.NEED_INFO
await self.client_handler.set_auth(self)
# Handler didn't set auth, ask user for slot name
if self.auth is None:
await self.get_username()
if password_requested and not self.password:
self.auth_status = AuthStatus.NEED_INFO
await super(BizHawkClientContext, self).server_auth(password_requested)
await self.send_connect()
self.auth_status = AuthStatus.PENDING
async def disconnect(self, allow_autoreconnect: bool = False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED
await super().disconnect(allow_autoreconnect)
async def _game_watcher(ctx: BizHawkClientContext):
showed_connecting_message = False
@@ -87,7 +130,18 @@ async def _game_watcher(ctx: BizHawkClientContext):
logger.info("Waiting to connect to BizHawk...")
showed_connecting_message = True
if not await connect(ctx.bizhawk_ctx):
# Since a call to `connect` can take a while to return, this will cancel connecting
# if the user has decided to close the client.
connect_task = asyncio.create_task(connect(ctx.bizhawk_ctx), name="BizHawkConnect")
exit_task = asyncio.create_task(ctx.exit_event.wait(), name="ExitWait")
await asyncio.wait([connect_task, exit_task], return_when=asyncio.FIRST_COMPLETED)
if exit_task.done():
connect_task.cancel()
return
if not connect_task.result():
# Failed to connect
continue
showed_no_handler_message = False
@@ -109,12 +163,13 @@ async def _game_watcher(ctx: BizHawkClientContext):
rom_hash = await get_hash(ctx.bizhawk_ctx)
if ctx.rom_hash is not None and ctx.rom_hash != rom_hash:
if ctx.server is not None:
if ctx.server is not None and not ctx.server.socket.closed:
logger.info(f"ROM changed. Disconnecting from server.")
await ctx.disconnect(True)
ctx.auth = None
ctx.username = None
ctx.client_handler = None
await ctx.disconnect(False)
ctx.rom_hash = rom_hash
if ctx.client_handler is None:
@@ -136,15 +191,14 @@ async def _game_watcher(ctx: BizHawkClientContext):
except NotConnectedError:
continue
# Get slot name and send `Connect`
if ctx.server is not None and ctx.username is None:
await ctx.client_handler.set_auth(ctx)
if ctx.auth is None:
await ctx.get_username()
await ctx.send_connect()
# Server auth
if ctx.server is not None and not ctx.server.socket.closed:
if ctx.auth_status == AuthStatus.NOT_AUTHENTICATED:
Utils.async_start(ctx.server_auth(ctx.password_requested))
else:
ctx.auth_status = AuthStatus.NOT_AUTHENTICATED
# Call the handler's game watcher
await ctx.client_handler.game_watcher(ctx)

View File

@@ -6,9 +6,8 @@ from typing import Optional, Any
import Utils
from .Locations import AdventureLocation, LocationData
from Utils import OptionsType
from settings import get_settings
from worlds.Files import APDeltaPatch, AutoPatchRegister, APContainer
from itertools import chain
import bsdiff4
@@ -313,9 +312,8 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def get_base_rom_path(file_name: str = "") -> str:
options: OptionsType = Utils.get_options()
if not file_name:
file_name = options["adventure_options"]["rom_file"]
file_name = get_settings()["adventure_options"]["rom_file"]
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name

View File

@@ -520,7 +520,8 @@ class ALTTPSNIClient(SNIClient):
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
currently_dead = gamemode[0] in DEATH_MODES
await ctx.handle_deathlink_state(currently_dead)
await ctx.handle_deathlink_state(currently_dead,
ctx.player_names[ctx.slot] + " ran out of hearts." if ctx.slot else "")
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)

View File

@@ -264,7 +264,8 @@ 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)
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True,
name="LttP Dungeon Items")
dungeon_music_addresses = {'Eastern Palace - Prize': [0x1559A],

View File

@@ -293,7 +293,6 @@ def generate_itempool(world):
loc.access_rule = lambda state: has_triforce_pieces(state, player)
region.locations.append(loc)
multiworld.clear_location_cache()
multiworld.push_item(loc, ItemFactory('Triforce', player), False)
loc.event = True

View File

@@ -103,9 +103,10 @@ class map_shuffle(DungeonItem):
class key_drop_shuffle(Toggle):
"""Shuffle keys found in pots and dropped from killed enemies."""
"""Shuffle keys found in pots and dropped from killed enemies,
respects the small key and big key shuffle options."""
display_name = "Key Drop Shuffle"
default = False
class Crystals(Range):
range_start = 0

View File

@@ -783,11 +783,12 @@ def get_nonnative_item_sprite(code: int) -> int:
def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
local_random = world.per_slot_randoms[player]
local_world = world.worlds[player]
# patch items
for location in world.get_locations():
if location.player != player or location.address is None or location.shop_slot is not None:
for location in world.get_locations(player):
if location.address is None or location.shop_slot is not None:
continue
itemid = location.item.code if location.item is not None else 0x5A
@@ -1190,12 +1191,8 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
])
# set Fountain bottle exchange items
if world.difficulty[player] in ['hard', 'expert']:
rom.write_byte(0x348FF, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x48][local_random.randint(0, 5)])
rom.write_byte(0x3493B, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x48][local_random.randint(0, 5)])
else:
rom.write_byte(0x348FF, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x3D, 0x48][local_random.randint(0, 6)])
rom.write_byte(0x3493B, [0x16, 0x2B, 0x2C, 0x2D, 0x3C, 0x3D, 0x48][local_random.randint(0, 6)])
rom.write_byte(0x348FF, item_table[local_world.waterfall_fairy_bottle_fill].item_code)
rom.write_byte(0x3493B, item_table[local_world.pyramid_fairy_bottle_fill].item_code)
# enable Fat Fairy Chests
rom.write_bytes(0x1FC16, [0xB1, 0xC6, 0xF9, 0xC9, 0xC6, 0xF9])
@@ -2247,7 +2244,7 @@ def write_strings(rom, world, player):
tt['sign_north_of_links_house'] = '> Randomizer The telepathic tiles can have hints!'
hint_locations = HintLocations.copy()
local_random.shuffle(hint_locations)
all_entrances = [entrance for entrance in world.get_entrances() if entrance.player == player]
all_entrances = list(world.get_entrances(player))
local_random.shuffle(all_entrances)
# First we take care of the one inconvenient dungeon in the appropriately simple shuffles.

View File

@@ -198,8 +198,13 @@ def global_rules(world, player):
# determines which S&Q locations are available - hide from paths since it isn't an in-game location
for exit in world.get_region('Menu', player).exits:
exit.hide_path = True
set_rule(world.get_entrance('Old Man S&Q', player), lambda state: state.can_reach('Old Man', 'Location', player))
try:
old_man_sq = world.get_entrance('Old Man S&Q', player)
except KeyError:
pass # it doesn't exist, should be dungeon-only unittests
else:
old_man = world.get_location("Old Man", player)
set_rule(old_man_sq, lambda state: old_man.can_reach(state))
set_rule(world.get_location('Sunken Treasure', player), lambda state: state.has('Open Floodgate', player))
set_rule(world.get_location('Dark Blacksmith Ruins', player), lambda state: state.has('Return Smith', player))
@@ -1527,16 +1532,16 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool):
# Helper functions to determine if the moon pearl is required
if inverted:
def is_bunny(region):
return region.is_light_world
return region and region.is_light_world
def is_link(region):
return region.is_dark_world
return region and region.is_dark_world
else:
def is_bunny(region):
return region.is_dark_world
return region and region.is_dark_world
def is_link(region):
return region.is_light_world
return region and region.is_light_world
def get_rule_to_add(region, location = None, connecting_entrance = None):
# In OWG, a location can potentially be superbunny-mirror accessible or
@@ -1604,21 +1609,20 @@ def set_bunny_rules(world: MultiWorld, player: int, inverted: bool):
return options_to_access_rule(possible_options)
# Add requirements for bunny-impassible caves if link is a bunny in them
for region in [world.get_region(name, player) for name in bunny_impassable_caves]:
for region in (world.get_region(name, player) for name in bunny_impassable_caves):
if not is_bunny(region):
continue
rule = get_rule_to_add(region)
for exit in region.exits:
add_rule(exit, rule)
for region_exit in region.exits:
add_rule(region_exit, rule)
paradox_shop = world.get_region('Light World Death Mountain Shop', player)
if is_bunny(paradox_shop):
add_rule(paradox_shop.entrances[0], get_rule_to_add(paradox_shop))
# Add requirements for all locations that are actually in the dark world, except those available to the bunny, including dungeon revival
for entrance in world.get_entrances():
if entrance.player == player and is_bunny(entrance.connected_region):
for entrance in world.get_entrances(player):
if is_bunny(entrance.connected_region):
if world.logic[player] in ['minorglitches', 'owglitches', 'hybridglitches', 'nologic'] :
if entrance.connected_region.type == LTTPRegionType.Dungeon:
if entrance.parent_region.type != LTTPRegionType.Dungeon and entrance.connected_region.name in OverworldGlitchRules.get_invalid_bunny_revival_dungeons():

View File

@@ -348,7 +348,6 @@ def create_shops(world, player: int):
loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player)
loc.shop_slot_disabled = True
shop.region.locations.append(loc)
world.clear_location_cache()
class ShopData(NamedTuple):
@@ -619,6 +618,4 @@ def create_dynamic_shop_locations(world, player):
if shop.type == ShopType.TakeAny:
loc.shop_slot_disabled = True
shop.region.locations.append(loc)
world.clear_location_cache()
loc.shop_slot = i

View File

@@ -31,7 +31,7 @@ def can_shoot_arrows(state: CollectionState, player: int) -> bool:
def has_triforce_pieces(state: CollectionState, player: int) -> bool:
count = state.multiworld.treasure_hunt_count[player]
return state.item_count('Triforce Piece', player) + state.item_count('Power Star', player) >= count
return state.count('Triforce Piece', player) + state.count('Power Star', player) >= count
def has_crystals(state: CollectionState, count: int, player: int) -> bool:
@@ -60,9 +60,9 @@ def has_hearts(state: CollectionState, player: int, count: int) -> int:
def heart_count(state: CollectionState, player: int) -> int:
# Warning: This only considers items that are marked as advancement items
diff = state.multiworld.difficulty_requirements[player]
return min(state.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ state.item_count('Sanctuary Heart Container', player) \
+ min(state.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ state.count('Sanctuary Heart Container', player) \
+ min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
+ 3 # starting hearts

View File

@@ -31,7 +31,7 @@ def fake_pearl_state(state, player):
if state.has('Moon Pearl', player):
return state
fake_state = state.copy()
fake_state.prog_items['Moon Pearl', player] += 1
fake_state.prog_items[player]['Moon Pearl'] += 1
return fake_state

View File

@@ -195,7 +195,7 @@ class ALTTPWorld(World):
"Ganons Tower": {"Ganons Tower - Bob's Torch", "Ganons Tower - Hope Room - Left",
"Ganons Tower - Hope Room - Right", "Ganons Tower - Tile Room",
"Ganons Tower - Compass Room - Top Left", "Ganons Tower - Compass Room - Top Right",
"Ganons Tower - Compass Room - Bottom Left", "Ganons Tower - Compass Room - Bottom Left",
"Ganons Tower - Compass Room - Bottom Left", "Ganons Tower - Compass Room - Bottom Right",
"Ganons Tower - DMs Room - Top Left", "Ganons Tower - DMs Room - Top Right",
"Ganons Tower - DMs Room - Bottom Left", "Ganons Tower - DMs Room - Bottom Right",
"Ganons Tower - Map Chest", "Ganons Tower - Firesnake Room",
@@ -249,6 +249,8 @@ class ALTTPWorld(World):
rom_name_available_event: threading.Event
has_progressive_bows: bool
dungeons: typing.Dict[str, Dungeon]
waterfall_fairy_bottle_fill: str
pyramid_fairy_bottle_fill: str
def __init__(self, *args, **kwargs):
self.dungeon_local_item_names = set()
@@ -256,6 +258,8 @@ class ALTTPWorld(World):
self.rom_name_available_event = threading.Event()
self.has_progressive_bows = False
self.dungeons = {}
self.waterfall_fairy_bottle_fill = "Bottle"
self.pyramid_fairy_bottle_fill = "Bottle"
super(ALTTPWorld, self).__init__(*args, **kwargs)
@classmethod
@@ -273,52 +277,62 @@ class ALTTPWorld(World):
def generate_early(self):
player = self.player
world = self.multiworld
multiworld = self.multiworld
if world.mode[player] == 'standard' \
and world.smallkey_shuffle[player] \
and world.smallkey_shuffle[player] != smallkey_shuffle.option_universal \
and world.smallkey_shuffle[player] != smallkey_shuffle.option_own_dungeons \
and world.smallkey_shuffle[player] != smallkey_shuffle.option_start_with:
# fairy bottle fills
bottle_options = [
"Bottle (Red Potion)", "Bottle (Green Potion)", "Bottle (Blue Potion)",
"Bottle (Bee)", "Bottle (Good Bee)"
]
if multiworld.difficulty[player] not in ["hard", "expert"]:
bottle_options.append("Bottle (Fairy)")
self.waterfall_fairy_bottle_fill = self.random.choice(bottle_options)
self.pyramid_fairy_bottle_fill = self.random.choice(bottle_options)
if multiworld.mode[player] == 'standard' \
and multiworld.smallkey_shuffle[player] \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_universal \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_own_dungeons \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_start_with:
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
# system for sharing ER layouts
self.er_seed = str(world.random.randint(0, 2 ** 64))
self.er_seed = str(multiworld.random.randint(0, 2 ** 64))
if "-" in world.shuffle[player]:
shuffle, seed = world.shuffle[player].split("-", 1)
world.shuffle[player] = shuffle
if "-" in multiworld.shuffle[player]:
shuffle, seed = multiworld.shuffle[player].split("-", 1)
multiworld.shuffle[player] = shuffle
if shuffle == "vanilla":
self.er_seed = "vanilla"
elif seed.startswith("group-") or world.is_race:
self.er_seed = get_same_seed(world, (
shuffle, seed, world.retro_caves[player], world.mode[player], world.logic[player]))
elif seed.startswith("group-") or multiworld.is_race:
self.er_seed = get_same_seed(multiworld, (
shuffle, seed, multiworld.retro_caves[player], multiworld.mode[player], multiworld.logic[player]))
else: # not a race or group seed, use set seed as is.
self.er_seed = seed
elif world.shuffle[player] == "vanilla":
elif multiworld.shuffle[player] == "vanilla":
self.er_seed = "vanilla"
for dungeon_item in ["smallkey_shuffle", "bigkey_shuffle", "compass_shuffle", "map_shuffle"]:
option = getattr(world, dungeon_item)[player]
option = getattr(multiworld, dungeon_item)[player]
if option == "own_world":
world.local_items[player].value |= self.item_name_groups[option.item_name_group]
multiworld.local_items[player].value |= self.item_name_groups[option.item_name_group]
elif option == "different_world":
world.non_local_items[player].value |= self.item_name_groups[option.item_name_group]
if world.mode[player] == "standard":
world.non_local_items[player].value -= {"Small Key (Hyrule Castle)"}
multiworld.non_local_items[player].value |= self.item_name_groups[option.item_name_group]
if multiworld.mode[player] == "standard":
multiworld.non_local_items[player].value -= {"Small Key (Hyrule Castle)"}
elif option.in_dungeon:
self.dungeon_local_item_names |= self.item_name_groups[option.item_name_group]
if option == "original_dungeon":
self.dungeon_specific_item_names |= self.item_name_groups[option.item_name_group]
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
multiworld.difficulty_requirements[player] = difficulties[multiworld.difficulty[player]]
# enforce pre-defined local items.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].value.add('Triforce Piece')
if multiworld.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
multiworld.local_items[player].value.add('Triforce Piece')
# Not possible to place crystals outside boss prizes yet (might as well make it consistent with pendants too).
world.non_local_items[player].value -= item_name_groups['Pendants']
world.non_local_items[player].value -= item_name_groups['Crystals']
multiworld.non_local_items[player].value -= item_name_groups['Pendants']
multiworld.non_local_items[player].value -= item_name_groups['Crystals']
create_dungeons = create_dungeons
@@ -364,7 +378,6 @@ class ALTTPWorld(World):
world.register_indirect_condition(world.get_region(region_name, player),
world.get_entrance(entrance_name, player))
def collect_item(self, state: CollectionState, item: Item, remove=False):
item_name = item.name
if item_name.startswith('Progressive '):
@@ -470,7 +483,8 @@ class ALTTPWorld(World):
prizepool = unplaced_prizes.copy()
prize_locs = empty_crystal_locations.copy()
world.random.shuffle(prize_locs)
fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True)
fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True,
name="LttP Dungeon Prizes")
except FillError as e:
lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
attempts - attempt)
@@ -585,27 +599,26 @@ class ALTTPWorld(World):
for player in checks_in_area:
checks_in_area[player]["Total"] = 0
for location in multiworld.get_locations():
if location.game == cls.game and type(location.address) is int:
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
if location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
else:
assert False, "Unknown Location area."
# TODO: remove Total as it's duplicated data and breaks consistent typing
checks_in_area[location.player]["Total"] += 1
for location in multiworld.get_locations(player):
if location.game == cls.game and type(location.address) is int:
main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
if location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
else:
assert False, "Unknown Location area."
# TODO: remove Total as it's duplicated data and breaks consistent typing
checks_in_area[location.player]["Total"] += 1
multidata["checks_in_area"].update(checks_in_area)
@@ -693,13 +706,18 @@ class ALTTPWorld(World):
spoiler_handle.write('Prize shuffle %s\n' % self.multiworld.shuffle_prizes[self.player])
def write_spoiler(self, spoiler_handle: typing.TextIO) -> None:
player_name = self.multiworld.get_player_name(self.player)
spoiler_handle.write("\n\nMedallions:\n")
spoiler_handle.write(f"\nMisery Mire ({self.multiworld.get_player_name(self.player)}):"
spoiler_handle.write(f"\nMisery Mire ({player_name}):"
f" {self.multiworld.required_medallions[self.player][0]}")
spoiler_handle.write(
f"\nTurtle Rock ({self.multiworld.get_player_name(self.player)}):"
f"\nTurtle Rock ({player_name}):"
f" {self.multiworld.required_medallions[self.player][1]}")
spoiler_handle.write("\n\nFairy Fountain Bottle Fill:\n")
spoiler_handle.write(f"\nPyramid Fairy ({player_name}):"
f" {self.pyramid_fairy_bottle_fill}")
spoiler_handle.write(f"\nWaterfall Fairy ({player_name}):"
f" {self.waterfall_fairy_bottle_fill}")
if self.multiworld.boss_shuffle[self.player] != "none":
def create_boss_map() -> typing.Dict:
boss_map = {
@@ -830,4 +848,4 @@ class ALttPLogic(LogicMixin):
return True
if self.multiworld.smallkey_shuffle[player] == smallkey_shuffle.option_universal:
return can_buy_unlimited(self, 'Small Key (Universal)', player)
return self.prog_items[item, player] >= count
return self.prog_items[player][item] >= count

View File

@@ -1,6 +1,6 @@
from BaseClasses import CollectionState, ItemClassification
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import connect_simple, mandatory_connections
from worlds.alttp.EntranceShuffle import mandatory_connections, connect_simple
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import create_regions

View File

@@ -5,12 +5,7 @@ from ..generic.Rules import set_rule
class ArchipIDLELogic(LogicMixin):
def _archipidle_location_is_accessible(self, player_id, items_required):
items_received = 0
for item in self.prog_items:
if item[1] == player_id:
items_received += 1
return items_received >= items_required
return sum(self.prog_items[player_id].values()) >= items_required
def set_rules(world: MultiWorld, player: int):

View File

@@ -67,6 +67,7 @@ class StartingLocation(ChoiceIsRandom):
class Ending(Choice):
"""Choose which ending is required to complete the game.
Talking to Tirso in Albero will tell you the selected ending for the current game.
Ending A: Collect all thorn upgrades.
Ending C: Collect all thorn upgrades and the Holy Wound of Abnegation."""
display_name = "Ending"

View File

@@ -578,11 +578,12 @@ def rules(blasphemousworld):
or state.has("Purified Hand of the Nun", player)
or state.has("D01Z02S03[NW]", player)
and (
can_cross_gap(state, logic, player, 1)
can_cross_gap(state, logic, player, 2)
or state.has("Lorquiana", player)
or aubade(state, player)
or state.has("Cantina of the Blue Rose", player)
or charge_beam(state, player)
or state.has("Ranged Skill", player)
)
))
set_rule(world.get_location("Albero: Lvdovico's 1st reward", player),
@@ -702,10 +703,11 @@ def rules(blasphemousworld):
# Items
set_rule(world.get_location("WotBC: Cliffside Child of Moonlight", player),
lambda state: (
can_cross_gap(state, logic, player, 1)
can_cross_gap(state, logic, player, 2)
or aubade(state, player)
or charge_beam(state, player)
or state.has_any({"Lorquiana", "Cante Jondo of the Three Sisters", "Cantina of the Blue Rose", "Cloistered Ruby"}, player)
or state.has_any({"Lorquiana", "Cante Jondo of the Three Sisters", "Cantina of the Blue Rose", \
"Cloistered Ruby", "Ranged Skill"}, player)
or precise_skips_allowed(logic)
))
# Doors

View File

@@ -19,6 +19,7 @@ In addition, there are other changes to the game that make it better optimized f
- The Apodictic Heart of Mea Culpa can be unequipped.
- Dying with the Immaculate Bead is unnecessary, it is automatically upgraded to the Weight of True Guilt.
- If the option is enabled, the 34 corpses in game will have their messages changed to give hints about certain items and locations. The Shroud of Dreamt Sins is not required to hear them.
- Talking to Tirso in Albero will tell you the selected ending for the current game.
## What has been changed about the side quests?

View File

@@ -1,37 +1,34 @@
from ..generic.Rules import set_rule, add_rule
from BaseClasses import MultiWorld
from ..AutoWorld import LogicMixin
from ..generic.Rules import set_rule
from BaseClasses import MultiWorld, CollectionState
class ChecksFinderLogic(LogicMixin):
def _has_total(self, player: int, total: int):
return (self.item_count('Map Width', player)+self.item_count('Map Height', player)+
self.item_count('Map Bombs', player)) >= total
def _has_total(state: CollectionState, player: int, total: int):
return (state.count('Map Width', player) + state.count('Map Height', player) +
state.count('Map Bombs', player)) >= total
# Sets rules on entrances and advancements that are always applied
def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location(("Tile 6"), player), lambda state: state._has_total(player, 1))
set_rule(world.get_location(("Tile 7"), player), lambda state: state._has_total(player, 2))
set_rule(world.get_location(("Tile 8"), player), lambda state: state._has_total(player, 3))
set_rule(world.get_location(("Tile 9"), player), lambda state: state._has_total(player, 4))
set_rule(world.get_location(("Tile 10"), player), lambda state: state._has_total(player, 5))
set_rule(world.get_location(("Tile 11"), player), lambda state: state._has_total(player, 6))
set_rule(world.get_location(("Tile 12"), player), lambda state: state._has_total(player, 7))
set_rule(world.get_location(("Tile 13"), player), lambda state: state._has_total(player, 8))
set_rule(world.get_location(("Tile 14"), player), lambda state: state._has_total(player, 9))
set_rule(world.get_location(("Tile 15"), player), lambda state: state._has_total(player, 10))
set_rule(world.get_location(("Tile 16"), player), lambda state: state._has_total(player, 11))
set_rule(world.get_location(("Tile 17"), player), lambda state: state._has_total(player, 12))
set_rule(world.get_location(("Tile 18"), player), lambda state: state._has_total(player, 13))
set_rule(world.get_location(("Tile 19"), player), lambda state: state._has_total(player, 14))
set_rule(world.get_location(("Tile 20"), player), lambda state: state._has_total(player, 15))
set_rule(world.get_location(("Tile 21"), player), lambda state: state._has_total(player, 16))
set_rule(world.get_location(("Tile 22"), player), lambda state: state._has_total(player, 17))
set_rule(world.get_location(("Tile 23"), player), lambda state: state._has_total(player, 18))
set_rule(world.get_location(("Tile 24"), player), lambda state: state._has_total(player, 19))
set_rule(world.get_location(("Tile 25"), player), lambda state: state._has_total(player, 20))
set_rule(world.get_location("Tile 6", player), lambda state: _has_total(state, player, 1))
set_rule(world.get_location("Tile 7", player), lambda state: _has_total(state, player, 2))
set_rule(world.get_location("Tile 8", player), lambda state: _has_total(state, player, 3))
set_rule(world.get_location("Tile 9", player), lambda state: _has_total(state, player, 4))
set_rule(world.get_location("Tile 10", player), lambda state: _has_total(state, player, 5))
set_rule(world.get_location("Tile 11", player), lambda state: _has_total(state, player, 6))
set_rule(world.get_location("Tile 12", player), lambda state: _has_total(state, player, 7))
set_rule(world.get_location("Tile 13", player), lambda state: _has_total(state, player, 8))
set_rule(world.get_location("Tile 14", player), lambda state: _has_total(state, player, 9))
set_rule(world.get_location("Tile 15", player), lambda state: _has_total(state, player, 10))
set_rule(world.get_location("Tile 16", player), lambda state: _has_total(state, player, 11))
set_rule(world.get_location("Tile 17", player), lambda state: _has_total(state, player, 12))
set_rule(world.get_location("Tile 18", player), lambda state: _has_total(state, player, 13))
set_rule(world.get_location("Tile 19", player), lambda state: _has_total(state, player, 14))
set_rule(world.get_location("Tile 20", player), lambda state: _has_total(state, player, 15))
set_rule(world.get_location("Tile 21", player), lambda state: _has_total(state, player, 16))
set_rule(world.get_location("Tile 22", player), lambda state: _has_total(state, player, 17))
set_rule(world.get_location("Tile 23", player), lambda state: _has_total(state, player, 18))
set_rule(world.get_location("Tile 24", player), lambda state: _has_total(state, player, 19))
set_rule(world.get_location("Tile 25", player), lambda state: _has_total(state, player, 20))
# Sets rules on completion condition

View File

@@ -14,8 +14,8 @@ class ChecksFinderWeb(WebWorld):
"A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers "
"single-player, multiworld, and related software.",
"English",
"checksfinder_en.md",
"checksfinder/en",
"setup_en.md",
"setup/en",
["Mewlif"]
)]
@@ -69,8 +69,8 @@ class ChecksFinderWorld(World):
def create_regions(self):
menu = Region("Menu", self.player, self.multiworld)
board = Region("Board", self.player, self.multiworld)
board.locations = [ChecksFinderAdvancement(self.player, loc_name, loc_data.id, board)
for loc_name, loc_data in advancement_table.items() if loc_data.region == board.name]
board.locations += [ChecksFinderAdvancement(self.player, loc_name, loc_data.id, board)
for loc_name, loc_data in advancement_table.items() if loc_data.region == board.name]
connection = Entrance(self.player, "New Board", menu)
menu.exits.append(connection)

View File

@@ -14,11 +14,18 @@ many checks as you have gained items, plus five to start with being available.
## When the player receives an item, what happens?
When the player receives an item in ChecksFinder, it either can make the future boards they play be bigger in width or
height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being
bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a number
height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being
bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a
number
next to an icon, the number is how many you have gotten and the icon represents which item it is.
## What is the victory condition?
Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map
Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received.
Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received.
## Unique Local Commands
The following command is only available when using the ChecksFinderClient to play with Archipelago.
- `/resync` Manually trigger a resync.

View File

@@ -1271,6 +1271,14 @@ _cut_content_items = [DS3ItemData(row[0], row[1], False, row[2]) for row in [
("Dorris Swarm", 0x40393870, DS3ItemCategory.SKIP),
]]
item_descriptions = {
"Cinders": """
All four Cinders of a Lord.
Once you have these four, you can fight Soul of Cinder and win the game.
""",
}
_all_items = _vanilla_items + _dlc_items
item_dictionary = {item_data.name: item_data for item_data in _all_items}

View File

@@ -171,6 +171,16 @@ class MaxLevelsIn10WeaponPoolOption(Range):
default = 10
class EarlySmallLothricBanner(Choice):
"""This option makes it so the user can choose to force the Small Lothric Banner into an early sphere in their world or
into an early sphere across all worlds."""
display_name = "Early Small Lothric Banner"
option_off = 0
option_early_global = 1
option_early_local = 2
default = option_off
class LateBasinOfVowsOption(Toggle):
"""This option makes it so the Basin of Vows is still randomized, but guarantees you that you wont have to venture into
Lothric Castle to find your Small Lothric Banner to get out of High Wall of Lothric. So you may find Basin of Vows early,
@@ -215,6 +225,7 @@ dark_souls_options: typing.Dict[str, Option] = {
"max_levels_in_5": MaxLevelsIn5WeaponPoolOption,
"min_levels_in_10": MinLevelsIn10WeaponPoolOption,
"max_levels_in_10": MaxLevelsIn10WeaponPoolOption,
"early_banner": EarlySmallLothricBanner,
"late_basin_of_vows": LateBasinOfVowsOption,
"late_dlc": LateDLCOption,
"no_spell_requirements": NoSpellRequirementsOption,

View File

@@ -7,9 +7,9 @@ from Options import Toggle
from worlds.AutoWorld import World, WebWorld
from worlds.generic.Rules import set_rule, add_rule, add_item_rule
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names, item_descriptions
from .Locations import DarkSouls3Location, DS3LocationCategory, location_tables, location_dictionary
from .Options import RandomizeWeaponLevelOption, PoolTypeOption, dark_souls_options
from .Options import RandomizeWeaponLevelOption, PoolTypeOption, EarlySmallLothricBanner, dark_souls_options
class DarkSouls3Web(WebWorld):
@@ -60,6 +60,7 @@ class DarkSouls3World(World):
"Cinders of a Lord - Lothric Prince"
}
}
item_descriptions = item_descriptions
def __init__(self, multiworld: MultiWorld, player: int):
@@ -85,6 +86,10 @@ class DarkSouls3World(World):
self.enabled_location_categories.add(DS3LocationCategory.NPC)
if self.multiworld.enable_key_locations[self.player] == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.KEY)
if self.multiworld.early_banner[self.player] == EarlySmallLothricBanner.option_early_global:
self.multiworld.early_items[self.player]['Small Lothric Banner'] = 1
elif self.multiworld.early_banner[self.player] == EarlySmallLothricBanner.option_early_local:
self.multiworld.local_early_items[self.player]['Small Lothric Banner'] = 1
if self.multiworld.enable_boss_locations[self.player] == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.BOSS)
if self.multiworld.enable_misc_locations[self.player] == Toggle.option_true:

View File

@@ -1,6 +1,6 @@
from dataclasses import dataclass
from Options import Choice, DeathLink, PerGameCommonOptions, SpecialRange
from Options import Choice, DeathLink, NamedRange, PerGameCommonOptions
class DoubleJumpGlitch(Choice):
@@ -33,7 +33,7 @@ class CoinSanity(Choice):
default = 0
class CoinSanityRange(SpecialRange):
class CoinSanityRange(NamedRange):
"""This is the amount of coins in a coin bundle
You need to collect that number of coins to get a location check, and when receiving coin items, you will get bundles of this size
It is highly recommended to not set this value below 10, as it generates a very large number of boring locations and items.

View File

@@ -12,11 +12,11 @@ def create_event(player, event: str) -> DLCQuestItem:
def has_enough_coin(player: int, coin: int):
return lambda state: state.prog_items[" coins", player] >= coin
return lambda state: state.prog_items[player][" coins"] >= coin
def has_enough_coin_freemium(player: int, coin: int):
return lambda state: state.prog_items[" coins freemium", player] >= coin
return lambda state: state.prog_items[player][" coins freemium"] >= coin
def set_rules(world, player, World_Options: Options.DLCQuestOptions):

View File

@@ -3,7 +3,7 @@ from typing import Union
from BaseClasses import Tutorial, CollectionState
from worlds.AutoWorld import WebWorld, World
from . import Options
from .Items import DLCQuestItem, ItemData, create_items, item_table
from .Items import DLCQuestItem, ItemData, create_items, item_table, items_by_group, Group
from .Locations import DLCQuestLocation, location_table
from .Options import DLCQuestOptions
from .Regions import create_regions
@@ -60,7 +60,9 @@ class DLCqworld(World):
created_items = create_items(self, self.options, locations_count + len(items_to_exclude), self.multiworld.random)
self.multiworld.itempool += created_items
self.multiworld.early_items[self.player]["Movement Pack"] = 1
if self.options.campaign == Options.Campaign.option_basic or self.options.campaign == Options.Campaign.option_both:
self.multiworld.early_items[self.player]["Movement Pack"] = 1
for item in items_to_exclude:
if item in self.multiworld.itempool:
@@ -77,6 +79,10 @@ class DLCqworld(World):
return DLCQuestItem(item.name, item.classification, item.code, self.player)
def get_filler_item_name(self) -> str:
trap = self.multiworld.random.choice(items_by_group[Group.Trap])
return trap.name
def fill_slot_data(self):
options_dict = self.options.as_dict(
"death_link", "ending_choice", "campaign", "coinsanity", "item_shuffle"
@@ -92,7 +98,7 @@ class DLCqworld(World):
if change:
suffix = item.coin_suffix
if suffix:
state.prog_items[suffix, self.player] += item.coins
state.prog_items[self.player][suffix] += item.coins
return change
def remove(self, state: CollectionState, item: DLCQuestItem) -> bool:
@@ -100,5 +106,5 @@ class DLCqworld(World):
if change:
suffix = item.coin_suffix
if suffix:
state.prog_items[suffix, self.player] -= item.coins
state.prog_items[self.player][suffix] -= item.coins
return change

View File

@@ -1,7 +1,7 @@
from typing import Dict
from BaseClasses import MultiWorld
from Options import SpecialRange
from Options import NamedRange
from .option_names import options_to_include
from .checks.world_checks import assert_can_win, assert_same_number_items_locations
from . import DLCQuestTestBase, setup_dlc_quest_solo_multiworld
@@ -14,7 +14,7 @@ def basic_checks(tester: DLCQuestTestBase, multiworld: MultiWorld):
def get_option_choices(option) -> Dict[str, int]:
if issubclass(option, SpecialRange):
if issubclass(option, NamedRange):
return option.special_range_names
elif option.options:
return option.options

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