mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-12 18:43:48 -07:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea2175cb8a | ||
|
|
11873e059a | ||
|
|
6c1023a88c | ||
|
|
0be0732a2b | ||
|
|
c9aa283711 | ||
|
|
cf2204a861 | ||
|
|
dfdcad28e5 | ||
|
|
ab4324c901 | ||
|
|
1e251dcdc0 | ||
|
|
9c1f7bfea9 | ||
|
|
5393563700 | ||
|
|
28576f2b0d | ||
|
|
ba519fecd0 | ||
|
|
86fb450ecc | ||
|
|
920240cb6f | ||
|
|
53dd0d5a7d | ||
|
|
807f544b26 | ||
|
|
1d1693df62 | ||
|
|
51574959ec | ||
|
|
04f726aef2 | ||
|
|
8a4298e504 | ||
|
|
e7f8f40464 | ||
|
|
847582ff5f | ||
|
|
1a44f5cf1c | ||
|
|
032bc75070 | ||
|
|
fb47483212 | ||
|
|
d185df3972 | ||
|
|
941dcb60e5 | ||
|
|
25756831b7 | ||
|
|
9add1495d5 | ||
|
|
34dba007dc | ||
|
|
02d3eef565 | ||
|
|
c839a76fe7 | ||
|
|
29e1c3dcf4 | ||
|
|
f6616da5a9 | ||
|
|
8678e02d54 | ||
|
|
2f37bedc92 | ||
|
|
91fdfe3e17 | ||
|
|
a41b0051a6 | ||
|
|
b8abe9f980 | ||
|
|
dd3ae5ecbd | ||
|
|
e96602d31b | ||
|
|
81d953daa3 | ||
|
|
bd774a454e | ||
|
|
ca724c92ad | ||
|
|
11eebbbd32 | ||
|
|
608794cded | ||
|
|
816de5ff02 | ||
|
|
0b941e2268 | ||
|
|
57713cda50 | ||
|
|
f56cdd6ec3 | ||
|
|
773c517757 | ||
|
|
2509b7fa3f | ||
|
|
10652d23e0 | ||
|
|
f0bc3d33ac | ||
|
|
92d1ed60c6 | ||
|
|
fe2b431821 | ||
|
|
0cc83698f9 | ||
|
|
428f643b07 | ||
|
|
d4e2b75520 | ||
|
|
96cc7f79dc | ||
|
|
bdfbc7e14a | ||
|
|
94c6562f82 | ||
|
|
22fe31a141 | ||
|
|
72fa19ee1f | ||
|
|
d899e918b4 | ||
|
|
33d31c4f0f | ||
|
|
9c3c69702a | ||
|
|
dae1a3e0f9 | ||
|
|
1f1ef10cfe | ||
|
|
760af59308 | ||
|
|
3dd7e3e706 | ||
|
|
189b129dca | ||
|
|
092e8d14ad | ||
|
|
4cfc73b582 | ||
|
|
ff9c11d772 | ||
|
|
b83aec5c12 | ||
|
|
caf63dd737 | ||
|
|
395d35571c | ||
|
|
e0be79639c | ||
|
|
37b7f0d32d | ||
|
|
50677ee6a2 | ||
|
|
f8bc3359c7 | ||
|
|
6e537e17e6 | ||
|
|
e853fc208b | ||
|
|
1a36da33b4 | ||
|
|
56fc614588 | ||
|
|
47f1fcf382 | ||
|
|
51c6be047f | ||
|
|
2c46c48ba9 | ||
|
|
32820ba653 | ||
|
|
6173bc6e03 | ||
|
|
e71ea94fe5 | ||
|
|
e3f169b4c3 | ||
|
|
e4e74074f0 | ||
|
|
149630d532 | ||
|
|
2dcfbff751 | ||
|
|
ec45479c52 | ||
|
|
aee0df5359 | ||
|
|
2cdd03f786 | ||
|
|
ce42fda85f | ||
|
|
78a18dee4e | ||
|
|
b7d46004e2 | ||
|
|
c3fe341736 | ||
|
|
79bb43b77c | ||
|
|
bedc78d335 | ||
|
|
1b582e5b09 | ||
|
|
f278dd95c5 | ||
|
|
92f75f3e03 | ||
|
|
f5adc7bdc5 | ||
|
|
78d4da53a7 | ||
|
|
e206c065bf | ||
|
|
5273812039 | ||
|
|
7c3af68e59 | ||
|
|
449973687b | ||
|
|
f5638552cc | ||
|
|
78ee19de51 | ||
|
|
82444229be | ||
|
|
2cc03d003a | ||
|
|
0e4fa378dd | ||
|
|
ffc000ec91 | ||
|
|
32b8f9f9f3 | ||
|
|
4412434976 | ||
|
|
9bdbced51f | ||
|
|
bd574ef261 | ||
|
|
45719eb7e0 | ||
|
|
d81fd280fa | ||
|
|
6b57275859 | ||
|
|
63f012cce7 | ||
|
|
679cb3e197 | ||
|
|
38b5a90c07 | ||
|
|
203f17f0f6 | ||
|
|
65995cd586 | ||
|
|
64e2d55e92 | ||
|
|
ef66f64030 | ||
|
|
e641c3ca1b | ||
|
|
111c3186bd | ||
|
|
f0e9080108 | ||
|
|
fd8867c782 | ||
|
|
f81d2653e0 | ||
|
|
1288f15e45 | ||
|
|
cde2a6e754 | ||
|
|
81dd1e359b | ||
|
|
8dffd87bee | ||
|
|
67be80e59d | ||
|
|
ff1f5569e7 | ||
|
|
8b9b482972 | ||
|
|
d0ce44cd38 | ||
|
|
aae78a8a12 | ||
|
|
7a5e11e8d4 | ||
|
|
a9ab53cb8b | ||
|
|
5ed8c2e1c0 | ||
|
|
67128ece38 | ||
|
|
8aed24151f |
2
.github/workflows/unittests.yml
vendored
2
.github/workflows/unittests.yml
vendored
@@ -37,4 +37,4 @@ jobs:
|
||||
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
|
||||
- name: Unittests
|
||||
run: |
|
||||
pytest test
|
||||
pytest
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
*.apz5
|
||||
@@ -48,7 +49,7 @@ Output Logs/
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
/setup.ini
|
||||
|
||||
/installdelete.iss
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -135,6 +136,7 @@ venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.code-workspace
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
||||
297
BaseClasses.py
297
BaseClasses.py
@@ -1,20 +1,20 @@
|
||||
from __future__ import annotations
|
||||
from argparse import Namespace
|
||||
|
||||
import copy
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
import logging
|
||||
import json
|
||||
import functools
|
||||
from collections import OrderedDict, Counter, deque
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
import typing # this can go away when Python 3.8 support is dropped
|
||||
import secrets
|
||||
import json
|
||||
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 OrderedDict, Counter, deque
|
||||
from enum import unique, IntEnum, IntFlag
|
||||
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
|
||||
|
||||
import NetUtils
|
||||
import Options
|
||||
import Utils
|
||||
import NetUtils
|
||||
|
||||
|
||||
class Group(TypedDict, total=False):
|
||||
@@ -26,6 +26,7 @@ class Group(TypedDict, total=False):
|
||||
replacement_items: Dict[int, Optional[str]]
|
||||
local_items: Set[str]
|
||||
non_local_items: Set[str]
|
||||
link_replacement: bool
|
||||
|
||||
|
||||
class MultiWorld():
|
||||
@@ -47,6 +48,7 @@ class MultiWorld():
|
||||
precollected_items: Dict[int, List[Item]]
|
||||
state: CollectionState
|
||||
|
||||
plando_options: PlandoOptions
|
||||
accessibility: Dict[int, Options.Accessibility]
|
||||
early_items: Dict[int, Dict[str, int]]
|
||||
local_early_items: Dict[int, Dict[str, int]]
|
||||
@@ -159,8 +161,9 @@ class MultiWorld():
|
||||
self.custom_data = {}
|
||||
self.worlds = {}
|
||||
self.slot_seeds = {}
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self):
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
return self.player_ids + tuple(self.groups)
|
||||
|
||||
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
|
||||
@@ -222,27 +225,32 @@ class MultiWorld():
|
||||
|
||||
def set_item_links(self):
|
||||
item_links = {}
|
||||
|
||||
replacement_prio = [False, True, None]
|
||||
for player in self.player_ids:
|
||||
for item_link in self.item_links[player].value:
|
||||
if item_link["name"] in item_links:
|
||||
if item_links[item_link["name"]]["game"] != self.game[player]:
|
||||
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
|
||||
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
|
||||
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
|
||||
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
|
||||
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
|
||||
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
|
||||
current_link = item_links[item_link["name"]]
|
||||
current_link["players"][player] = item_link["replacement_item"]
|
||||
current_link["item_pool"] &= set(item_link["item_pool"])
|
||||
current_link["exclude"] |= set(item_link.get("exclude", []))
|
||||
current_link["local_items"] &= set(item_link.get("local_items", []))
|
||||
current_link["non_local_items"] &= set(item_link.get("non_local_items", []))
|
||||
current_link["link_replacement"] = min(current_link["link_replacement"],
|
||||
replacement_prio.index(item_link["link_replacement"]))
|
||||
else:
|
||||
if item_link["name"] in self.player_name.values():
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
|
||||
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) "
|
||||
f"({self.get_player_name(player)}).")
|
||||
item_links[item_link["name"]] = {
|
||||
"players": {player: item_link["replacement_item"]},
|
||||
"item_pool": set(item_link["item_pool"]),
|
||||
"exclude": set(item_link.get("exclude", [])),
|
||||
"game": self.game[player],
|
||||
"local_items": set(item_link.get("local_items", [])),
|
||||
"non_local_items": set(item_link.get("non_local_items", []))
|
||||
"non_local_items": set(item_link.get("non_local_items", [])),
|
||||
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
|
||||
}
|
||||
|
||||
for name, item_link in item_links.items():
|
||||
@@ -267,10 +275,12 @@ class MultiWorld():
|
||||
for group_name, item_link in item_links.items():
|
||||
game = item_link["game"]
|
||||
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
|
||||
|
||||
group["item_pool"] = item_link["item_pool"]
|
||||
group["replacement_items"] = item_link["players"]
|
||||
group["local_items"] = item_link["local_items"]
|
||||
group["non_local_items"] = item_link["non_local_items"]
|
||||
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
|
||||
|
||||
# intended for unittests
|
||||
def set_default_common_options(self):
|
||||
@@ -285,11 +295,11 @@ class MultiWorld():
|
||||
self.is_race = True
|
||||
|
||||
@functools.cached_property
|
||||
def player_ids(self):
|
||||
def player_ids(self) -> Tuple[int, ...]:
|
||||
return tuple(range(1, self.players + 1))
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_game_players(self, game_name: str):
|
||||
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()
|
||||
@@ -308,10 +318,7 @@ class MultiWorld():
|
||||
|
||||
def get_out_file_name_base(self, player: int) -> str:
|
||||
""" the base name (without file extension) for each player's output file for a seed """
|
||||
return f"AP_{self.seed_name}_P{player}" \
|
||||
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||
if (self.player_name[player] != f"Player{player}")
|
||||
else '')
|
||||
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
|
||||
|
||||
def initialize_regions(self, regions=None):
|
||||
for region in regions if regions else self.regions:
|
||||
@@ -386,7 +393,12 @@ class MultiWorld():
|
||||
def get_items(self) -> List[Item]:
|
||||
return [loc.item for loc in self.get_filled_locations()] + self.itempool
|
||||
|
||||
def find_item_locations(self, item, player: int) -> List[Location]:
|
||||
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.player not in player_groups and
|
||||
(location.item.player == player or location.item.player in player_groups)]
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player]
|
||||
|
||||
@@ -394,7 +406,12 @@ class MultiWorld():
|
||||
return next(location for location in self.get_locations() if
|
||||
location.item and location.item.name == item and location.item.player == player)
|
||||
|
||||
def find_items_in_locations(self, items: Set[str], player: int) -> List[Location]:
|
||||
def find_items_in_locations(self, items: Set[str], player: int, resolve_group_locations: bool = False) -> List[Location]:
|
||||
if resolve_group_locations:
|
||||
player_groups = self.get_player_groups(player)
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name in items and location.player not in player_groups and
|
||||
(location.item.player == player or location.item.player in player_groups)]
|
||||
return [location for location in self.get_locations() if
|
||||
location.item and location.item.name in items and location.item.player == player]
|
||||
|
||||
@@ -427,46 +444,35 @@ class MultiWorld():
|
||||
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) -> List[Location]:
|
||||
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]
|
||||
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
|
||||
|
||||
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and not location.item]
|
||||
return [location for location in self.get_locations() if not location.item]
|
||||
|
||||
def get_unfilled_dungeon_locations(self):
|
||||
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
|
||||
return [location for location in self.get_locations(player) if location.item is None]
|
||||
|
||||
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
|
||||
if player is not None:
|
||||
return [location for location in self.get_locations() if
|
||||
location.player == player and location.item is not None]
|
||||
return [location for location in self.get_locations() if location.item is not None]
|
||||
return [location for location in self.get_locations(player) if location.item is not None]
|
||||
|
||||
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
|
||||
if state is None:
|
||||
state = self.state
|
||||
return [location for location in self.get_locations() if
|
||||
(player is None or location.player == player) and location.can_reach(state)]
|
||||
state: CollectionState = state if state else self.state
|
||||
return [location for location in self.get_locations(player) if location.can_reach(state)]
|
||||
|
||||
def get_placeable_locations(self, state=None, player=None) -> List[Location]:
|
||||
if state is None:
|
||||
state = self.state
|
||||
return [location for location in self.get_locations() if
|
||||
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
|
||||
state: CollectionState = state if state else self.state
|
||||
return [location for location in self.get_locations(player) if location.item is None and location.can_reach(state)]
|
||||
|
||||
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
|
||||
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
|
||||
for player in players:
|
||||
if len(locations) == 0:
|
||||
locations = [location.name for location in self.get_unfilled_locations(player)]
|
||||
for location_name in locations:
|
||||
if not location_names:
|
||||
location_names = [location.name for location in self.get_unfilled_locations(player)]
|
||||
for location_name in location_names:
|
||||
location = self._location_cache.get((location_name, player), None)
|
||||
if location is not None and location.item is None:
|
||||
yield location
|
||||
@@ -1375,6 +1381,157 @@ class Spoiler():
|
||||
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
|
||||
self.bosses[str(player)]["Ganon"] = "Ganon"
|
||||
|
||||
def create_playthrough(self, create_paths: bool = True):
|
||||
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||
from itertools import chain
|
||||
# get locations containing progress items
|
||||
multiworld = self.multiworld
|
||||
prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement}
|
||||
state_cache = [None]
|
||||
collection_spheres: List[Set[Location]] = []
|
||||
state = CollectionState(multiworld)
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
sphere_candidates -= sphere
|
||||
collection_spheres.append(sphere)
|
||||
state_cache.append(state.copy())
|
||||
|
||||
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere),
|
||||
len(prog_locations))
|
||||
if not sphere:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
self.unreachables = sphere_candidates
|
||||
break
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
location.item.player)
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if multiworld.can_beat_game(state_cache[num]):
|
||||
to_delete.add(location)
|
||||
restore_later[location] = old_item
|
||||
else:
|
||||
# still required, got to keep it around
|
||||
location.item = old_item
|
||||
|
||||
# cull entries in spheres for spoiler walkthrough at end
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
multiworld.precollected_items[item.player].remove(item)
|
||||
multiworld.state.remove(item)
|
||||
if not multiworld.can_beat_game():
|
||||
multiworld.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
||||
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
||||
# to build up the correct spheres
|
||||
|
||||
required_locations = {item for sphere in collection_spheres for item in sphere}
|
||||
state = CollectionState(multiworld)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
required_locations -= sphere
|
||||
|
||||
collection_spheres.append(sphere)
|
||||
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere), len(required_locations))
|
||||
if not sphere:
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
# we can finally output our playthrough
|
||||
self.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(multiworld.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
self.playthrough[str(i + 1)] = {
|
||||
str(location): str(location.item) for location in sorted(sphere)}
|
||||
if create_paths:
|
||||
self.create_paths(state, collection_spheres)
|
||||
|
||||
# repair the multiworld again
|
||||
for location, item in restore_later.items():
|
||||
location.item = item
|
||||
|
||||
for item in removed_precollected:
|
||||
multiworld.push_precollected(item)
|
||||
|
||||
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
|
||||
from itertools import zip_longest
|
||||
multiworld = self.multiworld
|
||||
|
||||
def flist_to_iter(node):
|
||||
while node:
|
||||
value, node = node
|
||||
yield value
|
||||
|
||||
def get_path(state, region):
|
||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||
# Now we combine the flat string list into (region, exit) pairs
|
||||
pathsiter = iter(string_path_flat)
|
||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||
return list(pathpairs)
|
||||
|
||||
self.paths = {}
|
||||
topology_worlds = (player for player in multiworld.player_ids if multiworld.worlds[player].topology_present)
|
||||
for player in topology_worlds:
|
||||
self.paths.update(
|
||||
{str(location): get_path(state, location.parent_region)
|
||||
for sphere in collection_spheres for location in sphere
|
||||
if location.player == player})
|
||||
if player in multiworld.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
|
||||
for (_, exit_path) in path):
|
||||
if multiworld.mode[player] != 'inverted':
|
||||
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
def to_json(self):
|
||||
self.parse_data()
|
||||
out = OrderedDict()
|
||||
@@ -1413,6 +1570,7 @@ class Spoiler():
|
||||
Utils.__version__, self.multiworld.seed))
|
||||
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
|
||||
outfile.write('Players: %d\n' % self.multiworld.players)
|
||||
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
|
||||
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
|
||||
|
||||
for player in range(1, self.multiworld.players + 1):
|
||||
@@ -1529,6 +1687,45 @@ class Tutorial(NamedTuple):
|
||||
authors: List[str]
|
||||
|
||||
|
||||
class PlandoOptions(IntFlag):
|
||||
none = 0b0000
|
||||
items = 0b0001
|
||||
connections = 0b0010
|
||||
texts = 0b0100
|
||||
bosses = 0b1000
|
||||
|
||||
@classmethod
|
||||
def from_option_string(cls, option_string: str) -> PlandoOptions:
|
||||
result = cls(0)
|
||||
for part in option_string.split(","):
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_set(cls, option_set: Set[str]) -> PlandoOptions:
|
||||
result = cls(0)
|
||||
for part in option_set:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
|
||||
try:
|
||||
part = cls[part]
|
||||
except Exception as e:
|
||||
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||
else:
|
||||
return base | part
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
|
||||
return "None"
|
||||
|
||||
|
||||
seeddigits = 20
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ class CommonContext:
|
||||
tags: typing.Set[str] = {"AP"}
|
||||
game: typing.Optional[str] = None
|
||||
items_handling: typing.Optional[int] = None
|
||||
want_slot_data: bool = True # should slot_data be retrieved via Connect
|
||||
|
||||
# datapackage
|
||||
# Contents in flux until connection to server is made, to download correct data for this multiworld.
|
||||
@@ -192,7 +193,7 @@ class CommonContext:
|
||||
self.hint_cost = None
|
||||
self.slot_info = {}
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"release": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
@@ -259,7 +260,7 @@ class CommonContext:
|
||||
self.server_task = None
|
||||
self.hint_cost = None
|
||||
self.permissions = {
|
||||
"forfeit": "disabled",
|
||||
"release": "disabled",
|
||||
"collect": "disabled",
|
||||
"remaining": "disabled",
|
||||
}
|
||||
@@ -309,7 +310,7 @@ class CommonContext:
|
||||
'cmd': 'Connect',
|
||||
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags, 'items_handling': self.items_handling,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game
|
||||
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
|
||||
}
|
||||
if kwargs:
|
||||
payload.update(kwargs)
|
||||
@@ -493,7 +494,7 @@ class CommonContext:
|
||||
self._messagebox.open()
|
||||
return self._messagebox
|
||||
|
||||
def _handle_connection_loss(self, msg: str) -> None:
|
||||
def handle_connection_loss(self, msg: str) -> None:
|
||||
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
|
||||
exc_info = sys.exc_info()
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
@@ -579,14 +580,22 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
|
||||
for msg in decode(data):
|
||||
await process_server_cmd(ctx, msg)
|
||||
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
|
||||
except websockets.InvalidMessage:
|
||||
# probably encrypted
|
||||
if address.startswith("ws://"):
|
||||
await server_loop(ctx, "ws" + address[1:])
|
||||
else:
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
|
||||
f"{reconnect_hint()}")
|
||||
except ConnectionRefusedError:
|
||||
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
|
||||
ctx.handle_connection_loss("Connection refused by the server. "
|
||||
"May not be running Archipelago on that address or port.")
|
||||
except websockets.InvalidURI:
|
||||
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
|
||||
except OSError:
|
||||
ctx._handle_connection_loss("Failed to connect to the multiworld server")
|
||||
ctx.handle_connection_loss("Failed to connect to the multiworld server")
|
||||
except Exception:
|
||||
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
|
||||
finally:
|
||||
await ctx.connection_closed()
|
||||
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
|
||||
@@ -798,9 +807,10 @@ if __name__ == '__main__':
|
||||
# Text Mode to use !hint and such with games that have no text entry
|
||||
|
||||
class TextContext(CommonContext):
|
||||
tags = {"AP", "IgnoreGame", "TextOnly"}
|
||||
tags = {"AP", "TextOnly"}
|
||||
game = "" # empty matches any game since 0.3.2
|
||||
items_handling = 0b111 # receive all items for /received
|
||||
want_slot_data = False # Can't use game specific slot_data
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -811,6 +821,10 @@ if __name__ == '__main__':
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
self.game = self.slot_info[self.slot].game
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.game = ""
|
||||
await super().disconnect(allow_autoreconnect)
|
||||
|
||||
|
||||
async def main(args):
|
||||
|
||||
48
Fill.py
48
Fill.py
@@ -24,7 +24,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
|
||||
|
||||
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
|
||||
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
|
||||
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
|
||||
allow_partial: bool = False) -> None:
|
||||
unplaced_items: typing.List[Item] = []
|
||||
placements: typing.List[Location] = []
|
||||
|
||||
@@ -132,7 +133,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
|
||||
if on_place:
|
||||
on_place(spot_to_fill)
|
||||
|
||||
if len(unplaced_items) > 0 and len(locations) > 0:
|
||||
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
|
||||
# There are leftover unplaceable items and locations that won't accept them
|
||||
if world.can_beat_game():
|
||||
logging.warning(
|
||||
@@ -252,11 +253,12 @@ def distribute_early_items(world: MultiWorld,
|
||||
fill_locations: typing.List[Location],
|
||||
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
|
||||
""" returns new fill_locations and itempool """
|
||||
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
|
||||
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
|
||||
for player in world.player_ids:
|
||||
items = itertools.chain(world.early_items[player], world.local_early_items[player])
|
||||
for item in items:
|
||||
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
|
||||
early_items_count[item, player] = [world.early_items[player].get(item, 0),
|
||||
world.local_early_items[player].get(item, 0)]
|
||||
if early_items_count:
|
||||
early_locations: typing.List[Location] = []
|
||||
early_priority_locations: typing.List[Location] = []
|
||||
@@ -280,42 +282,50 @@ def distribute_early_items(world: MultiWorld,
|
||||
for i, item in enumerate(itempool):
|
||||
if (item.name, item.player) in early_items_count:
|
||||
if item.advancement:
|
||||
if early_items_count[(item.name, item.player)][1]:
|
||||
if early_items_count[item.name, item.player][1]:
|
||||
early_local_prog_items[item.player].append(item)
|
||||
early_items_count[(item.name, item.player)][1] -= 1
|
||||
early_items_count[item.name, item.player][1] -= 1
|
||||
else:
|
||||
early_prog_items.append(item)
|
||||
early_items_count[(item.name, item.player)][0] -= 1
|
||||
early_items_count[item.name, item.player][0] -= 1
|
||||
else:
|
||||
if early_items_count[(item.name, item.player)][1]:
|
||||
if early_items_count[item.name, item.player][1]:
|
||||
early_local_rest_items[item.player].append(item)
|
||||
early_items_count[(item.name, item.player)][1] -= 1
|
||||
early_items_count[item.name, item.player][1] -= 1
|
||||
else:
|
||||
early_rest_items.append(item)
|
||||
early_items_count[(item.name, item.player)][0] -= 1
|
||||
early_items_count[item.name, item.player][0] -= 1
|
||||
item_indexes_to_remove.add(i)
|
||||
if early_items_count[(item.name, item.player)] == [0, 0]:
|
||||
del early_items_count[(item.name, item.player)]
|
||||
if early_items_count[item.name, item.player] == [0, 0]:
|
||||
del early_items_count[item.name, item.player]
|
||||
if len(early_items_count) == 0:
|
||||
break
|
||||
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
|
||||
for player in world.player_ids:
|
||||
player_local = early_local_rest_items[player]
|
||||
fill_restrictive(world, base_state,
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
early_local_rest_items[player], lock=True)
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
player_local, lock=True, allow_partial=True)
|
||||
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)
|
||||
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
|
||||
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],
|
||||
early_local_prog_items[player], lock=True)
|
||||
[loc for loc in early_locations if loc.player == player],
|
||||
player_local, lock=True, allow_partial=True)
|
||||
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)
|
||||
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
|
||||
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 "
|
||||
f"{len(unplaced_early_items)} items early.")
|
||||
f"{unplaced_early_items} early.")
|
||||
itempool += unplaced_early_items
|
||||
|
||||
fill_locations.extend(early_locations)
|
||||
|
||||
72
Generate.py
72
Generate.py
@@ -2,14 +2,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Set, Dict, Tuple, Callable, Any, Union
|
||||
import os
|
||||
from collections import Counter, ChainMap
|
||||
import random
|
||||
import string
|
||||
import enum
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections import Counter, ChainMap
|
||||
from typing import Dict, Tuple, Callable, Any, Union
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
@@ -18,52 +17,17 @@ ModuleUpdate.update()
|
||||
import Utils
|
||||
from worlds.alttp import Options as LttPOptions
|
||||
from worlds.generic import PlandoConnection
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
|
||||
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
|
||||
from worlds.alttp.EntranceRandomizer import parse_arguments
|
||||
from Main import main as ERmain
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from BaseClasses import seeddigits, get_seed, PlandoOptions
|
||||
import Options
|
||||
from worlds.alttp.Text import TextTable
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
import copy
|
||||
|
||||
|
||||
class PlandoSettings(enum.IntFlag):
|
||||
items = 0b0001
|
||||
connections = 0b0010
|
||||
texts = 0b0100
|
||||
bosses = 0b1000
|
||||
|
||||
@classmethod
|
||||
def from_option_string(cls, option_string: str) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_string.split(","):
|
||||
part = part.strip().lower()
|
||||
if part:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
|
||||
result = cls(0)
|
||||
for part in option_set:
|
||||
result = cls._handle_part(part, result)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
|
||||
try:
|
||||
part = cls[part]
|
||||
except Exception as e:
|
||||
raise KeyError(f"{part} is not a recognized name for a plando module. "
|
||||
f"Known options: {', '.join(flag.name for flag in cls)}") from e
|
||||
else:
|
||||
return base | part
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value:
|
||||
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
|
||||
return "Off"
|
||||
|
||||
|
||||
def mystery_argparse():
|
||||
@@ -97,7 +61,7 @@ def mystery_argparse():
|
||||
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
|
||||
if not os.path.isabs(args.meta_file_path):
|
||||
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
|
||||
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
|
||||
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
|
||||
return args, options
|
||||
|
||||
|
||||
@@ -170,6 +134,7 @@ def main(args=None, callback=ERmain):
|
||||
f"A mix is also permitted.")
|
||||
erargs = parse_arguments(['--multi', str(args.multi)])
|
||||
erargs.seed = seed
|
||||
erargs.plando_options = args.plando
|
||||
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
|
||||
erargs.spoiler = args.spoiler
|
||||
erargs.race = args.race
|
||||
@@ -226,7 +191,7 @@ def main(args=None, callback=ERmain):
|
||||
elif not erargs.name[player]: # if name was not specified, generate it from filename
|
||||
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
|
||||
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
|
||||
|
||||
|
||||
player += 1
|
||||
except Exception as e:
|
||||
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
|
||||
@@ -443,7 +408,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
|
||||
return weights
|
||||
|
||||
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
|
||||
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
|
||||
if option_key in game_weights:
|
||||
try:
|
||||
if not option.supports_weighting:
|
||||
@@ -459,7 +424,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
|
||||
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
|
||||
|
||||
|
||||
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
|
||||
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
|
||||
if "linked_options" in weights:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
@@ -472,7 +437,7 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
||||
if tuplize_version(version) > version_tuple:
|
||||
raise Exception(f"Settings reports required version of generator is at least {version}, "
|
||||
f"however generator is of version {__version__}")
|
||||
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
|
||||
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
|
||||
if required_plando_options not in plando_options:
|
||||
if required_plando_options:
|
||||
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
|
||||
@@ -503,14 +468,15 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
for option_key, option in Options.per_game_common_options.items():
|
||||
# skip setting this option if already set from common_options, defaulting to root option
|
||||
if not (option_key in Options.common_options and option_key not in game_weights):
|
||||
if option_key not in world_type.option_definitions and \
|
||||
(option_key not in Options.common_options or option_key in game_weights):
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
if PlandoSettings.items in plando_options:
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = game_weights.get("plando_items", [])
|
||||
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
|
||||
# bad hardcoded behavior to make this work for now
|
||||
ret.plando_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = game_weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice("percentage", placement, 100)):
|
||||
@@ -625,7 +591,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
|
||||
|
||||
ret.plando_texts = {}
|
||||
if PlandoSettings.texts in plando_options:
|
||||
if PlandoOptions.texts in plando_options:
|
||||
tt = TextTable()
|
||||
tt.removeUnwantedText()
|
||||
options = weights.get("plando_texts", [])
|
||||
@@ -637,7 +603,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
|
||||
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
|
||||
|
||||
ret.plando_connections = []
|
||||
if PlandoSettings.connections in plando_options:
|
||||
if PlandoOptions.connections in plando_options:
|
||||
options = weights.get("plando_connections", [])
|
||||
for placement in options:
|
||||
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
|
||||
|
||||
212
Main.py
212
Main.py
@@ -1,5 +1,4 @@
|
||||
import collections
|
||||
from itertools import zip_longest, chain
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -11,7 +10,7 @@ import zipfile
|
||||
from typing import Dict, List, Tuple, Optional, Set
|
||||
|
||||
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
|
||||
from worlds.alttp.Items import item_name_groups
|
||||
import worlds
|
||||
from worlds.alttp.Regions import is_main_entrance
|
||||
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
|
||||
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
|
||||
@@ -39,6 +38,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
logger = logging.getLogger()
|
||||
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
|
||||
world.plando_options = args.plando_options
|
||||
|
||||
world.shuffle = args.shuffle.copy()
|
||||
world.logic = args.logic.copy()
|
||||
@@ -116,26 +116,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for _ in range(count):
|
||||
world.push_precollected(world.create_item(item_name, player))
|
||||
|
||||
for player in world.player_ids:
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.local_items[player].value.add('Triforce Piece')
|
||||
|
||||
# Not possible to place pendants/crystals outside boss prizes yet.
|
||||
world.non_local_items[player].value -= item_name_groups['Pendants']
|
||||
world.non_local_items[player].value -= item_name_groups['Crystals']
|
||||
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
|
||||
logger.info('Creating World.')
|
||||
AutoWorld.call_all(world, "create_regions")
|
||||
|
||||
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:
|
||||
# items can't be both local and non-local, prefer local
|
||||
world.non_local_items[player].value -= world.local_items[player].value
|
||||
world.non_local_items[player].value -= set(world.local_early_items[player])
|
||||
|
||||
if world.players > 1:
|
||||
locality_rules(world)
|
||||
else:
|
||||
@@ -217,11 +214,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
while itemcount > len(world.itempool):
|
||||
items_to_add = []
|
||||
for player in group["players"]:
|
||||
if group["link_replacement"]:
|
||||
item_player = group_id
|
||||
else:
|
||||
item_player = player
|
||||
if group["replacement_items"][player]:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
|
||||
group["replacement_items"][player]))
|
||||
else:
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
|
||||
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
|
||||
world.random.shuffle(items_to_add)
|
||||
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
|
||||
|
||||
@@ -295,27 +296,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
checks_in_area[location.player]["Dark World"].append(location.address)
|
||||
checks_in_area[location.player]["Total"] += 1
|
||||
|
||||
oldmancaves = []
|
||||
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
|
||||
for index, take_any in enumerate(takeanyregions):
|
||||
for region in [world.get_region(take_any, player) for player in
|
||||
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
|
||||
item = world.create_item(
|
||||
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
|
||||
region.player)
|
||||
player = region.player
|
||||
location_id = SHOP_ID_START + total_shop_slots + index
|
||||
|
||||
main_entrance = region.get_connecting_entrance(is_main_entrance)
|
||||
if main_entrance.parent_region.type == RegionType.LightWorld:
|
||||
checks_in_area[player]["Light World"].append(location_id)
|
||||
else:
|
||||
checks_in_area[player]["Dark World"].append(location_id)
|
||||
checks_in_area[player]["Total"] += 1
|
||||
|
||||
er_hint_data[player][location_id] = main_entrance.name
|
||||
oldmancaves.append(((location_id, player), (item.code, player)))
|
||||
|
||||
FillDisabledShopSlots(world)
|
||||
|
||||
def write_multidata():
|
||||
@@ -371,16 +351,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
for player in world.groups.get(location.item.player, {}).get("players", [])]):
|
||||
precollect_hint(location)
|
||||
|
||||
# custom datapackage
|
||||
datapackage = {}
|
||||
for game_world in world.worlds.values():
|
||||
if game_world.data_version == 0 and game_world.game not in datapackage:
|
||||
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
|
||||
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
|
||||
|
||||
multidata = {
|
||||
"slot_data": slot_data,
|
||||
"slot_info": slot_info,
|
||||
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
|
||||
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
|
||||
"remote_items": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_items},
|
||||
"remote_start_inventory": {player for player in world.player_ids if
|
||||
world.worlds[player].remote_start_inventory},
|
||||
"locations": locations_data,
|
||||
"checks_in_area": checks_in_area,
|
||||
"server_options": baked_server_options,
|
||||
@@ -390,7 +373,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
"version": tuple(version_tuple),
|
||||
"tags": ["AP"],
|
||||
"minimum_versions": minimum_versions,
|
||||
"seed_name": world.seed_name
|
||||
"seed_name": world.seed_name,
|
||||
"datapackage": datapackage,
|
||||
}
|
||||
AutoWorld.call_all(world, "modify_multidata", multidata)
|
||||
|
||||
@@ -416,7 +400,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
if args.spoiler > 1:
|
||||
logger.info('Calculating playthrough.')
|
||||
create_playthrough(world)
|
||||
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
|
||||
|
||||
if args.spoiler:
|
||||
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
|
||||
@@ -430,143 +414,3 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
||||
|
||||
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
|
||||
return world
|
||||
|
||||
|
||||
def create_playthrough(world):
|
||||
"""Destructive to the world while it is run, damage gets repaired afterwards."""
|
||||
# get locations containing progress items
|
||||
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
|
||||
state_cache = [None]
|
||||
collection_spheres = []
|
||||
state = CollectionState(world)
|
||||
sphere_candidates = set(prog_locations)
|
||||
logging.debug('Building up collection spheres.')
|
||||
while sphere_candidates:
|
||||
|
||||
# build up spheres of collection radius.
|
||||
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
|
||||
|
||||
sphere = {location for location in sphere_candidates if state.can_reach(location)}
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
sphere_candidates -= sphere
|
||||
collection_spheres.append(sphere)
|
||||
state_cache.append(state.copy())
|
||||
|
||||
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
|
||||
len(prog_locations))
|
||||
if not sphere:
|
||||
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
|
||||
location.item.name, location.item.player, location.name, location.player) for location in
|
||||
sphere_candidates])
|
||||
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
|
||||
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
|
||||
f'Something went terribly wrong here.')
|
||||
else:
|
||||
world.spoiler.unreachables = sphere_candidates
|
||||
break
|
||||
|
||||
# in the second phase, we cull each sphere such that the game is still beatable,
|
||||
# reducing each range of influence to the bare minimum required inside it
|
||||
restore_later = {}
|
||||
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
|
||||
to_delete = set()
|
||||
for location in sphere:
|
||||
# we remove the item at location and check if game is still beatable
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
|
||||
location.item.player)
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if world.can_beat_game(state_cache[num]):
|
||||
to_delete.add(location)
|
||||
restore_later[location] = old_item
|
||||
else:
|
||||
# still required, got to keep it around
|
||||
location.item = old_item
|
||||
|
||||
# cull entries in spheres for spoiler walkthrough at end
|
||||
sphere -= to_delete
|
||||
|
||||
# second phase, sphere 0
|
||||
removed_precollected = []
|
||||
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
world.precollected_items[item.player].remove(item)
|
||||
world.state.remove(item)
|
||||
if not world.can_beat_game():
|
||||
world.push_precollected(item)
|
||||
else:
|
||||
removed_precollected.append(item)
|
||||
|
||||
# we are now down to just the required progress items in collection_spheres. Unfortunately
|
||||
# the previous pruning stage could potentially have made certain items dependant on others
|
||||
# in the same or later sphere (because the location had 2 ways to access but the item originally
|
||||
# used to access it was deemed not required.) So we need to do one final sphere collection pass
|
||||
# to build up the correct spheres
|
||||
|
||||
required_locations = {item for sphere in collection_spheres for item in sphere}
|
||||
state = CollectionState(world)
|
||||
collection_spheres = []
|
||||
while required_locations:
|
||||
state.sweep_for_events(key_only=True)
|
||||
|
||||
sphere = set(filter(state.can_reach, required_locations))
|
||||
|
||||
for location in sphere:
|
||||
state.collect(location.item, True, location)
|
||||
|
||||
required_locations -= sphere
|
||||
|
||||
collection_spheres.append(sphere)
|
||||
|
||||
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
|
||||
len(sphere), len(required_locations))
|
||||
if not sphere:
|
||||
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
|
||||
|
||||
def flist_to_iter(node):
|
||||
while node:
|
||||
value, node = node
|
||||
yield value
|
||||
|
||||
def get_path(state, region):
|
||||
reversed_path_as_flist = state.path.get(region, (region, None))
|
||||
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
|
||||
# Now we combine the flat string list into (region, exit) pairs
|
||||
pathsiter = iter(string_path_flat)
|
||||
pathpairs = zip_longest(pathsiter, pathsiter)
|
||||
return list(pathpairs)
|
||||
|
||||
world.spoiler.paths = {}
|
||||
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
|
||||
for player in topology_worlds:
|
||||
world.spoiler.paths.update(
|
||||
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
|
||||
sphere if location.player == player})
|
||||
if player in world.get_game_players("A Link to the Past"):
|
||||
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
|
||||
# Maybe move the big bomb over to the Event system instead?
|
||||
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
|
||||
if world.mode[player] != 'inverted':
|
||||
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Big Bomb Shop', player))
|
||||
else:
|
||||
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
|
||||
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
|
||||
|
||||
# we can finally output our playthrough
|
||||
world.spoiler.playthrough = {"0": sorted([str(item) for item in
|
||||
chain.from_iterable(world.precollected_items.values())
|
||||
if item.advancement])}
|
||||
|
||||
for i, sphere in enumerate(collection_spheres):
|
||||
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
|
||||
|
||||
# repair the world again
|
||||
for location, item in restore_later.items():
|
||||
location.item = item
|
||||
|
||||
for item in removed_precollected:
|
||||
world.push_precollected(item)
|
||||
|
||||
374
MultiServer.py
374
MultiServer.py
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import copy
|
||||
import functools
|
||||
import logging
|
||||
import zlib
|
||||
@@ -16,11 +17,15 @@ import pickle
|
||||
import itertools
|
||||
import time
|
||||
import operator
|
||||
import hashlib
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl
|
||||
|
||||
import websockets
|
||||
import colorama
|
||||
try:
|
||||
@@ -39,6 +44,28 @@ min_client_version = Version(0, 1, 6)
|
||||
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
|
||||
colorama.init()
|
||||
|
||||
|
||||
def remove_from_list(container, value):
|
||||
try:
|
||||
container.remove(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return container
|
||||
|
||||
|
||||
def pop_from_container(container, value):
|
||||
try:
|
||||
container.pop(value)
|
||||
except ValueError:
|
||||
pass
|
||||
return container
|
||||
|
||||
|
||||
def update_dict(dictionary, entries):
|
||||
dictionary.update(entries)
|
||||
return dictionary
|
||||
|
||||
|
||||
# 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)
|
||||
@@ -55,9 +82,19 @@ modify_functions = {
|
||||
"and": operator.and_,
|
||||
"left_shift": operator.lshift,
|
||||
"right_shift": operator.rshift,
|
||||
# lists/dicts
|
||||
"remove": remove_from_list,
|
||||
"pop": pop_from_container,
|
||||
"update": update_dict,
|
||||
}
|
||||
|
||||
|
||||
def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||
# save at expected times so other systems using savegame can expect it
|
||||
# represents the target second of the auto_save_interval at which to save
|
||||
return int(hashlib.sha256(seed_name.encode()).hexdigest(), 16) % interval
|
||||
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
tags: typing.List[str] = []
|
||||
@@ -109,7 +146,7 @@ class Context:
|
||||
"location_check_points": int,
|
||||
"server_password": str,
|
||||
"password": str,
|
||||
"forfeit_mode": str,
|
||||
"release_mode": str,
|
||||
"remaining_mode": str,
|
||||
"collect_mode": str,
|
||||
"item_cheat": bool,
|
||||
@@ -120,16 +157,17 @@ class Context:
|
||||
groups: typing.Dict[int, typing.Set[int]]
|
||||
save_version = 2
|
||||
stored_data: typing.Dict[str, object]
|
||||
read_data: typing.Dict[str, object]
|
||||
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
|
||||
|
||||
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
|
||||
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
|
||||
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
|
||||
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
|
||||
forced_auto_forfeits: typing.Dict[str, bool]
|
||||
non_hintable_names: typing.Dict[str, typing.Set[str]]
|
||||
|
||||
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
|
||||
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
|
||||
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
|
||||
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
|
||||
log_network: bool = False):
|
||||
super(Context, self).__init__()
|
||||
@@ -145,9 +183,7 @@ class Context:
|
||||
self.player_names: typing.Dict[team_slot, str] = {}
|
||||
self.player_name_lookup: typing.Dict[str, team_slot] = {}
|
||||
self.connect_names = {} # names of slots clients can connect to
|
||||
self.allow_forfeits = {}
|
||||
self.remote_items = set()
|
||||
self.remote_start_inventory = set()
|
||||
self.allow_releases = {}
|
||||
# player location_id item_id target_player_id
|
||||
self.locations = {}
|
||||
self.host = host
|
||||
@@ -164,7 +200,7 @@ class Context:
|
||||
self.location_check_points = location_check_points
|
||||
self.hints_used = collections.defaultdict(int)
|
||||
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
|
||||
self.forfeit_mode: str = forfeit_mode
|
||||
self.release_mode: str = release_mode
|
||||
self.remaining_mode: str = remaining_mode
|
||||
self.collect_mode: str = collect_mode
|
||||
self.item_cheat = item_cheat
|
||||
@@ -191,16 +227,15 @@ class Context:
|
||||
self.random = random.Random()
|
||||
self.stored_data = {}
|
||||
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
|
||||
self.read_data = {}
|
||||
|
||||
# init empty to satisfy linter, I suppose
|
||||
self.gamespackage = {}
|
||||
self.item_name_groups = {}
|
||||
self.all_item_and_group_names = {}
|
||||
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset)
|
||||
|
||||
self._load_game_data()
|
||||
self._init_game_data()
|
||||
|
||||
# Datapackage retrieval
|
||||
def _load_game_data(self):
|
||||
@@ -210,7 +245,6 @@ class Context:
|
||||
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
|
||||
worlds.AutoWorldRegister.world_types.items()}
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit
|
||||
self.non_hintable_names[world_name] = world.hint_blacklist
|
||||
|
||||
def _init_game_data(self):
|
||||
@@ -310,7 +344,7 @@ class Context:
|
||||
if not client.auth:
|
||||
return
|
||||
if client.version >= print_command_compatability_threshold:
|
||||
async_start(self.send_msgs(client,
|
||||
async_start(self.send_msgs(client,
|
||||
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
|
||||
else:
|
||||
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
|
||||
@@ -342,7 +376,7 @@ class Context:
|
||||
return restricted_loads(zlib.decompress(data[1:]))
|
||||
|
||||
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
|
||||
|
||||
self.read_data = {}
|
||||
mdata_ver = decoded_obj["minimum_versions"]["server"]
|
||||
if mdata_ver > Utils.version_tuple:
|
||||
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
|
||||
@@ -359,13 +393,15 @@ class Context:
|
||||
self.clients[team][player] = []
|
||||
self.player_names[team, player] = name
|
||||
self.player_name_lookup[name] = team, player
|
||||
self.read_data[f"hints_{team}_{player}"] = lambda local_team=team, local_player=player: \
|
||||
list(self.get_rechecked_hints(local_team, local_player))
|
||||
self.seed_name = decoded_obj["seed_name"]
|
||||
self.random.seed(self.seed_name)
|
||||
self.connect_names = decoded_obj['connect_names']
|
||||
self.remote_items = decoded_obj['remote_items']
|
||||
self.remote_start_inventory = decoded_obj.get('remote_start_inventory', decoded_obj['remote_items'])
|
||||
self.locations = decoded_obj['locations']
|
||||
self.slot_data = decoded_obj['slot_data']
|
||||
for slot, data in self.slot_data.items():
|
||||
self.read_data[f"slot_data_{slot}"] = lambda data=data: data
|
||||
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
|
||||
for player, loc_data in decoded_obj["er_hint_data"].items()}
|
||||
|
||||
@@ -406,6 +442,16 @@ class Context:
|
||||
server_options = decoded_obj.get("server_options", {})
|
||||
self._set_options(server_options)
|
||||
|
||||
# custom datapackage
|
||||
for game_name, data in decoded_obj.get("datapackage", {}).items():
|
||||
logging.info(f"Loading custom datapackage for game {game_name}")
|
||||
self.gamespackage[game_name] = data
|
||||
self.item_name_groups[game_name] = data["item_name_groups"]
|
||||
del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups
|
||||
self._init_game_data()
|
||||
for game_name, data in self.item_name_groups.items():
|
||||
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
|
||||
|
||||
# saving
|
||||
|
||||
def save(self, now=False) -> bool:
|
||||
@@ -451,10 +497,16 @@ class Context:
|
||||
def _start_async_saving(self):
|
||||
if not self.auto_saver_thread:
|
||||
def save_regularly():
|
||||
import time
|
||||
# time.time() is platform dependent, so using the expensive datetime method instead
|
||||
def get_datetime_second():
|
||||
now = datetime.datetime.now()
|
||||
return now.second + now.microsecond * 0.000001
|
||||
|
||||
second = get_saving_second(self.seed_name, self.auto_save_interval)
|
||||
while not self.exit_event.is_set():
|
||||
try:
|
||||
time.sleep(self.auto_save_interval)
|
||||
next_wakeup = (second - get_datetime_second()) % self.auto_save_interval
|
||||
time.sleep(max(1.0, next_wakeup))
|
||||
if self.save_dirty:
|
||||
logging.debug("Saving via thread.")
|
||||
self._save()
|
||||
@@ -488,9 +540,10 @@ class Context:
|
||||
"group_collected": dict(self.group_collected),
|
||||
"stored_data": self.stored_data,
|
||||
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
|
||||
"server_password": self.server_password, "password": self.password, "forfeit_mode":
|
||||
self.forfeit_mode, "remaining_mode": self.remaining_mode, "collect_mode":
|
||||
self.collect_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||
"server_password": self.server_password, "password": self.password,
|
||||
"forfeit_mode": self.release_mode, "release_mode": self.release_mode, # TODO remove forfeit_mode around 0.4
|
||||
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
|
||||
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
|
||||
|
||||
}
|
||||
|
||||
@@ -521,7 +574,7 @@ class Context:
|
||||
self.location_check_points = savedata["game_options"]["location_check_points"]
|
||||
self.server_password = savedata["game_options"]["server_password"]
|
||||
self.password = savedata["game_options"]["password"]
|
||||
self.forfeit_mode = savedata["game_options"]["forfeit_mode"]
|
||||
self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal"))
|
||||
self.remaining_mode = savedata["game_options"]["remaining_mode"]
|
||||
self.collect_mode = savedata["game_options"]["collect_mode"]
|
||||
self.item_cheat = savedata["game_options"]["item_cheat"]
|
||||
@@ -532,7 +585,7 @@ class Context:
|
||||
|
||||
if "stored_data" in savedata:
|
||||
self.stored_data = savedata["stored_data"]
|
||||
# count items and slots from lists for item_handling = remote
|
||||
# count items and slots from lists for items_handling = remote
|
||||
logging.info(
|
||||
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
|
||||
f'for {sum(k[2] for k in self.received_items)} players')
|
||||
@@ -544,12 +597,17 @@ class Context:
|
||||
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
|
||||
return 0
|
||||
|
||||
def recheck_hints(self):
|
||||
for team, slot in self.hints:
|
||||
self.hints[team, slot] = {
|
||||
hint.re_check(self, team) for hint in
|
||||
self.hints[team, slot]
|
||||
}
|
||||
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
|
||||
for hint_team, hint_slot in self.hints:
|
||||
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
|
||||
self.hints[hint_team, hint_slot] = {
|
||||
hint.re_check(self, hint_team) for hint in
|
||||
self.hints[hint_team, hint_slot]
|
||||
}
|
||||
|
||||
def get_rechecked_hints(self, team: int, slot: int):
|
||||
self.recheck_hints(team, slot)
|
||||
return self.hints[team, slot]
|
||||
|
||||
def get_players_package(self):
|
||||
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
|
||||
@@ -561,6 +619,8 @@ class Context:
|
||||
|
||||
def _set_options(self, server_options: dict):
|
||||
for key, value in server_options.items():
|
||||
if key == "forfeit_mode":
|
||||
key = "release_mode"
|
||||
data_type = self.simple_options.get(key, None)
|
||||
if data_type is not None:
|
||||
if value not in {False, True, None}: # some can be boolean OR text, such as password
|
||||
@@ -584,50 +644,59 @@ class Context:
|
||||
else:
|
||||
return self.player_names[team, slot]
|
||||
|
||||
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
|
||||
"""Send and remember hints."""
|
||||
if only_new:
|
||||
hints = [hint for hint in hints if hint not in self.hints[team, hint.finding_player]]
|
||||
if not hints:
|
||||
return
|
||||
new_hint_events: typing.Set[int] = set()
|
||||
concerns = collections.defaultdict(list)
|
||||
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
|
||||
data = (hint, hint.as_network_message())
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
concerns[player].append(data)
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
# remember hints in all cases
|
||||
if not hint.found:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
|
||||
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||
for slot in new_hint_events:
|
||||
self.on_new_hint(team, slot)
|
||||
for slot, hint_data in concerns.items():
|
||||
clients = self.clients[team].get(slot)
|
||||
if not clients:
|
||||
continue
|
||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||
for client in clients:
|
||||
async_start(self.send_msgs(client, client_hints))
|
||||
|
||||
# "events"
|
||||
|
||||
def on_goal_achieved(self, client: Client):
|
||||
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
|
||||
f' has completed their goal.'
|
||||
self.notify_all(finished_msg)
|
||||
if "auto" in self.collect_mode:
|
||||
collect_player(self, client.team, client.slot)
|
||||
if "auto" in self.forfeit_mode:
|
||||
forfeit_player(self, client.team, client.slot)
|
||||
elif self.forced_auto_forfeits[self.games[client.slot]]:
|
||||
forfeit_player(self, client.team, client.slot)
|
||||
if "auto" in self.release_mode:
|
||||
release_player(self, client.team, client.slot)
|
||||
self.save() # save goal completion flag
|
||||
|
||||
|
||||
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
|
||||
"""Send and remember hints."""
|
||||
if only_new:
|
||||
hints = [hint for hint in hints if hint not in ctx.hints[team, hint.finding_player]]
|
||||
if not hints:
|
||||
return
|
||||
concerns = collections.defaultdict(list)
|
||||
for hint in sorted(hints, key=operator.attrgetter('found'), reverse=True):
|
||||
data = (hint, hint.as_network_message())
|
||||
for player in ctx.slot_set(hint.receiving_player):
|
||||
concerns[player].append(data)
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
# remember hints in all cases
|
||||
if not hint.found:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in ctx.hints[team, hint.finding_player]:
|
||||
ctx.hints[team, hint.finding_player].add(hint)
|
||||
for player in ctx.slot_set(hint.receiving_player):
|
||||
ctx.hints[team, player].add(hint)
|
||||
|
||||
logging.info("Notice (Team #%d): %s" % (team + 1, format_hint(ctx, team, hint)))
|
||||
|
||||
for slot, hint_data in concerns.items():
|
||||
clients = ctx.clients[team].get(slot)
|
||||
if not clients:
|
||||
continue
|
||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
|
||||
for client in clients:
|
||||
async_start(ctx.send_msgs(client, client_hints))
|
||||
def on_new_hint(self, team: int, slot: int):
|
||||
key: str = f"_read_hints_{team}_{slot}"
|
||||
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
|
||||
if targets:
|
||||
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
|
||||
|
||||
|
||||
def update_aliases(ctx: Context, team: int):
|
||||
@@ -676,10 +745,7 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
await ctx.send_msgs(client, [{
|
||||
'cmd': 'RoomInfo',
|
||||
'password': bool(ctx.password),
|
||||
# TODO remove around 0.4
|
||||
'players': players,
|
||||
# TODO convert to list of games present in 0.4
|
||||
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
|
||||
'games': {ctx.games[x] for x in range(1, len(ctx.games) + 1)},
|
||||
# tags are for additional features in the communication.
|
||||
# Name them by feature or fork, as you feel is appropriate.
|
||||
'tags': ctx.tags,
|
||||
@@ -687,8 +753,6 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
'permissions': get_permissions(ctx),
|
||||
'hint_cost': ctx.hint_cost,
|
||||
'location_check_points': ctx.location_check_points,
|
||||
'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values())
|
||||
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
|
||||
'datapackage_versions': {game: game_data["version"] for game, game_data
|
||||
in ctx.gamespackage.items()},
|
||||
'seed_name': ctx.seed_name,
|
||||
@@ -698,7 +762,8 @@ async def on_client_connected(ctx: Context, client: Client):
|
||||
|
||||
def get_permissions(ctx) -> typing.Dict[str, Permission]:
|
||||
return {
|
||||
"forfeit": Permission.from_text(ctx.forfeit_mode),
|
||||
"forfeit": Permission.from_text(ctx.release_mode), # TODO remove around 0.4
|
||||
"release": Permission.from_text(ctx.release_mode),
|
||||
"remaining": Permission.from_text(ctx.remaining_mode),
|
||||
"collect": Permission.from_text(ctx.collect_mode)
|
||||
}
|
||||
@@ -826,7 +891,7 @@ def update_checked_locations(ctx: Context, team: int, slot: int):
|
||||
[{"cmd": "RoomUpdate", "checked_locations": get_checked_checks(ctx, team, slot)}])
|
||||
|
||||
|
||||
def forfeit_player(ctx: Context, team: int, slot: int):
|
||||
def release_player(ctx: Context, team: int, slot: int):
|
||||
"""register any locations that are in the multidata"""
|
||||
all_locations = set(ctx.locations[slot])
|
||||
ctx.notify_all("%s (Team #%d) has released all remaining items from their world." % (ctx.player_names[(team, slot)], team + 1))
|
||||
@@ -1133,13 +1198,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
output = f"!admin {command}"
|
||||
if output.lower().startswith(
|
||||
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
|
||||
"!admin login"): # disallow others from seeing the supplied password, whether it is correct.
|
||||
output = f"!admin login {('*' * random.randint(4, 16))}"
|
||||
elif output.lower().startswith(
|
||||
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
|
||||
# disallow others from knowing what the new remote administration password is.
|
||||
"!admin /option server_password"):
|
||||
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
|
||||
# Otherwise notify the others what is happening.
|
||||
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
|
||||
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
|
||||
self.client.slot) + ': ' + output)
|
||||
|
||||
if not self.ctx.server_password:
|
||||
self.output("Sorry, Remote administration is disabled")
|
||||
@@ -1147,8 +1214,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
if not command:
|
||||
if self.is_authenticated():
|
||||
self.output(
|
||||
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
|
||||
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\n"
|
||||
"Use !admin logout to log out of the current session.")
|
||||
else:
|
||||
self.output("Usage: !admin login [password]")
|
||||
return True
|
||||
@@ -1190,23 +1257,19 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
|
||||
def _cmd_release(self) -> bool:
|
||||
"""Sends remaining items in your world to their recipients."""
|
||||
return self._cmd_forfeit()
|
||||
|
||||
def _cmd_forfeit(self) -> bool:
|
||||
"""Surrender and send your remaining items out to their recipients. Use release in the future."""
|
||||
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
|
||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||
if self.ctx.allow_releases.get((self.client.team, self.client.slot), False):
|
||||
release_player(self.ctx, self.client.team, self.client.slot)
|
||||
return True
|
||||
if "enabled" in self.ctx.forfeit_mode:
|
||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||
if "enabled" in self.ctx.release_mode:
|
||||
release_player(self.ctx, self.client.team, self.client.slot)
|
||||
return True
|
||||
elif "disabled" in self.ctx.forfeit_mode:
|
||||
elif "disabled" in self.ctx.release_mode:
|
||||
self.output("Sorry, client item releasing has been disabled on this server. "
|
||||
"You can ask the server admin for a /release")
|
||||
return False
|
||||
else: # is auto or goal
|
||||
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
|
||||
forfeit_player(self.ctx, self.client.team, self.client.slot)
|
||||
release_player(self.ctx, self.client.team, self.client.slot)
|
||||
return True
|
||||
else:
|
||||
self.output(
|
||||
@@ -1338,7 +1401,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
hints = {hint.re_check(self.ctx, self.client.team) for hint in
|
||||
self.ctx.hints[self.client.team, self.client.slot]}
|
||||
self.ctx.hints[self.client.team, self.client.slot] = hints
|
||||
notify_hints(self.ctx, self.client.team, list(hints))
|
||||
self.ctx.notify_hints(self.client.team, list(hints))
|
||||
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
|
||||
f"You have {points_available} points.")
|
||||
return True
|
||||
@@ -1391,7 +1454,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
|
||||
old_hints = set(hints) - new_hints
|
||||
if old_hints:
|
||||
notify_hints(self.ctx, self.client.team, list(old_hints))
|
||||
self.ctx.notify_hints(self.client.team, list(old_hints))
|
||||
if not new_hints:
|
||||
self.output("Hint was previously used, no points deducted.")
|
||||
if new_hints:
|
||||
@@ -1432,13 +1495,18 @@ class ClientMessageProcessor(CommonCommandProcessor):
|
||||
self.output(f"You can't afford the hint. "
|
||||
f"You have {points_available} points and need at least "
|
||||
f"{self.ctx.get_hint_cost(self.client.slot)}.")
|
||||
notify_hints(self.ctx, self.client.team, hints)
|
||||
self.ctx.notify_hints(self.client.team, hints)
|
||||
self.ctx.save()
|
||||
return True
|
||||
|
||||
else:
|
||||
if points_available >= cost:
|
||||
self.output("Nothing found. Item/Location may not exist.")
|
||||
if for_location:
|
||||
self.output(f"Nothing found for recognized location name \"{hint_name}\". "
|
||||
f"Location appears to not exist in this multiworld.")
|
||||
else:
|
||||
self.output(f"Nothing found for recognized item name \"{hint_name}\". "
|
||||
f"Item appears to not exist in this multiworld.")
|
||||
else:
|
||||
self.output(f"You can't afford the hint. "
|
||||
f"You have {points_available} points and need at least "
|
||||
@@ -1512,27 +1580,16 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
else:
|
||||
team, slot = ctx.connect_names[args['name']]
|
||||
game = ctx.games[slot]
|
||||
ignore_game = "IgnoreGame" in args["tags"] or ( # IgnoreGame is deprecated. TODO: remove after 0.3.3?
|
||||
("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game"))
|
||||
ignore_game = ("TextOnly" in args["tags"] or "Tracker" in args["tags"]) and not args.get("game")
|
||||
if not ignore_game and args['game'] != game:
|
||||
errors.add('InvalidGame')
|
||||
minver = min_client_version if ignore_game else ctx.minimum_client_versions[slot]
|
||||
if minver > args['version']:
|
||||
errors.add('IncompatibleVersion')
|
||||
if args.get('items_handling', None) is None:
|
||||
# fall back to load from multidata
|
||||
client.no_items = False
|
||||
client.remote_items = slot in ctx.remote_items
|
||||
client.remote_start_inventory = slot in ctx.remote_start_inventory
|
||||
await ctx.send_msgs(client, [{
|
||||
"cmd": "Print", "text":
|
||||
"Warning: Client is not sending items_handling flags, "
|
||||
"which will not be supported in the future."}])
|
||||
else:
|
||||
try:
|
||||
client.items_handling = args['items_handling']
|
||||
except (ValueError, TypeError):
|
||||
errors.add('InvalidItemsHandling')
|
||||
try:
|
||||
client.items_handling = args['items_handling']
|
||||
except (ValueError, TypeError):
|
||||
errors.add('InvalidItemsHandling')
|
||||
|
||||
# only exact version match allowed
|
||||
if ctx.compatibility == 0 and args['version'] != version_tuple:
|
||||
@@ -1554,15 +1611,15 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
client.version = args['version']
|
||||
client.tags = args['tags']
|
||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||
reply = [{
|
||||
connected_packet = {
|
||||
"cmd": "Connected",
|
||||
"team": client.team, "slot": client.slot,
|
||||
"players": ctx.get_players_package(),
|
||||
"missing_locations": get_missing_checks(ctx, team, slot),
|
||||
"checked_locations": get_checked_checks(ctx, team, slot),
|
||||
"slot_data": ctx.slot_data[client.slot],
|
||||
"slot_info": ctx.slot_info
|
||||
}]
|
||||
}
|
||||
reply = [connected_packet]
|
||||
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
|
||||
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
|
||||
if (start_inventory or items) and not client.no_items:
|
||||
@@ -1571,7 +1628,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if not client.auth: # if this was a Re-Connect, don't print to console
|
||||
client.auth = True
|
||||
await on_client_joined(ctx, client)
|
||||
|
||||
if args.get("slot_data", True):
|
||||
connected_packet["slot_data"] = ctx.slot_data[client.slot]
|
||||
await ctx.send_msgs(client, reply)
|
||||
|
||||
elif cmd == "GetDataPackage":
|
||||
@@ -1659,7 +1717,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
if create_as_hint:
|
||||
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
|
||||
locs.append(NetworkItem(target_item, location, target_player, flags))
|
||||
notify_hints(ctx, client.team, hints, only_new=create_as_hint == 2)
|
||||
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
|
||||
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
|
||||
|
||||
elif cmd == 'StatusUpdate':
|
||||
@@ -1693,18 +1751,22 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
return
|
||||
args["cmd"] = "Retrieved"
|
||||
keys = args["keys"]
|
||||
args["keys"] = {key: ctx.stored_data.get(key, None) for key in keys}
|
||||
args["keys"] = {
|
||||
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
|
||||
ctx.stored_data.get(key, None)
|
||||
for key in keys
|
||||
}
|
||||
await ctx.send_msgs(client, [args])
|
||||
|
||||
elif cmd == "Set":
|
||||
if "key" not in args or \
|
||||
if "key" not in args or args["key"].startswith("_read_") or \
|
||||
"operations" not in args or not type(args["operations"]) == list:
|
||||
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
|
||||
"text": 'Set', "original_cmd": cmd}])
|
||||
return
|
||||
args["cmd"] = "SetReply"
|
||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||
args["original_value"] = value
|
||||
args["original_value"] = copy.copy(value)
|
||||
for operation in args["operations"]:
|
||||
func = modify_functions[operation["operation"]]
|
||||
value = func(value, operation["value"])
|
||||
@@ -1835,28 +1897,23 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
|
||||
@mark_raw
|
||||
def _cmd_release(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items from a player to their intended recipients."""
|
||||
return self._cmd_forfeit(player_name)
|
||||
|
||||
@mark_raw
|
||||
def _cmd_forfeit(self, player_name: str) -> bool:
|
||||
"""Send out the remaining items from a player to their intended recipients."""
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, _ = player
|
||||
forfeit_player(self.ctx, team, slot)
|
||||
release_player(self.ctx, team, slot)
|
||||
return True
|
||||
|
||||
self.output(f"Could not find player {player_name} to release")
|
||||
return False
|
||||
|
||||
@mark_raw
|
||||
def _cmd_allow_forfeit(self, player_name: str) -> bool:
|
||||
def _cmd_allow_release(self, player_name: str) -> bool:
|
||||
"""Allow the specified player to use the !release command."""
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, name = player
|
||||
self.ctx.allow_forfeits[(team, slot)] = True
|
||||
self.ctx.allow_releases[(team, slot)] = True
|
||||
self.output(f"Player {name} is now allowed to use the !release command at any time.")
|
||||
return True
|
||||
|
||||
@@ -1864,12 +1921,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
return False
|
||||
|
||||
@mark_raw
|
||||
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
|
||||
def _cmd_forbid_release(self, player_name: str) -> bool:
|
||||
""""Disallow the specified player from using the !release command."""
|
||||
player = self.resolve_player(player_name)
|
||||
if player:
|
||||
team, slot, name = player
|
||||
self.ctx.allow_forfeits[(team, slot)] = False
|
||||
self.ctx.allow_releases[(team, slot)] = False
|
||||
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
|
||||
return True
|
||||
|
||||
@@ -1905,6 +1962,37 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
"""Sends an item to the specified player"""
|
||||
return self._cmd_send_multiple(1, player_name, *item_name)
|
||||
|
||||
def _cmd_send_location(self, player_name: str, *location_name: str) -> bool:
|
||||
"""Send out item from a player's location as though they checked it"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
if usable:
|
||||
team, slot = self.ctx.player_name_lookup[seeked_player]
|
||||
game = self.ctx.games[slot]
|
||||
full_name = " ".join(location_name)
|
||||
|
||||
if full_name.isnumeric():
|
||||
location, usable, response = int(full_name), True, None
|
||||
elif self.ctx.location_names_for_game(game) is not None:
|
||||
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
|
||||
else:
|
||||
self.output("Can't look up location for unknown game. Send by ID instead.")
|
||||
return False
|
||||
|
||||
if usable:
|
||||
if isinstance(location, int):
|
||||
register_location_checks(self.ctx, team, slot, [location])
|
||||
else:
|
||||
seeked_location: int = self.ctx.location_names_for_game(self.ctx.games[slot])[location]
|
||||
register_location_checks(self.ctx, team, slot, [seeked_location])
|
||||
return True
|
||||
else:
|
||||
self.output(response)
|
||||
return False
|
||||
|
||||
else:
|
||||
self.output(response)
|
||||
return False
|
||||
|
||||
def _cmd_hint(self, player_name: str, *item_name: str) -> bool:
|
||||
"""Send out a hint for a player's item to their team"""
|
||||
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
|
||||
@@ -1931,7 +2019,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
hints = collect_hints(self.ctx, team, slot, item)
|
||||
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
self.ctx.notify_hints(team, hints)
|
||||
|
||||
else:
|
||||
self.output("No hints found.")
|
||||
@@ -1966,7 +2054,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
else:
|
||||
hints = collect_hint_location_name(self.ctx, team, slot, location)
|
||||
if hints:
|
||||
notify_hints(self.ctx, team, hints)
|
||||
self.ctx.notify_hints(team, hints)
|
||||
else:
|
||||
self.output("No hints found.")
|
||||
return True
|
||||
@@ -1993,7 +2081,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
|
||||
return input_text
|
||||
setattr(self.ctx, option_name, attrtype(option))
|
||||
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
|
||||
if option_name in {"forfeit_mode", "remaining_mode", "collect_mode"}:
|
||||
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
|
||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
|
||||
elif option_name in {"hint_cost", "location_check_points"}:
|
||||
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
|
||||
@@ -2033,19 +2121,21 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument('--password', default=defaults["password"])
|
||||
parser.add_argument('--savefile', default=defaults["savefile"])
|
||||
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
|
||||
parser.add_argument('--cert', help="Path to a SSL Certificate for encryption.")
|
||||
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
|
||||
parser.add_argument('--loglevel', default=defaults["loglevel"],
|
||||
choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
|
||||
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
|
||||
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
|
||||
parser.add_argument('--forfeit_mode', default=defaults["forfeit_mode"], nargs='?',
|
||||
parser.add_argument('--release_mode', default=defaults["release_mode"], nargs='?',
|
||||
choices=['auto', 'enabled', 'disabled', "goal", "auto-enabled"], help='''\
|
||||
Select !forfeit Accessibility. (default: %(default)s)
|
||||
auto: Automatic "forfeit" on goal completion
|
||||
enabled: !forfeit is always available
|
||||
disabled: !forfeit is never available
|
||||
goal: !forfeit can be used after goal completion
|
||||
auto-enabled: !forfeit is available and automatically triggered on goal completion
|
||||
Select !release Accessibility. (default: %(default)s)
|
||||
auto: Automatic "release" on goal completion
|
||||
enabled: !release is always available
|
||||
disabled: !release is never available
|
||||
goal: !release can be used after goal completion
|
||||
auto-enabled: !release is available and automatically triggered on goal completion
|
||||
''')
|
||||
parser.add_argument('--collect_mode', default=defaults["collect_mode"], nargs='?',
|
||||
choices=['auto', 'enabled', 'disabled', "goal", "auto-enabled"], help='''\
|
||||
@@ -2067,7 +2157,7 @@ def parse_args() -> argparse.Namespace:
|
||||
help="automatically shut down the server after this many minutes without new location checks. "
|
||||
"0 to keep running. Not yet implemented.")
|
||||
parser.add_argument('--use_embedded_options', action="store_true",
|
||||
help='retrieve forfeit, remaining and hint options from the multidata file,'
|
||||
help='retrieve release, remaining and hint options from the multidata file,'
|
||||
' instead of host.yaml')
|
||||
parser.add_argument('--compatibility', default=defaults["compatibility"], type=int,
|
||||
help="""
|
||||
@@ -2105,11 +2195,19 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
await asyncio.sleep(seconds)
|
||||
|
||||
|
||||
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
||||
import ssl
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_default_certs()
|
||||
ssl_context.load_cert_chain(path, cert_key if cert_key else path)
|
||||
return ssl_context
|
||||
|
||||
|
||||
async def main(args: argparse.Namespace):
|
||||
Utils.init_logging("Server", loglevel=args.loglevel.lower())
|
||||
|
||||
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
|
||||
args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.collect_mode,
|
||||
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
|
||||
args.remaining_mode,
|
||||
args.auto_shutdown, args.compatibility, args.log_network)
|
||||
data_filename = args.multidata
|
||||
@@ -2140,8 +2238,10 @@ async def main(args: argparse.Namespace):
|
||||
|
||||
ctx.init_save(not args.disable_save)
|
||||
|
||||
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
|
||||
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
ip = args.host if args.host else Utils.get_public_ipv4()
|
||||
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
|
||||
'No password' if not ctx.password else 'Password: %s' % ctx.password))
|
||||
|
||||
@@ -43,7 +43,7 @@ class Permission(enum.IntFlag):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for release
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
|
||||
@staticmethod
|
||||
@@ -86,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
|
||||
data = obj._asdict()
|
||||
data["class"] = obj.__class__.__name__
|
||||
return data
|
||||
if isinstance(obj, (tuple, list, set)):
|
||||
if isinstance(obj, (tuple, list, set, frozenset)):
|
||||
return tuple(_scan_for_TypedTuples(o) for o in obj)
|
||||
if isinstance(obj, dict):
|
||||
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
|
||||
@@ -109,7 +109,7 @@ def get_any_version(data: dict) -> Version:
|
||||
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
|
||||
|
||||
|
||||
whitelist = {
|
||||
allowlist = {
|
||||
"NetworkPlayer": NetworkPlayer,
|
||||
"NetworkItem": NetworkItem,
|
||||
"NetworkSlot": NetworkSlot
|
||||
@@ -125,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
|
||||
hook = custom_hooks.get(o.get("class", None), None)
|
||||
if hook:
|
||||
return hook(o)
|
||||
cls = whitelist.get(o.get("class", None), None)
|
||||
cls = allowlist.get(o.get("class", None), None)
|
||||
if cls:
|
||||
for key in tuple(o):
|
||||
if key not in cls._fields:
|
||||
|
||||
@@ -3,6 +3,7 @@ import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
@@ -217,13 +218,18 @@ def adjust(args):
|
||||
# Load up the ROM
|
||||
rom = Rom(file=args.rom, force_use=True)
|
||||
delete_zootdec = True
|
||||
elif os.path.splitext(args.rom)[-1] == '.apz5':
|
||||
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
|
||||
# Load vanilla ROM
|
||||
rom = Rom(file=args.vanilla_rom, force_use=True)
|
||||
apz5_file = args.rom
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
# Patch file
|
||||
apply_patch_file(rom, args.rom)
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
else:
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
|
||||
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
|
||||
# Call patch_cosmetics
|
||||
try:
|
||||
patch_cosmetics(ootworld, rom)
|
||||
|
||||
46
OoTClient.py
46
OoTClient.py
@@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import multiprocessing
|
||||
import subprocess
|
||||
import zipfile
|
||||
from asyncio import StreamReader, StreamWriter
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
@@ -50,7 +51,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
|
||||
|
||||
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
|
||||
|
||||
script_version: int = 2
|
||||
script_version: int = 3
|
||||
|
||||
def get_item_value(ap_id):
|
||||
return ap_id - 66000
|
||||
@@ -85,6 +86,9 @@ class OoTContext(CommonContext):
|
||||
self.n64_status = CONNECTION_INITIAL_STATUS
|
||||
self.awaiting_rom = False
|
||||
self.location_table = {}
|
||||
self.collectible_table = {}
|
||||
self.collectible_override_flags_address = 0
|
||||
self.collectible_offsets = {}
|
||||
self.deathlink_enabled = False
|
||||
self.deathlink_pending = False
|
||||
self.deathlink_sent_this_death = False
|
||||
@@ -117,6 +121,13 @@ class OoTContext(CommonContext):
|
||||
self.ui = OoTManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd, args):
|
||||
if cmd == 'Connected':
|
||||
slot_data = args.get('slot_data', None)
|
||||
if slot_data:
|
||||
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
|
||||
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
|
||||
|
||||
|
||||
def get_payload(ctx: OoTContext):
|
||||
if ctx.deathlink_enabled and ctx.deathlink_pending:
|
||||
@@ -125,11 +136,14 @@ def get_payload(ctx: OoTContext):
|
||||
else:
|
||||
trigger_death = False
|
||||
|
||||
return json.dumps({
|
||||
payload = json.dumps({
|
||||
"items": [get_item_value(item.item) for item in ctx.items_received],
|
||||
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
|
||||
"triggerDeath": trigger_death
|
||||
"triggerDeath": trigger_death,
|
||||
"collectibleOverrides": ctx.collectible_override_flags_address,
|
||||
"collectibleOffsets": ctx.collectible_offsets
|
||||
})
|
||||
return payload
|
||||
|
||||
|
||||
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
@@ -141,6 +155,7 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
ctx.deathlink_client_override = False
|
||||
ctx.finished_game = False
|
||||
ctx.location_table = {}
|
||||
ctx.collectible_table = {}
|
||||
ctx.deathlink_pending = False
|
||||
ctx.deathlink_sent_this_death = False
|
||||
ctx.auth = payload['playerName']
|
||||
@@ -161,11 +176,17 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
|
||||
ctx.finished_game = True
|
||||
|
||||
# Locations handling
|
||||
if ctx.location_table != payload['locations']:
|
||||
ctx.location_table = payload['locations']
|
||||
locations = payload['locations']
|
||||
collectibles = payload['collectibles']
|
||||
|
||||
if ctx.location_table != locations or ctx.collectible_table != collectibles:
|
||||
ctx.location_table = locations
|
||||
ctx.collectible_table = collectibles
|
||||
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
|
||||
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "LocationChecks",
|
||||
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
|
||||
"locations": locs1 + locs2
|
||||
}])
|
||||
|
||||
# Deathlink handling
|
||||
@@ -191,13 +212,6 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
try:
|
||||
await asyncio.wait_for(writer.drain(), timeout=1.5)
|
||||
try:
|
||||
# Data will return a dict with up to six fields:
|
||||
# 1. str: player name (always)
|
||||
# 2. int: script version (always)
|
||||
# 3. bool: deathlink active (always)
|
||||
# 4. dict[str, bool]: checked locations
|
||||
# 5. bool: whether Link is currently at 0 HP
|
||||
# 6. bool: whether the game currently registers as complete
|
||||
data = await asyncio.wait_for(reader.readline(), timeout=10)
|
||||
data_decoded = json.loads(data.decode())
|
||||
reported_version = data_decoded.get('scriptVersion', 0)
|
||||
@@ -270,12 +284,16 @@ async def run_game(romfile):
|
||||
|
||||
|
||||
async def patch_and_run_game(apz5_file):
|
||||
apz5_file = os.path.abspath(apz5_file)
|
||||
base_name = os.path.splitext(apz5_file)[0]
|
||||
decomp_path = base_name + '-decomp.z64'
|
||||
comp_path = base_name + '.z64'
|
||||
# Load vanilla ROM, patch file, compress ROM
|
||||
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
|
||||
apply_patch_file(rom, apz5_file)
|
||||
apply_patch_file(rom, apz5_file,
|
||||
sub_file=(os.path.basename(base_name) + '.zpf'
|
||||
if zipfile.is_zipfile(apz5_file)
|
||||
else None))
|
||||
rom.write_to_file(decomp_path)
|
||||
os.chdir(data_path("Compress"))
|
||||
compress_rom_file(decomp_path, comp_path)
|
||||
|
||||
16
Options.py
16
Options.py
@@ -133,10 +133,10 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
||||
raise NotImplementedError
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from Generate import PlandoSettings
|
||||
from Generate import PlandoOptions
|
||||
from worlds.AutoWorld import World
|
||||
|
||||
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
|
||||
def verify(self, world: World, player_name: str, plando_options: PlandoOptions) -> None:
|
||||
pass
|
||||
else:
|
||||
def verify(self, *args, **kwargs) -> None:
|
||||
@@ -578,8 +578,8 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
if isinstance(self.value, int):
|
||||
return
|
||||
from Generate import PlandoSettings
|
||||
if not(PlandoSettings.bosses & plando_options):
|
||||
from Generate import PlandoOptions
|
||||
if not(PlandoOptions.bosses & plando_options):
|
||||
import logging
|
||||
# plando is disabled but plando options were given so pull the option and change it to an int
|
||||
option = self.value.split(";")[-1]
|
||||
@@ -927,7 +927,8 @@ class ItemLinks(OptionList):
|
||||
Optional("exclude"): [And(str, len)],
|
||||
"replacement_item": Or(And(str, len), None),
|
||||
Optional("local_items"): [And(str, len)],
|
||||
Optional("non_local_items"): [And(str, len)]
|
||||
Optional("non_local_items"): [And(str, len)],
|
||||
Optional("link_replacement"): Or(None, bool),
|
||||
}
|
||||
])
|
||||
|
||||
@@ -950,6 +951,7 @@ class ItemLinks(OptionList):
|
||||
return pool
|
||||
|
||||
def verify(self, world, player_name: str, plando_options) -> None:
|
||||
link: dict
|
||||
super(ItemLinks, self).verify(world, player_name, plando_options)
|
||||
existing_links = set()
|
||||
for link in self.value:
|
||||
@@ -974,7 +976,9 @@ class ItemLinks(OptionList):
|
||||
|
||||
intersection = local_items.intersection(non_local_items)
|
||||
if intersection:
|
||||
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
|
||||
raise Exception(f"item_link {link['name']} has {intersection} "
|
||||
f"items in both its local_items and non_local_items pool.")
|
||||
link.setdefault("link_replacement", None)
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
|
||||
@@ -15,6 +15,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
|
||||
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": {}}
|
||||
location_bytes_bits = {}
|
||||
@@ -39,6 +40,8 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
|
||||
|
||||
DISPLAY_MSGS = True
|
||||
|
||||
SCRIPT_VERSION = 1
|
||||
|
||||
|
||||
class GBCommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
@@ -53,7 +56,6 @@ class GBCommandProcessor(ClientCommandProcessor):
|
||||
class GBContext(CommonContext):
|
||||
command_processor = GBCommandProcessor
|
||||
game = 'Pokemon Red and Blue'
|
||||
items_handling = 0b101
|
||||
|
||||
def __init__(self, server_address, password):
|
||||
super().__init__(server_address, password)
|
||||
@@ -64,6 +66,10 @@ class GBContext(CommonContext):
|
||||
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
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -82,6 +88,8 @@ class GBContext(CommonContext):
|
||||
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':
|
||||
@@ -92,6 +100,10 @@ class GBContext(CommonContext):
|
||||
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
|
||||
|
||||
@@ -107,13 +119,16 @@ class GBContext(CommonContext):
|
||||
|
||||
def get_payload(ctx: GBContext):
|
||||
current_time = time.time()
|
||||
return json.dumps(
|
||||
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}
|
||||
if key[0] > current_time - 10},
|
||||
"deathlink": ctx.deathlink_pending
|
||||
}
|
||||
)
|
||||
ctx.deathlink_pending = False
|
||||
return ret
|
||||
|
||||
|
||||
async def parse_locations(data: List, ctx: GBContext):
|
||||
@@ -121,14 +136,8 @@ async def parse_locations(data: List, ctx: GBContext):
|
||||
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
|
||||
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
|
||||
|
||||
# Check for clear problems
|
||||
if len(flags['Rod']) > 1:
|
||||
return
|
||||
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
|
||||
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
|
||||
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
|
||||
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
|
||||
return
|
||||
|
||||
for flag_type, loc_map in location_map.items():
|
||||
for flag, loc_id in loc_map.items():
|
||||
@@ -168,8 +177,15 @@ async def gb_sync_task(ctx: GBContext):
|
||||
# 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())
|
||||
#print(data_decoded)
|
||||
|
||||
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})
|
||||
@@ -179,13 +195,20 @@ async def gb_sync_task(ctx: GBContext):
|
||||
if not ctx.auth:
|
||||
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
|
||||
if ctx.auth == '':
|
||||
logger.info("Invalid ROM detected. No player name built into the ROM.")
|
||||
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 ctx.set_deathlink:
|
||||
await ctx.update_death_link(True)
|
||||
except asyncio.TimeoutError:
|
||||
logger.debug("Read Timed Out, Reconnecting")
|
||||
error_status = CONNECTION_TIMING_OUT_STATUS
|
||||
@@ -243,8 +266,16 @@ async def run_game(romfile):
|
||||
async def patch_and_run_game(game_version, patch_file, ctx):
|
||||
base_name = os.path.splitext(patch_file)[0]
|
||||
comp_path = base_name + '.gb'
|
||||
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
|
||||
base_rom = bytes(stream.read())
|
||||
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:
|
||||
|
||||
@@ -33,6 +33,7 @@ Currently, the following games are supported:
|
||||
* Hylics 2
|
||||
* Overcooked! 2
|
||||
* Zillion
|
||||
* Lufia II Ancient Cave
|
||||
|
||||
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
|
||||
|
||||
@@ -10,6 +10,8 @@ import re
|
||||
import sys
|
||||
import typing
|
||||
import queue
|
||||
import zipfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
# CommonClient import first to trigger ModuleUpdater
|
||||
@@ -120,9 +122,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
|
||||
return False
|
||||
|
||||
def _cmd_download_data(self, force: bool = False) -> bool:
|
||||
def _cmd_download_data(self) -> bool:
|
||||
"""Download the most recent release of the necessary files for playing SC2 with
|
||||
Archipelago. force should be True or False. force=True will overwrite your files."""
|
||||
Archipelago. Will overwrite existing files."""
|
||||
if "SC2PATH" not in os.environ:
|
||||
check_game_install_path()
|
||||
|
||||
@@ -132,11 +134,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
else:
|
||||
current_ver = None
|
||||
|
||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
|
||||
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData',
|
||||
current_version=current_ver, force_download=True)
|
||||
|
||||
if tempzip != '':
|
||||
try:
|
||||
import zipfile
|
||||
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
|
||||
sc2_logger.info(f"Download complete. Version {version} installed.")
|
||||
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
|
||||
@@ -195,12 +197,16 @@ class SC2Context(CommonContext):
|
||||
self.build_location_to_mission_mapping()
|
||||
|
||||
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
|
||||
is_mod_installed_correctly()
|
||||
maps_present = is_mod_installed_correctly()
|
||||
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
|
||||
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
|
||||
current_ver = f.read()
|
||||
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
|
||||
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
|
||||
elif maps_present:
|
||||
sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). "
|
||||
"Run /download_data to update them.")
|
||||
|
||||
|
||||
def on_print_json(self, args: dict):
|
||||
# goes to this world
|
||||
@@ -639,6 +645,13 @@ def request_unfinished_missions(ctx: SC2Context):
|
||||
|
||||
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
|
||||
|
||||
# Removing All-In from location pool
|
||||
final_mission = lookup_id_to_mission[ctx.final_mission]
|
||||
if final_mission in unfinished_missions.keys():
|
||||
message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message
|
||||
if unfinished_missions[final_mission] == -1:
|
||||
unfinished_missions.pop(final_mission)
|
||||
|
||||
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
|
||||
mark_up_objectives(
|
||||
f"[{len(unfinished_missions[mission])}/"
|
||||
@@ -996,7 +1009,7 @@ def download_latest_release_zip(owner: str, repo: str, current_version: str = No
|
||||
download_url = r1.json()["assets"][0]["browser_download_url"]
|
||||
|
||||
r2 = requests.get(download_url, headers=headers)
|
||||
if r2.status_code == 200:
|
||||
if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
|
||||
with open(f"{repo}.zip", "wb") as fh:
|
||||
fh.write(r2.content)
|
||||
sc2_logger.info(f"Successfully downloaded {repo}.zip.")
|
||||
|
||||
46
Utils.py
46
Utils.py
@@ -38,7 +38,7 @@ class Version(typing.NamedTuple):
|
||||
build: int
|
||||
|
||||
|
||||
__version__ = "0.3.6"
|
||||
__version__ = "0.3.8"
|
||||
version_tuple = tuplize_version(__version__)
|
||||
|
||||
is_linux = sys.platform.startswith("linux")
|
||||
@@ -99,7 +99,7 @@ def local_path(*path: str) -> str:
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
|
||||
else:
|
||||
import __main__
|
||||
if hasattr(__main__, "__file__"):
|
||||
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
|
||||
# we are running in a normal Python environment
|
||||
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
|
||||
else:
|
||||
@@ -236,7 +236,7 @@ def get_default_options() -> OptionsType:
|
||||
"bridge_chat_out": True,
|
||||
},
|
||||
"sni_options": {
|
||||
"sni": "SNI",
|
||||
"sni_path": "SNI",
|
||||
"snes_rom_start": True,
|
||||
},
|
||||
"sm_options": {
|
||||
@@ -260,7 +260,7 @@ def get_default_options() -> OptionsType:
|
||||
"disable_item_cheat": False,
|
||||
"location_check_points": 1,
|
||||
"hint_cost": 10,
|
||||
"forfeit_mode": "goal",
|
||||
"release_mode": "goal",
|
||||
"collect_mode": "disabled",
|
||||
"remaining_mode": "goal",
|
||||
"auto_shutdown": 0,
|
||||
@@ -268,13 +268,12 @@ def get_default_options() -> OptionsType:
|
||||
"log_network": 0
|
||||
},
|
||||
"generator": {
|
||||
"teams": 1,
|
||||
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
|
||||
"player_files_path": "Players",
|
||||
"players": 0,
|
||||
"weights_file_path": "weights.yaml",
|
||||
"meta_file_path": "meta.yaml",
|
||||
"spoiler": 2,
|
||||
"spoiler": 3,
|
||||
"glitch_triforce_room": 1,
|
||||
"race": 0,
|
||||
"plando_options": "bosses",
|
||||
@@ -286,6 +285,7 @@ def get_default_options() -> OptionsType:
|
||||
},
|
||||
"oot_options": {
|
||||
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
|
||||
"rom_start": True
|
||||
},
|
||||
"dkc3_options": {
|
||||
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
|
||||
@@ -303,9 +303,14 @@ def get_default_options() -> OptionsType:
|
||||
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
|
||||
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
|
||||
"rom_start": True
|
||||
}
|
||||
},
|
||||
"ffr_options": {
|
||||
"display_msgs": True,
|
||||
},
|
||||
"lufia2ac_options": {
|
||||
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
|
||||
},
|
||||
}
|
||||
|
||||
return options
|
||||
|
||||
|
||||
@@ -452,6 +457,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
|
||||
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
|
||||
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
|
||||
exception_logger: typing.Optional[str] = None):
|
||||
import datetime
|
||||
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
|
||||
log_folder = user_path("logs")
|
||||
os.makedirs(log_folder, exist_ok=True)
|
||||
@@ -460,6 +466,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
root_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
root_logger.setLevel(loglevel)
|
||||
if "a" not in write_mode:
|
||||
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
|
||||
file_handler = logging.FileHandler(
|
||||
os.path.join(log_folder, f"{name}.txt"),
|
||||
write_mode,
|
||||
@@ -487,7 +495,25 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
|
||||
logging.info(f"Archipelago ({__version__}) logging initialized.")
|
||||
def _cleanup():
|
||||
for file in os.scandir(log_folder):
|
||||
if file.name.endswith(".txt"):
|
||||
last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime)
|
||||
if datetime.datetime.now() - last_change > datetime.timedelta(days=7):
|
||||
try:
|
||||
os.unlink(file.path)
|
||||
except Exception as e:
|
||||
logging.exception(e)
|
||||
else:
|
||||
logging.debug(f"Deleted old logfile {file.path}")
|
||||
import threading
|
||||
threading.Thread(target=_cleanup, name="LogCleaner").start()
|
||||
import platform
|
||||
logging.info(
|
||||
f"Archipelago ({__version__}) logging initialized"
|
||||
f" on {platform.platform()}"
|
||||
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
||||
)
|
||||
|
||||
|
||||
def stream_input(stream, queue):
|
||||
@@ -656,7 +682,7 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
|
||||
_faf_tasks: "Set[asyncio.Task[None]]" = set()
|
||||
|
||||
|
||||
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
|
||||
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
|
||||
"""
|
||||
Use this to start a task when you don't keep a reference to it or immediately await it,
|
||||
to prevent early garbage collection. "fire-and-forget"
|
||||
|
||||
@@ -29,7 +29,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
|
||||
def get_app():
|
||||
register()
|
||||
app = raw_app
|
||||
if os.path.exists(configpath):
|
||||
if os.path.exists(configpath) and not app.config["TESTING"]:
|
||||
import yaml
|
||||
app.config.from_file(configpath, yaml.safe_load)
|
||||
logging.info(f"Updated config from {configpath}")
|
||||
|
||||
@@ -24,6 +24,8 @@ app.jinja_env.filters['all'] = all
|
||||
app.config["SELFHOST"] = True # application process is in charge of running the websites
|
||||
app.config["GENERATORS"] = 8 # maximum concurrent world gens
|
||||
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
|
||||
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
|
||||
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
|
||||
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
|
||||
app.config["DEBUG"] = False
|
||||
app.config["PORT"] = 80
|
||||
|
||||
@@ -39,10 +39,11 @@ def get_datapackage():
|
||||
|
||||
@api_endpoints.route('/datapackage_version')
|
||||
@cache.cached()
|
||||
|
||||
def get_datapackage_versions():
|
||||
from worlds import network_data_package, AutoWorldRegister
|
||||
|
||||
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
|
||||
version_package["version"] = network_data_package["version"]
|
||||
return version_package
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import pickle
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request, session, url_for
|
||||
from flask import request, session, url_for, Markup
|
||||
from pony.orm import commit
|
||||
|
||||
from WebHostLib import app
|
||||
@@ -21,13 +21,18 @@ def generate_api():
|
||||
if 'file' in request.files:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, Markup):
|
||||
return {"text": options.striptags()}, 400
|
||||
if isinstance(options, str):
|
||||
return {"text": options}, 400
|
||||
if "race" in request.form:
|
||||
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
|
||||
meta_options_source = request.form
|
||||
|
||||
json_data = request.get_json()
|
||||
# json_data is optional, we can have it silently fall to None as it used to do.
|
||||
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
|
||||
json_data = request.get_json(silent=True)
|
||||
|
||||
if json_data:
|
||||
meta_options_source = json_data
|
||||
if 'weights' in json_data:
|
||||
|
||||
@@ -177,6 +177,8 @@ class MultiworldInstance():
|
||||
with guardian_lock:
|
||||
multiworlds[self.room_id] = self
|
||||
self.ponyconfig = config["PONY"]
|
||||
self.cert = config["SELFLAUNCHCERT"]
|
||||
self.key = config["SELFLAUNCHKEY"]
|
||||
|
||||
def start(self):
|
||||
if self.process and self.process.is_alive():
|
||||
@@ -184,7 +186,8 @@ class MultiworldInstance():
|
||||
|
||||
logging.info(f"Spinning up {self.room_id}")
|
||||
process = multiprocessing.Process(group=None, target=run_server_process,
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data()),
|
||||
args=(self.room_id, self.ponyconfig, get_static_server_data(),
|
||||
self.cert, self.key),
|
||||
name="MultiHost")
|
||||
process.start()
|
||||
# bind after start to prevent thread sync issues with guardian.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import zipfile
|
||||
from typing import *
|
||||
|
||||
from flask import request, flash, redirect, url_for, render_template
|
||||
from flask import request, flash, redirect, url_for, render_template, Markup
|
||||
|
||||
from WebHostLib import app
|
||||
|
||||
@@ -12,7 +12,7 @@ def allowed_file(filename):
|
||||
return filename.endswith(('.txt', ".yaml", ".zip"))
|
||||
|
||||
|
||||
from Generate import roll_settings, PlandoSettings
|
||||
from Generate import roll_settings, PlandoOptions
|
||||
from Utils import parse_yamls
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ def check():
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
results, _ = roll_options(options)
|
||||
@@ -38,7 +38,7 @@ def mysterycheck():
|
||||
return redirect(url_for("check"), 301)
|
||||
|
||||
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
|
||||
options = {}
|
||||
# if user does not select file, browser also
|
||||
# submit an empty part without filename
|
||||
@@ -50,6 +50,10 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
with zipfile.ZipFile(file, 'r') as zfile:
|
||||
infolist = zfile.infolist()
|
||||
|
||||
if any(file.filename.endswith(".archipelago") for file in infolist):
|
||||
return Markup("Error: Your .zip file contains an .archipelago file. "
|
||||
'Did you mean to <a href="/uploads">host a game</a>?')
|
||||
|
||||
for file in infolist:
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
|
||||
@@ -65,7 +69,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
|
||||
def roll_options(options: Dict[str, Union[dict, str]],
|
||||
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
|
||||
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
|
||||
plando_options = PlandoSettings.from_set(set(plando_options))
|
||||
plando_options = PlandoOptions.from_set(set(plando_options))
|
||||
results = {}
|
||||
rolled_results = {}
|
||||
for filename, text in options.items():
|
||||
|
||||
@@ -10,12 +10,14 @@ import random
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import typing
|
||||
|
||||
import websockets
|
||||
from pony.orm import db_session, commit, select
|
||||
from pony.orm import commit, db_session, select
|
||||
|
||||
import Utils
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
|
||||
|
||||
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
|
||||
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
|
||||
from .models import Room, Command, db
|
||||
|
||||
@@ -66,7 +68,6 @@ class WebHostContext(Context):
|
||||
def _load_game_data(self):
|
||||
for key, value in self.static_server_data.items():
|
||||
setattr(self, key, value)
|
||||
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
|
||||
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
|
||||
|
||||
def listen_to_db_commands(self):
|
||||
@@ -126,7 +127,6 @@ def get_random_port():
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"forced_auto_forfeits": {},
|
||||
"non_hintable_names": {},
|
||||
"gamespackage": worlds.network_data_package["games"],
|
||||
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
|
||||
@@ -134,13 +134,13 @@ def get_static_server_data() -> dict:
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
|
||||
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str]):
|
||||
# establish DB connection for multidata and multisave
|
||||
db.bind(**ponyconfig)
|
||||
db.generate_mapping(check_tables=False)
|
||||
@@ -150,15 +150,15 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
|
||||
ctx = WebHostContext(static_server_data)
|
||||
ctx.load(room_id)
|
||||
ctx.init_save()
|
||||
|
||||
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
|
||||
try:
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
|
||||
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
|
||||
ping_interval=None)
|
||||
ping_interval=None, ssl=ssl_context)
|
||||
|
||||
await ctx.server
|
||||
port = 0
|
||||
|
||||
@@ -72,7 +72,14 @@ def download_slot_file(room_id, player_id: int):
|
||||
if name.endswith("info.json"):
|
||||
fname = name.rsplit("/", 1)[0] + ".zip"
|
||||
elif slot_data.game == "Ocarina of Time":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
stream = io.BytesIO(slot_data.data)
|
||||
if zipfile.is_zipfile(stream):
|
||||
with zipfile.ZipFile(stream) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith(".zpf"):
|
||||
fname = name.rsplit(".", 1)[0] + ".apz5"
|
||||
else: # pre-ootr-7.0 support
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
|
||||
elif slot_data.game == "VVVVVV":
|
||||
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
|
||||
elif slot_data.game == "Zillion":
|
||||
|
||||
@@ -12,7 +12,7 @@ from flask import request, flash, redirect, url_for, session, render_template
|
||||
from pony.orm import commit, db_session
|
||||
|
||||
from BaseClasses import seeddigits, get_seed
|
||||
from Generate import handle_name, PlandoSettings
|
||||
from Generate import handle_name, PlandoOptions
|
||||
from Main import main as ERmain
|
||||
from Utils import __version__
|
||||
from WebHostLib import app
|
||||
@@ -33,7 +33,7 @@ def get_meta(options_source: dict) -> dict:
|
||||
|
||||
server_options = {
|
||||
"hint_cost": int(options_source.get("hint_cost", 10)),
|
||||
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
|
||||
"release_mode": options_source.get("release_mode", "goal"),
|
||||
"remaining_mode": options_source.get("remaining_mode", "disabled"),
|
||||
"collect_mode": options_source.get("collect_mode", "disabled"),
|
||||
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
|
||||
@@ -52,7 +52,7 @@ def generate(race=False):
|
||||
else:
|
||||
file = request.files['file']
|
||||
options = get_yaml_data(file)
|
||||
if type(options) == str:
|
||||
if isinstance(options, str):
|
||||
flash(options)
|
||||
else:
|
||||
meta = get_meta(request.form)
|
||||
@@ -92,7 +92,7 @@ def generate(race=False):
|
||||
return render_template("generate.html", race=race, version=__version__)
|
||||
|
||||
|
||||
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
|
||||
if not meta:
|
||||
meta: Dict[str, Any] = {}
|
||||
|
||||
@@ -114,12 +114,12 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
|
||||
erargs = parse_arguments(['--multi', str(playercount)])
|
||||
erargs.seed = seed
|
||||
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
|
||||
erargs.spoiler = 0 if race else 2
|
||||
erargs.spoiler = 0 if race else 3
|
||||
erargs.race = race
|
||||
erargs.outputname = seedname
|
||||
erargs.outputpath = target.name
|
||||
erargs.teams = 1
|
||||
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
|
||||
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
|
||||
{"bosses", "items", "connections", "texts"}))
|
||||
|
||||
name_counter = Counter()
|
||||
|
||||
@@ -69,10 +69,6 @@ def tutorial(game, file, lang):
|
||||
|
||||
@app.route('/tutorial/')
|
||||
def tutorial_landing():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("tutorialLanding.html")
|
||||
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ def create():
|
||||
|
||||
del file_data
|
||||
|
||||
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
|
||||
with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f:
|
||||
f.write(res)
|
||||
|
||||
# Generate JSON files for player-settings pages
|
||||
|
||||
@@ -3,5 +3,5 @@ pony>=0.7.16
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.0.1
|
||||
Flask-Compress>=1.13
|
||||
Flask-Limiter>=2.7.0
|
||||
bokeh>=3.0.0
|
||||
Flask-Limiter>=2.8.1
|
||||
bokeh>=3.0.2
|
||||
|
||||
@@ -4,6 +4,7 @@ window.addEventListener('load', () => {
|
||||
"ordering": true,
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
console.log(tables);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 seeds. In each player's
|
||||
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.
|
||||
|
||||
@@ -29,7 +29,7 @@ their game.
|
||||
|
||||
## What happens if a person has to leave early?
|
||||
|
||||
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
|
||||
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?
|
||||
|
||||
@@ -205,6 +205,11 @@ const buildOptionsTable = (settings, romOpts = false) => {
|
||||
let presetOption = document.createElement('option');
|
||||
presetOption.innerText = presetName;
|
||||
presetOption.value = settings[setting].value_names[presetName];
|
||||
const words = presetOption.innerText.split("_");
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
|
||||
}
|
||||
presetOption.innerText = words.join(" ");
|
||||
specialRangeSelect.appendChild(presetOption);
|
||||
});
|
||||
let customOption = document.createElement('option');
|
||||
|
||||
49
WebHostLib/static/assets/sc2wolTracker.js
Normal file
49
WebHostLib/static/assets/sc2wolTracker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
window.addEventListener('load', () => {
|
||||
// Reload tracker every 15 seconds
|
||||
const url = window.location;
|
||||
setInterval(() => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
|
||||
// Create a fake DOM using the returned HTML
|
||||
const domParser = new DOMParser();
|
||||
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
|
||||
|
||||
// Update item tracker
|
||||
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
|
||||
// Update only counters in the location-table
|
||||
let counters = document.getElementsByClassName('counter');
|
||||
const fakeCounters = fakeDOM.getElementsByClassName('counter');
|
||||
for (let i = 0; i < counters.length; i++) {
|
||||
counters[i].innerHTML = fakeCounters[i].innerHTML;
|
||||
}
|
||||
};
|
||||
ajax.open('GET', url);
|
||||
ajax.send();
|
||||
}, 15000)
|
||||
|
||||
// Collapsible advancement sections
|
||||
const categories = document.getElementsByClassName("location-category");
|
||||
for (let i = 0; i < categories.length; i++) {
|
||||
let hide_id = categories[i].id.split('-')[0];
|
||||
if (hide_id == 'Total') {
|
||||
continue;
|
||||
}
|
||||
categories[i].addEventListener('click', function() {
|
||||
// Toggle the advancement list
|
||||
document.getElementById(hide_id).classList.toggle("hide");
|
||||
// Change text of the header
|
||||
const tab_header = document.getElementById(hide_id+'-header').children[0];
|
||||
const orig_text = tab_header.innerHTML;
|
||||
let new_text;
|
||||
if (orig_text.includes("▼")) {
|
||||
new_text = orig_text.replace("▼", "▲");
|
||||
}
|
||||
else {
|
||||
new_text = orig_text.replace("▲", "▼");
|
||||
}
|
||||
tab_header.innerHTML = new_text;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -17,6 +17,13 @@ window.addEventListener('load', () => {
|
||||
paging: false,
|
||||
info: false,
|
||||
dom: "t",
|
||||
stateSave: true,
|
||||
stateSaveCallback: function(settings,data) {
|
||||
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
|
||||
},
|
||||
stateLoadCallback: function(settings) {
|
||||
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
|
||||
},
|
||||
columnDefs: [
|
||||
{
|
||||
targets: 'hours',
|
||||
@@ -68,10 +75,18 @@ window.addEventListener('load', () => {
|
||||
console.info(tables.search());
|
||||
tables.draw();
|
||||
});
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
|
||||
|
||||
function getSleepTimeSeconds(){
|
||||
// -40 % 60 is -40, which is absolutely wrong and should burn
|
||||
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
|
||||
return sleepSeconds || 60;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const target = $("<div></div>");
|
||||
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
|
||||
console.log("Updating Tracker...");
|
||||
target.load("/tracker/" + tracker, function (response, status) {
|
||||
if (status === "success") {
|
||||
target.find(".table").each(function (i, new_table) {
|
||||
@@ -90,9 +105,9 @@ window.addEventListener('load', () => {
|
||||
console.log(response);
|
||||
}
|
||||
})
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
}
|
||||
|
||||
setInterval(update, 30000);
|
||||
setTimeout(update, getSleepTimeSeconds()*1000);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
adjustTableHeight();
|
||||
|
||||
@@ -6,6 +6,7 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 3, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
$("#seeds-table").DataTable({
|
||||
"paging": false,
|
||||
@@ -13,5 +14,6 @@ window.addEventListener('load', () => {
|
||||
"order": [[ 2, "desc" ]],
|
||||
"info": false,
|
||||
"dom": "t",
|
||||
"stateSave": true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,9 @@ h5, h6{
|
||||
margin-bottom: 20px;
|
||||
background-color: #ffff00;
|
||||
}
|
||||
.user-message a{
|
||||
color: #ff7700;
|
||||
}
|
||||
|
||||
.interactive{
|
||||
color: #ffef00;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 448px;
|
||||
width: 480px;
|
||||
background-color: rgb(60, 114, 157);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 448px;
|
||||
width: 480px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
@@ -108,7 +108,7 @@
|
||||
}
|
||||
|
||||
#location-table td:first-child {
|
||||
width: 272px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.location-category td:first-child {
|
||||
|
||||
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
110
WebHostLib/static/styles/sc2wolTracker.css
Normal file
@@ -0,0 +1,110 @@
|
||||
#player-tracker-wrapper{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#inventory-table{
|
||||
border-top: 2px solid #000000;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
padding: 3px 3px 10px;
|
||||
width: 500px;
|
||||
background-color: #525494;
|
||||
}
|
||||
|
||||
#inventory-table td{
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#inventory-table td.title{
|
||||
padding-top: 10px;
|
||||
height: 20px;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#inventory-table img{
|
||||
height: 100%;
|
||||
max-width: 40px;
|
||||
max-height: 40px;
|
||||
border: 1px solid #000000;
|
||||
filter: grayscale(100%) contrast(75%) brightness(20%);
|
||||
}
|
||||
|
||||
#inventory-table img.acquired{
|
||||
filter: none;
|
||||
}
|
||||
|
||||
#inventory-table div.counted-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#inventory-table div.item-count {
|
||||
text-align: left;
|
||||
color: black;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table{
|
||||
width: 500px;
|
||||
border-left: 2px solid #000000;
|
||||
border-right: 2px solid #000000;
|
||||
border-bottom: 2px solid #000000;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
background-color: #525494;
|
||||
padding: 10px 3px 3px;
|
||||
font-family: "JuraBook", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
#location-table th{
|
||||
vertical-align: middle;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
#location-table td{
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
#location-table td.counter {
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#location-table td.toggle-arrow {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#location-table tr#Total-header {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#location-table img{
|
||||
height: 100%;
|
||||
max-width: 30px;
|
||||
max-height: 30px;
|
||||
}
|
||||
|
||||
#location-table tbody.locations {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#location-table td.location-name {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str
|
||||
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
|
||||
games_played = defaultdict(Counter)
|
||||
total_games = Counter()
|
||||
cutoff = date.today()-timedelta(days=30)
|
||||
cutoff = date.today() - timedelta(days=30)
|
||||
room: Room
|
||||
for room in select(room for room in Room if room.creation_time >= cutoff):
|
||||
for slot in room.seed.slots:
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div id="check-result" class="grass-island">
|
||||
<h1>Verification Results</h1>
|
||||
<p>The results of your requested file check are below.</p>
|
||||
<table class="table autodatatable">
|
||||
<table id="results-table" class="table autodatatable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
|
||||
@@ -40,20 +40,20 @@
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="forfeit_mode">Forfeit Permission:
|
||||
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
|
||||
<label for="release_mode">Release Permission:
|
||||
<span class="interactive" data-tooltip="Permissions on when players are able to release all remaining items from their world.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<select name="forfeit_mode" id="forfeit_mode">
|
||||
<select name="release_mode" id="release_mode">
|
||||
<option value="auto">Automatic on goal completion</option>
|
||||
<option value="goal">Allow !forfeit after goal completion</option>
|
||||
<option value="goal">Allow !release after goal completion</option>
|
||||
<option value="auto-enabled">
|
||||
Automatic on goal completion and manual !forfeit
|
||||
Automatic on goal completion and manual !release
|
||||
</option>
|
||||
<option value="enabled">Manual !forfeit</option>
|
||||
<option value="enabled">Manual !release</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
</select>
|
||||
</td>
|
||||
@@ -62,7 +62,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<label for="collect_mode">Collect Permission:
|
||||
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
|
||||
<span class="interactive" data-tooltip="Permissions on when players are able to collect all their remaining items from across the multiworld.">
|
||||
(?)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/dirtHeader.html' %}
|
||||
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
|
||||
<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>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table">
|
||||
<table id="received-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
@@ -37,7 +37,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table">
|
||||
<table id="locations-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
No file to download for this game.
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
||||
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
233
WebHostLib/templates/sc2wolTracker.html
Normal file
233
WebHostLib/templates/sc2wolTracker.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ player_name }}'s Tracker</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/sc2wolTracker.css') }}"/>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/sc2wolTracker.js') }}"></script>
|
||||
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/jura" type="text/css"/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
|
||||
<table id="inventory-table">
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Starting Resources
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Starting Minerals'] }}" class="{{ 'acquired' if '+15 Starting Minerals' in acquired_items }}" title="Starting Minerals" /></td>
|
||||
<td colspan="2"><div class="item-count">+{{ minerals_count }}</div></td>
|
||||
<td><img src="{{ icons['Starting Vespene'] }}" class="{{ 'acquired' if '+15 Starting Vespene' in acquired_items }}" title="Starting Vespene" /></td>
|
||||
<td colspan="2"><div class="item-count">+{{ vespene_count }}</div></td>
|
||||
<!--
|
||||
<td><img src="{{ icons['Starting Supply'] }}" class="{{ 'acquired' if '+2 Starting Supply' in acquired_items }}" title="Starting Supply" /></td>
|
||||
<td colspan="2"><div class="item-count">+{{ supply_count }}</div></td>
|
||||
-->
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Weapon & Armor Upgrades
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ infantry_weapon_url }}" class="{{ 'acquired' if 'Progressive Infantry Weapon' in acquired_items }}" title="Progressive Infantry Weapons{% if infantry_weapon_level > 0 %} (Level {{ infantry_weapon_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ infantry_armor_url }}" class="{{ 'acquired' if 'Progressive Infantry Armor' in acquired_items }}" title="Progressive Infantry Armor{% if infantry_armor_level > 0 %} (Level {{ infantry_armor_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ vehicle_weapon_url }}" class="{{ 'acquired' if 'Progressive Vehicle Weapon' in acquired_items }}" title="Progressive Vehicle Weapons{% if vehicle_weapon_level > 0 %} (Level {{ vehicle_weapon_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ vehicle_armor_url }}" class="{{ 'acquired' if 'Progressive Vehicle Armor' in acquired_items }}" title="Progressive Vehicle Armor{% if vehicle_armor_level > 0 %} (Level {{ vehicle_armor_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ ship_weapon_url }}" class="{{ 'acquired' if 'Progressive Ship Weapon' in acquired_items }}" title="Progressive Ship Weapons{% if ship_weapon_level > 0 %} (Level {{ ship_weapon_level }}){% endif %}" /></td>
|
||||
<td><img src="{{ ship_armor_url }}" class="{{ 'acquired' if 'Progressive Ship Armor' in acquired_items }}" title="Progressive Ship Armor{% if ship_armor_level > 0 %} (Level {{ ship_armor_level }}){% endif %}" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Base
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Bunker'] }}" class="{{ 'acquired' if 'Bunker' in acquired_items }}" title="Bunker" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Missile Turret'] }}" class="{{ 'acquired' if 'Missile Turret' in acquired_items }}" title="Missile Turret" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Sensor Tower'] }}" class="{{ 'acquired' if 'Sensor Tower' in acquired_items }}" title="Sensor Tower" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Projectile Accelerator (Bunker)'] }}" class="{{ 'acquired' if 'Projectile Accelerator (Bunker)' in acquired_items }}" title="Projectile Accelerator (Bunker)" /></td>
|
||||
<td><img src="{{ icons['Neosteel Bunker (Bunker)'] }}" class="{{ 'acquired' if 'Neosteel Bunker (Bunker)' in acquired_items }}" title="Neosteel Bunker (Bunker)" /></td>
|
||||
<td><img src="{{ icons['Titanium Housing (Missile Turret)'] }}" class="{{ 'acquired' if 'Titanium Housing (Missile Turret)' in acquired_items }}" title="Titanium Housing (Missile Turret)" /></td>
|
||||
<td><img src="{{ icons['Hellstorm Batteries (Missile Turret)'] }}" class="{{ 'acquired' if 'Hellstorm Batteries (Missile Turret)' in acquired_items }}" title="Hellstorm Batteries (Missile Turret)" /></td>
|
||||
<td colspan="2"> </td>
|
||||
<td><img src="{{ icons['Advanced Construction (SCV)'] }}" class="{{ 'acquired' if 'Advanced Construction (SCV)' in acquired_items }}" title="Advanced Construction (SCV)" /></td>
|
||||
<td><img src="{{ icons['Dual-Fusion Welders (SCV)'] }}" class="{{ 'acquired' if 'Dual-Fusion Welders (SCV)' in acquired_items }}" title="Dual-Fusion Welders (SCV)" /></td>
|
||||
<td><img src="{{ icons['Fire-Suppression System (Building)'] }}" class="{{ 'acquired' if 'Fire-Suppression System (Building)' in acquired_items }}" title="Fire-Suppression System (Building)" /></td>
|
||||
<td><img src="{{ icons['Orbital Command (Building)'] }}" class="{{ 'acquired' if 'Orbital Command (Building)' in acquired_items }}" title="Orbital Command (Building)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Infantry
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Marine'] }}" class="{{ 'acquired' if 'Marine' in acquired_items }}" title="Marine" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Medic'] }}" class="{{ 'acquired' if 'Medic' in acquired_items }}" title="Medic" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Firebat'] }}" class="{{ 'acquired' if 'Firebat' in acquired_items }}" title="Firebat" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Marauder'] }}" class="{{ 'acquired' if 'Marauder' in acquired_items }}" title="Marauder" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Reaper'] }}" class="{{ 'acquired' if 'Reaper' in acquired_items }}" title="Reaper" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Stimpack (Marine)'] }}" class="{{ 'acquired' if 'Stimpack (Marine)' in acquired_items }}" title="Stimpack (Marine)" /></td>
|
||||
<td><img src="{{ icons['Combat Shield (Marine)'] }}" class="{{ 'acquired' if 'Combat Shield (Marine)' in acquired_items }}" title="Combat Shield (Marine)" /></td>
|
||||
<td><img src="{{ icons['Advanced Medic Facilities (Medic)'] }}" class="{{ 'acquired' if 'Advanced Medic Facilities (Medic)' in acquired_items }}" title="Advanced Medic Facilities (Medic)" /></td>
|
||||
<td><img src="{{ icons['Stabilizer Medpacks (Medic)'] }}" class="{{ 'acquired' if 'Stabilizer Medpacks (Medic)' in acquired_items }}" title="Stabilizer Medpacks (Medic)" /></td>
|
||||
<td><img src="{{ icons['Incinerator Gauntlets (Firebat)'] }}" class="{{ 'acquired' if 'Incinerator Gauntlets (Firebat)' in acquired_items }}" title="Incinerator Gauntlets (Firebat)" /></td>
|
||||
<td><img src="{{ icons['Juggernaut Plating (Firebat)'] }}" class="{{ 'acquired' if 'Juggernaut Plating (Firebat)' in acquired_items }}" title="Juggernaut Plating (Firebat)" /></td>
|
||||
<td><img src="{{ icons['Concussive Shells (Marauder)'] }}" class="{{ 'acquired' if 'Concussive Shells (Marauder)' in acquired_items }}" title="Concussive Shells (Marauder)" /></td>
|
||||
<td><img src="{{ icons['Kinetic Foam (Marauder)'] }}" class="{{ 'acquired' if 'Kinetic Foam (Marauder)' in acquired_items }}" title="Kinetic Foam (Marauder)" /></td>
|
||||
<td><img src="{{ icons['U-238 Rounds (Reaper)'] }}" class="{{ 'acquired' if 'U-238 Rounds (Reaper)' in acquired_items }}" title="U-238 Rounds (Reaper)" /></td>
|
||||
<td><img src="{{ icons['G-4 Clusterbomb (Reaper)'] }}" class="{{ 'acquired' if 'G-4 Clusterbomb (Reaper)' in acquired_items }}" title="G-4 Clusterbomb (Reaper)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Vehicles
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Hellion'] }}" class="{{ 'acquired' if 'Hellion' in acquired_items }}" title="Hellion" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Vulture'] }}" class="{{ 'acquired' if 'Vulture' in acquired_items }}" title="Vulture" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Goliath'] }}" class="{{ 'acquired' if 'Goliath' in acquired_items }}" title="Goliath" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Diamondback'] }}" class="{{ 'acquired' if 'Diamondback' in acquired_items }}" title="Diamondback" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Siege Tank'] }}" class="{{ 'acquired' if 'Siege Tank' in acquired_items }}" title="Siege Tank" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Twin-Linked Flamethrower (Hellion)'] }}" class="{{ 'acquired' if 'Twin-Linked Flamethrower (Hellion)' in acquired_items }}" title="Twin-Linked Flamethrower (Hellion)" /></td>
|
||||
<td><img src="{{ icons['Thermite Filaments (Hellion)'] }}" class="{{ 'acquired' if 'Thermite Filaments (Hellion)' in acquired_items }}" title="Thermite Filaments (Hellion)" /></td>
|
||||
<td><img src="{{ icons['Cerberus Mine (Vulture)'] }}" class="{{ 'acquired' if 'Cerberus Mine (Vulture)' in acquired_items }}" title="Cerberus Mine (Vulture)" /></td>
|
||||
<td><img src="{{ icons['Replenishable Magazine (Vulture)'] }}" class="{{ 'acquired' if 'Replenishable Magazine (Vulture)' in acquired_items }}" title="Replenishable Magazine (Vulture)" /></td>
|
||||
<td><img src="{{ icons['Multi-Lock Weapons System (Goliath)'] }}" class="{{ 'acquired' if 'Multi-Lock Weapons System (Goliath)' in acquired_items }}" title="Multi-Lock Weapons System (Goliath)" /></td>
|
||||
<td><img src="{{ icons['Ares-Class Targeting System (Goliath)'] }}" class="{{ 'acquired' if 'Ares-Class Targeting System (Goliath)' in acquired_items }}" title="Ares-Class Targeting System (Goliath)" /></td>
|
||||
<td><img src="{{ icons['Tri-Lithium Power Cell (Diamondback)'] }}" class="{{ 'acquired' if 'Tri-Lithium Power Cell (Diamondback)' in acquired_items }}" title="Tri-Lithium Power Cell (Diamondback)" /></td>
|
||||
<td><img src="{{ icons['Shaped Hull (Diamondback)'] }}" class="{{ 'acquired' if 'Shaped Hull (Diamondback)' in acquired_items }}" title="Shaped Hull (Diamondback)" /></td>
|
||||
<td><img src="{{ icons['Maelstrom Rounds (Siege Tank)'] }}" class="{{ 'acquired' if 'Maelstrom Rounds (Siege Tank)' in acquired_items }}" title="Maelstrom Rounds (Siege Tank)" /></td>
|
||||
<td><img src="{{ icons['Shaped Blast (Siege Tank)'] }}" class="{{ 'acquired' if 'Shaped Blast (Siege Tank)' in acquired_items }}" title="Shaped Blast (Siege Tank)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Starships
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Medivac'] }}" class="{{ 'acquired' if 'Medivac' in acquired_items }}" title="Medivac" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Wraith'] }}" class="{{ 'acquired' if 'Wraith' in acquired_items }}" title="Wraith" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Viking'] }}" class="{{ 'acquired' if 'Viking' in acquired_items }}" title="Viking" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Banshee'] }}" class="{{ 'acquired' if 'Banshee' in acquired_items }}" title="Banshee" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Battlecruiser'] }}" class="{{ 'acquired' if 'Battlecruiser' in acquired_items }}" title="Battlecruiser" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Rapid Deployment Tube (Medivac)'] }}" class="{{ 'acquired' if 'Rapid Deployment Tube (Medivac)' in acquired_items }}" title="Rapid Deployment Tube (Medivac)" /></td>
|
||||
<td><img src="{{ icons['Advanced Healing AI (Medivac)'] }}" class="{{ 'acquired' if 'Advanced Healing AI (Medivac)' in acquired_items }}" title="Advanced Healing AI (Medivac)" /></td>
|
||||
<td><img src="{{ icons['Tomahawk Power Cells (Wraith)'] }}" class="{{ 'acquired' if 'Tomahawk Power Cells (Wraith)' in acquired_items }}" title="Tomahawk Power Cells (Wraith)" /></td>
|
||||
<td><img src="{{ icons['Displacement Field (Wraith)'] }}" class="{{ 'acquired' if 'Displacement Field (Wraith)' in acquired_items }}" title="Displacement Field (Wraith)" /></td>
|
||||
<td><img src="{{ icons['Ripwave Missiles (Viking)'] }}" class="{{ 'acquired' if 'Ripwave Missiles (Viking)' in acquired_items }}" title="Ripwave Missiles (Viking)" /></td>
|
||||
<td><img src="{{ icons['Phobos-Class Weapons System (Viking)'] }}" class="{{ 'acquired' if 'Phobos-Class Weapons System (Viking)' in acquired_items }}" title="Phobos-Class Weapons System (Viking)" /></td>
|
||||
<td><img src="{{ icons['Cross-Spectrum Dampeners (Banshee)'] }}" class="{{ 'acquired' if 'Cross-Spectrum Dampeners (Banshee)' in acquired_items }}" title="Cross-Spectrum Dampeners (Banshee)" /></td>
|
||||
<td><img src="{{ icons['Shockwave Missile Battery (Banshee)'] }}" class="{{ 'acquired' if 'Shockwave Missile Battery (Banshee)' in acquired_items }}" title="Shockwave Missile Battery (Banshee)" /></td>
|
||||
<td><img src="{{ icons['Missile Pods (Battlecruiser)'] }}" class="{{ 'acquired' if 'Missile Pods (Battlecruiser)' in acquired_items }}" title="Missile Pods (Battlecruiser)" /></td>
|
||||
<td><img src="{{ icons['Defensive Matrix (Battlecruiser)'] }}" class="{{ 'acquired' if 'Defensive Matrix (Battlecruiser)' in acquired_items }}" title="Defensive Matrix (Battlecruiser)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Dominion
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="{{ icons['Ghost'] }}" class="{{ 'acquired' if 'Ghost' in acquired_items }}" title="Ghost" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Spectre'] }}" class="{{ 'acquired' if 'Spectre' in acquired_items }}" title="Spectre" /></td>
|
||||
<td colspan="2"><img src="{{ icons['Thor'] }}" class="{{ 'acquired' if 'Thor' in acquired_items }}" title="Thor" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Ocular Implants (Ghost)'] }}" class="{{ 'acquired' if 'Ocular Implants (Ghost)' in acquired_items }}" title="Ocular Implants (Ghost)" /></td>
|
||||
<td><img src="{{ icons['Crius Suit (Ghost)'] }}" class="{{ 'acquired' if 'Crius Suit (Ghost)' in acquired_items }}" title="Crius Suit (Ghost)" /></td>
|
||||
<td><img src="{{ icons['Psionic Lash (Spectre)'] }}" class="{{ 'acquired' if 'Psionic Lash (Spectre)' in acquired_items }}" title="Psionic Lash (Spectre)" /></td>
|
||||
<td><img src="{{ icons['Nyx-Class Cloaking Module (Spectre)'] }}" class="{{ 'acquired' if 'Nyx-Class Cloaking Module (Spectre)' in acquired_items }}" title="Nyx-Class Cloaking Module (Spectre)" /></td>
|
||||
<td><img src="{{ icons['330mm Barrage Cannon (Thor)'] }}" class="{{ 'acquired' if '330mm Barrage Cannon (Thor)' in acquired_items }}" title="330mm Barrage Cannon (Thor)" /></td>
|
||||
<td><img src="{{ icons['Immortality Protocol (Thor)'] }}" class="{{ 'acquired' if 'Immortality Protocol (Thor)' in acquired_items }}" title="Immortality Protocol (Thor)" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Mercenaries
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['War Pigs'] }}" class="{{ 'acquired' if 'War Pigs' in acquired_items }}" title="War Pigs" /></td>
|
||||
<td><img src="{{ icons['Devil Dogs'] }}" class="{{ 'acquired' if 'Devil Dogs' in acquired_items }}" title="Devil Dogs" /></td>
|
||||
<td><img src="{{ icons['Hammer Securities'] }}" class="{{ 'acquired' if 'Hammer Securities' in acquired_items }}" title="Hammer Securities" /></td>
|
||||
<td><img src="{{ icons['Spartan Company'] }}" class="{{ 'acquired' if 'Spartan Company' in acquired_items }}" title="Spartan Company" /></td>
|
||||
<td><img src="{{ icons['Siege Breakers'] }}" class="{{ 'acquired' if 'Siege Breakers' in acquired_items }}" title="Siege Breakers" /></td>
|
||||
<td><img src="{{ icons['Hel\'s Angel'] }}" class="{{ 'acquired' if 'Hel\'s Angel' in acquired_items }}" title="Hel's Angel" /></td>
|
||||
<td><img src="{{ icons['Dusk Wings'] }}" class="{{ 'acquired' if 'Dusk Wings' in acquired_items }}" title="Dusk Wings" /></td>
|
||||
<td><img src="{{ icons['Jackson\'s Revenge'] }}" class="{{ 'acquired' if 'Jackson\'s Revenge' in acquired_items }}" title="Jackson's Revenge" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Lab Upgrades
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Ultra-Capacitors'] }}" class="{{ 'acquired' if 'Ultra-Capacitors' in acquired_items }}" title="Ultra-Capacitors" /></td>
|
||||
<td><img src="{{ icons['Vanadium Plating'] }}" class="{{ 'acquired' if 'Vanadium Plating' in acquired_items }}" title="Vanadium Plating" /></td>
|
||||
<td><img src="{{ icons['Orbital Depots'] }}" class="{{ 'acquired' if 'Orbital Depots' in acquired_items }}" title="Orbital Depots" /></td>
|
||||
<td><img src="{{ icons['Micro-Filtering'] }}" class="{{ 'acquired' if 'Micro-Filtering' in acquired_items }}" title="Micro-Filtering" /></td>
|
||||
<td><img src="{{ icons['Automated Refinery'] }}" class="{{ 'acquired' if 'Automated Refinery' in acquired_items }}" title="Automated Refinery" /></td>
|
||||
<td><img src="{{ icons['Command Center Reactor'] }}" class="{{ 'acquired' if 'Command Center Reactor' in acquired_items }}" title="Command Center Reactor" /></td>
|
||||
<td><img src="{{ icons['Raven'] }}" class="{{ 'acquired' if 'Raven' in acquired_items }}" title="Raven" /></td>
|
||||
<td><img src="{{ icons['Science Vessel'] }}" class="{{ 'acquired' if 'Science Vessel' in acquired_items }}" title="Science Vessel" /></td>
|
||||
<td><img src="{{ icons['Tech Reactor'] }}" class="{{ 'acquired' if 'Tech Reactor' in acquired_items }}" title="Tech Reactor" /></td>
|
||||
<td><img src="{{ icons['Orbital Strike'] }}" class="{{ 'acquired' if 'Orbital Strike' in acquired_items }}" title="Orbital Strike" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Shrike Turret'] }}" class="{{ 'acquired' if 'Shrike Turret' in acquired_items }}" title="Shrike Turret" /></td>
|
||||
<td><img src="{{ icons['Fortified Bunker'] }}" class="{{ 'acquired' if 'Fortified Bunker' in acquired_items }}" title="Fortified Bunker" /></td>
|
||||
<td><img src="{{ icons['Planetary Fortress'] }}" class="{{ 'acquired' if 'Planetary Fortress' in acquired_items }}" title="Planetary Fortress" /></td>
|
||||
<td><img src="{{ icons['Perdition Turret'] }}" class="{{ 'acquired' if 'Perdition Turret' in acquired_items }}" title="Perdition Turret" /></td>
|
||||
<td><img src="{{ icons['Predator'] }}" class="{{ 'acquired' if 'Predator' in acquired_items }}" title="Predator" /></td>
|
||||
<td><img src="{{ icons['Hercules'] }}" class="{{ 'acquired' if 'Hercules' in acquired_items }}" title="Hercules" /></td>
|
||||
<td><img src="{{ icons['Cellular Reactor'] }}" class="{{ 'acquired' if 'Cellular Reactor' in acquired_items }}" title="Cellular Reactor" /></td>
|
||||
<td><img src="{{ icons['Regenerative Bio-Steel'] }}" class="{{ 'acquired' if 'Regenerative Bio-Steel' in acquired_items }}" title="Regenerative Bio-Steel" /></td>
|
||||
<td><img src="{{ icons['Hive Mind Emulator'] }}" class="{{ 'acquired' if 'Hive Mind Emulator' in acquired_items }}" title="Hive Mind Emulator" /></td>
|
||||
<td><img src="{{ icons['Psi Disrupter'] }}" class="{{ 'acquired' if 'Psi Disrupter' in acquired_items }}" title="Psi Disrupter" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="10" class="title">
|
||||
Protoss Units
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="{{ icons['Zealot'] }}" class="{{ 'acquired' if 'Zealot' in acquired_items }}" title="Zealot" /></td>
|
||||
<td><img src="{{ icons['Stalker'] }}" class="{{ 'acquired' if 'Stalker' in acquired_items }}" title="Stalker" /></td>
|
||||
<td><img src="{{ icons['High Templar'] }}" class="{{ 'acquired' if 'High Templar' in acquired_items }}" title="High Templar" /></td>
|
||||
<td><img src="{{ icons['Dark Templar'] }}" class="{{ 'acquired' if 'Dark Templar' in acquired_items }}" title="Dark Templar" /></td>
|
||||
<td><img src="{{ icons['Immortal'] }}" class="{{ 'acquired' if 'Immortal' in acquired_items }}" title="Immortal" /></td>
|
||||
<td><img src="{{ icons['Colossus'] }}" class="{{ 'acquired' if 'Colossus' in acquired_items }}" title="Colossus" /></td>
|
||||
<td><img src="{{ icons['Phoenix'] }}" class="{{ 'acquired' if 'Phoenix' in acquired_items }}" title="Phoenix" /></td>
|
||||
<td><img src="{{ icons['Void Ray'] }}" class="{{ 'acquired' if 'Void Ray' in acquired_items }}" title="Void Ray" /></td>
|
||||
<td><img src="{{ icons['Carrier'] }}" class="{{ 'acquired' if 'Carrier' in acquired_items }}" title="Carrier" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
<table id="location-table">
|
||||
{% for area in checks_in_area %}
|
||||
{% if checks_in_area[area] > 0 %}
|
||||
<tr class="location-category" id="{{area}}-header">
|
||||
<td>{{ area }} {{'▼' if area != 'Total'}}</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 class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -31,14 +31,14 @@
|
||||
|
||||
<h2>Game Info Pages</h2>
|
||||
<ul>
|
||||
{% for game in games %}
|
||||
{% for game in games | title_sorted %}
|
||||
<li><a href="{{ url_for('game_info', game=game, lang='en') }}">{{ game }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<h2>Game Settings Pages</h2>
|
||||
<ul>
|
||||
{% for game in games %}
|
||||
{% for game in games | title_sorted %}
|
||||
<li><a href="{{ url_for('player_settings', game=game) }}">{{ game }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div id="tables-container">
|
||||
{% for team, players in inventory.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table class="table unique-item-table">
|
||||
<table id="inventory-table" class="table unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
@@ -44,7 +44,7 @@
|
||||
<tbody>
|
||||
{%- for player, items in players.items() -%}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
||||
<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" -%}
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
{% for team, players in checks_done.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table">
|
||||
<table id="checks-table" class="table non-unique-item-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">#</th>
|
||||
@@ -121,7 +121,7 @@
|
||||
<tbody>
|
||||
{%- for player, checks in players.items() -%}
|
||||
<tr>
|
||||
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
|
||||
<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 -%}
|
||||
@@ -153,7 +153,7 @@
|
||||
{% endfor %}
|
||||
{% for team, hints in hints.items() %}
|
||||
<div class="table-wrapper">
|
||||
<table class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
||||
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Finder</th>
|
||||
|
||||
@@ -7,7 +7,7 @@ from uuid import UUID
|
||||
from flask import render_template
|
||||
from werkzeug.exceptions import abort
|
||||
|
||||
from MultiServer import Context
|
||||
from MultiServer import Context, get_saving_second
|
||||
from NetUtils import SlotType
|
||||
from Utils import restricted_loads
|
||||
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
|
||||
@@ -280,16 +280,25 @@ def get_static_room_data(room: Room):
|
||||
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber])
|
||||
for playernumber in range(1, len(names[0]) + 1)
|
||||
if playernumber not in groups}
|
||||
|
||||
saving_second = get_saving_second(multidata["seed_name"])
|
||||
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
|
||||
multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups
|
||||
multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups, saving_second
|
||||
_multidata_cache[room.seed.id] = result
|
||||
return result
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
||||
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
|
||||
key = f"{tracker}_{tracked_team}_{tracked_player}_{want_generic}"
|
||||
tracker_page = cache.get(key)
|
||||
if tracker_page:
|
||||
return tracker_page
|
||||
timeout, tracker_page = _get_player_tracker(tracker, tracked_team, tracked_player, want_generic)
|
||||
cache.set(key, tracker_page, timeout)
|
||||
return tracker_page
|
||||
|
||||
|
||||
def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool):
|
||||
# Team and player must be positive and greater than zero
|
||||
if tracked_team < 0 or tracked_player < 1:
|
||||
abort(404)
|
||||
@@ -300,7 +309,7 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
||||
|
||||
# Collect seed information and pare it down to a single player
|
||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
||||
precollected_items, games, slot_data, groups, saving_second = get_static_room_data(room)
|
||||
player_name = names[tracked_team][tracked_player - 1]
|
||||
location_to_area = player_location_to_area[tracked_player]
|
||||
inventory = collections.Counter()
|
||||
@@ -338,21 +347,24 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
|
||||
checks_done["Total"] += 1
|
||||
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
|
||||
if specific_tracker and not want_generic:
|
||||
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done, slot_data[tracked_player])
|
||||
tracker = specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second)
|
||||
else:
|
||||
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done)
|
||||
tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
|
||||
seed_checks_in_area, checks_done, saving_second)
|
||||
|
||||
return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker
|
||||
|
||||
|
||||
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
|
||||
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
|
||||
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
|
||||
return get_player_tracker(tracker, tracked_team, tracked_player, True)
|
||||
|
||||
|
||||
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, player_name: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
# Note the presence of the triforce item
|
||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
||||
@@ -414,7 +426,8 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
|
||||
|
||||
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
|
||||
@@ -516,14 +529,15 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
|
||||
inventory=inventory, icons=icons,
|
||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||
id in lookup_any_item_id_to_name},
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
player=player, team=team, room=room, player_name=playerName, saving_second = saving_second,
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
|
||||
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
|
||||
@@ -636,43 +650,47 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
|
||||
# Gather dungeon locations
|
||||
area_id_ranges = {
|
||||
"Overworld": (67000, 67280),
|
||||
"Deku Tree": (67281, 67303),
|
||||
"Dodongo's Cavern": (67304, 67334),
|
||||
"Jabu Jabu's Belly": (67335, 67359),
|
||||
"Bottom of the Well": (67360, 67384),
|
||||
"Forest Temple": (67385, 67420),
|
||||
"Fire Temple": (67421, 67457),
|
||||
"Water Temple": (67458, 67484),
|
||||
"Shadow Temple": (67485, 67532),
|
||||
"Spirit Temple": (67533, 67582),
|
||||
"Ice Cavern": (67583, 67596),
|
||||
"Gerudo Training Ground": (67597, 67635),
|
||||
"Thieves' Hideout": (67259, 67263),
|
||||
"Ganon's Castle": (67636, 67673),
|
||||
"Overworld": ((67000, 67263), (67269, 67280), (67747, 68024), (68054, 68062)),
|
||||
"Deku Tree": ((67281, 67303), (68063, 68077)),
|
||||
"Dodongo's Cavern": ((67304, 67334), (68078, 68160)),
|
||||
"Jabu Jabu's Belly": ((67335, 67359), (68161, 68188)),
|
||||
"Bottom of the Well": ((67360, 67384), (68189, 68230)),
|
||||
"Forest Temple": ((67385, 67420), (68231, 68281)),
|
||||
"Fire Temple": ((67421, 67457), (68282, 68350)),
|
||||
"Water Temple": ((67458, 67484), (68351, 68483)),
|
||||
"Shadow Temple": ((67485, 67532), (68484, 68565)),
|
||||
"Spirit Temple": ((67533, 67582), (68566, 68625)),
|
||||
"Ice Cavern": ((67583, 67596), (68626, 68649)),
|
||||
"Gerudo Training Ground": ((67597, 67635), (68650, 68656)),
|
||||
"Thieves' Hideout": ((67264, 67268), (68025, 68053)),
|
||||
"Ganon's Castle": ((67636, 67673), (68657, 68705)),
|
||||
}
|
||||
|
||||
def lookup_and_trim(id, area):
|
||||
full_name = lookup_any_location_id_to_name[id]
|
||||
if id == 67673:
|
||||
return full_name[13:] # Ganons Tower Boss Key Chest
|
||||
if 'Ganons Tower' in full_name:
|
||||
return full_name
|
||||
if area not in ["Overworld", "Thieves' Hideout"]:
|
||||
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
|
||||
return full_name[len(area):]
|
||||
return full_name
|
||||
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
|
||||
location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
|
||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
||||
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
|
||||
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
|
||||
for area, (min_id, max_id) in area_id_ranges.items()}
|
||||
|
||||
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
|
||||
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
|
||||
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
|
||||
for loc in location_info["Thieves' Hideout"]:
|
||||
del location_info["Overworld"][loc]
|
||||
location_info = {}
|
||||
checks_done = {}
|
||||
checks_in_area = {}
|
||||
for area, ranges in area_id_ranges.items():
|
||||
location_info[area] = {}
|
||||
checks_done[area] = 0
|
||||
checks_in_area[area] = 0
|
||||
for r in ranges:
|
||||
min_id, max_id = r
|
||||
for id in range(min_id, max_id+1):
|
||||
if id in locations[player]:
|
||||
checked = id in checked_locations
|
||||
location_info[area][lookup_and_trim(id, area)] = checked
|
||||
checks_in_area[area] += 1
|
||||
checks_done[area] += checked
|
||||
|
||||
checks_done['Total'] = sum(checks_done.values())
|
||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||
@@ -683,25 +701,28 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
if "GS" in lookup_and_trim(id, ''):
|
||||
display_data["token_count"] += 1
|
||||
|
||||
oot_y = '✔'
|
||||
oot_x = '✕'
|
||||
|
||||
# Gather small and boss key info
|
||||
small_key_counts = {
|
||||
"Forest Temple": inventory[66175],
|
||||
"Fire Temple": inventory[66176],
|
||||
"Water Temple": inventory[66177],
|
||||
"Spirit Temple": inventory[66178],
|
||||
"Shadow Temple": inventory[66179],
|
||||
"Bottom of the Well": inventory[66180],
|
||||
"Gerudo Training Ground": inventory[66181],
|
||||
"Thieves' Hideout": inventory[66182],
|
||||
"Ganon's Castle": inventory[66183],
|
||||
"Forest Temple": oot_y if inventory[66203] else inventory[66175],
|
||||
"Fire Temple": oot_y if inventory[66204] else inventory[66176],
|
||||
"Water Temple": oot_y if inventory[66205] else inventory[66177],
|
||||
"Spirit Temple": oot_y if inventory[66206] else inventory[66178],
|
||||
"Shadow Temple": oot_y if inventory[66207] else inventory[66179],
|
||||
"Bottom of the Well": oot_y if inventory[66208] else inventory[66180],
|
||||
"Gerudo Training Ground": oot_y if inventory[66209] else inventory[66181],
|
||||
"Thieves' Hideout": oot_y if inventory[66210] else inventory[66182],
|
||||
"Ganon's Castle": oot_y if inventory[66211] else inventory[66183],
|
||||
}
|
||||
boss_key_counts = {
|
||||
"Forest Temple": '✔' if inventory[66149] else '✕',
|
||||
"Fire Temple": '✔' if inventory[66150] else '✕',
|
||||
"Water Temple": '✔' if inventory[66151] else '✕',
|
||||
"Spirit Temple": '✔' if inventory[66152] else '✕',
|
||||
"Shadow Temple": '✔' if inventory[66153] else '✕',
|
||||
"Ganon's Castle": '✔' if inventory[66154] else '✕',
|
||||
"Forest Temple": oot_y if inventory[66149] else oot_x,
|
||||
"Fire Temple": oot_y if inventory[66150] else oot_x,
|
||||
"Water Temple": oot_y if inventory[66151] else oot_x,
|
||||
"Spirit Temple": oot_y if inventory[66152] else oot_x,
|
||||
"Shadow Temple": oot_y if inventory[66153] else oot_x,
|
||||
"Ganon's Castle": oot_y if inventory[66154] else oot_x,
|
||||
}
|
||||
|
||||
# Victory condition
|
||||
@@ -718,7 +739,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
|
||||
|
||||
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
|
||||
slot_data: Dict[str, Any], saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png",
|
||||
@@ -824,7 +846,8 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
|
||||
|
||||
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
|
||||
saving_second: int) -> str:
|
||||
|
||||
icons = {
|
||||
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
|
||||
@@ -922,37 +945,285 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
|
||||
slot_data: Dict, saving_second: int) -> str:
|
||||
|
||||
SC2WOL_LOC_ID_OFFSET = 1000
|
||||
SC2WOL_ITEM_ID_OFFSET = 1000
|
||||
|
||||
icons = {
|
||||
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
|
||||
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
|
||||
"Starting Supply": "https://static.wikia.nocookie.net/starcraft/images/d/d3/TerranSupply_SC2_Icon1.gif",
|
||||
|
||||
"Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
|
||||
"Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
|
||||
"Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
|
||||
"Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
|
||||
"Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
|
||||
"Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
|
||||
"Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
|
||||
"Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
|
||||
"Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
|
||||
"Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
|
||||
"Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
|
||||
"Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
|
||||
"Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
|
||||
"Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
|
||||
"Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
|
||||
"Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
|
||||
"Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
|
||||
"Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
|
||||
|
||||
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
|
||||
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
|
||||
"Sensor Tower": "https://static.wikia.nocookie.net/starcraft/images/d/d2/SensorTower_SC2_Icon1.jpg",
|
||||
|
||||
"Projectile Accelerator (Bunker)": "https://0rganics.org/archipelago/sc2wol/ProjectileAccelerator.png",
|
||||
"Neosteel Bunker (Bunker)": "https://0rganics.org/archipelago/sc2wol/NeosteelBunker.png",
|
||||
"Titanium Housing (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/TitaniumHousing.png",
|
||||
"Hellstorm Batteries (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png",
|
||||
"Advanced Construction (SCV)": "https://0rganics.org/archipelago/sc2wol/AdvancedConstruction.png",
|
||||
"Dual-Fusion Welders (SCV)": "https://0rganics.org/archipelago/sc2wol/Dual-FusionWelders.png",
|
||||
"Fire-Suppression System (Building)": "https://0rganics.org/archipelago/sc2wol/Fire-SuppressionSystem.png",
|
||||
"Orbital Command (Building)": "https://0rganics.org/archipelago/sc2wol/OrbitalCommandCampaign.png",
|
||||
|
||||
"Marine": "https://static.wikia.nocookie.net/starcraft/images/4/47/Marine_SC2_Icon1.jpg",
|
||||
"Medic": "https://static.wikia.nocookie.net/starcraft/images/7/74/Medic_SC2_Rend1.jpg",
|
||||
"Firebat": "https://static.wikia.nocookie.net/starcraft/images/3/3c/Firebat_SC2_Rend1.jpg",
|
||||
"Marauder": "https://static.wikia.nocookie.net/starcraft/images/b/ba/Marauder_SC2_Icon1.jpg",
|
||||
"Reaper": "https://static.wikia.nocookie.net/starcraft/images/7/7d/Reaper_SC2_Icon1.jpg",
|
||||
|
||||
"Stimpack (Marine)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png",
|
||||
"Combat Shield (Marine)": "https://0rganics.org/archipelago/sc2wol/CombatShieldCampaign.png",
|
||||
"Advanced Medic Facilities (Medic)": "https://0rganics.org/archipelago/sc2wol/AdvancedMedicFacilities.png",
|
||||
"Stabilizer Medpacks (Medic)": "https://0rganics.org/archipelago/sc2wol/StabilizerMedpacks.png",
|
||||
"Incinerator Gauntlets (Firebat)": "https://0rganics.org/archipelago/sc2wol/IncineratorGauntlets.png",
|
||||
"Juggernaut Plating (Firebat)": "https://0rganics.org/archipelago/sc2wol/JuggernautPlating.png",
|
||||
"Concussive Shells (Marauder)": "https://0rganics.org/archipelago/sc2wol/ConcussiveShellsCampaign.png",
|
||||
"Kinetic Foam (Marauder)": "https://0rganics.org/archipelago/sc2wol/KineticFoam.png",
|
||||
"U-238 Rounds (Reaper)": "https://0rganics.org/archipelago/sc2wol/U-238Rounds.png",
|
||||
"G-4 Clusterbomb (Reaper)": "https://0rganics.org/archipelago/sc2wol/G-4Clusterbomb.png",
|
||||
|
||||
"Hellion": "https://static.wikia.nocookie.net/starcraft/images/5/56/Hellion_SC2_Icon1.jpg",
|
||||
"Vulture": "https://static.wikia.nocookie.net/starcraft/images/d/da/Vulture_WoL.jpg",
|
||||
"Goliath": "https://static.wikia.nocookie.net/starcraft/images/e/eb/Goliath_WoL.jpg",
|
||||
"Diamondback": "https://static.wikia.nocookie.net/starcraft/images/a/a6/Diamondback_WoL.jpg",
|
||||
"Siege Tank": "https://static.wikia.nocookie.net/starcraft/images/5/57/SiegeTank_SC2_Icon1.jpg",
|
||||
|
||||
"Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png",
|
||||
"Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.png",
|
||||
"Cerberus Mine (Vulture)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png",
|
||||
"Replenishable Magazine (Vulture)": "https://0rganics.org/archipelago/sc2wol/ReplenishableMagazine.png",
|
||||
"Multi-Lock Weapons System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Multi-LockWeaponsSystem.png",
|
||||
"Ares-Class Targeting System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Ares-ClassTargetingSystem.png",
|
||||
"Tri-Lithium Power Cell (Diamondback)": "https://0rganics.org/archipelago/sc2wol/Tri-LithiumPowerCell.png",
|
||||
"Shaped Hull (Diamondback)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png",
|
||||
"Maelstrom Rounds (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/MaelstromRounds.png",
|
||||
"Shaped Blast (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/ShapedBlast.png",
|
||||
|
||||
"Medivac": "https://static.wikia.nocookie.net/starcraft/images/d/db/Medivac_SC2_Icon1.jpg",
|
||||
"Wraith": "https://static.wikia.nocookie.net/starcraft/images/7/75/Wraith_WoL.jpg",
|
||||
"Viking": "https://static.wikia.nocookie.net/starcraft/images/2/2a/Viking_SC2_Icon1.jpg",
|
||||
"Banshee": "https://static.wikia.nocookie.net/starcraft/images/3/32/Banshee_SC2_Icon1.jpg",
|
||||
"Battlecruiser": "https://static.wikia.nocookie.net/starcraft/images/f/f5/Battlecruiser_SC2_Icon1.jpg",
|
||||
|
||||
"Rapid Deployment Tube (Medivac)": "https://0rganics.org/archipelago/sc2wol/RapidDeploymentTube.png",
|
||||
"Advanced Healing AI (Medivac)": "https://0rganics.org/archipelago/sc2wol/AdvancedHealingAI.png",
|
||||
"Tomahawk Power Cells (Wraith)": "https://0rganics.org/archipelago/sc2wol/TomahawkPowerCells.png",
|
||||
"Displacement Field (Wraith)": "https://0rganics.org/archipelago/sc2wol/DisplacementField.png",
|
||||
"Ripwave Missiles (Viking)": "https://0rganics.org/archipelago/sc2wol/RipwaveMissiles.png",
|
||||
"Phobos-Class Weapons System (Viking)": "https://0rganics.org/archipelago/sc2wol/Phobos-ClassWeaponsSystem.png",
|
||||
"Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png",
|
||||
"Shockwave Missile Battery (Banshee)": "https://0rganics.org/archipelago/sc2wol/ShockwaveMissileBattery.png",
|
||||
"Missile Pods (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/MissilePods.png",
|
||||
"Defensive Matrix (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png",
|
||||
|
||||
"Ghost": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Ghost_SC2_Icon1.jpg",
|
||||
"Spectre": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Spectre_WoL.jpg",
|
||||
"Thor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/Thor_SC2_Icon1.jpg",
|
||||
|
||||
"Ocular Implants (Ghost)": "https://0rganics.org/archipelago/sc2wol/OcularImplants.png",
|
||||
"Crius Suit (Ghost)": "https://0rganics.org/archipelago/sc2wol/CriusSuit.png",
|
||||
"Psionic Lash (Spectre)": "https://0rganics.org/archipelago/sc2wol/PsionicLash.png",
|
||||
"Nyx-Class Cloaking Module (Spectre)": "https://0rganics.org/archipelago/sc2wol/Nyx-ClassCloakingModule.png",
|
||||
"330mm Barrage Cannon (Thor)": "https://0rganics.org/archipelago/sc2wol/330mmBarrageCannon.png",
|
||||
"Immortality Protocol (Thor)": "https://0rganics.org/archipelago/sc2wol/ImmortalityProtocol.png",
|
||||
|
||||
"War Pigs": "https://static.wikia.nocookie.net/starcraft/images/e/ed/WarPigs_SC2_Icon1.jpg",
|
||||
"Devil Dogs": "https://static.wikia.nocookie.net/starcraft/images/3/33/DevilDogs_SC2_Icon1.jpg",
|
||||
"Hammer Securities": "https://static.wikia.nocookie.net/starcraft/images/3/3b/HammerSecurity_SC2_Icon1.jpg",
|
||||
"Spartan Company": "https://static.wikia.nocookie.net/starcraft/images/b/be/SpartanCompany_SC2_Icon1.jpg",
|
||||
"Siege Breakers": "https://static.wikia.nocookie.net/starcraft/images/3/31/SiegeBreakers_SC2_Icon1.jpg",
|
||||
"Hel's Angel": "https://static.wikia.nocookie.net/starcraft/images/6/63/HelsAngels_SC2_Icon1.jpg",
|
||||
"Dusk Wings": "https://static.wikia.nocookie.net/starcraft/images/5/52/DuskWings_SC2_Icon1.jpg",
|
||||
"Jackson's Revenge": "https://static.wikia.nocookie.net/starcraft/images/9/95/JacksonsRevenge_SC2_Icon1.jpg",
|
||||
|
||||
"Ultra-Capacitors": "https://static.wikia.nocookie.net/starcraft/images/2/23/SC2_Lab_Ultra_Capacitors_Icon.png",
|
||||
"Vanadium Plating": "https://static.wikia.nocookie.net/starcraft/images/6/67/SC2_Lab_VanPlating_Icon.png",
|
||||
"Orbital Depots": "https://static.wikia.nocookie.net/starcraft/images/0/01/SC2_Lab_Orbital_Depot_Icon.png",
|
||||
"Micro-Filtering": "https://static.wikia.nocookie.net/starcraft/images/2/20/SC2_Lab_MicroFilter_Icon.png",
|
||||
"Automated Refinery": "https://static.wikia.nocookie.net/starcraft/images/7/71/SC2_Lab_Auto_Refinery_Icon.png",
|
||||
"Command Center Reactor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/SC2_Lab_CC_Reactor_Icon.png",
|
||||
"Raven": "https://static.wikia.nocookie.net/starcraft/images/1/19/SC2_Lab_Raven_Icon.png",
|
||||
"Science Vessel": "https://static.wikia.nocookie.net/starcraft/images/c/c3/SC2_Lab_SciVes_Icon.png",
|
||||
"Tech Reactor": "https://static.wikia.nocookie.net/starcraft/images/c/c5/SC2_Lab_Tech_Reactor_Icon.png",
|
||||
"Orbital Strike": "https://static.wikia.nocookie.net/starcraft/images/d/df/SC2_Lab_Orb_Strike_Icon.png",
|
||||
|
||||
"Shrike Turret": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png",
|
||||
"Fortified Bunker": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png",
|
||||
"Planetary Fortress": "https://static.wikia.nocookie.net/starcraft/images/0/0b/SC2_Lab_PlanetFortress_Icon.png",
|
||||
"Perdition Turret": "https://static.wikia.nocookie.net/starcraft/images/a/af/SC2_Lab_PerdTurret_Icon.png",
|
||||
"Predator": "https://static.wikia.nocookie.net/starcraft/images/8/83/SC2_Lab_Predator_Icon.png",
|
||||
"Hercules": "https://static.wikia.nocookie.net/starcraft/images/4/40/SC2_Lab_Hercules_Icon.png",
|
||||
"Cellular Reactor": "https://static.wikia.nocookie.net/starcraft/images/d/d8/SC2_Lab_CellReactor_Icon.png",
|
||||
"Regenerative Bio-Steel": "https://static.wikia.nocookie.net/starcraft/images/d/d3/SC2_Lab_BioSteel_Icon.png",
|
||||
"Hive Mind Emulator": "https://static.wikia.nocookie.net/starcraft/images/b/bc/SC2_Lab_Hive_Emulator_Icon.png",
|
||||
"Psi Disrupter": "https://static.wikia.nocookie.net/starcraft/images/c/cf/SC2_Lab_Psi_Disruptor_Icon.png",
|
||||
|
||||
"Zealot": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Icon_Protoss_Zealot.jpg",
|
||||
"Stalker": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Icon_Protoss_Stalker.jpg",
|
||||
"High Templar": "https://static.wikia.nocookie.net/starcraft/images/a/a0/Icon_Protoss_High_Templar.jpg",
|
||||
"Dark Templar": "https://static.wikia.nocookie.net/starcraft/images/9/90/Icon_Protoss_Dark_Templar.jpg",
|
||||
"Immortal": "https://static.wikia.nocookie.net/starcraft/images/c/c1/Icon_Protoss_Immortal.jpg",
|
||||
"Colossus": "https://static.wikia.nocookie.net/starcraft/images/4/40/Icon_Protoss_Colossus.jpg",
|
||||
"Phoenix": "https://static.wikia.nocookie.net/starcraft/images/b/b1/Icon_Protoss_Phoenix.jpg",
|
||||
"Void Ray": "https://static.wikia.nocookie.net/starcraft/images/1/1d/VoidRay_SC2_Rend1.jpg",
|
||||
"Carrier": "https://static.wikia.nocookie.net/starcraft/images/2/2c/Icon_Protoss_Carrier.jpg",
|
||||
|
||||
"Nothing": "",
|
||||
}
|
||||
|
||||
sc2wol_location_ids = {
|
||||
"Liberation Day": [SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 101, SC2WOL_LOC_ID_OFFSET + 102, SC2WOL_LOC_ID_OFFSET + 103, SC2WOL_LOC_ID_OFFSET + 104, SC2WOL_LOC_ID_OFFSET + 105, SC2WOL_LOC_ID_OFFSET + 106],
|
||||
"The Outlaws": [SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 201],
|
||||
"Zero Hour": [SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 301, SC2WOL_LOC_ID_OFFSET + 302, SC2WOL_LOC_ID_OFFSET + 303],
|
||||
"Evacuation": [SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 401, SC2WOL_LOC_ID_OFFSET + 402, SC2WOL_LOC_ID_OFFSET + 403],
|
||||
"Outbreak": [SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 501, SC2WOL_LOC_ID_OFFSET + 502],
|
||||
"Safe Haven": [SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 601, SC2WOL_LOC_ID_OFFSET + 602, SC2WOL_LOC_ID_OFFSET + 603],
|
||||
"Haven's Fall": [SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 701, SC2WOL_LOC_ID_OFFSET + 702, SC2WOL_LOC_ID_OFFSET + 703],
|
||||
"Smash and Grab": [SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 801, SC2WOL_LOC_ID_OFFSET + 802, SC2WOL_LOC_ID_OFFSET + 803, SC2WOL_LOC_ID_OFFSET + 804],
|
||||
"The Dig": [SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 901, SC2WOL_LOC_ID_OFFSET + 902, SC2WOL_LOC_ID_OFFSET + 903],
|
||||
"The Moebius Factor": [SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1003, SC2WOL_LOC_ID_OFFSET + 1004, SC2WOL_LOC_ID_OFFSET + 1005, SC2WOL_LOC_ID_OFFSET + 1006, SC2WOL_LOC_ID_OFFSET + 1007, SC2WOL_LOC_ID_OFFSET + 1008],
|
||||
"Supernova": [SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1101, SC2WOL_LOC_ID_OFFSET + 1102, SC2WOL_LOC_ID_OFFSET + 1103, SC2WOL_LOC_ID_OFFSET + 1104],
|
||||
"Maw of the Void": [SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1201, SC2WOL_LOC_ID_OFFSET + 1202, SC2WOL_LOC_ID_OFFSET + 1203, SC2WOL_LOC_ID_OFFSET + 1204, SC2WOL_LOC_ID_OFFSET + 1205],
|
||||
"Devil's Playground": [SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1301, SC2WOL_LOC_ID_OFFSET + 1302],
|
||||
"Welcome to the Jungle": [SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1401, SC2WOL_LOC_ID_OFFSET + 1402, SC2WOL_LOC_ID_OFFSET + 1403],
|
||||
"Breakout": [SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1501, SC2WOL_LOC_ID_OFFSET + 1502],
|
||||
"Ghost of a Chance": [SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1601, SC2WOL_LOC_ID_OFFSET + 1602, SC2WOL_LOC_ID_OFFSET + 1603, SC2WOL_LOC_ID_OFFSET + 1604, SC2WOL_LOC_ID_OFFSET + 1605],
|
||||
"The Great Train Robbery": [SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1701, SC2WOL_LOC_ID_OFFSET + 1702, SC2WOL_LOC_ID_OFFSET + 1703],
|
||||
"Cutthroat": [SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1801, SC2WOL_LOC_ID_OFFSET + 1802, SC2WOL_LOC_ID_OFFSET + 1803, SC2WOL_LOC_ID_OFFSET + 1804],
|
||||
"Engine of Destruction": [SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 1901, SC2WOL_LOC_ID_OFFSET + 1902, SC2WOL_LOC_ID_OFFSET + 1903, SC2WOL_LOC_ID_OFFSET + 1904, SC2WOL_LOC_ID_OFFSET + 1905],
|
||||
"Media Blitz": [SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2001, SC2WOL_LOC_ID_OFFSET + 2002, SC2WOL_LOC_ID_OFFSET + 2003, SC2WOL_LOC_ID_OFFSET + 2004],
|
||||
"Piercing the Shroud": [SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2101, SC2WOL_LOC_ID_OFFSET + 2102, SC2WOL_LOC_ID_OFFSET + 2103, SC2WOL_LOC_ID_OFFSET + 2104, SC2WOL_LOC_ID_OFFSET + 2105],
|
||||
"Whispers of Doom": [SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2201, SC2WOL_LOC_ID_OFFSET + 2202, SC2WOL_LOC_ID_OFFSET + 2203],
|
||||
"A Sinister Turn": [SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2301, SC2WOL_LOC_ID_OFFSET + 2302, SC2WOL_LOC_ID_OFFSET + 2303],
|
||||
"Echoes of the Future": [SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2401, SC2WOL_LOC_ID_OFFSET + 2402],
|
||||
"In Utter Darkness": [SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2501, SC2WOL_LOC_ID_OFFSET + 2502],
|
||||
"Gates of Hell": [SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2601],
|
||||
"Belly of the Beast": [SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2701, SC2WOL_LOC_ID_OFFSET + 2702, SC2WOL_LOC_ID_OFFSET + 2703],
|
||||
"Shatter the Sky": [SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2801, SC2WOL_LOC_ID_OFFSET + 2802, SC2WOL_LOC_ID_OFFSET + 2803, SC2WOL_LOC_ID_OFFSET + 2804, SC2WOL_LOC_ID_OFFSET + 2805],
|
||||
}
|
||||
|
||||
display_data = {}
|
||||
|
||||
# Determine display for progressive items
|
||||
progressive_items = {
|
||||
"Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Infantry Armor": 102 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET
|
||||
}
|
||||
progressive_names = {
|
||||
"Progressive Infantry Weapon": ["Infantry Weapons Level 1", "Infantry Weapons Level 1", "Infantry Weapons Level 2", "Infantry Weapons Level 3"],
|
||||
"Progressive Infantry Armor": ["Infantry Armor Level 1", "Infantry Armor Level 1", "Infantry Armor Level 2", "Infantry Armor Level 3"],
|
||||
"Progressive Vehicle Weapon": ["Vehicle Weapons Level 1", "Vehicle Weapons Level 1", "Vehicle Weapons Level 2", "Vehicle Weapons Level 3"],
|
||||
"Progressive Vehicle Armor": ["Vehicle Armor Level 1", "Vehicle Armor Level 1", "Vehicle Armor Level 2", "Vehicle Armor Level 3"],
|
||||
"Progressive Ship Weapon": ["Ship Weapons Level 1", "Ship Weapons Level 1", "Ship Weapons Level 2", "Ship Weapons Level 3"],
|
||||
"Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"]
|
||||
}
|
||||
for item_name, item_id in progressive_items.items():
|
||||
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
|
||||
display_name = progressive_names[item_name][level]
|
||||
base_name = item_name.split(maxsplit=1)[1].lower().replace(' ', '_')
|
||||
display_data[base_name + "_level"] = level
|
||||
display_data[base_name + "_url"] = icons[display_name]
|
||||
|
||||
# Multi-items
|
||||
multi_items = {
|
||||
"+15 Starting Minerals": 800 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"+15 Starting Vespene": 801 + SC2WOL_ITEM_ID_OFFSET,
|
||||
"+2 Starting Supply": 802 + SC2WOL_ITEM_ID_OFFSET
|
||||
}
|
||||
for item_name, item_id in multi_items.items():
|
||||
base_name = item_name.split()[-1].lower()
|
||||
count = inventory[item_id]
|
||||
if base_name == "supply":
|
||||
count = count * 2
|
||||
display_data[base_name + "_count"] = count
|
||||
else:
|
||||
count = count * 15
|
||||
display_data[base_name + "_count"] = count
|
||||
|
||||
# Victory condition
|
||||
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
|
||||
display_data['game_finished'] = game_state == 30
|
||||
|
||||
# Turn location IDs into mission objective counts
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
||||
lookup_name = lambda id: lookup_any_location_id_to_name[id]
|
||||
location_info = {mission_name: {lookup_name(id): (id in checked_locations) for id in mission_locations if id in set(locations[player])} for mission_name, mission_locations in sc2wol_location_ids.items()}
|
||||
checks_done = {mission_name: len([id for id in mission_locations if id in checked_locations and id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
|
||||
checks_done['Total'] = len(checked_locations)
|
||||
checks_in_area = {mission_name: len([id for id in mission_locations if id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
|
||||
checks_in_area['Total'] = sum(checks_in_area.values())
|
||||
|
||||
return render_template("sc2wolTracker.html",
|
||||
inventory=inventory, icons=icons,
|
||||
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
|
||||
id in lookup_any_item_id_to_name},
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
|
||||
**display_data)
|
||||
|
||||
|
||||
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
|
||||
inventory: Counter, team: int, player: int, playerName: str,
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
|
||||
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
|
||||
saving_second: int) -> str:
|
||||
|
||||
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
|
||||
player_received_items = {}
|
||||
if multisave.get('version', 0) > 0:
|
||||
# add numbering to all items but starter_inventory
|
||||
ordered_items = multisave.get('received_items', {}).get((team, player, True), [])
|
||||
else:
|
||||
ordered_items = multisave.get('received_items', {}).get((team, player), [])
|
||||
|
||||
# add numbering to all items but starter_inventory
|
||||
for order_index, networkItem in enumerate(ordered_items, start=1):
|
||||
player_received_items[networkItem.item] = order_index
|
||||
|
||||
return render_template("genericTracker.html",
|
||||
inventory=inventory,
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checked_locations=checked_locations,
|
||||
not_checked_locations=set(locations[player]) - checked_locations,
|
||||
received_items=player_received_items)
|
||||
inventory=inventory,
|
||||
player=player, team=team, room=room, player_name=playerName,
|
||||
checked_locations=checked_locations,
|
||||
not_checked_locations=set(locations[player]) - checked_locations,
|
||||
received_items=player_received_items,
|
||||
saving_second=saving_second)
|
||||
|
||||
|
||||
@app.route('/tracker/<suuid:tracker>')
|
||||
@cache.memoize(timeout=60) # multisave is currently created at most every minute
|
||||
@cache.memoize(timeout=1) # multisave is currently created at most every minute
|
||||
def getTracker(tracker: UUID):
|
||||
room: Room = Room.get(tracker=tracker)
|
||||
if not room:
|
||||
abort(404)
|
||||
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
|
||||
precollected_items, games, slot_data, groups = get_static_room_data(room)
|
||||
precollected_items, games, slot_data, groups, saving_second = get_static_room_data(room)
|
||||
|
||||
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups}
|
||||
for teamnumber, team in enumerate(names)}
|
||||
@@ -1044,5 +1315,6 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
|
||||
"Ocarina of Time": __renderOoTTracker,
|
||||
"Timespinner": __renderTimespinnerTracker,
|
||||
"A Link to the Past": __renderAlttpTracker,
|
||||
"Super Metroid": __renderSuperMetroidTracker
|
||||
"Super Metroid": __renderSuperMetroidTracker,
|
||||
"Starcraft 2 Wings of Liberty": __renderSC2WoLTracker
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import uuid
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
|
||||
from flask import request, flash, redirect, url_for, session, render_template
|
||||
from flask import request, flash, redirect, url_for, session, render_template, Markup
|
||||
from pony.orm import flush, select
|
||||
|
||||
import MultiServer
|
||||
@@ -15,68 +15,41 @@ from worlds.Files import AutoPatchRegister
|
||||
from . import app
|
||||
from .models import Seed, Room, Slot
|
||||
|
||||
banned_zip_contents = (".sfc",)
|
||||
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
|
||||
|
||||
|
||||
def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None):
|
||||
if not owner:
|
||||
owner = session["_id"]
|
||||
infolist = zfile.infolist()
|
||||
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
|
||||
flash(Markup("Error: Your .zip file only contains .yaml files. "
|
||||
'Did you mean to <a href="/generate">generate a game</a>?'))
|
||||
return
|
||||
slots: typing.Set[Slot] = set()
|
||||
spoiler = ""
|
||||
files = {}
|
||||
multidata = None
|
||||
|
||||
# Load files.
|
||||
for file in infolist:
|
||||
handler = AutoPatchRegister.get_handler(file.filename)
|
||||
if file.filename.endswith(banned_zip_contents):
|
||||
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
|
||||
"Your file was deleted."
|
||||
|
||||
# AP Container
|
||||
elif handler:
|
||||
raw = zfile.open(file, "r").read()
|
||||
patch = handler(BytesIO(raw))
|
||||
patch.read()
|
||||
slots.add(Slot(data=raw,
|
||||
player_name=patch.player_name,
|
||||
player_id=patch.player,
|
||||
game=patch.game))
|
||||
|
||||
elif file.filename.endswith(".apmc"):
|
||||
data = zfile.open(file, "r").read()
|
||||
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
|
||||
slots.add(Slot(data=data,
|
||||
player_name=metadata["player_name"],
|
||||
player_id=metadata["player_id"],
|
||||
game="Minecraft"))
|
||||
|
||||
elif file.filename.endswith(".apv6"):
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="VVVVVV"))
|
||||
|
||||
elif file.filename.endswith(".apsm64ex"):
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Super Mario 64"))
|
||||
|
||||
elif file.filename.endswith(".zip"):
|
||||
# Factorio mods need a specific name or they do not function
|
||||
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-", 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Factorio"))
|
||||
|
||||
elif file.filename.endswith(".apz5"):
|
||||
# .apz5 must be named specifically since they don't contain any metadata
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Ocarina of Time"))
|
||||
|
||||
elif file.filename.endswith(".json"):
|
||||
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3)
|
||||
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
|
||||
player_id=int(slot_id[1:]), game="Dark Souls III"))
|
||||
patch = handler(BytesIO(data))
|
||||
patch.read()
|
||||
files[patch.player] = data
|
||||
|
||||
# Spoiler
|
||||
elif file.filename.endswith(".txt"):
|
||||
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
|
||||
|
||||
# Multi-data
|
||||
elif file.filename.endswith(".archipelago"):
|
||||
try:
|
||||
multidata = zfile.open(file).read()
|
||||
@@ -84,17 +57,36 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
flash("Could not load multidata. File may be corrupted or incompatible.")
|
||||
multidata = None
|
||||
|
||||
# Minecraft
|
||||
elif file.filename.endswith(".apmc"):
|
||||
data = zfile.open(file, "r").read()
|
||||
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
|
||||
files[metadata["player_id"]] = data
|
||||
|
||||
# Factorio
|
||||
elif file.filename.endswith(".zip"):
|
||||
_, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
|
||||
data = zfile.open(file, "r").read()
|
||||
files[int(slot_id[1:])] = data
|
||||
|
||||
# All other files using the standard MultiWorld.get_out_file_name_base method
|
||||
else:
|
||||
_, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
|
||||
data = zfile.open(file, "r").read()
|
||||
files[int(slot_id[1:])] = data
|
||||
|
||||
# Load multi data.
|
||||
if multidata:
|
||||
decompressed_multidata = MultiServer.Context.decompress(multidata)
|
||||
if "slot_info" in decompressed_multidata:
|
||||
player_names = {slot.player_name for slot in slots}
|
||||
leftover_names: typing.Dict[int, NetworkSlot] = {
|
||||
slot_id: slot_info for slot_id, slot_info in decompressed_multidata["slot_info"].items()
|
||||
if slot_info.name not in player_names and slot_info.type != SlotType.group}
|
||||
newslots = [(Slot(data=None, player_name=slot_info.name, player_id=slot, game=slot_info.game))
|
||||
for slot, slot_info in leftover_names.items()]
|
||||
for slot in newslots:
|
||||
slots.add(slot)
|
||||
for slot, slot_info in decompressed_multidata["slot_info"].items():
|
||||
# Ignore Player Groups (e.g. item links)
|
||||
if slot_info.type == SlotType.group:
|
||||
continue
|
||||
slots.add(Slot(data=files.get(slot, None),
|
||||
player_name=slot_info.name,
|
||||
player_id=slot,
|
||||
game=slot_info.game))
|
||||
|
||||
flush() # commit slots
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ class ZillionContext(CommonContext):
|
||||
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
|
||||
items_handling = 1 # receive items from other players
|
||||
|
||||
known_name: Optional[str]
|
||||
""" This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """
|
||||
|
||||
from_game: "asyncio.Queue[events.EventFromGame]"
|
||||
to_game: "asyncio.Queue[events.EventToGame]"
|
||||
ap_local_count: int
|
||||
@@ -82,6 +85,7 @@ class ZillionContext(CommonContext):
|
||||
server_address: str,
|
||||
password: str) -> None:
|
||||
super().__init__(server_address, password)
|
||||
self.known_name = None
|
||||
self.from_game = asyncio.Queue()
|
||||
self.to_game = asyncio.Queue()
|
||||
self.got_room_info = asyncio.Event()
|
||||
@@ -258,6 +262,10 @@ class ZillionContext(CommonContext):
|
||||
assert id_ in id_to_loc
|
||||
self.loc_mem_to_id[mem] = id_
|
||||
|
||||
if len(self.loc_mem_to_id) != 394:
|
||||
logger.warn("invalid Zillion `Connected` packet, "
|
||||
f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}")
|
||||
|
||||
self.got_slot_data.set()
|
||||
|
||||
payload = {
|
||||
@@ -392,7 +400,8 @@ async def zillion_sync_task(ctx: ZillionContext) -> None:
|
||||
game_id = memory.get_rom_to_ram_data(ram)
|
||||
name, seed_end = name_seed_from_ram(game_id)
|
||||
if len(name):
|
||||
if name == ctx.auth:
|
||||
if name == ctx.known_name:
|
||||
ctx.auth = name
|
||||
# this is the name we know
|
||||
if ctx.server and ctx.server.socket: # type: ignore
|
||||
if ctx.got_room_info.is_set():
|
||||
@@ -435,6 +444,7 @@ async def zillion_sync_task(ctx: ZillionContext) -> None:
|
||||
memory.reset_game_state()
|
||||
|
||||
ctx.auth = name
|
||||
ctx.known_name = name
|
||||
async_start(ctx.connect())
|
||||
await asyncio.wait((
|
||||
ctx.got_room_info.wait(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<TabbedPanel>
|
||||
tab_width: 200
|
||||
tab_width: root.width / app.tab_count
|
||||
<SelectableLabel>:
|
||||
canvas.before:
|
||||
Color:
|
||||
|
||||
@@ -2,8 +2,8 @@ local socket = require("socket")
|
||||
local json = require('json')
|
||||
local math = require('math')
|
||||
|
||||
local last_modified_date = '2022-07-24' -- Should be the last modified date
|
||||
local script_version = 2
|
||||
local last_modified_date = '2022-11-27' -- Should be the last modified date
|
||||
local script_version = 3
|
||||
|
||||
--------------------------------------------------
|
||||
-- Heavily modified form of RiptideSage's tracker
|
||||
@@ -25,6 +25,9 @@ local inf_table_offset = save_context_offset + 0xEF8 -- 0x11B4C8
|
||||
|
||||
local temp_context = nil
|
||||
|
||||
local collectibles_overrides = nil
|
||||
local collectible_offsets = nil
|
||||
|
||||
-- Offsets for scenes can be found here
|
||||
-- https://wiki.cloudmodding.com/oot/Scene_Table/NTSC_1.0
|
||||
-- Each scene is 0x1c bits long, chests at 0x0, switches at 0x4, collectibles at 0xc
|
||||
@@ -40,12 +43,16 @@ end
|
||||
-- [1] is the scene id
|
||||
-- [2] is the location type, which varies as input to the function
|
||||
-- [3] is the location id within the scene, and represents the bit which was checked
|
||||
-- REORDERED IN 7.0 TO scene id - location type - 0x00 - location id
|
||||
-- Note that temp_context is 0-indexed and expected_values is 1-indexed, because consistency.
|
||||
local check_temp_context = function(expected_values)
|
||||
if temp_context[0] ~= 0x00 then return false end
|
||||
for i=1,3 do
|
||||
if temp_context[i] ~= expected_values[i] then return false end
|
||||
end
|
||||
-- if temp_context[0] ~= 0x00 then return false end
|
||||
-- for i=1,3 do
|
||||
-- if temp_context[i] ~= expected_values[i] then return false end
|
||||
-- end
|
||||
if temp_context[0] ~= expected_values[1] then return false end
|
||||
if temp_context[1] ~= expected_values[2] then return false end
|
||||
if temp_context[3] ~= expected_values[3] then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -67,7 +74,7 @@ local on_the_ground_check = function(scene_offset, bit_to_check)
|
||||
end
|
||||
|
||||
local boss_item_check = function(scene_offset)
|
||||
return chest_check(scene_offset, 0x1F)
|
||||
return on_the_ground_check(scene_offset, 0x1F)
|
||||
or check_temp_context({scene_offset, 0x00, 0x4F})
|
||||
end
|
||||
|
||||
@@ -226,6 +233,8 @@ local read_kokiri_forest_checks = function()
|
||||
checks["KF Shop Item 6"] = shop_check(0x6, 0x1)
|
||||
checks["KF Shop Item 7"] = shop_check(0x6, 0x2)
|
||||
checks["KF Shop Item 8"] = shop_check(0x6, 0x3)
|
||||
|
||||
checks["KF Shop Blue Rupee"] = on_the_ground_check(0x2D, 0x1)
|
||||
return checks
|
||||
end
|
||||
|
||||
@@ -454,7 +463,7 @@ local read_kakariko_village_checks = function()
|
||||
checks["Kak Impas House Cow"] = cow_check(0x37, 0x18)
|
||||
|
||||
checks["Kak GS Tree"] = skulltula_check(0x10, 0x5)
|
||||
checks["Kak GS Guards House"] = skulltula_check(0x10, 0x1)
|
||||
checks["Kak GS Near Gate Guard"] = skulltula_check(0x10, 0x1)
|
||||
checks["Kak GS Watchtower"] = skulltula_check(0x10, 0x2)
|
||||
checks["Kak GS Skulltula House"] = skulltula_check(0x10, 0x4)
|
||||
checks["Kak GS House Under Construction"] = skulltula_check(0x10, 0x3)
|
||||
@@ -480,7 +489,7 @@ local read_graveyard_checks = function()
|
||||
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
|
||||
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
|
||||
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
|
||||
checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
|
||||
checks["Graveyard Dampe Race Hookshot Chest"] = chest_check(0x48, 0x00)
|
||||
checks["Graveyard Dampe Race Freestanding PoH"] = on_the_ground_check(0x48, 0x7)
|
||||
|
||||
checks["Graveyard GS Bean Patch"] = skulltula_check(0x10, 0x0)
|
||||
@@ -545,7 +554,7 @@ local read_shadow_temple_checks = function(mq_table_address)
|
||||
checks["Shadow Temple Boss Key Chest"] = chest_check(0x07, 0x0B)
|
||||
checks["Shadow Temple Invisible Floormaster Chest"] = chest_check(0x07, 0x0D)
|
||||
|
||||
checks["Shadow Temple GS Like Like Room"] = skulltula_check(0x07, 0x3)
|
||||
checks["Shadow Temple GS Invisible Blades Room"] = skulltula_check(0x07, 0x3)
|
||||
checks["Shadow Temple GS Falling Spikes Room"] = skulltula_check(0x07, 0x1)
|
||||
checks["Shadow Temple GS Single Giant Pot"] = skulltula_check(0x07, 0x0)
|
||||
checks["Shadow Temple GS Near Ship"] = skulltula_check(0x07, 0x4)
|
||||
@@ -723,9 +732,9 @@ local read_fire_temple_checks = function(mq_table_address)
|
||||
|
||||
checks["Fire Temple MQ GS Big Lava Room Open Door"] = skulltula_check(0x4, 0x0)
|
||||
checks["Fire Temple MQ GS Skull On Fire"] = skulltula_check(0x4, 0x2)
|
||||
checks["Fire Temple MQ GS Fire Wall Maze Center"] = skulltula_check(0x4, 0x3)
|
||||
checks["Fire Temple MQ GS Fire Wall Maze Side Room"] = skulltula_check(0x4, 0x4)
|
||||
checks["Fire Temple MQ GS Above Fire Wall Maze"] = skulltula_check(0x4, 0x1)
|
||||
checks["Fire Temple MQ GS Flame Maze Center"] = skulltula_check(0x4, 0x3)
|
||||
checks["Fire Temple MQ GS Flame Maze Side Room"] = skulltula_check(0x4, 0x4)
|
||||
checks["Fire Temple MQ GS Above Flame Maze"] = skulltula_check(0x4, 0x1)
|
||||
end
|
||||
|
||||
checks["Fire Temple Volvagia Heart"] = boss_item_check(0x15)
|
||||
@@ -743,6 +752,12 @@ local read_zoras_river_checks = function()
|
||||
checks["ZR Deku Scrub Grotto Front"] = scrub_sanity_check(0x15, 0x9)
|
||||
checks["ZR Deku Scrub Grotto Rear"] = scrub_sanity_check(0x15, 0x8)
|
||||
|
||||
checks["ZR Frogs Zeldas Lullaby"] = event_check(0xD, 0x1)
|
||||
checks["ZR Frogs Eponas Song"] = event_check(0xD, 0x2)
|
||||
checks["ZR Frogs Suns Song"] = event_check(0xD, 0x3)
|
||||
checks["ZR Frogs Sarias Song"] = event_check(0xD, 0x4)
|
||||
checks["ZR Frogs Song of Time"] = event_check(0xD, 0x5)
|
||||
|
||||
checks["ZR GS Tree"] = skulltula_check(0x11, 0x1)
|
||||
--NOTE: There is no GS in the soft soil. It's the only one that doesn't have one.
|
||||
checks["ZR GS Ladder"] = skulltula_check(0x11, 0x0)
|
||||
@@ -912,10 +927,10 @@ end
|
||||
|
||||
local read_gerudo_fortress_checks = function()
|
||||
local checks = {}
|
||||
checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
|
||||
checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
|
||||
checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
|
||||
checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
|
||||
checks["Hideout 1 Torch Jail Gerudo Key"] = on_the_ground_check(0xC, 0xC)
|
||||
checks["Hideout 2 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xF)
|
||||
checks["Hideout 3 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xA)
|
||||
checks["Hideout 4 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xE)
|
||||
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
|
||||
checks["GF Chest"] = chest_check(0x5D, 0x0)
|
||||
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
|
||||
@@ -1170,9 +1185,22 @@ local check_all_locations = function(mq_table_address)
|
||||
for k,v in pairs(read_ganons_castle_checks(mq_table_address)) do location_checks[k] = v end
|
||||
for k,v in pairs(read_outside_ganons_castle_checks()) do location_checks[k] = v end
|
||||
for k,v in pairs(read_song_checks()) do location_checks[k] = v end
|
||||
-- write 0 to temp context values
|
||||
mainmemory.write_u32_be(0x40002C, 0)
|
||||
mainmemory.write_u32_be(0x400030, 0)
|
||||
return location_checks
|
||||
end
|
||||
|
||||
local check_collectibles = function()
|
||||
local retval = {}
|
||||
if collectible_offsets ~= nil then
|
||||
for id, data in pairs(collectible_offsets) do
|
||||
local mem = mainmemory.readbyte(collectible_overrides + data[1] + bit.rshift(data[2], 3))
|
||||
retval[id] = bit.check(mem, data[2] % 8)
|
||||
end
|
||||
end
|
||||
return retval
|
||||
end
|
||||
|
||||
-- convenience functions
|
||||
|
||||
@@ -1557,9 +1585,10 @@ local outgoing_player_addr = coop_context + 18
|
||||
|
||||
local player_names_address = coop_context + 20
|
||||
local player_name_length = 8 -- 8 bytes
|
||||
local rom_name_location = player_names_address + 0x800
|
||||
local rom_name_location = player_names_address + 0x800 + 0x5 -- 0x800 player names, 0x5 CFG_FILE_SELECT_HASH
|
||||
|
||||
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
|
||||
-- TODO: load dynamically from slot data
|
||||
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0E9F) - 0x03480000)
|
||||
|
||||
local save_context_addr = 0x11A5D0
|
||||
local internal_count_addr = save_context_addr + 0x90
|
||||
@@ -1568,7 +1597,7 @@ local item_queue = {}
|
||||
local first_connect = true
|
||||
local game_complete = false
|
||||
|
||||
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
|
||||
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0EAD)
|
||||
|
||||
local bytes_to_string = function(bytes)
|
||||
local string = ''
|
||||
@@ -1718,7 +1747,7 @@ function is_game_complete()
|
||||
end
|
||||
|
||||
function deathlink_enabled()
|
||||
local death_link_flag = mainmemory.read_u16_be(coop_context + 0xA)
|
||||
local death_link_flag = mainmemory.readbyte(coop_context + 0xB)
|
||||
return death_link_flag > 0
|
||||
end
|
||||
|
||||
@@ -1774,6 +1803,13 @@ function process_block(block)
|
||||
mainmemory.write_u16_be(incoming_item_addr, item_queue[received_items_count+1])
|
||||
end
|
||||
end
|
||||
-- Record collectible data if necessary
|
||||
if collectible_overrides == nil and block['collectibleOverrides'] ~= 0 then
|
||||
collectible_overrides = mainmemory.read_u32_be(rando_context + block['collectibleOverrides']) - 0x80000000
|
||||
end
|
||||
if collectible_offsets ~= block['collectibleOffsets'] then
|
||||
collectible_offsets = block['collectibleOffsets']
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1805,6 +1841,7 @@ function receive()
|
||||
retTable["deathlinkActive"] = deathlink_enabled()
|
||||
if InSafeState() then
|
||||
retTable["locations"] = check_all_locations(master_quest_table_address)
|
||||
retTable["collectibles"] = check_collectibles()
|
||||
retTable["isDead"] = get_death_state()
|
||||
retTable["gameComplete"] = is_game_complete()
|
||||
end
|
||||
|
||||
@@ -7,18 +7,25 @@ local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
|
||||
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
|
||||
local STATE_UNINITIALIZED = "Uninitialized"
|
||||
|
||||
local SCRIPT_VERSION = 1
|
||||
|
||||
local APIndex = 0x1A6E
|
||||
local APDeathLinkAddress = 0x00FD
|
||||
local APItemAddress = 0x00FF
|
||||
local EventFlagAddress = 0x1735
|
||||
local MissableAddress = 0x161A
|
||||
local HiddenItemsAddress = 0x16DE
|
||||
local RodAddress = 0x1716
|
||||
local InGame = 0x1A71
|
||||
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
|
||||
@@ -69,11 +76,10 @@ function processBlock(block)
|
||||
end
|
||||
local itemsBlock = block["items"]
|
||||
memDomain.wram()
|
||||
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
|
||||
-- print(itemsBlock)
|
||||
ItemsReceived = itemsBlock
|
||||
|
||||
if itemsBlock ~= nil then
|
||||
ItemsReceived = itemsBlock
|
||||
end
|
||||
deathlink_rec = block["deathlink"]
|
||||
end
|
||||
|
||||
function difference(a, b)
|
||||
@@ -104,14 +110,7 @@ function generateLocationsChecked()
|
||||
|
||||
return data
|
||||
end
|
||||
function generateSerialData()
|
||||
memDomain.wram()
|
||||
status = u8(0x1A73)
|
||||
if status == 0 then
|
||||
return nil
|
||||
end
|
||||
return uRange(0x1A76, u8(0x1A74))
|
||||
end
|
||||
|
||||
local function arrayEqual(a1, a2)
|
||||
if #a1 ~= #a2 then
|
||||
return false
|
||||
@@ -135,7 +134,6 @@ function receive()
|
||||
curstate = STATE_UNINITIALIZED
|
||||
return
|
||||
elseif e == 'timeout' then
|
||||
print("timeout")
|
||||
return
|
||||
elseif e ~= nil then
|
||||
print(e)
|
||||
@@ -157,16 +155,16 @@ function receive()
|
||||
playerName = newPlayerName
|
||||
seedName = newSeedName
|
||||
local retTable = {}
|
||||
retTable["scriptVersion"] = SCRIPT_VERSION
|
||||
retTable["clientCompatibilityVersion"] = u8(ClientCompatibilityAddress)
|
||||
retTable["playerName"] = playerName
|
||||
retTable["seedName"] = seedName
|
||||
memDomain.wram()
|
||||
if u8(InGame) == 0xAC then
|
||||
retTable["locations"] = generateLocationsChecked()
|
||||
serialData = generateSerialData()
|
||||
if serialData ~= nil then
|
||||
retTable["serial"] = serialData
|
||||
end
|
||||
end
|
||||
retTable["deathLink"] = deathlink_send
|
||||
deathlink_send = false
|
||||
msg = json.encode(retTable).."\n"
|
||||
local ret, error = gbSocket:send(msg)
|
||||
if ret == nil then
|
||||
@@ -197,6 +195,12 @@ function main()
|
||||
receive()
|
||||
if u8(InGame) == 0xAC and 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
|
||||
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
|
||||
end
|
||||
@@ -212,7 +216,6 @@ function main()
|
||||
print("Attempting to connect")
|
||||
local client, timeout = server:accept()
|
||||
if timeout == nil then
|
||||
-- print('Initial Connection Made')
|
||||
curstate = STATE_INITIAL_CONNECTION_MADE
|
||||
gbSocket = client
|
||||
gbSocket:settimeout(0)
|
||||
|
||||
@@ -148,7 +148,7 @@ The next step is to know what you need to make the game do now that you can modi
|
||||
- Listen for messages from the Archipelago server
|
||||
- Modify the game to display messages from the Archipelago server
|
||||
- Add interface for connecting to the Archipelago server with passwords and sessions
|
||||
- Add commands for manually rewarding, re-syncing, forfeiting, and other actions
|
||||
- Add commands for manually rewarding, re-syncing, releasing, and other actions
|
||||
|
||||
To elaborate, you need to be able to inform the server whenever you check locations, print out messages that you receive
|
||||
from the server in-game so players can read them, award items when the server tells you to, sync and re-sync when necessary,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
|
||||
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
|
||||
See [world api.md](world api.md) for details.
|
||||
See [world api.md](world%20api.md) for details.
|
||||
|
||||
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
|
||||
file into the worlds folder.
|
||||
|
||||
@@ -8,4 +8,4 @@ We conduct ourselves openly and inclusively here. Please do not contribute to an
|
||||
|
||||
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private ones, such as private messaging or emails.
|
||||
|
||||
Any incidents of abuse may be reported directly to ijwu at hmfarran@gmail.com.
|
||||
Any incidents of abuse may be reported directly to eudaimonistic at eudaimonistic42@gmail.com
|
||||
|
||||
@@ -70,23 +70,22 @@ Sent to clients when they connect to an Archipelago server.
|
||||
| version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which the server is running. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
|
||||
| password | bool | Denoted whether a password is required to join this room.|
|
||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". |
|
||||
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
|
||||
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
|
||||
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
|
||||
| games | list\[str\] | List of games present in this multiworld. |
|
||||
| datapackage_version | int | Sum of individual games' datapackage version. Deprecated. Use `datapackage_versions` instead. |
|
||||
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
|
||||
| seed_name | str | uniquely identifying name of this generation |
|
||||
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
|
||||
|
||||
#### forfeit
|
||||
Dictates what is allowed when it comes to a player forfeiting their run. A forfeit is an action which distributes the rest of the items in a player's run to those other players awaiting them.
|
||||
#### release
|
||||
Dictates what is allowed when it comes to a player releasing their run. A release is an action which distributes the rest of the items in a player's run to those other players awaiting them.
|
||||
|
||||
* `auto`: Distributes a player's items to other players when they complete their goal.
|
||||
* `enabled`: Denotes that players may forfeit at any time in the game.
|
||||
* `enabled`: Denotes that players may release at any time in the game.
|
||||
* `auto-enabled`: Both of the above options together.
|
||||
* `disabled`: All forfeit modes disabled.
|
||||
* `goal`: Allows for manual use of forfeit command once a player completes their goal. (Disabled until goal completion)
|
||||
* `disabled`: All release modes disabled.
|
||||
* `goal`: Allows for manual use of release command once a player completes their goal. (Disabled until goal completion)
|
||||
|
||||
#### collect
|
||||
Dictates what is allowed when it comes to a player collecting their run. A collect is an action which sends the rest of the items in a player's run.
|
||||
@@ -121,15 +120,15 @@ InvalidItemsHandling indicates a wrong value type or flag combination was sent.
|
||||
### Connected
|
||||
Sent to clients when the connection handshake is successfully completed.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
|
||||
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
|
||||
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
|
||||
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
|
||||
| slot_data | dict | Contains a json object for slot related data, differs per game. Empty if not required. |
|
||||
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
|
||||
| Name | Type | Notes |
|
||||
|-------------------|------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| team | int | Your team number. See [NetworkPlayer](#NetworkPlayer) for more info on team number. |
|
||||
| slot | int | Your slot number on your team. See [NetworkPlayer](#NetworkPlayer) for more info on the slot number. |
|
||||
| players | list\[[NetworkPlayer](#NetworkPlayer)\] | List denoting other players in the multiworld, whether connected or not. |
|
||||
| missing_locations | list\[int\] | Contains ids of remaining locations that need to be checked. Useful for trackers, among other things. |
|
||||
| checked_locations | list\[int\] | Contains ids of all locations that have been checked. Useful for trackers, among other things. Location ids are in the range of ± 2<sup>53</sup>-1. |
|
||||
| slot_data | dict\[str, any\] | Contains a json object for slot related data, differs per game. Empty if not required. Not present if slot_data in [Connect](#Connect) is false. |
|
||||
| slot_info | dict\[int, [NetworkSlot](#NetworkSlot)\] | maps each slot to a [NetworkSlot](#NetworkSlot) information |
|
||||
|
||||
### ReceivedItems
|
||||
Sent to clients when they receive an item.
|
||||
@@ -242,11 +241,11 @@ Additional arguments added to the [Get](#Get) package that triggered this [Retri
|
||||
### SetReply
|
||||
Sent to clients in response to a [Set](#Set) package if want_reply was set to true, or if the client has registered to receive updates for a certain key using the [SetNotify](#SetNotify) package. SetReply packages are sent even if a [Set](#Set) package did not alter the value for the key.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| key | str | The key that was updated. |
|
||||
| value | any | The new value for the key. |
|
||||
| original_value | any | The value the key had before it was updated. |
|
||||
| Name | Type | Notes |
|
||||
|----------------|------|--------------------------------------------------------------------------------------------|
|
||||
| key | str | The key that was updated. |
|
||||
| value | any | The new value for the key. |
|
||||
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
||||
|
||||
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
||||
|
||||
@@ -269,15 +268,16 @@ These packets are sent purely from client to server. They are not accepted by cl
|
||||
Sent by the client to initiate a connection to an Archipelago game session.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| password | str | If the game session requires a password, it should be passed here. |
|
||||
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
||||
| name | str | The player name for this client. |
|
||||
| uuid | str | Unique identifier for player client. |
|
||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
| Name | Type | Notes |
|
||||
|----------------|-----------------------------------|----------------------------------------------------------------------------------------------|
|
||||
| password | str | If the game session requires a password, it should be passed here. |
|
||||
| game | str | The name of the game the client is playing. Example: `A Link to the Past` |
|
||||
| name | str | The player name for this client. |
|
||||
| uuid | str | Unique identifier for player client. |
|
||||
| version | [NetworkVersion](#NetworkVersion) | An object representing the Archipelago version this client supports. |
|
||||
| items_handling | int | Flags configuring which items should be sent by the server. Read below for individual flags. |
|
||||
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
|
||||
| slot_data | bool | If true, the Connect answer will contain slot_data |
|
||||
|
||||
#### items_handling flags
|
||||
| Value | Meaning |
|
||||
@@ -367,14 +367,23 @@ Used to request a single or multiple values from the server's data storage, see
|
||||
|
||||
Additional arguments sent in this package will also be added to the [Retrieved](#Retrieved) package it triggers.
|
||||
|
||||
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. |
|
||||
|
||||
### 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.
|
||||
Keys that start with `_read_` cannot be set.
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ------ | ----- | ------ |
|
||||
| key | str | The key to manipulate. |
|
||||
| default | any | The default value to use in case the key has no value on the server. |
|
||||
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| Name | Type | Notes |
|
||||
|------------|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|
|
||||
| key | str | The key to manipulate. Can never start with "_read". |
|
||||
| default | any | The default value to use in case the key has no value on the server. |
|
||||
| want_reply | bool | If true, the server will send a [SetReply](#SetReply) response back to the client. |
|
||||
| operations | list\[[DataStorageOperation](#DataStorageOperation)\] | Operations to apply to the value, multiple operations can be present and they will be executed in order of appearance. |
|
||||
|
||||
Additional arguments sent in this package will also be added to the [SetReply](#SetReply) package it triggers.
|
||||
@@ -402,6 +411,9 @@ The following operations can be applied to a datastorage key
|
||||
| xor | Applies a bitwise Exclusive OR to the current value of the key with `value`. |
|
||||
| left_shift | Applies a bitwise left-shift to the current value of the key by `value`. |
|
||||
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
|
||||
| remove | List only: removes the first instance of `value` found in the list. |
|
||||
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
|
||||
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
|
||||
|
||||
### SetNotify
|
||||
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
|
||||
@@ -587,10 +599,24 @@ class Permission(enum.IntEnum):
|
||||
disabled = 0b000 # 0, completely disables access
|
||||
enabled = 0b001 # 1, allows manual use
|
||||
goal = 0b010 # 2, allows manual use after goal completion
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for forfeit and collect
|
||||
auto = 0b110 # 6, forces use after goal completion, only works for release and collect
|
||||
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
|
||||
```
|
||||
|
||||
### Hint
|
||||
An object representing a Hint.
|
||||
```python
|
||||
import typing
|
||||
class Hint(typing.NamedTuple):
|
||||
receiving_player: int
|
||||
finding_player: int
|
||||
location: int
|
||||
item: int
|
||||
found: bool
|
||||
entrance: str = ""
|
||||
item_flags: int = 0
|
||||
```
|
||||
|
||||
### Data Package Contents
|
||||
A data package is a JSON object which may contain arbitrary metadata to enable a client to interact with the Archipelago server most easily. Currently, this package is used to send ID to name mappings so that clients need not maintain their own mappings.
|
||||
|
||||
@@ -622,7 +648,6 @@ Tags are represented as a list of strings, the common Client tags follow:
|
||||
| Name | Notes |
|
||||
|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
|
||||
| IgnoreGame | Deprecated. See Tracker and TextOnly. Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
|
||||
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
|
||||
| Tracker | Tells the server that this client will not send locations and is actually a Tracker. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
| TextOnly | Tells the server that this client will not send locations and is intended for chat. When specified and used with empty or null `game` in [Connect](#connect), game and game's version validation will be skipped. |
|
||||
|
||||
@@ -343,18 +343,6 @@ class MyGameWorld(World):
|
||||
option_definitions = mygame_options # assign the options dict to the world
|
||||
#...
|
||||
```
|
||||
|
||||
### Local or Remote
|
||||
|
||||
A world with `remote_items` set to `True` gets all items items from the server
|
||||
and no item from the local game. So for an RPG opening a chest would not add
|
||||
any item to your inventory, instead the server will send you what was in that
|
||||
chest. The advantage is that a generic mod can be used that does not need to
|
||||
know anything about the seed.
|
||||
|
||||
A world with `remote_items` set to `False` will locally reward its local items.
|
||||
For console games this can remove delay and make script/animation/dialog flow
|
||||
more natural. These games typically have been edited to 'bake in' the items.
|
||||
|
||||
### A World Class Skeleton
|
||||
|
||||
@@ -379,8 +367,6 @@ class MyGameWorld(World):
|
||||
game: str = "My Game" # name of the game/world
|
||||
option_definitions = mygame_options # options the player can set
|
||||
topology_present: bool = True # show path to required location checks in spoiler
|
||||
remote_items: bool = False # True if all items come from the server
|
||||
remote_start_inventory: bool = False # True if start inventory comes from the server
|
||||
|
||||
# data_version is used to signal that items, locations or their names
|
||||
# changed. Set this to 0 during development so other games' clients do not
|
||||
@@ -415,17 +401,13 @@ The world has to provide the following things for generation
|
||||
* additions to the item pool
|
||||
* additions to the regions list: at least one called "Menu"
|
||||
* locations placed inside those regions
|
||||
* a `def create_item(self, item: str) -> MyGameItem` for plando/manual placing
|
||||
* applying `self.world.precollected_items` for plando/start inventory
|
||||
if not using a `remote_start_inventory`
|
||||
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
|
||||
* applying `self.world.push_precollected` for start inventory
|
||||
* a `def generate_output(self, output_directory: str)` that creates the output
|
||||
if there is output to be generated. If only items are randomized and
|
||||
`remote_items = True` it is possible to have a generic mod and output
|
||||
generation can be skipped. In all other cases this is required. When this is
|
||||
called, `self.world.get_locations()` has all locations for all players, with
|
||||
properties `item` pointing to the item and `player` identifying the player.
|
||||
`self.world.get_filled_locations(self.player)` will filter for this world.
|
||||
`item.player` can be used to see if it's a local item.
|
||||
files if there is output to be generated. When this is
|
||||
called, `self.world.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.
|
||||
|
||||
In addition, the following methods can be implemented and attributes can be set
|
||||
|
||||
@@ -433,12 +415,13 @@ In addition, the following methods can be implemented and attributes can be set
|
||||
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)`
|
||||
called to place player's regions into the MultiWorld's regions list. If it's
|
||||
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 `basic` as well.
|
||||
* `def create_items(self)`
|
||||
called to place player's items into the MultiWorld's itempool.
|
||||
* `def set_rules(self)`
|
||||
called to set access and item rules on locations and entrances.
|
||||
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)`
|
||||
called after the previous steps. Some placement and player specific
|
||||
randomizations can be done here. After this step all regions and items have
|
||||
@@ -677,6 +660,7 @@ def generate_output(self, output_directory: str):
|
||||
if location.item.player == self.player else "Remote"
|
||||
for location in self.multiworld.get_filled_locations(self.player)},
|
||||
# store start_inventory from player's .yaml
|
||||
# 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,
|
||||
|
||||
26
host.yaml
26
host.yaml
@@ -22,14 +22,14 @@ server_options:
|
||||
# Relative point cost to receive a hint via !hint for players
|
||||
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint, for a total of 5
|
||||
hint_cost: 10 # Set to 0 if you want free hints
|
||||
# Forfeit modes
|
||||
# A Forfeit sends out the remaining items *from* a world that forfeits
|
||||
# "disabled" -> clients can't forfeit,
|
||||
# "enabled" -> clients can always forfeit
|
||||
# "auto" -> automatic forfeit on goal completion
|
||||
# "auto-enabled" -> automatic forfeit on goal completion and manual forfeit is also enabled
|
||||
# "goal" -> forfeit is allowed after goal completion
|
||||
forfeit_mode: "goal"
|
||||
# Release modes
|
||||
# A Release sends out the remaining items *from* a world that releases
|
||||
# "disabled" -> clients can't release,
|
||||
# "enabled" -> clients can always release
|
||||
# "auto" -> automatic release on goal completion
|
||||
# "auto-enabled" -> automatic release on goal completion and manual release is also enabled
|
||||
# "goal" -> release is allowed after goal completion
|
||||
release_mode: "goal"
|
||||
# Collect modes
|
||||
# A Collect sends the remaining items *to* a world that collects
|
||||
# "disabled" -> clients can't collect,
|
||||
@@ -68,9 +68,10 @@ generator:
|
||||
meta_file_path: "meta.yaml"
|
||||
# Create a spoiler file
|
||||
# 0 -> None
|
||||
# 1 -> Spoiler without playthrough
|
||||
# 2 -> Full spoiler
|
||||
spoiler: 2
|
||||
# 1 -> Spoiler without playthrough or paths to playthrough required items
|
||||
# 2 -> Spoiler with playthrough (viable solution to goals)
|
||||
# 3 -> Spoiler with playthrough and traversal paths towards items
|
||||
spoiler: 3
|
||||
# Glitch to Triforce room from Ganon
|
||||
# When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality + hammer)
|
||||
# and have completed the goal required for killing ganon to be able to access the triforce room.
|
||||
@@ -92,6 +93,9 @@ sni_options:
|
||||
lttp_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
lufia2ac_options:
|
||||
# File name of the US rom
|
||||
rom_file: "Lufia II - Rise of the Sinistrals (USA).sfc"
|
||||
sm_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "Super Metroid (JU).sfc"
|
||||
|
||||
@@ -57,6 +57,7 @@ Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full ho
|
||||
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
|
||||
Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning
|
||||
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
|
||||
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
|
||||
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
|
||||
@@ -69,6 +70,7 @@ Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Se
|
||||
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
|
||||
Name: "client/factorio"; Description: "Factorio"; Types: full playing
|
||||
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
|
||||
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
|
||||
@@ -90,6 +92,7 @@ Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).s
|
||||
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
|
||||
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
|
||||
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
|
||||
Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac
|
||||
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
|
||||
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
|
||||
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
|
||||
@@ -152,6 +155,7 @@ Type: dirifempty; Name: "{app}"
|
||||
[InstallDelete]
|
||||
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
|
||||
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
|
||||
#include "installdelete.iss"
|
||||
|
||||
[Registry]
|
||||
|
||||
@@ -190,6 +194,11 @@ Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Arch
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
|
||||
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
|
||||
|
||||
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
|
||||
@@ -200,15 +209,15 @@ Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archip
|
||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
|
||||
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
|
||||
|
||||
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/red
|
||||
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
|
||||
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn/blue
|
||||
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
|
||||
@@ -262,6 +271,9 @@ var SMWRomFilePage: TInputFileWizardPage;
|
||||
var soerom: string;
|
||||
var SoERomFilePage: TInputFileWizardPage;
|
||||
|
||||
var l2acrom: string;
|
||||
var L2ACROMFilePage: TInputFileWizardPage;
|
||||
|
||||
var ootrom: string;
|
||||
var OoTROMFilePage: TInputFileWizardPage;
|
||||
|
||||
@@ -422,6 +434,8 @@ begin
|
||||
Result := not (SMWROMFilePage.Values[0] = '')
|
||||
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
|
||||
Result := not (SoEROMFilePage.Values[0] = '')
|
||||
else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then
|
||||
Result := not (L2ACROMFilePage.Values[0] = '')
|
||||
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
|
||||
Result := not (OoTROMFilePage.Values[0] = '')
|
||||
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
|
||||
@@ -526,6 +540,22 @@ begin
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetL2ACROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(l2acrom) > 0 then
|
||||
Result := l2acrom
|
||||
else if Assigned(L2ACROMFilePage) then
|
||||
begin
|
||||
R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d')
|
||||
if R <> 0 then
|
||||
MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
|
||||
|
||||
Result := L2ACROMFilePage.Values[0]
|
||||
end
|
||||
else
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
function GetZlROMPath(Param: string): string;
|
||||
begin
|
||||
if Length(zlrom) > 0 then
|
||||
@@ -609,6 +639,10 @@ begin
|
||||
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
|
||||
if Length(bluerom) = 0 then
|
||||
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
|
||||
|
||||
l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d');
|
||||
if Length(l2acrom) = 0 then
|
||||
L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc');
|
||||
end;
|
||||
|
||||
|
||||
@@ -623,6 +657,8 @@ begin
|
||||
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
|
||||
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
|
||||
if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac'));
|
||||
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
|
||||
Result := not (WizardIsComponentSelected('generator/soe'));
|
||||
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
|
||||
|
||||
16
kvui.py
16
kvui.py
@@ -330,6 +330,12 @@ class GameManager(App):
|
||||
|
||||
super(GameManager, self).__init__()
|
||||
|
||||
@property
|
||||
def tab_count(self):
|
||||
if hasattr(self, "tabs"):
|
||||
return max(1, len(self.tabs.tab_list))
|
||||
return 1
|
||||
|
||||
def build(self) -> Layout:
|
||||
self.container = ContainerLayout()
|
||||
|
||||
@@ -392,11 +398,13 @@ class GameManager(App):
|
||||
Clock.schedule_interval(self.update_texts, 1 / 30)
|
||||
self.container.add_widget(self.grid)
|
||||
|
||||
# If the address contains a port, select it; otherwise, select the host.
|
||||
s = self.server_connect_bar.text
|
||||
host_start = s.find("@") + 1
|
||||
ipv6_end = s.find("]", host_start) + 1
|
||||
port_start = s.find(":", ipv6_end if ipv6_end > 0 else host_start) + 1
|
||||
self.server_connect_bar.focus = True
|
||||
self.server_connect_bar.select_text(
|
||||
self.server_connect_bar.text.find(":") + 1,
|
||||
len(self.server_connect_bar.text)
|
||||
)
|
||||
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
|
||||
|
||||
return self.container
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
meta_description: Meta-Mystery file with the intention of having similar-length completion times for a hopefully better experience
|
||||
null:
|
||||
progression_balancing: # Progression balancing tries to make sure that the player has *something* towards any players goal in each "sphere"
|
||||
on: 0 # Force every player into progression balancing
|
||||
off: 0 # Force every player out of progression balancing, then prepare for a lot of logical BK
|
||||
null: 1 # Let players decide via their own progression_balancing flag in their yaml, defaulting to on
|
||||
normal: 0 # Force every player into default progression balancing
|
||||
disabled: 0 # Force every player out of progression balancing, then prepare for a lot of logical BK
|
||||
null: 1 # Let players decide via their own progression_balancing setting in their yaml, defaulting to 50
|
||||
A Link to the Past:
|
||||
goals:
|
||||
ganon: 100 # Climb GT, defeat Agahnim 2, and then kill Ganon
|
||||
@@ -36,4 +36,4 @@ A Link to the Past:
|
||||
30: 50
|
||||
triforce_pieces_required: # Set to how many out of X triforce pieces you need to win the game in a triforce hunt. Default is 20. Max is 90, Min is 1
|
||||
# Format "pieces: chance"
|
||||
25: 50
|
||||
25: 50
|
||||
|
||||
@@ -27,17 +27,62 @@ game: # Pick a game to play
|
||||
A Link to the Past: 1
|
||||
requires:
|
||||
version: 0.3.3 # Version of Archipelago required for this yaml to work as expected.
|
||||
# Shared Options supported by all games:
|
||||
accessibility:
|
||||
items: 0 # Guarantees you will be able to acquire all items, but you may not be able to access all locations
|
||||
locations: 50 # Guarantees you will be able to access all locations, and therefore all items
|
||||
none: 0 # Guarantees only that the game is beatable. You may not be able to access all locations or acquire all items
|
||||
progression_balancing: # A system to reduce BK, as in times during which you can't do anything, by moving your items into an earlier access sphere
|
||||
0: 0 # Choose a lower number if you don't mind a longer multiworld, or can glitch/sequence break around missing items.
|
||||
25: 0
|
||||
50: 50 # Make it likely you have stuff to do.
|
||||
99: 0 # Get important items early, and stay at the front of the progression.
|
||||
A Link to the Past:
|
||||
progression_balancing:
|
||||
# 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.
|
||||
#
|
||||
# You can define additional values between the minimum and maximum values.
|
||||
# Minimum value is 0
|
||||
# Maximum value is 99
|
||||
random: 0
|
||||
random-low: 0
|
||||
random-high: 0
|
||||
disabled: 0 # equivalent to 0
|
||||
normal: 50 # equivalent to 50
|
||||
extreme: 0 # equivalent to 99
|
||||
|
||||
accessibility:
|
||||
# Set rules for reachability of your items/locations.
|
||||
# Locations: ensure everything can be reached and acquired.
|
||||
# Items: ensure all logically relevant items can be acquired.
|
||||
# Minimal: ensure what is needed to reach your goal can be acquired.
|
||||
locations: 0
|
||||
items: 50
|
||||
minimal: 0
|
||||
|
||||
local_items:
|
||||
# Forces these items to be in their native world.
|
||||
[ ]
|
||||
|
||||
non_local_items:
|
||||
# Forces these items to be outside their native world.
|
||||
[ ]
|
||||
|
||||
start_inventory:
|
||||
# Start with these items.
|
||||
{ }
|
||||
|
||||
start_hints:
|
||||
# Start with these item's locations prefilled into the !hint command.
|
||||
[ ]
|
||||
|
||||
start_location_hints:
|
||||
# Start with these locations and their item prefilled into the !hint command
|
||||
[ ]
|
||||
|
||||
exclude_locations:
|
||||
# Prevent these locations from having an important item
|
||||
[ ]
|
||||
|
||||
priority_locations:
|
||||
# Prevent these locations from having an unimportant item
|
||||
[ ]
|
||||
|
||||
item_links:
|
||||
# Share part of your item pool with other players.
|
||||
[ ]
|
||||
|
||||
### Logic Section ###
|
||||
glitches_required: # Determine the logic required to complete the seed
|
||||
none: 50 # No glitches required
|
||||
@@ -120,8 +165,8 @@ A Link to the Past:
|
||||
open_pyramid:
|
||||
goal: 50 # Opens the pyramid if the goal requires you to kill Ganon, unless the goal is Slow Ganon or All Dungeons
|
||||
auto: 0 # Same as Goal, but also is closed if holes are shuffled and ganon is part of the shuffle pool
|
||||
yes: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt
|
||||
no: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower
|
||||
open: 0 # Pyramid hole is always open. Ganon's vulnerable condition is still required before he can he hurt
|
||||
closed: 0 # Pyramid hole is always closed until you defeat Agahnim atop Ganon's Tower
|
||||
triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
|
||||
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
|
||||
percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required
|
||||
|
||||
82
setup.py
82
setup.py
@@ -1,23 +1,35 @@
|
||||
import base64
|
||||
import datetime
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
import sysconfig
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from hashlib import sha3_512
|
||||
import base64
|
||||
import datetime
|
||||
from Utils import version_tuple, is_windows, is_linux
|
||||
from collections.abc import Iterable
|
||||
import typing
|
||||
import setuptools
|
||||
from Launcher import components, icon_paths
|
||||
import zipfile
|
||||
from collections.abc import Iterable
|
||||
from hashlib import sha3_512
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Launcher import components, icon_paths
|
||||
from Utils import version_tuple, is_windows, is_linux
|
||||
|
||||
# On Python < 3.10 LogicMixin is not currently supported.
|
||||
apworlds: set = {
|
||||
"Subnautica",
|
||||
"Factorio",
|
||||
"Rogue Legacy",
|
||||
}
|
||||
|
||||
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
|
||||
import subprocess
|
||||
import pkg_resources
|
||||
requirement = 'cx-Freeze>=6.13.1'
|
||||
requirement = 'cx-Freeze>=6.14.1'
|
||||
try:
|
||||
pkg_resources.require(requirement)
|
||||
import cx_Freeze
|
||||
@@ -27,12 +39,15 @@ except pkg_resources.ResolutionError:
|
||||
subprocess.call([sys.executable, '-m', 'pip', 'install', requirement, '--upgrade'])
|
||||
import cx_Freeze
|
||||
|
||||
# .build only exists if cx-Freeze is the right version, so we have to update/install that first before this line
|
||||
import setuptools.command.build
|
||||
|
||||
if os.path.exists("X:/pw.txt"):
|
||||
print("Using signtool")
|
||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
||||
pw = f.read()
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
|
||||
r'" /fd sha256 /tr http://timestamp.digicert.com/ '
|
||||
else:
|
||||
signtool = None
|
||||
|
||||
@@ -55,6 +70,7 @@ exes = [
|
||||
]
|
||||
|
||||
extra_data = ["LICENSE", "data", "EnemizerCLI", "host.yaml", "SNI"]
|
||||
extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
|
||||
|
||||
|
||||
def remove_sprites_from_folder(folder):
|
||||
@@ -70,7 +86,7 @@ def _threaded_hash(filepath):
|
||||
|
||||
|
||||
# cx_Freeze's build command runs other commands. Override to accept --yes and store that.
|
||||
class BuildCommand(cx_Freeze.command.build.Build):
|
||||
class BuildCommand(setuptools.command.build.build):
|
||||
user_options = [
|
||||
('yes', 'y', 'Answer "yes" to all questions.'),
|
||||
]
|
||||
@@ -94,6 +110,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
]
|
||||
yes: bool
|
||||
extra_data: Iterable # [any] not available in 3.8
|
||||
extra_libs: Iterable # work around broken include_files
|
||||
|
||||
buildfolder: Path
|
||||
libfolder: Path
|
||||
@@ -104,6 +121,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
super().initialize_options()
|
||||
self.yes = BuildCommand.last_yes
|
||||
self.extra_data = []
|
||||
self.extra_libs = []
|
||||
|
||||
def finalize_options(self):
|
||||
super().finalize_options()
|
||||
@@ -160,17 +178,22 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
self.buildtime = datetime.datetime.utcnow()
|
||||
super().run()
|
||||
|
||||
# include_files seems to be broken with this setup. implement here
|
||||
# include_files seems to not be done automatically. implement here
|
||||
for src, dst in self.include_files:
|
||||
print('copying', src, '->', self.buildfolder / dst)
|
||||
print(f"copying {src} -> {self.buildfolder / dst}")
|
||||
shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False)
|
||||
|
||||
# now that include_files is completely broken, run find_libs here
|
||||
for src, dst in find_libs(*self.extra_libs):
|
||||
print(f"copying {src} -> {self.buildfolder / dst}")
|
||||
shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False)
|
||||
|
||||
# post build steps
|
||||
if sys.platform == "win32": # kivy_deps is win32 only, linux picks them up automatically
|
||||
if is_windows: # kivy_deps is win32 only, linux picks them up automatically
|
||||
from kivy_deps import sdl2, glew
|
||||
for folder in sdl2.dep_bins + glew.dep_bins:
|
||||
shutil.copytree(folder, self.libfolder, dirs_exist_ok=True)
|
||||
print('copying', folder, '->', self.libfolder)
|
||||
print(f"copying {folder} -> {self.libfolder}")
|
||||
|
||||
for data in self.extra_data:
|
||||
self.installfile(Path(data))
|
||||
@@ -185,11 +208,26 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
from WebHostLib.options import create
|
||||
create()
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
assert not apworlds - set(AutoWorldRegister.world_types), "Unknown world designated for .apworld"
|
||||
folders_to_remove: typing.List[str] = []
|
||||
for worldname, worldtype in AutoWorldRegister.world_types.items():
|
||||
if not worldtype.hidden:
|
||||
file_name = worldname+".yaml"
|
||||
shutil.copyfile(os.path.join("WebHostLib", "static", "generated", "configs", file_name),
|
||||
self.buildfolder / "Players" / "Templates" / file_name)
|
||||
if worldname in apworlds:
|
||||
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
|
||||
world_directory = self.libfolder / "worlds" / file_name
|
||||
# this method creates an apworld that cannot be moved to a different OS or minor python version,
|
||||
# which should be ok
|
||||
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9) as zf:
|
||||
entry: os.DirEntry
|
||||
for path in world_directory.rglob("*.*"):
|
||||
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
|
||||
zf.write(path, relative_path)
|
||||
folders_to_remove.append(file_name)
|
||||
shutil.rmtree(world_directory)
|
||||
shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml")
|
||||
# TODO: fix LttP options one day
|
||||
shutil.copyfile("playerSettings.yaml", self.buildfolder / "Players" / "Templates" / "A Link to the Past.yaml")
|
||||
@@ -218,9 +256,13 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
||||
self.create_manifest()
|
||||
|
||||
if is_windows:
|
||||
# Inno setup stuff
|
||||
with open("setup.ini", "w") as f:
|
||||
min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000"
|
||||
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
|
||||
with open("installdelete.iss", "w") as f:
|
||||
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"
|
||||
for world_directory in folders_to_remove)
|
||||
else:
|
||||
# make sure extra programs are executable
|
||||
enemizer_exe = self.buildfolder / 'EnemizerCLI/EnemizerCLI.Core'
|
||||
@@ -352,6 +394,9 @@ $APPDIR/$exe "$@"
|
||||
|
||||
def find_libs(*args: str) -> typing.Sequence[typing.Tuple[str, str]]:
|
||||
"""Try to find system libraries to be included."""
|
||||
if not args:
|
||||
return []
|
||||
|
||||
arch = build_arch.replace('_', '-')
|
||||
libc = 'libc6' # we currently don't support musl
|
||||
|
||||
@@ -418,12 +463,13 @@ cx_Freeze.setup(
|
||||
"pandas"],
|
||||
"zip_include_packages": ["*"],
|
||||
"zip_exclude_packages": ["worlds", "sc2"],
|
||||
"include_files": find_libs("libssl.so", "libcrypto.so") if is_linux else [],
|
||||
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
|
||||
"include_msvcr": False,
|
||||
"replace_paths": [("*", "")],
|
||||
"replace_paths": ["*."],
|
||||
"optimize": 1,
|
||||
"build_exe": buildfolder,
|
||||
"extra_data": extra_data,
|
||||
"extra_libs": extra_libs,
|
||||
"bin_includes": ["libffi.so", "libcrypt.so"] if is_linux else []
|
||||
},
|
||||
"bdist_appimage": {
|
||||
|
||||
101
test/TestBase.py
101
test/TestBase.py
@@ -1,12 +1,17 @@
|
||||
import typing
|
||||
import unittest
|
||||
import pathlib
|
||||
from argparse import Namespace
|
||||
|
||||
import Utils
|
||||
from test.general import gen_steps
|
||||
from worlds import AutoWorld
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
file_path = pathlib.Path(__file__).parent.parent
|
||||
Utils.local_path.cached_path = file_path
|
||||
|
||||
from BaseClasses import MultiWorld, CollectionState, ItemClassification
|
||||
from BaseClasses import MultiWorld, CollectionState, ItemClassification, Item
|
||||
from worlds.alttp.Items import ItemFactory
|
||||
|
||||
|
||||
@@ -92,3 +97,97 @@ class TestBase(unittest.TestCase):
|
||||
new_items.remove(missing_item)
|
||||
items = ItemFactory(new_items, 1)
|
||||
return self.get_state(items)
|
||||
|
||||
|
||||
class WorldTestBase(unittest.TestCase):
|
||||
options: typing.Dict[str, typing.Any] = {}
|
||||
multiworld: MultiWorld
|
||||
|
||||
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 """
|
||||
|
||||
def setUp(self) -> None:
|
||||
if self.auto_construct:
|
||||
self.world_setup()
|
||||
|
||||
def world_setup(self, seed: typing.Optional[int] = None) -> None:
|
||||
if not hasattr(self, "game"):
|
||||
raise NotImplementedError("didn't define game name")
|
||||
self.multiworld = MultiWorld(1)
|
||||
self.multiworld.game[1] = self.game
|
||||
self.multiworld.player_name = {1: "Tester"}
|
||||
self.multiworld.set_seed(seed)
|
||||
args = Namespace()
|
||||
for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items():
|
||||
setattr(args, name, {
|
||||
1: option.from_any(self.options.get(name, getattr(option, "default")))
|
||||
})
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
for step in gen_steps:
|
||||
call_all(self.multiworld, step)
|
||||
|
||||
def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]]) -> None:
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
for item in self.multiworld.get_items():
|
||||
if item.name not in item_names:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def get_item_by_name(self, item_name: str) -> Item:
|
||||
for item in self.multiworld.get_items():
|
||||
if item.name == item_name:
|
||||
return item
|
||||
raise ValueError("No such item")
|
||||
|
||||
def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
return [item for item in self.multiworld.itempool if item.name in item_names]
|
||||
|
||||
def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
""" collect all of the items in the item pool that have the given names """
|
||||
items = self.get_items_by_name(item_names)
|
||||
self.collect(items)
|
||||
return items
|
||||
|
||||
def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.location.event and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
return self.multiworld.state.can_reach(location, "Location", 1)
|
||||
|
||||
def can_reach_entrance(self, entrance: str) -> bool:
|
||||
return self.multiworld.state.can_reach(entrance, "Entrance", 1)
|
||||
|
||||
def count(self, item_name: str) -> int:
|
||||
return self.multiworld.state.count(item_name, 1)
|
||||
|
||||
def assertAccessDependency(self,
|
||||
locations: typing.List[str],
|
||||
possible_items: typing.Iterable[typing.Iterable[str]]) -> None:
|
||||
all_items = [item_name for item_names in possible_items for item_name in item_names]
|
||||
|
||||
self.collect_all_but(all_items)
|
||||
for location in self.multiworld.get_locations():
|
||||
self.assertEqual(self.multiworld.state.can_reach(location), location.name not in locations)
|
||||
for item_names in possible_items:
|
||||
items = self.collect_by_name(item_names)
|
||||
for location in locations:
|
||||
self.assertTrue(self.can_reach_location(location))
|
||||
self.remove(items)
|
||||
|
||||
def assertBeatable(self, beatable: bool):
|
||||
self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
import warnings
|
||||
warnings.simplefilter("always")
|
||||
|
||||
|
||||
25
test/general/TestHostYAML.py
Normal file
25
test/general/TestHostYAML.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
|
||||
import Utils
|
||||
|
||||
|
||||
class TestIDs(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
with open(Utils.local_path("host.yaml")) as f:
|
||||
cls.yaml_options = Utils.parse_yaml(f.read())
|
||||
|
||||
def testUtilsHasHost(self):
|
||||
for option_key, option_set in Utils.get_default_options().items():
|
||||
with self.subTest(option_key):
|
||||
self.assertIn(option_key, self.yaml_options)
|
||||
for sub_option_key in option_set:
|
||||
self.assertIn(sub_option_key, self.yaml_options[option_key])
|
||||
|
||||
def testHostHasUtils(self):
|
||||
utils_options = Utils.get_default_options()
|
||||
for option_key, option_set in self.yaml_options.items():
|
||||
with self.subTest(option_key):
|
||||
self.assertIn(option_key, utils_options)
|
||||
for sub_option_key in option_set:
|
||||
self.assertIn(sub_option_key, utils_options[option_key])
|
||||
@@ -33,6 +33,14 @@ class TestBase(unittest.TestCase):
|
||||
for item in items:
|
||||
self.assertIn(item, world_type.item_name_to_id)
|
||||
|
||||
def testItemNameGroupConflict(self):
|
||||
"""Test that all item name groups aren't also item names."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game_name, game_name=game_name):
|
||||
for group_name in world_type.item_name_groups:
|
||||
with self.subTest(group_name, group_name=group_name):
|
||||
self.assertNotIn(group_name, world_type.item_name_to_id)
|
||||
|
||||
def testItemCountGreaterEqualLocations(self):
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
|
||||
|
||||
16
test/general/TestLocations.py
Normal file
16
test/general/TestLocations.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import unittest
|
||||
from collections import Counter
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_default_world
|
||||
|
||||
|
||||
class TestBase(unittest.TestCase):
|
||||
def testCreateDuplicateLocations(self):
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if game_name in {"Final Fantasy"}:
|
||||
continue
|
||||
multiworld = setup_default_world(world_type)
|
||||
locations = Counter(multiworld.get_locations())
|
||||
if locations:
|
||||
self.assertLessEqual(locations.most_common(1)[0][1], 1,
|
||||
f"{world_type.game} has duplicate of location {locations.most_common(1)}")
|
||||
@@ -9,14 +9,13 @@ import os
|
||||
import ModuleUpdate
|
||||
ModuleUpdate.update_ran = True # don't upgrade
|
||||
import Generate
|
||||
import Utils
|
||||
|
||||
|
||||
class TestGenerateMain(unittest.TestCase):
|
||||
"""This tests Generate.py (ArchipelagoGenerate.exe) main"""
|
||||
|
||||
generate_dir = Path(Generate.__file__).parent
|
||||
run_dir = generate_dir / 'test' # reproducible cwd that's neither __file__ nor Generate.__file__
|
||||
run_dir = generate_dir / "test" # reproducible cwd that's neither __file__ nor Generate.__file__
|
||||
abs_input_dir = Path(__file__).parent / 'data' / 'OnePlayer'
|
||||
rel_input_dir = abs_input_dir.relative_to(run_dir) # directly supplied relative paths are relative to cwd
|
||||
yaml_input_dir = abs_input_dir.relative_to(generate_dir) # yaml paths are relative to user_path
|
||||
@@ -30,12 +29,29 @@ class TestGenerateMain(unittest.TestCase):
|
||||
f"{list(output_path.glob('*'))}")
|
||||
|
||||
def setUp(self):
|
||||
Utils.local_path.cached_path = str(self.generate_dir)
|
||||
self.original_argv = sys.argv.copy()
|
||||
self.original_cwd = os.getcwd()
|
||||
self.original_local_path = Generate.Utils.local_path.cached_path
|
||||
self.original_user_path = Generate.Utils.user_path.cached_path
|
||||
|
||||
# Force both user_path and local_path to a specific path. They have independent caches.
|
||||
Generate.Utils.user_path.cached_path = Generate.Utils.local_path.cached_path = str(self.generate_dir)
|
||||
os.chdir(self.run_dir)
|
||||
self.output_tempdir = TemporaryDirectory(prefix='AP_out_')
|
||||
|
||||
def tearDown(self):
|
||||
self.output_tempdir.cleanup()
|
||||
os.chdir(self.original_cwd)
|
||||
sys.argv = self.original_argv
|
||||
Generate.Utils.local_path.cached_path = self.original_local_path
|
||||
Generate.Utils.user_path.cached_path = self.original_user_path
|
||||
|
||||
def test_paths(self):
|
||||
self.assertTrue(os.path.exists(self.generate_dir))
|
||||
self.assertTrue(os.path.exists(self.run_dir))
|
||||
self.assertTrue(os.path.exists(self.abs_input_dir))
|
||||
self.assertTrue(os.path.exists(self.rel_input_dir))
|
||||
self.assertFalse(os.path.exists(self.yaml_input_dir)) # relative to user_path, not cwd
|
||||
|
||||
def test_generate_absolute(self):
|
||||
sys.argv = [sys.argv[0], '--seed', '0',
|
||||
@@ -57,7 +73,7 @@ class TestGenerateMain(unittest.TestCase):
|
||||
|
||||
def test_generate_yaml(self):
|
||||
# override host.yaml
|
||||
defaults = Utils.get_options()["generator"]
|
||||
defaults = Generate.Utils.get_options()["generator"]
|
||||
defaults["player_files_path"] = str(self.yaml_input_dir)
|
||||
defaults["players"] = 0
|
||||
|
||||
|
||||
41
test/webhost/TestAPIGenerate.py
Normal file
41
test/webhost/TestAPIGenerate.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import unittest
|
||||
import json
|
||||
|
||||
|
||||
class TestDocs(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
from WebHost import get_app, raw_app
|
||||
raw_app.config["PONY"] = {
|
||||
"provider": "sqlite",
|
||||
"filename": ":memory:",
|
||||
"create_db": True,
|
||||
}
|
||||
raw_app.config.update({
|
||||
"TESTING": True,
|
||||
})
|
||||
app = get_app()
|
||||
|
||||
cls.client = app.test_client()
|
||||
|
||||
def testCorrectErrorEmptyRequest(self):
|
||||
response = self.client.post("/api/generate")
|
||||
self.assertIn("No options found. Expected file attachment or json weights.", response.text)
|
||||
|
||||
def testGenerationQueued(self):
|
||||
options = {
|
||||
"Tester1":
|
||||
{
|
||||
"game": "Archipelago",
|
||||
"name": "Tester",
|
||||
"Archipelago": {}
|
||||
}
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/generate",
|
||||
data=json.dumps({"weights": options}),
|
||||
content_type='application/json'
|
||||
)
|
||||
json_data = response.get_json()
|
||||
self.assertTrue(json_data["text"].startswith("Generation of seed "))
|
||||
self.assertTrue(json_data["text"].endswith(" started successfully."))
|
||||
@@ -7,8 +7,9 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
class TestDocs(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tutorials_data = WebHost.create_ordered_tutorials_file()
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.tutorials_data = WebHost.create_ordered_tutorials_file()
|
||||
|
||||
def testHasTutorial(self):
|
||||
games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data)
|
||||
|
||||
@@ -7,10 +7,11 @@ import WebHost
|
||||
|
||||
|
||||
class TestFileGeneration(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
|
||||
# should not create the folder *here*
|
||||
self.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib")
|
||||
cls.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib")
|
||||
|
||||
def testOptions(self):
|
||||
WebHost.create_options_files()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
def load_tests(loader, standard_tests, pattern):
|
||||
import os
|
||||
import unittest
|
||||
from ..TestBase import file_path
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
suite = unittest.TestSuite()
|
||||
suite.addTests(standard_tests)
|
||||
folders = [os.path.join(os.path.split(world.__file__)[0], "test")
|
||||
for world in AutoWorldRegister.world_types.values()]
|
||||
for folder in folders:
|
||||
if os.path.exists(folder):
|
||||
suite.addTests(loader.discover(folder, top_level_dir=file_path))
|
||||
return suite
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import typing
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from test.general import gen_steps
|
||||
from BaseClasses import MultiWorld, Item
|
||||
from worlds import AutoWorld
|
||||
from worlds.AutoWorld import call_all
|
||||
|
||||
|
||||
class WorldTestBase(unittest.TestCase):
|
||||
options: typing.Dict[str, typing.Any] = {}
|
||||
multiworld: MultiWorld
|
||||
|
||||
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 """
|
||||
|
||||
def setUp(self) -> None:
|
||||
if self.auto_construct:
|
||||
self.world_setup()
|
||||
|
||||
def world_setup(self, seed: typing.Optional[int] = None) -> None:
|
||||
if not hasattr(self, "game"):
|
||||
raise NotImplementedError("didn't define game name")
|
||||
self.multiworld = MultiWorld(1)
|
||||
self.multiworld.game[1] = self.game
|
||||
self.multiworld.player_name = {1: "Tester"}
|
||||
self.multiworld.set_seed(seed)
|
||||
args = Namespace()
|
||||
for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items():
|
||||
setattr(args, name, {
|
||||
1: option.from_any(self.options.get(name, getattr(option, "default")))
|
||||
})
|
||||
self.multiworld.set_options(args)
|
||||
self.multiworld.set_default_common_options()
|
||||
for step in gen_steps:
|
||||
call_all(self.multiworld, step)
|
||||
|
||||
def collect_all_but(self, item_names: typing.Union[str, typing.Iterable[str]]) -> None:
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
for item in self.multiworld.get_items():
|
||||
if item.name not in item_names:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def get_item_by_name(self, item_name: str) -> Item:
|
||||
for item in self.multiworld.get_items():
|
||||
if item.name == item_name:
|
||||
return item
|
||||
raise ValueError("No such item")
|
||||
|
||||
def get_items_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
return [item for item in self.multiworld.itempool if item.name in item_names]
|
||||
|
||||
def collect_by_name(self, item_names: typing.Union[str, typing.Iterable[str]]) -> typing.List[Item]:
|
||||
""" collect all of the items in the item pool that have the given names """
|
||||
items = self.get_items_by_name(item_names)
|
||||
self.collect(items)
|
||||
return items
|
||||
|
||||
def collect(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
self.multiworld.state.collect(item)
|
||||
|
||||
def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
|
||||
if isinstance(items, Item):
|
||||
items = (items,)
|
||||
for item in items:
|
||||
if item.location and item.location.event and item.location in self.multiworld.state.events:
|
||||
self.multiworld.state.events.remove(item.location)
|
||||
self.multiworld.state.remove(item)
|
||||
|
||||
def can_reach_location(self, location: str) -> bool:
|
||||
return self.multiworld.state.can_reach(location, "Location", 1)
|
||||
|
||||
def count(self, item_name: str) -> int:
|
||||
return self.multiworld.state.count(item_name, 1)
|
||||
|
||||
def assertAccessDependency(self,
|
||||
locations: typing.List[str],
|
||||
possible_items: typing.Iterable[typing.Iterable[str]]) -> None:
|
||||
all_items = [item_name for item_names in possible_items for item_name in item_names]
|
||||
|
||||
self.collect_all_but(all_items)
|
||||
for location in self.multiworld.get_locations():
|
||||
self.assertEqual(self.multiworld.state.can_reach(location), location.name not in locations)
|
||||
for item_names in possible_items:
|
||||
items = self.collect_by_name(item_names)
|
||||
for location in locations:
|
||||
self.assertTrue(self.can_reach_location(location))
|
||||
self.remove(items)
|
||||
|
||||
def assertBeatable(self, beatable: bool):
|
||||
self.assertEqual(self.multiworld.can_beat_game(self.multiworld.state), beatable)
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
import sys
|
||||
import pathlib
|
||||
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING
|
||||
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING, \
|
||||
ClassVar
|
||||
|
||||
from Options import AssembleOptions
|
||||
from BaseClasses import CollectionState
|
||||
@@ -71,47 +72,47 @@ class AutoLogicRegister(type):
|
||||
return new_class
|
||||
|
||||
|
||||
def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
|
||||
method = getattr(world.worlds[player], method_name)
|
||||
def call_single(multiworld: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
|
||||
method = getattr(multiworld.worlds[player], method_name)
|
||||
return method(*args)
|
||||
|
||||
|
||||
def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None:
|
||||
def call_all(multiworld: "MultiWorld", method_name: str, *args: Any) -> None:
|
||||
world_types: Set[AutoWorldRegister] = set()
|
||||
for player in world.player_ids:
|
||||
prev_item_count = len(world.itempool)
|
||||
world_types.add(world.worlds[player].__class__)
|
||||
call_single(world, method_name, player, *args)
|
||||
for player in multiworld.player_ids:
|
||||
prev_item_count = len(multiworld.itempool)
|
||||
world_types.add(multiworld.worlds[player].__class__)
|
||||
call_single(multiworld, method_name, player, *args)
|
||||
if __debug__:
|
||||
new_items = world.itempool[prev_item_count:]
|
||||
new_items = multiworld.itempool[prev_item_count:]
|
||||
for i, item in enumerate(new_items):
|
||||
for other in new_items[i+1:]:
|
||||
assert item is not other, (
|
||||
f"Duplicate item reference of \"{item.name}\" in \"{world.worlds[player].game}\" "
|
||||
f"of player \"{world.player_name[player]}\". Please make a copy instead.")
|
||||
f"Duplicate item reference of \"{item.name}\" in \"{multiworld.worlds[player].game}\" "
|
||||
f"of player \"{multiworld.player_name[player]}\". Please make a copy instead.")
|
||||
|
||||
# TODO: investigate: Iterating through a set is not a deterministic order.
|
||||
# If any random is used, this could make unreproducible seed.
|
||||
for world_type in world_types:
|
||||
stage_callable = getattr(world_type, f"stage_{method_name}", None)
|
||||
if stage_callable:
|
||||
stage_callable(world, *args)
|
||||
stage_callable(multiworld, *args)
|
||||
|
||||
|
||||
def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None:
|
||||
world_types = {world.worlds[player].__class__ for player in world.player_ids}
|
||||
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:
|
||||
stage_callable = getattr(world_type, f"stage_{method_name}", None)
|
||||
if stage_callable:
|
||||
stage_callable(world, *args)
|
||||
stage_callable(multiworld, *args)
|
||||
|
||||
|
||||
class WebWorld:
|
||||
"""Webhost integration"""
|
||||
|
||||
|
||||
settings_page: Union[bool, str] = True
|
||||
"""display a settings page. Can be a link to a specific page or external tool."""
|
||||
|
||||
|
||||
game_info_languages: List[str] = ['en']
|
||||
"""docs folder will be scanned for game info pages using this list in the format '{language}_{game_name}.md'"""
|
||||
|
||||
@@ -130,24 +131,24 @@ class World(metaclass=AutoWorldRegister):
|
||||
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
|
||||
A Game should have its own subclass of World in which it defines the required data structures."""
|
||||
|
||||
option_definitions: Dict[str, AssembleOptions] = {} # link your Options mapping
|
||||
game: str # name the game
|
||||
topology_present: bool = False # indicate if world type has any meaningful layout/pathing
|
||||
option_definitions: ClassVar[Dict[str, AssembleOptions]] = {} # link your Options mapping
|
||||
game: ClassVar[str] # name the game
|
||||
topology_present: ClassVar[bool] = False # indicate if world type has any meaningful layout/pathing
|
||||
|
||||
# gets automatically populated with all item and item group names
|
||||
all_item_and_group_names: FrozenSet[str] = frozenset()
|
||||
all_item_and_group_names: ClassVar[FrozenSet[str]] = frozenset()
|
||||
|
||||
# map names to their IDs
|
||||
item_name_to_id: Dict[str, int] = {}
|
||||
location_name_to_id: Dict[str, int] = {}
|
||||
item_name_to_id: ClassVar[Dict[str, int]] = {}
|
||||
location_name_to_id: ClassVar[Dict[str, int]] = {}
|
||||
|
||||
# maps item group names to sets of items. Example: "Weapons" -> {"Sword", "Bow"}
|
||||
item_name_groups: Dict[str, Set[str]] = {}
|
||||
item_name_groups: ClassVar[Dict[str, Set[str]]] = {}
|
||||
|
||||
# increment this every time something in your world's names/id mappings changes.
|
||||
# While this is set to 0 in *any* AutoWorld, the entire DataPackage is considered in testing mode and will be
|
||||
# retrieved by clients on every connection.
|
||||
data_version: int = 1
|
||||
data_version: ClassVar[int] = 1
|
||||
|
||||
# override this if changes to a world break forward-compatibility of the client
|
||||
# The base version of (0, 1, 6) is provided for backwards compatibility and does *not* need to be updated in the
|
||||
@@ -157,46 +158,30 @@ class World(metaclass=AutoWorldRegister):
|
||||
# update this if the resulting multidata breaks forward-compatibility of the server
|
||||
required_server_version: Tuple[int, int, int] = (0, 2, 4)
|
||||
|
||||
hint_blacklist: FrozenSet[str] = frozenset() # any names that should not be hintable
|
||||
|
||||
# NOTE: remote_items and remote_start_inventory are now available in the network protocol for the client to set.
|
||||
# These values will be removed.
|
||||
# if a world is set to remote_items, then it just needs to send location checks to the server and the server
|
||||
# sends back the items
|
||||
# if a world is set to remote_items = False, then the server never sends an item where receiver == finder,
|
||||
# the client finds its own items in its own world.
|
||||
remote_items: bool = True
|
||||
|
||||
# If remote_start_inventory is true, the start_inventory/world.precollected_items is sent on connection,
|
||||
# otherwise the world implementation is in charge of writing the items to their output data.
|
||||
remote_start_inventory: bool = True
|
||||
|
||||
# For games where after a victory it is impossible to go back in and get additional/remaining Locations checked.
|
||||
# this forces forfeit: auto for those games.
|
||||
forced_auto_forfeit: bool = False
|
||||
hint_blacklist: ClassVar[FrozenSet[str]] = frozenset() # any names that should not be hintable
|
||||
|
||||
# Hide World Type from various views. Does not remove functionality.
|
||||
hidden: bool = False
|
||||
hidden: ClassVar[bool] = False
|
||||
|
||||
# see WebWorld for options
|
||||
web: WebWorld = WebWorld()
|
||||
web: ClassVar[WebWorld] = WebWorld()
|
||||
|
||||
# autoset on creation:
|
||||
multiworld: "MultiWorld"
|
||||
player: int
|
||||
|
||||
# automatically generated
|
||||
item_id_to_name: Dict[int, str]
|
||||
location_id_to_name: Dict[int, str]
|
||||
item_id_to_name: ClassVar[Dict[int, str]]
|
||||
location_id_to_name: ClassVar[Dict[int, str]]
|
||||
|
||||
item_names: Set[str] # set of all potential item names
|
||||
location_names: Set[str] # set of all potential location names
|
||||
item_names: ClassVar[Set[str]] # set of all potential item names
|
||||
location_names: ClassVar[Set[str]] # set of all potential location names
|
||||
|
||||
zip_path: Optional[pathlib.Path] = None # If loaded from a .apworld, this is the Path to it.
|
||||
__file__: str # path it was loaded from
|
||||
zip_path: ClassVar[Optional[pathlib.Path]] = None # If loaded from a .apworld, this is the Path to it.
|
||||
__file__: ClassVar[str] # path it was loaded from
|
||||
|
||||
def __init__(self, world: "MultiWorld", player: int):
|
||||
self.multiworld = world
|
||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||
self.multiworld = multiworld
|
||||
self.player = player
|
||||
|
||||
# overridable methods that get called by Main.py, sorted by execution order
|
||||
@@ -250,7 +235,10 @@ class World(metaclass=AutoWorldRegister):
|
||||
def fill_slot_data(self) -> Dict[str, Any]: # json of WebHostLib.models.Slot
|
||||
"""Fill in the `slot_data` field in the `Connected` network package.
|
||||
This is a way the generator can give custom data to the client.
|
||||
The client will receive this as JSON in the `Connected` response."""
|
||||
The client will receive this as JSON in the `Connected` response.
|
||||
|
||||
The generation does not wait for `generate_output` to complete before calling this.
|
||||
`threading.Event` can be used if you need to wait for something from `generate_output`."""
|
||||
return {}
|
||||
|
||||
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]):
|
||||
|
||||
@@ -20,6 +20,17 @@ if typing.TYPE_CHECKING:
|
||||
from .AutoWorld import World
|
||||
|
||||
|
||||
class GamesPackage(typing.TypedDict):
|
||||
item_name_to_id: typing.Dict[str, int]
|
||||
location_name_to_id: typing.Dict[str, int]
|
||||
version: int
|
||||
|
||||
|
||||
class DataPackage(typing.TypedDict):
|
||||
version: int
|
||||
games: typing.Dict[str, GamesPackage]
|
||||
|
||||
|
||||
class WorldSource(typing.NamedTuple):
|
||||
path: str # typically relative path from this module
|
||||
is_zip: bool = False
|
||||
@@ -41,20 +52,26 @@ world_sources.sort()
|
||||
for world_source in world_sources:
|
||||
if world_source.is_zip:
|
||||
importer = zipimport.zipimporter(os.path.join(folder, world_source.path))
|
||||
spec = importer.find_spec(world_source.path.split(".", 1)[0])
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
if hasattr(importer, "find_spec"): # new in Python 3.10
|
||||
spec = importer.find_spec(world_source.path.split(".", 1)[0])
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
else: # TODO: remove with 3.8 support
|
||||
mod = importer.load_module(world_source.path.split(".", 1)[0])
|
||||
|
||||
mod.__package__ = f"worlds.{mod.__package__}"
|
||||
mod.__name__ = f"worlds.{mod.__name__}"
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
|
||||
importer.exec_module(mod)
|
||||
# Found no equivalent for < 3.10
|
||||
if hasattr(importer, "exec_module"):
|
||||
importer.exec_module(mod)
|
||||
else:
|
||||
importlib.import_module(f".{world_source.path}", "worlds")
|
||||
|
||||
lookup_any_item_id_to_name = {}
|
||||
lookup_any_location_id_to_name = {}
|
||||
games = {}
|
||||
games: typing.Dict[str, GamesPackage] = {}
|
||||
|
||||
from .AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -69,14 +86,12 @@ for world_name, world in AutoWorldRegister.world_types.items():
|
||||
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 = {
|
||||
"version": sum(world.data_version for world in AutoWorldRegister.world_types.values()),
|
||||
network_data_package: DataPackage = {
|
||||
"games": games,
|
||||
}
|
||||
|
||||
# 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()):
|
||||
network_data_package["version"] = 0
|
||||
import logging
|
||||
|
||||
logging.warning(f"Datapackage is in custom mode. Custom Worlds: "
|
||||
|
||||
@@ -322,7 +322,7 @@ location_table_misc = {'Bottle Merchant': (0x3c9, 0x2),
|
||||
location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()}
|
||||
|
||||
|
||||
async def track_locations(ctx, roomid, roomdata):
|
||||
async def track_locations(ctx, roomid, roomdata) -> bool:
|
||||
from SNIClient import snes_read, snes_buffered_write, snes_flush_writes
|
||||
new_locations = []
|
||||
|
||||
@@ -451,10 +451,126 @@ async def track_locations(ctx, roomid, roomdata):
|
||||
if misc_data_changed:
|
||||
snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data))
|
||||
|
||||
|
||||
if new_locations:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
# verify rom is still the same:
|
||||
rom_name = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom_name is None or all(byte == b"\x00" for byte in rom_name) or rom_name[:2] != b"AP" or \
|
||||
rom_name != ctx.rom:
|
||||
snes_logger.info(f"Discarding recent {len(new_locations)} checks as ROM Status has changed.")
|
||||
return False
|
||||
else:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
await snes_flush_writes(ctx)
|
||||
return True
|
||||
|
||||
|
||||
class ALTTPSNIClient(SNIClient):
|
||||
game = "A Link to the Past"
|
||||
|
||||
async def deathlink_kill_player(self, ctx):
|
||||
from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes
|
||||
invincible = await snes_read(ctx, WRAM_START + 0x037B, 1)
|
||||
last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
|
||||
await asyncio.sleep(0.25)
|
||||
health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
|
||||
if not invincible or not last_health or not health:
|
||||
ctx.death_state = DeathState.dead
|
||||
ctx.last_death_link = time.time()
|
||||
return
|
||||
if not invincible[0] and last_health[0] == health[0]:
|
||||
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373,
|
||||
bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
if not gamemode or gamemode[0] in DEATH_MODES:
|
||||
ctx.death_state = DeathState.dead
|
||||
|
||||
async def validate_rom(self, ctx) -> bool:
|
||||
from SNIClient import snes_read
|
||||
|
||||
rom_name = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom_name is None or all(byte == b"\x00" for byte in rom_name) or rom_name[:2] != b"AP":
|
||||
return False
|
||||
|
||||
ctx.game = self.game
|
||||
ctx.items_handling = 0b001 # full local
|
||||
|
||||
ctx.rom = rom_name
|
||||
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
|
||||
if death_link:
|
||||
ctx.allow_collect = bool(death_link[0] & 0b100)
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
|
||||
return True
|
||||
|
||||
async def game_watcher(self, ctx):
|
||||
from SNIClient import snes_read, snes_buffered_write, snes_flush_writes
|
||||
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)
|
||||
|
||||
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
|
||||
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
|
||||
if gamemode is None or gameend is None or game_timer is None or \
|
||||
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
|
||||
return
|
||||
|
||||
if gameend[0]:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if gamemode in ENDGAME_MODES: # triforce room and credits
|
||||
return
|
||||
|
||||
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
|
||||
if data is None:
|
||||
return
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2]
|
||||
roomid = data[4] | (data[5] << 8)
|
||||
roomdata = data[6]
|
||||
scout_location = data[7]
|
||||
|
||||
if recv_index < len(ctx.items_received) and recv_item == 0:
|
||||
item = ctx.items_received[recv_index]
|
||||
recv_index += 1
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_names[item.item], 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_ADDR,
|
||||
bytes([item.item]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0]))
|
||||
if scout_location > 0 and scout_location in ctx.locations_info:
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
|
||||
bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
|
||||
bytes([ctx.locations_info[scout_location].item]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)]))
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
if scout_location > 0 and scout_location not in ctx.locations_scouted:
|
||||
ctx.locations_scouted.add(scout_location)
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||
same_rom = await track_locations(ctx, roomid, roomdata)
|
||||
if not same_rom:
|
||||
return
|
||||
|
||||
|
||||
def get_alttp_settings(romfile: str):
|
||||
@@ -582,112 +698,3 @@ def get_alttp_settings(romfile: str):
|
||||
else:
|
||||
adjusted = False
|
||||
return adjustedromfile, adjusted
|
||||
|
||||
|
||||
class ALTTPSNIClient(SNIClient):
|
||||
game = "A Link to the Past"
|
||||
|
||||
async def deathlink_kill_player(self, ctx):
|
||||
from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes
|
||||
invincible = await snes_read(ctx, WRAM_START + 0x037B, 1)
|
||||
last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
|
||||
await asyncio.sleep(0.25)
|
||||
health = await snes_read(ctx, WRAM_START + 0xF36D, 1)
|
||||
if not invincible or not last_health or not health:
|
||||
ctx.death_state = DeathState.dead
|
||||
ctx.last_death_link = time.time()
|
||||
return
|
||||
if not invincible[0] and last_health[0] == health[0]:
|
||||
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
|
||||
snes_buffered_write(ctx, WRAM_START + 0x0373,
|
||||
bytes([8])) # deal 1 full heart of damage at next opportunity
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
if not gamemode or gamemode[0] in DEATH_MODES:
|
||||
ctx.death_state = DeathState.dead
|
||||
|
||||
|
||||
async def validate_rom(self, ctx):
|
||||
from SNIClient import snes_read, snes_buffered_write, snes_flush_writes
|
||||
|
||||
rom_name = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE)
|
||||
if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"AP":
|
||||
return False
|
||||
|
||||
ctx.game = self.game
|
||||
ctx.items_handling = 0b001 # full local
|
||||
|
||||
ctx.rom = rom_name
|
||||
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
|
||||
if death_link:
|
||||
ctx.allow_collect = bool(death_link[0] & 0b100)
|
||||
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
|
||||
await ctx.update_death_link(bool(death_link[0] & 0b1))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def game_watcher(self, ctx):
|
||||
from SNIClient import snes_read, snes_buffered_write, snes_flush_writes
|
||||
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)
|
||||
|
||||
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
|
||||
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
|
||||
if gamemode is None or gameend is None or game_timer is None or \
|
||||
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
|
||||
return
|
||||
|
||||
if gameend[0]:
|
||||
if not ctx.finished_game:
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
if gamemode in ENDGAME_MODES: # triforce room and credits
|
||||
return
|
||||
|
||||
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
|
||||
if data is None:
|
||||
return
|
||||
|
||||
recv_index = data[0] | (data[1] << 8)
|
||||
recv_item = data[2]
|
||||
roomid = data[4] | (data[5] << 8)
|
||||
roomdata = data[6]
|
||||
scout_location = data[7]
|
||||
|
||||
if recv_index < len(ctx.items_received) and recv_item == 0:
|
||||
item = ctx.items_received[recv_index]
|
||||
recv_index += 1
|
||||
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
|
||||
color(ctx.item_names[item.item], 'red', 'bold'),
|
||||
color(ctx.player_names[item.player], 'yellow'),
|
||||
ctx.location_names[item.location], recv_index, len(ctx.items_received)))
|
||||
|
||||
snes_buffered_write(ctx, RECV_PROGRESS_ADDR,
|
||||
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_ADDR,
|
||||
bytes([item.item]))
|
||||
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0]))
|
||||
if scout_location > 0 and scout_location in ctx.locations_info:
|
||||
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
|
||||
bytes([scout_location]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
|
||||
bytes([ctx.locations_info[scout_location].item]))
|
||||
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
|
||||
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)]))
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
if scout_location > 0 and scout_location not in ctx.locations_scouted:
|
||||
ctx.locations_scouted.add(scout_location)
|
||||
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
|
||||
await track_locations(ctx, roomid, roomdata)
|
||||
|
||||
@@ -118,6 +118,10 @@ def get_dungeon_item_pool_player(world, player) -> typing.List:
|
||||
return [item for dungeon in world.dungeons.values() if dungeon.player == player for item in dungeon.all_items]
|
||||
|
||||
|
||||
def get_unfilled_dungeon_locations(multiworld) -> typing.List:
|
||||
return [location for location in multiworld.get_locations() if not location.item and location.parent_region.dungeon]
|
||||
|
||||
|
||||
def fill_dungeons_restrictive(world):
|
||||
"""Places dungeon-native items into their dungeons, places nothing if everything is shuffled outside."""
|
||||
localized: set = set()
|
||||
@@ -134,7 +138,7 @@ def fill_dungeons_restrictive(world):
|
||||
if in_dungeon_items:
|
||||
restricted_players = {player for player, restricted in world.restrict_dungeon_item_on_boss.items() if
|
||||
restricted}
|
||||
locations = [location for location in world.get_unfilled_dungeon_locations()
|
||||
locations = [location for location in get_unfilled_dungeon_locations(world)
|
||||
# filter boss
|
||||
if not (location.player in restricted_players and location.name in lookup_boss_drops)]
|
||||
if dungeon_specific:
|
||||
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
|
||||
from BaseClasses import Region, RegionType, ItemClassification
|
||||
from worlds.alttp.SubClasses import ALttPLocation
|
||||
from worlds.alttp.Shops import TakeAny, total_shop_slots, set_up_shops, shuffle_shops
|
||||
from worlds.alttp.Shops import TakeAny, total_shop_slots, set_up_shops, shuffle_shops, create_dynamic_shop_locations
|
||||
from worlds.alttp.Bosses import place_bosses
|
||||
from worlds.alttp.Dungeons import get_dungeon_item_pool_player
|
||||
from worlds.alttp.EntranceShuffle import connect_entrance
|
||||
@@ -436,12 +436,13 @@ def generate_itempool(world):
|
||||
|
||||
if world.shop_shuffle[player]:
|
||||
shuffle_shops(world, nonprogressionitems, player)
|
||||
create_dynamic_shop_locations(world, player)
|
||||
|
||||
world.itempool += progressionitems + nonprogressionitems
|
||||
|
||||
if world.retro_caves[player]:
|
||||
set_up_take_anys(world, player) # depends on world.itempool to be set
|
||||
# set_up_take_anys needs to run first
|
||||
create_dynamic_shop_locations(world, player)
|
||||
|
||||
|
||||
take_any_locations = {
|
||||
@@ -487,7 +488,7 @@ def set_up_take_anys(world, player):
|
||||
world.itempool.append(ItemFactory('Rupees (20)', player))
|
||||
old_man_take_any.shop.add_inventory(0, sword.name, 0, 0, create_location=True)
|
||||
else:
|
||||
old_man_take_any.shop.add_inventory(0, 'Rupees (300)', 0, 0)
|
||||
old_man_take_any.shop.add_inventory(0, 'Rupees (300)', 0, 0, create_location=True)
|
||||
|
||||
for num in range(4):
|
||||
take_any = Region("Take-Any #{}".format(num+1), RegionType.Cave, 'a cave of choice', player)
|
||||
@@ -501,29 +502,11 @@ def set_up_take_anys(world, player):
|
||||
take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1)
|
||||
world.shops.append(take_any.shop)
|
||||
take_any.shop.add_inventory(0, 'Blue Potion', 0, 0)
|
||||
take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0)
|
||||
take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0, create_location=True)
|
||||
|
||||
world.initialize_regions()
|
||||
|
||||
|
||||
def create_dynamic_shop_locations(world, player):
|
||||
for shop in world.shops:
|
||||
if shop.region.player == player:
|
||||
for i, item in enumerate(shop.inventory):
|
||||
if item is None:
|
||||
continue
|
||||
if item['create_location']:
|
||||
loc = ALttPLocation(player, f"{shop.region.name} {shop.slot_names[i]}", parent=shop.region)
|
||||
shop.region.locations.append(loc)
|
||||
|
||||
world.clear_location_cache()
|
||||
|
||||
world.push_item(loc, ItemFactory(item['item'], player), False)
|
||||
loc.shop_slot = i
|
||||
loc.event = True
|
||||
loc.locked = True
|
||||
|
||||
|
||||
def get_pool_core(world, player: int):
|
||||
shuffle = world.shuffle[player]
|
||||
difficulty = world.difficulty[player]
|
||||
|
||||
@@ -20,7 +20,7 @@ def GetBeemizerItem(world, player: int, item):
|
||||
|
||||
|
||||
# should be replaced with direct world.create_item(item) call in the future
|
||||
def ItemFactory(items, player: int):
|
||||
def ItemFactory(items: typing.Union[str, typing.Iterable[str]], player: int):
|
||||
from worlds.alttp import ALTTPWorld
|
||||
world = ALTTPWorld(None, player)
|
||||
ret = []
|
||||
|
||||
@@ -39,8 +39,8 @@ class OpenPyramid(Choice):
|
||||
option_auto = 3
|
||||
default = option_goal
|
||||
|
||||
alias_yes = option_open
|
||||
alias_no = option_closed
|
||||
alias_true = option_open
|
||||
alias_false = option_closed
|
||||
|
||||
def to_bool(self, world: MultiWorld, player: int) -> bool:
|
||||
if self.value == self.option_goal:
|
||||
|
||||
@@ -686,142 +686,296 @@ lookup_name_to_id = {name: data[0] for name, data in location_table.items() if t
|
||||
lookup_name_to_id = {**lookup_name_to_id, **{name: data[1] for name, data in key_drop_data.items()}}
|
||||
lookup_name_to_id.update(shop_table_by_location)
|
||||
|
||||
lookup_vanilla_location_to_entrance = {1572883: 'Kings Grave Inner Rocks', 191256: 'Kings Grave Inner Rocks',
|
||||
1573194: 'Kings Grave Inner Rocks', 1573189: 'Kings Grave Inner Rocks',
|
||||
212328: 'Kings Grave Inner Rocks', 60175: 'Blinds Hideout',
|
||||
60178: 'Blinds Hideout', 60181: 'Blinds Hideout', 60184: 'Blinds Hideout',
|
||||
60187: 'Blinds Hideout', 188229: 'Hyrule Castle Secret Entrance Drop',
|
||||
59761: 'Hyrule Castle Secret Entrance Drop', 975299: 'Zoras River',
|
||||
1573193: 'Zoras River', 59824: 'Waterfall of Wishing',
|
||||
59857: 'Waterfall of Wishing', 59770: 'Kings Grave', 59788: 'Dam',
|
||||
59836: 'Links House', 59854: 'Tavern North', 59881: 'Chicken House',
|
||||
59890: 'Aginahs Cave', 60034: 'Sahasrahlas Hut', 60037: 'Sahasrahlas Hut',
|
||||
60040: 'Sahasrahlas Hut', 193020: 'Sahasrahlas Hut',
|
||||
60046: 'Kakariko Well Drop', 60049: 'Kakariko Well Drop',
|
||||
60052: 'Kakariko Well Drop', 60055: 'Kakariko Well Drop',
|
||||
60058: 'Kakariko Well Drop', 1572906: 'Blacksmiths Hut',
|
||||
1572885: 'Bat Cave Drop', 211407: 'Sick Kids House',
|
||||
212605: 'Hobo Bridge', 1572864: 'Lost Woods Hideout Drop',
|
||||
1572865: 'Lumberjack Tree Tree', 1572867: 'Cave 45',
|
||||
1572868: 'Graveyard Cave', 1572869: 'Checkerboard Cave',
|
||||
60226: 'Mini Moldorm Cave', 60229: 'Mini Moldorm Cave',
|
||||
60232: 'Mini Moldorm Cave', 60235: 'Mini Moldorm Cave',
|
||||
1572880: 'Mini Moldorm Cave', 60238: 'Ice Rod Cave',
|
||||
60223: 'Bonk Rock Cave', 1572882: 'Library', 1572884: 'Potion Shop',
|
||||
1573188: 'Lake Hylia Island Mirror Spot',
|
||||
1573186: 'Maze Race Mirror Spot', 1573187: 'Desert Ledge Return Rocks',
|
||||
59791: 'Desert Palace Entrance (West)',
|
||||
1573216: 'Desert Palace Entrance (West)',
|
||||
59830: 'Desert Palace Entrance (West)',
|
||||
59851: 'Desert Palace Entrance (West)',
|
||||
59842: 'Desert Palace Entrance (West)',
|
||||
1573201: 'Desert Palace Entrance (North)', 59767: 'Eastern Palace',
|
||||
59773: 'Eastern Palace', 59827: 'Eastern Palace', 59833: 'Eastern Palace',
|
||||
59893: 'Eastern Palace', 1573200: 'Eastern Palace',
|
||||
166320: 'Master Sword Meadow', 59764: 'Hyrule Castle Entrance (South)',
|
||||
60172: 'Hyrule Castle Entrance (South)',
|
||||
60169: 'Hyrule Castle Entrance (South)',
|
||||
59758: 'Hyrule Castle Entrance (South)',
|
||||
60253: 'Hyrule Castle Entrance (South)',
|
||||
60256: 'Hyrule Castle Entrance (South)',
|
||||
60259: 'Hyrule Castle Entrance (South)', 60025: 'Sanctuary S&Q',
|
||||
60085: 'Agahnims Tower', 60082: 'Agahnims Tower',
|
||||
1010170: 'Old Man Cave (West)', 1572866: 'Spectacle Rock Cave',
|
||||
60202: 'Paradox Cave (Bottom)', 60205: 'Paradox Cave (Bottom)',
|
||||
60208: 'Paradox Cave (Bottom)', 60211: 'Paradox Cave (Bottom)',
|
||||
60214: 'Paradox Cave (Bottom)', 60217: 'Paradox Cave (Bottom)',
|
||||
60220: 'Paradox Cave (Bottom)', 59839: 'Spiral Cave',
|
||||
1572886: 'Death Mountain (Top)', 1573184: 'Spectacle Rock Mirror Spot',
|
||||
1573218: 'Tower of Hera', 59821: 'Tower of Hera', 59878: 'Tower of Hera',
|
||||
59899: 'Tower of Hera', 59896: 'Tower of Hera', 1573202: 'Tower of Hera',
|
||||
1573191: 'Top of Pyramid', 975237: 'Catfish Entrance Rock',
|
||||
209095: 'South Dark World Bridge', 1573192: 'South Dark World Bridge',
|
||||
1572887: 'Bombos Tablet Mirror Spot', 60190: 'Hype Cave',
|
||||
60193: 'Hype Cave', 60196: 'Hype Cave', 60199: 'Hype Cave',
|
||||
1572881: 'Hype Cave', 1572870: 'Dark World Hammer Peg Cave',
|
||||
59776: 'Pyramid Fairy', 59779: 'Pyramid Fairy', 59884: 'Brewery',
|
||||
59887: 'C-Shaped House', 60840: 'Chest Game',
|
||||
1573190: 'Bumper Cave (Bottom)', 60019: 'Mire Shed', 60022: 'Mire Shed',
|
||||
60028: 'Superbunny Cave (Top)', 60031: 'Superbunny Cave (Top)',
|
||||
60043: 'Spike Cave', 60241: 'Hookshot Cave', 60244: 'Hookshot Cave',
|
||||
60250: 'Hookshot Cave', 60247: 'Hookshot Cave',
|
||||
1573185: 'Floating Island Mirror Spot', 59845: 'Mimic Cave',
|
||||
60061: 'Swamp Palace', 59782: 'Swamp Palace', 59785: 'Swamp Palace',
|
||||
60064: 'Swamp Palace', 60070: 'Swamp Palace', 60067: 'Swamp Palace',
|
||||
60073: 'Swamp Palace', 60076: 'Swamp Palace', 60079: 'Swamp Palace',
|
||||
1573204: 'Swamp Palace', 59908: 'Thieves Town', 59905: 'Thieves Town',
|
||||
59911: 'Thieves Town', 59914: 'Thieves Town', 59917: 'Thieves Town',
|
||||
59920: 'Thieves Town', 59923: 'Thieves Town', 1573206: 'Thieves Town',
|
||||
59803: 'Skull Woods First Section Door',
|
||||
59848: 'Skull Woods First Section Hole (East)',
|
||||
59794: 'Skull Woods First Section Hole (West)',
|
||||
59809: 'Skull Woods First Section Hole (West)',
|
||||
59800: 'Skull Woods First Section Hole (North)',
|
||||
59806: 'Skull Woods Second Section Door (East)',
|
||||
59902: 'Skull Woods Final Section', 1573205: 'Skull Woods Final Section',
|
||||
59860: 'Ice Palace', 59797: 'Ice Palace', 59818: 'Ice Palace',
|
||||
59875: 'Ice Palace', 59872: 'Ice Palace', 59812: 'Ice Palace',
|
||||
59869: 'Ice Palace', 1573207: 'Ice Palace', 60007: 'Misery Mire',
|
||||
60010: 'Misery Mire', 59998: 'Misery Mire', 60001: 'Misery Mire',
|
||||
59866: 'Misery Mire', 60004: 'Misery Mire', 60013: 'Misery Mire',
|
||||
1573208: 'Misery Mire', 59938: 'Turtle Rock', 59932: 'Turtle Rock',
|
||||
59935: 'Turtle Rock', 59926: 'Turtle Rock',
|
||||
59941: 'Dark Death Mountain Ledge (West)',
|
||||
59929: 'Dark Death Mountain Ledge (East)',
|
||||
59956: 'Dark Death Mountain Ledge (West)',
|
||||
59953: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59950: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59947: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59944: 'Turtle Rock Isolated Ledge Entrance',
|
||||
1573209: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59995: 'Palace of Darkness', 59965: 'Palace of Darkness',
|
||||
59977: 'Palace of Darkness', 59959: 'Palace of Darkness',
|
||||
59962: 'Palace of Darkness', 59986: 'Palace of Darkness',
|
||||
59971: 'Palace of Darkness', 59980: 'Palace of Darkness',
|
||||
59983: 'Palace of Darkness', 59989: 'Palace of Darkness',
|
||||
59992: 'Palace of Darkness', 59968: 'Palace of Darkness',
|
||||
59974: 'Palace of Darkness', 1573203: 'Palace of Darkness',
|
||||
1573217: 'Ganons Tower', 60121: 'Ganons Tower', 60124: 'Ganons Tower',
|
||||
60130: 'Ganons Tower', 60133: 'Ganons Tower', 60136: 'Ganons Tower',
|
||||
60139: 'Ganons Tower', 60142: 'Ganons Tower', 60088: 'Ganons Tower',
|
||||
60091: 'Ganons Tower', 60094: 'Ganons Tower', 60097: 'Ganons Tower',
|
||||
60115: 'Ganons Tower', 60112: 'Ganons Tower', 60100: 'Ganons Tower',
|
||||
60103: 'Ganons Tower', 60106: 'Ganons Tower', 60109: 'Ganons Tower',
|
||||
60127: 'Ganons Tower', 60118: 'Ganons Tower', 60148: 'Ganons Tower',
|
||||
60151: 'Ganons Tower', 60145: 'Ganons Tower', 60157: 'Ganons Tower',
|
||||
60160: 'Ganons Tower', 60163: 'Ganons Tower', 60166: 'Ganons Tower',
|
||||
0x140037: 'Hyrule Castle Entrance (South)',
|
||||
0x140034: 'Hyrule Castle Entrance (South)',
|
||||
0x14000d: 'Hyrule Castle Entrance (South)',
|
||||
0x14003d: 'Hyrule Castle Entrance (South)',
|
||||
0x14005b: 'Eastern Palace', 0x140049: 'Eastern Palace',
|
||||
0x140031: 'Desert Palace Entrance (North)',
|
||||
0x14002b: 'Desert Palace Entrance (North)',
|
||||
0x140028: 'Desert Palace Entrance (North)',
|
||||
0x140061: 'Agahnims Tower', 0x140052: 'Agahnims Tower',
|
||||
0x140019: 'Swamp Palace', 0x140016: 'Swamp Palace', 0x140013: 'Swamp Palace',
|
||||
0x140010: 'Swamp Palace', 0x14000a: 'Swamp Palace',
|
||||
0x14002e: 'Skull Woods Second Section Door (East)',
|
||||
0x14001c: 'Skull Woods Final Section',
|
||||
0x14005e: 'Thieves Town', 0x14004f: 'Thieves Town',
|
||||
0x140004: 'Ice Palace', 0x140022: 'Ice Palace',
|
||||
0x140025: 'Ice Palace', 0x140046: 'Ice Palace',
|
||||
0x140055: 'Misery Mire', 0x14004c: 'Misery Mire',
|
||||
0x140064: 'Misery Mire',
|
||||
0x140058: 'Turtle Rock', 0x140007: 'Dark Death Mountain Ledge (West)',
|
||||
0x140040: 'Ganons Tower', 0x140043: 'Ganons Tower',
|
||||
0x14003a: 'Ganons Tower', 0x14001f: 'Ganons Tower',
|
||||
0x400000: 'Cave Shop (Dark Death Mountain)', 0x400001: 'Cave Shop (Dark Death Mountain)', 0x400002: 'Cave Shop (Dark Death Mountain)',
|
||||
0x400003: 'Red Shield Shop', 0x400004: 'Red Shield Shop', 0x400005: 'Red Shield Shop',
|
||||
0x400006: 'Dark Lake Hylia Shop', 0x400007: 'Dark Lake Hylia Shop', 0x400008: 'Dark Lake Hylia Shop',
|
||||
0x400009: 'Dark World Lumberjack Shop', 0x40000a: 'Dark World Lumberjack Shop', 0x40000b: 'Dark World Lumberjack Shop',
|
||||
0x40000c: 'Village of Outcasts Shop', 0x40000d: 'Village of Outcasts Shop', 0x40000e: 'Village of Outcasts Shop',
|
||||
0x40000f: 'Dark World Potion Shop', 0x400010: 'Dark World Potion Shop', 0x400011: 'Dark World Potion Shop',
|
||||
0x400012: 'Light World Death Mountain Shop', 0x400013: 'Light World Death Mountain Shop', 0x400014: 'Light World Death Mountain Shop',
|
||||
0x400015: 'Kakariko Shop', 0x400016: 'Kakariko Shop', 0x400017: 'Kakariko Shop',
|
||||
0x400018: 'Cave Shop (Lake Hylia)', 0x400019: 'Cave Shop (Lake Hylia)', 0x40001a: 'Cave Shop (Lake Hylia)',
|
||||
0x40001b: 'Potion Shop', 0x40001c: 'Potion Shop', 0x40001d: 'Potion Shop',
|
||||
0x40001e: 'Capacity Upgrade', 0x40001f: 'Capacity Upgrade', 0x400020: 'Capacity Upgrade'}
|
||||
lookup_vanilla_location_to_entrance = {
|
||||
59758: 'Hyrule Castle Entrance (South)',
|
||||
59761: 'Hyrule Castle Secret Entrance Drop',
|
||||
59764: 'Hyrule Castle Entrance (South)',
|
||||
59767: 'Eastern Palace',
|
||||
59770: 'Kings Grave',
|
||||
59773: 'Eastern Palace',
|
||||
59776: 'Pyramid Fairy',
|
||||
59779: 'Pyramid Fairy',
|
||||
59782: 'Swamp Palace',
|
||||
59785: 'Swamp Palace',
|
||||
59788: 'Dam',
|
||||
59791: 'Desert Palace Entrance (West)',
|
||||
59794: 'Skull Woods First Section Hole (West)',
|
||||
59797: 'Ice Palace',
|
||||
59800: 'Skull Woods First Section Hole (North)',
|
||||
59803: 'Skull Woods First Section Door',
|
||||
59806: 'Skull Woods Second Section Door (East)',
|
||||
59809: 'Skull Woods First Section Hole (West)',
|
||||
59812: 'Ice Palace',
|
||||
59818: 'Ice Palace',
|
||||
59821: 'Tower of Hera',
|
||||
59824: 'Waterfall of Wishing',
|
||||
59827: 'Eastern Palace',
|
||||
59830: 'Desert Palace Entrance (West)',
|
||||
59833: 'Eastern Palace',
|
||||
59836: 'Links House',
|
||||
59839: 'Spiral Cave',
|
||||
59842: 'Desert Palace Entrance (West)',
|
||||
59845: 'Mimic Cave',
|
||||
59848: 'Skull Woods First Section Hole (East)',
|
||||
59851: 'Desert Palace Entrance (West)',
|
||||
59854: 'Tavern North',
|
||||
59857: 'Waterfall of Wishing',
|
||||
59860: 'Ice Palace',
|
||||
59866: 'Misery Mire',
|
||||
59869: 'Ice Palace',
|
||||
59872: 'Ice Palace',
|
||||
59875: 'Ice Palace',
|
||||
59878: 'Tower of Hera',
|
||||
59881: 'Chicken House',
|
||||
59884: 'Brewery',
|
||||
59887: 'C-Shaped House',
|
||||
59890: 'Aginahs Cave',
|
||||
59893: 'Eastern Palace',
|
||||
59896: 'Tower of Hera',
|
||||
59899: 'Tower of Hera',
|
||||
59902: 'Skull Woods Final Section',
|
||||
59905: 'Thieves Town',
|
||||
59908: 'Thieves Town',
|
||||
59911: 'Thieves Town',
|
||||
59914: 'Thieves Town',
|
||||
59917: 'Thieves Town',
|
||||
59920: 'Thieves Town',
|
||||
59923: 'Thieves Town',
|
||||
59926: 'Turtle Rock',
|
||||
59929: 'Dark Death Mountain Ledge (East)',
|
||||
59932: 'Turtle Rock',
|
||||
59935: 'Turtle Rock',
|
||||
59938: 'Turtle Rock',
|
||||
59941: 'Dark Death Mountain Ledge (West)',
|
||||
59944: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59947: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59950: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59953: 'Turtle Rock Isolated Ledge Entrance',
|
||||
59956: 'Dark Death Mountain Ledge (West)',
|
||||
59959: 'Palace of Darkness',
|
||||
59962: 'Palace of Darkness',
|
||||
59965: 'Palace of Darkness',
|
||||
59968: 'Palace of Darkness',
|
||||
59971: 'Palace of Darkness',
|
||||
59974: 'Palace of Darkness',
|
||||
59977: 'Palace of Darkness',
|
||||
59980: 'Palace of Darkness',
|
||||
59983: 'Palace of Darkness',
|
||||
59986: 'Palace of Darkness',
|
||||
59989: 'Palace of Darkness',
|
||||
59992: 'Palace of Darkness',
|
||||
59995: 'Palace of Darkness',
|
||||
59998: 'Misery Mire',
|
||||
60001: 'Misery Mire',
|
||||
60004: 'Misery Mire',
|
||||
60007: 'Misery Mire',
|
||||
60010: 'Misery Mire',
|
||||
60013: 'Misery Mire',
|
||||
60019: 'Mire Shed',
|
||||
60022: 'Mire Shed',
|
||||
60025: 'Sanctuary S&Q',
|
||||
60028: 'Superbunny Cave (Top)',
|
||||
60031: 'Superbunny Cave (Top)',
|
||||
60034: 'Sahasrahlas Hut',
|
||||
60037: 'Sahasrahlas Hut',
|
||||
60040: 'Sahasrahlas Hut',
|
||||
60043: 'Spike Cave',
|
||||
60046: 'Kakariko Well Drop',
|
||||
60049: 'Kakariko Well Drop',
|
||||
60052: 'Kakariko Well Drop',
|
||||
60055: 'Kakariko Well Drop',
|
||||
60058: 'Kakariko Well Drop',
|
||||
60061: 'Swamp Palace',
|
||||
60064: 'Swamp Palace',
|
||||
60067: 'Swamp Palace',
|
||||
60070: 'Swamp Palace',
|
||||
60073: 'Swamp Palace',
|
||||
60076: 'Swamp Palace',
|
||||
60079: 'Swamp Palace',
|
||||
60082: 'Agahnims Tower',
|
||||
60085: 'Agahnims Tower',
|
||||
60088: 'Ganons Tower',
|
||||
60091: 'Ganons Tower',
|
||||
60094: 'Ganons Tower',
|
||||
60097: 'Ganons Tower',
|
||||
60100: 'Ganons Tower',
|
||||
60103: 'Ganons Tower',
|
||||
60106: 'Ganons Tower',
|
||||
60109: 'Ganons Tower',
|
||||
60112: 'Ganons Tower',
|
||||
60115: 'Ganons Tower',
|
||||
60118: 'Ganons Tower',
|
||||
60121: 'Ganons Tower',
|
||||
60124: 'Ganons Tower',
|
||||
60127: 'Ganons Tower',
|
||||
60130: 'Ganons Tower',
|
||||
60133: 'Ganons Tower',
|
||||
60136: 'Ganons Tower',
|
||||
60139: 'Ganons Tower',
|
||||
60142: 'Ganons Tower',
|
||||
60145: 'Ganons Tower',
|
||||
60148: 'Ganons Tower',
|
||||
60151: 'Ganons Tower',
|
||||
60157: 'Ganons Tower',
|
||||
60160: 'Ganons Tower',
|
||||
60163: 'Ganons Tower',
|
||||
60166: 'Ganons Tower',
|
||||
60169: 'Hyrule Castle Entrance (South)',
|
||||
60172: 'Hyrule Castle Entrance (South)',
|
||||
60175: 'Blinds Hideout',
|
||||
60178: 'Blinds Hideout',
|
||||
60181: 'Blinds Hideout',
|
||||
60184: 'Blinds Hideout',
|
||||
60187: 'Blinds Hideout',
|
||||
60190: 'Hype Cave',
|
||||
60193: 'Hype Cave',
|
||||
60196: 'Hype Cave',
|
||||
60199: 'Hype Cave',
|
||||
60202: 'Paradox Cave (Bottom)',
|
||||
60205: 'Paradox Cave (Bottom)',
|
||||
60208: 'Paradox Cave (Bottom)',
|
||||
60211: 'Paradox Cave (Bottom)',
|
||||
60214: 'Paradox Cave (Bottom)',
|
||||
60217: 'Paradox Cave (Bottom)',
|
||||
60220: 'Paradox Cave (Bottom)',
|
||||
60223: 'Bonk Rock Cave',
|
||||
60226: 'Mini Moldorm Cave',
|
||||
60229: 'Mini Moldorm Cave',
|
||||
60232: 'Mini Moldorm Cave',
|
||||
60235: 'Mini Moldorm Cave',
|
||||
60238: 'Ice Rod Cave',
|
||||
60241: 'Hookshot Cave',
|
||||
60244: 'Hookshot Cave',
|
||||
60247: 'Hookshot Cave',
|
||||
60250: 'Hookshot Cave',
|
||||
60253: 'Hyrule Castle Entrance (South)',
|
||||
60256: 'Hyrule Castle Entrance (South)',
|
||||
60259: 'Hyrule Castle Entrance (South)',
|
||||
60840: 'Chest Game',
|
||||
166320: 'Master Sword Meadow',
|
||||
188229: 'Hyrule Castle Secret Entrance Drop',
|
||||
191256: 'Kings Grave Inner Rocks',
|
||||
193020: 'Sahasrahlas Hut',
|
||||
209095: 'South Dark World Bridge',
|
||||
211407: 'Sick Kids House',
|
||||
212328: 'Kings Grave Inner Rocks',
|
||||
212605: 'Hobo Bridge',
|
||||
975237: 'Catfish Entrance Rock',
|
||||
975299: 'Zoras River',
|
||||
1010170: 'Old Man Cave (West)',
|
||||
1310724: 'Ice Palace',
|
||||
1310727: 'Dark Death Mountain Ledge (West)',
|
||||
1310730: 'Swamp Palace',
|
||||
1310733: 'Hyrule Castle Entrance (South)',
|
||||
1310736: 'Swamp Palace',
|
||||
1310739: 'Swamp Palace',
|
||||
1310742: 'Swamp Palace',
|
||||
1310745: 'Swamp Palace',
|
||||
1310748: 'Skull Woods Final Section',
|
||||
1310751: 'Ganons Tower',
|
||||
1310754: 'Ice Palace',
|
||||
1310757: 'Ice Palace',
|
||||
1310760: 'Desert Palace Entrance (North)',
|
||||
1310763: 'Desert Palace Entrance (North)',
|
||||
1310766: 'Skull Woods Second Section Door (East)',
|
||||
1310769: 'Desert Palace Entrance (North)',
|
||||
1310772: 'Hyrule Castle Entrance (South)',
|
||||
1310775: 'Hyrule Castle Entrance (South)',
|
||||
1310778: 'Ganons Tower',
|
||||
1310781: 'Hyrule Castle Entrance (South)',
|
||||
1310784: 'Ganons Tower',
|
||||
1310787: 'Ganons Tower',
|
||||
1310790: 'Ice Palace',
|
||||
1310793: 'Eastern Palace',
|
||||
1310796: 'Misery Mire',
|
||||
1310799: 'Thieves Town',
|
||||
1310802: 'Agahnims Tower',
|
||||
1310805: 'Misery Mire',
|
||||
1310808: 'Turtle Rock',
|
||||
1310811: 'Eastern Palace',
|
||||
1310814: 'Thieves Town',
|
||||
1310817: 'Agahnims Tower',
|
||||
1310820: 'Misery Mire',
|
||||
1572864: 'Lost Woods Hideout Drop',
|
||||
1572865: 'Lumberjack Tree Tree',
|
||||
1572866: 'Spectacle Rock Cave',
|
||||
1572867: 'Cave 45',
|
||||
1572868: 'Graveyard Cave',
|
||||
1572869: 'Checkerboard Cave',
|
||||
1572870: 'Dark World Hammer Peg Cave',
|
||||
1572880: 'Mini Moldorm Cave',
|
||||
1572881: 'Hype Cave',
|
||||
1572882: 'Library',
|
||||
1572883: 'Kings Grave Inner Rocks',
|
||||
1572884: 'Potion Shop',
|
||||
1572885: 'Bat Cave Drop',
|
||||
1572886: 'Death Mountain (Top)',
|
||||
1572887: 'Bombos Tablet Mirror Spot',
|
||||
1572906: 'Blacksmiths Hut',
|
||||
1573184: 'Spectacle Rock Mirror Spot',
|
||||
1573185: 'Floating Island Mirror Spot',
|
||||
1573186: 'Maze Race Mirror Spot',
|
||||
1573187: 'Desert Ledge Return Rocks',
|
||||
1573188: 'Lake Hylia Island Mirror Spot',
|
||||
1573189: 'Kings Grave Inner Rocks',
|
||||
1573190: 'Bumper Cave (Bottom)',
|
||||
1573191: 'Top of Pyramid',
|
||||
1573192: 'South Dark World Bridge',
|
||||
1573193: 'Zoras River',
|
||||
1573194: 'Kings Grave Inner Rocks',
|
||||
1573200: 'Eastern Palace',
|
||||
1573201: 'Desert Palace Entrance (North)',
|
||||
1573202: 'Tower of Hera',
|
||||
1573203: 'Palace of Darkness',
|
||||
1573204: 'Swamp Palace',
|
||||
1573205: 'Skull Woods Final Section',
|
||||
1573206: 'Thieves Town',
|
||||
1573207: 'Ice Palace',
|
||||
1573208: 'Misery Mire',
|
||||
1573209: 'Turtle Rock Isolated Ledge Entrance',
|
||||
1573216: 'Desert Palace Entrance (West)',
|
||||
1573217: 'Ganons Tower',
|
||||
1573218: 'Tower of Hera',
|
||||
4194304: 'Cave Shop (Dark Death Mountain)',
|
||||
4194305: 'Cave Shop (Dark Death Mountain)',
|
||||
4194306: 'Cave Shop (Dark Death Mountain)',
|
||||
4194307: 'Red Shield Shop',
|
||||
4194308: 'Red Shield Shop',
|
||||
4194309: 'Red Shield Shop',
|
||||
4194310: 'Dark Lake Hylia Shop',
|
||||
4194311: 'Dark Lake Hylia Shop',
|
||||
4194312: 'Dark Lake Hylia Shop',
|
||||
4194313: 'Dark World Lumberjack Shop',
|
||||
4194314: 'Dark World Lumberjack Shop',
|
||||
4194315: 'Dark World Lumberjack Shop',
|
||||
4194316: 'Village of Outcasts Shop',
|
||||
4194317: 'Village of Outcasts Shop',
|
||||
4194318: 'Village of Outcasts Shop',
|
||||
4194319: 'Dark World Potion Shop',
|
||||
4194320: 'Dark World Potion Shop',
|
||||
4194321: 'Dark World Potion Shop',
|
||||
4194322: 'Light World Death Mountain Shop',
|
||||
4194323: 'Light World Death Mountain Shop',
|
||||
4194324: 'Light World Death Mountain Shop',
|
||||
4194325: 'Kakariko Shop',
|
||||
4194326: 'Kakariko Shop',
|
||||
4194327: 'Kakariko Shop',
|
||||
4194328: 'Cave Shop (Lake Hylia)',
|
||||
4194329: 'Cave Shop (Lake Hylia)',
|
||||
4194330: 'Cave Shop (Lake Hylia)',
|
||||
4194331: 'Potion Shop',
|
||||
4194332: 'Potion Shop',
|
||||
4194333: 'Potion Shop',
|
||||
4194334: 'Capacity Upgrade',
|
||||
4194335: 'Capacity Upgrade',
|
||||
4194336: 'Capacity Upgrade',
|
||||
# have no vanilla entrance
|
||||
4194337: "Old Man Sword Cave",
|
||||
4194338: "Take-Any #1",
|
||||
4194339: "Take-Any #2",
|
||||
4194340: "Take-Any #3",
|
||||
4194341: "Take-Any #4",
|
||||
}
|
||||
|
||||
lookup_prizes = {location for location in location_table if location.endswith(" - Prize")}
|
||||
lookup_boss_drops = {location for location in location_table if location.endswith(" - Boss")}
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import Utils
|
||||
import worlds.AutoWorld
|
||||
import worlds.Files
|
||||
|
||||
LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
|
||||
@@ -17,7 +16,6 @@ import random
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import xxtea
|
||||
import concurrent.futures
|
||||
import bsdiff4
|
||||
from typing import Optional, List
|
||||
@@ -39,7 +37,6 @@ from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to
|
||||
from worlds.alttp.Items import ItemFactory, item_table, item_name_groups, progression_items
|
||||
from worlds.alttp.EntranceShuffle import door_addresses
|
||||
from worlds.alttp.Options import smallkey_shuffle
|
||||
import Patch
|
||||
|
||||
try:
|
||||
from maseya import z3pr
|
||||
@@ -47,6 +44,11 @@ try:
|
||||
except:
|
||||
z3pr = None
|
||||
|
||||
try:
|
||||
import xxtea
|
||||
except:
|
||||
xxtea = None
|
||||
|
||||
enemizer_logger = logging.getLogger("Enemizer")
|
||||
|
||||
|
||||
@@ -85,6 +87,11 @@ class LocalRom(object):
|
||||
self.write_bytes(startaddress + i, bytearray(data))
|
||||
|
||||
def encrypt(self, world, player):
|
||||
global xxtea
|
||||
if xxtea is None:
|
||||
# cause crash to provide traceback
|
||||
import xxtea
|
||||
|
||||
local_random = world.slot_seeds[player]
|
||||
key = bytes(local_random.getrandbits(8 * 16).to_bytes(16, 'big'))
|
||||
self.write_bytes(0x1800B0, bytearray(key))
|
||||
@@ -788,11 +795,11 @@ def patch_rom(world, rom, player, enemized):
|
||||
itemid = 0x33
|
||||
elif location.item.compass:
|
||||
itemid = 0x25
|
||||
if world.worlds[player].remote_items: # remote items does not currently work
|
||||
itemid = list(location_table.keys()).index(location.name) + 1
|
||||
assert itemid < 0x100
|
||||
rom.write_byte(location.player_address, 0xFF)
|
||||
elif location.item.player != player:
|
||||
# if world.worlds[player].remote_items: # remote items does not currently work
|
||||
# itemid = list(location_table.keys()).index(location.name) + 1
|
||||
# assert itemid < 0x100
|
||||
# rom.write_byte(location.player_address, 0xFF)
|
||||
if location.item.player != player:
|
||||
if location.player_address is not None:
|
||||
rom.write_byte(location.player_address, min(location.item.player, ROM_PLAYER_LIMIT))
|
||||
else:
|
||||
@@ -1647,7 +1654,7 @@ def patch_rom(world, rom, player, enemized):
|
||||
write_strings(rom, world, player)
|
||||
|
||||
# remote items flag, does not currently work
|
||||
rom.write_byte(0x18637C, int(world.worlds[player].remote_items))
|
||||
rom.write_byte(0x18637C, 0)
|
||||
|
||||
# set rom name
|
||||
# 21 bytes
|
||||
@@ -2297,7 +2304,7 @@ def write_strings(rom, world, player):
|
||||
'dungeonscrossed'] else 8
|
||||
hint_count = min(hint_count, len(items_to_hint), len(hint_locations))
|
||||
if hint_count:
|
||||
locations = world.find_items_in_locations(items_to_hint, player)
|
||||
locations = world.find_items_in_locations(items_to_hint, player, True)
|
||||
local_random.shuffle(locations)
|
||||
for x in range(min(hint_count, len(locations))):
|
||||
this_location = locations.pop()
|
||||
@@ -2314,7 +2321,7 @@ def write_strings(rom, world, player):
|
||||
|
||||
# We still need the older hints of course. Those are done here.
|
||||
|
||||
silverarrows = world.find_item_locations('Silver Bow', player)
|
||||
silverarrows = world.find_item_locations('Silver Bow', player, True)
|
||||
local_random.shuffle(silverarrows)
|
||||
silverarrow_hint = (
|
||||
' %s?' % hint_text(silverarrows[0]).replace('Ganon\'s', 'my')) if silverarrows else '?\nI think not!'
|
||||
@@ -2322,13 +2329,13 @@ def write_strings(rom, world, player):
|
||||
tt['ganon_phase_3_no_silvers_alt'] = 'Did you find the silver arrows%s' % silverarrow_hint
|
||||
if world.worlds[player].has_progressive_bows and (world.difficulty_requirements[player].progressive_bow_limit >= 2 or (
|
||||
world.swordless[player] or world.logic[player] == 'noglitches')):
|
||||
prog_bow_locs = world.find_item_locations('Progressive Bow', player)
|
||||
prog_bow_locs = world.find_item_locations('Progressive Bow', player, True)
|
||||
world.slot_seeds[player].shuffle(prog_bow_locs)
|
||||
found_bow = False
|
||||
found_bow_alt = False
|
||||
while prog_bow_locs and not (found_bow and found_bow_alt):
|
||||
bow_loc = prog_bow_locs.pop()
|
||||
if bow_loc.item.code == 0x65:
|
||||
if bow_loc.item.code == 0x65 or (found_bow and not prog_bow_locs):
|
||||
found_bow_alt = True
|
||||
target = 'ganon_phase_3_no_silvers'
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import collections
|
||||
import logging
|
||||
from typing import Iterator, Set
|
||||
|
||||
from worlds.alttp import OverworldGlitchRules
|
||||
from BaseClasses import RegionType, MultiWorld, Entrance
|
||||
from worlds.alttp.Items import ItemFactory, progression_items, item_name_groups
|
||||
from worlds.alttp.Items import ItemFactory, progression_items, item_name_groups, item_table
|
||||
from worlds.alttp.OverworldGlitchRules import overworld_glitches_rules, no_logic_rules
|
||||
from worlds.alttp.Regions import location_table
|
||||
from worlds.alttp.UnderworldGlitchRules import underworld_glitches_rules
|
||||
from worlds.alttp.Bosses import GanonDefeatRule
|
||||
from worlds.generic.Rules import set_rule, add_rule, forbid_item, add_item_rule, item_in_locations, \
|
||||
@@ -176,6 +179,14 @@ def dungeon_boss_rules(world, player):
|
||||
def global_rules(world, player):
|
||||
# ganon can only carry triforce
|
||||
add_item_rule(world.get_location('Ganon', player), lambda item: item.name == 'Triforce' and item.player == player)
|
||||
# dungeon prizes can only be crystals/pendants
|
||||
crystals_and_pendants: Set[str] = \
|
||||
{item for item, item_data in item_table.items() if item_data.type == "Crystal"}
|
||||
prize_locations: Iterator[str] = \
|
||||
(locations for locations, location_data in location_table.items() if location_data[2] == True)
|
||||
for prize_location in prize_locations:
|
||||
add_item_rule(world.get_location(prize_location, player),
|
||||
lambda item: item.name in crystals_and_pendants and item.player == player)
|
||||
# determines which S&Q locations are available - hide from paths since it isn't an in-game location
|
||||
world.get_region('Menu', player).can_reach_private = lambda state: True
|
||||
for exit in world.get_region('Menu', player).exits:
|
||||
@@ -515,7 +526,7 @@ def default_rules(world, player):
|
||||
set_rule(world.get_entrance('Floating Island Mirror Spot', player), lambda state: state.has('Magic Mirror', player))
|
||||
set_rule(world.get_entrance('Turtle Rock', player), lambda state: state.has('Moon Pearl', player) and state.has_sword(player) and state.has_turtle_rock_medallion(player) and state.can_reach('Turtle Rock (Top)', 'Region', player)) # sword required to cast magic (!)
|
||||
|
||||
set_rule(world.get_entrance('Pyramid Hole', player), lambda state: state.has('Beat Agahnim 2', player) or world.open_pyramid[player])
|
||||
set_rule(world.get_entrance('Pyramid Hole', player), lambda state: state.has('Beat Agahnim 2', player) or world.open_pyramid[player].to_bool(world, player))
|
||||
|
||||
if world.swordless[player]:
|
||||
swordless_rules(world, player)
|
||||
|
||||
@@ -39,9 +39,9 @@ class Shop():
|
||||
blacklist: Set[str] = set() # items that don't work, todo: actually check against this
|
||||
type = ShopType.Shop
|
||||
slot_names: Dict[int, str] = {
|
||||
0: "Left",
|
||||
1: "Center",
|
||||
2: "Right"
|
||||
0: " Left",
|
||||
1: " Center",
|
||||
2: " Right"
|
||||
}
|
||||
|
||||
def __init__(self, region, room_id: int, shopkeeper_config: int, custom: bool, locked: bool, sram_offset: int):
|
||||
@@ -142,7 +142,11 @@ class Shop():
|
||||
|
||||
class TakeAny(Shop):
|
||||
type = ShopType.TakeAny
|
||||
|
||||
slot_names: Dict[int, str] = {
|
||||
0: "",
|
||||
1: "",
|
||||
2: ""
|
||||
}
|
||||
|
||||
class UpgradeShop(Shop):
|
||||
type = ShopType.UpgradeShop
|
||||
@@ -168,8 +172,10 @@ def FillDisabledShopSlots(world):
|
||||
|
||||
|
||||
def ShopSlotFill(world):
|
||||
shop_slots: Set[ALttPLocation] = {location for shop_locations in (shop.region.locations for shop in world.shops)
|
||||
shop_slots: Set[ALttPLocation] = {location for shop_locations in
|
||||
(shop.region.locations for shop in world.shops if shop.type != ShopType.TakeAny)
|
||||
for location in shop_locations if location.shop_slot is not None}
|
||||
|
||||
removed = set()
|
||||
for location in shop_slots:
|
||||
shop: Shop = location.parent_region.shop
|
||||
@@ -318,7 +324,7 @@ def create_shops(world, player: int):
|
||||
for index, item in enumerate(inventory):
|
||||
shop.add_inventory(index, *item)
|
||||
if not locked and num_slots:
|
||||
slot_name = f"{region.name} {shop.slot_names[index]}"
|
||||
slot_name = f"{region.name}{shop.slot_names[index]}"
|
||||
loc = ALttPLocation(player, slot_name, address=shop_table_by_location[slot_name],
|
||||
parent=region, hint_text="for sale")
|
||||
loc.shop_slot = index
|
||||
@@ -376,7 +382,7 @@ total_dynamic_shop_slots = sum(3 for shopname, data in shop_table.items() if not
|
||||
|
||||
SHOP_ID_START = 0x400000
|
||||
shop_table_by_location_id = dict(enumerate(
|
||||
(f"{name} {Shop.slot_names[num]}" for name, shop_data in
|
||||
(f"{name}{Shop.slot_names[num]}" for name, shop_data in
|
||||
sorted(shop_table.items(), key=lambda item: item[1].sram_offset)
|
||||
for num in range(3)), start=SHOP_ID_START))
|
||||
|
||||
@@ -591,3 +597,22 @@ def price_to_funny_price(world, item: dict, player: int):
|
||||
item['price'] = min(price_chart[p_type](item['price']), 255)
|
||||
item['price_type'] = p_type
|
||||
break
|
||||
|
||||
|
||||
def create_dynamic_shop_locations(world, player):
|
||||
for shop in world.shops:
|
||||
if shop.region.player == player:
|
||||
for i, item in enumerate(shop.inventory):
|
||||
if item is None:
|
||||
continue
|
||||
if item['create_location']:
|
||||
slot_name = f"{shop.region.name}{shop.slot_names[i]}"
|
||||
loc = ALttPLocation(player, slot_name,
|
||||
address=shop_table_by_location[slot_name], parent=shop.region)
|
||||
loc.place_locked_item(ItemFactory(item['item'], player))
|
||||
if shop.type == ShopType.TakeAny:
|
||||
loc.shop_slot_disabled = True
|
||||
shop.region.locations.append(loc)
|
||||
world.clear_location_cache()
|
||||
|
||||
loc.shop_slot = i
|
||||
|
||||
@@ -121,8 +121,6 @@ class ALTTPWorld(World):
|
||||
location_name_to_id = lookup_name_to_id
|
||||
|
||||
data_version = 8
|
||||
remote_items: bool = False
|
||||
remote_start_inventory: bool = False
|
||||
required_client_version = (0, 3, 2)
|
||||
web = ALTTPWeb()
|
||||
|
||||
@@ -157,6 +155,8 @@ class ALTTPWorld(World):
|
||||
rom_file = get_base_rom_path()
|
||||
if not os.path.exists(rom_file):
|
||||
raise FileNotFoundError(rom_file)
|
||||
if world.is_race:
|
||||
import xxtea
|
||||
|
||||
def generate_early(self):
|
||||
if self.use_enemizer():
|
||||
@@ -193,6 +193,14 @@ class ALTTPWorld(World):
|
||||
|
||||
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
|
||||
|
||||
# enforce pre-defined local items.
|
||||
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
|
||||
world.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']
|
||||
|
||||
def create_regions(self):
|
||||
player = self.player
|
||||
world = self.multiworld
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestAgahnimsTower(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestDarkPalace(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestDesertPalace(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestEasternPalace(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestGanonsTower(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestIcePalace(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestMiseryMire(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestSkullWoods(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestSwampPalace(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestThievesTown(TestDungeon):
|
||||
@@ -1,4 +1,4 @@
|
||||
from test.dungeons.TestDungeon import TestDungeon
|
||||
from .TestDungeon import TestDungeon
|
||||
|
||||
|
||||
class TestTowerOfHera(TestDungeon):
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user