mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 07:23:28 -07:00
Compare commits
182 Commits
NewSoupVi-
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930f627794 | ||
|
|
d4fc90410c | ||
|
|
484c5f2671 | ||
|
|
04771fa4f0 | ||
|
|
2639796255 | ||
|
|
4ebabc1208 | ||
|
|
ce34b60712 | ||
|
|
54094c6331 | ||
|
|
3986f6f11a | ||
|
|
5662da6f7d | ||
|
|
33a75fb2cb | ||
|
|
ee9bcb84b7 | ||
|
|
b5269e9aa4 | ||
|
|
00a6ac3a52 | ||
|
|
ea8a14b003 | ||
|
|
414ab86422 | ||
|
|
d4e2698ae0 | ||
|
|
3f8e3082c0 | ||
|
|
0f738935ee | ||
|
|
9c57976252 | ||
|
|
3e08acf381 | ||
|
|
113259bc15 | ||
|
|
61afe76eae | ||
|
|
08b3b3ecf5 | ||
|
|
bc61221ec6 | ||
|
|
2f0b81e12c | ||
|
|
bb9a6bcd2e | ||
|
|
c8b7ef1016 | ||
|
|
e00467c2a2 | ||
|
|
0eb6150e95 | ||
|
|
91d977479d | ||
|
|
cd761db170 | ||
|
|
026011323e | ||
|
|
adc5f3a07d | ||
|
|
69940374e1 | ||
|
|
6dc461609b | ||
|
|
58d460678e | ||
|
|
0f7fd48cdd | ||
|
|
18de035b4d | ||
|
|
11fa43f0a4 | ||
|
|
91a8fc91d6 | ||
|
|
15bde56551 | ||
|
|
d744e086ef | ||
|
|
378fa5d5c4 | ||
|
|
8349774c5c | ||
|
|
34795b598a | ||
|
|
efd5004330 | ||
|
|
c799531105 | ||
|
|
5c1ded1fe9 | ||
|
|
b2162bb8e6 | ||
|
|
f1769a8d00 | ||
|
|
f520c1d9f2 | ||
|
|
910369a7f8 | ||
|
|
dbf6b6f935 | ||
|
|
e9c463c897 | ||
|
|
f4e43ca9e0 | ||
|
|
a298be9c41 | ||
|
|
18bcaa85a2 | ||
|
|
359f45d50f | ||
|
|
f5c574c37a | ||
|
|
f75a1ae117 | ||
|
|
768ccffe72 | ||
|
|
f6668997e6 | ||
|
|
db11c620a7 | ||
|
|
da48af60dc | ||
|
|
19faaa4104 | ||
|
|
628252896e | ||
|
|
f28aff6f9a | ||
|
|
894732be47 | ||
|
|
051518e72a | ||
|
|
b7b78dead3 | ||
|
|
d1167027f4 | ||
|
|
445c9b22d6 | ||
|
|
67e8877143 | ||
|
|
1fe8024b43 | ||
|
|
8e14e463e4 | ||
|
|
b8666b2562 | ||
|
|
57afdfda6f | ||
|
|
738c21c625 | ||
|
|
41898ed640 | ||
|
|
1ebc9e2ec0 | ||
|
|
9466d5274e | ||
|
|
a53bcb4697 | ||
|
|
8c5592e406 | ||
|
|
41055cd963 | ||
|
|
43874b1d28 | ||
|
|
b570aa2ec6 | ||
|
|
c43233120a | ||
|
|
57a571cc11 | ||
|
|
8622cb6204 | ||
|
|
90417e0022 | ||
|
|
96b941ed35 | ||
|
|
1832bac1a3 | ||
|
|
86641223c1 | ||
|
|
cc770418f2 | ||
|
|
513e361764 | ||
|
|
ddf7fdccc7 | ||
|
|
3df2dbe051 | ||
|
|
3d1d6908c8 | ||
|
|
7474c27372 | ||
|
|
bb0948154d | ||
|
|
fa2816822b | ||
|
|
5a42c70675 | ||
|
|
949527f9cb | ||
|
|
1a1b7e9cf4 | ||
|
|
edacb17171 | ||
|
|
33fd9de281 | ||
|
|
a126dee068 | ||
|
|
e2b942139a | ||
|
|
823b17c386 | ||
|
|
05d1b2129a | ||
|
|
436c0a4104 | ||
|
|
96f469c737 | ||
|
|
4f77abac4f | ||
|
|
d5cd95c7fb | ||
|
|
a2fbf856ff | ||
|
|
4fa8c43266 | ||
|
|
992841a951 | ||
|
|
eb3c3d6bf2 | ||
|
|
39847c5502 | ||
|
|
130232b457 | ||
|
|
ca8ffe583d | ||
|
|
563794ab83 | ||
|
|
9443861849 | ||
|
|
cbf4bbbca8 | ||
|
|
9e353ebb8e | ||
|
|
9183e8f9c9 | ||
|
|
0bb657d2c8 | ||
|
|
992f192529 | ||
|
|
1c9409cac9 | ||
|
|
005a143e3e | ||
|
|
8732974857 | ||
|
|
1ac8349bd4 | ||
|
|
2b9fa89050 | ||
|
|
23ea3c0efc | ||
|
|
698d27aada | ||
|
|
3a46c9fd3e | ||
|
|
9507300939 | ||
|
|
0d6db291de | ||
|
|
d218dec826 | ||
|
|
3d5c277c31 | ||
|
|
a9435dc6bb | ||
|
|
8f307c226b | ||
|
|
4b8f990960 | ||
|
|
3a5a4b89ee | ||
|
|
1485882642 | ||
|
|
2e4f5a64b3 | ||
|
|
90f80ce1c1 | ||
|
|
78904151b0 | ||
|
|
9d4bd6eebd | ||
|
|
5c56dc0357 | ||
|
|
c7810823e8 | ||
|
|
902d03d447 | ||
|
|
b7621a0923 | ||
|
|
b7baaed391 | ||
|
|
9dac7d9cc3 | ||
|
|
1eefe23f11 | ||
|
|
207a76d1b5 | ||
|
|
01df35f215 | ||
|
|
bedf746f1d | ||
|
|
b91a7ac6fb | ||
|
|
79e6beeec3 | ||
|
|
dae9d4c575 | ||
|
|
04928bd83d | ||
|
|
0f3818e711 | ||
|
|
0f1dc6e19c | ||
|
|
ffd0c8b341 | ||
|
|
6220963195 | ||
|
|
20119e3162 | ||
|
|
4cb8fa3cdd | ||
|
|
93e8613da7 | ||
|
|
f9cc19e150 | ||
|
|
0f1c119c76 | ||
|
|
4c734b467f | ||
|
|
1f966ee705 | ||
|
|
172ad4e57d | ||
|
|
3f935aac13 | ||
|
|
9928639ce2 | ||
|
|
0fc722cb28 | ||
|
|
4edca0ce54 | ||
|
|
70942eda8c | ||
|
|
adcb2f59ca |
16
.github/pyright-config.json
vendored
16
.github/pyright-config.json
vendored
@@ -1,8 +1,20 @@
|
|||||||
{
|
{
|
||||||
"include": [
|
"include": [
|
||||||
"type_check.py",
|
"../BizHawkClient.py",
|
||||||
|
"../Patch.py",
|
||||||
|
"../test/general/test_groups.py",
|
||||||
|
"../test/general/test_helpers.py",
|
||||||
|
"../test/general/test_memory.py",
|
||||||
|
"../test/general/test_names.py",
|
||||||
|
"../test/multiworld/__init__.py",
|
||||||
|
"../test/multiworld/test_multiworlds.py",
|
||||||
|
"../test/netutils/__init__.py",
|
||||||
|
"../test/programs/__init__.py",
|
||||||
|
"../test/programs/test_multi_server.py",
|
||||||
|
"../test/utils/__init__.py",
|
||||||
|
"../test/webhost/test_descriptions.py",
|
||||||
"../worlds/AutoSNIClient.py",
|
"../worlds/AutoSNIClient.py",
|
||||||
"../Patch.py"
|
"type_check.py"
|
||||||
],
|
],
|
||||||
|
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|||||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -132,7 +132,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
|
|||||||
4
.github/workflows/ctest.yml
vendored
4
.github/workflows/ctest.yml
vendored
@@ -11,7 +11,7 @@ on:
|
|||||||
- '**.hh?'
|
- '**.hh?'
|
||||||
- '**.hpp'
|
- '**.hpp'
|
||||||
- '**.hxx'
|
- '**.hxx'
|
||||||
- '**.CMakeLists'
|
- '**/CMakeLists.txt'
|
||||||
- '.github/workflows/ctest.yml'
|
- '.github/workflows/ctest.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@@ -21,7 +21,7 @@ on:
|
|||||||
- '**.hh?'
|
- '**.hh?'
|
||||||
- '**.hpp'
|
- '**.hpp'
|
||||||
- '**.hxx'
|
- '**.hxx'
|
||||||
- '**.CMakeLists'
|
- '**/CMakeLists.txt'
|
||||||
- '.github/workflows/ctest.yml'
|
- '.github/workflows/ctest.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
|||||||
# charset-normalizer was somehow incomplete in the github runner
|
# charset-normalizer was somehow incomplete in the github runner
|
||||||
"${{ env.PYTHON }}" -m venv venv
|
"${{ env.PYTHON }}" -m venv venv
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
|
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
|
||||||
python setup.py build_exe --yes bdist_appimage --yes
|
python setup.py build_exe --yes bdist_appimage --yes
|
||||||
echo -e "setup.py build output:\n `ls build`"
|
echo -e "setup.py build output:\n `ls build`"
|
||||||
echo -e "setup.py dist output:\n `ls dist`"
|
echo -e "setup.py dist output:\n `ls dist`"
|
||||||
|
|||||||
2
.github/workflows/strict-type-check.yml
vendored
2
.github/workflows/strict-type-check.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
|
|
||||||
- name: "Install dependencies"
|
- name: "Install dependencies"
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip pyright==1.1.358
|
python -m pip install --upgrade pip pyright==1.1.392.post0
|
||||||
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
|
||||||
|
|
||||||
- name: "pyright: strict check on specific files"
|
- name: "pyright: strict check on specific files"
|
||||||
|
|||||||
@@ -869,21 +869,40 @@ class CollectionState():
|
|||||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||||
return self.prog_items[player][item] >= count
|
return self.prog_items[player][item] >= count
|
||||||
|
|
||||||
|
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
|
||||||
|
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
|
||||||
|
# argument to all() would be a new generator instance, for example.
|
||||||
def has_all(self, items: Iterable[str], player: int) -> bool:
|
def has_all(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if each item name of items is in state at least once."""
|
"""Returns True if each item name of items is in state at least once."""
|
||||||
return all(self.prog_items[player][item] for item in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item in items:
|
||||||
|
if not player_prog_items[item]:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_any(self, items: Iterable[str], player: int) -> bool:
|
def has_any(self, items: Iterable[str], player: int) -> bool:
|
||||||
"""Returns True if at least one item name of items is in state at least once."""
|
"""Returns True if at least one item name of items is in state at least once."""
|
||||||
return any(self.prog_items[player][item] for item in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item in items:
|
||||||
|
if player_prog_items[item]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if each item name is in the state at least as many times as specified."""
|
"""Returns True if each item name is in the state at least as many times as specified."""
|
||||||
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item, count in item_counts.items():
|
||||||
|
if player_prog_items[item] < count:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
|
||||||
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
"""Returns True if at least one item name is in the state at least as many times as specified."""
|
||||||
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
|
player_prog_items = self.prog_items[player]
|
||||||
|
for item, count in item_counts.items():
|
||||||
|
if player_prog_items[item] >= count:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def count(self, item: str, player: int) -> int:
|
def count(self, item: str, player: int) -> int:
|
||||||
return self.prog_items[player][item]
|
return self.prog_items[player][item]
|
||||||
@@ -911,11 +930,20 @@ class CollectionState():
|
|||||||
|
|
||||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state."""
|
"""Returns the cumulative count of items from a list present in state."""
|
||||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
total = 0
|
||||||
|
for item_name in items:
|
||||||
|
total += player_prog_items[item_name]
|
||||||
|
return total
|
||||||
|
|
||||||
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
|
||||||
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
|
||||||
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
|
player_prog_items = self.prog_items[player]
|
||||||
|
total = 0
|
||||||
|
for item_name in items:
|
||||||
|
if player_prog_items[item_name] > 0:
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
# item name group related
|
# item name group related
|
||||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import ssl
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import kvui
|
import kvui
|
||||||
|
import argparse
|
||||||
|
|
||||||
logger = logging.getLogger("Client")
|
logger = logging.getLogger("Client")
|
||||||
|
|
||||||
@@ -459,6 +460,13 @@ class CommonContext:
|
|||||||
await self.send_msgs([payload])
|
await self.send_msgs([payload])
|
||||||
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
|
||||||
|
|
||||||
|
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
|
||||||
|
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
|
||||||
|
locations = set(locations) & self.missing_locations
|
||||||
|
if locations:
|
||||||
|
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
|
||||||
|
return locations
|
||||||
|
|
||||||
async def console_input(self) -> str:
|
async def console_input(self) -> str:
|
||||||
if self.ui:
|
if self.ui:
|
||||||
self.ui.focus_textinput()
|
self.ui.focus_textinput()
|
||||||
@@ -701,8 +709,16 @@ class CommonContext:
|
|||||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||||
|
|
||||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
def make_gui(self) -> "type[kvui.GameManager]":
|
||||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
"""
|
||||||
|
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
|
||||||
|
|
||||||
|
Common changes are changing `base_title` to update the window title of the client and
|
||||||
|
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
|
||||||
|
|
||||||
|
ex. `logging_pairs.append(("Foo", "Bar"))`
|
||||||
|
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
|
||||||
|
"""
|
||||||
from kvui import GameManager
|
from kvui import GameManager
|
||||||
|
|
||||||
class TextManager(GameManager):
|
class TextManager(GameManager):
|
||||||
@@ -891,6 +907,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||||||
ctx.disconnected_intentionally = True
|
ctx.disconnected_intentionally = True
|
||||||
ctx.event_invalid_game()
|
ctx.event_invalid_game()
|
||||||
elif 'IncompatibleVersion' in errors:
|
elif 'IncompatibleVersion' in errors:
|
||||||
|
ctx.disconnected_intentionally = True
|
||||||
raise Exception('Server reported your client version as incompatible. '
|
raise Exception('Server reported your client version as incompatible. '
|
||||||
'This probably means you have to update.')
|
'This probably means you have to update.')
|
||||||
elif 'InvalidItemsHandling' in errors:
|
elif 'InvalidItemsHandling' in errors:
|
||||||
@@ -1041,6 +1058,32 @@ def get_base_parser(description: typing.Optional[str] = None):
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def handle_url_arg(args: "argparse.Namespace",
|
||||||
|
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
|
||||||
|
"""
|
||||||
|
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
|
||||||
|
If alternate data is required the urlparse response is saved back to args.url if valid
|
||||||
|
"""
|
||||||
|
if not args.url:
|
||||||
|
return args
|
||||||
|
|
||||||
|
url = urllib.parse.urlparse(args.url)
|
||||||
|
if url.scheme != "archipelago":
|
||||||
|
if not parser:
|
||||||
|
parser = get_base_parser()
|
||||||
|
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
||||||
|
return args
|
||||||
|
|
||||||
|
args.url = url
|
||||||
|
args.connect = url.netloc
|
||||||
|
if url.username:
|
||||||
|
args.name = urllib.parse.unquote(url.username)
|
||||||
|
if url.password:
|
||||||
|
args.password = urllib.parse.unquote(url.password)
|
||||||
|
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
def run_as_textclient(*args):
|
def run_as_textclient(*args):
|
||||||
class TextContext(CommonContext):
|
class TextContext(CommonContext):
|
||||||
# Text Mode to use !hint and such with games that have no text entry
|
# Text Mode to use !hint and such with games that have no text entry
|
||||||
@@ -1053,7 +1096,7 @@ def run_as_textclient(*args):
|
|||||||
if password_requested and not self.password:
|
if password_requested and not self.password:
|
||||||
await super(TextContext, self).server_auth(password_requested)
|
await super(TextContext, self).server_auth(password_requested)
|
||||||
await self.get_username()
|
await self.get_username()
|
||||||
await self.send_connect()
|
await self.send_connect(game="")
|
||||||
|
|
||||||
def on_package(self, cmd: str, args: dict):
|
def on_package(self, cmd: str, args: dict):
|
||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
@@ -1082,17 +1125,7 @@ def run_as_textclient(*args):
|
|||||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
args = handle_url_arg(args, parser=parser)
|
||||||
if args.url:
|
|
||||||
url = urllib.parse.urlparse(args.url)
|
|
||||||
if url.scheme == "archipelago":
|
|
||||||
args.connect = url.netloc
|
|
||||||
if url.username:
|
|
||||||
args.name = urllib.parse.unquote(url.username)
|
|
||||||
if url.password:
|
|
||||||
args.password = urllib.parse.unquote(url.password)
|
|
||||||
else:
|
|
||||||
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
|
|
||||||
|
|
||||||
# use colorama to display colored text highlighting on windows
|
# use colorama to display colored text highlighting on windows
|
||||||
colorama.init()
|
colorama.init()
|
||||||
|
|||||||
28
Fill.py
28
Fill.py
@@ -502,7 +502,13 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
# "priority fill"
|
# "priority fill"
|
||||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
name="Priority", one_item_per_player=False)
|
name="Priority", one_item_per_player=True, allow_partial=True)
|
||||||
|
|
||||||
|
if prioritylocations:
|
||||||
|
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
|
||||||
|
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||||
|
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
|
||||||
|
name="Priority Retry", one_item_per_player=False)
|
||||||
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
|
||||||
defaultlocations = prioritylocations + defaultlocations
|
defaultlocations = prioritylocations + defaultlocations
|
||||||
|
|
||||||
@@ -571,6 +577,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
|||||||
print_data = {"items": items_counter, "locations": locations_counter}
|
print_data = {"items": items_counter, "locations": locations_counter}
|
||||||
logging.info(f"Per-Player counts: {print_data})")
|
logging.info(f"Per-Player counts: {print_data})")
|
||||||
|
|
||||||
|
more_locations = locations_counter - items_counter
|
||||||
|
more_items = items_counter - locations_counter
|
||||||
|
for player in multiworld.player_ids:
|
||||||
|
if more_locations[player]:
|
||||||
|
logging.error(
|
||||||
|
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
|
||||||
|
elif more_items[player]:
|
||||||
|
logging.warning(
|
||||||
|
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
|
||||||
|
if unfilled:
|
||||||
|
raise FillError(
|
||||||
|
f"Unable to fill all locations.\n" +
|
||||||
|
f"Unfilled locations({len(unfilled)}): {unfilled}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
f"Unable to place all items.\n" +
|
||||||
|
f"Unplaced items({len(unplaced)}): {unplaced}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def flood_items(multiworld: MultiWorld) -> None:
|
def flood_items(multiworld: MultiWorld) -> None:
|
||||||
# get items to distribute
|
# get items to distribute
|
||||||
|
|||||||
25
Generate.py
25
Generate.py
@@ -42,7 +42,9 @@ def mystery_argparse():
|
|||||||
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
|
||||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||||
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
|
||||||
parser.add_argument('--log_level', default='info', help='Sets log level')
|
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
|
||||||
|
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
|
||||||
|
default=defaults.logtime, action='store_true')
|
||||||
parser.add_argument("--csv_output", action="store_true",
|
parser.add_argument("--csv_output", action="store_true",
|
||||||
help="Output rolled player options to csv (made for async multiworld).")
|
help="Output rolled player options to csv (made for async multiworld).")
|
||||||
parser.add_argument("--plando", default=defaults.plando_options,
|
parser.add_argument("--plando", default=defaults.plando_options,
|
||||||
@@ -75,7 +77,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
|
|||||||
|
|
||||||
seed = get_seed(args.seed)
|
seed = get_seed(args.seed)
|
||||||
|
|
||||||
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
|
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
|
||||||
random.seed(seed)
|
random.seed(seed)
|
||||||
seed_name = get_seed_name(random)
|
seed_name = get_seed_name(random)
|
||||||
|
|
||||||
@@ -438,7 +440,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
if "linked_options" in weights:
|
if "linked_options" in weights:
|
||||||
weights = roll_linked_options(weights)
|
weights = roll_linked_options(weights)
|
||||||
|
|
||||||
valid_keys = set()
|
valid_keys = {"triggers"}
|
||||||
if "triggers" in weights:
|
if "triggers" in weights:
|
||||||
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
weights = roll_triggers(weights, weights["triggers"], valid_keys)
|
||||||
|
|
||||||
@@ -497,16 +499,23 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
|
|||||||
for option_key, option in world_type.options_dataclass.type_hints.items():
|
for option_key, option in world_type.options_dataclass.type_hints.items():
|
||||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||||
valid_keys.add(option_key)
|
valid_keys.add(option_key)
|
||||||
for option_key in game_weights:
|
|
||||||
if option_key in {"triggers", *valid_keys}:
|
# TODO remove plando_items after moving it to the options system
|
||||||
continue
|
valid_keys.add("plando_items")
|
||||||
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
|
||||||
f"for player {ret.name}.")
|
|
||||||
if PlandoOptions.items in plando_options:
|
if PlandoOptions.items in plando_options:
|
||||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||||
if ret.game == "A Link to the Past":
|
if ret.game == "A Link to the Past":
|
||||||
|
# TODO there are still more LTTP options not on the options system
|
||||||
|
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
|
||||||
roll_alttp_settings(ret, game_weights)
|
roll_alttp_settings(ret, game_weights)
|
||||||
|
|
||||||
|
# log a warning for options within a game section that aren't determined as valid
|
||||||
|
for option_key in game_weights:
|
||||||
|
if option_key in valid_keys:
|
||||||
|
continue
|
||||||
|
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
|
||||||
|
f"for player {ret.name}.")
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
|||||||
from NetUtils import ClientStatus
|
from NetUtils import ClientStatus
|
||||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||||
from worlds.ladx.GpsTracker import GpsTracker
|
from worlds.ladx.GpsTracker import GpsTracker
|
||||||
|
from worlds.ladx.TrackerConsts import storage_key
|
||||||
from worlds.ladx.ItemTracker import ItemTracker
|
from worlds.ladx.ItemTracker import ItemTracker
|
||||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||||
@@ -100,19 +101,23 @@ class LAClientConstants:
|
|||||||
WRamCheckSize = 0x4
|
WRamCheckSize = 0x4
|
||||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||||
|
|
||||||
|
wRamStart = 0xC000
|
||||||
|
hRamStart = 0xFF80
|
||||||
|
hRamSize = 0x80
|
||||||
|
|
||||||
MinGameplayValue = 0x06
|
MinGameplayValue = 0x06
|
||||||
MaxGameplayValue = 0x1A
|
MaxGameplayValue = 0x1A
|
||||||
VictoryGameplayAndSub = 0x0102
|
VictoryGameplayAndSub = 0x0102
|
||||||
|
|
||||||
|
|
||||||
class RAGameboy():
|
class RAGameboy():
|
||||||
cache = []
|
cache = []
|
||||||
cache_start = 0
|
|
||||||
cache_size = 0
|
|
||||||
last_cache_read = None
|
last_cache_read = None
|
||||||
socket = None
|
socket = None
|
||||||
|
|
||||||
def __init__(self, address, port) -> None:
|
def __init__(self, address, port) -> None:
|
||||||
|
self.cache_start = LAClientConstants.wRamStart
|
||||||
|
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
|
||||||
|
|
||||||
self.address = address
|
self.address = address
|
||||||
self.port = port
|
self.port = port
|
||||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
@@ -131,9 +136,14 @@ class RAGameboy():
|
|||||||
async def get_retroarch_status(self):
|
async def get_retroarch_status(self):
|
||||||
return await self.send_command("GET_STATUS")
|
return await self.send_command("GET_STATUS")
|
||||||
|
|
||||||
def set_cache_limits(self, cache_start, cache_size):
|
def set_checks_range(self, checks_start, checks_size):
|
||||||
self.cache_start = cache_start
|
self.checks_start = checks_start
|
||||||
self.cache_size = cache_size
|
self.checks_size = checks_size
|
||||||
|
|
||||||
|
def set_location_range(self, location_start, location_size, critical_addresses):
|
||||||
|
self.location_start = location_start
|
||||||
|
self.location_size = location_size
|
||||||
|
self.critical_location_addresses = critical_addresses
|
||||||
|
|
||||||
def send(self, b):
|
def send(self, b):
|
||||||
if type(b) is str:
|
if type(b) is str:
|
||||||
@@ -188,21 +198,57 @@ class RAGameboy():
|
|||||||
if not await self.check_safe_gameplay():
|
if not await self.check_safe_gameplay():
|
||||||
return
|
return
|
||||||
|
|
||||||
cache = []
|
attempts = 0
|
||||||
remaining_size = self.cache_size
|
while True:
|
||||||
while remaining_size:
|
# RA doesn't let us do an atomic read of a large enough block of RAM
|
||||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
# Some bytes can't change in between reading location_block and hram_block
|
||||||
remaining_size -= len(block)
|
location_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||||
cache += block
|
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
|
||||||
|
verification_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||||
|
|
||||||
|
valid = True
|
||||||
|
for address in self.critical_location_addresses:
|
||||||
|
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
|
||||||
|
valid = False
|
||||||
|
|
||||||
|
if valid:
|
||||||
|
break
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
# Shouldn't really happen, but keep it from choking
|
||||||
|
if attempts > 5:
|
||||||
|
return
|
||||||
|
|
||||||
|
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
|
||||||
|
|
||||||
if not await self.check_safe_gameplay():
|
if not await self.check_safe_gameplay():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.cache = cache
|
self.cache = bytearray(self.cache_size)
|
||||||
|
|
||||||
|
start = self.checks_start - self.cache_start
|
||||||
|
self.cache[start:start + len(checks_block)] = checks_block
|
||||||
|
|
||||||
|
start = self.location_start - self.cache_start
|
||||||
|
self.cache[start:start + len(location_block)] = location_block
|
||||||
|
|
||||||
|
start = LAClientConstants.hRamStart - self.cache_start
|
||||||
|
self.cache[start:start + len(hram_block)] = hram_block
|
||||||
|
|
||||||
self.last_cache_read = time.time()
|
self.last_cache_read = time.time()
|
||||||
|
|
||||||
|
async def read_memory_block(self, address: int, size: int):
|
||||||
|
block = bytearray()
|
||||||
|
remaining_size = size
|
||||||
|
while remaining_size:
|
||||||
|
chunk = await self.async_read_memory(address + len(block), remaining_size)
|
||||||
|
remaining_size -= len(chunk)
|
||||||
|
block += chunk
|
||||||
|
|
||||||
|
return block
|
||||||
|
|
||||||
async def read_memory_cache(self, addresses):
|
async def read_memory_cache(self, addresses):
|
||||||
# TODO: can we just update once per frame?
|
|
||||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||||
await self.update_cache()
|
await self.update_cache()
|
||||||
if not self.cache:
|
if not self.cache:
|
||||||
@@ -359,11 +405,12 @@ class LinksAwakeningClient():
|
|||||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||||
self.auth = auth
|
self.auth = auth
|
||||||
|
|
||||||
async def wait_and_init_tracker(self):
|
async def wait_and_init_tracker(self, magpie: MagpieBridge):
|
||||||
await self.wait_for_game_ready()
|
await self.wait_for_game_ready()
|
||||||
self.tracker = LocationTracker(self.gameboy)
|
self.tracker = LocationTracker(self.gameboy)
|
||||||
self.item_tracker = ItemTracker(self.gameboy)
|
self.item_tracker = ItemTracker(self.gameboy)
|
||||||
self.gps_tracker = GpsTracker(self.gameboy)
|
self.gps_tracker = GpsTracker(self.gameboy)
|
||||||
|
magpie.gps_tracker = self.gps_tracker
|
||||||
|
|
||||||
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
async def recved_item_from_ap(self, item_id, from_player, next_index):
|
||||||
# Don't allow getting an item until you've got your first check
|
# Don't allow getting an item until you've got your first check
|
||||||
@@ -405,9 +452,11 @@ class LinksAwakeningClient():
|
|||||||
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
|
||||||
|
|
||||||
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
|
||||||
|
await self.gameboy.update_cache()
|
||||||
await self.tracker.readChecks(item_get_cb)
|
await self.tracker.readChecks(item_get_cb)
|
||||||
await self.item_tracker.readItems()
|
await self.item_tracker.readItems()
|
||||||
await self.gps_tracker.read_location()
|
await self.gps_tracker.read_location()
|
||||||
|
await self.gps_tracker.read_entrances()
|
||||||
|
|
||||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||||
if self.deathlink_debounce and current_health != 0:
|
if self.deathlink_debounce and current_health != 0:
|
||||||
@@ -465,6 +514,10 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
magpie_task = None
|
magpie_task = None
|
||||||
won = False
|
won = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slot_storage_key(self):
|
||||||
|
return f"{self.slot_info[self.slot].name}_{storage_key}"
|
||||||
|
|
||||||
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
|
||||||
self.client = LinksAwakeningClient()
|
self.client = LinksAwakeningClient()
|
||||||
self.slot_data = {}
|
self.slot_data = {}
|
||||||
@@ -507,7 +560,19 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||||
|
|
||||||
async def send_checks(self):
|
async def send_checks(self):
|
||||||
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
|
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
|
||||||
|
await self.send_msgs(message)
|
||||||
|
|
||||||
|
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
|
||||||
|
# Store the entrances we find on the server for future sessions
|
||||||
|
message = [{
|
||||||
|
"cmd": "Set",
|
||||||
|
"key": self.slot_storage_key,
|
||||||
|
"default": {},
|
||||||
|
"want_reply": False,
|
||||||
|
"operations": [{"operation": "update", "value": entrances}],
|
||||||
|
}]
|
||||||
|
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
|
|
||||||
had_invalid_slot_data = None
|
had_invalid_slot_data = None
|
||||||
@@ -536,6 +601,12 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
logger.info("victory!")
|
logger.info("victory!")
|
||||||
await self.send_msgs(message)
|
await self.send_msgs(message)
|
||||||
self.won = True
|
self.won = True
|
||||||
|
|
||||||
|
async def request_found_entrances(self):
|
||||||
|
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
|
# Ask for updates so that players can co-op entrances in a seed
|
||||||
|
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
|
||||||
|
|
||||||
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
|
||||||
if self.ENABLE_DEATHLINK:
|
if self.ENABLE_DEATHLINK:
|
||||||
@@ -560,6 +631,10 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
|
|
||||||
while self.client.auth == None:
|
while self.client.auth == None:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
# Just return if we're closing
|
||||||
|
if self.exit_event.is_set():
|
||||||
|
return
|
||||||
self.auth = self.client.auth
|
self.auth = self.client.auth
|
||||||
await self.send_connect()
|
await self.send_connect()
|
||||||
|
|
||||||
@@ -572,6 +647,12 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if cmd == "ReceivedItems":
|
if cmd == "ReceivedItems":
|
||||||
for index, item in enumerate(args["items"], start=args["index"]):
|
for index, item in enumerate(args["items"], start=args["index"]):
|
||||||
self.client.recvd_checks[index] = item
|
self.client.recvd_checks[index] = item
|
||||||
|
|
||||||
|
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
|
||||||
|
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
|
||||||
|
|
||||||
|
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
|
||||||
|
self.client.gps_tracker.receive_found_entrances(args["value"])
|
||||||
|
|
||||||
async def sync(self):
|
async def sync(self):
|
||||||
sync_msg = [{'cmd': 'Sync'}]
|
sync_msg = [{'cmd': 'Sync'}]
|
||||||
@@ -585,6 +666,12 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
checkMetadataTable[check.id])] for check in ladxr_checks]
|
checkMetadataTable[check.id])] for check in ladxr_checks]
|
||||||
self.new_checks(checks, [check.id for check in ladxr_checks])
|
self.new_checks(checks, [check.id for check in ladxr_checks])
|
||||||
|
|
||||||
|
for check in ladxr_checks:
|
||||||
|
if check.value and check.linkedItem:
|
||||||
|
linkedItem = check.linkedItem
|
||||||
|
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
|
||||||
|
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
|
||||||
|
|
||||||
async def victory():
|
async def victory():
|
||||||
await self.send_victory()
|
await self.send_victory()
|
||||||
|
|
||||||
@@ -618,12 +705,20 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
if not self.client.recvd_checks:
|
if not self.client.recvd_checks:
|
||||||
await self.sync()
|
await self.sync()
|
||||||
|
|
||||||
await self.client.wait_and_init_tracker()
|
await self.client.wait_and_init_tracker(self.magpie)
|
||||||
|
|
||||||
|
min_tick_duration = 0.1
|
||||||
|
last_tick = time.time()
|
||||||
while True:
|
while True:
|
||||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
tick_duration = now - last_tick
|
||||||
|
sleep_duration = max(min_tick_duration - tick_duration, 0)
|
||||||
|
await asyncio.sleep(sleep_duration)
|
||||||
|
|
||||||
|
last_tick = now
|
||||||
|
|
||||||
if self.last_resend + 5.0 < now:
|
if self.last_resend + 5.0 < now:
|
||||||
self.last_resend = now
|
self.last_resend = now
|
||||||
await self.send_checks()
|
await self.send_checks()
|
||||||
@@ -631,8 +726,15 @@ class LinksAwakeningContext(CommonContext):
|
|||||||
try:
|
try:
|
||||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||||
await self.magpie.set_item_tracker(self.client.item_tracker)
|
await self.magpie.set_item_tracker(self.client.item_tracker)
|
||||||
await self.magpie.send_gps(self.client.gps_tracker)
|
|
||||||
self.magpie.slot_data = self.slot_data
|
self.magpie.slot_data = self.slot_data
|
||||||
|
|
||||||
|
if self.client.gps_tracker.needs_found_entrances:
|
||||||
|
await self.request_found_entrances()
|
||||||
|
self.client.gps_tracker.needs_found_entrances = False
|
||||||
|
|
||||||
|
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
|
||||||
|
if new_entrances:
|
||||||
|
await self.send_new_entrances(new_entrances)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Don't let magpie errors take out the client
|
# Don't let magpie errors take out the client
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
|
|||||||
WINDOW_MIN_WIDTH = 425
|
WINDOW_MIN_WIDTH = 425
|
||||||
|
|
||||||
class AdjusterWorld(object):
|
class AdjusterWorld(object):
|
||||||
|
class AdjusterSubWorld(object):
|
||||||
|
def __init__(self, random):
|
||||||
|
self.random = random
|
||||||
|
|
||||||
def __init__(self, sprite_pool):
|
def __init__(self, sprite_pool):
|
||||||
import random
|
import random
|
||||||
self.sprite_pool = {1: sprite_pool}
|
self.sprite_pool = {1: sprite_pool}
|
||||||
self.per_slot_randoms = {1: random}
|
self.per_slot_randoms = {1: random}
|
||||||
|
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||||
|
|
||||||
|
|
||||||
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||||
|
|||||||
3
Main.py
3
Main.py
@@ -148,7 +148,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
|
|||||||
else:
|
else:
|
||||||
multiworld.worlds[1].options.non_local_items.value = set()
|
multiworld.worlds[1].options.non_local_items.value = set()
|
||||||
multiworld.worlds[1].options.local_items.value = set()
|
multiworld.worlds[1].options.local_items.value = set()
|
||||||
|
|
||||||
|
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||||
AutoWorld.call_all(multiworld, "generate_basic")
|
AutoWorld.call_all(multiworld, "generate_basic")
|
||||||
|
|
||||||
# remove starting inventory from pool items.
|
# remove starting inventory from pool items.
|
||||||
|
|||||||
104
MultiServer.py
104
MultiServer.py
@@ -28,9 +28,11 @@ ModuleUpdate.update()
|
|||||||
|
|
||||||
if typing.TYPE_CHECKING:
|
if typing.TYPE_CHECKING:
|
||||||
import ssl
|
import ssl
|
||||||
|
from NetUtils import ServerConnection
|
||||||
|
|
||||||
import websockets
|
|
||||||
import colorama
|
import colorama
|
||||||
|
import websockets
|
||||||
|
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||||
try:
|
try:
|
||||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||||
from pony.orm.dbapiprovider import OperationalError
|
from pony.orm.dbapiprovider import OperationalError
|
||||||
@@ -119,13 +121,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
|||||||
|
|
||||||
class Client(Endpoint):
|
class Client(Endpoint):
|
||||||
version = Version(0, 0, 0)
|
version = Version(0, 0, 0)
|
||||||
tags: typing.List[str] = []
|
tags: typing.List[str]
|
||||||
remote_items: bool
|
remote_items: bool
|
||||||
remote_start_inventory: bool
|
remote_start_inventory: bool
|
||||||
no_items: bool
|
no_items: bool
|
||||||
no_locations: bool
|
no_locations: bool
|
||||||
|
no_text: bool
|
||||||
|
|
||||||
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
|
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
|
||||||
super().__init__(socket)
|
super().__init__(socket)
|
||||||
self.auth = False
|
self.auth = False
|
||||||
self.team = None
|
self.team = None
|
||||||
@@ -175,6 +178,7 @@ class Context:
|
|||||||
"compatibility": int}
|
"compatibility": int}
|
||||||
# team -> slot id -> list of clients authenticated to slot.
|
# team -> slot id -> list of clients authenticated to slot.
|
||||||
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
|
||||||
|
endpoints: list[Client]
|
||||||
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
|
||||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||||
@@ -364,18 +368,28 @@ class Context:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def broadcast_all(self, msgs: typing.List[dict]):
|
def broadcast_all(self, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in self.endpoints
|
||||||
|
if endpoint.auth and not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
|
||||||
self.logger.info("Notice (all): %s" % text)
|
self.logger.info("Notice (all): %s" % text)
|
||||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||||
|
|
||||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
data = self.dumper(msgs)
|
||||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
endpoints = (
|
||||||
|
endpoint
|
||||||
|
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
|
||||||
|
if not (msg_is_text and endpoint.no_text)
|
||||||
|
)
|
||||||
|
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
|
||||||
|
|
||||||
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
|
||||||
msgs = self.dumper(msgs)
|
msgs = self.dumper(msgs)
|
||||||
@@ -389,13 +403,13 @@ class Context:
|
|||||||
await on_client_disconnected(self, endpoint)
|
await on_client_disconnected(self, endpoint)
|
||||||
|
|
||||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||||
|
|
||||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||||
if not client.auth:
|
if not client.auth or client.no_text:
|
||||||
return
|
return
|
||||||
async_start(self.send_msgs(client,
|
async_start(self.send_msgs(client,
|
||||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||||
@@ -444,7 +458,7 @@ class Context:
|
|||||||
|
|
||||||
self.slot_info = decoded_obj["slot_info"]
|
self.slot_info = decoded_obj["slot_info"]
|
||||||
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||||
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
|
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
|
||||||
if slot_info.type == SlotType.group}
|
if slot_info.type == SlotType.group}
|
||||||
|
|
||||||
self.clients = {0: {}}
|
self.clients = {0: {}}
|
||||||
@@ -743,23 +757,24 @@ class Context:
|
|||||||
concerns[player].append(data)
|
concerns[player].append(data)
|
||||||
if not hint.local and data not in concerns[hint.finding_player]:
|
if not hint.local and data not in concerns[hint.finding_player]:
|
||||||
concerns[hint.finding_player].append(data)
|
concerns[hint.finding_player].append(data)
|
||||||
# remember hints in all cases
|
|
||||||
|
|
||||||
# since hints are bidirectional, finding player and receiving player,
|
# only remember hints that were not already found at the time of creation
|
||||||
# we can check once if hint already exists
|
if not hint.found:
|
||||||
if hint not in self.hints[team, hint.finding_player]:
|
# since hints are bidirectional, finding player and receiving player,
|
||||||
self.hints[team, hint.finding_player].add(hint)
|
# we can check once if hint already exists
|
||||||
new_hint_events.add(hint.finding_player)
|
if hint not in self.hints[team, hint.finding_player]:
|
||||||
for player in self.slot_set(hint.receiving_player):
|
self.hints[team, hint.finding_player].add(hint)
|
||||||
self.hints[team, player].add(hint)
|
new_hint_events.add(hint.finding_player)
|
||||||
new_hint_events.add(player)
|
for player in self.slot_set(hint.receiving_player):
|
||||||
|
self.hints[team, player].add(hint)
|
||||||
|
new_hint_events.add(player)
|
||||||
|
|
||||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||||
for slot in new_hint_events:
|
for slot in new_hint_events:
|
||||||
self.on_new_hint(team, slot)
|
self.on_new_hint(team, slot)
|
||||||
for slot, hint_data in concerns.items():
|
for slot, hint_data in concerns.items():
|
||||||
if recipients is None or slot in recipients:
|
if recipients is None or slot in recipients:
|
||||||
clients = self.clients[team].get(slot)
|
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
|
||||||
if not clients:
|
if not clients:
|
||||||
continue
|
continue
|
||||||
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
|
||||||
@@ -768,7 +783,7 @@ class Context:
|
|||||||
|
|
||||||
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
|
||||||
for hint in self.hints[team, finding_player]:
|
for hint in self.hints[team, finding_player]:
|
||||||
if hint.location == seeked_location:
|
if hint.location == seeked_location and hint.finding_player == finding_player:
|
||||||
return hint
|
return hint
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -818,7 +833,7 @@ def update_aliases(ctx: Context, team: int):
|
|||||||
async_start(ctx.send_encoded_msgs(client, cmd))
|
async_start(ctx.send_encoded_msgs(client, cmd))
|
||||||
|
|
||||||
|
|
||||||
async def server(websocket, path: str = "/", ctx: Context = None):
|
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
|
||||||
client = Client(websocket, ctx)
|
client = Client(websocket, ctx)
|
||||||
ctx.endpoints.append(client)
|
ctx.endpoints.append(client)
|
||||||
|
|
||||||
@@ -909,6 +924,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
|||||||
"If your client supports it, "
|
"If your client supports it, "
|
||||||
"you may have additional local commands you can list with /help.",
|
"you may have additional local commands you can list with /help.",
|
||||||
{"type": "Tutorial"})
|
{"type": "Tutorial"})
|
||||||
|
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
|
||||||
|
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
|
||||||
|
"It may stop working in the future. If you are a player, please report this to the "
|
||||||
|
"client's developer.")
|
||||||
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
@@ -1059,21 +1078,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
|
|||||||
|
|
||||||
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
|
||||||
count_activity: bool = True):
|
count_activity: bool = True):
|
||||||
|
slot_locations = ctx.locations[slot]
|
||||||
new_locations = set(locations) - ctx.location_checks[team, slot]
|
new_locations = set(locations) - ctx.location_checks[team, slot]
|
||||||
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
|
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
|
||||||
if new_locations:
|
if new_locations:
|
||||||
if count_activity:
|
if count_activity:
|
||||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
|
||||||
|
sortable: list[tuple[int, int, int, int]] = []
|
||||||
for location in new_locations:
|
for location in new_locations:
|
||||||
item_id, target_player, flags = ctx.locations[slot][location]
|
# extract all fields to avoid runtime overhead in LocationStore
|
||||||
|
item_id, target_player, flags = slot_locations[location]
|
||||||
|
# sort/group by receiver and item
|
||||||
|
sortable.append((target_player, item_id, location, flags))
|
||||||
|
|
||||||
|
info_texts: list[dict[str, typing.Any]] = []
|
||||||
|
for target_player, item_id, location, flags in sorted(sortable):
|
||||||
new_item = NetworkItem(item_id, location, slot, flags)
|
new_item = NetworkItem(item_id, location, slot, flags)
|
||||||
send_items_to(ctx, team, target_player, new_item)
|
send_items_to(ctx, team, target_player, new_item)
|
||||||
|
|
||||||
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
|
||||||
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
|
||||||
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
|
||||||
info_text = json_format_send_event(new_item, target_player)
|
if len(info_texts) >= 140:
|
||||||
ctx.broadcast_team(team, [info_text])
|
# split into chunks that are close to compression window of 64K but not too big on the wire
|
||||||
|
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
|
||||||
|
ctx.broadcast_team(team, info_texts)
|
||||||
|
info_texts.clear()
|
||||||
|
info_texts.append(json_format_send_event(new_item, target_player))
|
||||||
|
ctx.broadcast_team(team, info_texts)
|
||||||
|
del info_texts
|
||||||
|
del sortable
|
||||||
|
|
||||||
ctx.location_checks[team, slot] |= new_locations
|
ctx.location_checks[team, slot] |= new_locations
|
||||||
send_new_items(ctx)
|
send_new_items(ctx)
|
||||||
@@ -1100,7 +1135,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
|
|||||||
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
|
||||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||||
in ctx.locations.find_item(slots, seeked_item_id):
|
in ctx.locations.find_item(slots, seeked_item_id):
|
||||||
prev_hint = ctx.get_hint(team, slot, location_id)
|
prev_hint = ctx.get_hint(team, finding_player, location_id)
|
||||||
if prev_hint:
|
if prev_hint:
|
||||||
hints.append(prev_hint)
|
hints.append(prev_hint)
|
||||||
else:
|
else:
|
||||||
@@ -1786,7 +1821,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
ctx.clients[team][slot].append(client)
|
ctx.clients[team][slot].append(client)
|
||||||
client.version = args['version']
|
client.version = args['version']
|
||||||
client.tags = args['tags']
|
client.tags = args['tags']
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
|
||||||
|
# set NoText for old PopTracker clients that predate the tag to save traffic
|
||||||
|
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
|
||||||
connected_packet = {
|
connected_packet = {
|
||||||
"cmd": "Connected",
|
"cmd": "Connected",
|
||||||
"team": client.team, "slot": client.slot,
|
"team": client.team, "slot": client.slot,
|
||||||
@@ -1859,6 +1896,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
client.tags = args["tags"]
|
client.tags = args["tags"]
|
||||||
if set(old_tags) != set(client.tags):
|
if set(old_tags) != set(client.tags):
|
||||||
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
|
||||||
|
client.no_text = "NoText" in client.tags or (
|
||||||
|
"PopTracker" in client.tags and client.version < (0, 5, 1)
|
||||||
|
)
|
||||||
ctx.broadcast_text_all(
|
ctx.broadcast_text_all(
|
||||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
|
||||||
f"from {old_tags} to {client.tags}.",
|
f"from {old_tags} to {client.tags}.",
|
||||||
@@ -1887,7 +1927,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
for location in args["locations"]:
|
for location in args["locations"]:
|
||||||
if type(location) is not int:
|
if type(location) is not int:
|
||||||
await ctx.send_msgs(client,
|
await ctx.send_msgs(client,
|
||||||
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
|
[{'cmd': 'InvalidPacket', "type": "arguments",
|
||||||
|
"text": 'Locations has to be a list of integers',
|
||||||
"original_cmd": cmd}])
|
"original_cmd": cmd}])
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -1990,6 +2031,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
|||||||
args["cmd"] = "SetReply"
|
args["cmd"] = "SetReply"
|
||||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||||
args["original_value"] = copy.copy(value)
|
args["original_value"] = copy.copy(value)
|
||||||
|
args["slot"] = client.slot
|
||||||
for operation in args["operations"]:
|
for operation in args["operations"]:
|
||||||
func = modify_functions[operation["operation"]]
|
func = modify_functions[operation["operation"]]
|
||||||
value = func(value, operation["value"])
|
value = func(value, operation["value"])
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import enum
|
|||||||
import warnings
|
import warnings
|
||||||
from json import JSONEncoder, JSONDecoder
|
from json import JSONEncoder, JSONDecoder
|
||||||
|
|
||||||
import websockets
|
if typing.TYPE_CHECKING:
|
||||||
|
from websockets import WebSocketServerProtocol as ServerConnection
|
||||||
|
|
||||||
from Utils import ByValue, Version
|
from Utils import ByValue, Version
|
||||||
|
|
||||||
|
|
||||||
class HintStatus(ByValue, enum.IntEnum):
|
class HintStatus(ByValue, enum.IntEnum):
|
||||||
HINT_FOUND = 0
|
HINT_UNSPECIFIED = 0
|
||||||
HINT_UNSPECIFIED = 1
|
|
||||||
HINT_NO_PRIORITY = 10
|
HINT_NO_PRIORITY = 10
|
||||||
HINT_AVOID = 20
|
HINT_AVOID = 20
|
||||||
HINT_PRIORITY = 30
|
HINT_PRIORITY = 30
|
||||||
|
HINT_FOUND = 40
|
||||||
|
|
||||||
|
|
||||||
class JSONMessagePart(typing.TypedDict, total=False):
|
class JSONMessagePart(typing.TypedDict, total=False):
|
||||||
@@ -151,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
|||||||
|
|
||||||
|
|
||||||
class Endpoint:
|
class Endpoint:
|
||||||
socket: websockets.WebSocketServerProtocol
|
socket: "ServerConnection"
|
||||||
|
|
||||||
def __init__(self, socket):
|
def __init__(self, socket):
|
||||||
self.socket = socket
|
self.socket = socket
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
@@ -197,7 +196,6 @@ def set_icon(window):
|
|||||||
def adjust(args):
|
def adjust(args):
|
||||||
# Create a fake multiworld and OOTWorld to use as a base
|
# Create a fake multiworld and OOTWorld to use as a base
|
||||||
multiworld = MultiWorld(1)
|
multiworld = MultiWorld(1)
|
||||||
multiworld.per_slot_randoms = {1: random}
|
|
||||||
ootworld = OOTWorld(multiworld, 1)
|
ootworld = OOTWorld(multiworld, 1)
|
||||||
# Set options in the fake OOTWorld
|
# Set options in the fake OOTWorld
|
||||||
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
|
||||||
|
|||||||
24
Options.py
24
Options.py
@@ -137,7 +137,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
|
|||||||
If this is False, the docstring is instead interpreted as plain text, and
|
If this is False, the docstring is instead interpreted as plain text, and
|
||||||
displayed as-is on the WebHost with whitespace preserved.
|
displayed as-is on the WebHost with whitespace preserved.
|
||||||
|
|
||||||
If this is None, it inherits the value of `World.rich_text_options_doc`. For
|
If this is None, it inherits the value of `WebWorld.rich_text_options_doc`. For
|
||||||
backwards compatibility, this defaults to False, but worlds are encouraged to
|
backwards compatibility, this defaults to False, but worlds are encouraged to
|
||||||
set it to True and use reStructuredText for their Option documentation.
|
set it to True and use reStructuredText for their Option documentation.
|
||||||
|
|
||||||
@@ -689,9 +689,9 @@ class Range(NumericOption):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def weighted_range(cls, text) -> Range:
|
def weighted_range(cls, text) -> Range:
|
||||||
if text == "random-low":
|
if text == "random-low":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
|
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
|
||||||
elif text == "random-high":
|
elif text == "random-high":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
|
||||||
elif text == "random-middle":
|
elif text == "random-middle":
|
||||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||||
elif text.startswith("random-range-"):
|
elif text.startswith("random-range-"):
|
||||||
@@ -717,11 +717,11 @@ class Range(NumericOption):
|
|||||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||||
if text.startswith("random-range-low"):
|
if text.startswith("random-range-low"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
|
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
|
||||||
elif text.startswith("random-range-middle"):
|
elif text.startswith("random-range-middle"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||||
elif text.startswith("random-range-high"):
|
elif text.startswith("random-range-high"):
|
||||||
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
|
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
|
||||||
else:
|
else:
|
||||||
return cls(random.randint(random_range[0], random_range[1]))
|
return cls(random.randint(random_range[0], random_range[1]))
|
||||||
|
|
||||||
@@ -739,8 +739,16 @@ class Range(NumericOption):
|
|||||||
return str(self.value)
|
return str(self.value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||||
return int(round(random.triangular(lower, end, tri), 0))
|
"""
|
||||||
|
Integer triangular distribution for `lower` inclusive to `end` inclusive.
|
||||||
|
|
||||||
|
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
|
||||||
|
"""
|
||||||
|
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
|
||||||
|
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
|
||||||
|
# when a != b, so ensure the result is never more than `end`.
|
||||||
|
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
|
||||||
|
|
||||||
|
|
||||||
class NamedRange(Range):
|
class NamedRange(Range):
|
||||||
@@ -1574,7 +1582,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
|||||||
}
|
}
|
||||||
output.append(player_output)
|
output.append(player_output)
|
||||||
for option_key, option in world.options_dataclass.type_hints.items():
|
for option_key, option in world.options_dataclass.type_hints.items():
|
||||||
if issubclass(Removed, option):
|
if option.visibility == Visibility.none:
|
||||||
continue
|
continue
|
||||||
display_name = getattr(option, "display_name", option_key)
|
display_name = getattr(option, "display_name", option_key)
|
||||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||||
|
|||||||
19
Utils.py
19
Utils.py
@@ -443,7 +443,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
|||||||
else:
|
else:
|
||||||
mod = importlib.import_module(module)
|
mod = importlib.import_module(module)
|
||||||
obj = getattr(mod, name)
|
obj = getattr(mod, name)
|
||||||
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
|
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
|
||||||
|
self.options_module.PlandoText)):
|
||||||
return obj
|
return obj
|
||||||
# Forbid everything else.
|
# Forbid everything else.
|
||||||
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
|
||||||
@@ -521,8 +522,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
|
|||||||
def filter(self, record: logging.LogRecord) -> bool:
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
return self.condition(record)
|
return self.condition(record)
|
||||||
|
|
||||||
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||||
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.msg))
|
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
|
||||||
root_logger.addHandler(file_handler)
|
root_logger.addHandler(file_handler)
|
||||||
if sys.stdout:
|
if sys.stdout:
|
||||||
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
|
||||||
@@ -940,7 +941,7 @@ def freeze_support() -> None:
|
|||||||
|
|
||||||
def visualize_regions(root_region: Region, file_name: str, *,
|
def visualize_regions(root_region: Region, file_name: str, *,
|
||||||
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
|
||||||
linetype_ortho: bool = True) -> None:
|
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
|
||||||
"""Visualize the layout of a world as a PlantUML diagram.
|
"""Visualize the layout of a world as a PlantUML diagram.
|
||||||
|
|
||||||
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
|
||||||
@@ -956,16 +957,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
Items without ID will be shown in italics.
|
Items without ID will be shown in italics.
|
||||||
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
|
||||||
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
|
||||||
|
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||||
|
|
||||||
Example usage in World code:
|
Example usage in World code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
|
state = self.multiworld.get_all_state(False)
|
||||||
|
state.update_reachable_regions(self.player)
|
||||||
|
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
|
||||||
|
regions_to_highlight=state.reachable_regions[self.player])
|
||||||
|
|
||||||
Example usage in Main code:
|
Example usage in Main code:
|
||||||
from Utils import visualize_regions
|
from Utils import visualize_regions
|
||||||
for player in multiworld.player_ids:
|
for player in multiworld.player_ids:
|
||||||
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
|
||||||
"""
|
"""
|
||||||
|
if regions_to_highlight is None:
|
||||||
|
regions_to_highlight = set()
|
||||||
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
|
||||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -1018,7 +1025,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
|
|||||||
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
|
||||||
|
|
||||||
def visualize_region(region: Region) -> None:
|
def visualize_region(region: Region) -> None:
|
||||||
uml.append(f"class \"{fmt(region)}\"")
|
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
|
||||||
if show_locations:
|
if show_locations:
|
||||||
visualize_locations(region)
|
visualize_locations(region)
|
||||||
visualize_exits(region)
|
visualize_exits(region)
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ from typing import List, Tuple
|
|||||||
|
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
|
|
||||||
from ..models import Seed
|
from ..models import Seed, Slot
|
||||||
|
|
||||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
def get_players(seed: Seed) -> List[Tuple[str, str]]:
|
||||||
return [(slot.player_name, slot.game) for slot in seed.slots]
|
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
|
||||||
|
|
||||||
|
|
||||||
from . import datapackage, generate, room, user # trigger registration
|
from . import datapackage, generate, room, user # trigger registration
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ def get_seeds():
|
|||||||
"creation_time": seed.creation_time,
|
"creation_time": seed.creation_time,
|
||||||
"players": get_players(seed.slots),
|
"players": get_players(seed.slots),
|
||||||
})
|
})
|
||||||
return jsonify(response)
|
return jsonify(response)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ class WebHostContext(Context):
|
|||||||
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
|
||||||
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
|
||||||
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
|
||||||
|
missing_checksum = False
|
||||||
|
|
||||||
for game in list(multidata.get("datapackage", {})):
|
for game in list(multidata.get("datapackage", {})):
|
||||||
game_data = multidata["datapackage"][game]
|
game_data = multidata["datapackage"][game]
|
||||||
@@ -132,11 +133,13 @@ class WebHostContext(Context):
|
|||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
|
||||||
|
else:
|
||||||
|
missing_checksum = True # Game rolled on old AP and will load data package from multidata
|
||||||
self.gamespackage[game] = static_gamespackage.get(game, {})
|
self.gamespackage[game] = static_gamespackage.get(game, {})
|
||||||
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
self.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||||
|
|
||||||
if not game_data_packages:
|
if not game_data_packages and not missing_checksum:
|
||||||
# all static -> use the static dicts directly
|
# all static -> use the static dicts directly
|
||||||
self.gamespackage = static_gamespackage
|
self.gamespackage = static_gamespackage
|
||||||
self.item_name_groups = static_item_name_groups
|
self.item_name_groups = static_item_name_groups
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
|
|||||||
|
|
||||||
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
|
||||||
players to randomize any of the supported games, and send items between them. This allows players of different
|
players to randomize any of the supported games, and send items between them. This allows players of different
|
||||||
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
|
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
|
||||||
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
Here is a list of our [Supported Games](https://archipelago.gg/games).
|
||||||
|
|
||||||
## Can I generate a single-player game with Archipelago?
|
## Can I generate a single-player game with Archipelago?
|
||||||
|
|||||||
@@ -75,6 +75,27 @@
|
|||||||
#inventory-table img.acquired.green{ /*32CD32*/
|
#inventory-table img.acquired.green{ /*32CD32*/
|
||||||
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
|
||||||
}
|
}
|
||||||
|
#inventory-table img.acquired.hotpink{ /*FF69B4*/
|
||||||
|
filter: sepia(100%) hue-rotate(300deg) saturate(10);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
|
||||||
|
filter: sepia(100%) hue-rotate(347deg) saturate(10);
|
||||||
|
}
|
||||||
|
#inventory-table img.acquired.crimson{ /*DB143B*/
|
||||||
|
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table span{
|
||||||
|
color: #B4B4A0;
|
||||||
|
font-size: 40px;
|
||||||
|
max-width: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
filter: grayscale(100%) contrast(75%) brightness(30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#inventory-table span.acquired{
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
#inventory-table div.image-stack{
|
#inventory-table div.image-stack{
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -99,6 +99,52 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %}
|
||||||
|
<div class="table-row">
|
||||||
|
{% if 'PrismBreak' in options %}
|
||||||
|
<div class="C1">
|
||||||
|
<div class="image-stack">
|
||||||
|
<div class="stack-front">
|
||||||
|
<div class="stack-top-left">
|
||||||
|
<img src="{{ icons['Laser Access'] }}" class="hotpink {{ 'acquired' if 'Laser Access A' in acquired_items }}" title="Laser Access A" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-top-right">
|
||||||
|
<img src="{{ icons['Laser Access'] }}" class="lightsalmon {{ 'acquired' if 'Laser Access I' in acquired_items }}" title="Laser Access I" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-bottum-left">
|
||||||
|
<img src="{{ icons['Laser Access'] }}" class="crimson {{ 'acquired' if 'Laser Access M' in acquired_items }}" title="Laser Access M" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'LockKeyAmadeus' in options %}
|
||||||
|
<div class="C2">
|
||||||
|
<div class="image-stack">
|
||||||
|
<div class="stack-front">
|
||||||
|
<div class="stack-top-left">
|
||||||
|
<img src="{{ icons['Lab Glasses'] }}" class="{{ 'acquired' if 'Lab Access Genza' in acquired_items }}" title="Lab Access Genza" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-top-right">
|
||||||
|
<img src="{{ icons['Eye Orb'] }}" class="{{ 'acquired' if 'Lab Access Dynamo' in acquired_items }}" title="Lab Access Dynamo" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-bottum-left">
|
||||||
|
<img src="{{ icons['Lab Coat'] }}" class="{{ 'acquired' if 'Lab Access Research' in acquired_items }}" title="Lab Access Research" />
|
||||||
|
</div>
|
||||||
|
<div class="stack-bottum-right">
|
||||||
|
<img src="{{ icons['Demon'] }}" class="{{ 'acquired' if 'Lab Access Experiment' in acquired_items }}" title="Lab Access Experiment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if 'GateKeep' in options %}
|
||||||
|
<div class="C3">
|
||||||
|
<span class="{{ 'acquired' if 'Drawbridge Key' in acquired_items }}" title="Drawbridge Key">❖</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table id="location-table">
|
<table id="location-table">
|
||||||
|
|||||||
@@ -1071,6 +1071,11 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
|
||||||
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
|
||||||
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
|
||||||
|
"Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png",
|
||||||
|
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
|
||||||
|
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
|
||||||
|
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
|
||||||
|
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
|
||||||
}
|
}
|
||||||
|
|
||||||
timespinner_location_ids = {
|
timespinner_location_ids = {
|
||||||
@@ -1118,6 +1123,9 @@ if "Timespinner" in network_data_package["games"]:
|
|||||||
timespinner_location_ids["Ancient Pyramid"] += [
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
1337237, 1337238, 1337239,
|
1337237, 1337238, 1337239,
|
||||||
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||||
|
if (slot_data["PyramidStart"]):
|
||||||
|
timespinner_location_ids["Ancient Pyramid"] += [
|
||||||
|
1337233, 1337234, 1337235]
|
||||||
|
|
||||||
display_data = {}
|
display_data = {}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ Response:
|
|||||||
|
|
||||||
Expected Response Type: `HASH_RESPONSE`
|
Expected Response Type: `HASH_RESPONSE`
|
||||||
|
|
||||||
|
- `MEMORY_SIZE`
|
||||||
|
Returns the size in bytes of the specified memory domain.
|
||||||
|
|
||||||
|
Expected Response Type: `MEMORY_SIZE_RESPONSE`
|
||||||
|
|
||||||
|
Additional Fields:
|
||||||
|
- `domain` (`string`): The name of the memory domain to check
|
||||||
|
|
||||||
- `GUARD`
|
- `GUARD`
|
||||||
Checks a section of memory against `expected_data`. If the bytes starting
|
Checks a section of memory against `expected_data`. If the bytes starting
|
||||||
at `address` do not match `expected_data`, the response will have `value`
|
at `address` do not match `expected_data`, the response will have `value`
|
||||||
@@ -216,6 +224,12 @@ Response:
|
|||||||
Additional Fields:
|
Additional Fields:
|
||||||
- `value` (`string`): The returned hash
|
- `value` (`string`): The returned hash
|
||||||
|
|
||||||
|
- `MEMORY_SIZE_RESPONSE`
|
||||||
|
Contains the size in bytes of the specified memory domain.
|
||||||
|
|
||||||
|
Additional Fields:
|
||||||
|
- `value` (`number`): The size of the domain in bytes
|
||||||
|
|
||||||
- `GUARD_RESPONSE`
|
- `GUARD_RESPONSE`
|
||||||
The result of an attempted `GUARD` request.
|
The result of an attempted `GUARD` request.
|
||||||
|
|
||||||
@@ -376,6 +390,15 @@ request_handlers = {
|
|||||||
return res
|
return res
|
||||||
end,
|
end,
|
||||||
|
|
||||||
|
["MEMORY_SIZE"] = function (req)
|
||||||
|
local res = {}
|
||||||
|
|
||||||
|
res["type"] = "MEMORY_SIZE_RESPONSE"
|
||||||
|
res["value"] = memory.getmemorydomainsize(req["domain"])
|
||||||
|
|
||||||
|
return res
|
||||||
|
end,
|
||||||
|
|
||||||
["GUARD"] = function (req)
|
["GUARD"] = function (req)
|
||||||
local res = {}
|
local res = {}
|
||||||
local expected_data = base64.decode(req["expected_data"])
|
local expected_data = base64.decode(req["expected_data"])
|
||||||
@@ -613,9 +636,11 @@ end)
|
|||||||
|
|
||||||
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
|
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
|
||||||
print("Must use BizHawk 2.7.0 or newer")
|
print("Must use BizHawk 2.7.0 or newer")
|
||||||
elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then
|
|
||||||
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.")
|
|
||||||
else
|
else
|
||||||
|
if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 10) then
|
||||||
|
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.10.")
|
||||||
|
end
|
||||||
|
|
||||||
if emu.getsystemid() == "NULL" then
|
if emu.getsystemid() == "NULL" then
|
||||||
print("No ROM is loaded. Please load a ROM.")
|
print("No ROM is loaded. Please load a ROM.")
|
||||||
while emu.getsystemid() == "NULL" do
|
while emu.getsystemid() == "NULL" do
|
||||||
|
|||||||
@@ -1816,7 +1816,7 @@ end
|
|||||||
|
|
||||||
-- Main control handling: main loop and socket receive
|
-- Main control handling: main loop and socket receive
|
||||||
|
|
||||||
function receive()
|
function APreceive()
|
||||||
l, e = ootSocket:receive()
|
l, e = ootSocket:receive()
|
||||||
-- Handle incoming message
|
-- Handle incoming message
|
||||||
if e == 'closed' then
|
if e == 'closed' then
|
||||||
@@ -1874,7 +1874,7 @@ function main()
|
|||||||
end
|
end
|
||||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||||
if (frame % 30 == 0) then
|
if (frame % 30 == 0) then
|
||||||
receive()
|
APreceive()
|
||||||
end
|
end
|
||||||
elseif (curstate == STATE_UNINITIALIZED) then
|
elseif (curstate == STATE_UNINITIALIZED) then
|
||||||
if (frame % 60 == 0) then
|
if (frame % 60 == 0) then
|
||||||
|
|||||||
@@ -99,6 +99,9 @@
|
|||||||
# Lingo
|
# Lingo
|
||||||
/worlds/lingo/ @hatkirby
|
/worlds/lingo/ @hatkirby
|
||||||
|
|
||||||
|
# Links Awakening DX
|
||||||
|
/worlds/ladx/ @threeandthreee
|
||||||
|
|
||||||
# Lufia II Ancient Cave
|
# Lufia II Ancient Cave
|
||||||
/worlds/lufia2ac/ @el-u
|
/worlds/lufia2ac/ @el-u
|
||||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||||
@@ -236,9 +239,6 @@
|
|||||||
# Final Fantasy (1)
|
# Final Fantasy (1)
|
||||||
# /worlds/ff1/
|
# /worlds/ff1/
|
||||||
|
|
||||||
# Links Awakening DX
|
|
||||||
# /worlds/ladx/
|
|
||||||
|
|
||||||
# Ocarina of Time
|
# Ocarina of Time
|
||||||
# /worlds/oot/
|
# /worlds/oot/
|
||||||
|
|
||||||
|
|||||||
@@ -370,19 +370,13 @@ target_group_lookup = bake_target_group_lookup(world, get_target_groups)
|
|||||||
|
|
||||||
#### When to call `randomize_entrances`
|
#### When to call `randomize_entrances`
|
||||||
|
|
||||||
The short answer is that you will almost always want to do ER in `pre_fill`. For more information why, continue reading.
|
The correct step for this is `World.connect_entrances`.
|
||||||
|
|
||||||
ER begins by collecting the entire item pool and then uses your access rules to try and prevent some kinds of failures.
|
Currently, you could theoretically do it as early as `World.create_regions` or as late as `pre_fill`.
|
||||||
This means 2 things about when you can call ER:
|
However, there are upcoming changes to Item Plando and Generic Entrance Randomizer to make the two features work better
|
||||||
1. You must supply your item pool before calling ER, or call ER before setting any rules which require items.
|
together.
|
||||||
2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules
|
These changes necessitate that entrance randomization is done exactly in `World.connect_entrances`.
|
||||||
and create your events before you call ER if you want to guarantee a correct output.
|
It is fine for your Entrances to be connected differently or not at all before this step.
|
||||||
|
|
||||||
If the conditions above are met, you could theoretically do ER as early as `create_regions`. However, plando is also
|
|
||||||
a consideration. Since item plando happens between `set_rules` and `pre_fill` and modifies the item pool, doing ER
|
|
||||||
in `pre_fill` is the only way to account for placements made by item plando, otherwise you risk impossible seeds or
|
|
||||||
generation failures. Obviously, if your world implements entrance plando, you will likely want to do that before ER as
|
|
||||||
well.
|
|
||||||
|
|
||||||
#### Informing your client about randomized entrances
|
#### Informing your client about randomized entrances
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
|
|||||||
|
|
||||||
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
|
||||||
|
|
||||||
|
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
|
||||||
|
working in the future.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```javascript
|
```javascript
|
||||||
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
|
||||||
@@ -261,6 +264,7 @@ Sent to clients in response to a [Set](#Set) package if want_reply was set to tr
|
|||||||
| key | str | The key that was updated. |
|
| key | str | The key that was updated. |
|
||||||
| value | any | The new value for the key. |
|
| 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. |
|
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
|
||||||
|
| slot | int | The slot that originally sent the Set package causing this change. |
|
||||||
|
|
||||||
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
|
||||||
|
|
||||||
@@ -359,11 +363,11 @@ An enumeration containing the possible hint states.
|
|||||||
```python
|
```python
|
||||||
import enum
|
import enum
|
||||||
class HintStatus(enum.IntEnum):
|
class HintStatus(enum.IntEnum):
|
||||||
HINT_FOUND = 0 # The location has been collected. Status cannot be changed once found.
|
HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
|
||||||
HINT_UNSPECIFIED = 1 # The receiving player has not specified any status
|
|
||||||
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
|
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
|
||||||
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
|
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
|
||||||
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
|
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
|
||||||
|
HINT_FOUND = 40 # The location has been collected. Status cannot be changed once found.
|
||||||
```
|
```
|
||||||
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
|
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
|
||||||
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
|
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
|
||||||
@@ -529,9 +533,9 @@ In JSON this may look like:
|
|||||||
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
{"item": 3, "location": 3, "player": 3, "flags": 0}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||||
|
|
||||||
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
|
||||||
|
|
||||||
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
|
||||||
|
|
||||||
@@ -744,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
|
|||||||
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
|
||||||
|
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
|
||||||
|
|
||||||
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
|
||||||
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ user hovers over the yellow "(?)" icon, and included in the YAML templates gener
|
|||||||
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
|
||||||
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
|
||||||
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
default for backwards compatibility, world authors are encouraged to write their Option documentation as
|
||||||
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
|
reStructuredText and enable rich text rendering by setting `WebWorld.rich_text_options_doc = True`.
|
||||||
|
|
||||||
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
[reStructuredText]: https://docutils.sourceforge.io/rst.html
|
||||||
|
|
||||||
|
|||||||
@@ -73,11 +73,11 @@ When tests are run, this class will create a multiworld with a single player hav
|
|||||||
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
|
||||||
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
|
||||||
overridden. For more information on what methods are available to your class, check the
|
overridden. For more information on what methods are available to your class, check the
|
||||||
[WorldTestBase definition](/test/bases.py#L104).
|
[WorldTestBase definition](/test/bases.py#L106).
|
||||||
|
|
||||||
#### Alternatives to WorldTestBase
|
#### Alternatives to WorldTestBase
|
||||||
|
|
||||||
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
|
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
|
||||||
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
|
||||||
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
|
||||||
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
testing portions of your code that can be tested without relying on a multiworld to be created first.
|
||||||
|
|||||||
@@ -222,8 +222,8 @@ could also be progress in a research tree, or even something more abstract like
|
|||||||
|
|
||||||
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
|
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
|
||||||
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
|
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
|
||||||
letter or symbol). The ID needs to be unique across all games, and is best kept in the same range as the item IDs.
|
letter or symbol). The ID needs to be unique across all locations within the game.
|
||||||
Locations and items can share IDs, so typically a game's locations and items start at the same ID.
|
Locations and items can share IDs, and locations can share IDs with other games' locations.
|
||||||
|
|
||||||
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
|
||||||
|
|
||||||
@@ -243,7 +243,9 @@ progression. Progression items will be assigned to locations with higher priorit
|
|||||||
and satisfy progression balancing.
|
and satisfy progression balancing.
|
||||||
|
|
||||||
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
|
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
|
||||||
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
|
||||||
|
The ID thus also needs to be unique across all items with different names within the game.
|
||||||
|
Items and locations can share IDs, and items can share IDs with other games' items.
|
||||||
|
|
||||||
Other classifications include:
|
Other classifications include:
|
||||||
|
|
||||||
@@ -289,7 +291,7 @@ like entrance randomization in logic.
|
|||||||
|
|
||||||
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
|
||||||
|
|
||||||
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295-L296)),
|
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
|
||||||
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
|
||||||
|
|
||||||
### Entrances
|
### Entrances
|
||||||
@@ -329,7 +331,7 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
|
|||||||
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
|
||||||
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
|
||||||
|
|
||||||
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L301),
|
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
|
||||||
avoiding the need for indirect conditions at the expense of performance.
|
avoiding the need for indirect conditions at the expense of performance.
|
||||||
|
|
||||||
### Item Rules
|
### Item Rules
|
||||||
@@ -490,6 +492,9 @@ In addition, the following methods can be implemented and are called in this ord
|
|||||||
after this step. Locations cannot be moved to different regions after this step.
|
after this step. Locations cannot be moved to different regions after this step.
|
||||||
* `set_rules(self)`
|
* `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.
|
||||||
|
* `connect_entrances(self)`
|
||||||
|
by the end of this step, all entrances must exist and be connected to their source and target regions.
|
||||||
|
Entrance randomization should be done here.
|
||||||
* `generate_basic(self)`
|
* `generate_basic(self)`
|
||||||
player-specific randomization that does not affect logic can be done here.
|
player-specific randomization that does not affect logic can be done here.
|
||||||
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
|
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
|
||||||
@@ -557,17 +562,13 @@ from .items import is_progression # this is just a dummy
|
|||||||
|
|
||||||
def create_item(self, item: str) -> MyGameItem:
|
def create_item(self, item: str) -> MyGameItem:
|
||||||
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
|
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
|
||||||
classification = ItemClassification.progression if is_progression(item) else
|
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
||||||
ItemClassification.filler
|
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
||||||
|
|
||||||
|
|
||||||
return MyGameItem(item, classification, self.item_name_to_id[item],
|
|
||||||
self.player)
|
|
||||||
|
|
||||||
|
|
||||||
def create_event(self, event: str) -> MyGameItem:
|
def create_event(self, event: str) -> MyGameItem:
|
||||||
# while we are at it, we can also add a helper to create events
|
# while we are at it, we can also add a helper to create events
|
||||||
return MyGameItem(event, True, None, self.player)
|
return MyGameItem(event, ItemClassification.progression, None, self.player)
|
||||||
```
|
```
|
||||||
|
|
||||||
#### create_items
|
#### create_items
|
||||||
@@ -835,14 +836,16 @@ def generate_output(self, output_directory: str) -> None:
|
|||||||
|
|
||||||
### Slot Data
|
### Slot Data
|
||||||
|
|
||||||
If the game client needs to know information about the generated seed, a preferred method of transferring the data
|
If a client or tracker needs to know information about the generated seed, a preferred method of transferring the data
|
||||||
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning
|
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning a `dict` with
|
||||||
a `dict` with `str` keys that can be serialized with json.
|
`str` keys that can be serialized with json. However, to not waste resources, it should be limited to data that is
|
||||||
But, to not waste resources, it should be limited to data that is absolutely necessary. Slot data is sent to your client
|
absolutely necessary. Slot data is sent to your client once it has successfully
|
||||||
once it has successfully [connected](network%20protocol.md#connected).
|
[connected](network%20protocol.md#connected).
|
||||||
|
|
||||||
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
|
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
|
||||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most
|
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. Adding
|
||||||
common usage of slot data is sending option results that the client needs to be aware of.
|
item/location pairs is unnecessary since the AP server already retains and freely gives that information to clients
|
||||||
|
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def fill_slot_data(self) -> Dict[str, Any]:
|
def fill_slot_data(self) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -157,17 +157,16 @@ class ERPlacementState:
|
|||||||
def placed_regions(self) -> set[Region]:
|
def placed_regions(self) -> set[Region]:
|
||||||
return self.collection_state.reachable_regions[self.world.player]
|
return self.collection_state.reachable_regions[self.world.player]
|
||||||
|
|
||||||
def find_placeable_exits(self, check_validity: bool) -> list[Entrance]:
|
def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
|
||||||
if check_validity:
|
if check_validity:
|
||||||
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
||||||
blocked_connections = sorted(blocked_connections, key=lambda x: x.name)
|
placeable_randomized_exits = [ex for ex in usable_exits
|
||||||
placeable_randomized_exits = [connection for connection in blocked_connections
|
if not ex.connected_region
|
||||||
if not connection.connected_region
|
and ex in blocked_connections
|
||||||
and connection.is_valid_source_transition(self)]
|
and ex.is_valid_source_transition(self)]
|
||||||
else:
|
else:
|
||||||
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
# this is on a beaten minimal attempt, so any exit anywhere is fair game
|
||||||
placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player)
|
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
|
||||||
for ex in region.exits if not ex.connected_region]
|
|
||||||
self.world.random.shuffle(placeable_randomized_exits)
|
self.world.random.shuffle(placeable_randomized_exits)
|
||||||
return placeable_randomized_exits
|
return placeable_randomized_exits
|
||||||
|
|
||||||
@@ -181,7 +180,8 @@ class ERPlacementState:
|
|||||||
self.placements.append(source_exit)
|
self.placements.append(source_exit)
|
||||||
self.pairings.append((source_exit.name, target_entrance.name))
|
self.pairings.append((source_exit.name, target_entrance.name))
|
||||||
|
|
||||||
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool:
|
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
|
||||||
|
usable_exits: set[Entrance]) -> bool:
|
||||||
copied_state = self.collection_state.copy()
|
copied_state = self.collection_state.copy()
|
||||||
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
||||||
# propagate back to the real multiworld.
|
# propagate back to the real multiworld.
|
||||||
@@ -198,6 +198,9 @@ class ERPlacementState:
|
|||||||
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
|
||||||
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
||||||
continue
|
continue
|
||||||
|
# make sure we are only paying attention to usable exits
|
||||||
|
if _exit not in usable_exits:
|
||||||
|
continue
|
||||||
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
# technically this should be is_valid_source_transition, but that may rely on side effects from
|
||||||
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
|
||||||
# not want them to persist). can_reach is a close enough approximation most of the time.
|
# not want them to persist). can_reach is a close enough approximation most of the time.
|
||||||
@@ -326,6 +329,24 @@ def randomize_entrances(
|
|||||||
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||||
perform_validity_check = True
|
perform_validity_check = True
|
||||||
|
|
||||||
|
if not er_targets:
|
||||||
|
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
||||||
|
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
||||||
|
if not exits:
|
||||||
|
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
||||||
|
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
||||||
|
if len(er_targets) != len(exits):
|
||||||
|
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
||||||
|
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
||||||
|
|
||||||
|
# used when membership checks are needed on the exit list, e.g. speculative sweep
|
||||||
|
exits_set = set(exits)
|
||||||
|
for entrance in er_targets:
|
||||||
|
entrance_lookup.add(entrance)
|
||||||
|
|
||||||
|
# place the menu region and connected start region(s)
|
||||||
|
er_state.collection_state.update_reachable_regions(world.player)
|
||||||
|
|
||||||
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
|
||||||
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
||||||
# remove the placed targets from consideration
|
# remove the placed targets from consideration
|
||||||
@@ -339,7 +360,7 @@ def randomize_entrances(
|
|||||||
|
|
||||||
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
||||||
nonlocal perform_validity_check
|
nonlocal perform_validity_check
|
||||||
placeable_exits = er_state.find_placeable_exits(perform_validity_check)
|
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
|
||||||
for source_exit in placeable_exits:
|
for source_exit in placeable_exits:
|
||||||
target_groups = target_group_lookup[source_exit.randomization_group]
|
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||||
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
|
||||||
@@ -355,7 +376,7 @@ def randomize_entrances(
|
|||||||
and len(placeable_exits) == 1)
|
and len(placeable_exits) == 1)
|
||||||
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
||||||
if (needs_speculative_sweep
|
if (needs_speculative_sweep
|
||||||
and not er_state.test_speculative_connection(source_exit, target_entrance)):
|
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
|
||||||
continue
|
continue
|
||||||
do_placement(source_exit, target_entrance)
|
do_placement(source_exit, target_entrance)
|
||||||
return True
|
return True
|
||||||
@@ -378,13 +399,14 @@ def randomize_entrances(
|
|||||||
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
|
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
|
||||||
# ensure that we have enough locations to place our progression
|
# ensure that we have enough locations to place our progression
|
||||||
accessible_location_count = 0
|
accessible_location_count = 0
|
||||||
prog_item_count = sum(er_state.collection_state.prog_items[world.player].values())
|
prog_item_count = len([item for item in world.multiworld.itempool if item.advancement and item.player == world.player])
|
||||||
# short-circuit location checking in this case
|
# short-circuit location checking in this case
|
||||||
if prog_item_count == 0:
|
if prog_item_count == 0:
|
||||||
return True
|
return True
|
||||||
for region in er_state.placed_regions:
|
for region in er_state.placed_regions:
|
||||||
for loc in region.locations:
|
for loc in region.locations:
|
||||||
if loc.can_reach(er_state.collection_state):
|
if not loc.item and loc.can_reach(er_state.collection_state):
|
||||||
|
# don't count locations with preplaced items
|
||||||
accessible_location_count += 1
|
accessible_location_count += 1
|
||||||
if accessible_location_count >= prog_item_count:
|
if accessible_location_count >= prog_item_count:
|
||||||
perform_validity_check = False
|
perform_validity_check = False
|
||||||
@@ -406,21 +428,6 @@ def randomize_entrances(
|
|||||||
f"All unplaced entrances: {unplaced_entrances}\n"
|
f"All unplaced entrances: {unplaced_entrances}\n"
|
||||||
f"All unplaced exits: {unplaced_exits}")
|
f"All unplaced exits: {unplaced_exits}")
|
||||||
|
|
||||||
if not er_targets:
|
|
||||||
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
|
|
||||||
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
|
|
||||||
if not exits:
|
|
||||||
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
|
|
||||||
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
|
|
||||||
if len(er_targets) != len(exits):
|
|
||||||
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
|
|
||||||
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
|
|
||||||
for entrance in er_targets:
|
|
||||||
entrance_lookup.add(entrance)
|
|
||||||
|
|
||||||
# place the menu region and connected start region(s)
|
|
||||||
er_state.collection_state.update_reachable_regions(world.player)
|
|
||||||
|
|
||||||
# stage 1 - try to place all the non-dead-end entrances
|
# stage 1 - try to place all the non-dead-end entrances
|
||||||
while entrance_lookup.others:
|
while entrance_lookup.others:
|
||||||
if not find_pairing(dead_end=False, require_new_exits=True):
|
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||||
|
|||||||
19
kvui.py
19
kvui.py
@@ -26,6 +26,10 @@ import Utils
|
|||||||
if Utils.is_frozen():
|
if Utils.is_frozen():
|
||||||
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
|
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
|
||||||
|
|
||||||
|
import platformdirs
|
||||||
|
os.environ["KIVY_HOME"] = os.path.join(platformdirs.user_config_dir("Archipelago", False), "kivy")
|
||||||
|
os.makedirs(os.environ["KIVY_HOME"], exist_ok=True)
|
||||||
|
|
||||||
from kivy.config import Config
|
from kivy.config import Config
|
||||||
|
|
||||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||||
@@ -440,8 +444,11 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
|||||||
if child.collide_point(*touch.pos):
|
if child.collide_point(*touch.pos):
|
||||||
key = child.sort_key
|
key = child.sort_key
|
||||||
if key == "status":
|
if key == "status":
|
||||||
parent.hint_sorter = lambda element: element["status"]["hint"]["status"]
|
parent.hint_sorter = lambda element: status_sort_weights[element["status"]["hint"]["status"]]
|
||||||
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
|
else:
|
||||||
|
parent.hint_sorter = lambda element: (
|
||||||
|
remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||||
|
)
|
||||||
if key == parent.sort_key:
|
if key == parent.sort_key:
|
||||||
# second click reverses order
|
# second click reverses order
|
||||||
parent.reversed = not parent.reversed
|
parent.reversed = not parent.reversed
|
||||||
@@ -825,7 +832,13 @@ status_colors: typing.Dict[HintStatus, str] = {
|
|||||||
HintStatus.HINT_AVOID: "salmon",
|
HintStatus.HINT_AVOID: "salmon",
|
||||||
HintStatus.HINT_PRIORITY: "plum",
|
HintStatus.HINT_PRIORITY: "plum",
|
||||||
}
|
}
|
||||||
|
status_sort_weights: dict[HintStatus, int] = {
|
||||||
|
HintStatus.HINT_FOUND: 0,
|
||||||
|
HintStatus.HINT_UNSPECIFIED: 1,
|
||||||
|
HintStatus.HINT_NO_PRIORITY: 2,
|
||||||
|
HintStatus.HINT_AVOID: 3,
|
||||||
|
HintStatus.HINT_PRIORITY: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class HintLog(RecycleView):
|
class HintLog(RecycleView):
|
||||||
|
|||||||
@@ -2,3 +2,6 @@
|
|||||||
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
|
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
|
||||||
python_classes = Test
|
python_classes = Test
|
||||||
python_functions = test
|
python_functions = test
|
||||||
|
testpaths =
|
||||||
|
test
|
||||||
|
worlds
|
||||||
|
|||||||
25
settings.py
25
settings.py
@@ -109,7 +109,7 @@ class Group:
|
|||||||
def get_type_hints(cls) -> Dict[str, Any]:
|
def get_type_hints(cls) -> Dict[str, Any]:
|
||||||
"""Returns resolved type hints for the class"""
|
"""Returns resolved type hints for the class"""
|
||||||
if cls._type_cache is None:
|
if cls._type_cache is None:
|
||||||
if not isinstance(next(iter(cls.__annotations__.values())), str):
|
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||||
# non-str: assume already resolved
|
# non-str: assume already resolved
|
||||||
cls._type_cache = cls.__annotations__
|
cls._type_cache = cls.__annotations__
|
||||||
else:
|
else:
|
||||||
@@ -270,15 +270,20 @@ class Group:
|
|||||||
# fetch class to avoid going through getattr
|
# fetch class to avoid going through getattr
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
type_hints = cls.get_type_hints()
|
type_hints = cls.get_type_hints()
|
||||||
|
entries = [e for e in self]
|
||||||
|
if not entries:
|
||||||
|
# write empty dict for empty Group with no instance values
|
||||||
|
cls._dump_value({}, f, indent=" " * level)
|
||||||
# validate group
|
# validate group
|
||||||
for name in cls.__annotations__.keys():
|
for name in cls.__annotations__.keys():
|
||||||
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
|
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
|
||||||
# dump ordered members
|
# dump ordered members
|
||||||
for name in self:
|
for name in entries:
|
||||||
attr = cast(object, getattr(self, name))
|
attr = cast(object, getattr(self, name))
|
||||||
attr_cls = type_hints[name] if name in type_hints else attr.__class__
|
attr_cls = type_hints[name] if name in type_hints else attr.__class__
|
||||||
attr_cls_origin = typing.get_origin(attr_cls)
|
attr_cls_origin = typing.get_origin(attr_cls)
|
||||||
while attr_cls_origin is Union: # resolve to first type for doc string
|
# resolve to first type for doc string
|
||||||
|
while attr_cls_origin is Union or attr_cls_origin is types.UnionType:
|
||||||
attr_cls = typing.get_args(attr_cls)[0]
|
attr_cls = typing.get_args(attr_cls)[0]
|
||||||
attr_cls_origin = typing.get_origin(attr_cls)
|
attr_cls_origin = typing.get_origin(attr_cls)
|
||||||
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
|
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
|
||||||
@@ -678,6 +683,8 @@ class GeneratorOptions(Group):
|
|||||||
race: Race = Race(0)
|
race: Race = Race(0)
|
||||||
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
|
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
|
||||||
panic_method: PanicMethod = PanicMethod("swap")
|
panic_method: PanicMethod = PanicMethod("swap")
|
||||||
|
loglevel: str = "info"
|
||||||
|
logtime: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SNIOptions(Group):
|
class SNIOptions(Group):
|
||||||
@@ -785,7 +792,17 @@ class Settings(Group):
|
|||||||
if location:
|
if location:
|
||||||
from Utils import parse_yaml
|
from Utils import parse_yaml
|
||||||
with open(location, encoding="utf-8-sig") as f:
|
with open(location, encoding="utf-8-sig") as f:
|
||||||
options = parse_yaml(f.read())
|
from yaml.error import MarkedYAMLError
|
||||||
|
try:
|
||||||
|
options = parse_yaml(f.read())
|
||||||
|
except MarkedYAMLError as ex:
|
||||||
|
if ex.problem_mark:
|
||||||
|
f.seek(0)
|
||||||
|
lines = f.readlines()
|
||||||
|
problem_line = lines[ex.problem_mark.line]
|
||||||
|
error_line = " " * ex.problem_mark.column + "^"
|
||||||
|
raise Exception(f"{ex.context} {ex.problem}\n{problem_line}{error_line}")
|
||||||
|
raise ex
|
||||||
# TODO: detect if upgrade is required
|
# TODO: detect if upgrade is required
|
||||||
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
|
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
|
||||||
self.update(options or {})
|
self.update(options or {})
|
||||||
|
|||||||
@@ -18,7 +18,15 @@ def run_locations_benchmark():
|
|||||||
|
|
||||||
class BenchmarkRunner:
|
class BenchmarkRunner:
|
||||||
gen_steps: typing.Tuple[str, ...] = (
|
gen_steps: typing.Tuple[str, ...] = (
|
||||||
"generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
"generate_early",
|
||||||
|
"create_regions",
|
||||||
|
"create_items",
|
||||||
|
"set_rules",
|
||||||
|
"connect_entrances",
|
||||||
|
"generate_basic",
|
||||||
|
"pre_fill",
|
||||||
|
)
|
||||||
|
|
||||||
rule_iterations: int = 100_000
|
rule_iterations: int = 100_000
|
||||||
|
|
||||||
if sys.version_info >= (3, 9):
|
if sys.version_info >= (3, 9):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
cmake_minimum_required(VERSION 3.5)
|
cmake_minimum_required(VERSION 3.16)
|
||||||
project(ap-cpp-tests)
|
project(ap-cpp-tests)
|
||||||
|
|
||||||
enable_testing()
|
enable_testing()
|
||||||
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
|
|||||||
|
|
||||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||||
add_definitions("/source-charset:utf-8")
|
add_definitions("/source-charset:utf-8")
|
||||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
# set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
|
||||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
# set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
|
||||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||||
# enable static analysis for gcc
|
# enable static analysis for gcc
|
||||||
add_compile_options(-fanalyzer -Werror)
|
add_compile_options(-fanalyzer -Werror)
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, Mul
|
|||||||
from worlds import network_data_package
|
from worlds import network_data_package
|
||||||
from worlds.AutoWorld import World, call_all
|
from worlds.AutoWorld import World, call_all
|
||||||
|
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
|
gen_steps = (
|
||||||
|
"generate_early",
|
||||||
|
"create_regions",
|
||||||
|
"create_items",
|
||||||
|
"set_rules",
|
||||||
|
"connect_entrances",
|
||||||
|
"generate_basic",
|
||||||
|
"pre_fill",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def setup_solo_multiworld(
|
def setup_solo_multiworld(
|
||||||
|
|||||||
@@ -311,6 +311,37 @@ class TestRandomizeEntrances(unittest.TestCase):
|
|||||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||||
for exit_ in region.exits if not exit_.connected_region])
|
for exit_ in region.exits if not exit_.connected_region])
|
||||||
|
|
||||||
|
def test_minimal_entrance_rando_with_collect_override(self):
|
||||||
|
"""
|
||||||
|
tests that entrance randomization can complete with minimal accessibility and unreachable exits
|
||||||
|
when the world defines a collect override that add extra values to prog_items
|
||||||
|
"""
|
||||||
|
multiworld = generate_test_multiworld()
|
||||||
|
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
|
||||||
|
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
|
||||||
|
generate_disconnected_region_grid(multiworld, 5, 1)
|
||||||
|
prog_items = generate_items(10, 1, True)
|
||||||
|
multiworld.itempool += prog_items
|
||||||
|
filler_items = generate_items(15, 1, False)
|
||||||
|
multiworld.itempool += filler_items
|
||||||
|
e = multiworld.get_entrance("region1_right", 1)
|
||||||
|
set_rule(e, lambda state: False)
|
||||||
|
|
||||||
|
old_collect = multiworld.worlds[1].collect
|
||||||
|
|
||||||
|
def new_collect(state, item):
|
||||||
|
old_collect(state, item)
|
||||||
|
state.prog_items[item.player]["counter"] += 300
|
||||||
|
|
||||||
|
multiworld.worlds[1].collect = new_collect
|
||||||
|
|
||||||
|
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
|
||||||
|
|
||||||
|
self.assertEqual([], [entrance for region in multiworld.get_regions()
|
||||||
|
for entrance in region.entrances if not entrance.parent_region])
|
||||||
|
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||||
|
for exit_ in region.exits if not exit_.connected_region])
|
||||||
|
|
||||||
def test_restrictive_region_requirement_does_not_fail(self):
|
def test_restrictive_region_requirement_does_not_fail(self):
|
||||||
multiworld = generate_test_multiworld()
|
multiworld = generate_test_multiworld()
|
||||||
generate_disconnected_region_grid(multiworld, 2, 1)
|
generate_disconnected_region_grid(multiworld, 2, 1)
|
||||||
|
|||||||
63
test/general/test_entrances.py
Normal file
63
test/general/test_entrances.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import unittest
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister, call_all, World
|
||||||
|
from . import setup_solo_multiworld
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(unittest.TestCase):
|
||||||
|
def test_entrance_connection_steps(self):
|
||||||
|
"""Tests that Entrances are connected and not changed after connect_entrances."""
|
||||||
|
def get_entrance_name_to_source_and_target_dict(world: World):
|
||||||
|
return [
|
||||||
|
(entrance.name, entrance.parent_region, entrance.connected_region)
|
||||||
|
for entrance in world.get_entrances()
|
||||||
|
]
|
||||||
|
|
||||||
|
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||||
|
additional_steps = ("generate_basic", "pre_fill")
|
||||||
|
|
||||||
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest("Game", game_name=game_name):
|
||||||
|
multiworld = setup_solo_multiworld(world_type, gen_steps)
|
||||||
|
|
||||||
|
original_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
all(entrance[1] is not None and entrance[2] is not None for entrance in original_entrances),
|
||||||
|
f"{game_name} had unconnected entrances after connect_entrances"
|
||||||
|
)
|
||||||
|
|
||||||
|
for step in additional_steps:
|
||||||
|
with self.subTest("Step", step=step):
|
||||||
|
call_all(multiworld, step)
|
||||||
|
step_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
original_entrances, step_entrances, f"{game_name} modified entrances during {step}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_state_before_connect_entrances(self):
|
||||||
|
"""Before connect_entrances, Entrance objects may be unconnected.
|
||||||
|
Thus, we test that get_all_state is performed with allow_partial_entrances if used before or during
|
||||||
|
connect_entrances."""
|
||||||
|
|
||||||
|
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
|
||||||
|
|
||||||
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest("Game", game_name=game_name):
|
||||||
|
multiworld = setup_solo_multiworld(world_type, ())
|
||||||
|
|
||||||
|
original_get_all_state = multiworld.get_all_state
|
||||||
|
|
||||||
|
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
|
||||||
|
self.assertTrue(allow_partial_entrances, (
|
||||||
|
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
|
||||||
|
"As such, any call to get_all_state must use allow_partial_entrances = True."
|
||||||
|
))
|
||||||
|
|
||||||
|
return original_get_all_state(use_cache, allow_partial_entrances)
|
||||||
|
|
||||||
|
multiworld.get_all_state = patched_get_all_state
|
||||||
|
|
||||||
|
for step in gen_steps:
|
||||||
|
with self.subTest("Step", step=step):
|
||||||
|
call_all(multiworld, step)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from typing import Callable, Dict, Optional
|
from typing import Callable, Dict, Optional
|
||||||
|
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
from BaseClasses import CollectionState, MultiWorld, Region
|
from BaseClasses import CollectionState, MultiWorld, Region
|
||||||
|
|
||||||
|
|
||||||
@@ -8,6 +10,7 @@ class TestHelpers(unittest.TestCase):
|
|||||||
multiworld: MultiWorld
|
multiworld: MultiWorld
|
||||||
player: int = 1
|
player: int = 1
|
||||||
|
|
||||||
|
@override
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.multiworld = MultiWorld(self.player)
|
self.multiworld = MultiWorld(self.player)
|
||||||
self.multiworld.game[self.player] = "helper_test_game"
|
self.multiworld.game[self.player] = "helper_test_game"
|
||||||
@@ -38,15 +41,15 @@ class TestHelpers(unittest.TestCase):
|
|||||||
"TestRegion1": {"TestRegion2": "connection"},
|
"TestRegion1": {"TestRegion2": "connection"},
|
||||||
"TestRegion2": {"TestRegion1": None},
|
"TestRegion2": {"TestRegion1": None},
|
||||||
}
|
}
|
||||||
|
|
||||||
reg_exit_set: Dict[str, set[str]] = {
|
reg_exit_set: Dict[str, set[str]] = {
|
||||||
"TestRegion1": {"TestRegion3"}
|
"TestRegion1": {"TestRegion3"}
|
||||||
}
|
}
|
||||||
|
|
||||||
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
|
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
|
||||||
"TestRegion1": lambda state: state.has("test_item", self.player)
|
"TestRegion1": lambda state: state.has("test_item", self.player)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
|
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
|
||||||
|
|
||||||
with self.subTest("Test Location Creation Helper"):
|
with self.subTest("Test Location Creation Helper"):
|
||||||
@@ -73,7 +76,7 @@ class TestHelpers(unittest.TestCase):
|
|||||||
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
|
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
|
||||||
self.assertEqual(exit_rules[exit_reg],
|
self.assertEqual(exit_rules[exit_reg],
|
||||||
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
||||||
|
|
||||||
for region in reg_exit_set:
|
for region in reg_exit_set:
|
||||||
current_region = self.multiworld.get_region(region, self.player)
|
current_region = self.multiworld.get_region(region, self.player)
|
||||||
current_region.add_exits(reg_exit_set[region])
|
current_region.add_exits(reg_exit_set[region])
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class TestImplemented(unittest.TestCase):
|
|||||||
"""Tests that if a world creates slot data, it's json serializable."""
|
"""Tests that if a world creates slot data, it's json serializable."""
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
# has an await for generate_output which isn't being called
|
# has an await for generate_output which isn't being called
|
||||||
if game_name in {"Ocarina of Time", "Zillion"}:
|
if game_name in {"Ocarina of Time"}:
|
||||||
continue
|
continue
|
||||||
multiworld = setup_solo_multiworld(world_type)
|
multiworld = setup_solo_multiworld(world_type)
|
||||||
with self.subTest(game=game_name, seed=multiworld.seed):
|
with self.subTest(game=game_name, seed=multiworld.seed):
|
||||||
@@ -117,3 +117,12 @@ class TestImplemented(unittest.TestCase):
|
|||||||
f"\nUnexpectedly reachable locations in sphere {sphere_num}:"
|
f"\nUnexpectedly reachable locations in sphere {sphere_num}:"
|
||||||
f"\n{reachable_only_with_explicit}")
|
f"\n{reachable_only_with_explicit}")
|
||||||
self.fail("Unreachable")
|
self.fail("Unreachable")
|
||||||
|
|
||||||
|
def test_no_items_or_locations_or_regions_submitted_in_init(self):
|
||||||
|
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
|
||||||
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest("Game", game=game_name):
|
||||||
|
multiworld = setup_solo_multiworld(world_type, ())
|
||||||
|
self.assertEqual(len(multiworld.itempool), 0)
|
||||||
|
self.assertEqual(len(multiworld.get_locations()), 0)
|
||||||
|
self.assertEqual(len(multiworld.get_regions()), 0)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from BaseClasses import CollectionState
|
||||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||||
from . import setup_solo_multiworld
|
from . import setup_solo_multiworld
|
||||||
|
|
||||||
@@ -8,12 +9,31 @@ class TestBase(unittest.TestCase):
|
|||||||
def test_create_item(self):
|
def test_create_item(self):
|
||||||
"""Test that a world can successfully create all items in its datapackage"""
|
"""Test that a world can successfully create all items in its datapackage"""
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
|
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
|
||||||
|
proxy_world = multiworld.worlds[1]
|
||||||
for item_name in world_type.item_name_to_id:
|
for item_name in world_type.item_name_to_id:
|
||||||
|
test_state = CollectionState(multiworld)
|
||||||
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
|
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
|
||||||
item = proxy_world.create_item(item_name)
|
item = proxy_world.create_item(item_name)
|
||||||
|
|
||||||
|
with self.subTest("Item Name", item_name=item_name, game_name=game_name):
|
||||||
self.assertEqual(item.name, item_name)
|
self.assertEqual(item.name, item_name)
|
||||||
|
|
||||||
|
if item.advancement:
|
||||||
|
with self.subTest("Item State Collect", item_name=item_name, game_name=game_name):
|
||||||
|
test_state.collect(item, True)
|
||||||
|
|
||||||
|
with self.subTest("Item State Remove", item_name=item_name, game_name=game_name):
|
||||||
|
test_state.remove(item)
|
||||||
|
|
||||||
|
self.assertEqual(test_state.prog_items, multiworld.state.prog_items,
|
||||||
|
"Item Collect -> Remove should restore empty state.")
|
||||||
|
else:
|
||||||
|
with self.subTest("Item State Collect No Change", item_name=item_name, game_name=game_name):
|
||||||
|
# Non-Advancement should not modify state.
|
||||||
|
test_state.collect(item)
|
||||||
|
self.assertEqual(test_state.prog_items, multiworld.state.prog_items)
|
||||||
|
|
||||||
def test_item_name_group_has_valid_item(self):
|
def test_item_name_group_has_valid_item(self):
|
||||||
"""Test that all item name groups contain valid items. """
|
"""Test that all item name groups contain valid items. """
|
||||||
# This cannot test for Event names that you may have declared for logic, only sendable Items.
|
# This cannot test for Event names that you may have declared for logic, only sendable Items.
|
||||||
@@ -67,7 +87,7 @@ class TestBase(unittest.TestCase):
|
|||||||
def test_itempool_not_modified(self):
|
def test_itempool_not_modified(self):
|
||||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||||
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
|
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
|
||||||
worlds_to_test = {game: world
|
worlds_to_test = {game: world
|
||||||
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
|
||||||
@@ -84,7 +104,7 @@ class TestBase(unittest.TestCase):
|
|||||||
def test_locality_not_modified(self):
|
def test_locality_not_modified(self):
|
||||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||||
additional_steps = ("set_rules", "generate_basic", "pre_fill")
|
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
|
||||||
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
|
||||||
for game_name, world_type in worlds_to_test.items():
|
for game_name, world_type in worlds_to_test.items():
|
||||||
with self.subTest("Game", game=game_name):
|
with self.subTest("Game", game=game_name):
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ class TestBase(unittest.TestCase):
|
|||||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||||
f"{game_name} modified locations count during rule creation")
|
f"{game_name} modified locations count during rule creation")
|
||||||
|
|
||||||
|
call_all(multiworld, "connect_entrances")
|
||||||
|
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||||
|
f"{game_name} modified region count during rule creation")
|
||||||
|
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||||
|
f"{game_name} modified locations count during rule creation")
|
||||||
|
|
||||||
call_all(multiworld, "generate_basic")
|
call_all(multiworld, "generate_basic")
|
||||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||||
f"{game_name} modified region count during generate_basic")
|
f"{game_name} modified region count during generate_basic")
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from BaseClasses import MultiWorld
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import setup_solo_multiworld
|
from . import setup_solo_multiworld
|
||||||
|
|
||||||
|
|
||||||
class TestWorldMemory(unittest.TestCase):
|
class TestWorldMemory(unittest.TestCase):
|
||||||
def test_leak(self):
|
def test_leak(self) -> None:
|
||||||
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
|
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
|
||||||
import gc
|
import gc
|
||||||
import weakref
|
import weakref
|
||||||
|
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
|
||||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
with self.subTest("Game", game_name=game_name):
|
with self.subTest("Game creation", game_name=game_name):
|
||||||
weak = weakref.ref(setup_solo_multiworld(world_type))
|
weak = weakref.ref(setup_solo_multiworld(world_type))
|
||||||
gc.collect()
|
refs[game_name] = weak
|
||||||
|
gc.collect()
|
||||||
|
for game_name, weak in refs.items():
|
||||||
|
with self.subTest("Game cleanup", game_name=game_name):
|
||||||
self.assertFalse(weak(), "World leaked a reference")
|
self.assertFalse(weak(), "World leaked a reference")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister
|
|||||||
|
|
||||||
|
|
||||||
class TestNames(unittest.TestCase):
|
class TestNames(unittest.TestCase):
|
||||||
def test_item_names_format(self):
|
def test_item_names_format(self) -> None:
|
||||||
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
with self.subTest(game=gamename):
|
with self.subTest(game=gamename):
|
||||||
@@ -11,7 +11,7 @@ class TestNames(unittest.TestCase):
|
|||||||
self.assertFalse(item_name.isnumeric(),
|
self.assertFalse(item_name.isnumeric(),
|
||||||
f"Item name \"{item_name}\" is invalid. It must not be numeric.")
|
f"Item name \"{item_name}\" is invalid. It must not be numeric.")
|
||||||
|
|
||||||
def test_location_name_format(self):
|
def test_location_name_format(self) -> None:
|
||||||
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
|
||||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||||
with self.subTest(game=gamename):
|
with self.subTest(game=gamename):
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import unittest
|
|||||||
|
|
||||||
from BaseClasses import CollectionState
|
from BaseClasses import CollectionState
|
||||||
from worlds.AutoWorld import AutoWorldRegister
|
from worlds.AutoWorld import AutoWorldRegister
|
||||||
from . import setup_solo_multiworld
|
from . import setup_solo_multiworld, gen_steps
|
||||||
|
|
||||||
|
|
||||||
class TestBase(unittest.TestCase):
|
class TestBase(unittest.TestCase):
|
||||||
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
|
gen_steps = gen_steps
|
||||||
|
|
||||||
default_settings_unreachable_regions = {
|
default_settings_unreachable_regions = {
|
||||||
"A Link to the Past": {
|
"A Link to the Past": {
|
||||||
|
|||||||
29
test/general/test_state.py
Normal file
29
test/general/test_state.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||||
|
from . import setup_solo_multiworld
|
||||||
|
|
||||||
|
|
||||||
|
class TestBase(unittest.TestCase):
|
||||||
|
gen_steps = (
|
||||||
|
"generate_early",
|
||||||
|
"create_regions",
|
||||||
|
)
|
||||||
|
|
||||||
|
test_steps = (
|
||||||
|
"create_items",
|
||||||
|
"set_rules",
|
||||||
|
"connect_entrances",
|
||||||
|
"generate_basic",
|
||||||
|
"pre_fill",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_state_is_available(self):
|
||||||
|
"""Ensure all_state can be created at certain steps."""
|
||||||
|
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||||
|
with self.subTest("Game", game=game_name):
|
||||||
|
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
|
||||||
|
for step in self.test_steps:
|
||||||
|
with self.subTest("Step", step=step):
|
||||||
|
call_all(multiworld, step)
|
||||||
|
self.assertTrue(multiworld.get_all_state(False, True))
|
||||||
@@ -378,6 +378,10 @@ class World(metaclass=AutoWorldRegister):
|
|||||||
"""Method for setting the rules on the World's regions and locations."""
|
"""Method for setting the rules on the World's regions and locations."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def connect_entrances(self) -> None:
|
||||||
|
"""Method to finalize the source and target regions of the World's entrances"""
|
||||||
|
pass
|
||||||
|
|
||||||
def generate_basic(self) -> None:
|
def generate_basic(self) -> None:
|
||||||
"""
|
"""
|
||||||
Useful for randomizing things that don't affect logic but are better to be determined before the output stage.
|
Useful for randomizing things that don't affect logic but are better to be determined before the output stage.
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class Component:
|
|||||||
processes = weakref.WeakSet()
|
processes = weakref.WeakSet()
|
||||||
|
|
||||||
|
|
||||||
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
|
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||||
global processes
|
global processes
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
process = multiprocessing.Process(target=func, name=name, args=args)
|
process = multiprocessing.Process(target=func, name=name, args=args)
|
||||||
@@ -95,6 +95,14 @@ def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] =
|
|||||||
processes.add(process)
|
processes.add(process)
|
||||||
|
|
||||||
|
|
||||||
|
def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
|
||||||
|
from Utils import is_kivy_running
|
||||||
|
if is_kivy_running():
|
||||||
|
launch_subprocess(func, name, args)
|
||||||
|
else:
|
||||||
|
func(*args)
|
||||||
|
|
||||||
|
|
||||||
class SuffixIdentifier:
|
class SuffixIdentifier:
|
||||||
suffixes: Iterable[str]
|
suffixes: Iterable[str]
|
||||||
|
|
||||||
@@ -111,7 +119,7 @@ class SuffixIdentifier:
|
|||||||
|
|
||||||
def launch_textclient(*args):
|
def launch_textclient(*args):
|
||||||
import CommonClient
|
import CommonClient
|
||||||
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
|
launch(CommonClient.run_as_textclient, name="TextClient", args=args)
|
||||||
|
|
||||||
|
|
||||||
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ async def lock(ctx) -> None
|
|||||||
async def unlock(ctx) -> None
|
async def unlock(ctx) -> None
|
||||||
|
|
||||||
async def get_hash(ctx) -> str
|
async def get_hash(ctx) -> str
|
||||||
|
async def get_memory_size(ctx, domain: str) -> int
|
||||||
async def get_system(ctx) -> str
|
async def get_system(ctx) -> str
|
||||||
async def get_cores(ctx) -> dict[str, str]
|
async def get_cores(ctx) -> dict[str, str]
|
||||||
async def ping(ctx) -> None
|
async def ping(ctx) -> None
|
||||||
@@ -168,9 +169,10 @@ select dialog and they will be associated with BizHawkClient. This does not affe
|
|||||||
associate the file extension with Archipelago.
|
associate the file extension with Archipelago.
|
||||||
|
|
||||||
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is
|
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is
|
||||||
running on a system you specified in your `system` class variable. In most cases, that will be a single system and you
|
running on a system you specified in your `system` class variable. Take extra care here, because your code will run
|
||||||
can be sure that you're not about to try to read from nonexistent domains or out of bounds. If you decide to claim this
|
against ROMs that you have no control over. If you're reading an address deep in ROM, you might want to check the size
|
||||||
ROM as yours, this is where you should do setup for things like `items_handling`.
|
of ROM before you attempt to read it using `get_memory_size`. If you decide to claim this ROM as yours, this is where
|
||||||
|
you should do setup for things like `items_handling`.
|
||||||
|
|
||||||
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM.
|
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM.
|
||||||
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do
|
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do
|
||||||
@@ -268,6 +270,8 @@ server connection before trying to interact with it.
|
|||||||
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and
|
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and
|
||||||
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to
|
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to
|
||||||
set it automatically based on data in the ROM or on your client instance.
|
set it automatically based on data in the ROM or on your client instance.
|
||||||
|
- Use `get_memory_size` inside `validate_rom` if you need to read at large addresses, in case some other game has a
|
||||||
|
smaller ROM size.
|
||||||
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a
|
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a
|
||||||
subclass of `CommonContext` and its API.
|
subclass of `CommonContext` and its API.
|
||||||
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at
|
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import base64
|
|||||||
import enum
|
import enum
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import typing
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
|
||||||
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
|
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
|
||||||
@@ -44,10 +44,10 @@ class SyncError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class BizHawkContext:
|
class BizHawkContext:
|
||||||
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
|
streams: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None
|
||||||
connection_status: ConnectionStatus
|
connection_status: ConnectionStatus
|
||||||
_lock: asyncio.Lock
|
_lock: asyncio.Lock
|
||||||
_port: typing.Optional[int]
|
_port: int | None
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.streams = None
|
self.streams = None
|
||||||
@@ -122,12 +122,12 @@ async def get_script_version(ctx: BizHawkContext) -> int:
|
|||||||
return int(await ctx._send_message("VERSION"))
|
return int(await ctx._send_message("VERSION"))
|
||||||
|
|
||||||
|
|
||||||
async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]:
|
async def send_requests(ctx: BizHawkContext, req_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
"""Sends a list of requests to the BizHawk connector and returns their responses.
|
"""Sends a list of requests to the BizHawk connector and returns their responses.
|
||||||
|
|
||||||
It's likely you want to use the wrapper functions instead of this."""
|
It's likely you want to use the wrapper functions instead of this."""
|
||||||
responses = json.loads(await ctx._send_message(json.dumps(req_list)))
|
responses = json.loads(await ctx._send_message(json.dumps(req_list)))
|
||||||
errors: typing.List[ConnectorError] = []
|
errors: list[ConnectorError] = []
|
||||||
|
|
||||||
for response in responses:
|
for response in responses:
|
||||||
if response["type"] == "ERROR":
|
if response["type"] == "ERROR":
|
||||||
@@ -151,7 +151,7 @@ async def ping(ctx: BizHawkContext) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def get_hash(ctx: BizHawkContext) -> str:
|
async def get_hash(ctx: BizHawkContext) -> str:
|
||||||
"""Gets the system name for the currently loaded ROM"""
|
"""Gets the hash value of the currently loaded ROM"""
|
||||||
res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
|
res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
|
||||||
|
|
||||||
if res["type"] != "HASH_RESPONSE":
|
if res["type"] != "HASH_RESPONSE":
|
||||||
@@ -160,6 +160,16 @@ async def get_hash(ctx: BizHawkContext) -> str:
|
|||||||
return res["value"]
|
return res["value"]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_memory_size(ctx: BizHawkContext, domain: str) -> int:
|
||||||
|
"""Gets the size in bytes of the specified memory domain"""
|
||||||
|
res = (await send_requests(ctx, [{"type": "MEMORY_SIZE", "domain": domain}]))[0]
|
||||||
|
|
||||||
|
if res["type"] != "MEMORY_SIZE_RESPONSE":
|
||||||
|
raise SyncError(f"Expected response of type MEMORY_SIZE_RESPONSE but got {res['type']}")
|
||||||
|
|
||||||
|
return res["value"]
|
||||||
|
|
||||||
|
|
||||||
async def get_system(ctx: BizHawkContext) -> str:
|
async def get_system(ctx: BizHawkContext) -> str:
|
||||||
"""Gets the system name for the currently loaded ROM"""
|
"""Gets the system name for the currently loaded ROM"""
|
||||||
res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
|
res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
|
||||||
@@ -170,7 +180,7 @@ async def get_system(ctx: BizHawkContext) -> str:
|
|||||||
return res["value"]
|
return res["value"]
|
||||||
|
|
||||||
|
|
||||||
async def get_cores(ctx: BizHawkContext) -> typing.Dict[str, str]:
|
async def get_cores(ctx: BizHawkContext) -> dict[str, str]:
|
||||||
"""Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have
|
"""Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have
|
||||||
entries."""
|
entries."""
|
||||||
res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0]
|
res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0]
|
||||||
@@ -223,8 +233,8 @@ async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
|
|||||||
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
|
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
|
||||||
|
|
||||||
|
|
||||||
async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]],
|
async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]],
|
||||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]:
|
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> list[bytes] | None:
|
||||||
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
|
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
|
||||||
value.
|
value.
|
||||||
|
|
||||||
@@ -252,7 +262,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
|
|||||||
"domain": domain
|
"domain": domain
|
||||||
} for address, size, domain in read_list])
|
} for address, size, domain in read_list])
|
||||||
|
|
||||||
ret: typing.List[bytes] = []
|
ret: list[bytes] = []
|
||||||
for item in res:
|
for item in res:
|
||||||
if item["type"] == "GUARD_RESPONSE":
|
if item["type"] == "GUARD_RESPONSE":
|
||||||
if not item["value"]:
|
if not item["value"]:
|
||||||
@@ -266,7 +276,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
|
async def read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]]) -> list[bytes]:
|
||||||
"""Reads data at 1 or more addresses.
|
"""Reads data at 1 or more addresses.
|
||||||
|
|
||||||
Items in `read_list` should be organized `(address, size, domain)` where
|
Items in `read_list` should be organized `(address, size, domain)` where
|
||||||
@@ -278,8 +288,8 @@ async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int,
|
|||||||
return await guarded_read(ctx, read_list, [])
|
return await guarded_read(ctx, read_list, [])
|
||||||
|
|
||||||
|
|
||||||
async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]],
|
async def guarded_write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]],
|
||||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool:
|
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> bool:
|
||||||
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
|
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
|
||||||
|
|
||||||
Items in `write_list` should be organized `(address, value, domain)` where
|
Items in `write_list` should be organized `(address, value, domain)` where
|
||||||
@@ -316,7 +326,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None:
|
async def write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]]) -> None:
|
||||||
"""Writes data to 1 or more addresses.
|
"""Writes data to 1 or more addresses.
|
||||||
|
|
||||||
Items in write_list should be organized `(address, value, domain)` where
|
Items in write_list should be organized `(address, value, domain)` where
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ A module containing the BizHawkClient base class and metaclass
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
|
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch as launch_component
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .context import BizHawkClientContext
|
from .context import BizHawkClientContext
|
||||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
def launch_client(*args) -> None:
|
def launch_client(*args) -> None:
|
||||||
from .context import launch
|
from .context import launch
|
||||||
launch_subprocess(launch, name="BizHawkClient", args=args)
|
launch_component(launch, name="BizHawkClient", args=args)
|
||||||
|
|
||||||
|
|
||||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||||
@@ -24,9 +24,9 @@ components.append(component)
|
|||||||
|
|
||||||
|
|
||||||
class AutoBizHawkClientRegister(abc.ABCMeta):
|
class AutoBizHawkClientRegister(abc.ABCMeta):
|
||||||
game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {}
|
game_handlers: ClassVar[dict[tuple[str, ...], dict[str, BizHawkClient]]] = {}
|
||||||
|
|
||||||
def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister:
|
def __new__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> AutoBizHawkClientRegister:
|
||||||
new_class = super().__new__(cls, name, bases, namespace)
|
new_class = super().__new__(cls, name, bases, namespace)
|
||||||
|
|
||||||
# Register handler
|
# Register handler
|
||||||
@@ -54,7 +54,7 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
|
|||||||
return new_class
|
return new_class
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
|
async def get_handler(ctx: "BizHawkClientContext", system: str) -> BizHawkClient | None:
|
||||||
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
|
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
|
||||||
if system in systems:
|
if system in systems:
|
||||||
for handler in handlers.values():
|
for handler in handlers.values():
|
||||||
@@ -65,13 +65,13 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
|
|||||||
|
|
||||||
|
|
||||||
class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
|
class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
|
||||||
system: ClassVar[Union[str, Tuple[str, ...]]]
|
system: ClassVar[str | tuple[str, ...]]
|
||||||
"""The system(s) that the game this client is for runs on"""
|
"""The system(s) that the game this client is for runs on"""
|
||||||
|
|
||||||
game: ClassVar[str]
|
game: ClassVar[str]
|
||||||
"""The game this client is for"""
|
"""The game this client is for"""
|
||||||
|
|
||||||
patch_suffix: ClassVar[Optional[Union[str, Tuple[str, ...]]]]
|
patch_suffix: ClassVar[str | tuple[str, ...] | None]
|
||||||
"""The file extension(s) this client is meant to open and patch (e.g. ".apz3")"""
|
"""The file extension(s) this client is meant to open and patch (e.g. ".apz3")"""
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ checking or launching the client, otherwise it will probably cause circular impo
|
|||||||
import asyncio
|
import asyncio
|
||||||
import enum
|
import enum
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any
|
||||||
|
|
||||||
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
||||||
import Patch
|
import Patch
|
||||||
@@ -41,17 +41,18 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
|
|||||||
|
|
||||||
class BizHawkClientContext(CommonContext):
|
class BizHawkClientContext(CommonContext):
|
||||||
command_processor = BizHawkClientCommandProcessor
|
command_processor = BizHawkClientCommandProcessor
|
||||||
|
server_seed_name: str | None = None
|
||||||
auth_status: AuthStatus
|
auth_status: AuthStatus
|
||||||
password_requested: bool
|
password_requested: bool
|
||||||
client_handler: Optional[BizHawkClient]
|
client_handler: BizHawkClient | None
|
||||||
slot_data: Optional[Dict[str, Any]] = None
|
slot_data: dict[str, Any] | None = None
|
||||||
rom_hash: Optional[str] = None
|
rom_hash: str | None = None
|
||||||
bizhawk_ctx: BizHawkContext
|
bizhawk_ctx: BizHawkContext
|
||||||
|
|
||||||
watcher_timeout: float
|
watcher_timeout: float
|
||||||
"""The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
|
"""The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
|
||||||
|
|
||||||
def __init__(self, server_address: Optional[str], password: Optional[str]):
|
def __init__(self, server_address: str | None, password: str | None):
|
||||||
super().__init__(server_address, password)
|
super().__init__(server_address, password)
|
||||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||||
self.password_requested = False
|
self.password_requested = False
|
||||||
@@ -68,6 +69,8 @@ class BizHawkClientContext(CommonContext):
|
|||||||
if cmd == "Connected":
|
if cmd == "Connected":
|
||||||
self.slot_data = args.get("slot_data", None)
|
self.slot_data = args.get("slot_data", None)
|
||||||
self.auth_status = AuthStatus.AUTHENTICATED
|
self.auth_status = AuthStatus.AUTHENTICATED
|
||||||
|
elif cmd == "RoomInfo":
|
||||||
|
self.server_seed_name = args.get("seed_name", None)
|
||||||
|
|
||||||
if self.client_handler is not None:
|
if self.client_handler is not None:
|
||||||
self.client_handler.on_package(self, cmd, args)
|
self.client_handler.on_package(self, cmd, args)
|
||||||
@@ -100,6 +103,7 @@ class BizHawkClientContext(CommonContext):
|
|||||||
|
|
||||||
async def disconnect(self, allow_autoreconnect: bool=False):
|
async def disconnect(self, allow_autoreconnect: bool=False):
|
||||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||||
|
self.server_seed_name = None
|
||||||
await super().disconnect(allow_autoreconnect)
|
await super().disconnect(allow_autoreconnect)
|
||||||
|
|
||||||
|
|
||||||
@@ -231,20 +235,28 @@ async def _run_game(rom: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _patch_and_run_game(patch_file: str):
|
def _patch_and_run_game(patch_file: str):
|
||||||
try:
|
try:
|
||||||
metadata, output_file = Patch.create_rom_file(patch_file)
|
metadata, output_file = Patch.create_rom_file(patch_file)
|
||||||
Utils.async_start(_run_game(output_file))
|
Utils.async_start(_run_game(output_file))
|
||||||
|
return metadata
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(exc)
|
logger.exception(exc)
|
||||||
|
Utils.messagebox("Error Patching Game", str(exc), True)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def launch(*launch_args) -> None:
|
def launch(*launch_args: str) -> None:
|
||||||
async def main():
|
async def main():
|
||||||
parser = get_base_parser()
|
parser = get_base_parser()
|
||||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
||||||
args = parser.parse_args(launch_args)
|
args = parser.parse_args(launch_args)
|
||||||
|
|
||||||
|
if args.patch_file != "":
|
||||||
|
metadata = _patch_and_run_game(args.patch_file)
|
||||||
|
if "server" in metadata:
|
||||||
|
args.connect = metadata["server"]
|
||||||
|
|
||||||
ctx = BizHawkClientContext(args.connect, args.password)
|
ctx = BizHawkClientContext(args.connect, args.password)
|
||||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||||
|
|
||||||
@@ -252,9 +264,6 @@ def launch(*launch_args) -> None:
|
|||||||
ctx.run_gui()
|
ctx.run_gui()
|
||||||
ctx.run_cli()
|
ctx.run_cli()
|
||||||
|
|
||||||
if args.patch_file != "":
|
|
||||||
Utils.async_start(_patch_and_run_game(args.patch_file))
|
|
||||||
|
|
||||||
watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
|
watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
|
||||||
|
from Options import Choice, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
||||||
|
|
||||||
|
|
||||||
class FreeincarnateMax(Range):
|
class FreeincarnateMax(Range):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
||||||
from Options import PerGameCommonOptions
|
from Options import PerGameCommonOptions
|
||||||
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
from .Locations import location_table, AdventureLocation, dragon_room_to_region
|
||||||
|
|
||||||
|
|
||||||
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
|
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import zipfile
|
import zipfile
|
||||||
from typing import Optional, Any
|
from typing import Any
|
||||||
|
|
||||||
import Utils
|
|
||||||
from .Locations import AdventureLocation, LocationData
|
|
||||||
from settings import get_settings
|
|
||||||
from worlds.Files import APPatch, AutoPatchRegister
|
|
||||||
|
|
||||||
import bsdiff4
|
import bsdiff4
|
||||||
|
|
||||||
|
import Utils
|
||||||
|
from settings import get_settings
|
||||||
|
from worlds.Files import APPatch, AutoPatchRegister
|
||||||
|
from .Locations import LocationData
|
||||||
|
|
||||||
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
|
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
import base64
|
|
||||||
import copy
|
import copy
|
||||||
import itertools
|
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import settings
|
|
||||||
import typing
|
import typing
|
||||||
from enum import IntFlag
|
from typing import ClassVar, Dict, Optional, Tuple
|
||||||
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
|
|
||||||
|
|
||||||
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
|
import settings
|
||||||
LocationProgressType
|
from BaseClasses import Item, ItemClassification, MultiWorld, Tutorial, LocationProgressType
|
||||||
from Utils import __version__
|
from Utils import __version__
|
||||||
from Options import AssembleOptions
|
|
||||||
from worlds.AutoWorld import WebWorld, World
|
from worlds.AutoWorld import WebWorld, World
|
||||||
from Fill import fill_restrictive
|
from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
||||||
from worlds.generic.Rules import add_rule, set_rule
|
|
||||||
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
|
|
||||||
AdventureOptions
|
|
||||||
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
|
|
||||||
AdventureAutoCollectLocation
|
|
||||||
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
|
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
|
||||||
from .Locations import location_table, base_location_id, LocationData, get_random_room_in_regions
|
from .Locations import location_table, base_location_id, LocationData, get_random_room_in_regions
|
||||||
from .Offsets import static_item_data_location, items_ram_start, static_item_element_size, item_position_table, \
|
from .Offsets import static_item_data_location, items_ram_start, static_item_element_size, item_position_table, \
|
||||||
static_first_dragon_index, connector_port_offset, yorgle_speed_data_location, grundle_speed_data_location, \
|
static_first_dragon_index, connector_port_offset, yorgle_speed_data_location, grundle_speed_data_location, \
|
||||||
rhindle_speed_data_location, item_ram_addresses, start_castle_values, start_castle_offset
|
rhindle_speed_data_location, item_ram_addresses, start_castle_values, start_castle_offset
|
||||||
|
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, AdventureOptions
|
||||||
from .Regions import create_regions
|
from .Regions import create_regions
|
||||||
|
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, AdventureAutoCollectLocation
|
||||||
from .Rules import set_rules
|
from .Rules import set_rules
|
||||||
|
|
||||||
|
|
||||||
from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
|
||||||
|
|
||||||
# Adventure
|
# Adventure
|
||||||
components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn')))
|
components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn')))
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,12 @@ def set_dw_rules(world: "HatInTimeWorld"):
|
|||||||
add_dw_rules(world, all_clear)
|
add_dw_rules(world, all_clear)
|
||||||
add_rule(main_stamp, main_objective.access_rule)
|
add_rule(main_stamp, main_objective.access_rule)
|
||||||
add_rule(all_clear, main_objective.access_rule)
|
add_rule(all_clear, main_objective.access_rule)
|
||||||
# Only set bonus stamp rules if we don't auto complete bonuses
|
# Only set bonus stamp rules to require All Clear if we don't auto complete bonuses
|
||||||
if not world.options.DWAutoCompleteBonuses and not world.is_bonus_excluded(all_clear.name):
|
if not world.options.DWAutoCompleteBonuses and not world.is_bonus_excluded(all_clear.name):
|
||||||
add_rule(bonus_stamps, all_clear.access_rule)
|
add_rule(bonus_stamps, all_clear.access_rule)
|
||||||
|
else:
|
||||||
|
# As soon as the Main Objective is completed, the bonuses auto-complete.
|
||||||
|
add_rule(bonus_stamps, main_objective.access_rule)
|
||||||
|
|
||||||
if world.options.DWShuffle:
|
if world.options.DWShuffle:
|
||||||
for i in range(len(world.dw_shuffle)-1):
|
for i in range(len(world.dw_shuffle)-1):
|
||||||
@@ -343,6 +346,7 @@ def create_enemy_events(world: "HatInTimeWorld"):
|
|||||||
|
|
||||||
def set_enemy_rules(world: "HatInTimeWorld"):
|
def set_enemy_rules(world: "HatInTimeWorld"):
|
||||||
no_tourist = "Camera Tourist" in world.excluded_dws or "Camera Tourist" in world.excluded_bonuses
|
no_tourist = "Camera Tourist" in world.excluded_dws or "Camera Tourist" in world.excluded_bonuses
|
||||||
|
difficulty = get_difficulty(world)
|
||||||
|
|
||||||
for enemy, regions in hit_list.items():
|
for enemy, regions in hit_list.items():
|
||||||
if no_tourist and enemy in bosses:
|
if no_tourist and enemy in bosses:
|
||||||
@@ -372,6 +376,14 @@ def set_enemy_rules(world: "HatInTimeWorld"):
|
|||||||
or state.has("Zipline Unlock - The Lava Cake Path", world.player)
|
or state.has("Zipline Unlock - The Lava Cake Path", world.player)
|
||||||
or state.has("Zipline Unlock - The Windmill Path", world.player))
|
or state.has("Zipline Unlock - The Windmill Path", world.player))
|
||||||
|
|
||||||
|
elif enemy == "Toilet":
|
||||||
|
if area == "Toilet of Doom":
|
||||||
|
# The boss firewall is in the way and can only be skipped on Expert logic using a cherry hover.
|
||||||
|
add_rule(event, lambda state: has_paintings(state, world, 1, allow_skip=difficulty == Difficulty.EXPERT))
|
||||||
|
if difficulty < Difficulty.HARD:
|
||||||
|
# Hard logic and above can cross the boss arena gap with a cherry bridge.
|
||||||
|
add_rule(event, lambda state: can_use_hookshot(state, world))
|
||||||
|
|
||||||
elif enemy == "Director":
|
elif enemy == "Director":
|
||||||
if area == "Dead Bird Studio Basement":
|
if area == "Dead Bird Studio Basement":
|
||||||
add_rule(event, lambda state: can_use_hookshot(state, world))
|
add_rule(event, lambda state: can_use_hookshot(state, world))
|
||||||
@@ -430,7 +442,7 @@ hit_list = {
|
|||||||
# Bosses
|
# Bosses
|
||||||
"Mafia Boss": ["Down with the Mafia!", "Encore! Encore!", "Boss Rush"],
|
"Mafia Boss": ["Down with the Mafia!", "Encore! Encore!", "Boss Rush"],
|
||||||
|
|
||||||
"Conductor": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
|
"Director": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
|
||||||
"Toilet": ["Toilet of Doom", "Boss Rush"],
|
"Toilet": ["Toilet of Doom", "Boss Rush"],
|
||||||
|
|
||||||
"Snatcher": ["Your Contract has Expired", "Breaching the Contract", "Boss Rush",
|
"Snatcher": ["Your Contract has Expired", "Breaching the Contract", "Boss Rush",
|
||||||
@@ -454,7 +466,7 @@ triple_enemy_locations = [
|
|||||||
|
|
||||||
bosses = [
|
bosses = [
|
||||||
"Mafia Boss",
|
"Mafia Boss",
|
||||||
"Conductor",
|
"Director",
|
||||||
"Toilet",
|
"Toilet",
|
||||||
"Snatcher",
|
"Snatcher",
|
||||||
"Toxic Flower",
|
"Toxic Flower",
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ ahit_locations = {
|
|||||||
required_hats=[HatType.DWELLER], paintings=3),
|
required_hats=[HatType.DWELLER], paintings=3),
|
||||||
|
|
||||||
"Subcon Forest - Tall Tree Hookshot Swing": LocData(2000324766, "Subcon Forest Area",
|
"Subcon Forest - Tall Tree Hookshot Swing": LocData(2000324766, "Subcon Forest Area",
|
||||||
required_hats=[HatType.DWELLER],
|
|
||||||
hookshot=True,
|
hookshot=True,
|
||||||
paintings=3),
|
paintings=3),
|
||||||
|
|
||||||
@@ -323,7 +322,7 @@ ahit_locations = {
|
|||||||
"Alpine Skyline - The Twilight Path": LocData(2000334434, "Alpine Skyline Area", required_hats=[HatType.DWELLER]),
|
"Alpine Skyline - The Twilight Path": LocData(2000334434, "Alpine Skyline Area", required_hats=[HatType.DWELLER]),
|
||||||
"Alpine Skyline - The Twilight Bell: Wide Purple Platform": LocData(2000336478, "The Twilight Bell"),
|
"Alpine Skyline - The Twilight Bell: Wide Purple Platform": LocData(2000336478, "The Twilight Bell"),
|
||||||
"Alpine Skyline - The Twilight Bell: Ice Platform": LocData(2000335826, "The Twilight Bell"),
|
"Alpine Skyline - The Twilight Bell: Ice Platform": LocData(2000335826, "The Twilight Bell"),
|
||||||
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area"),
|
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area (TIHS)", hookshot=True),
|
||||||
"Alpine Skyline - Windy Passage": LocData(2000334776, "Alpine Skyline Area (TIHS)", hookshot=True),
|
"Alpine Skyline - Windy Passage": LocData(2000334776, "Alpine Skyline Area (TIHS)", hookshot=True),
|
||||||
"Alpine Skyline - The Windmill: Inside Pon Cluster": LocData(2000336395, "The Windmill"),
|
"Alpine Skyline - The Windmill: Inside Pon Cluster": LocData(2000336395, "The Windmill"),
|
||||||
"Alpine Skyline - The Windmill: Entrance": LocData(2000335783, "The Windmill"),
|
"Alpine Skyline - The Windmill: Entrance": LocData(2000335783, "The Windmill"),
|
||||||
@@ -407,7 +406,7 @@ act_completions = {
|
|||||||
hit_type=HitType.umbrella_or_brewing, hookshot=True, paintings=1),
|
hit_type=HitType.umbrella_or_brewing, hookshot=True, paintings=1),
|
||||||
|
|
||||||
"Act Completion (Queen Vanessa's Manor)": LocData(2000312017, "Queen Vanessa's Manor",
|
"Act Completion (Queen Vanessa's Manor)": LocData(2000312017, "Queen Vanessa's Manor",
|
||||||
hit_type=HitType.umbrella, paintings=1),
|
hit_type=HitType.dweller_bell, paintings=1),
|
||||||
|
|
||||||
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
|
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
|
||||||
required_hats=[HatType.SPRINT]),
|
required_hats=[HatType.SPRINT]),
|
||||||
@@ -878,7 +877,7 @@ snatcher_coins = {
|
|||||||
dlc_flags=HatDLC.death_wish),
|
dlc_flags=HatDLC.death_wish),
|
||||||
|
|
||||||
"Snatcher Coin - Top of HQ (DW: BTH)": LocData(0, "Beat the Heat", snatcher_coin="Snatcher Coin - Top of HQ",
|
"Snatcher Coin - Top of HQ (DW: BTH)": LocData(0, "Beat the Heat", snatcher_coin="Snatcher Coin - Top of HQ",
|
||||||
dlc_flags=HatDLC.death_wish),
|
hit_type=HitType.umbrella, dlc_flags=HatDLC.death_wish),
|
||||||
|
|
||||||
"Snatcher Coin - Top of Tower": LocData(0, "Mafia Town Area (HUMT)", snatcher_coin="Snatcher Coin - Top of Tower",
|
"Snatcher Coin - Top of Tower": LocData(0, "Mafia Town Area (HUMT)", snatcher_coin="Snatcher Coin - Top of Tower",
|
||||||
dlc_flags=HatDLC.death_wish),
|
dlc_flags=HatDLC.death_wish),
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ class MinExtraYarn(Range):
|
|||||||
There must be at least this much more yarn over the total number of yarn needed to craft all hats.
|
There must be at least this much more yarn over the total number of yarn needed to craft all hats.
|
||||||
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
|
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
|
||||||
there must be at least 50 yarn in the pool."""
|
there must be at least 50 yarn in the pool."""
|
||||||
display_name = "Max Extra Yarn"
|
display_name = "Min Extra Yarn"
|
||||||
range_start = 5
|
range_start = 5
|
||||||
range_end = 15
|
range_end = 15
|
||||||
default = 10
|
default = 10
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
|||||||
|
|
||||||
# Moderate: Mystifying Time Mesa time trial without hats
|
# Moderate: Mystifying Time Mesa time trial without hats
|
||||||
set_rule(world.multiworld.get_location("Alpine Skyline - Mystifying Time Mesa: Zipline", world.player),
|
set_rule(world.multiworld.get_location("Alpine Skyline - Mystifying Time Mesa: Zipline", world.player),
|
||||||
lambda state: can_use_hookshot(state, world))
|
lambda state: True)
|
||||||
|
|
||||||
# Moderate: Goat Refinery from TIHS with Sprint only
|
# Moderate: Goat Refinery from TIHS with Sprint only
|
||||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||||
@@ -493,9 +493,6 @@ def set_hard_rules(world: "HatInTimeWorld"):
|
|||||||
lambda state: has_paintings(state, world, 3, True))
|
lambda state: has_paintings(state, world, 3, True))
|
||||||
|
|
||||||
# SDJ
|
# SDJ
|
||||||
add_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player),
|
|
||||||
lambda state: can_use_hat(state, world, HatType.SPRINT) and has_paintings(state, world, 2), "or")
|
|
||||||
|
|
||||||
add_rule(world.multiworld.get_location("Act Completion (Time Rift - Curly Tail Trail)", world.player),
|
add_rule(world.multiworld.get_location("Act Completion (Time Rift - Curly Tail Trail)", world.player),
|
||||||
lambda state: can_use_hat(state, world, HatType.SPRINT), "or")
|
lambda state: can_use_hat(state, world, HatType.SPRINT), "or")
|
||||||
|
|
||||||
@@ -533,7 +530,10 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
|||||||
# Expert: Mafia Town - Above Boats, Top of Lighthouse, and Hot Air Balloon with nothing
|
# Expert: Mafia Town - Above Boats, Top of Lighthouse, and Hot Air Balloon with nothing
|
||||||
set_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player), lambda state: True)
|
set_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player), lambda state: True)
|
||||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Lighthouse", world.player), lambda state: True)
|
set_rule(world.multiworld.get_location("Mafia Town - Top of Lighthouse", world.player), lambda state: True)
|
||||||
set_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player), lambda state: True)
|
# There are not enough buckets/beach balls to bucket/ball hover in Heating Up Mafia Town, so any other Mafia Town
|
||||||
|
# act is required.
|
||||||
|
add_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player),
|
||||||
|
lambda state: state.can_reach_region("Mafia Town Area", world.player), "or")
|
||||||
|
|
||||||
# Expert: Clear Dead Bird Studio with nothing
|
# Expert: Clear Dead Bird Studio with nothing
|
||||||
for loc in world.multiworld.get_region("Dead Bird Studio - Post Elevator Area", world.player).locations:
|
for loc in world.multiworld.get_region("Dead Bird Studio - Post Elevator Area", world.player).locations:
|
||||||
@@ -590,7 +590,7 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
|||||||
|
|
||||||
if world.is_dlc2():
|
if world.is_dlc2():
|
||||||
# Expert: clear Rush Hour with nothing
|
# Expert: clear Rush Hour with nothing
|
||||||
if not world.options.NoTicketSkips:
|
if world.options.NoTicketSkips != NoTicketSkips.option_true:
|
||||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), lambda state: True)
|
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), lambda state: True)
|
||||||
else:
|
else:
|
||||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||||
@@ -739,7 +739,7 @@ def set_dlc1_rules(world: "HatInTimeWorld"):
|
|||||||
|
|
||||||
# This particular item isn't present in Act 3 for some reason, yes in vanilla too
|
# This particular item isn't present in Act 3 for some reason, yes in vanilla too
|
||||||
add_rule(world.multiworld.get_location("The Arctic Cruise - Toilet", world.player),
|
add_rule(world.multiworld.get_location("The Arctic Cruise - Toilet", world.player),
|
||||||
lambda state: state.can_reach("Bon Voyage!", "Region", world.player)
|
lambda state: (state.can_reach("Bon Voyage!", "Region", world.player) and can_use_hookshot(state, world))
|
||||||
or state.can_reach("Ship Shape", "Region", world.player))
|
or state.can_reach("Ship Shape", "Region", world.player))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ from .DeathWishRules import set_dw_rules, create_enemy_events, hit_list, bosses
|
|||||||
from worlds.AutoWorld import World, WebWorld, CollectionState
|
from worlds.AutoWorld import World, WebWorld, CollectionState
|
||||||
from worlds.generic.Rules import add_rule
|
from worlds.generic.Rules import add_rule
|
||||||
from typing import List, Dict, TextIO
|
from typing import List, Dict, TextIO
|
||||||
from worlds.LauncherComponents import Component, components, icon_paths, launch_subprocess, Type
|
from worlds.LauncherComponents import Component, components, icon_paths, launch as launch_component, Type
|
||||||
from Utils import local_path
|
from Utils import local_path
|
||||||
|
|
||||||
|
|
||||||
def launch_client():
|
def launch_client():
|
||||||
from .Client import launch
|
from .Client import launch
|
||||||
launch_subprocess(launch, name="AHITClient")
|
launch_component(launch, name="AHITClient")
|
||||||
|
|
||||||
|
|
||||||
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
|
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
|
|
||||||
3. Click the **Betas** tab. In the **Beta Participation** dropdown, select `tcplink`.
|
3. Click the **Betas** tab. In the **Beta Participation** dropdown, select `tcplink`.
|
||||||
While it downloads, you can subscribe to the [Archipelago workshop mod.]((https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601))
|
While it downloads, you can subscribe to the [Archipelago workshop mod](https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601).
|
||||||
|
|
||||||
|
|
||||||
4. Once the game finishes downloading, start it up.
|
4. Once the game finishes downloading, start it up.
|
||||||
@@ -62,4 +62,4 @@ The level that the relic set unlocked will stay unlocked.
|
|||||||
|
|
||||||
### When I start a new save file, the intro cinematic doesn't get skipped, Hat Kid's body is missing and the mod doesn't work!
|
### When I start a new save file, the intro cinematic doesn't get skipped, Hat Kid's body is missing and the mod doesn't work!
|
||||||
There is a bug on older versions of A Hat in Time that causes save file creation to fail to work properly
|
There is a bug on older versions of A Hat in Time that causes save file creation to fail to work properly
|
||||||
if you have too many save files. Delete them and it should fix the problem.
|
if you have too many save files. Delete them and it should fix the problem.
|
||||||
|
|||||||
@@ -119,7 +119,9 @@ def KholdstareDefeatRule(state, player: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def VitreousDefeatRule(state, player: int) -> bool:
|
def VitreousDefeatRule(state, player: int) -> bool:
|
||||||
return can_shoot_arrows(state, player) or has_melee_weapon(state, player)
|
return ((can_shoot_arrows(state, player) and can_use_bombs(state, player, 10))
|
||||||
|
or can_shoot_arrows(state, player, 35) or state.has("Silver Bow", player)
|
||||||
|
or has_melee_weapon(state, player))
|
||||||
|
|
||||||
|
|
||||||
def TrinexxDefeatRule(state, player: int) -> bool:
|
def TrinexxDefeatRule(state, player: int) -> bool:
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
|
|||||||
snes_logger.info(f"Discarding recent {len(new_locations)} checks as ROM Status has changed.")
|
snes_logger.info(f"Discarding recent {len(new_locations)} checks as ROM Status has changed.")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
await ctx.check_locations(new_locations)
|
||||||
await snes_flush_writes(ctx)
|
await snes_flush_writes(ctx)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -484,8 +484,7 @@ def generate_itempool(world):
|
|||||||
if multiworld.randomize_cost_types[player]:
|
if multiworld.randomize_cost_types[player]:
|
||||||
# Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic
|
# Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic
|
||||||
for item in items:
|
for item in items:
|
||||||
if (item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart")
|
if item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart"):
|
||||||
or "Arrow Upgrade" in item.name):
|
|
||||||
item.classification = ItemClassification.progression
|
item.classification = ItemClassification.progression
|
||||||
else:
|
else:
|
||||||
# Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives)
|
# Otherwise, logic has some branches where having 4 hearts is one possible requirement (of several alternatives)
|
||||||
@@ -713,7 +712,7 @@ def get_pool_core(world, player: int):
|
|||||||
pool.remove("Rupees (20)")
|
pool.remove("Rupees (20)")
|
||||||
|
|
||||||
if retro_bow:
|
if retro_bow:
|
||||||
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (50)'}
|
replace = {'Single Arrow', 'Arrows (10)', 'Arrow Upgrade (+5)', 'Arrow Upgrade (+10)', 'Arrow Upgrade (70)'}
|
||||||
pool = ['Rupees (5)' if item in replace else item for item in pool]
|
pool = ['Rupees (5)' if item in replace else item for item in pool]
|
||||||
if world.small_key_shuffle[player] == small_key_shuffle.option_universal:
|
if world.small_key_shuffle[player] == small_key_shuffle.option_universal:
|
||||||
pool.extend(diff.universal_keys)
|
pool.extend(diff.universal_keys)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from worlds.AutoWorld import World
|
|||||||
def GetBeemizerItem(world, player: int, item):
|
def GetBeemizerItem(world, player: int, item):
|
||||||
item_name = item if isinstance(item, str) else item.name
|
item_name = item if isinstance(item, str) else item.name
|
||||||
|
|
||||||
if item_name not in trap_replaceable:
|
if item_name not in trap_replaceable or player in world.groups:
|
||||||
return item
|
return item
|
||||||
|
|
||||||
# first roll - replaceable item should be replaced, within beemizer_total_chance
|
# first roll - replaceable item should be replaced, within beemizer_total_chance
|
||||||
@@ -110,9 +110,9 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\
|
|||||||
'Crystal 7': ItemData(IC.progression, 'Crystal', (0x08, 0x34, 0x64, 0x40, 0x7C, 0x06), None, None, None, None, None, None, "a blue crystal"),
|
'Crystal 7': ItemData(IC.progression, 'Crystal', (0x08, 0x34, 0x64, 0x40, 0x7C, 0x06), None, None, None, None, None, None, "a blue crystal"),
|
||||||
'Single Arrow': ItemData(IC.filler, None, 0x43, 'a lonely arrow\nsits here.', 'and the arrow', 'stick-collecting kid', 'sewing needle for sale', 'fungus for arrow', 'archer boy sews again', 'an arrow'),
|
'Single Arrow': ItemData(IC.filler, None, 0x43, 'a lonely arrow\nsits here.', 'and the arrow', 'stick-collecting kid', 'sewing needle for sale', 'fungus for arrow', 'archer boy sews again', 'an arrow'),
|
||||||
'Arrows (10)': ItemData(IC.filler, None, 0x44, 'This will give\nyou ten shots\nwith your bow!', 'and the arrow pack','stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again','ten arrows'),
|
'Arrows (10)': ItemData(IC.filler, None, 0x44, 'This will give\nyou ten shots\nwith your bow!', 'and the arrow pack','stick-collecting kid', 'sewing kit for sale', 'fungus for arrows', 'archer boy sews again','ten arrows'),
|
||||||
'Arrow Upgrade (+10)': ItemData(IC.useful, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
'Arrow Upgrade (+10)': ItemData(IC.progression_skip_balancing, None, 0x54, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||||
'Arrow Upgrade (+5)': ItemData(IC.useful, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
'Arrow Upgrade (+5)': ItemData(IC.progression_skip_balancing, None, 0x53, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||||
'Arrow Upgrade (70)': ItemData(IC.useful, None, 0x4D, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
'Arrow Upgrade (70)': ItemData(IC.progression_skip_balancing, None, 0x4D, 'increase arrow\nstorage, low\nlow price', 'and the quiver', 'quiver-enlarging kid', 'arrow boost for sale', 'witch and more skewers', 'upgrade boy sews more again', 'arrow capacity'),
|
||||||
'Single Bomb': ItemData(IC.filler, None, 0x27, 'I make things\ngo BOOM! But\njust once.', 'and the explosion', 'the bomb-holding kid', 'firecracker for sale', 'blend fungus into bomb', '\'splosion boy explodes again', 'a bomb'),
|
'Single Bomb': ItemData(IC.filler, None, 0x27, 'I make things\ngo BOOM! But\njust once.', 'and the explosion', 'the bomb-holding kid', 'firecracker for sale', 'blend fungus into bomb', '\'splosion boy explodes again', 'a bomb'),
|
||||||
'Bombs (3)': ItemData(IC.filler, None, 0x28, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'),
|
'Bombs (3)': ItemData(IC.filler, None, 0x28, 'I make things\ngo triple\nBOOM!!!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'three bombs'),
|
||||||
'Bombs (10)': ItemData(IC.filler, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),
|
'Bombs (10)': ItemData(IC.filler, None, 0x31, 'I make things\ngo BOOM! Ten\ntimes!', 'and the explosions', 'the bomb-holding kid', 'firecrackers for sale', 'blend fungus into bombs', '\'splosion boy explodes again', 'ten bombs'),
|
||||||
|
|||||||
@@ -1547,9 +1547,9 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
|||||||
rom.write_byte(0x18003B, 0x01 if world.map_shuffle[player] else 0x00) # maps showing crystals on overworld
|
rom.write_byte(0x18003B, 0x01 if world.map_shuffle[player] else 0x00) # maps showing crystals on overworld
|
||||||
|
|
||||||
# compasses showing dungeon count
|
# compasses showing dungeon count
|
||||||
if local_world.clock_mode or not world.dungeon_counters[player]:
|
if local_world.clock_mode or world.dungeon_counters[player] == 'off':
|
||||||
rom.write_byte(0x18003C, 0x00) # Currently must be off if timer is on, because they use same HUD location
|
rom.write_byte(0x18003C, 0x00) # Currently must be off if timer is on, because they use same HUD location
|
||||||
elif world.dungeon_counters[player] is True:
|
elif world.dungeon_counters[player] == 'on':
|
||||||
rom.write_byte(0x18003C, 0x02) # always on
|
rom.write_byte(0x18003C, 0x02) # always on
|
||||||
elif world.compass_shuffle[player] or world.dungeon_counters[player] == 'pickup':
|
elif world.compass_shuffle[player] or world.dungeon_counters[player] == 'pickup':
|
||||||
rom.write_byte(0x18003C, 0x01) # show on pickup
|
rom.write_byte(0x18003C, 0x01) # show on pickup
|
||||||
|
|||||||
@@ -1125,7 +1125,7 @@ def set_trock_key_rules(world, player):
|
|||||||
for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']:
|
for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']:
|
||||||
set_rule(world.get_entrance(entrance, player), lambda state: False)
|
set_rule(world.get_entrance(entrance, player), lambda state: False)
|
||||||
|
|
||||||
all_state = world.get_all_state(use_cache=False)
|
all_state = world.get_all_state(use_cache=False, allow_partial_entrances=True)
|
||||||
all_state.reachable_regions[player] = set() # wipe reachable regions so that the locked doors actually work
|
all_state.reachable_regions[player] = set() # wipe reachable regions so that the locked doors actually work
|
||||||
all_state.stale[player] = True
|
all_state.stale[player] = True
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,8 @@ def push_shop_inventories(multiworld):
|
|||||||
# Retro Bow arrows will already have been pushed
|
# Retro Bow arrows will already have been pushed
|
||||||
if (not multiworld.retro_bow[location.player]) or ((item_name, location.item.player)
|
if (not multiworld.retro_bow[location.player]) or ((item_name, location.item.player)
|
||||||
!= ("Single Arrow", location.player)):
|
!= ("Single Arrow", location.player)):
|
||||||
location.shop.push_inventory(location.shop_slot, item_name, location.shop_price,
|
location.shop.push_inventory(location.shop_slot, item_name,
|
||||||
|
round(location.shop_price * get_price_modifier(location.item)),
|
||||||
1, location.item.player if location.item.player != location.player else 0,
|
1, location.item.player if location.item.player != location.player else 0,
|
||||||
location.shop_price_type)
|
location.shop_price_type)
|
||||||
location.shop_price = location.shop.inventory[location.shop_slot]["price"] = min(location.shop_price,
|
location.shop_price = location.shop.inventory[location.shop_slot]["price"] = min(location.shop_price,
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo
|
|||||||
|
|
||||||
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
|
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
|
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
|
||||||
shop in state.multiworld.shops)
|
shop in state.multiworld.shops)
|
||||||
|
|
||||||
|
|
||||||
def can_buy(state: CollectionState, item: str, player: int) -> bool:
|
def can_buy(state: CollectionState, item: str, player: int) -> bool:
|
||||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
|
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
|
||||||
shop in state.multiworld.shops)
|
shop in state.multiworld.shops)
|
||||||
|
|
||||||
|
|
||||||
def can_shoot_arrows(state: CollectionState, player: int) -> bool:
|
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:
|
||||||
if state.multiworld.retro_bow[player]:
|
if state.multiworld.retro_bow[player]:
|
||||||
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_buy(state, 'Single Arrow', player)
|
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_buy(state, 'Single Arrow', player)
|
||||||
return state.has('Bow', player) or state.has('Silver Bow', player)
|
return (state.has('Bow', player) or state.has('Silver Bow', player)) and can_hold_arrows(state, player, count)
|
||||||
|
|
||||||
|
|
||||||
def has_triforce_pieces(state: CollectionState, player: int) -> bool:
|
def has_triforce_pieces(state: CollectionState, player: int) -> bool:
|
||||||
@@ -61,13 +61,13 @@ def heart_count(state: CollectionState, player: int) -> int:
|
|||||||
# Warning: This only considers items that are marked as advancement items
|
# Warning: This only considers items that are marked as advancement items
|
||||||
diff = state.multiworld.worlds[player].difficulty_requirements
|
diff = state.multiworld.worlds[player].difficulty_requirements
|
||||||
return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
return min(state.count('Boss Heart Container', player), diff.boss_heart_container_limit) \
|
||||||
+ state.count('Sanctuary Heart Container', player) \
|
+ state.count('Sanctuary Heart Container', player) \
|
||||||
+ min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
+ min(state.count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
|
||||||
+ 3 # starting hearts
|
+ 3 # starting hearts
|
||||||
|
|
||||||
|
|
||||||
def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
|
def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
|
||||||
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
|
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
|
||||||
basemagic = 8
|
basemagic = 8
|
||||||
if state.has('Magic Upgrade (1/4)', player):
|
if state.has('Magic Upgrade (1/4)', player):
|
||||||
basemagic = 32
|
basemagic = 32
|
||||||
@@ -84,11 +84,18 @@ def can_extend_magic(state: CollectionState, player: int, smallmagic: int = 16,
|
|||||||
|
|
||||||
|
|
||||||
def can_hold_arrows(state: CollectionState, player: int, quantity: int):
|
def can_hold_arrows(state: CollectionState, player: int, quantity: int):
|
||||||
arrows = 30 + ((state.count("Arrow Upgrade (+5)", player) * 5) + (state.count("Arrow Upgrade (+10)", player) * 10)
|
if state.multiworld.worlds[player].options.shuffle_capacity_upgrades:
|
||||||
+ (state.count("Bomb Upgrade (50)", player) * 50))
|
if quantity == 0:
|
||||||
# Arrow Upgrade (+5) beyond the 6th gives +10
|
return True
|
||||||
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
|
if state.has("Arrow Upgrade (70)", player):
|
||||||
return min(70, arrows) >= quantity
|
arrows = 70
|
||||||
|
else:
|
||||||
|
arrows = (30 + (state.count("Arrow Upgrade (+5)", player) * 5)
|
||||||
|
+ (state.count("Arrow Upgrade (+10)", player) * 10))
|
||||||
|
# Arrow Upgrade (+5) beyond the 6th gives +10
|
||||||
|
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
|
||||||
|
return min(70, arrows) >= quantity
|
||||||
|
return quantity <= 30 or state.has("Capacity Upgrade Shop", player)
|
||||||
|
|
||||||
|
|
||||||
def can_use_bombs(state: CollectionState, player: int, quantity: int = 1) -> bool:
|
def can_use_bombs(state: CollectionState, player: int, quantity: int = 1) -> bool:
|
||||||
@@ -146,19 +153,19 @@ def can_get_good_bee(state: CollectionState, player: int) -> bool:
|
|||||||
def can_retrieve_tablet(state: CollectionState, player: int) -> bool:
|
def can_retrieve_tablet(state: CollectionState, player: int) -> bool:
|
||||||
return state.has('Book of Mudora', player) and (has_beam_sword(state, player) or
|
return state.has('Book of Mudora', player) and (has_beam_sword(state, player) or
|
||||||
(state.multiworld.swordless[player] and
|
(state.multiworld.swordless[player] and
|
||||||
state.has("Hammer", player)))
|
state.has("Hammer", player)))
|
||||||
|
|
||||||
|
|
||||||
def has_sword(state: CollectionState, player: int) -> bool:
|
def has_sword(state: CollectionState, player: int) -> bool:
|
||||||
return state.has('Fighter Sword', player) \
|
return state.has('Fighter Sword', player) \
|
||||||
or state.has('Master Sword', player) \
|
or state.has('Master Sword', player) \
|
||||||
or state.has('Tempered Sword', player) \
|
or state.has('Tempered Sword', player) \
|
||||||
or state.has('Golden Sword', player)
|
or state.has('Golden Sword', player)
|
||||||
|
|
||||||
|
|
||||||
def has_beam_sword(state: CollectionState, player: int) -> bool:
|
def has_beam_sword(state: CollectionState, player: int) -> bool:
|
||||||
return state.has('Master Sword', player) or state.has('Tempered Sword', player) or state.has('Golden Sword',
|
return state.has('Master Sword', player) or state.has('Tempered Sword', player) or state.has('Golden Sword',
|
||||||
player)
|
player)
|
||||||
|
|
||||||
|
|
||||||
def has_melee_weapon(state: CollectionState, player: int) -> bool:
|
def has_melee_weapon(state: CollectionState, player: int) -> bool:
|
||||||
@@ -171,9 +178,9 @@ def has_fire_source(state: CollectionState, player: int) -> bool:
|
|||||||
|
|
||||||
def can_melt_things(state: CollectionState, player: int) -> bool:
|
def can_melt_things(state: CollectionState, player: int) -> bool:
|
||||||
return state.has('Fire Rod', player) or \
|
return state.has('Fire Rod', player) or \
|
||||||
(state.has('Bombos', player) and
|
(state.has('Bombos', player) and
|
||||||
(state.multiworld.swordless[player] or
|
(state.multiworld.swordless[player] or
|
||||||
has_sword(state, player)))
|
has_sword(state, player)))
|
||||||
|
|
||||||
|
|
||||||
def has_misery_mire_medallion(state: CollectionState, player: int) -> bool:
|
def has_misery_mire_medallion(state: CollectionState, player: int) -> bool:
|
||||||
|
|||||||
@@ -1,224 +1,123 @@
|
|||||||
# Guía de instalación para A Link to the Past Randomizer Multiworld
|
# Guía de instalación para A Link to the Past Randomizer Multiworld
|
||||||
|
|
||||||
<div id="tutorial-video-container">
|
|
||||||
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/mJKEHaiyR_Y" frameborder="0"
|
|
||||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
|
|
||||||
</iframe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Software requerido
|
## Software requerido
|
||||||
|
|
||||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
|
- [SNI](https://github.com/alttpo/sni/releases). Esto está incluido automáticamente en la instalación de Archipelago.
|
||||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
|
- SNI no es compatible con (Q)Usb2Snes.
|
||||||
- Un emulador capaz de ejecutar scripts Lua
|
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES, por ejemplo:
|
||||||
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
|
- Un emulador capaz de conectarse a SNI
|
||||||
|
([snes9x-nwa](https://github.com/Skarsnik/snes9x-emunwa/releases), [snes9x-rr](https://github.com/gocha/snes9x-rr/releases),
|
||||||
|
[BSNES-plus](https://github.com/black-sliver/bsnes-plus),
|
||||||
[BizHawk](https://tasvideos.org/BizHawk), o
|
[BizHawk](https://tasvideos.org/BizHawk), o
|
||||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo). O,
|
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo).
|
||||||
- Un flashcart SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), o otro hardware compatible
|
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), u otro hardware compatible. **nota:
|
||||||
|
Las SNES minis modificadas no tienen soporte de SNI. Algunos usuarios dicen haber tenido éxito con Qusb2Snes para esta consola,
|
||||||
|
pero no tiene soporte.**
|
||||||
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
|
||||||
|
|
||||||
## Procedimiento de instalación
|
## Procedimiento de instalación
|
||||||
|
|
||||||
### Instalación en Windows
|
1. Descarga e instala [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
|
||||||
|
**El archivo del instalador se encuentra en la sección de assets al final de la información de version**.
|
||||||
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
|
2. La primera vez que realices una generación local o parchees tu juego, se te pedirá que ubiques tu archivo ROM base.
|
||||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu
|
Este es tu archivo ROM de Link to the Past japonés. Esto sólo debe hacerse una vez.
|
||||||
intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
|
|
||||||
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar '
|
4. Si estás usando un emulador, deberías de asignar tu emulador con compatibilidad con Lua como el programa por defecto para abrir archivos
|
||||||
Setup.BerserkerMultiWorld.Doors.exe'
|
ROM.
|
||||||
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías
|
1. Extrae la carpeta de tu emulador al Escritorio, o algún otro sitio que vayas a recordar.
|
||||||
instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del
|
2. Haz click derecho en un archivo ROM y selecciona **Abrir con...**
|
||||||
archivo una segunda vez.
|
3. Marca la casilla junto a **Usar siempre este programa para abrir archivos .sfc**
|
||||||
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (
|
4. Baja al final de la lista y haz click en el texto gris **Buscar otro programa en este PC**
|
||||||
posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
|
5. Busca el archivo `.exe` de tu emulador y haz click en **Abrir**. Este archivo debería de encontrarse dentro de la carpeta que
|
||||||
|
extrajiste en el paso uno.
|
||||||
2. Si estas usando un emulador, deberías asignar la versión capaz de ejecutar scripts Lua como programa por defecto para
|
|
||||||
lanzar ficheros de ROM de SNES.
|
|
||||||
1. Extrae tu emulador al escritorio, o cualquier sitio que después recuerdes.
|
|
||||||
2. Haz click derecho en un fichero de ROM (ha de tener la extensión sfc) y selecciona **Abrir con...**
|
|
||||||
3. Marca la opción **Usar siempre esta aplicación para abrir los archivos .sfc**
|
|
||||||
4. Baja hasta el final de la lista y haz click en la opción **Buscar otra aplicación en el equipo** (Si usas Windows
|
|
||||||
10 es posible que debas hacer click en **Más aplicaciones**)
|
|
||||||
5. Busca el archivo .exe de tu emulador y haz click en **Abrir**. Este archivo debe estar en el directorio donde
|
|
||||||
extrajiste en el paso 1.
|
|
||||||
|
|
||||||
### Instalación en Macintosh
|
|
||||||
|
|
||||||
- ¡Necesitamos voluntarios para rellenar esta seccion! Contactad con **Farrak Kilhn** (en inglés) en Discord si queréis
|
|
||||||
ayudar.
|
|
||||||
|
|
||||||
## Configurar tu archivo YAML
|
|
||||||
|
|
||||||
### Que es un archivo YAML y por qué necesito uno?
|
|
||||||
|
|
||||||
Tu archivo YAML contiene un conjunto de opciones de configuración que proveen al generador con información sobre como
|
|
||||||
debe generar tu juego. Cada jugador en una partida de multiworld proveerá su propio fichero YAML. Esta configuración
|
|
||||||
permite que cada jugador disfrute de una experiencia personalizada a su gusto, y cada jugador dentro de la misma partida
|
|
||||||
de multiworld puede tener diferentes opciones.
|
|
||||||
|
|
||||||
### Donde puedo obtener un fichero YAML?
|
|
||||||
|
|
||||||
La página "[Generate Game](/games/A%20Link%20to%20the%20Past/player-options)" en el sitio web te permite configurar tu
|
|
||||||
configuración personal y descargar un fichero "YAML".
|
|
||||||
|
|
||||||
### Configuración YAML avanzada
|
|
||||||
|
|
||||||
Una version mas avanzada del fichero Yaml puede ser creada usando la pagina
|
|
||||||
["Weighted settings"](/games/A Link to the Past/weighted-options),
|
|
||||||
la cual te permite tener almacenadas hasta 3 preajustes. La pagina "Weighted Settings" tiene muchas opciones
|
|
||||||
representadas con controles deslizantes. Esto permite elegir cuan probable los valores de una categoría pueden ser
|
|
||||||
elegidos sobre otros de la misma.
|
|
||||||
|
|
||||||
Por ejemplo, imagina que el generador crea un cubo llamado "map_shuffle", y pone trozos de papel doblado en él por cada
|
|
||||||
sub-opción. Ademas imaginemos que tu valor elegido para "on" es 20 y el elegido para "off" es 40.
|
|
||||||
|
|
||||||
Por tanto, en este ejemplo, habrán 60 trozos de papel. 20 para "on" y 40 para "off". Cuando el generador esta decidiendo
|
|
||||||
si activar o no "map shuffle" para tu partida, meterá la mano en el cubo y sacara un trozo de papel al azar. En este
|
|
||||||
ejemplo, es mucho mas probable (2 de cada 3 veces (40/60)) que "map shuffle" esté desactivado.
|
|
||||||
|
|
||||||
Si quieres que una opción no pueda ser escogida, simplemente asigna el valor 0 a dicha opción. Recuerda que cada opción
|
|
||||||
debe tener al menos un valor mayor que cero, si no la generación fallará.
|
|
||||||
|
|
||||||
### Verificando tu archivo YAML
|
|
||||||
|
|
||||||
Si quieres validar que tu fichero YAML para asegurarte que funciona correctamente, puedes hacerlo en la pagina
|
|
||||||
[YAML Validator](/check).
|
|
||||||
|
|
||||||
## Generar una partida para un jugador
|
|
||||||
|
|
||||||
1. Navega a [la pagina Generate game](/games/A%20Link%20to%20the%20Past/player-options), configura tus opciones, haz
|
|
||||||
click en el boton "Generate game".
|
|
||||||
2. Se te redigirá a una pagina "Seed Info", donde puedes descargar tu archivo de parche.
|
|
||||||
3. Haz doble click en tu fichero de parche, y el emulador debería ejecutar tu juego automáticamente. Como el Cliente no
|
|
||||||
es necesario para partidas de un jugador, puedes cerrarlo junto a la pagina web (que tiene como titulo "Multiworld
|
|
||||||
WebUI") que se ha abierto automáticamente.
|
|
||||||
|
|
||||||
## Unirse a una partida MultiWorld
|
|
||||||
|
|
||||||
### Obtener el fichero de parche y crea tu ROM
|
### Obtener el fichero de parche y crea tu ROM
|
||||||
|
|
||||||
Cuando te unes a una partida multiworld, debes proveer tu fichero YAML a quien sea el creador de la partida. Una vez
|
Cuando te unas a una partida multiworld, se te pedirá enviarle tu archivo de configuración a quien quiera que esté creando. Una vez eso
|
||||||
este hecho, el creador te devolverá un enlace para descargar el parche o un fichero zip conteniendo todos los ficheros
|
este hecho, el creador te devolverá un enlace para descargar el parche o un fichero zip conteniendo todos los ficheros
|
||||||
de parche de la partida Tu fichero de parche debe tener la extensión `.aplttp`.
|
de parche de la partida. Tu fichero de parche debe de tener la extensión `.aplttp`.
|
||||||
|
|
||||||
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y haz doble click. Esto debería ejecutar
|
Pon tu fichero de parche en el escritorio o en algún sitio conveniente, y hazle doble click. Esto debería ejecutar
|
||||||
automáticamente el cliente, y ademas creara la rom en el mismo directorio donde este el fichero de parche.
|
automáticamente el cliente, y además creará la rom en el mismo directorio donde este el fichero de parche.
|
||||||
|
|
||||||
### Conectar al cliente
|
### Conectar al cliente
|
||||||
|
|
||||||
#### Con emulador
|
#### Con emulador
|
||||||
|
|
||||||
Cuando el cliente se lance automáticamente, QUsb2Snes debería haberse ejecutado también. Si es la primera vez que lo
|
Cuando el cliente se lance automáticamente, SNI debería de ejecutarse en segundo plano. Si es la
|
||||||
ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la comunicación.
|
primera vez que se ejecuta, tal vez se te pida permitir que se comunique a través del firewall de Windows
|
||||||
|
|
||||||
|
#### snes9x-nwa
|
||||||
|
|
||||||
|
1. Haz click en el menu Network y marca 'Enable Emu Network Control
|
||||||
|
2. Carga tu archivo ROM si no lo habías hecho antes
|
||||||
|
|
||||||
##### snes9x-rr
|
##### snes9x-rr
|
||||||
|
|
||||||
1. Carga tu fichero de ROM, si no lo has hecho ya
|
1. Carga tu fichero ROM, si no lo has hecho ya
|
||||||
2. Abre el menu "File" y situa el raton en **Lua Scripting**
|
2. Abre el menu "File" y situa el raton en **Lua Scripting**
|
||||||
3. Haz click en **New Lua Script Window...**
|
3. Haz click en **New Lua Script Window...**
|
||||||
4. En la nueva ventana, haz click en **Browse...**
|
4. En la nueva ventana, haz click en **Browse...**
|
||||||
5. Navega hacia el directorio donde este situado snes9x-rr, entra en el directorio `lua`, y
|
5. Selecciona el archivo lua conector incluido con tu cliente
|
||||||
escoge `multibridge.lua`
|
- Busca en la carpeta de Archipelago `/SNI/lua/`.
|
||||||
6. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
6. Si ves un error mientras carga el script que dice `socket.dll missing` o algo similar, ve a la carpeta de
|
||||||
nombre en la esquina superior izquierda.
|
el lua que estas usando en tu gestor de archivos y copia el `socket.dll` a la raíz de tu instalación de snes9x.
|
||||||
|
|
||||||
|
##### BNES-Plus
|
||||||
|
|
||||||
|
1. Cargue su archivo ROM si aún no se ha cargado.
|
||||||
|
2. El emulador debería conectarse automáticamente mientras SNI se está ejecutando.
|
||||||
|
|
||||||
##### BizHawk
|
##### BizHawk
|
||||||
|
|
||||||
1. Asegurate que se ha cargado el nucleo BSNES. Debes hacer esto en el menu Tools y siguiento estas opciones:
|
1. Asegurate que se ha cargado el núcleo BSNES. Se hace en la barra de menú principal, bajo:
|
||||||
`Config --> Cores --> SNES --> BSNES`
|
- (≤ 2.8) `Config` 〉 `Cores` 〉 `SNES` 〉 `BSNES`
|
||||||
Una vez cambiado el nucleo cargado, BizHawk ha de ser reiniciado.
|
- (≥ 2.9) `Config` 〉 `Preferred Cores` 〉 `SNES` 〉 `BSNESv115+`
|
||||||
2. Carga tu fichero de ROM, si no lo has hecho ya.
|
2. Carga tu fichero de ROM, si no lo has hecho ya.
|
||||||
3. Haz click en el menu Tools y en la opción **Lua Console**
|
Si has cambiado tu preferencia de núcleo tras haber cargado la ROM, no te olvides de volverlo a cargar (atajo por defecto: Ctrl+R).
|
||||||
4. Haz click en el botón para abrir un nuevo script Lua.
|
3. Arrastra el archivo `Connector.lua` que has descargado a la ventana principal de EmuHawk.
|
||||||
5. Navega al directorio de instalación de MultiWorld Utilities, y en los siguiente directorios:
|
- Busca en la carpeta de Archipelago `/SNI/lua/`.
|
||||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
- También podrías abrir la consola de Lua manualmente, hacer click en `Script` 〉 `Open Script`, e ir a `Connector.lua`
|
||||||
6. Selecciona `luabridge.lua` y haz click en Abrir.
|
con el selector de archivos.
|
||||||
7. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
|
|
||||||
nombre en la esquina superior izquierda.
|
|
||||||
|
|
||||||
##### RetroArch 1.10.1 o más nuevo
|
##### RetroArch 1.10.1 o más nuevo
|
||||||
|
|
||||||
Sólo hay que segiur estos pasos una vez.
|
Sólo hay que seguir estos pasos una vez.
|
||||||
|
|
||||||
1. Comienza en la pantalla del menú principal de RetroArch.
|
1. Comienza en la pantalla del menú principal de RetroArch.
|
||||||
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
|
||||||
3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el
|
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
|
||||||
default) el Puerto de comandos de red.
|
el Puerto de comandos de red.
|
||||||
|
|
||||||

|

|
||||||
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
|
||||||
SFC (bsnes-mercury Performance)".
|
SFC (bsnes-mercury Performance)".
|
||||||
|
|
||||||
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los sólos núcleos que permiten
|
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los únicos núcleos que permiten
|
||||||
que herramientas externas lean datos del ROM.
|
que herramientas externas lean datos del ROM.
|
||||||
|
|
||||||
#### Con Hardware
|
#### Con Hardware
|
||||||
|
|
||||||
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora. Los
|
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, por favor hazlo ahora. Los
|
||||||
usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
usuarios de SD2SNES y FXPak Pro pueden descargar el firmware apropiado
|
||||||
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Los usuarios de otros dispositivos pueden encontrar información
|
[aqui](https://github.com/RedGuyyyy/sd2snes/releases). Puede que los usuarios de otros dispositivos encuentren informacion útil
|
||||||
[en esta página](http://usb2snes.com/#supported-platforms).
|
[en esta página](http://usb2snes.com/#supported-platforms).
|
||||||
|
|
||||||
1. Cierra tu emulador, el cual debe haberse autoejecutado.
|
1. Cierra tu emulador, el cual debe haberse autoejecutado.
|
||||||
2. Cierra QUsb2Snes, el cual fue ejecutado junto al cliente.
|
2. Enciende tu dispositivo y carga la ROM.
|
||||||
3. Ejecuta la version correcta de QUsb2Snes (v0.7.16).
|
|
||||||
4. Enciende tu dispositivo y carga la ROM.
|
|
||||||
5. Observa en el cliente que ahora muestra "SNES Device: Connected", y aparece el nombre del dispositivo.
|
|
||||||
|
|
||||||
### Conecta al MultiServer
|
### Conecta al Servidor Archipelago
|
||||||
|
|
||||||
El fichero de parche que ha lanzado el cliente debe haberte conectado automaticamente al MultiServer. Hay algunas
|
El fichero de parche que ha lanzado el cliente debería de haberte conectado automaticamente al MultiServer. Sin embargo hay algunas
|
||||||
razonas por las que esto puede que no pase, incluyendo que el juego este hospedado en el sitio web pero se genero en
|
razones por las que puede que esto no suceda, como que la partida este hospedada en la página web pero generada en otra parte. Si la
|
||||||
algún otro sitio. Si el cliente muestra "Server Status: Not Connected", preguntale al creador de la partida la dirección
|
ventana del cliente muestra "Server Status: Not Connected", simplemente preguntale al creador de la partida la dirección
|
||||||
del servidor, copiala en el campo "Server" y presiona Enter.
|
del servidor, cópiala en el campo "Server" y presiona Enter.
|
||||||
|
|
||||||
El cliente intentara conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" en algún momento.
|
El cliente intentará conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" momentáneamente.
|
||||||
Si el cliente no se conecta al cabo de un rato, puede ser que necesites refrescar la pagina web.
|
|
||||||
|
|
||||||
### Jugando
|
### Jugar al juego
|
||||||
|
|
||||||
Cuando ambos SNES Device and Server aparezcan como "connected", estas listo para empezar a jugar. Felicidades por unirte
|
Cuando el cliente muestre tanto el dispositivo SNES como el servidor como conectados, estas listo para empezar a jugar. Felicidades por
|
||||||
satisfactoriamente a una partida de multiworld!
|
haberte unido a una partida multiworld con exito! Puedes ejecutar varios comandos en tu cliente. Para mas informacion
|
||||||
|
acerca de estos comando puedes usar `/help` para comandos locales del cliente y `!help` para comandos de servidor.
|
||||||
## Hospedando una partida de multiworld
|
|
||||||
|
|
||||||
La manera recomendad para hospedar una partida es usar el servicio proveído en
|
|
||||||
[el sitio web](/generate). El proceso es relativamente sencillo:
|
|
||||||
|
|
||||||
1. Recolecta los ficheros YAML de todos los jugadores que participen.
|
|
||||||
2. Crea un fichero ZIP conteniendo esos ficheros.
|
|
||||||
3. Carga el fichero zip en el sitio web enlazado anteriormente.
|
|
||||||
4. Espera a que la seed sea generada.
|
|
||||||
5. Cuando esto acabe, se te redigirá a una pagina titulada "Seed Info".
|
|
||||||
6. Haz click en "Create New Room". Esto te llevara a la pagina del servidor. Pasa el enlace a esta pagina a los
|
|
||||||
jugadores para que puedan descargar los ficheros de parche de ahi.
|
|
||||||
**Nota:** Los ficheros de parche de esta pagina permiten a los jugadores conectarse al servidor automaticamente,
|
|
||||||
mientras que los de la pagina "Seed info" no.
|
|
||||||
7. Hay un enlace a un MultiWorld Tracker en la parte superior de la pagina de la sala. Deberías pasar también este
|
|
||||||
enlace a los jugadores para que puedan ver el progreso de la partida. A los observadores también se les puede pasar
|
|
||||||
este enlace.
|
|
||||||
8. Una vez todos los jugadores se han unido, podeis empezar a jugar.
|
|
||||||
|
|
||||||
## Auto-Tracking
|
|
||||||
|
|
||||||
Si deseas usar auto-tracking para tu partida, varios programas ofrecen esta funcionalidad.
|
|
||||||
El programa recomentdado actualmente es:
|
|
||||||
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
|
|
||||||
|
|
||||||
### Instalación
|
|
||||||
|
|
||||||
1. Descarga el fichero de instalacion apropiado para tu ordenador (Usuarios de windows quieren el fichero ".msi").
|
|
||||||
2. Durante el proceso de insatalación, puede que se te pida instalar Microsoft Visual Studio Build Tools. Un enlace este
|
|
||||||
programa se muestra durante la proceso, y debe ser ejecutado manualmente.
|
|
||||||
|
|
||||||
### Activar auto-tracking
|
|
||||||
|
|
||||||
1. Con OpenTracker ejecutado, haz click en el menu Tracking en la parte superior de la ventana, y elige **
|
|
||||||
AutoTracker...**
|
|
||||||
2. Click the **Get Devices** button
|
|
||||||
3. Selecciona tu "SNES device" de la lista
|
|
||||||
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal
|
|
||||||
Tracking**
|
|
||||||
5. Haz click en el boton **Start Autotracking**
|
|
||||||
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
|
|
||||||
|
|||||||
@@ -77,5 +77,5 @@ class TestMiseryMire(TestDungeon):
|
|||||||
["Misery Mire - Boss", False, [], ['Bomb Upgrade (+5)', 'Bomb Upgrade (+10)', 'Bomb Upgrade (50)']],
|
["Misery Mire - Boss", False, [], ['Bomb Upgrade (+5)', 'Bomb Upgrade (+10)', 'Bomb Upgrade (50)']],
|
||||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Sword', 'Pegasus Boots']],
|
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Sword', 'Pegasus Boots']],
|
||||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Hammer', 'Pegasus Boots']],
|
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Hammer', 'Pegasus Boots']],
|
||||||
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Pegasus Boots']],
|
["Misery Mire - Boss", True, ['Bomb Upgrade (+5)', 'Big Key (Misery Mire)', 'Lamp', 'Cane of Somaria', 'Progressive Bow', 'Arrow Upgrade (+5)', 'Pegasus Boots']],
|
||||||
])
|
])
|
||||||
@@ -93,7 +93,7 @@ class AquariaWorld(World):
|
|||||||
options: AquariaOptions
|
options: AquariaOptions
|
||||||
"Every options of the world"
|
"Every options of the world"
|
||||||
|
|
||||||
regions: AquariaRegions
|
regions: AquariaRegions | None
|
||||||
"Used to manage Regions"
|
"Used to manage Regions"
|
||||||
|
|
||||||
exclude: List[str]
|
exclude: List[str]
|
||||||
@@ -101,10 +101,17 @@ class AquariaWorld(World):
|
|||||||
def __init__(self, multiworld: MultiWorld, player: int):
|
def __init__(self, multiworld: MultiWorld, player: int):
|
||||||
"""Initialisation of the Aquaria World"""
|
"""Initialisation of the Aquaria World"""
|
||||||
super(AquariaWorld, self).__init__(multiworld, player)
|
super(AquariaWorld, self).__init__(multiworld, player)
|
||||||
self.regions = AquariaRegions(multiworld, player)
|
self.regions = None
|
||||||
self.ingredients_substitution = []
|
self.ingredients_substitution = []
|
||||||
self.exclude = []
|
self.exclude = []
|
||||||
|
|
||||||
|
def generate_early(self) -> None:
|
||||||
|
"""
|
||||||
|
Run before any general steps of the MultiWorld other than options. Useful for getting and adjusting option
|
||||||
|
results and determining layouts for entrance rando etc. start inventory gets pushed after this step.
|
||||||
|
"""
|
||||||
|
self.regions = AquariaRegions(self.multiworld, self.player)
|
||||||
|
|
||||||
def create_regions(self) -> None:
|
def create_regions(self) -> None:
|
||||||
"""
|
"""
|
||||||
Create every Region in `regions`
|
Create every Region in `regions`
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ location_names: Dict[str, str] = {
|
|||||||
"RESCUED_CHERUB_15": "DC: Top of elevator Child of Moonlight",
|
"RESCUED_CHERUB_15": "DC: Top of elevator Child of Moonlight",
|
||||||
"Lady[D01Z05S22]": "DC: Lady of the Six Sorrows, from MD",
|
"Lady[D01Z05S22]": "DC: Lady of the Six Sorrows, from MD",
|
||||||
"QI75": "DC: Chalice room",
|
"QI75": "DC: Chalice room",
|
||||||
"Sword[D01Z05S24]": "DC: Mea culpa altar",
|
"Sword[D01Z05S24]": "DC: Mea Culpa altar",
|
||||||
"CO44": "DC: Elevator shaft ledge",
|
"CO44": "DC: Elevator shaft ledge",
|
||||||
"RESCUED_CHERUB_22": "DC: Elevator shaft Child of Moonlight",
|
"RESCUED_CHERUB_22": "DC: Elevator shaft Child of Moonlight",
|
||||||
"Lady[D01Z05S26]": "DC: Lady of the Six Sorrows, elevator shaft",
|
"Lady[D01Z05S26]": "DC: Lady of the Six Sorrows, elevator shaft",
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ class BlasphemousWorld(World):
|
|||||||
|
|
||||||
def generate_early(self):
|
def generate_early(self):
|
||||||
if not self.options.starting_location.randomized:
|
if not self.options.starting_location.randomized:
|
||||||
if self.options.starting_location == "mourning_havoc" and self.options.difficulty < 2:
|
if (self.options.starting_location == "knot_of_words" or self.options.starting_location == "rooftops" \
|
||||||
|
or self.options.starting_location == "mourning_havoc") and self.options.difficulty < 2:
|
||||||
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
raise OptionError(f"[Blasphemous - '{self.player_name}'] "
|
||||||
f"{self.options.starting_location} cannot be chosen if Difficulty is lower than Hard.")
|
f"{self.options.starting_location} cannot be chosen if Difficulty is lower than Hard.")
|
||||||
|
|
||||||
@@ -83,6 +84,8 @@ class BlasphemousWorld(World):
|
|||||||
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ]
|
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ]
|
||||||
|
|
||||||
if self.options.difficulty < 2:
|
if self.options.difficulty < 2:
|
||||||
|
locations.remove(4)
|
||||||
|
locations.remove(5)
|
||||||
locations.remove(6)
|
locations.remove(6)
|
||||||
|
|
||||||
if self.options.dash_shuffle:
|
if self.options.dash_shuffle:
|
||||||
@@ -103,6 +106,9 @@ class BlasphemousWorld(World):
|
|||||||
if not self.options.wall_climb_shuffle:
|
if not self.options.wall_climb_shuffle:
|
||||||
self.multiworld.push_precollected(self.create_item("Wall Climb Ability"))
|
self.multiworld.push_precollected(self.create_item("Wall Climb Ability"))
|
||||||
|
|
||||||
|
if self.options.thorn_shuffle == "local_only":
|
||||||
|
self.options.local_items.value.add("Thorn Upgrade")
|
||||||
|
|
||||||
if not self.options.boots_of_pleading:
|
if not self.options.boots_of_pleading:
|
||||||
self.disabled_locations.append("RE401")
|
self.disabled_locations.append("RE401")
|
||||||
|
|
||||||
@@ -200,9 +206,6 @@ class BlasphemousWorld(World):
|
|||||||
|
|
||||||
if not self.options.skill_randomizer:
|
if not self.options.skill_randomizer:
|
||||||
self.place_items_from_dict(skill_dict)
|
self.place_items_from_dict(skill_dict)
|
||||||
|
|
||||||
if self.options.thorn_shuffle == "local_only":
|
|
||||||
self.options.local_items.value.add("Thorn Upgrade")
|
|
||||||
|
|
||||||
|
|
||||||
def place_items_from_set(self, location_set: Set[str], name: str):
|
def place_items_from_set(self, location_set: Set[str], name: str):
|
||||||
|
|||||||
@@ -85,20 +85,7 @@ class TestGrievanceHard(BlasphemousTestBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestKnotOfWordsEasy(BlasphemousTestBase):
|
# knot of the three words, rooftops, and mourning and havoc can't be selected on easy or normal. hard only
|
||||||
options = {
|
|
||||||
"starting_location": "knot_of_words",
|
|
||||||
"difficulty": "easy"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestKnotOfWordsNormal(BlasphemousTestBase):
|
|
||||||
options = {
|
|
||||||
"starting_location": "knot_of_words",
|
|
||||||
"difficulty": "normal"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestKnotOfWordsHard(BlasphemousTestBase):
|
class TestKnotOfWordsHard(BlasphemousTestBase):
|
||||||
options = {
|
options = {
|
||||||
"starting_location": "knot_of_words",
|
"starting_location": "knot_of_words",
|
||||||
@@ -106,20 +93,6 @@ class TestKnotOfWordsHard(BlasphemousTestBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestRooftopsEasy(BlasphemousTestBase):
|
|
||||||
options = {
|
|
||||||
"starting_location": "rooftops",
|
|
||||||
"difficulty": "easy"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestRooftopsNormal(BlasphemousTestBase):
|
|
||||||
options = {
|
|
||||||
"starting_location": "rooftops",
|
|
||||||
"difficulty": "normal"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestRooftopsHard(BlasphemousTestBase):
|
class TestRooftopsHard(BlasphemousTestBase):
|
||||||
options = {
|
options = {
|
||||||
"starting_location": "rooftops",
|
"starting_location": "rooftops",
|
||||||
@@ -127,7 +100,6 @@ class TestRooftopsHard(BlasphemousTestBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# mourning and havoc can't be selected on easy or normal. hard only
|
|
||||||
class TestMourningHavocHard(BlasphemousTestBase):
|
class TestMourningHavocHard(BlasphemousTestBase):
|
||||||
options = {
|
options = {
|
||||||
"starting_location": "mourning_havoc",
|
"starting_location": "mourning_havoc",
|
||||||
|
|||||||
@@ -12,6 +12,12 @@
|
|||||||
|
|
||||||
1. Download the above release and extract it.
|
1. Download the above release and extract it.
|
||||||
|
|
||||||
|
## Installation Procedures (Linux and Steam Deck)
|
||||||
|
|
||||||
|
1. Download the above release and extract it.
|
||||||
|
|
||||||
|
2. Add Celeste64.exe to Steam as a Non-Steam Game. In the properties for it on Steam, set it to use Proton as the compatibility tool. Launch the game through Steam in order to run it.
|
||||||
|
|
||||||
## Joining a MultiWorld Game
|
## Joining a MultiWorld Game
|
||||||
|
|
||||||
1. Before launching the game, edit the `AP.json` file in the root of the Celeste 64 install.
|
1. Before launching the game, edit the `AP.json` file in the root of the Celeste 64 install.
|
||||||
@@ -33,5 +39,3 @@ An Example `AP.json` file:
|
|||||||
"Password": ""
|
"Password": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,19 +25,10 @@ class DarkSouls3Web(WebWorld):
|
|||||||
"English",
|
"English",
|
||||||
"setup_en.md",
|
"setup_en.md",
|
||||||
"setup/en",
|
"setup/en",
|
||||||
["Marech"]
|
["Natalie", "Marech"]
|
||||||
)
|
)
|
||||||
|
|
||||||
setup_fr = Tutorial(
|
tutorials = [setup_en]
|
||||||
setup_en.tutorial_name,
|
|
||||||
setup_en.description,
|
|
||||||
"Français",
|
|
||||||
"setup_fr.md",
|
|
||||||
"setup/fr",
|
|
||||||
["Marech"]
|
|
||||||
)
|
|
||||||
|
|
||||||
tutorials = [setup_en, setup_fr]
|
|
||||||
option_groups = option_groups
|
option_groups = option_groups
|
||||||
item_descriptions = item_descriptions
|
item_descriptions = item_descriptions
|
||||||
rich_text_options_doc = True
|
rich_text_options_doc = True
|
||||||
|
|||||||
@@ -3,11 +3,13 @@
|
|||||||
## Required Software
|
## Required Software
|
||||||
|
|
||||||
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
|
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
|
||||||
- [Dark Souls III AP Client](https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest)
|
- [Dark Souls III AP Client]
|
||||||
|
|
||||||
|
[Dark Souls III AP Client]: https://github.com/nex3/Dark-Souls-III-Archipelago-client/releases/latest
|
||||||
|
|
||||||
## Optional Software
|
## Optional Software
|
||||||
|
|
||||||
- Map tracker not yet updated for 3.0.0
|
- [Map tracker](https://github.com/TVV1GK/DS3_AP_Maptracker)
|
||||||
|
|
||||||
## Setting Up
|
## Setting Up
|
||||||
|
|
||||||
@@ -73,3 +75,65 @@ things to keep in mind:
|
|||||||
|
|
||||||
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
|
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
|
||||||
[WINE]: https://www.winehq.org/
|
[WINE]: https://www.winehq.org/
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Enemy randomizer issues
|
||||||
|
|
||||||
|
The DS3 Archipelago randomizer uses [thefifthmatt's DS3 enemy randomizer],
|
||||||
|
essentially unchanged. Unfortunately, this randomizer has a few known issues,
|
||||||
|
including enemy AI not working, enemies spawning in places they can't be killed,
|
||||||
|
and, in a few rare cases, enemies spawning in ways that crash the game when they
|
||||||
|
load. These bugs should be [reported upstream], but unfortunately the
|
||||||
|
Archipelago devs can't help much with them.
|
||||||
|
|
||||||
|
[thefifthmatt's DS3 enemy randomizer]: https://www.nexusmods.com/darksouls3/mods/484
|
||||||
|
[reported upstream]: https://github.com/thefifthmatt/SoulsRandomizers/issues
|
||||||
|
|
||||||
|
Because in rare cases the enemy randomizer can cause seeds to be impossible to
|
||||||
|
complete, we recommend disabling it for large async multiworlds for safety
|
||||||
|
purposes.
|
||||||
|
|
||||||
|
### `launchmod_darksouls3.bat` isn't working
|
||||||
|
|
||||||
|
Sometimes `launchmod_darksouls3.bat` will briefly flash a terminal on your
|
||||||
|
screen and then terminate without actually starting the game. This is usually
|
||||||
|
caused by some issue communicating with Steam either to find `DarkSoulsIII.exe`
|
||||||
|
or to launch it properly. If this is happening to you, make sure:
|
||||||
|
|
||||||
|
* You have DS3 1.15.2 installed. This is the latest patch as of January 2025.
|
||||||
|
(Note that older versions of Archipelago required an older patch, but that
|
||||||
|
_will not work_ with the current version.)
|
||||||
|
|
||||||
|
* You own the DS3 DLC if your randomizer config has DLC enabled. (It's possible,
|
||||||
|
but unconfirmed, that you need the DLC even when it's disabled in your config).
|
||||||
|
|
||||||
|
* Steam is not running in administrator mode. To fix this, right-click
|
||||||
|
`steam.exe` (by default this is in `C:\Program Files\Steam`), select
|
||||||
|
"Properties", open the "Compatiblity" tab, and uncheck "Run this program as an
|
||||||
|
administrator".
|
||||||
|
|
||||||
|
* There is no `dinput8.dll` file in your DS3 game directory. This is the old way
|
||||||
|
of installing mods, and it can interfere with the new ModEngine2 workflow.
|
||||||
|
|
||||||
|
If you've checked all of these, you can also try:
|
||||||
|
|
||||||
|
* Running `launchmod_darksouls3.bat` as an administrator.
|
||||||
|
|
||||||
|
* Reinstalling DS3 or even reinstalling Steam itself.
|
||||||
|
|
||||||
|
* Making sure DS3 is installed on the same drive as Steam and as the randomizer.
|
||||||
|
(A number of users are able to run these on different drives, but this has
|
||||||
|
helped some users.)
|
||||||
|
|
||||||
|
If none of this works, unfortunately there's not much we can do. We use
|
||||||
|
ModEngine2 to launch DS3 with the Archipelago mod enabled, but unfortunately
|
||||||
|
it's no longer maintained and its successor, ModEngine3, isn't usable yet.
|
||||||
|
|
||||||
|
### `DS3Randomizer.exe` isn't working
|
||||||
|
|
||||||
|
This is almost always caused by using a version of the randomizer client that's
|
||||||
|
not compatible with the version used to generate the multiworld. If you're
|
||||||
|
generating your multiworld on archipelago.gg, you *must* use the latest [Dark
|
||||||
|
Souls III AP Client]. If you want to use a different client version, you *must*
|
||||||
|
generate the multiworld locally using the apworld bundled with the client.
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Guide d'installation de Dark Souls III Randomizer
|
|
||||||
|
|
||||||
## Logiciels requis
|
|
||||||
|
|
||||||
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
|
|
||||||
- [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases)
|
|
||||||
|
|
||||||
## Concept général
|
|
||||||
|
|
||||||
Le client Archipelago de Dark Souls III est un fichier dinput8.dll. Cette .dll va lancer une invite de commande Windows
|
|
||||||
permettant de lire des informations de la partie et écrire des commandes pour intéragir avec le serveur Archipelago.
|
|
||||||
|
|
||||||
## Procédures d'installation
|
|
||||||
|
|
||||||
<span style="color:#ff7800">
|
|
||||||
**Il y a des risques de bannissement permanent des serveurs FromSoftware si ce mod est utilisé en ligne.**
|
|
||||||
</span>
|
|
||||||
Ce client a été testé sur la version Steam officielle du jeu (v1.15/1.35), peu importe les DLCs actuellement installés.
|
|
||||||
|
|
||||||
Télécharger le fichier dinput8.dll disponible dans le [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases) et
|
|
||||||
placez-le à la racine du jeu (ex: "SteamLibrary\steamapps\common\DARK SOULS III\Game")
|
|
||||||
|
|
||||||
## Rejoindre une partie Multiworld
|
|
||||||
|
|
||||||
1. Lancer DarkSoulsIII.exe ou lancer le jeu depuis Steam
|
|
||||||
2. Ecrire "/connect {SERVER_IP}:{SERVER_PORT} {SLOT_NAME}" dans l'invite de commande Windows ouverte au lancement du jeu
|
|
||||||
3. Une fois connecté, créez une nouvelle partie, choisissez une classe et attendez que les autres soient prêts avant de lancer
|
|
||||||
4. Vous pouvez quitter et lancer le jeu n'importe quand pendant une partie
|
|
||||||
|
|
||||||
## Où trouver le fichier de configuration ?
|
|
||||||
|
|
||||||
La [Page de configuration](/games/Dark%20Souls%20III/player-options) sur le site vous permez de configurer vos
|
|
||||||
paramètres et de les exporter sous la forme d'un fichier.
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from NetUtils import ClientStatus, color
|
from NetUtils import ClientStatus, color
|
||||||
from worlds.AutoSNIClient import SNIClient
|
from worlds.AutoSNIClient import SNIClient
|
||||||
@@ -32,7 +31,7 @@ class DKC3SNIClient(SNIClient):
|
|||||||
|
|
||||||
|
|
||||||
async def validate_rom(self, ctx):
|
async def validate_rom(self, ctx):
|
||||||
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
|
from SNIClient import snes_read
|
||||||
|
|
||||||
rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE)
|
rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE)
|
||||||
if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3":
|
if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3":
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
from BaseClasses import Item, ItemClassification
|
from BaseClasses import Item
|
||||||
from .Names import ItemName
|
from .Names import ItemName
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import typing
|
|
||||||
|
|
||||||
from Options import Choice, Range, Toggle, DeathLink, DefaultOnToggle, OptionGroup, PerGameCommonOptions
|
from Options import Choice, Range, Toggle, DefaultOnToggle, OptionGroup, PerGameCommonOptions
|
||||||
|
|
||||||
|
|
||||||
class Goal(Choice):
|
class Goal(Choice):
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
from BaseClasses import MultiWorld, Region, Entrance
|
from BaseClasses import Region, Entrance
|
||||||
from .Items import DKC3Item
|
from worlds.AutoWorld import World
|
||||||
from .Locations import DKC3Location
|
from .Locations import DKC3Location
|
||||||
from .Names import LocationName, ItemName
|
from .Names import LocationName, ItemName
|
||||||
from worlds.AutoWorld import World
|
|
||||||
|
|
||||||
|
|
||||||
def create_regions(world: World, active_locations):
|
def create_regions(world: World, active_locations):
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Utils
|
|||||||
from Utils import read_snes_rom
|
from Utils import read_snes_rom
|
||||||
from worlds.AutoWorld import World
|
from worlds.AutoWorld import World
|
||||||
from worlds.Files import APDeltaPatch
|
from worlds.Files import APDeltaPatch
|
||||||
from .Locations import lookup_id_to_name, all_locations
|
|
||||||
from .Levels import level_list, level_dict
|
from .Levels import level_list, level_dict
|
||||||
|
|
||||||
USHASH = '120abf304f0c40fe059f6a192ed4f947'
|
USHASH = '120abf304f0c40fe059f6a192ed4f947'
|
||||||
@@ -436,7 +435,7 @@ level_music_ids = [
|
|||||||
|
|
||||||
class LocalRom:
|
class LocalRom:
|
||||||
|
|
||||||
def __init__(self, file, patch=True, vanillaRom=None, name=None, hash=None):
|
def __init__(self, file, name=None, hash=None):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.hash = hash
|
self.hash = hash
|
||||||
self.orig_buffer = None
|
self.orig_buffer = None
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
|
from worlds.AutoWorld import World
|
||||||
|
from worlds.generic.Rules import add_rule
|
||||||
from .Names import LocationName, ItemName
|
from .Names import LocationName, ItemName
|
||||||
from worlds.AutoWorld import LogicMixin, World
|
|
||||||
from worlds.generic.Rules import add_rule, set_rule
|
|
||||||
|
|
||||||
|
|
||||||
def set_rules(world: World):
|
def set_rules(world: World):
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
|
||||||
import typing
|
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import settings
|
||||||
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
|
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
|
||||||
from Options import PerGameCommonOptions
|
from Options import PerGameCommonOptions
|
||||||
import Patch
|
|
||||||
import settings
|
|
||||||
from worlds.AutoWorld import WebWorld, World
|
from worlds.AutoWorld import WebWorld, World
|
||||||
|
|
||||||
from .Client import DKC3SNIClient
|
from .Client import DKC3SNIClient
|
||||||
from .Items import DKC3Item, ItemData, item_table, inventory_table, junk_table
|
from .Items import DKC3Item, ItemData, item_table, inventory_table, junk_table
|
||||||
from .Levels import level_list
|
from .Levels import level_list
|
||||||
|
|||||||
@@ -650,8 +650,8 @@ item_table: Dict[int, ItemDict] = {
|
|||||||
'doom_type': 2006,
|
'doom_type': 2006,
|
||||||
'episode': -1,
|
'episode': -1,
|
||||||
'map': -1},
|
'map': -1},
|
||||||
350106: {'classification': ItemClassification.progression,
|
350106: {'classification': ItemClassification.useful,
|
||||||
'count': 1,
|
'count': 0,
|
||||||
'name': 'Backpack',
|
'name': 'Backpack',
|
||||||
'doom_type': 8,
|
'doom_type': 8,
|
||||||
'episode': -1,
|
'episode': -1,
|
||||||
@@ -1160,6 +1160,30 @@ item_table: Dict[int, ItemDict] = {
|
|||||||
'doom_type': 2026,
|
'doom_type': 2026,
|
||||||
'episode': 4,
|
'episode': 4,
|
||||||
'map': 9},
|
'map': 9},
|
||||||
|
350191: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Bullet capacity',
|
||||||
|
'doom_type': 65001,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
|
350192: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Shell capacity',
|
||||||
|
'doom_type': 65002,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
|
350193: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Energy cell capacity',
|
||||||
|
'doom_type': 65003,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
|
350194: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Rocket capacity',
|
||||||
|
'doom_type': 65004,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from Options import PerGameCommonOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
from Options import PerGameCommonOptions, Range, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +144,84 @@ class Episode4(Toggle):
|
|||||||
display_name = "Episode 4"
|
display_name = "Episode 4"
|
||||||
|
|
||||||
|
|
||||||
|
class SplitBackpack(Toggle):
|
||||||
|
"""Split the Backpack into four individual items, each one increasing ammo capacity for one type of weapon only."""
|
||||||
|
display_name = "Split Backpack"
|
||||||
|
|
||||||
|
|
||||||
|
class BackpackCount(Range):
|
||||||
|
"""How many Backpacks will be available.
|
||||||
|
If Split Backpack is set, this will be the number of each capacity upgrade available."""
|
||||||
|
display_name = "Backpack Count"
|
||||||
|
range_start = 0
|
||||||
|
range_end = 10
|
||||||
|
default = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoBullets(Range):
|
||||||
|
"""Set the starting ammo capacity for bullets."""
|
||||||
|
display_name = "Max Ammo - Bullets"
|
||||||
|
range_start = 200
|
||||||
|
range_end = 999
|
||||||
|
default = 200
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoShells(Range):
|
||||||
|
"""Set the starting ammo capacity for shotgun shells."""
|
||||||
|
display_name = "Max Ammo - Shells"
|
||||||
|
range_start = 50
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoRockets(Range):
|
||||||
|
"""Set the starting ammo capacity for rockets."""
|
||||||
|
display_name = "Max Ammo - Rockets"
|
||||||
|
range_start = 50
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoEnergyCells(Range):
|
||||||
|
"""Set the starting ammo capacity for energy cells."""
|
||||||
|
display_name = "Max Ammo - Energy Cells"
|
||||||
|
range_start = 300
|
||||||
|
range_end = 999
|
||||||
|
default = 300
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoBullets(Range):
|
||||||
|
"""Set the amount of bullet capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Bullets"
|
||||||
|
range_start = 20
|
||||||
|
range_end = 999
|
||||||
|
default = 200
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoShells(Range):
|
||||||
|
"""Set the amount of shotgun shell capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Shells"
|
||||||
|
range_start = 5
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoRockets(Range):
|
||||||
|
"""Set the amount of rocket capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Rockets"
|
||||||
|
range_start = 5
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoEnergyCells(Range):
|
||||||
|
"""Set the amount of energy cell capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Energy Cells"
|
||||||
|
range_start = 30
|
||||||
|
range_end = 999
|
||||||
|
default = 300
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DOOM1993Options(PerGameCommonOptions):
|
class DOOM1993Options(PerGameCommonOptions):
|
||||||
start_inventory_from_pool: StartInventoryPool
|
start_inventory_from_pool: StartInventoryPool
|
||||||
@@ -163,3 +241,14 @@ class DOOM1993Options(PerGameCommonOptions):
|
|||||||
episode3: Episode3
|
episode3: Episode3
|
||||||
episode4: Episode4
|
episode4: Episode4
|
||||||
|
|
||||||
|
split_backpack: SplitBackpack
|
||||||
|
backpack_count: BackpackCount
|
||||||
|
max_ammo_bullets: MaxAmmoBullets
|
||||||
|
max_ammo_shells: MaxAmmoShells
|
||||||
|
max_ammo_rockets: MaxAmmoRockets
|
||||||
|
max_ammo_energy_cells: MaxAmmoEnergyCells
|
||||||
|
added_ammo_bullets: AddedAmmoBullets
|
||||||
|
added_ammo_shells: AddedAmmoShells
|
||||||
|
added_ammo_rockets: AddedAmmoRockets
|
||||||
|
added_ammo_energy_cells: AddedAmmoEnergyCells
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class DOOM1993World(World):
|
|||||||
options: DOOM1993Options
|
options: DOOM1993Options
|
||||||
game = "DOOM 1993"
|
game = "DOOM 1993"
|
||||||
web = DOOM1993Web()
|
web = DOOM1993Web()
|
||||||
required_client_version = (0, 3, 9)
|
required_client_version = (0, 5, 0) # 1.2.0-prerelease or higher
|
||||||
|
|
||||||
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
||||||
item_name_groups = Items.item_name_groups
|
item_name_groups = Items.item_name_groups
|
||||||
@@ -204,6 +204,15 @@ class DOOM1993World(World):
|
|||||||
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
||||||
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
||||||
|
|
||||||
|
# Backpack(s) based on options
|
||||||
|
if self.options.split_backpack.value:
|
||||||
|
itempool += [self.create_item("Bullet capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
itempool += [self.create_item("Shell capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
itempool += [self.create_item("Energy cell capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
itempool += [self.create_item("Rocket capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
else:
|
||||||
|
itempool += [self.create_item("Backpack") for _ in range(self.options.backpack_count.value)]
|
||||||
|
|
||||||
# Place end level items in locked locations
|
# Place end level items in locked locations
|
||||||
for map_name in Maps.map_names:
|
for map_name in Maps.map_names:
|
||||||
loc_name = map_name + " - Exit"
|
loc_name = map_name + " - Exit"
|
||||||
@@ -265,7 +274,7 @@ class DOOM1993World(World):
|
|||||||
# Was balanced for 3 episodes (We added 4th episode, but keep same ratio)
|
# Was balanced for 3 episodes (We added 4th episode, but keep same ratio)
|
||||||
count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3))))
|
count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3))))
|
||||||
if count == 0:
|
if count == 0:
|
||||||
logger.warning("Warning, no ", item_name, " will be placed.")
|
logger.warning(f"Warning, no {item_name} will be placed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
@@ -281,4 +290,14 @@ class DOOM1993World(World):
|
|||||||
# an older version, the player would end up stuck.
|
# an older version, the player would end up stuck.
|
||||||
slot_data["two_ways_keydoors"] = True
|
slot_data["two_ways_keydoors"] = True
|
||||||
|
|
||||||
|
# Send slot data for ammo capacity values; this must be generic because Heretic uses it too
|
||||||
|
slot_data["ammo1start"] = self.options.max_ammo_bullets.value
|
||||||
|
slot_data["ammo2start"] = self.options.max_ammo_shells.value
|
||||||
|
slot_data["ammo3start"] = self.options.max_ammo_energy_cells.value
|
||||||
|
slot_data["ammo4start"] = self.options.max_ammo_rockets.value
|
||||||
|
slot_data["ammo1add"] = self.options.added_ammo_bullets.value
|
||||||
|
slot_data["ammo2add"] = self.options.added_ammo_shells.value
|
||||||
|
slot_data["ammo3add"] = self.options.added_ammo_energy_cells.value
|
||||||
|
slot_data["ammo4add"] = self.options.added_ammo_rockets.value
|
||||||
|
|
||||||
return slot_data
|
return slot_data
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ item_table: Dict[int, ItemDict] = {
|
|||||||
'doom_type': 82,
|
'doom_type': 82,
|
||||||
'episode': -1,
|
'episode': -1,
|
||||||
'map': -1},
|
'map': -1},
|
||||||
360007: {'classification': ItemClassification.progression,
|
360007: {'classification': ItemClassification.useful,
|
||||||
'count': 1,
|
'count': 0,
|
||||||
'name': 'Backpack',
|
'name': 'Backpack',
|
||||||
'doom_type': 8,
|
'doom_type': 8,
|
||||||
'episode': -1,
|
'episode': -1,
|
||||||
@@ -1058,6 +1058,30 @@ item_table: Dict[int, ItemDict] = {
|
|||||||
'doom_type': 2026,
|
'doom_type': 2026,
|
||||||
'episode': 4,
|
'episode': 4,
|
||||||
'map': 2},
|
'map': 2},
|
||||||
|
360600: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Bullet capacity',
|
||||||
|
'doom_type': 65001,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
|
360601: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Shell capacity',
|
||||||
|
'doom_type': 65002,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
|
360602: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Energy cell capacity',
|
||||||
|
'doom_type': 65003,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
|
360603: {'classification': ItemClassification.useful,
|
||||||
|
'count': 0,
|
||||||
|
'name': 'Rocket capacity',
|
||||||
|
'doom_type': 65004,
|
||||||
|
'episode': -1,
|
||||||
|
'map': -1},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import typing
|
import typing
|
||||||
|
|
||||||
from Options import PerGameCommonOptions, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
from Options import PerGameCommonOptions, Range, Choice, Toggle, DeathLink, DefaultOnToggle, StartInventoryPool
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -136,6 +136,84 @@ class SecretLevels(Toggle):
|
|||||||
display_name = "Secret Levels"
|
display_name = "Secret Levels"
|
||||||
|
|
||||||
|
|
||||||
|
class SplitBackpack(Toggle):
|
||||||
|
"""Split the Backpack into four individual items, each one increasing ammo capacity for one type of weapon only."""
|
||||||
|
display_name = "Split Backpack"
|
||||||
|
|
||||||
|
|
||||||
|
class BackpackCount(Range):
|
||||||
|
"""How many Backpacks will be available.
|
||||||
|
If Split Backpack is set, this will be the number of each capacity upgrade available."""
|
||||||
|
display_name = "Backpack Count"
|
||||||
|
range_start = 0
|
||||||
|
range_end = 10
|
||||||
|
default = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoBullets(Range):
|
||||||
|
"""Set the starting ammo capacity for bullets."""
|
||||||
|
display_name = "Max Ammo - Bullets"
|
||||||
|
range_start = 200
|
||||||
|
range_end = 999
|
||||||
|
default = 200
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoShells(Range):
|
||||||
|
"""Set the starting ammo capacity for shotgun shells."""
|
||||||
|
display_name = "Max Ammo - Shells"
|
||||||
|
range_start = 50
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoRockets(Range):
|
||||||
|
"""Set the starting ammo capacity for rockets."""
|
||||||
|
display_name = "Max Ammo - Rockets"
|
||||||
|
range_start = 50
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class MaxAmmoEnergyCells(Range):
|
||||||
|
"""Set the starting ammo capacity for energy cells."""
|
||||||
|
display_name = "Max Ammo - Energy Cells"
|
||||||
|
range_start = 300
|
||||||
|
range_end = 999
|
||||||
|
default = 300
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoBullets(Range):
|
||||||
|
"""Set the amount of bullet capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Bullets"
|
||||||
|
range_start = 20
|
||||||
|
range_end = 999
|
||||||
|
default = 200
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoShells(Range):
|
||||||
|
"""Set the amount of shotgun shell capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Shells"
|
||||||
|
range_start = 5
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoRockets(Range):
|
||||||
|
"""Set the amount of rocket capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Rockets"
|
||||||
|
range_start = 5
|
||||||
|
range_end = 999
|
||||||
|
default = 50
|
||||||
|
|
||||||
|
|
||||||
|
class AddedAmmoEnergyCells(Range):
|
||||||
|
"""Set the amount of energy cell capacity added when collecting a backpack or capacity upgrade."""
|
||||||
|
display_name = "Added Ammo - Energy Cells"
|
||||||
|
range_start = 30
|
||||||
|
range_end = 999
|
||||||
|
default = 300
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DOOM2Options(PerGameCommonOptions):
|
class DOOM2Options(PerGameCommonOptions):
|
||||||
start_inventory_from_pool: StartInventoryPool
|
start_inventory_from_pool: StartInventoryPool
|
||||||
@@ -153,3 +231,14 @@ class DOOM2Options(PerGameCommonOptions):
|
|||||||
episode2: Episode2
|
episode2: Episode2
|
||||||
episode3: Episode3
|
episode3: Episode3
|
||||||
episode4: SecretLevels
|
episode4: SecretLevels
|
||||||
|
|
||||||
|
split_backpack: SplitBackpack
|
||||||
|
backpack_count: BackpackCount
|
||||||
|
max_ammo_bullets: MaxAmmoBullets
|
||||||
|
max_ammo_shells: MaxAmmoShells
|
||||||
|
max_ammo_rockets: MaxAmmoRockets
|
||||||
|
max_ammo_energy_cells: MaxAmmoEnergyCells
|
||||||
|
added_ammo_bullets: AddedAmmoBullets
|
||||||
|
added_ammo_shells: AddedAmmoShells
|
||||||
|
added_ammo_rockets: AddedAmmoRockets
|
||||||
|
added_ammo_energy_cells: AddedAmmoEnergyCells
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class DOOM2World(World):
|
|||||||
options: DOOM2Options
|
options: DOOM2Options
|
||||||
game = "DOOM II"
|
game = "DOOM II"
|
||||||
web = DOOM2Web()
|
web = DOOM2Web()
|
||||||
required_client_version = (0, 3, 9)
|
required_client_version = (0, 5, 0) # 1.2.0-prerelease or higher
|
||||||
|
|
||||||
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
item_name_to_id = {data["name"]: item_id for item_id, data in Items.item_table.items()}
|
||||||
item_name_groups = Items.item_name_groups
|
item_name_groups = Items.item_name_groups
|
||||||
@@ -196,6 +196,15 @@ class DOOM2World(World):
|
|||||||
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
count = item["count"] if item["name"] not in self.starting_level_for_episode else item["count"] - 1
|
||||||
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
itempool += [self.create_item(item["name"]) for _ in range(count)]
|
||||||
|
|
||||||
|
# Backpack(s) based on options
|
||||||
|
if self.options.split_backpack.value:
|
||||||
|
itempool += [self.create_item("Bullet capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
itempool += [self.create_item("Shell capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
itempool += [self.create_item("Energy cell capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
itempool += [self.create_item("Rocket capacity") for _ in range(self.options.backpack_count.value)]
|
||||||
|
else:
|
||||||
|
itempool += [self.create_item("Backpack") for _ in range(self.options.backpack_count.value)]
|
||||||
|
|
||||||
# Place end level items in locked locations
|
# Place end level items in locked locations
|
||||||
for map_name in Maps.map_names:
|
for map_name in Maps.map_names:
|
||||||
loc_name = map_name + " - Exit"
|
loc_name = map_name + " - Exit"
|
||||||
@@ -258,11 +267,23 @@ class DOOM2World(World):
|
|||||||
# Was balanced based on DOOM 1993's first 3 episodes
|
# Was balanced based on DOOM 1993's first 3 episodes
|
||||||
count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3))))
|
count = min(remaining_loc, max(1, int(round(self.items_ratio[item_name] * ep_count / 3))))
|
||||||
if count == 0:
|
if count == 0:
|
||||||
logger.warning("Warning, no ", item_name, " will be placed.")
|
logger.warning(f"Warning, no {item_name} will be placed.")
|
||||||
return
|
return
|
||||||
|
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
itempool.append(self.create_item(item_name))
|
itempool.append(self.create_item(item_name))
|
||||||
|
|
||||||
def fill_slot_data(self) -> Dict[str, Any]:
|
def fill_slot_data(self) -> Dict[str, Any]:
|
||||||
return self.options.as_dict("difficulty", "random_monsters", "random_pickups", "random_music", "flip_levels", "allow_death_logic", "pro", "death_link", "reset_level_on_death", "episode1", "episode2", "episode3", "episode4")
|
slot_data = self.options.as_dict("difficulty", "random_monsters", "random_pickups", "random_music", "flip_levels", "allow_death_logic", "pro", "death_link", "reset_level_on_death", "episode1", "episode2", "episode3", "episode4")
|
||||||
|
|
||||||
|
# Send slot data for ammo capacity values; this must be generic because Heretic uses it too
|
||||||
|
slot_data["ammo1start"] = self.options.max_ammo_bullets.value
|
||||||
|
slot_data["ammo2start"] = self.options.max_ammo_shells.value
|
||||||
|
slot_data["ammo3start"] = self.options.max_ammo_energy_cells.value
|
||||||
|
slot_data["ammo4start"] = self.options.max_ammo_rockets.value
|
||||||
|
slot_data["ammo1add"] = self.options.added_ammo_bullets.value
|
||||||
|
slot_data["ammo2add"] = self.options.added_ammo_shells.value
|
||||||
|
slot_data["ammo3add"] = self.options.added_ammo_energy_cells.value
|
||||||
|
slot_data["ammo4add"] = self.options.added_ammo_rockets.value
|
||||||
|
|
||||||
|
return slot_data
|
||||||
|
|||||||
@@ -234,8 +234,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
|
||||||
else:
|
else:
|
||||||
data = data["info"]
|
data = data["info"]
|
||||||
research_data = data["research_done"]
|
research_data: set[int] = {int(tech_name.split("-")[1]) for tech_name in data["research_done"]}
|
||||||
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
|
|
||||||
victory = data["victory"]
|
victory = data["victory"]
|
||||||
await ctx.update_death_link(data["death_link"])
|
await ctx.update_death_link(data["death_link"])
|
||||||
ctx.multiplayer = data.get("multiplayer", False)
|
ctx.multiplayer = data.get("multiplayer", False)
|
||||||
@@ -249,7 +248,7 @@ async def game_watcher(ctx: FactorioContext):
|
|||||||
f"New researches done: "
|
f"New researches done: "
|
||||||
f"{[ctx.location_names.lookup_in_game(rid) for rid in research_data - ctx.locations_checked]}")
|
f"{[ctx.location_names.lookup_in_game(rid) for rid in research_data - ctx.locations_checked]}")
|
||||||
ctx.locations_checked = research_data
|
ctx.locations_checked = research_data
|
||||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
|
await ctx.check_locations(research_data)
|
||||||
death_link_tick = data.get("death_link_tick", 0)
|
death_link_tick = data.get("death_link_tick", 0)
|
||||||
if death_link_tick != ctx.death_link_tick:
|
if death_link_tick != ctx.death_link_tick:
|
||||||
ctx.death_link_tick = death_link_tick
|
ctx.death_link_tick = death_link_tick
|
||||||
|
|||||||
@@ -3,13 +3,23 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
from schema import Schema, Optional, And, Or
|
from schema import Schema, Optional, And, Or, SchemaError
|
||||||
|
|
||||||
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
|
from Options import Choice, OptionDict, OptionSet, DefaultOnToggle, Range, DeathLink, Toggle, \
|
||||||
StartInventoryPool, PerGameCommonOptions, OptionGroup
|
StartInventoryPool, PerGameCommonOptions, OptionGroup
|
||||||
|
|
||||||
# schema helpers
|
# schema helpers
|
||||||
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
|
class FloatRange:
|
||||||
|
def __init__(self, low, high):
|
||||||
|
self._low = low
|
||||||
|
self._high = high
|
||||||
|
|
||||||
|
def validate(self, value):
|
||||||
|
if not isinstance(value, (float, int)):
|
||||||
|
raise SchemaError(f"should be instance of float or int, but was {value!r}")
|
||||||
|
if not self._low <= value <= self._high:
|
||||||
|
raise SchemaError(f"{value} is not between {self._low} and {self._high}")
|
||||||
|
|
||||||
LuaBool = Or(bool, And(int, lambda n: n in (0, 1)))
|
LuaBool = Or(bool, And(int, lambda n: n in (0, 1)))
|
||||||
|
|
||||||
|
|
||||||
@@ -225,6 +235,12 @@ class FactorioStartItems(OptionDict):
|
|||||||
"""Mapping of Factorio internal item-name to amount granted on start."""
|
"""Mapping of Factorio internal item-name to amount granted on start."""
|
||||||
display_name = "Starting Items"
|
display_name = "Starting Items"
|
||||||
default = {"burner-mining-drill": 4, "stone-furnace": 4, "raw-fish": 50}
|
default = {"burner-mining-drill": 4, "stone-furnace": 4, "raw-fish": 50}
|
||||||
|
schema = Schema(
|
||||||
|
{
|
||||||
|
str: And(int, lambda n: n > 0,
|
||||||
|
error="amount of starting items has to be a positive integer"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FactorioFreeSampleBlacklist(OptionSet):
|
class FactorioFreeSampleBlacklist(OptionSet):
|
||||||
@@ -247,7 +263,8 @@ class AttackTrapCount(TrapCount):
|
|||||||
|
|
||||||
|
|
||||||
class TeleportTrapCount(TrapCount):
|
class TeleportTrapCount(TrapCount):
|
||||||
"""Trap items that when received trigger a random teleport."""
|
"""Trap items that when received trigger a random teleport.
|
||||||
|
It is ensured the player can walk back to where they got teleported from."""
|
||||||
display_name = "Teleport Traps"
|
display_name = "Teleport Traps"
|
||||||
|
|
||||||
|
|
||||||
@@ -294,6 +311,11 @@ class EvolutionTrapIncrease(Range):
|
|||||||
range_end = 100
|
range_end = 100
|
||||||
|
|
||||||
|
|
||||||
|
class InventorySpillTrapCount(TrapCount):
|
||||||
|
"""Trap items that when received trigger dropping your main inventory and trash inventory onto the ground."""
|
||||||
|
display_name = "Inventory Spill Traps"
|
||||||
|
|
||||||
|
|
||||||
class FactorioWorldGen(OptionDict):
|
class FactorioWorldGen(OptionDict):
|
||||||
"""World Generation settings. Overview of options at https://wiki.factorio.com/Map_generator,
|
"""World Generation settings. Overview of options at https://wiki.factorio.com/Map_generator,
|
||||||
with in-depth documentation at https://lua-api.factorio.com/latest/Concepts.html#MapGenSettings"""
|
with in-depth documentation at https://lua-api.factorio.com/latest/Concepts.html#MapGenSettings"""
|
||||||
@@ -474,6 +496,7 @@ class FactorioOptions(PerGameCommonOptions):
|
|||||||
artillery_traps: ArtilleryTrapCount
|
artillery_traps: ArtilleryTrapCount
|
||||||
atomic_rocket_traps: AtomicRocketTrapCount
|
atomic_rocket_traps: AtomicRocketTrapCount
|
||||||
atomic_cliff_remover_traps: AtomicCliffRemoverTrapCount
|
atomic_cliff_remover_traps: AtomicCliffRemoverTrapCount
|
||||||
|
inventory_spill_traps: InventorySpillTrapCount
|
||||||
attack_traps: AttackTrapCount
|
attack_traps: AttackTrapCount
|
||||||
evolution_traps: EvolutionTrapCount
|
evolution_traps: EvolutionTrapCount
|
||||||
evolution_trap_increase: EvolutionTrapIncrease
|
evolution_trap_increase: EvolutionTrapIncrease
|
||||||
@@ -508,6 +531,7 @@ option_groups: list[OptionGroup] = [
|
|||||||
ArtilleryTrapCount,
|
ArtilleryTrapCount,
|
||||||
AtomicRocketTrapCount,
|
AtomicRocketTrapCount,
|
||||||
AtomicCliffRemoverTrapCount,
|
AtomicCliffRemoverTrapCount,
|
||||||
|
InventorySpillTrapCount,
|
||||||
],
|
],
|
||||||
start_collapsed=True
|
start_collapsed=True
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Utils
|
|||||||
import settings
|
import settings
|
||||||
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
|
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification
|
||||||
from worlds.AutoWorld import World, WebWorld
|
from worlds.AutoWorld import World, WebWorld
|
||||||
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
|
from worlds.LauncherComponents import Component, components, Type, launch as launch_component
|
||||||
from worlds.generic import Rules
|
from worlds.generic import Rules
|
||||||
from .Locations import location_pools, location_table
|
from .Locations import location_pools, location_table
|
||||||
from .Mod import generate_mod
|
from .Mod import generate_mod
|
||||||
@@ -24,7 +24,7 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
|
|||||||
|
|
||||||
def launch_client():
|
def launch_client():
|
||||||
from .Client import launch
|
from .Client import launch
|
||||||
launch_subprocess(launch, name="FactorioClient")
|
launch_component(launch, name="FactorioClient")
|
||||||
|
|
||||||
|
|
||||||
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
|
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
|
||||||
@@ -78,6 +78,7 @@ all_items["Cluster Grenade Trap"] = factorio_base_id - 5
|
|||||||
all_items["Artillery Trap"] = factorio_base_id - 6
|
all_items["Artillery Trap"] = factorio_base_id - 6
|
||||||
all_items["Atomic Rocket Trap"] = factorio_base_id - 7
|
all_items["Atomic Rocket Trap"] = factorio_base_id - 7
|
||||||
all_items["Atomic Cliff Remover Trap"] = factorio_base_id - 8
|
all_items["Atomic Cliff Remover Trap"] = factorio_base_id - 8
|
||||||
|
all_items["Inventory Spill Trap"] = factorio_base_id - 9
|
||||||
|
|
||||||
|
|
||||||
class Factorio(World):
|
class Factorio(World):
|
||||||
@@ -112,6 +113,8 @@ class Factorio(World):
|
|||||||
science_locations: typing.List[FactorioScienceLocation]
|
science_locations: typing.List[FactorioScienceLocation]
|
||||||
removed_technologies: typing.Set[str]
|
removed_technologies: typing.Set[str]
|
||||||
settings: typing.ClassVar[FactorioSettings]
|
settings: typing.ClassVar[FactorioSettings]
|
||||||
|
trap_names: tuple[str] = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery",
|
||||||
|
"Atomic Rocket", "Atomic Cliff Remover", "Inventory Spill")
|
||||||
|
|
||||||
def __init__(self, world, player: int):
|
def __init__(self, world, player: int):
|
||||||
super(Factorio, self).__init__(world, player)
|
super(Factorio, self).__init__(world, player)
|
||||||
@@ -136,15 +139,11 @@ class Factorio(World):
|
|||||||
random = self.random
|
random = self.random
|
||||||
nauvis = Region("Nauvis", player, self.multiworld)
|
nauvis = Region("Nauvis", player, self.multiworld)
|
||||||
|
|
||||||
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
|
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo
|
||||||
self.options.evolution_traps + \
|
|
||||||
self.options.attack_traps + \
|
for name in self.trap_names:
|
||||||
self.options.teleport_traps + \
|
name = name.replace(" ", "_").lower()+"_traps"
|
||||||
self.options.grenade_traps + \
|
location_count += getattr(self.options, name)
|
||||||
self.options.cluster_grenade_traps + \
|
|
||||||
self.options.atomic_rocket_traps + \
|
|
||||||
self.options.atomic_cliff_remover_traps + \
|
|
||||||
self.options.artillery_traps
|
|
||||||
|
|
||||||
location_pool = []
|
location_pool = []
|
||||||
|
|
||||||
@@ -196,9 +195,8 @@ class Factorio(World):
|
|||||||
def create_items(self) -> None:
|
def create_items(self) -> None:
|
||||||
self.custom_technologies = self.set_custom_technologies()
|
self.custom_technologies = self.set_custom_technologies()
|
||||||
self.set_custom_recipes()
|
self.set_custom_recipes()
|
||||||
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket",
|
|
||||||
"Atomic Cliff Remover")
|
for trap_name in self.trap_names:
|
||||||
for trap_name in traps:
|
|
||||||
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
|
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
|
||||||
range(getattr(self.options,
|
range(getattr(self.options,
|
||||||
f"{trap_name.lower().replace(' ', '_')}_traps")))
|
f"{trap_name.lower().replace(' ', '_')}_traps")))
|
||||||
@@ -280,9 +278,6 @@ class Factorio(World):
|
|||||||
self.get_location("Rocket Launch").access_rule = lambda state: all(state.has(technology, player)
|
self.get_location("Rocket Launch").access_rule = lambda state: all(state.has(technology, player)
|
||||||
for technology in
|
for technology in
|
||||||
victory_tech_names)
|
victory_tech_names)
|
||||||
for tech_name in victory_tech_names:
|
|
||||||
if not self.multiworld.get_all_state(True).has(tech_name, player):
|
|
||||||
print(tech_name)
|
|
||||||
self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player)
|
self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player)
|
||||||
|
|
||||||
def get_recipe(self, name: str) -> Recipe:
|
def get_recipe(self, name: str) -> Recipe:
|
||||||
|
|||||||
@@ -48,3 +48,107 @@ function fire_entity_at_entities(entity_name, entities, speed)
|
|||||||
target=target, speed=speed}
|
target=target, speed=speed}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local teleport_requests = {}
|
||||||
|
local teleport_attempts = {}
|
||||||
|
local max_attempts = 100
|
||||||
|
|
||||||
|
function attempt_teleport_player(player, attempt)
|
||||||
|
-- global attempt storage as metadata can't be stored
|
||||||
|
if attempt == nil then
|
||||||
|
attempt = teleport_attempts[player.index]
|
||||||
|
else
|
||||||
|
teleport_attempts[player.index] = attempt
|
||||||
|
end
|
||||||
|
|
||||||
|
if attempt > max_attempts then
|
||||||
|
player.print("Teleport failed: No valid position found after " .. max_attempts .. " attempts!")
|
||||||
|
teleport_attempts[player.index] = 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local surface = player.character.surface
|
||||||
|
local prototype_name = player.character.prototype.name
|
||||||
|
local original_position = player.character.position
|
||||||
|
local candidate_position = random_offset_position(original_position, 1024)
|
||||||
|
|
||||||
|
local non_colliding_position = surface.find_non_colliding_position(
|
||||||
|
prototype_name, candidate_position, 0, 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if non_colliding_position then
|
||||||
|
-- Request pathfinding asynchronously
|
||||||
|
local path_id = surface.request_path{
|
||||||
|
bounding_box = player.character.prototype.collision_box,
|
||||||
|
collision_mask = { layers = { ["player"] = true } },
|
||||||
|
start = original_position,
|
||||||
|
goal = non_colliding_position,
|
||||||
|
force = player.force.name,
|
||||||
|
radius = 1,
|
||||||
|
pathfind_flags = {cache = true, low_priority = true, allow_paths_through_own_entities = true},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Store the request with the player index as the key
|
||||||
|
teleport_requests[player.index] = path_id
|
||||||
|
else
|
||||||
|
attempt_teleport_player(player, attempt + 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function handle_teleport_attempt(event)
|
||||||
|
for player_index, path_id in pairs(teleport_requests) do
|
||||||
|
-- Check if the event matches the stored path_id
|
||||||
|
if path_id == event.id then
|
||||||
|
local player = game.players[player_index]
|
||||||
|
|
||||||
|
if event.path then
|
||||||
|
if player.character then
|
||||||
|
player.character.teleport(event.path[#event.path].position) -- Teleport to the last point in the path
|
||||||
|
-- Clear the attempts for this player
|
||||||
|
teleport_attempts[player_index] = 0
|
||||||
|
return
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
attempt_teleport_player(player, nil)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
function spill_character_inventory(character)
|
||||||
|
if not (character and character.valid) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- grab attrs once pre-loop
|
||||||
|
local position = character.position
|
||||||
|
local surface = character.surface
|
||||||
|
|
||||||
|
local inventories_to_spill = {
|
||||||
|
defines.inventory.character_main, -- Main inventory
|
||||||
|
defines.inventory.character_trash, -- Logistic trash slots
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, inventory_type in pairs(inventories_to_spill) do
|
||||||
|
local inventory = character.get_inventory(inventory_type)
|
||||||
|
if inventory and inventory.valid then
|
||||||
|
-- Spill each item stack onto the ground
|
||||||
|
for i = 1, #inventory do
|
||||||
|
local stack = inventory[i]
|
||||||
|
if stack and stack.valid_for_read then
|
||||||
|
local spilled_items = surface.spill_item_stack{
|
||||||
|
position = position,
|
||||||
|
stack = stack,
|
||||||
|
enable_looted = false, -- do not mark for auto-pickup
|
||||||
|
force = nil, -- do not mark for auto-deconstruction
|
||||||
|
allow_belts = true, -- do mark for putting it onto belts
|
||||||
|
}
|
||||||
|
if #spilled_items > 0 then
|
||||||
|
stack.clear() -- only delete if spilled successfully
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user