mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-10 01:23:48 -07:00
Compare commits
190 Commits
NewSoupVi-
...
core_multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c2cda333 | ||
|
|
06111ac6cf | ||
|
|
d83294efa7 | ||
|
|
be550ff6fb | ||
|
|
dd55409209 | ||
|
|
e267714d44 | ||
|
|
7c30c4a169 | ||
|
|
4882366ffc | ||
|
|
5f73c245fc | ||
|
|
21ffc0fc54 | ||
|
|
e95a41cf93 | ||
|
|
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": [
|
||||
"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",
|
||||
"../Patch.py"
|
||||
"type_check.py"
|
||||
],
|
||||
|
||||
"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
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
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
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
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?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
@@ -21,7 +21,7 @@ on:
|
||||
- '**.hh?'
|
||||
- '**.hpp'
|
||||
- '**.hxx'
|
||||
- '**.CMakeLists'
|
||||
- '**/CMakeLists.txt'
|
||||
- '.github/workflows/ctest.yml'
|
||||
|
||||
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
|
||||
"${{ env.PYTHON }}" -m venv venv
|
||||
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
|
||||
echo -e "setup.py build output:\n `ls build`"
|
||||
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"
|
||||
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
|
||||
|
||||
- name: "pyright: strict check on specific files"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@
|
||||
*_Spoiler.txt
|
||||
*.bmbp
|
||||
*.apbp
|
||||
*.apcivvi
|
||||
*.apl2ac
|
||||
*.apm3
|
||||
*.apmc
|
||||
|
||||
@@ -869,21 +869,40 @@ class CollectionState():
|
||||
def has(self, item: str, player: int, count: int = 1) -> bool:
|
||||
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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
return self.prog_items[player][item]
|
||||
@@ -911,11 +930,20 @@ class CollectionState():
|
||||
|
||||
def count_from_list(self, items: Iterable[str], player: int) -> int:
|
||||
"""Returns the cumulative count of items from a list present in state."""
|
||||
return sum(self.prog_items[player][item_name] for item_name in items)
|
||||
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:
|
||||
"""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
|
||||
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
|
||||
|
||||
@@ -31,6 +31,7 @@ import ssl
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import kvui
|
||||
import argparse
|
||||
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
@@ -459,6 +460,13 @@ class CommonContext:
|
||||
await self.send_msgs([payload])
|
||||
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:
|
||||
if self.ui:
|
||||
self.ui.focus_textinput()
|
||||
@@ -701,8 +709,16 @@ class CommonContext:
|
||||
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
|
||||
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
|
||||
|
||||
def make_gui(self) -> typing.Type["kvui.GameManager"]:
|
||||
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
class TextManager(GameManager):
|
||||
@@ -891,6 +907,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
||||
ctx.disconnected_intentionally = True
|
||||
ctx.event_invalid_game()
|
||||
elif 'IncompatibleVersion' in errors:
|
||||
ctx.disconnected_intentionally = True
|
||||
raise Exception('Server reported your client version as incompatible. '
|
||||
'This probably means you have to update.')
|
||||
elif 'InvalidItemsHandling' in errors:
|
||||
@@ -1041,6 +1058,32 @@ def get_base_parser(description: typing.Optional[str] = None):
|
||||
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):
|
||||
class TextContext(CommonContext):
|
||||
# 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:
|
||||
await super(TextContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
await self.send_connect()
|
||||
await self.send_connect(game="")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "Connected":
|
||||
@@ -1082,17 +1125,7 @@ def run_as_textclient(*args):
|
||||
parser.add_argument("url", nargs="?", help="Archipelago connection url")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
|
||||
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")
|
||||
args = handle_url_arg(args, parser=parser)
|
||||
|
||||
# use colorama to display colored text highlighting on windows
|
||||
colorama.init()
|
||||
|
||||
28
Fill.py
28
Fill.py
@@ -502,7 +502,13 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
# "priority fill"
|
||||
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
|
||||
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)
|
||||
defaultlocations = prioritylocations + defaultlocations
|
||||
|
||||
@@ -571,6 +577,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
print_data = {"items": items_counter, "locations": locations_counter}
|
||||
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:
|
||||
# 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
|
||||
parser.add_argument('--race', action='store_true', default=defaults.race)
|
||||
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",
|
||||
help="Output rolled player options to csv (made for async multiworld).")
|
||||
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)
|
||||
|
||||
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)
|
||||
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:
|
||||
weights = roll_linked_options(weights)
|
||||
|
||||
valid_keys = set()
|
||||
valid_keys = {"triggers"}
|
||||
if "triggers" in weights:
|
||||
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():
|
||||
handle_option(ret, game_weights, option_key, option, plando_options)
|
||||
valid_keys.add(option_key)
|
||||
for option_key in game_weights:
|
||||
if option_key in {"triggers", *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}.")
|
||||
|
||||
# TODO remove plando_items after moving it to the options system
|
||||
valid_keys.add("plando_items")
|
||||
if PlandoOptions.items in plando_options:
|
||||
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
|
||||
from NetUtils import ClientStatus
|
||||
from worlds.ladx.Common import BASE_ID as LABaseID
|
||||
from worlds.ladx.GpsTracker import GpsTracker
|
||||
from worlds.ladx.TrackerConsts import storage_key
|
||||
from worlds.ladx.ItemTracker import ItemTracker
|
||||
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
|
||||
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
|
||||
@@ -100,19 +101,23 @@ class LAClientConstants:
|
||||
WRamCheckSize = 0x4
|
||||
WRamSafetyValue = bytearray([0]*WRamCheckSize)
|
||||
|
||||
wRamStart = 0xC000
|
||||
hRamStart = 0xFF80
|
||||
hRamSize = 0x80
|
||||
|
||||
MinGameplayValue = 0x06
|
||||
MaxGameplayValue = 0x1A
|
||||
VictoryGameplayAndSub = 0x0102
|
||||
|
||||
|
||||
class RAGameboy():
|
||||
cache = []
|
||||
cache_start = 0
|
||||
cache_size = 0
|
||||
last_cache_read = None
|
||||
socket = 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.port = port
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
@@ -131,9 +136,14 @@ class RAGameboy():
|
||||
async def get_retroarch_status(self):
|
||||
return await self.send_command("GET_STATUS")
|
||||
|
||||
def set_cache_limits(self, cache_start, cache_size):
|
||||
self.cache_start = cache_start
|
||||
self.cache_size = cache_size
|
||||
def set_checks_range(self, checks_start, checks_size):
|
||||
self.checks_start = checks_start
|
||||
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):
|
||||
if type(b) is str:
|
||||
@@ -188,21 +198,57 @@ class RAGameboy():
|
||||
if not await self.check_safe_gameplay():
|
||||
return
|
||||
|
||||
cache = []
|
||||
remaining_size = self.cache_size
|
||||
while remaining_size:
|
||||
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
|
||||
remaining_size -= len(block)
|
||||
cache += block
|
||||
attempts = 0
|
||||
while True:
|
||||
# RA doesn't let us do an atomic read of a large enough block of RAM
|
||||
# Some bytes can't change in between reading location_block and hram_block
|
||||
location_block = await self.read_memory_block(self.location_start, self.location_size)
|
||||
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():
|
||||
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()
|
||||
|
||||
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):
|
||||
# TODO: can we just update once per frame?
|
||||
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
|
||||
await self.update_cache()
|
||||
if not self.cache:
|
||||
@@ -359,11 +405,12 @@ class LinksAwakeningClient():
|
||||
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
|
||||
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()
|
||||
self.tracker = LocationTracker(self.gameboy)
|
||||
self.item_tracker = ItemTracker(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):
|
||||
# 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
|
||||
|
||||
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.item_tracker.readItems()
|
||||
await self.gps_tracker.read_location()
|
||||
await self.gps_tracker.read_entrances()
|
||||
|
||||
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
|
||||
if self.deathlink_debounce and current_health != 0:
|
||||
@@ -465,6 +514,10 @@ class LinksAwakeningContext(CommonContext):
|
||||
magpie_task = None
|
||||
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:
|
||||
self.client = LinksAwakeningClient()
|
||||
self.slot_data = {}
|
||||
@@ -507,7 +560,19 @@ class LinksAwakeningContext(CommonContext):
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
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)
|
||||
|
||||
had_invalid_slot_data = None
|
||||
@@ -536,6 +601,12 @@ class LinksAwakeningContext(CommonContext):
|
||||
logger.info("victory!")
|
||||
await self.send_msgs(message)
|
||||
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:
|
||||
if self.ENABLE_DEATHLINK:
|
||||
@@ -560,6 +631,10 @@ class LinksAwakeningContext(CommonContext):
|
||||
|
||||
while self.client.auth == None:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Just return if we're closing
|
||||
if self.exit_event.is_set():
|
||||
return
|
||||
self.auth = self.client.auth
|
||||
await self.send_connect()
|
||||
|
||||
@@ -572,6 +647,12 @@ class LinksAwakeningContext(CommonContext):
|
||||
if cmd == "ReceivedItems":
|
||||
for index, item in enumerate(args["items"], start=args["index"]):
|
||||
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):
|
||||
sync_msg = [{'cmd': 'Sync'}]
|
||||
@@ -585,6 +666,12 @@ class LinksAwakeningContext(CommonContext):
|
||||
checkMetadataTable[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():
|
||||
await self.send_victory()
|
||||
|
||||
@@ -618,12 +705,20 @@ class LinksAwakeningContext(CommonContext):
|
||||
if not self.client.recvd_checks:
|
||||
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:
|
||||
await self.client.main_tick(on_item_get, victory, deathlink)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
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:
|
||||
self.last_resend = now
|
||||
await self.send_checks()
|
||||
@@ -631,8 +726,15 @@ class LinksAwakeningContext(CommonContext):
|
||||
try:
|
||||
self.magpie.set_checks(self.client.tracker.all_checks)
|
||||
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
|
||||
|
||||
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:
|
||||
# Don't let magpie errors take out the client
|
||||
pass
|
||||
|
||||
@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
class AdjusterWorld(object):
|
||||
class AdjusterSubWorld(object):
|
||||
def __init__(self, random):
|
||||
self.random = random
|
||||
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.per_slot_randoms = {1: random}
|
||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
|
||||
|
||||
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:
|
||||
multiworld.worlds[1].options.non_local_items.value = set()
|
||||
multiworld.worlds[1].options.local_items.value = set()
|
||||
|
||||
|
||||
AutoWorld.call_all(multiworld, "connect_entrances")
|
||||
AutoWorld.call_all(multiworld, "generate_basic")
|
||||
|
||||
# remove starting inventory from pool items.
|
||||
|
||||
121
MultiServer.py
121
MultiServer.py
@@ -28,9 +28,11 @@ ModuleUpdate.update()
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
import ssl
|
||||
from NetUtils import ServerConnection
|
||||
|
||||
import websockets
|
||||
import colorama
|
||||
import websockets
|
||||
from websockets.extensions.permessage_deflate import PerMessageDeflate
|
||||
try:
|
||||
# ponyorm is a requirement for webhost, not default server, so may not be importable
|
||||
from pony.orm.dbapiprovider import OperationalError
|
||||
@@ -119,13 +121,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
|
||||
|
||||
class Client(Endpoint):
|
||||
version = Version(0, 0, 0)
|
||||
tags: typing.List[str] = []
|
||||
tags: typing.List[str]
|
||||
remote_items: bool
|
||||
remote_start_inventory: bool
|
||||
no_items: 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)
|
||||
self.auth = False
|
||||
self.team = None
|
||||
@@ -175,6 +178,7 @@ class Context:
|
||||
"compatibility": int}
|
||||
# team -> slot id -> list of clients authenticated to slot.
|
||||
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]]]
|
||||
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
|
||||
hints_used: typing.Dict[typing.Tuple[int, int], int]
|
||||
@@ -364,18 +368,28 @@ class Context:
|
||||
return True
|
||||
|
||||
def broadcast_all(self, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||
data = self.dumper(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 = {}):
|
||||
self.logger.info("Notice (all): %s" % text)
|
||||
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
|
||||
|
||||
def broadcast_team(self, team: int, msgs: typing.List[dict]):
|
||||
msgs = self.dumper(msgs)
|
||||
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
|
||||
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
|
||||
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
|
||||
data = self.dumper(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]):
|
||||
msgs = self.dumper(msgs)
|
||||
@@ -389,13 +403,13 @@ class Context:
|
||||
await on_client_disconnected(self, endpoint)
|
||||
|
||||
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
if not client.auth or client.no_text:
|
||||
return
|
||||
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
|
||||
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
|
||||
|
||||
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
|
||||
if not client.auth:
|
||||
if not client.auth or client.no_text:
|
||||
return
|
||||
async_start(self.send_msgs(client,
|
||||
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
|
||||
@@ -743,23 +757,24 @@ class Context:
|
||||
concerns[player].append(data)
|
||||
if not hint.local and data not in concerns[hint.finding_player]:
|
||||
concerns[hint.finding_player].append(data)
|
||||
# remember hints in all cases
|
||||
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
# only remember hints that were not already found at the time of creation
|
||||
if not hint.found:
|
||||
# since hints are bidirectional, finding player and receiving player,
|
||||
# we can check once if hint already exists
|
||||
if hint not in self.hints[team, hint.finding_player]:
|
||||
self.hints[team, hint.finding_player].add(hint)
|
||||
new_hint_events.add(hint.finding_player)
|
||||
for player in self.slot_set(hint.receiving_player):
|
||||
self.hints[team, player].add(hint)
|
||||
new_hint_events.add(player)
|
||||
|
||||
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
|
||||
for slot in new_hint_events:
|
||||
self.on_new_hint(team, slot)
|
||||
for slot, hint_data in concerns.items():
|
||||
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:
|
||||
continue
|
||||
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]:
|
||||
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 None
|
||||
|
||||
@@ -818,7 +833,7 @@ def update_aliases(ctx: Context, team: int):
|
||||
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)
|
||||
ctx.endpoints.append(client)
|
||||
|
||||
@@ -909,6 +924,10 @@ async def on_client_joined(ctx: Context, client: Client):
|
||||
"If your client supports it, "
|
||||
"you may have additional local commands you can list with /help.",
|
||||
{"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)
|
||||
|
||||
|
||||
@@ -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],
|
||||
count_activity: bool = True):
|
||||
slot_locations = ctx.locations[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 count_activity:
|
||||
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
sortable: list[tuple[int, int, int, int]] = []
|
||||
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)
|
||||
send_items_to(ctx, team, target_player, new_item)
|
||||
|
||||
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],
|
||||
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)
|
||||
ctx.broadcast_team(team, [info_text])
|
||||
if len(info_texts) >= 140:
|
||||
# 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
|
||||
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]
|
||||
for finding_player, location_id, item_id, receiving_player, item_flags \
|
||||
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:
|
||||
hints.append(prev_hint)
|
||||
else:
|
||||
@@ -1786,7 +1821,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
ctx.clients[team][slot].append(client)
|
||||
client.version = args['version']
|
||||
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 = {
|
||||
"cmd": "Connected",
|
||||
"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"]
|
||||
if set(old_tags) != set(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(
|
||||
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed 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"]:
|
||||
if type(location) is not int:
|
||||
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}])
|
||||
return
|
||||
|
||||
@@ -1990,6 +2031,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
args["cmd"] = "SetReply"
|
||||
value = ctx.stored_data.get(args["key"], args.get("default", 0))
|
||||
args["original_value"] = copy.copy(value)
|
||||
args["slot"] = client.slot
|
||||
for operation in args["operations"]:
|
||||
func = modify_functions[operation["operation"]]
|
||||
value = func(value, operation["value"])
|
||||
@@ -2494,6 +2536,25 @@ async def main(args: argparse.Namespace):
|
||||
logging.info("No file selected. Exiting.")
|
||||
import sys
|
||||
sys.exit(1)
|
||||
elif not args.disable_save:
|
||||
import tempfile
|
||||
import os
|
||||
import sys
|
||||
try:
|
||||
common = os.path.commonpath((tempfile.gettempdir(), data_filename))
|
||||
if not os.path.samefile(tempfile.gettempdir(), common):
|
||||
raise ValueError
|
||||
|
||||
except ValueError:
|
||||
# win32 built-in zip-folder handling, uses "temporary internet files" to store
|
||||
if (sys.platform == "win32" and
|
||||
"/AppData/Local/Microsoft/Windows/INetCache/IE/" in data_filename):
|
||||
logging.info("File inside temporary directory (likely a zip file, just load the zip directly). "
|
||||
"Exiting.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
logging.info("File inside temporary directory. Exiting.")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
ctx.load(data_filename, args.use_embedded_options)
|
||||
|
||||
@@ -5,17 +5,18 @@ import enum
|
||||
import warnings
|
||||
from json import JSONEncoder, JSONDecoder
|
||||
|
||||
import websockets
|
||||
if typing.TYPE_CHECKING:
|
||||
from websockets import WebSocketServerProtocol as ServerConnection
|
||||
|
||||
from Utils import ByValue, Version
|
||||
|
||||
|
||||
class HintStatus(ByValue, enum.IntEnum):
|
||||
HINT_FOUND = 0
|
||||
HINT_UNSPECIFIED = 1
|
||||
HINT_UNSPECIFIED = 0
|
||||
HINT_NO_PRIORITY = 10
|
||||
HINT_AVOID = 20
|
||||
HINT_PRIORITY = 30
|
||||
HINT_FOUND = 40
|
||||
|
||||
|
||||
class JSONMessagePart(typing.TypedDict, total=False):
|
||||
@@ -151,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
|
||||
|
||||
|
||||
class Endpoint:
|
||||
socket: websockets.WebSocketServerProtocol
|
||||
socket: "ServerConnection"
|
||||
|
||||
def __init__(self, socket):
|
||||
self.socket = socket
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import tkinter as tk
|
||||
import argparse
|
||||
import logging
|
||||
import random
|
||||
import os
|
||||
import zipfile
|
||||
from itertools import chain
|
||||
@@ -197,7 +196,6 @@ def set_icon(window):
|
||||
def adjust(args):
|
||||
# Create a fake multiworld and OOTWorld to use as a base
|
||||
multiworld = MultiWorld(1)
|
||||
multiworld.per_slot_randoms = {1: random}
|
||||
ootworld = OOTWorld(multiworld, 1)
|
||||
# Set options in the fake OOTWorld
|
||||
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
|
||||
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
|
||||
set it to True and use reStructuredText for their Option documentation.
|
||||
|
||||
@@ -689,9 +689,9 @@ class Range(NumericOption):
|
||||
@classmethod
|
||||
def weighted_range(cls, text) -> Range:
|
||||
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":
|
||||
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":
|
||||
return cls(cls.triangular(cls.range_start, cls.range_end))
|
||||
elif text.startswith("random-range-"):
|
||||
@@ -717,11 +717,11 @@ class Range(NumericOption):
|
||||
f"{random_range[0]}-{random_range[1]} is outside allowed range "
|
||||
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
|
||||
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"):
|
||||
return cls(cls.triangular(random_range[0], random_range[1]))
|
||||
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:
|
||||
return cls(random.randint(random_range[0], random_range[1]))
|
||||
|
||||
@@ -739,8 +739,16 @@ class Range(NumericOption):
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
|
||||
return int(round(random.triangular(lower, end, tri), 0))
|
||||
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
|
||||
"""
|
||||
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):
|
||||
@@ -1574,7 +1582,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
|
||||
}
|
||||
output.append(player_output)
|
||||
for option_key, option in world.options_dataclass.type_hints.items():
|
||||
if issubclass(Removed, option):
|
||||
if option.visibility == Visibility.none:
|
||||
continue
|
||||
display_name = getattr(option, "display_name", option_key)
|
||||
player_output[display_name] = getattr(world.options, option_key).current_option_name
|
||||
|
||||
@@ -80,6 +80,7 @@ Currently, the following games are supported:
|
||||
* Saving Princess
|
||||
* Castlevania: Circle of the Moon
|
||||
* Inscryption
|
||||
* Civilization VI
|
||||
|
||||
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
19
Utils.py
19
Utils.py
@@ -443,7 +443,8 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
else:
|
||||
mod = importlib.import_module(module)
|
||||
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
|
||||
# Forbid everything else.
|
||||
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:
|
||||
return self.condition(record)
|
||||
|
||||
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("NoStream", lambda record: not getattr(record, "NoFile", False)))
|
||||
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
|
||||
root_logger.addHandler(file_handler)
|
||||
if sys.stdout:
|
||||
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, *,
|
||||
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.
|
||||
|
||||
: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.
|
||||
: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 regions_to_highlight: Regions that will be highlighted in green if they are reachable.
|
||||
|
||||
Example usage in World code:
|
||||
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:
|
||||
from Utils import visualize_regions
|
||||
for player in multiworld.player_ids:
|
||||
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"
|
||||
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
|
||||
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)}")
|
||||
|
||||
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:
|
||||
visualize_locations(region)
|
||||
visualize_exits(region)
|
||||
|
||||
@@ -3,13 +3,13 @@ from typing import List, Tuple
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from ..models import Seed
|
||||
from ..models import Seed, Slot
|
||||
|
||||
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -30,4 +30,4 @@ def get_seeds():
|
||||
"creation_time": seed.creation_time,
|
||||
"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.item_name_groups = {"Archipelago": static_item_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", {})):
|
||||
game_data = multidata["datapackage"][game]
|
||||
@@ -132,11 +133,13 @@ class WebHostContext(Context):
|
||||
continue
|
||||
else:
|
||||
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.item_name_groups[game] = static_item_name_groups.get(game, {})
|
||||
self.location_name_groups[game] = static_location_name_groups.get(game, {})
|
||||
|
||||
if not game_data_packages:
|
||||
if not game_data_packages and not missing_checksum:
|
||||
# all static -> use the static dicts directly
|
||||
self.gamespackage = static_gamespackage
|
||||
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
|
||||
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).
|
||||
|
||||
## Can I generate a single-player game with Archipelago?
|
||||
|
||||
@@ -75,6 +75,27 @@
|
||||
#inventory-table img.acquired.green{ /*32CD32*/
|
||||
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{
|
||||
display: grid;
|
||||
|
||||
@@ -99,6 +99,52 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
<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",
|
||||
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.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 = {
|
||||
@@ -1118,6 +1123,9 @@ if "Timespinner" in network_data_package["games"]:
|
||||
timespinner_location_ids["Ancient Pyramid"] += [
|
||||
1337237, 1337238, 1337239,
|
||||
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
|
||||
if (slot_data["PyramidStart"]):
|
||||
timespinner_location_ids["Ancient Pyramid"] += [
|
||||
1337233, 1337234, 1337235]
|
||||
|
||||
display_data = {}
|
||||
|
||||
|
||||
@@ -121,6 +121,14 @@ 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`
|
||||
Checks a section of memory against `expected_data`. If the bytes starting
|
||||
at `address` do not match `expected_data`, the response will have `value`
|
||||
@@ -216,6 +224,12 @@ Response:
|
||||
Additional Fields:
|
||||
- `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`
|
||||
The result of an attempted `GUARD` request.
|
||||
|
||||
@@ -376,6 +390,15 @@ request_handlers = {
|
||||
return res
|
||||
end,
|
||||
|
||||
["MEMORY_SIZE"] = function (req)
|
||||
local res = {}
|
||||
|
||||
res["type"] = "MEMORY_SIZE_RESPONSE"
|
||||
res["value"] = memory.getmemorydomainsize(req["domain"])
|
||||
|
||||
return res
|
||||
end,
|
||||
|
||||
["GUARD"] = function (req)
|
||||
local res = {}
|
||||
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
|
||||
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
|
||||
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
|
||||
print("No ROM is loaded. Please load a ROM.")
|
||||
while emu.getsystemid() == "NULL" do
|
||||
|
||||
@@ -1816,7 +1816,7 @@ end
|
||||
|
||||
-- Main control handling: main loop and socket receive
|
||||
|
||||
function receive()
|
||||
function APreceive()
|
||||
l, e = ootSocket:receive()
|
||||
-- Handle incoming message
|
||||
if e == 'closed' then
|
||||
@@ -1874,7 +1874,7 @@ function main()
|
||||
end
|
||||
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
|
||||
if (frame % 30 == 0) then
|
||||
receive()
|
||||
APreceive()
|
||||
end
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
if (frame % 60 == 0) then
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
# ChecksFinder
|
||||
/worlds/checksfinder/ @SunCatMC
|
||||
|
||||
# Civilization VI
|
||||
/worlds/civ6/ @hesto2
|
||||
|
||||
# Clique
|
||||
/worlds/clique/ @ThePhar
|
||||
|
||||
@@ -99,6 +102,9 @@
|
||||
# Lingo
|
||||
/worlds/lingo/ @hatkirby
|
||||
|
||||
# Links Awakening DX
|
||||
/worlds/ladx/ @threeandthreee
|
||||
|
||||
# Lufia II Ancient Cave
|
||||
/worlds/lufia2ac/ @el-u
|
||||
/worlds/lufia2ac/docs/ @wordfcuk @el-u
|
||||
@@ -236,9 +242,6 @@
|
||||
# Final Fantasy (1)
|
||||
# /worlds/ff1/
|
||||
|
||||
# Links Awakening DX
|
||||
# /worlds/ladx/
|
||||
|
||||
# Ocarina of Time
|
||||
# /worlds/oot/
|
||||
|
||||
|
||||
@@ -370,19 +370,13 @@ target_group_lookup = bake_target_group_lookup(world, get_target_groups)
|
||||
|
||||
#### 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.
|
||||
This means 2 things about when you can call ER:
|
||||
1. You must supply your item pool before calling ER, or call ER before setting any rules which require items.
|
||||
2. If you have rules dependent on anything other than items (e.g. `Entrance`s or events), you must set your rules
|
||||
and create your events before you call ER if you want to guarantee a correct output.
|
||||
|
||||
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.
|
||||
Currently, you could theoretically do it as early as `World.create_regions` or as late as `pre_fill`.
|
||||
However, there are upcoming changes to Item Plando and Generic Entrance Randomizer to make the two features work better
|
||||
together.
|
||||
These changes necessitate that entrance randomization is done exactly in `World.connect_entrances`.
|
||||
It is fine for your Entrances to be connected differently or not at all before this step.
|
||||
|
||||
#### 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.
|
||||
|
||||
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
|
||||
working in the future.
|
||||
|
||||
Example:
|
||||
```javascript
|
||||
[{"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. |
|
||||
| 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. |
|
||||
| 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.
|
||||
|
||||
@@ -359,11 +363,11 @@ An enumeration containing the possible hint states.
|
||||
```python
|
||||
import enum
|
||||
class HintStatus(enum.IntEnum):
|
||||
HINT_FOUND = 0 # The location has been collected. Status cannot be changed once found.
|
||||
HINT_UNSPECIFIED = 1 # The receiving player has not specified any status
|
||||
HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
|
||||
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_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 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` 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
|
||||
|
||||
@@ -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.² |
|
||||
| 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.² |
|
||||
| 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".\
|
||||
²: 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
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
[WorldTestBase definition](/test/bases.py#L104).
|
||||
[WorldTestBase definition](/test/bases.py#L106).
|
||||
|
||||
#### 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
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
Locations and items can share IDs, so typically a game's locations and items start at the same ID.
|
||||
letter or symbol). The ID needs to be unique across all locations within the game.
|
||||
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.
|
||||
|
||||
@@ -243,7 +243,9 @@ progression. Progression items will be assigned to locations with higher priorit
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -289,7 +291,7 @@ like entrance randomization in logic.
|
||||
|
||||
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").
|
||||
|
||||
### 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 **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.
|
||||
|
||||
### 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.
|
||||
* `set_rules(self)`
|
||||
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)`
|
||||
player-specific randomization that does not affect logic can be done here.
|
||||
* `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:
|
||||
# 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
|
||||
ItemClassification.filler
|
||||
|
||||
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item],
|
||||
self.player)
|
||||
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
|
||||
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
|
||||
|
||||
|
||||
def create_event(self, event: str) -> MyGameItem:
|
||||
# while we are at it, we can also add a helper to create events
|
||||
return MyGameItem(event, True, None, self.player)
|
||||
return MyGameItem(event, ItemClassification.progression, None, self.player)
|
||||
```
|
||||
|
||||
#### create_items
|
||||
@@ -835,14 +836,16 @@ def generate_output(self, output_directory: str) -> None:
|
||||
|
||||
### Slot Data
|
||||
|
||||
If the game client 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
|
||||
a `dict` with `str` keys that can be serialized with json.
|
||||
But, to not waste resources, it should be limited to data that is absolutely necessary. Slot data is sent to your client
|
||||
once it has successfully [connected](network%20protocol.md#connected).
|
||||
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 a `dict` with
|
||||
`str` keys that can be serialized with json. However, to not waste resources, it should be limited to data that is
|
||||
absolutely necessary. Slot data is sent to your client once it has successfully
|
||||
[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
|
||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most
|
||||
common usage of slot data is sending option results that the client needs to be aware of.
|
||||
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. Adding
|
||||
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
|
||||
def fill_slot_data(self) -> Dict[str, Any]:
|
||||
|
||||
@@ -157,17 +157,16 @@ class ERPlacementState:
|
||||
def placed_regions(self) -> set[Region]:
|
||||
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:
|
||||
blocked_connections = self.collection_state.blocked_connections[self.world.player]
|
||||
blocked_connections = sorted(blocked_connections, key=lambda x: x.name)
|
||||
placeable_randomized_exits = [connection for connection in blocked_connections
|
||||
if not connection.connected_region
|
||||
and connection.is_valid_source_transition(self)]
|
||||
placeable_randomized_exits = [ex for ex in usable_exits
|
||||
if not ex.connected_region
|
||||
and ex in blocked_connections
|
||||
and ex.is_valid_source_transition(self)]
|
||||
else:
|
||||
# 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)
|
||||
for ex in region.exits if not ex.connected_region]
|
||||
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
|
||||
self.world.random.shuffle(placeable_randomized_exits)
|
||||
return placeable_randomized_exits
|
||||
|
||||
@@ -181,7 +180,8 @@ class ERPlacementState:
|
||||
self.placements.append(source_exit)
|
||||
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()
|
||||
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
|
||||
# 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
|
||||
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
|
||||
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
|
||||
# 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.
|
||||
@@ -326,6 +329,24 @@ def randomize_entrances(
|
||||
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
|
||||
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:
|
||||
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
|
||||
# remove the placed targets from consideration
|
||||
@@ -339,7 +360,7 @@ def randomize_entrances(
|
||||
|
||||
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
|
||||
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:
|
||||
target_groups = target_group_lookup[source_exit.randomization_group]
|
||||
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)
|
||||
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
|
||||
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
|
||||
do_placement(source_exit, target_entrance)
|
||||
return True
|
||||
@@ -378,13 +399,14 @@ def randomize_entrances(
|
||||
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
|
||||
# ensure that we have enough locations to place our progression
|
||||
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
|
||||
if prog_item_count == 0:
|
||||
return True
|
||||
for region in er_state.placed_regions:
|
||||
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
|
||||
if accessible_location_count >= prog_item_count:
|
||||
perform_validity_check = False
|
||||
@@ -406,21 +428,6 @@ def randomize_entrances(
|
||||
f"All unplaced entrances: {unplaced_entrances}\n"
|
||||
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
|
||||
while entrance_lookup.others:
|
||||
if not find_pairing(dead_end=False, require_new_exits=True):
|
||||
|
||||
@@ -221,6 +221,11 @@ Root: HKCR; Subkey: "{#MyAppName}ygo06patch"; ValueData: "Ar
|
||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".apcivvi"; ValueData: "{#MyAppName}apcivvipatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "Archipelago Civilization 6 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
|
||||
|
||||
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
|
||||
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
|
||||
|
||||
19
kvui.py
19
kvui.py
@@ -26,6 +26,10 @@ import Utils
|
||||
if Utils.is_frozen():
|
||||
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
|
||||
|
||||
Config.set("input", "mouse", "mouse,disable_multitouch")
|
||||
@@ -440,8 +444,11 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
|
||||
if child.collide_point(*touch.pos):
|
||||
key = child.sort_key
|
||||
if key == "status":
|
||||
parent.hint_sorter = lambda element: element["status"]["hint"]["status"]
|
||||
else: parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
|
||||
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()
|
||||
)
|
||||
if key == parent.sort_key:
|
||||
# second click reverses order
|
||||
parent.reversed = not parent.reversed
|
||||
@@ -825,7 +832,13 @@ status_colors: typing.Dict[HintStatus, str] = {
|
||||
HintStatus.HINT_AVOID: "salmon",
|
||||
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):
|
||||
|
||||
@@ -2,3 +2,6 @@
|
||||
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
|
||||
python_classes = 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]:
|
||||
"""Returns resolved type hints for the class"""
|
||||
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
|
||||
cls._type_cache = cls.__annotations__
|
||||
else:
|
||||
@@ -270,15 +270,20 @@ class Group:
|
||||
# fetch class to avoid going through getattr
|
||||
cls = self.__class__
|
||||
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
|
||||
for name in cls.__annotations__.keys():
|
||||
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
|
||||
# dump ordered members
|
||||
for name in self:
|
||||
for name in entries:
|
||||
attr = cast(object, getattr(self, name))
|
||||
attr_cls = type_hints[name] if name in type_hints else attr.__class__
|
||||
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_origin = typing.get_origin(attr_cls)
|
||||
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
|
||||
@@ -678,6 +683,8 @@ class GeneratorOptions(Group):
|
||||
race: Race = Race(0)
|
||||
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
|
||||
panic_method: PanicMethod = PanicMethod("swap")
|
||||
loglevel: str = "info"
|
||||
logtime: bool = False
|
||||
|
||||
|
||||
class SNIOptions(Group):
|
||||
@@ -785,7 +792,17 @@ class Settings(Group):
|
||||
if location:
|
||||
from Utils import parse_yaml
|
||||
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: once we have a cache for _world_settings_name_cache, detect if any game section is missing
|
||||
self.update(options or {})
|
||||
|
||||
@@ -18,7 +18,15 @@ def run_locations_benchmark():
|
||||
|
||||
class BenchmarkRunner:
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
enable_testing()
|
||||
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
|
||||
|
||||
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
|
||||
add_definitions("/source-charset:utf-8")
|
||||
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
|
||||
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
|
||||
# set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
|
||||
# set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
|
||||
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
# enable static analysis for gcc
|
||||
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.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(
|
||||
|
||||
@@ -311,6 +311,37 @@ class TestRandomizeEntrances(unittest.TestCase):
|
||||
self.assertEqual([], [exit_ for region in multiworld.get_regions()
|
||||
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):
|
||||
multiworld = generate_test_multiworld()
|
||||
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
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from BaseClasses import CollectionState, MultiWorld, Region
|
||||
|
||||
|
||||
@@ -8,6 +10,7 @@ class TestHelpers(unittest.TestCase):
|
||||
multiworld: MultiWorld
|
||||
player: int = 1
|
||||
|
||||
@override
|
||||
def setUp(self) -> None:
|
||||
self.multiworld = MultiWorld(self.player)
|
||||
self.multiworld.game[self.player] = "helper_test_game"
|
||||
@@ -38,15 +41,15 @@ class TestHelpers(unittest.TestCase):
|
||||
"TestRegion1": {"TestRegion2": "connection"},
|
||||
"TestRegion2": {"TestRegion1": None},
|
||||
}
|
||||
|
||||
|
||||
reg_exit_set: Dict[str, set[str]] = {
|
||||
"TestRegion1": {"TestRegion3"}
|
||||
}
|
||||
|
||||
|
||||
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
|
||||
"TestRegion1": lambda state: state.has("test_item", self.player)
|
||||
}
|
||||
|
||||
|
||||
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
|
||||
|
||||
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}"
|
||||
self.assertEqual(exit_rules[exit_reg],
|
||||
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
|
||||
|
||||
|
||||
for region in reg_exit_set:
|
||||
current_region = self.multiworld.get_region(region, self.player)
|
||||
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."""
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
# 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
|
||||
multiworld = setup_solo_multiworld(world_type)
|
||||
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"\n{reachable_only_with_explicit}")
|
||||
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
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from worlds.AutoWorld import AutoWorldRegister, call_all
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
@@ -8,12 +9,31 @@ class TestBase(unittest.TestCase):
|
||||
def test_create_item(self):
|
||||
"""Test that a world can successfully create all items in its datapackage"""
|
||||
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:
|
||||
test_state = CollectionState(multiworld)
|
||||
with self.subTest("Create Item", item_name=item_name, game_name=game_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)
|
||||
|
||||
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):
|
||||
"""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.
|
||||
@@ -67,7 +87,7 @@ class TestBase(unittest.TestCase):
|
||||
def test_itempool_not_modified(self):
|
||||
"""Test that worlds don't modify the itempool after `create_items`"""
|
||||
gen_steps = ("generate_early", "create_regions", "create_items")
|
||||
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")
|
||||
worlds_to_test = {game: world
|
||||
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):
|
||||
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
|
||||
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()}
|
||||
for game_name, world_type in worlds_to_test.items():
|
||||
with self.subTest("Game", game=game_name):
|
||||
|
||||
@@ -45,6 +45,12 @@ class TestBase(unittest.TestCase):
|
||||
self.assertEqual(location_count, len(multiworld.get_locations()),
|
||||
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")
|
||||
self.assertEqual(region_count, len(multiworld.get_regions()),
|
||||
f"{game_name} modified region count during generate_basic")
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import unittest
|
||||
|
||||
from BaseClasses import MultiWorld
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_solo_multiworld
|
||||
|
||||
|
||||
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."""
|
||||
import gc
|
||||
import weakref
|
||||
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
|
||||
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))
|
||||
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")
|
||||
|
||||
@@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
|
||||
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"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
@@ -11,7 +11,7 @@ class TestNames(unittest.TestCase):
|
||||
self.assertFalse(item_name.isnumeric(),
|
||||
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"""
|
||||
for gamename, world_type in AutoWorldRegister.world_types.items():
|
||||
with self.subTest(game=gamename):
|
||||
|
||||
@@ -2,11 +2,11 @@ import unittest
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from . import setup_solo_multiworld
|
||||
from . import setup_solo_multiworld, gen_steps
|
||||
|
||||
|
||||
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 = {
|
||||
"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."""
|
||||
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:
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
import multiprocessing
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
@@ -111,7 +119,7 @@ class SuffixIdentifier:
|
||||
|
||||
def launch_textclient(*args):
|
||||
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]]:
|
||||
|
||||
@@ -55,6 +55,7 @@ async def lock(ctx) -> None
|
||||
async def unlock(ctx) -> None
|
||||
|
||||
async def get_hash(ctx) -> str
|
||||
async def get_memory_size(ctx, domain: str) -> int
|
||||
async def get_system(ctx) -> str
|
||||
async def get_cores(ctx) -> dict[str, str]
|
||||
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.
|
||||
|
||||
`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
|
||||
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
|
||||
ROM as yours, this is where you should do setup for things like `items_handling`.
|
||||
running on a system you specified in your `system` class variable. Take extra care here, because your code will run
|
||||
against ROMs that you have no control over. If you're reading an address deep in ROM, you might want to check the size
|
||||
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.
|
||||
`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
|
||||
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.
|
||||
- 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
|
||||
subclass of `CommonContext` and its API.
|
||||
- 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 json
|
||||
import sys
|
||||
import typing
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
|
||||
@@ -44,10 +44,10 @@ class SyncError(Exception):
|
||||
|
||||
|
||||
class BizHawkContext:
|
||||
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
|
||||
streams: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None
|
||||
connection_status: ConnectionStatus
|
||||
_lock: asyncio.Lock
|
||||
_port: typing.Optional[int]
|
||||
_port: int | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.streams = None
|
||||
@@ -122,12 +122,12 @@ async def get_script_version(ctx: BizHawkContext) -> int:
|
||||
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.
|
||||
|
||||
It's likely you want to use the wrapper functions instead of this."""
|
||||
responses = json.loads(await ctx._send_message(json.dumps(req_list)))
|
||||
errors: typing.List[ConnectorError] = []
|
||||
errors: list[ConnectorError] = []
|
||||
|
||||
for response in responses:
|
||||
if response["type"] == "ERROR":
|
||||
@@ -151,7 +151,7 @@ async def ping(ctx: BizHawkContext) -> None:
|
||||
|
||||
|
||||
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]
|
||||
|
||||
if res["type"] != "HASH_RESPONSE":
|
||||
@@ -160,6 +160,16 @@ async def get_hash(ctx: BizHawkContext) -> str:
|
||||
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:
|
||||
"""Gets the system name for the currently loaded ROM"""
|
||||
res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
|
||||
@@ -170,7 +180,7 @@ async def get_system(ctx: BizHawkContext) -> str:
|
||||
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
|
||||
entries."""
|
||||
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']}")
|
||||
|
||||
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]:
|
||||
async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]],
|
||||
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
|
||||
value.
|
||||
|
||||
@@ -252,7 +262,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
|
||||
"domain": domain
|
||||
} for address, size, domain in read_list])
|
||||
|
||||
ret: typing.List[bytes] = []
|
||||
ret: list[bytes] = []
|
||||
for item in res:
|
||||
if item["type"] == "GUARD_RESPONSE":
|
||||
if not item["value"]:
|
||||
@@ -266,7 +276,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
|
||||
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.
|
||||
|
||||
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, [])
|
||||
|
||||
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]],
|
||||
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool:
|
||||
async def guarded_write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]],
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
from .context import BizHawkClientContext
|
||||
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
|
||||
def launch_client(*args) -> None:
|
||||
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,
|
||||
@@ -24,9 +24,9 @@ components.append(component)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Register handler
|
||||
@@ -54,7 +54,7 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
|
||||
return new_class
|
||||
|
||||
@staticmethod
|
||||
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
|
||||
async def get_handler(ctx: "BizHawkClientContext", system: str) -> BizHawkClient | None:
|
||||
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
|
||||
if system in systems:
|
||||
for handler in handlers.values():
|
||||
@@ -65,13 +65,13 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
game: ClassVar[str]
|
||||
"""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")"""
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -6,7 +6,7 @@ checking or launching the client, otherwise it will probably cause circular impo
|
||||
import asyncio
|
||||
import enum
|
||||
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
|
||||
import Patch
|
||||
@@ -41,17 +41,18 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
|
||||
|
||||
class BizHawkClientContext(CommonContext):
|
||||
command_processor = BizHawkClientCommandProcessor
|
||||
server_seed_name: str | None = None
|
||||
auth_status: AuthStatus
|
||||
password_requested: bool
|
||||
client_handler: Optional[BizHawkClient]
|
||||
slot_data: Optional[Dict[str, Any]] = None
|
||||
rom_hash: Optional[str] = None
|
||||
client_handler: BizHawkClient | None
|
||||
slot_data: dict[str, Any] | None = None
|
||||
rom_hash: str | None = None
|
||||
bizhawk_ctx: BizHawkContext
|
||||
|
||||
watcher_timeout: float
|
||||
"""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)
|
||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||
self.password_requested = False
|
||||
@@ -68,6 +69,8 @@ class BizHawkClientContext(CommonContext):
|
||||
if cmd == "Connected":
|
||||
self.slot_data = args.get("slot_data", None)
|
||||
self.auth_status = AuthStatus.AUTHENTICATED
|
||||
elif cmd == "RoomInfo":
|
||||
self.server_seed_name = args.get("seed_name", None)
|
||||
|
||||
if self.client_handler is not None:
|
||||
self.client_handler.on_package(self, cmd, args)
|
||||
@@ -100,6 +103,7 @@ class BizHawkClientContext(CommonContext):
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool=False):
|
||||
self.auth_status = AuthStatus.NOT_AUTHENTICATED
|
||||
self.server_seed_name = None
|
||||
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:
|
||||
metadata, output_file = Patch.create_rom_file(patch_file)
|
||||
Utils.async_start(_run_game(output_file))
|
||||
return metadata
|
||||
except Exception as 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():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
|
||||
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.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
@@ -252,9 +264,6 @@ def launch(*launch_args) -> None:
|
||||
ctx.run_gui()
|
||||
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")
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
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):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
||||
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,
|
||||
|
||||
@@ -2,15 +2,15 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from typing import Optional, Any
|
||||
|
||||
import Utils
|
||||
from .Locations import AdventureLocation, LocationData
|
||||
from settings import get_settings
|
||||
from worlds.Files import APPatch, AutoPatchRegister
|
||||
from typing import Any
|
||||
|
||||
import bsdiff4
|
||||
|
||||
import Utils
|
||||
from settings import get_settings
|
||||
from worlds.Files import APPatch, AutoPatchRegister
|
||||
from .Locations import LocationData
|
||||
|
||||
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +1,24 @@
|
||||
import base64
|
||||
import copy
|
||||
import itertools
|
||||
import math
|
||||
import os
|
||||
import settings
|
||||
import typing
|
||||
from enum import IntFlag
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
|
||||
from typing import ClassVar, Dict, Optional, Tuple
|
||||
|
||||
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
|
||||
LocationProgressType
|
||||
import settings
|
||||
from BaseClasses import Item, ItemClassification, MultiWorld, Tutorial, LocationProgressType
|
||||
from Utils import __version__
|
||||
from Options import AssembleOptions
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from Fill import fill_restrictive
|
||||
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 worlds.LauncherComponents import Component, components, SuffixIdentifier
|
||||
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 .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, \
|
||||
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 .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, AdventureAutoCollectLocation
|
||||
from .Rules import set_rules
|
||||
|
||||
|
||||
from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
||||
|
||||
# Adventure
|
||||
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_rule(main_stamp, main_objective.access_rule)
|
||||
add_rule(all_clear, main_objective.access_rule)
|
||||
# Only set bonus stamp rules if we don't auto complete bonuses
|
||||
# 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):
|
||||
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:
|
||||
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"):
|
||||
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():
|
||||
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 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":
|
||||
if area == "Dead Bird Studio Basement":
|
||||
add_rule(event, lambda state: can_use_hookshot(state, world))
|
||||
@@ -430,7 +442,7 @@ hit_list = {
|
||||
# Bosses
|
||||
"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"],
|
||||
|
||||
"Snatcher": ["Your Contract has Expired", "Breaching the Contract", "Boss Rush",
|
||||
@@ -454,7 +466,7 @@ triple_enemy_locations = [
|
||||
|
||||
bosses = [
|
||||
"Mafia Boss",
|
||||
"Conductor",
|
||||
"Director",
|
||||
"Toilet",
|
||||
"Snatcher",
|
||||
"Toxic Flower",
|
||||
|
||||
@@ -206,7 +206,7 @@ ahit_locations = {
|
||||
"Subcon Village - Graveyard Ice Cube": LocData(2000325077, "Subcon Forest Area"),
|
||||
"Subcon Village - House Top": LocData(2000325471, "Subcon Forest Area"),
|
||||
"Subcon Village - Ice Cube House": LocData(2000325469, "Subcon Forest Area"),
|
||||
"Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Area", paintings=1),
|
||||
"Subcon Village - Snatcher Statue Chest": LocData(2000323730, "Subcon Forest Behind Boss Firewall"),
|
||||
"Subcon Village - Stump Platform Chest": LocData(2000323729, "Subcon Forest Area"),
|
||||
"Subcon Forest - Giant Tree Climb": LocData(2000325470, "Subcon Forest Area"),
|
||||
|
||||
@@ -233,7 +233,7 @@ ahit_locations = {
|
||||
"Subcon Forest - Long Tree Climb Chest": LocData(2000323734, "Subcon Forest Area",
|
||||
required_hats=[HatType.DWELLER], paintings=2),
|
||||
|
||||
"Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Area"),
|
||||
"Subcon Forest - Boss Arena Chest": LocData(2000323735, "Subcon Forest Boss Arena"),
|
||||
|
||||
"Subcon Forest - Manor Rooftop": LocData(2000325466, "Subcon Forest Area",
|
||||
hit_type=HitType.dweller_bell, paintings=1),
|
||||
@@ -264,7 +264,6 @@ ahit_locations = {
|
||||
required_hats=[HatType.DWELLER], paintings=3),
|
||||
|
||||
"Subcon Forest - Tall Tree Hookshot Swing": LocData(2000324766, "Subcon Forest Area",
|
||||
required_hats=[HatType.DWELLER],
|
||||
hookshot=True,
|
||||
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 Bell: Wide Purple Platform": LocData(2000336478, "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 - The Windmill: Inside Pon Cluster": LocData(2000336395, "The Windmill"),
|
||||
"Alpine Skyline - The Windmill: Entrance": LocData(2000335783, "The Windmill"),
|
||||
@@ -407,12 +406,12 @@ act_completions = {
|
||||
hit_type=HitType.umbrella_or_brewing, hookshot=True, paintings=1),
|
||||
|
||||
"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",
|
||||
required_hats=[HatType.SPRINT]),
|
||||
|
||||
"Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired",
|
||||
"Act Completion (Your Contract has Expired)": LocData(2000311390, "Your Contract has Expired - Post Fight",
|
||||
hit_type=HitType.umbrella),
|
||||
|
||||
"Act Completion (Time Rift - Pipe)": LocData(2000313069, "Time Rift - Pipe", hookshot=True),
|
||||
@@ -878,7 +877,7 @@ snatcher_coins = {
|
||||
dlc_flags=HatDLC.death_wish),
|
||||
|
||||
"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",
|
||||
dlc_flags=HatDLC.death_wish),
|
||||
@@ -977,7 +976,6 @@ event_locs = {
|
||||
**snatcher_coins,
|
||||
"HUMT Access": LocData(0, "Heating Up Mafia Town"),
|
||||
"TOD Access": LocData(0, "Toilet of Doom"),
|
||||
"YCHE Access": LocData(0, "Your Contract has Expired"),
|
||||
"AFR Access": LocData(0, "Alpine Free Roam"),
|
||||
"TIHS Access": LocData(0, "The Illness has Spread"),
|
||||
|
||||
|
||||
@@ -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.
|
||||
For example, if this option's value is 10, and the total yarn needed to craft all hats is 40,
|
||||
there must be at least 50 yarn in the pool."""
|
||||
display_name = "Max Extra Yarn"
|
||||
display_name = "Min Extra Yarn"
|
||||
range_start = 5
|
||||
range_end = 15
|
||||
default = 10
|
||||
|
||||
@@ -347,7 +347,7 @@ def create_regions(world: "HatInTimeWorld"):
|
||||
sf_act3 = create_region_and_connect(world, "Toilet of Doom", "Subcon Forest - Act 3", subcon_forest)
|
||||
sf_act4 = create_region_and_connect(world, "Queen Vanessa's Manor", "Subcon Forest - Act 4", subcon_forest)
|
||||
sf_act5 = create_region_and_connect(world, "Mail Delivery Service", "Subcon Forest - Act 5", subcon_forest)
|
||||
create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest)
|
||||
sf_finale = create_region_and_connect(world, "Your Contract has Expired", "Subcon Forest - Finale", subcon_forest)
|
||||
|
||||
# ------------------------------------------- ALPINE SKYLINE ------------------------------------------ #
|
||||
alpine_skyline = create_region_and_connect(world, "Alpine Skyline", "Telescope -> Alpine Skyline", spaceship)
|
||||
@@ -386,11 +386,24 @@ def create_regions(world: "HatInTimeWorld"):
|
||||
create_rift_connections(world, create_region(world, "Time Rift - Bazaar"))
|
||||
|
||||
sf_area: Region = create_region(world, "Subcon Forest Area")
|
||||
sf_behind_boss_firewall: Region = create_region(world, "Subcon Forest Behind Boss Firewall")
|
||||
sf_boss_arena: Region = create_region(world, "Subcon Forest Boss Arena")
|
||||
sf_area.connect(sf_behind_boss_firewall, "SF Area -> SF Behind Boss Firewall")
|
||||
sf_behind_boss_firewall.connect(sf_boss_arena, "SF Behind Boss Firewall -> SF Boss Arena")
|
||||
sf_act1.connect(sf_area, "Subcon Forest Entrance CO")
|
||||
sf_act2.connect(sf_area, "Subcon Forest Entrance SW")
|
||||
sf_act3.connect(sf_area, "Subcon Forest Entrance TOD")
|
||||
sf_act4.connect(sf_area, "Subcon Forest Entrance QVM")
|
||||
sf_act5.connect(sf_area, "Subcon Forest Entrance MDS")
|
||||
# YCHE puts the player directly in the boss arena, with no access to the rest of Subcon Forest by default.
|
||||
sf_finale.connect(sf_boss_arena, "Subcon Forest Entrance YCHE")
|
||||
# To support the Snatcher Hover expert logic for Act Completion (Your Contract has Expired), the act completion has
|
||||
# to go in a separate region because the Snatcher Hover gives direct access to the Act Completion, but does not
|
||||
# give access to the act itself.
|
||||
sf_finale_post_fight: Region = create_region(world, "Your Contract has Expired - Post Fight")
|
||||
# This connection must never have any rules placed on it because they will not be inherited when setting up act
|
||||
# connections, only the rules for the entrances to the act and the rules for the Act Completion are inherited.
|
||||
sf_finale.connect(sf_finale_post_fight, "YCHE -> YCHE - Post Fight")
|
||||
|
||||
create_rift_connections(world, create_region(world, "Time Rift - Sleepy Subcon"))
|
||||
create_rift_connections(world, create_region(world, "Time Rift - Pipe"))
|
||||
@@ -947,6 +960,16 @@ def get_shuffled_region(world: "HatInTimeWorld", region: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def get_region_shuffled_to(world: "HatInTimeWorld", region: str) -> str:
|
||||
if world.options.ActRandomizer:
|
||||
original_ci: str = chapter_act_info[region]
|
||||
shuffled_ci = world.act_connections[original_ci]
|
||||
return next(act_name for act_name, ci in chapter_act_info.items()
|
||||
if ci == shuffled_ci)
|
||||
else:
|
||||
return region
|
||||
|
||||
|
||||
def get_region_location_count(world: "HatInTimeWorld", region_name: str, included_only: bool = True) -> int:
|
||||
count = 0
|
||||
region = world.multiworld.get_region(region_name, world.player)
|
||||
|
||||
@@ -414,7 +414,7 @@ def set_moderate_rules(world: "HatInTimeWorld"):
|
||||
|
||||
# Moderate: Mystifying Time Mesa time trial without hats
|
||||
set_rule(world.multiworld.get_location("Alpine Skyline - Mystifying Time Mesa: Zipline", world.player),
|
||||
lambda state: can_use_hookshot(state, world))
|
||||
lambda state: True)
|
||||
|
||||
# Moderate: Goat Refinery from TIHS with Sprint only
|
||||
add_rule(world.multiworld.get_location("Alpine Skyline - Goat Refinery", world.player),
|
||||
@@ -481,9 +481,8 @@ def set_hard_rules(world: "HatInTimeWorld"):
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player),
|
||||
lambda state: has_paintings(state, world, 3))
|
||||
|
||||
# Cherry bridge over boss arena gap (painting still expected)
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
||||
lambda state: has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
|
||||
# Cherry bridge over boss arena gap
|
||||
set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"), lambda state: True)
|
||||
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player),
|
||||
lambda state: has_paintings(state, world, 2, True))
|
||||
@@ -493,9 +492,6 @@ def set_hard_rules(world: "HatInTimeWorld"):
|
||||
lambda state: has_paintings(state, world, 3, True))
|
||||
|
||||
# SDJ
|
||||
add_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT) and has_paintings(state, world, 2), "or")
|
||||
|
||||
add_rule(world.multiworld.get_location("Act Completion (Time Rift - Curly Tail Trail)", world.player),
|
||||
lambda state: can_use_hat(state, world, HatType.SPRINT), "or")
|
||||
|
||||
@@ -533,7 +529,10 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
||||
# Expert: Mafia Town - Above Boats, Top of Lighthouse, and Hot Air Balloon with nothing
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Above Boats", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Top of Lighthouse", world.player), lambda state: True)
|
||||
set_rule(world.multiworld.get_location("Mafia Town - Hot Air Balloon", world.player), lambda state: True)
|
||||
# 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
|
||||
for loc in world.multiworld.get_region("Dead Bird Studio - Post Elevator Area", world.player).locations:
|
||||
@@ -566,31 +565,65 @@ def set_expert_rules(world: "HatInTimeWorld"):
|
||||
lambda state: True)
|
||||
|
||||
# Expert: Cherry Hovering
|
||||
subcon_area = world.multiworld.get_region("Subcon Forest Area", world.player)
|
||||
yche = world.multiworld.get_region("Your Contract has Expired", world.player)
|
||||
entrance = yche.connect(subcon_area, "Subcon Forest Entrance YCHE")
|
||||
# Skipping the boss firewall is possible with a Cherry Hover.
|
||||
set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"),
|
||||
lambda state: has_paintings(state, world, 1, True))
|
||||
# The boss arena gap can be crossed in reverse with a Cherry Hover.
|
||||
subcon_boss_arena = world.get_region("Subcon Forest Boss Arena")
|
||||
subcon_behind_boss_firewall = world.get_region("Subcon Forest Behind Boss Firewall")
|
||||
subcon_boss_arena.connect(subcon_behind_boss_firewall, "SF Boss Arena -> SF Behind Boss Firewall")
|
||||
|
||||
if world.options.NoPaintingSkips:
|
||||
add_rule(entrance, lambda state: has_paintings(state, world, 1))
|
||||
subcon_area = world.get_region("Subcon Forest Area")
|
||||
|
||||
# The boss firewall can be skipped in reverse with a Cherry Hover, but it is not possible to remove the boss
|
||||
# firewall from reverse because the paintings to burn to remove the firewall are on the other side of the firewall.
|
||||
# Therefore, a painting skip is required. The paintings could be burned by already having access to
|
||||
# "Subcon Forest Area" through another entrance, but making a new connection to "Subcon Forest Area" in that case
|
||||
# would be pointless.
|
||||
if not world.options.NoPaintingSkips:
|
||||
# The import cannot be done at the module-level because it would cause a circular import.
|
||||
from .Regions import get_region_shuffled_to
|
||||
|
||||
subcon_behind_boss_firewall.connect(subcon_area, "SF Behind Boss Firewall -> SF Area")
|
||||
|
||||
# Because the Your Contract has Expired entrance can now reach "Subcon Forest Area", it needs to be connected to
|
||||
# each of the Subcon Forest Time Rift entrances, like the other Subcon Forest Acts.
|
||||
yche = world.get_region("Your Contract has Expired")
|
||||
|
||||
def connect_to_shuffled_act_at(original_act_name):
|
||||
region_name = get_region_shuffled_to(world, original_act_name)
|
||||
return yche.connect(world.get_region(region_name), f"{original_act_name} Portal - Entrance YCHE")
|
||||
|
||||
# Rules copied from `Rules.set_rift_rules()` with painting logic removed because painting skips must be
|
||||
# available.
|
||||
entrance = connect_to_shuffled_act_at("Time Rift - Pipe")
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 2"))
|
||||
reg_act_connection(world, world.get_entrance("Subcon Forest - Act 2").connected_region, entrance)
|
||||
|
||||
entrance = connect_to_shuffled_act_at("Time Rift - Village")
|
||||
add_rule(entrance, lambda state: can_clear_required_act(state, world, "Subcon Forest - Act 4"))
|
||||
reg_act_connection(world, world.get_entrance("Subcon Forest - Act 4").connected_region, entrance)
|
||||
|
||||
entrance = connect_to_shuffled_act_at("Time Rift - Sleepy Subcon")
|
||||
add_rule(entrance, lambda state: has_relic_combo(state, world, "UFO"))
|
||||
|
||||
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
||||
and has_paintings(state, world, 1, True))
|
||||
|
||||
# Set painting rules only. Skipping paintings is determined in has_paintings
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
||||
lambda state: has_paintings(state, world, 1, True))
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Magnet Badge Bush", world.player),
|
||||
lambda state: has_paintings(state, world, 3, True))
|
||||
|
||||
# You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him
|
||||
subcon_area.connect(yche, "Snatcher Hover")
|
||||
set_rule(world.multiworld.get_location("Act Completion (Your Contract has Expired)", world.player),
|
||||
lambda state: True)
|
||||
yche_post_fight = world.get_region("Your Contract has Expired - Post Fight")
|
||||
subcon_area.connect(yche_post_fight, "Snatcher Hover")
|
||||
# Cherry Hover from YCHE also works, so there are no requirements for the Act Completion.
|
||||
set_rule(world.get_location("Act Completion (Your Contract has Expired)"), lambda state: True)
|
||||
|
||||
if world.is_dlc2():
|
||||
# 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)
|
||||
else:
|
||||
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
|
||||
@@ -681,12 +714,18 @@ def set_subcon_rules(world: "HatInTimeWorld"):
|
||||
lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player)
|
||||
or can_use_hat(state, world, HatType.DWELLER))
|
||||
|
||||
# You can't skip over the boss arena wall without cherry hover, so these two need to be set this way
|
||||
set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player),
|
||||
lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world)
|
||||
and has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player))
|
||||
# You can't skip over the boss arena wall without cherry hover.
|
||||
set_rule(world.get_entrance("SF Area -> SF Behind Boss Firewall"),
|
||||
lambda state: has_paintings(state, world, 1, False))
|
||||
|
||||
# The painting wall can't be skipped without cherry hover, which is Expert
|
||||
# The hookpoints to cross the boss arena gap are only present in Toilet of Doom.
|
||||
set_rule(world.get_entrance("SF Behind Boss Firewall -> SF Boss Arena"),
|
||||
lambda state: state.has("TOD Access", world.player)
|
||||
and can_use_hookshot(state, world))
|
||||
|
||||
# The Act Completion is in the Toilet of Doom region, so the same rules as passing the boss firewall and crossing
|
||||
# the boss arena gap are required. "TOD Access" is implied from the region so does not need to be included in the
|
||||
# rule.
|
||||
set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player),
|
||||
lambda state: can_use_hookshot(state, world) and can_hit(state, world)
|
||||
and has_paintings(state, world, 1, False))
|
||||
@@ -739,7 +778,7 @@ def set_dlc1_rules(world: "HatInTimeWorld"):
|
||||
|
||||
# This particular item isn't present in Act 3 for some reason, yes in vanilla too
|
||||
add_rule(world.multiworld.get_location("The Arctic Cruise - Toilet", world.player),
|
||||
lambda state: state.can_reach("Bon Voyage!", "Region", world.player)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -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.generic.Rules import add_rule
|
||||
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
|
||||
|
||||
|
||||
def launch_client():
|
||||
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,
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
|
||||
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.
|
||||
@@ -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!
|
||||
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:
|
||||
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:
|
||||
|
||||
@@ -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.")
|
||||
return False
|
||||
else:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
await ctx.check_locations(new_locations)
|
||||
await snes_flush_writes(ctx)
|
||||
return True
|
||||
|
||||
|
||||
@@ -484,8 +484,7 @@ def generate_itempool(world):
|
||||
if multiworld.randomize_cost_types[player]:
|
||||
# Heart and Arrow costs require all Heart Container/Pieces and Arrow Upgrades to be advancement items for logic
|
||||
for item in items:
|
||||
if (item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart")
|
||||
or "Arrow Upgrade" in item.name):
|
||||
if item.name in ("Boss Heart Container", "Sanctuary Heart Container", "Piece of Heart"):
|
||||
item.classification = ItemClassification.progression
|
||||
else:
|
||||
# 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)")
|
||||
|
||||
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]
|
||||
if world.small_key_shuffle[player] == small_key_shuffle.option_universal:
|
||||
pool.extend(diff.universal_keys)
|
||||
|
||||
@@ -7,7 +7,7 @@ from worlds.AutoWorld import World
|
||||
def GetBeemizerItem(world, player: int, item):
|
||||
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
|
||||
|
||||
# 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"),
|
||||
'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'),
|
||||
'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 (+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 (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 (+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.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.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'),
|
||||
'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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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
|
||||
elif world.dungeon_counters[player] is True:
|
||||
elif world.dungeon_counters[player] == 'on':
|
||||
rom.write_byte(0x18003C, 0x02) # always on
|
||||
elif world.compass_shuffle[player] or world.dungeon_counters[player] == 'pickup':
|
||||
rom.write_byte(0x18003C, 0x01) # show on pickup
|
||||
|
||||
@@ -1120,28 +1120,28 @@ def toss_junk_item(world, player):
|
||||
raise Exception("Unable to find a junk item to toss to make room for a TR small key")
|
||||
|
||||
|
||||
def set_trock_key_rules(world, player):
|
||||
def set_trock_key_rules(multiworld, player):
|
||||
# First set all relevant locked doors to impassible.
|
||||
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(multiworld.get_entrance(entrance, player), lambda state: False)
|
||||
|
||||
all_state = world.get_all_state(use_cache=False)
|
||||
all_state = multiworld.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.stale[player] = True
|
||||
|
||||
# Check if each of the four main regions of the dungoen can be reached. The previous code section prevents key-costing moves within the dungeon.
|
||||
can_reach_back = all_state.can_reach(world.get_region('Turtle Rock (Eye Bridge)', player))
|
||||
can_reach_front = all_state.can_reach(world.get_region('Turtle Rock (Entrance)', player))
|
||||
can_reach_big_chest = all_state.can_reach(world.get_region('Turtle Rock (Big Chest)', player))
|
||||
can_reach_middle = all_state.can_reach(world.get_region('Turtle Rock (Second Section)', player))
|
||||
can_reach_back = all_state.can_reach(multiworld.get_region('Turtle Rock (Eye Bridge)', player))
|
||||
can_reach_front = all_state.can_reach(multiworld.get_region('Turtle Rock (Entrance)', player))
|
||||
can_reach_big_chest = all_state.can_reach(multiworld.get_region('Turtle Rock (Big Chest)', player))
|
||||
can_reach_middle = all_state.can_reach(multiworld.get_region('Turtle Rock (Second Section)', player))
|
||||
|
||||
# If you can't enter from the back, the door to the front of TR requires only 2 small keys if the big key is in one of these chests since 2 key doors are locked behind the big key door.
|
||||
# If you can only enter from the middle, this includes all locations that can only be reached by exiting the front. This can include Laser Bridge and Crystaroller if the front and back connect via Dark DM Ledge!
|
||||
front_locked_locations = {('Turtle Rock - Compass Chest', player), ('Turtle Rock - Roller Room - Left', player), ('Turtle Rock - Roller Room - Right', player)}
|
||||
if can_reach_middle and not can_reach_back and not can_reach_front:
|
||||
normal_regions = all_state.reachable_regions[player].copy()
|
||||
set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: True)
|
||||
set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: True)
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: True)
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: True)
|
||||
all_state.update_reachable_regions(player)
|
||||
front_locked_regions = all_state.reachable_regions[player].difference(normal_regions)
|
||||
front_locked_locations = set((location.name, player) for region in front_locked_regions for location in region.locations)
|
||||
@@ -1151,37 +1151,38 @@ def set_trock_key_rules(world, player):
|
||||
|
||||
# Big key door requires the big key, obviously. We removed this rule in the previous section to flag front_locked_locations correctly,
|
||||
# otherwise crystaroller room might not be properly marked as reachable through the back.
|
||||
set_rule(world.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player) and can_kill_most_things(state, player, 10) and can_bomb_or_bonk(state, player))
|
||||
|
||||
|
||||
# No matter what, the key requirement for going from the middle to the bottom should be five keys.
|
||||
set_rule(world.get_entrance('Turtle Rock Dark Room Staircase', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock Dark Room Staircase', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
|
||||
# Now we need to set rules based on which entrances we have access to. The most important point is whether we have back access. If we have back access, we
|
||||
# might open all the locked doors in any order, so we need maximally restrictive rules.
|
||||
if can_reach_back:
|
||||
set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 6) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player)))
|
||||
set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
set_rule(multiworld.get_location('Turtle Rock - Big Key Chest', player), lambda state: (state._lttp_has_key('Small Key (Turtle Rock)', player, 6) or location_item_name(state, 'Turtle Rock - Big Key Chest', player) == ('Small Key (Turtle Rock)', player)))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
|
||||
set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
set_rule(world.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
set_rule(world.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
else:
|
||||
# Middle to front requires 3 keys if the back is locked by this door, otherwise 5
|
||||
set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3)
|
||||
if item_name_in_location_names(state, 'Big Key (Turtle Rock)', player, front_locked_locations.union({('Turtle Rock - Pokey 1 Key Drop', player)}))
|
||||
else state._lttp_has_key('Small Key (Turtle Rock)', player, 5))
|
||||
# Middle to front requires 4 keys if the back is locked by this door, otherwise 6
|
||||
set_rule(world.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4)
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (South)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 4)
|
||||
if item_name_in_location_names(state, 'Big Key (Turtle Rock)', player, front_locked_locations)
|
||||
else state._lttp_has_key('Small Key (Turtle Rock)', player, 6))
|
||||
|
||||
# Front to middle requires 3 keys (if the middle is accessible then these doors can be avoided, otherwise no keys can be wasted)
|
||||
set_rule(world.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3))
|
||||
set_rule(world.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2))
|
||||
set_rule(world.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 1))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Chain Chomp Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 3))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock (Pokey Room) (North)', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 2))
|
||||
set_rule(multiworld.get_entrance('Turtle Rock Entrance to Pokey Room', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, 1))
|
||||
|
||||
set_rule(world.get_location('Turtle Rock - Big Key Chest', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, tr_big_key_chest_keys_needed(state)))
|
||||
set_rule(multiworld.get_location('Turtle Rock - Big Key Chest', player), lambda state: state._lttp_has_key('Small Key (Turtle Rock)', player, tr_big_key_chest_keys_needed(state)))
|
||||
|
||||
def tr_big_key_chest_keys_needed(state):
|
||||
# This function handles the key requirements for the TR Big Chest in the situations it having the Big Key should logically require 2 keys, small key
|
||||
@@ -1194,30 +1195,30 @@ def set_trock_key_rules(world, player):
|
||||
return 6
|
||||
|
||||
# If TR is only accessible from the middle, the big key must be further restricted to prevent softlock potential
|
||||
if not can_reach_front and not world.small_key_shuffle[player]:
|
||||
if not can_reach_front and not multiworld.small_key_shuffle[player]:
|
||||
# Must not go in the Big Key Chest - only 1 other chest available and 2+ keys required for all other chests
|
||||
forbid_item(world.get_location('Turtle Rock - Big Key Chest', player), 'Big Key (Turtle Rock)', player)
|
||||
forbid_item(multiworld.get_location('Turtle Rock - Big Key Chest', player), 'Big Key (Turtle Rock)', player)
|
||||
if not can_reach_big_chest:
|
||||
# Must not go in the Chain Chomps chest - only 2 other chests available and 3+ keys required for all other chests
|
||||
forbid_item(world.get_location('Turtle Rock - Chain Chomps', player), 'Big Key (Turtle Rock)', player)
|
||||
forbid_item(world.get_location('Turtle Rock - Pokey 2 Key Drop', player), 'Big Key (Turtle Rock)', player)
|
||||
if world.accessibility[player] == 'full':
|
||||
if world.big_key_shuffle[player] and can_reach_big_chest:
|
||||
forbid_item(multiworld.get_location('Turtle Rock - Chain Chomps', player), 'Big Key (Turtle Rock)', player)
|
||||
forbid_item(multiworld.get_location('Turtle Rock - Pokey 2 Key Drop', player), 'Big Key (Turtle Rock)', player)
|
||||
if multiworld.accessibility[player] == 'full':
|
||||
if multiworld.big_key_shuffle[player] and can_reach_big_chest:
|
||||
# Must not go in the dungeon - all 3 available chests (Chomps, Big Chest, Crystaroller) must be keys to access laser bridge, and the big key is required first
|
||||
for location in ['Turtle Rock - Chain Chomps', 'Turtle Rock - Compass Chest',
|
||||
'Turtle Rock - Pokey 1 Key Drop', 'Turtle Rock - Pokey 2 Key Drop',
|
||||
'Turtle Rock - Roller Room - Left', 'Turtle Rock - Roller Room - Right']:
|
||||
forbid_item(world.get_location(location, player), 'Big Key (Turtle Rock)', player)
|
||||
forbid_item(multiworld.get_location(location, player), 'Big Key (Turtle Rock)', player)
|
||||
else:
|
||||
# A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works
|
||||
item = item_factory('Small Key (Turtle Rock)', world.worlds[player])
|
||||
location = world.get_location('Turtle Rock - Big Key Chest', player)
|
||||
item = item_factory('Small Key (Turtle Rock)', multiworld.worlds[player])
|
||||
location = multiworld.get_location('Turtle Rock - Big Key Chest', player)
|
||||
location.place_locked_item(item)
|
||||
toss_junk_item(world, player)
|
||||
toss_junk_item(multiworld, player)
|
||||
|
||||
if world.accessibility[player] != 'full':
|
||||
set_always_allow(world.get_location('Turtle Rock - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Turtle Rock)' and item.player == player
|
||||
and state.can_reach(state.multiworld.get_region('Turtle Rock (Second Section)', player)))
|
||||
if multiworld.accessibility[player] != 'full':
|
||||
set_always_allow(multiworld.get_location('Turtle Rock - Big Key Chest', player), lambda state, item: item.name == 'Small Key (Turtle Rock)' and item.player == player
|
||||
and state.can_reach(state.multiworld.get_region('Turtle Rock (Second Section)', player)))
|
||||
|
||||
|
||||
def set_big_bomb_rules(world, player):
|
||||
|
||||
@@ -170,7 +170,8 @@ def push_shop_inventories(multiworld):
|
||||
# Retro Bow arrows will already have been pushed
|
||||
if (not multiworld.retro_bow[location.player]) or ((item_name, location.item.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,
|
||||
location.shop_price_type)
|
||||
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:
|
||||
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:
|
||||
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]:
|
||||
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:
|
||||
@@ -61,13 +61,13 @@ def heart_count(state: CollectionState, player: int) -> int:
|
||||
# Warning: This only considers items that are marked as advancement items
|
||||
diff = state.multiworld.worlds[player].difficulty_requirements
|
||||
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 \
|
||||
+ 3 # starting hearts
|
||||
+ 3 # starting hearts
|
||||
|
||||
|
||||
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
|
||||
if state.has('Magic Upgrade (1/4)', player):
|
||||
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):
|
||||
arrows = 30 + ((state.count("Arrow Upgrade (+5)", player) * 5) + (state.count("Arrow Upgrade (+10)", player) * 10)
|
||||
+ (state.count("Bomb Upgrade (50)", player) * 50))
|
||||
# Arrow Upgrade (+5) beyond the 6th gives +10
|
||||
arrows += max(0, ((state.count("Arrow Upgrade (+5)", player) - 6) * 10))
|
||||
return min(70, arrows) >= quantity
|
||||
if state.multiworld.worlds[player].options.shuffle_capacity_upgrades:
|
||||
if quantity == 0:
|
||||
return True
|
||||
if state.has("Arrow Upgrade (70)", player):
|
||||
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:
|
||||
@@ -146,19 +153,19 @@ def can_get_good_bee(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
|
||||
(state.multiworld.swordless[player] and
|
||||
state.has("Hammer", player)))
|
||||
state.has("Hammer", player)))
|
||||
|
||||
|
||||
def has_sword(state: CollectionState, player: int) -> bool:
|
||||
return state.has('Fighter Sword', player) \
|
||||
or state.has('Master Sword', player) \
|
||||
or state.has('Tempered Sword', player) \
|
||||
or state.has('Golden Sword', player)
|
||||
or state.has('Master Sword', player) \
|
||||
or state.has('Tempered Sword', player) \
|
||||
or state.has('Golden Sword', player)
|
||||
|
||||
|
||||
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',
|
||||
player)
|
||||
player)
|
||||
|
||||
|
||||
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:
|
||||
return state.has('Fire Rod', player) or \
|
||||
(state.has('Bombos', player) and
|
||||
(state.multiworld.swordless[player] or
|
||||
has_sword(state, player)))
|
||||
(state.has('Bombos', player) and
|
||||
(state.multiworld.swordless[player] or
|
||||
has_sword(state, player)))
|
||||
|
||||
|
||||
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
|
||||
|
||||
<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
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
|
||||
- Un emulador capaz de ejecutar scripts Lua
|
||||
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
- [SNI](https://github.com/alttpo/sni/releases). Esto está incluido automáticamente en la instalación de Archipelago.
|
||||
- SNI no es compatible con (Q)Usb2Snes.
|
||||
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES, por ejemplo:
|
||||
- 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
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo). O,
|
||||
- Un flashcart SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), o otro hardware compatible
|
||||
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo).
|
||||
- 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`
|
||||
|
||||
## Procedimiento de instalación
|
||||
|
||||
### Instalación en Windows
|
||||
|
||||
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
|
||||
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu
|
||||
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 '
|
||||
Setup.BerserkerMultiWorld.Doors.exe'
|
||||
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías
|
||||
instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del
|
||||
archivo una segunda vez.
|
||||
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (
|
||||
posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
|
||||
|
||||
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
|
||||
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**.
|
||||
2. La primera vez que realices una generación local o parchees tu juego, se te pedirá que ubiques tu archivo ROM base.
|
||||
Este es tu archivo ROM de Link to the Past japonés. Esto sólo debe hacerse una vez.
|
||||
|
||||
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
|
||||
ROM.
|
||||
1. Extrae la carpeta de tu emulador al Escritorio, o algún otro sitio que vayas a recordar.
|
||||
2. Haz click derecho en un archivo ROM y selecciona **Abrir con...**
|
||||
3. Marca la casilla junto a **Usar siempre este programa para abrir archivos .sfc**
|
||||
4. Baja al final de la lista y haz click en el texto gris **Buscar otro programa en este PC**
|
||||
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.
|
||||
|
||||
### 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
|
||||
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
|
||||
automáticamente el cliente, y ademas creara la rom en el mismo directorio donde este el fichero de parche.
|
||||
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 además creará la rom en el mismo directorio donde este el fichero de parche.
|
||||
|
||||
### Conectar al cliente
|
||||
|
||||
#### Con emulador
|
||||
|
||||
Cuando el cliente se lance automáticamente, QUsb2Snes debería haberse ejecutado también. Si es la primera vez que lo
|
||||
ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la comunicación.
|
||||
Cuando el cliente se lance automáticamente, SNI debería de ejecutarse en segundo plano. Si es la
|
||||
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
|
||||
|
||||
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**
|
||||
3. Haz click en **New Lua Script Window...**
|
||||
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
|
||||
escoge `multibridge.lua`
|
||||
6. 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.
|
||||
5. Selecciona el archivo lua conector incluido con tu cliente
|
||||
- Busca en la carpeta de Archipelago `/SNI/lua/`.
|
||||
6. Si ves un error mientras carga el script que dice `socket.dll missing` o algo similar, ve a la carpeta de
|
||||
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
|
||||
|
||||
1. Asegurate que se ha cargado el nucleo BSNES. Debes hacer esto en el menu Tools y siguiento estas opciones:
|
||||
`Config --> Cores --> SNES --> BSNES`
|
||||
Una vez cambiado el nucleo cargado, BizHawk ha de ser reiniciado.
|
||||
1. Asegurate que se ha cargado el núcleo BSNES. Se hace en la barra de menú principal, bajo:
|
||||
- (≤ 2.8) `Config` 〉 `Cores` 〉 `SNES` 〉 `BSNES`
|
||||
- (≥ 2.9) `Config` 〉 `Preferred Cores` 〉 `SNES` 〉 `BSNESv115+`
|
||||
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**
|
||||
4. Haz click en el botón para abrir un nuevo script Lua.
|
||||
5. Navega al directorio de instalación de MultiWorld Utilities, y en los siguiente directorios:
|
||||
`QUsb2Snes/Qusb2Snes/LuaBridge`
|
||||
6. Selecciona `luabridge.lua` y haz click en Abrir.
|
||||
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.
|
||||
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).
|
||||
3. Arrastra el archivo `Connector.lua` que has descargado a la ventana principal de EmuHawk.
|
||||
- Busca en la carpeta de Archipelago `/SNI/lua/`.
|
||||
- También podrías abrir la consola de Lua manualmente, hacer click en `Script` 〉 `Open Script`, e ir a `Connector.lua`
|
||||
con el selector de archivos.
|
||||
|
||||
##### 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.
|
||||
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
|
||||
default) el Puerto de comandos de red.
|
||||
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
|
||||
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 /
|
||||
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.
|
||||
|
||||
#### 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
|
||||
[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).
|
||||
|
||||
1. Cierra tu emulador, el cual debe haberse autoejecutado.
|
||||
2. Cierra QUsb2Snes, el cual fue ejecutado junto al cliente.
|
||||
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.
|
||||
2. Enciende tu dispositivo y carga la ROM.
|
||||
|
||||
### Conecta al MultiServer
|
||||
### Conecta al Servidor Archipelago
|
||||
|
||||
El fichero de parche que ha lanzado el cliente debe haberte conectado automaticamente al MultiServer. 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
|
||||
algún otro sitio. Si el cliente muestra "Server Status: Not Connected", preguntale al creador de la partida la dirección
|
||||
del servidor, copiala en el campo "Server" y presiona Enter.
|
||||
El fichero de parche que ha lanzado el cliente debería de haberte conectado automaticamente al MultiServer. Sin embargo hay algunas
|
||||
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
|
||||
ventana del cliente muestra "Server Status: Not Connected", simplemente preguntale al creador de la partida la dirección
|
||||
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.
|
||||
Si el cliente no se conecta al cabo de un rato, puede ser que necesites refrescar la pagina web.
|
||||
El cliente intentará conectarse a esta nueva dirección, y debería mostrar "Server Status: Connected" momentáneamente.
|
||||
|
||||
### Jugando
|
||||
### Jugar al juego
|
||||
|
||||
Cuando ambos SNES Device and Server aparezcan como "connected", estas listo para empezar a jugar. Felicidades por unirte
|
||||
satisfactoriamente a una partida de multiworld!
|
||||
|
||||
## 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
|
||||
Cuando el cliente muestre tanto el dispositivo SNES como el servidor como conectados, estas listo para empezar a jugar. Felicidades por
|
||||
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.
|
||||
|
||||
@@ -77,5 +77,5 @@ class TestMiseryMire(TestDungeon):
|
||||
["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', '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']],
|
||||
])
|
||||
@@ -79,12 +79,12 @@ class TestInvertedTurtleRock(TestInverted):
|
||||
["Turtle Rock - Crystaroller Room", False, [], ['Big Key (Turtle Rock)', 'Lamp']],
|
||||
["Turtle Rock - Crystaroller Room", False, [], ['Magic Mirror', 'Cane of Somaria']],
|
||||
["Turtle Rock - Crystaroller Room", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Somaria']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot', 'Cane of Somaria']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot', 'Cane of Somaria']],
|
||||
@@ -97,9 +97,9 @@ class TestInvertedTurtleRock(TestInverted):
|
||||
["Turtle Rock - Boss", False, [], ['Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", False, [], ['Magic Mirror', 'Lamp']],
|
||||
["Turtle Rock - Boss", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Flute', 'Magic Mirror', 'Moon Pearl', 'Hookshot', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']]
|
||||
|
||||
@@ -117,12 +117,12 @@ class TestInvertedTurtleRock(TestInverted):
|
||||
[location, False, [], ['Magic Mirror', 'Cane of Somaria']],
|
||||
[location, False, [], ['Magic Mirror', 'Lamp']],
|
||||
[location, False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
|
||||
# Mirroring into Eye Bridge does not require Cane of Somaria
|
||||
[location, True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Byrna']],
|
||||
|
||||
@@ -80,12 +80,12 @@ class TestInvertedTurtleRock(TestInvertedMinor):
|
||||
["Turtle Rock - Crystaroller Room", False, [], ['Big Key (Turtle Rock)', 'Lamp']],
|
||||
["Turtle Rock - Crystaroller Room", False, [], ['Magic Mirror', 'Cane of Somaria']],
|
||||
["Turtle Rock - Crystaroller Room", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Progressive Glove', 'Progressive Glove', 'Magic Mirror']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Somaria']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Moon Pearl', 'Hookshot', 'Cane of Somaria']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Lamp', 'Moon Pearl', 'Flute', 'Magic Mirror', 'Hookshot', 'Cane of Somaria']],
|
||||
@@ -98,9 +98,9 @@ class TestInvertedTurtleRock(TestInvertedMinor):
|
||||
["Turtle Rock - Boss", False, [], ['Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", False, [], ['Magic Mirror', 'Lamp']],
|
||||
["Turtle Rock - Boss", False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Small Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Flute', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Bottle', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Progressive Sword', 'Cane of Somaria', 'Magic Upgrade (1/2)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)','Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Boss", True, ['Ice Rod', 'Fire Rod', 'Flute', 'Magic Mirror', 'Moon Pearl', 'Hookshot', 'Hammer', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Big Key (Turtle Rock)']]
|
||||
])
|
||||
@@ -116,12 +116,12 @@ class TestInvertedTurtleRock(TestInvertedMinor):
|
||||
[location, False, [], ['Magic Mirror', 'Cane of Somaria']],
|
||||
[location, False, [], ['Magic Mirror', 'Lamp']],
|
||||
[location, False, ['Small Key (Turtle Rock)', 'Small Key (Turtle Rock)'], ['Magic Mirror', 'Small Key (Turtle Rock)']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cane of Byrna']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Cape']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Flute', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Lamp', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
[location, True, ['Big Key (Turtle Rock)', 'Bomb Upgrade (50)', 'Lamp', 'Progressive Glove', 'Quake', 'Progressive Sword', 'Cane of Somaria', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Small Key (Turtle Rock)', 'Progressive Shield', 'Progressive Shield', 'Progressive Shield']],
|
||||
|
||||
# Mirroring into Eye Bridge does not require Cane of Somaria
|
||||
[location, True, ['Lamp', 'Magic Mirror', 'Progressive Glove', 'Progressive Glove', 'Cane of Byrna']],
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestDungeons(TestInvertedOWG):
|
||||
["Turtle Rock - Chain Chomps", True, ['Progressive Sword', 'Progressive Sword', 'Pegasus Boots']],
|
||||
|
||||
["Turtle Rock - Crystaroller Room", False, []],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Moon Pearl', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Moon Pearl', 'Big Key (Turtle Rock)', 'Bomb Upgrade (50)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Moon Pearl', 'Lamp', 'Cane of Somaria']],
|
||||
|
||||
["Ganons Tower - Hope Room - Left", False, []],
|
||||
|
||||
@@ -120,8 +120,8 @@ class TestDungeons(TestVanillaOWG):
|
||||
#todo: does clip require sword?
|
||||
#["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)', 'Progressive Sword']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)', 'Hookshot']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Big Key (Turtle Rock)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Moon Pearl', 'Pegasus Boots', 'Big Key (Turtle Rock)', 'Hookshot', 'Bomb Upgrade (50)']],
|
||||
["Turtle Rock - Crystaroller Room", True, ['Pegasus Boots', 'Magic Mirror', 'Big Key (Turtle Rock)', 'Bomb Upgrade (50)']],
|
||||
|
||||
["Ganons Tower - Hope Room - Left", False, []],
|
||||
["Ganons Tower - Hope Room - Left", False, ['Moon Pearl', 'Crystal 1']],
|
||||
|
||||
@@ -93,7 +93,7 @@ class AquariaWorld(World):
|
||||
options: AquariaOptions
|
||||
"Every options of the world"
|
||||
|
||||
regions: AquariaRegions
|
||||
regions: AquariaRegions | None
|
||||
"Used to manage Regions"
|
||||
|
||||
exclude: List[str]
|
||||
@@ -101,10 +101,17 @@ class AquariaWorld(World):
|
||||
def __init__(self, multiworld: MultiWorld, player: int):
|
||||
"""Initialisation of the Aquaria World"""
|
||||
super(AquariaWorld, self).__init__(multiworld, player)
|
||||
self.regions = AquariaRegions(multiworld, player)
|
||||
self.regions = None
|
||||
self.ingredients_substitution = []
|
||||
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:
|
||||
"""
|
||||
Create every Region in `regions`
|
||||
|
||||
@@ -89,7 +89,7 @@ location_names: Dict[str, str] = {
|
||||
"RESCUED_CHERUB_15": "DC: Top of elevator Child of Moonlight",
|
||||
"Lady[D01Z05S22]": "DC: Lady of the Six Sorrows, from MD",
|
||||
"QI75": "DC: Chalice room",
|
||||
"Sword[D01Z05S24]": "DC: Mea culpa altar",
|
||||
"Sword[D01Z05S24]": "DC: Mea Culpa altar",
|
||||
"CO44": "DC: Elevator shaft ledge",
|
||||
"RESCUED_CHERUB_22": "DC: Elevator shaft Child of Moonlight",
|
||||
"Lady[D01Z05S26]": "DC: Lady of the Six Sorrows, elevator shaft",
|
||||
|
||||
@@ -67,7 +67,8 @@ class BlasphemousWorld(World):
|
||||
|
||||
def generate_early(self):
|
||||
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}'] "
|
||||
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 ]
|
||||
|
||||
if self.options.difficulty < 2:
|
||||
locations.remove(4)
|
||||
locations.remove(5)
|
||||
locations.remove(6)
|
||||
|
||||
if self.options.dash_shuffle:
|
||||
@@ -103,6 +106,9 @@ class BlasphemousWorld(World):
|
||||
if not self.options.wall_climb_shuffle:
|
||||
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:
|
||||
self.disabled_locations.append("RE401")
|
||||
|
||||
@@ -200,9 +206,6 @@ class BlasphemousWorld(World):
|
||||
|
||||
if not self.options.skill_randomizer:
|
||||
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):
|
||||
|
||||
@@ -85,20 +85,7 @@ class TestGrievanceHard(BlasphemousTestBase):
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsEasy(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "easy"
|
||||
}
|
||||
|
||||
|
||||
class TestKnotOfWordsNormal(BlasphemousTestBase):
|
||||
options = {
|
||||
"starting_location": "knot_of_words",
|
||||
"difficulty": "normal"
|
||||
}
|
||||
|
||||
|
||||
# knot of the three words, rooftops, and mourning and havoc can't be selected on easy or normal. hard only
|
||||
class TestKnotOfWordsHard(BlasphemousTestBase):
|
||||
options = {
|
||||
"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):
|
||||
options = {
|
||||
"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):
|
||||
options = {
|
||||
"starting_location": "mourning_havoc",
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
|
||||
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
|
||||
|
||||
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": ""
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
342
worlds/civ_6/Civ6Client.py
Normal file
342
worlds/civ_6/Civ6Client.py
Normal file
@@ -0,0 +1,342 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from typing import Any, Dict, List, Optional
|
||||
import zipfile
|
||||
|
||||
from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, logger, server_loop, gui_enabled
|
||||
from .Data import get_progressive_districts_data
|
||||
from .DeathLink import handle_check_deathlink
|
||||
from NetUtils import ClientStatus
|
||||
import Utils
|
||||
from .CivVIInterface import CivVIInterface, ConnectionState
|
||||
from .Enum import CivVICheckType
|
||||
from .Items import CivVIItemData, generate_item_table, get_item_by_civ_name
|
||||
from .Locations import CivVILocationData, generate_era_location_table
|
||||
from .TunerClient import TunerErrorException, TunerTimeoutException
|
||||
|
||||
|
||||
class CivVICommandProcessor(ClientCommandProcessor):
|
||||
def __init__(self, ctx: CommonContext):
|
||||
super().__init__(ctx)
|
||||
|
||||
def _cmd_deathlink(self):
|
||||
"""Toggle deathlink from client. Overrides default setting."""
|
||||
if isinstance(self.ctx, CivVIContext):
|
||||
self.ctx.death_link_enabled = not self.ctx.death_link_enabled
|
||||
self.ctx.death_link_just_changed = True
|
||||
Utils.async_start(self.ctx.update_death_link(
|
||||
self.ctx.death_link_enabled), name="Update Deathlink")
|
||||
self.ctx.logger.info(f"Deathlink is now {'enabled' if self.ctx.death_link_enabled else 'disabled'}")
|
||||
|
||||
def _cmd_resync(self):
|
||||
"""Resends all items to client, and has client resend all locations to server. This can take up to a minute if the player has received a lot of items"""
|
||||
if isinstance(self.ctx, CivVIContext):
|
||||
logger.info("Resyncing...")
|
||||
asyncio.create_task(self.ctx.resync())
|
||||
|
||||
def _cmd_toggle_progressive_eras(self):
|
||||
"""If you get stuck for some reason and unable to continue your game, you can run this command to disable the defeat that comes from pushing past the max unlocked era """
|
||||
if isinstance(self.ctx, CivVIContext):
|
||||
print("Toggling progressive eras, stand by...")
|
||||
self.ctx.is_pending_toggle_progressive_eras = True
|
||||
|
||||
|
||||
class CivVIContext(CommonContext):
|
||||
is_pending_death_link_reset = False
|
||||
is_pending_toggle_progressive_eras = False
|
||||
command_processor = CivVICommandProcessor
|
||||
game = "Civilization VI"
|
||||
items_handling = 0b111
|
||||
tuner_sync_task: Optional[asyncio.Task[None]] = None
|
||||
game_interface: CivVIInterface
|
||||
location_name_to_civ_location: Dict[str, CivVILocationData] = {}
|
||||
location_name_to_id: Dict[str, int] = {}
|
||||
item_id_to_civ_item: Dict[int, CivVIItemData] = {}
|
||||
item_table: Dict[str, CivVIItemData] = {}
|
||||
processing_multiple_items = False
|
||||
received_death_link = False
|
||||
death_link_message = ""
|
||||
death_link_enabled = False
|
||||
slot_data: Dict[str, Any]
|
||||
|
||||
death_link_just_changed = False
|
||||
# Used to prevent the deathlink from triggering when someone re enables it
|
||||
|
||||
logger = logger
|
||||
progressive_items_by_type = get_progressive_districts_data()
|
||||
item_name_to_id = {
|
||||
item.name: item.code for item in generate_item_table().values()}
|
||||
connection_state = ConnectionState.DISCONNECTED
|
||||
|
||||
def __init__(self, server_address: Optional[str], password: Optional[str], apcivvi_file: Optional[str] = None):
|
||||
super().__init__(server_address, password)
|
||||
self.slot_data: Dict[str, Any] = {}
|
||||
self.game_interface = CivVIInterface(logger)
|
||||
location_by_era = generate_era_location_table()
|
||||
self.item_table = generate_item_table()
|
||||
self.apcivvi_file = apcivvi_file
|
||||
|
||||
# Get tables formatted in a way that is easier to use here
|
||||
for locations in location_by_era.values():
|
||||
for location in locations.values():
|
||||
self.location_name_to_id[location.name] = location.code
|
||||
self.location_name_to_civ_location[location.name] = location
|
||||
|
||||
for item in self.item_table.values():
|
||||
self.item_id_to_civ_item[item.code] = item
|
||||
|
||||
async def resync(self):
|
||||
if self.processing_multiple_items:
|
||||
logger.info(
|
||||
"Waiting for items to finish processing, try again later")
|
||||
return
|
||||
await self.game_interface.resync()
|
||||
await handle_receive_items(self, -1)
|
||||
logger.info("Resynced")
|
||||
|
||||
def on_deathlink(self, data: Utils.Dict[str, Utils.Any]) -> None:
|
||||
super().on_deathlink(data)
|
||||
text = data.get("cause", "")
|
||||
if text:
|
||||
message = text
|
||||
else:
|
||||
message = f"Received from {data['source']}"
|
||||
self.death_link_message = message
|
||||
self.received_death_link = True
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(CivVIContext, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
self.tags = set()
|
||||
await self.send_connect()
|
||||
|
||||
def run_gui(self):
|
||||
from kvui import GameManager
|
||||
|
||||
class CivVIManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Civilization VI Client"
|
||||
|
||||
self.ui = CivVIManager(self)
|
||||
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
|
||||
|
||||
def on_package(self, cmd: str, args: Dict[str, Any]):
|
||||
if cmd == "Connected":
|
||||
self.slot_data = args["slot_data"]
|
||||
if "death_link" in args["slot_data"]:
|
||||
self.death_link_enabled = bool(args["slot_data"]["death_link"])
|
||||
Utils.async_start(self.update_death_link(
|
||||
bool(args["slot_data"]["death_link"])))
|
||||
|
||||
|
||||
def update_connection_status(ctx: CivVIContext, status: ConnectionState):
|
||||
if ctx.connection_state == status:
|
||||
return
|
||||
elif status == ConnectionState.IN_GAME:
|
||||
ctx.logger.info("Connected to Civ VI")
|
||||
elif status == ConnectionState.IN_MENU:
|
||||
ctx.logger.info("Connected to Civ VI, waiting for game to start")
|
||||
elif status == ConnectionState.DISCONNECTED:
|
||||
ctx.logger.info("Disconnected from Civ VI, attempting to reconnect...")
|
||||
|
||||
ctx.connection_state = status
|
||||
|
||||
|
||||
async def tuner_sync_task(ctx: CivVIContext):
|
||||
logger.info("Starting CivVI connector")
|
||||
while not ctx.exit_event.is_set():
|
||||
if not ctx.slot:
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
if ctx.processing_multiple_items:
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
state = await ctx.game_interface.is_in_game()
|
||||
update_connection_status(ctx, state)
|
||||
if state == ConnectionState.IN_GAME:
|
||||
await _handle_game_ready(ctx)
|
||||
else:
|
||||
await asyncio.sleep(3)
|
||||
except TunerTimeoutException:
|
||||
logger.error(
|
||||
"Timeout occurred while receiving data from Civ VI, this usually isn't a problem unless you see it repeatedly")
|
||||
await asyncio.sleep(3)
|
||||
except Exception as e:
|
||||
if isinstance(e, TunerErrorException):
|
||||
logger.debug(str(e))
|
||||
else:
|
||||
logger.debug(traceback.format_exc())
|
||||
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
|
||||
|
||||
async def handle_toggle_progressive_eras(ctx: CivVIContext):
|
||||
if ctx.is_pending_toggle_progressive_eras:
|
||||
ctx.is_pending_toggle_progressive_eras = False
|
||||
current = await ctx.game_interface.get_max_allowed_era()
|
||||
if current > -1:
|
||||
await ctx.game_interface.set_max_allowed_era(-1)
|
||||
logger.info("Disabled progressive eras")
|
||||
else:
|
||||
count = 0
|
||||
for _, network_item in enumerate(ctx.items_received):
|
||||
item: CivVIItemData = ctx.item_id_to_civ_item[network_item.item]
|
||||
if item.item_type == CivVICheckType.ERA:
|
||||
count += 1
|
||||
await ctx.game_interface.set_max_allowed_era(count)
|
||||
logger.info(f"Enabled progressive eras, set to {count}")
|
||||
|
||||
|
||||
async def handle_checked_location(ctx: CivVIContext):
|
||||
checked_locations = await ctx.game_interface.get_checked_locations()
|
||||
checked_location_ids = [location.code for location_name, location in ctx.location_name_to_civ_location.items(
|
||||
) if location_name in checked_locations]
|
||||
|
||||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": checked_location_ids}])
|
||||
|
||||
|
||||
async def handle_receive_items(ctx: CivVIContext, last_received_index_override: Optional[int] = None):
|
||||
try:
|
||||
last_received_index = last_received_index_override or await ctx.game_interface.get_last_received_index()
|
||||
if len(ctx.items_received) - last_received_index > 1:
|
||||
ctx.processing_multiple_items = True
|
||||
|
||||
progressive_districts: List[CivVIItemData] = []
|
||||
progressive_eras: List[CivVIItemData] = []
|
||||
for index, network_item in enumerate(ctx.items_received):
|
||||
|
||||
# Track these separately so if we replace "PROGRESSIVE_DISTRICT" with a specific tech, we can still check if need to add it to the list of districts
|
||||
item: CivVIItemData = ctx.item_id_to_civ_item[network_item.item]
|
||||
item_to_send: CivVIItemData = ctx.item_id_to_civ_item[network_item.item]
|
||||
if index > last_received_index:
|
||||
if item.item_type == CivVICheckType.PROGRESSIVE_DISTRICT and item.civ_name:
|
||||
# if the item is progressive, then check how far in that progression type we are and send the appropriate item
|
||||
count = sum(
|
||||
1 for count_item in progressive_districts if count_item.civ_name == item.civ_name)
|
||||
|
||||
if count >= len(ctx.progressive_items_by_type[item.civ_name]):
|
||||
logger.error(
|
||||
f"Received more progressive items than expected for {item.civ_name}")
|
||||
continue
|
||||
|
||||
item_civ_name = ctx.progressive_items_by_type[item.civ_name][count]
|
||||
actual_item_name = get_item_by_civ_name(item_civ_name, ctx.item_table).name
|
||||
item_to_send = ctx.item_table[actual_item_name]
|
||||
|
||||
sender = ctx.player_names[network_item.player]
|
||||
if item.item_type == CivVICheckType.ERA:
|
||||
count = len(progressive_eras) + 1
|
||||
await ctx.game_interface.give_item_to_player(item_to_send, sender, count)
|
||||
elif item.item_type == CivVICheckType.GOODY and item_to_send.civ_name:
|
||||
await ctx.game_interface.give_item_to_player(item_to_send, sender, game_id_override=item_to_send.civ_name)
|
||||
else:
|
||||
await ctx.game_interface.give_item_to_player(item_to_send, sender)
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
if item.item_type == CivVICheckType.PROGRESSIVE_DISTRICT:
|
||||
progressive_districts.append(item)
|
||||
elif item.item_type == CivVICheckType.ERA:
|
||||
progressive_eras.append(item)
|
||||
|
||||
ctx.processing_multiple_items = False
|
||||
finally:
|
||||
# If something errors out, then unblock item processing
|
||||
ctx.processing_multiple_items = False
|
||||
|
||||
|
||||
async def handle_check_goal_complete(ctx: CivVIContext):
|
||||
if ctx.finished_game:
|
||||
return
|
||||
result = await ctx.game_interface.check_victory()
|
||||
if result:
|
||||
logger.info("Sending Victory to server!")
|
||||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
|
||||
ctx.finished_game = True
|
||||
|
||||
|
||||
async def _handle_game_ready(ctx: CivVIContext):
|
||||
if ctx.server:
|
||||
if not ctx.slot:
|
||||
await asyncio.sleep(3)
|
||||
return
|
||||
|
||||
await handle_receive_items(ctx)
|
||||
await handle_checked_location(ctx)
|
||||
await handle_check_goal_complete(ctx)
|
||||
|
||||
if ctx.death_link_enabled:
|
||||
await handle_check_deathlink(ctx)
|
||||
|
||||
# process pending commands
|
||||
await handle_toggle_progressive_eras(ctx)
|
||||
await asyncio.sleep(3)
|
||||
else:
|
||||
logger.info("Waiting for player to connect to server")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
|
||||
def main(connect: Optional[str] = None, password: Optional[str] = None, name: Optional[str] = None):
|
||||
Utils.init_logging("Civilization VI Client")
|
||||
|
||||
async def _main(connect: Optional[str], password: Optional[str], name: Optional[str]):
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("apcivvi_file", default="", type=str, nargs="?", help="Path to apcivvi file")
|
||||
args = parser.parse_args()
|
||||
ctx = CivVIContext(connect, password, args.apcivvi_file)
|
||||
|
||||
if args.apcivvi_file:
|
||||
parent_dir: str = os.path.dirname(args.apcivvi_file)
|
||||
target_name: str = os.path.basename(args.apcivvi_file).replace(".apcivvi", "-MOD-FILES")
|
||||
target_path: str = os.path.join(parent_dir, target_name)
|
||||
if not os.path.exists(target_path):
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
logger.info("Extracting mod files to %s", target_path)
|
||||
with zipfile.ZipFile(args.apcivvi_file, "r") as zip_ref:
|
||||
for member in zip_ref.namelist():
|
||||
zip_ref.extract(member, target_path)
|
||||
|
||||
ctx.auth = name
|
||||
ctx.server_task = asyncio.create_task(
|
||||
server_loop(ctx), name="ServerLoop")
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
await asyncio.sleep(1)
|
||||
|
||||
ctx.tuner_sync_task = asyncio.create_task(
|
||||
tuner_sync_task(ctx), name="TunerSync")
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
ctx.server_address = None
|
||||
|
||||
await ctx.shutdown()
|
||||
|
||||
if ctx.tuner_sync_task:
|
||||
await asyncio.sleep(3)
|
||||
await ctx.tuner_sync_task
|
||||
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
asyncio.run(_main(connect, password, name))
|
||||
colorama.deinit()
|
||||
|
||||
|
||||
def debug_main():
|
||||
parser = get_base_parser()
|
||||
parser.add_argument("apcivvi_file", default="", type=str, nargs="?", help="Path to apcivvi file")
|
||||
parser.add_argument("--name", default=None,
|
||||
help="Slot Name to connect as.")
|
||||
parser.add_argument("--debug", default=None,
|
||||
help="debug mode, additional logging")
|
||||
args = parser.parse_args()
|
||||
if args.debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
main(args.connect, args.password, args.name)
|
||||
119
worlds/civ_6/CivVIInterface.py
Normal file
119
worlds/civ_6/CivVIInterface.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from enum import Enum
|
||||
from logging import Logger
|
||||
from typing import List, Optional
|
||||
|
||||
from .Items import CivVIItemData
|
||||
from .TunerClient import TunerClient, TunerConnectionException, TunerTimeoutException
|
||||
|
||||
|
||||
class ConnectionState(Enum):
|
||||
DISCONNECTED = 0
|
||||
IN_GAME = 1
|
||||
IN_MENU = 2
|
||||
|
||||
|
||||
class CivVIInterface:
|
||||
logger: Logger
|
||||
tuner: TunerClient
|
||||
last_error: Optional[str] = None
|
||||
|
||||
def __init__(self, logger: Logger):
|
||||
self.logger = logger
|
||||
self.tuner = TunerClient(logger)
|
||||
|
||||
async def is_in_game(self) -> ConnectionState:
|
||||
command = "IsInGame()"
|
||||
try:
|
||||
result = await self.tuner.send_game_command(command)
|
||||
if result == "false":
|
||||
return ConnectionState.IN_MENU
|
||||
self.last_error = None
|
||||
return ConnectionState.IN_GAME
|
||||
except TunerTimeoutException:
|
||||
self.print_connection_error(
|
||||
"Not connected to game, waiting for connection to be available")
|
||||
return ConnectionState.DISCONNECTED
|
||||
except TunerConnectionException as e:
|
||||
if "The remote computer refused the network connection" in str(e):
|
||||
self.print_connection_error(
|
||||
"Unable to connect to game. Verify that the tuner is enabled. Attempting to reconnect")
|
||||
else:
|
||||
self.print_connection_error(
|
||||
"Not connected to game, waiting for connection to be available")
|
||||
return ConnectionState.DISCONNECTED
|
||||
except Exception as e:
|
||||
if "attempt to index a nil valuestack traceback" in str(e) \
|
||||
or ".. is not supported for string .. nilstack traceback" in str(e):
|
||||
return ConnectionState.IN_MENU
|
||||
return ConnectionState.DISCONNECTED
|
||||
|
||||
def print_connection_error(self, error: str) -> None:
|
||||
if error != self.last_error:
|
||||
self.last_error = error
|
||||
self.logger.info(error)
|
||||
|
||||
async def give_item_to_player(self, item: CivVIItemData, sender: str = "", amount: int = 1, game_id_override: Optional[str] = None) -> None:
|
||||
if game_id_override:
|
||||
item_id = f'"{game_id_override}"'
|
||||
else:
|
||||
item_id = item.civ_vi_id
|
||||
|
||||
command = f"HandleReceiveItem({item_id}, \"{item.name}\", \"{item.item_type.value}\", \"{sender}\", {amount})"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def resync(self) -> None:
|
||||
"""Has the client resend all the checked locations"""
|
||||
command = "Resync()"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def check_victory(self) -> bool:
|
||||
command = "ClientGetVictory()"
|
||||
result = await self.tuner.send_game_command(command)
|
||||
return result == "true"
|
||||
|
||||
async def get_checked_locations(self) -> List[str]:
|
||||
command = "GetUnsentCheckedLocations()"
|
||||
result = await self.tuner.send_game_command(command, 2048 * 4)
|
||||
return result.split(",")
|
||||
|
||||
async def get_deathlink(self) -> str:
|
||||
"""returns either "false" or the name of the unit that killed the player's unit"""
|
||||
command = "ClientGetDeathLink()"
|
||||
result = await self.tuner.send_game_command(command)
|
||||
return result
|
||||
|
||||
async def kill_unit(self, message: str) -> None:
|
||||
command = f"KillUnit(\"{message}\")"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def get_last_received_index(self) -> int:
|
||||
command = "ClientGetLastReceivedIndex()"
|
||||
result = await self.tuner.send_game_command(command)
|
||||
return int(result)
|
||||
|
||||
async def send_notification(self, item: CivVIItemData, sender: str = "someone") -> None:
|
||||
command = f"GameCore.NotificationManager:SendNotification(GameCore.NotificationTypes.USER_DEFINED_2, \"{item.name} Received\", \"You have received {item.name} from \" .. \"{sender}\", 0, {item.civ_vi_id})"
|
||||
await self.tuner.send_command(command)
|
||||
|
||||
async def decrease_gold_by_percent(self, percent: int, message: str) -> None:
|
||||
command = f"DecreaseGoldByPercent({percent}, \"{message}\")"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def decrease_faith_by_percent(self, percent: int, message: str) -> None:
|
||||
command = f"DecreaseFaithByPercent({percent}, \"{message}\")"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def decrease_era_score_by_amount(self, amount: int, message: str) -> None:
|
||||
command = f"DecreaseEraScoreByAmount({amount}, \"{message}\")"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def set_max_allowed_era(self, count: int) -> None:
|
||||
command = f"SetMaxAllowedEra(\"{count}\")"
|
||||
await self.tuner.send_game_command(command)
|
||||
|
||||
async def get_max_allowed_era(self) -> int:
|
||||
command = "ClientGetMaxAllowedEra()"
|
||||
result = await self.tuner.send_game_command(command)
|
||||
if result == "":
|
||||
return -1
|
||||
return int(result)
|
||||
219
worlds/civ_6/Container.py
Normal file
219
worlds/civ_6/Container.py
Normal file
@@ -0,0 +1,219 @@
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, cast
|
||||
import zipfile
|
||||
from BaseClasses import Location
|
||||
from worlds.Files import APContainer
|
||||
|
||||
from .Enum import CivVICheckType
|
||||
from .Locations import CivVILocation, CivVILocationData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CivVIWorld
|
||||
|
||||
|
||||
# Python fstrings don't allow backslashes, so we use this workaround
|
||||
nl = "\n"
|
||||
tab = "\t"
|
||||
apo = "\'"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CivTreeItem:
|
||||
name: str
|
||||
cost: int
|
||||
ui_tree_row: int
|
||||
|
||||
|
||||
class CivVIContainer(APContainer):
|
||||
"""
|
||||
Responsible for generating the dynamic mod files for the Civ VI multiworld
|
||||
"""
|
||||
game: Optional[str] = "Civilization VI"
|
||||
|
||||
def __init__(self, patch_data: Dict[str, str], base_path: str, output_directory: str,
|
||||
player: Optional[int] = None, player_name: str = "", server: str = ""):
|
||||
self.patch_data = patch_data
|
||||
self.file_path = base_path
|
||||
container_path = os.path.join(output_directory, base_path + ".apcivvi")
|
||||
super().__init__(container_path, player, player_name, server)
|
||||
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
for filename, yml in self.patch_data.items():
|
||||
opened_zipfile.writestr(filename, yml)
|
||||
super().write_contents(opened_zipfile)
|
||||
|
||||
|
||||
def get_cost(world: 'CivVIWorld', location: CivVILocationData) -> int:
|
||||
"""
|
||||
Returns the cost of the item based on the game options
|
||||
"""
|
||||
# Research cost is between 50 and 150 where 100 equals the default cost
|
||||
multiplier = world.options.research_cost_multiplier / 100
|
||||
return int(world.location_table[location.name].cost * multiplier)
|
||||
|
||||
|
||||
def get_formatted_player_name(world: 'CivVIWorld', player: int) -> str:
|
||||
"""
|
||||
Returns the name of the player in the world
|
||||
"""
|
||||
if player != world.player:
|
||||
return f"{world.multiworld.player_name[player]}{apo}s"
|
||||
return "Your"
|
||||
|
||||
|
||||
def get_advisor_type(world: 'CivVIWorld', location: Location) -> str:
|
||||
if world.options.advisor_show_progression_items and location.item and location.item.advancement:
|
||||
return "ADVISOR_PROGRESSIVE"
|
||||
return "ADVISOR_GENERIC"
|
||||
|
||||
|
||||
def generate_new_items(world: 'CivVIWorld') -> str:
|
||||
"""
|
||||
Generates the XML for the new techs/civics as well as the blockers used to prevent players from researching their own items
|
||||
"""
|
||||
locations: List[CivVILocation] = cast(List[CivVILocation], world.multiworld.get_filled_locations(world.player))
|
||||
techs = [location for location in locations if location.location_type ==
|
||||
CivVICheckType.TECH]
|
||||
civics = [location for location in locations if location.location_type ==
|
||||
CivVICheckType.CIVIC]
|
||||
|
||||
boost_techs = []
|
||||
boost_civics = []
|
||||
|
||||
if world.options.boostsanity:
|
||||
boost_techs = [location for location in locations if location.location_type == CivVICheckType.BOOST and location.name.split("_")[1] == "TECH"]
|
||||
boost_civics = [location for location in locations if location.location_type == CivVICheckType.BOOST and location.name.split("_")[1] == "CIVIC"]
|
||||
techs += boost_techs
|
||||
civics += boost_civics
|
||||
|
||||
return f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<GameInfo>
|
||||
<Types>
|
||||
<Row Type="TECH_BLOCKER" Kind="KIND_TECH" />
|
||||
<Row Type="CIVIC_BLOCKER" Kind="KIND_CIVIC" />
|
||||
{"".join([f'{tab}<Row Type="{tech.name}" Kind="KIND_TECH" />{nl}' for
|
||||
tech in techs])}
|
||||
{"".join([f'{tab}<Row Type="{civic.name}" Kind="KIND_CIVIC" />{nl}' for
|
||||
civic in civics])}
|
||||
</Types>
|
||||
<Technologies>
|
||||
<Row TechnologyType="TECH_BLOCKER" Name="TECH_BLOCKER" EraType="ERA_ANCIENT" UITreeRow="0" Cost="99999" AdvisorType="ADVISOR_GENERIC" Description="Archipelago Tech created to prevent players from researching their own tech. If you can read this, then congrats you have reached the end of your tree before beating the game!"/>
|
||||
{"".join([f'{tab}<Row TechnologyType="{location.name}" '
|
||||
f'Name="{get_formatted_player_name(world, location.item.player)} '
|
||||
f'{location.item.name}" '
|
||||
f'EraType="{world.location_table[location.name].era_type}" '
|
||||
f'UITreeRow="{world.location_table[location.name].uiTreeRow}" '
|
||||
f'Cost="{get_cost(world, world.location_table[location.name])}" '
|
||||
f'Description="{location.name}" '
|
||||
f'AdvisorType="{get_advisor_type(world, location)}"'
|
||||
f'/>{nl}'
|
||||
for location in techs if location.item])}
|
||||
</Technologies>
|
||||
<TechnologyPrereqs>
|
||||
{"".join([f'{tab}<Row Technology="{location.name}" PrereqTech="TECH_BLOCKER" />{nl}' for location in boost_techs])}
|
||||
</TechnologyPrereqs>
|
||||
<Civics>
|
||||
<Row CivicType="CIVIC_BLOCKER" Name="CIVIC_BLOCKER" EraType="ERA_ANCIENT" UITreeRow="0" Cost="99999" AdvisorType="ADVISOR_GENERIC" Description="Archipelago Civic created to prevent players from researching their own civics. If you can read this, then congrats you have reached the end of your tree before beating the game!"/>
|
||||
{"".join([f'{tab}<Row CivicType="{location.name}" '
|
||||
f'Name="{get_formatted_player_name(world, location.item.player)} '
|
||||
f'{location.item.name}" '
|
||||
f'EraType="{world.location_table[location.name].era_type}" '
|
||||
f'UITreeRow="{world.location_table[location.name].uiTreeRow}" '
|
||||
f'Cost="{get_cost(world, world.location_table[location.name])}" '
|
||||
f'Description="{location.name}" '
|
||||
f'AdvisorType="{get_advisor_type(world, location)}"'
|
||||
f'/>{nl}'
|
||||
for location in civics if location.item])}
|
||||
</Civics>
|
||||
<CivicPrereqs>
|
||||
{"".join([f'{tab}<Row Civic="{location.name}" PrereqCivic="CIVIC_BLOCKER" />{nl}' for location in boost_civics])}
|
||||
</CivicPrereqs>
|
||||
|
||||
<Civics_XP2>
|
||||
{"".join([f'{tab}<Row CivicType="{location.name}" HiddenUntilPrereqComplete="true" RandomPrereqs="false"/>{nl}' for location in civics if world.options.hide_item_names])}
|
||||
</Civics_XP2>
|
||||
|
||||
<Technologies_XP2>
|
||||
{"".join([f'{tab}<Row TechnologyType="{location.name}" HiddenUntilPrereqComplete="true" RandomPrereqs="false"/>{nl}' for location in techs if world.options.hide_item_names])}
|
||||
</Technologies_XP2>
|
||||
|
||||
</GameInfo>
|
||||
"""
|
||||
|
||||
|
||||
def generate_setup_file(world: 'CivVIWorld') -> str:
|
||||
"""
|
||||
Generates the Lua for the setup file. This sets initial variables and state that affect gameplay around Progressive Eras
|
||||
"""
|
||||
setup = "-- Setup"
|
||||
if world.options.progression_style == "eras_and_districts":
|
||||
setup += f"""
|
||||
-- Init Progressive Era Value if it hasn't been set already
|
||||
if Game.GetProperty("MaxAllowedEra") == nil then
|
||||
print("Setting MaxAllowedEra to 0")
|
||||
Game.SetProperty("MaxAllowedEra", 0)
|
||||
end
|
||||
"""
|
||||
|
||||
if world.options.boostsanity:
|
||||
setup += f"""
|
||||
-- Init Boosts
|
||||
if Game.GetProperty("BoostsAsChecks") == nil then
|
||||
print("Setting Boosts As Checks to True")
|
||||
Game.SetProperty("BoostsAsChecks", true)
|
||||
end
|
||||
"""
|
||||
return setup
|
||||
|
||||
|
||||
def generate_goody_hut_sql(world: 'CivVIWorld') -> str:
|
||||
"""
|
||||
Generates the SQL for the goody huts or an empty string if they are disabled since the mod expects the file to be there
|
||||
"""
|
||||
|
||||
if world.options.shuffle_goody_hut_rewards:
|
||||
return f"""
|
||||
UPDATE GoodyHutSubTypes SET Description = NULL WHERE GoodyHut NOT IN ('METEOR_GOODIES', 'GOODYHUT_SAILOR_WONDROUS', 'DUMMY_GOODY_BUILDIER') AND Weight > 0;
|
||||
|
||||
INSERT INTO Modifiers
|
||||
(ModifierId, ModifierType, RunOnce, Permanent, SubjectRequirementSetId)
|
||||
SELECT ModifierID||'_AI', ModifierType, RunOnce, Permanent, 'PLAYER_IS_AI'
|
||||
FROM Modifiers
|
||||
WHERE EXISTS (
|
||||
SELECT ModifierId
|
||||
FROM GoodyHutSubTypes
|
||||
WHERE Modifiers.ModifierId = GoodyHutSubTypes.ModifierId AND GoodyHutSubTypes.GoodyHut NOT IN ('METEOR_GOODIES', 'GOODYHUT_SAILOR_WONDROUS', 'DUMMY_GOODY_BUILDIER') AND GoodyHutSubTypes.Weight > 0);
|
||||
|
||||
INSERT INTO ModifierArguments
|
||||
(ModifierId, Name, Type, Value)
|
||||
SELECT ModifierID||'_AI', Name, Type, Value
|
||||
FROM ModifierArguments
|
||||
WHERE EXISTS (
|
||||
SELECT ModifierId
|
||||
FROM GoodyHutSubTypes
|
||||
WHERE ModifierArguments.ModifierId = GoodyHutSubTypes.ModifierId AND GoodyHutSubTypes.GoodyHut NOT IN ('METEOR_GOODIES', 'GOODYHUT_SAILOR_WONDROUS', 'DUMMY_GOODY_BUILDIER') AND GoodyHutSubTypes.Weight > 0);
|
||||
|
||||
UPDATE GoodyHutSubTypes
|
||||
SET ModifierID = ModifierID||'_AI'
|
||||
WHERE GoodyHut NOT IN ('METEOR_GOODIES', 'GOODYHUT_SAILOR_WONDROUS', 'DUMMY_GOODY_BUILDIER') AND Weight > 0;
|
||||
|
||||
"""
|
||||
return "-- Goody Huts are disabled, no changes needed"
|
||||
|
||||
|
||||
def generate_update_boosts_sql(world: 'CivVIWorld') -> str:
|
||||
"""
|
||||
Generates the SQL for existing boosts in boostsanity or an empty string if they are disabled since the mod expects the file to be there
|
||||
"""
|
||||
|
||||
if world.options.boostsanity:
|
||||
return f"""
|
||||
UPDATE Boosts
|
||||
SET TechnologyType = 'BOOST_' || TechnologyType
|
||||
WHERE TechnologyType IS NOT NULL;
|
||||
UPDATE Boosts
|
||||
SET CivicType = 'BOOST_' || CivicType
|
||||
WHERE CivicType IS NOT NULL AND CivicType NOT IN ('CIVIC_CORPORATE_LIBERTARIANISM', 'CIVIC_DIGITAL_DEMOCRACY', 'CIVIC_SYNTHETIC_TECHNOCRACY', 'CIVIC_NEAR_FUTURE_GOVERNANCE');
|
||||
"""
|
||||
return "-- Boostsanity is disabled, no changes needed"
|
||||
70
worlds/civ_6/Data.py
Normal file
70
worlds/civ_6/Data.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from typing import Dict, List
|
||||
|
||||
from .ItemData import (
|
||||
CivVIBoostData,
|
||||
CivicPrereqData,
|
||||
ExistingItemData,
|
||||
GoodyHutRewardData,
|
||||
NewItemData,
|
||||
TechPrereqData,
|
||||
)
|
||||
|
||||
|
||||
def get_boosts_data() -> List[CivVIBoostData]:
|
||||
from .data.boosts import boosts
|
||||
|
||||
return boosts
|
||||
|
||||
|
||||
def get_era_required_items_data() -> Dict[str, List[str]]:
|
||||
from .data.era_required_items import era_required_items
|
||||
|
||||
return era_required_items
|
||||
|
||||
|
||||
def get_existing_civics_data() -> List[ExistingItemData]:
|
||||
from .data.existing_civics import existing_civics
|
||||
|
||||
return existing_civics
|
||||
|
||||
|
||||
def get_existing_techs_data() -> List[ExistingItemData]:
|
||||
from .data.existing_tech import existing_tech
|
||||
|
||||
return existing_tech
|
||||
|
||||
|
||||
def get_goody_hut_rewards_data() -> List[GoodyHutRewardData]:
|
||||
from .data.goody_hut_rewards import reward_data
|
||||
|
||||
return reward_data
|
||||
|
||||
|
||||
def get_new_civic_prereqs_data() -> List[CivicPrereqData]:
|
||||
from .data.new_civic_prereqs import new_civic_prereqs
|
||||
|
||||
return new_civic_prereqs
|
||||
|
||||
|
||||
def get_new_civics_data() -> List[NewItemData]:
|
||||
from .data.new_civics import new_civics
|
||||
|
||||
return new_civics
|
||||
|
||||
|
||||
def get_new_tech_prereqs_data() -> List[TechPrereqData]:
|
||||
from .data.new_tech_prereqs import new_tech_prereqs
|
||||
|
||||
return new_tech_prereqs
|
||||
|
||||
|
||||
def get_new_techs_data() -> List[NewItemData]:
|
||||
from .data.new_tech import new_tech
|
||||
|
||||
return new_tech
|
||||
|
||||
|
||||
def get_progressive_districts_data() -> Dict[str, List[str]]:
|
||||
from .data.progressive_districts import progressive_districts
|
||||
|
||||
return progressive_districts
|
||||
74
worlds/civ_6/DeathLink.py
Normal file
74
worlds/civ_6/DeathLink.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import random
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
if TYPE_CHECKING:
|
||||
from .Civ6Client import CivVIContext
|
||||
|
||||
# any is also an option but should not be considered an effect
|
||||
DEATH_LINK_EFFECTS = ["Gold", "Faith", "Era Score", "Unit Killed"]
|
||||
|
||||
|
||||
async def handle_receive_deathlink(ctx: 'CivVIContext', message: str):
|
||||
"""Resolves the effects of a deathlink received from the multiworld based on the options selected by the player"""
|
||||
chosen_effects: List[str] = ctx.slot_data["death_link_effect"]
|
||||
effect = random.choice(chosen_effects)
|
||||
|
||||
percent = ctx.slot_data["death_link_effect_percent"]
|
||||
if effect == "Gold":
|
||||
ctx.logger.info(f"Decreasing gold by {percent}%")
|
||||
await ctx.game_interface.decrease_gold_by_percent(percent, message)
|
||||
elif effect == "Faith":
|
||||
ctx.logger.info(f"Decreasing faith by {percent}%")
|
||||
await ctx.game_interface.decrease_faith_by_percent(percent, message)
|
||||
elif effect == "Era Score":
|
||||
ctx.logger.info("Decreasing era score by 1")
|
||||
await ctx.game_interface.decrease_era_score_by_amount(1, message)
|
||||
elif effect == "Unit Killed":
|
||||
ctx.logger.info("Destroying a random unit")
|
||||
await ctx.game_interface.kill_unit(message)
|
||||
|
||||
|
||||
async def handle_check_deathlink(ctx: 'CivVIContext'):
|
||||
"""Checks if the local player should send out a deathlink to the multiworld as well as if we should respond to any pending deathlinks sent to us """
|
||||
# check if we received a death link
|
||||
if ctx.received_death_link:
|
||||
ctx.received_death_link = False
|
||||
await handle_receive_deathlink(ctx, ctx.death_link_message)
|
||||
|
||||
# Check if we should send out a death link
|
||||
result = await ctx.game_interface.get_deathlink()
|
||||
if ctx.death_link_just_changed:
|
||||
ctx.death_link_just_changed = False
|
||||
return
|
||||
if result != "false":
|
||||
messages = [f"lost a unit to a {result}",
|
||||
f"offered a sacrifice to the great {result}",
|
||||
f"was killed by a {result}",
|
||||
f"made a donation to the {result} fund",
|
||||
f"made a tactical error",
|
||||
f"picked a fight with a {result} and lost",
|
||||
f"tried to befriend an enemy {result}",
|
||||
f"used a {result} to reduce their military spend",
|
||||
f"was defeated by a {result} in combat",
|
||||
f"bravely struck a {result} and paid the price",
|
||||
f"had a lapse in judgement against a {result}",
|
||||
f"learned at the hands of a {result}",
|
||||
f"attempted to non peacefully negotiate with a {result}",
|
||||
f"was outsmarted by a {result}",
|
||||
f"received a lesson from a {result}",
|
||||
f"now understands the importance of not fighting a {result}",
|
||||
f"let a {result} get the better of them",
|
||||
f"allowed a {result} to show them the error of their ways",
|
||||
f"heard the tragedy of Darth Plagueis the Wise from a {result}",
|
||||
f"refused to join a {result} in their quest for power",
|
||||
f"was tired of sitting in BK and decided to fight a {result} instead",
|
||||
f"purposely lost to a {result} as a cry for help",
|
||||
f"is wanting to remind everyone that they are here to have fun and not to win",
|
||||
f"is reconsidering their pursuit of a domination victory",
|
||||
f"had their plans toppled by a {result}",
|
||||
]
|
||||
|
||||
if ctx.slot is not None:
|
||||
player = ctx.player_names[ctx.slot]
|
||||
message = random.choice(messages)
|
||||
await ctx.send_death(f"{player} {message}")
|
||||
39
worlds/civ_6/Enum.py
Normal file
39
worlds/civ_6/Enum.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from enum import Enum
|
||||
|
||||
from BaseClasses import ItemClassification
|
||||
|
||||
|
||||
class EraType(Enum):
|
||||
ERA_ANCIENT = "ERA_ANCIENT"
|
||||
ERA_CLASSICAL = "ERA_CLASSICAL"
|
||||
ERA_MEDIEVAL = "ERA_MEDIEVAL"
|
||||
ERA_RENAISSANCE = "ERA_RENAISSANCE"
|
||||
ERA_INDUSTRIAL = "ERA_INDUSTRIAL"
|
||||
ERA_MODERN = "ERA_MODERN"
|
||||
ERA_ATOMIC = "ERA_ATOMIC"
|
||||
ERA_INFORMATION = "ERA_INFORMATION"
|
||||
ERA_FUTURE = "ERA_FUTURE"
|
||||
|
||||
|
||||
class CivVICheckType(Enum):
|
||||
TECH = "TECH"
|
||||
CIVIC = "CIVIC"
|
||||
PROGRESSIVE_DISTRICT = "PROGRESSIVE_DISTRICT"
|
||||
ERA = "ERA"
|
||||
GOODY = "GOODY"
|
||||
BOOST = "BOOST"
|
||||
EVENT = "EVENT"
|
||||
|
||||
class CivVIHintClassification(Enum):
|
||||
PROGRESSION = "Progression"
|
||||
USEFUL = "Useful"
|
||||
FILLER = "Filler"
|
||||
|
||||
def to_item_classification(self) -> ItemClassification:
|
||||
if self == CivVIHintClassification.PROGRESSION:
|
||||
return ItemClassification.progression
|
||||
if self == CivVIHintClassification.USEFUL:
|
||||
return ItemClassification.useful
|
||||
if self == CivVIHintClassification.FILLER:
|
||||
return ItemClassification.filler
|
||||
assert False
|
||||
38
worlds/civ_6/ItemData.py
Normal file
38
worlds/civ_6/ItemData.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, TypedDict
|
||||
|
||||
|
||||
class NewItemData(TypedDict):
|
||||
Type: str
|
||||
Cost: int
|
||||
UITreeRow: int
|
||||
EraType: str
|
||||
|
||||
|
||||
class ExistingItemData(NewItemData):
|
||||
Name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CivVIBoostData:
|
||||
Type: str
|
||||
EraType: str
|
||||
Prereq: List[str]
|
||||
PrereqRequiredCount: int
|
||||
Classification: str
|
||||
|
||||
|
||||
class GoodyHutRewardData(TypedDict):
|
||||
Type: str
|
||||
Name: str
|
||||
Rarity: str
|
||||
|
||||
|
||||
class CivicPrereqData(TypedDict):
|
||||
Civic: str
|
||||
PrereqTech: str
|
||||
|
||||
|
||||
class TechPrereqData(TypedDict):
|
||||
Technology: str
|
||||
PrereqTech: str
|
||||
353
worlds/civ_6/Items.py
Normal file
353
worlds/civ_6/Items.py
Normal file
@@ -0,0 +1,353 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, TYPE_CHECKING, List
|
||||
from BaseClasses import Item, ItemClassification
|
||||
from .Data import (
|
||||
GoodyHutRewardData,
|
||||
get_era_required_items_data,
|
||||
get_existing_civics_data,
|
||||
get_existing_techs_data,
|
||||
get_goody_hut_rewards_data,
|
||||
get_progressive_districts_data,
|
||||
)
|
||||
from .Enum import CivVICheckType, EraType
|
||||
from .ProgressiveDistricts import get_flat_progressive_districts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import CivVIWorld
|
||||
|
||||
|
||||
CIV_VI_AP_ITEM_ID_BASE = 5041000
|
||||
|
||||
NON_PROGRESSION_DISTRICTS = ["PROGRESSIVE_PRESERVE", "PROGRESSIVE_NEIGHBORHOOD"]
|
||||
|
||||
|
||||
# Items required as progression for boostsanity mode
|
||||
BOOSTSANITY_PROGRESSION_ITEMS = [
|
||||
"TECH_THE_WHEEL",
|
||||
"TECH_MASONRY",
|
||||
"TECH_ARCHERY",
|
||||
"TECH_ENGINEERING",
|
||||
"TECH_CONSTRUCTION",
|
||||
"TECH_GUNPOWDER",
|
||||
"TECH_MACHINERY",
|
||||
"TECH_SIEGE_TACTICS",
|
||||
"TECH_STIRRUPS",
|
||||
"TECH_ASTRONOMY",
|
||||
"TECH_BALLISTICS",
|
||||
"TECH_STEAM_POWER",
|
||||
"TECH_SANITATION",
|
||||
"TECH_COMPUTERS",
|
||||
"TECH_COMBUSTION",
|
||||
"TECH_TELECOMMUNICATIONS",
|
||||
"TECH_ROBOTICS",
|
||||
"CIVIC_FEUDALISM",
|
||||
"CIVIC_GUILDS",
|
||||
"CIVIC_THE_ENLIGHTENMENT",
|
||||
"CIVIC_MERCANTILISM",
|
||||
"CIVIC_CONSERVATION",
|
||||
"CIVIC_CIVIL_SERVICE",
|
||||
"CIVIC_GLOBALIZATION",
|
||||
"CIVIC_COLD_WAR",
|
||||
"CIVIC_URBANIZATION",
|
||||
"CIVIC_NATIONALISM",
|
||||
"CIVIC_MOBILIZATION",
|
||||
"PROGRESSIVE_NEIGHBORHOOD",
|
||||
"PROGRESSIVE_PRESERVE",
|
||||
]
|
||||
|
||||
|
||||
class FillerItemRarity(Enum):
|
||||
COMMON = "COMMON"
|
||||
UNCOMMON = "UNCOMMON"
|
||||
RARE = "RARE"
|
||||
|
||||
|
||||
FILLER_DISTRIBUTION: Dict[FillerItemRarity, float] = {
|
||||
FillerItemRarity.RARE: 0.025,
|
||||
FillerItemRarity.UNCOMMON: 0.2,
|
||||
FillerItemRarity.COMMON: 0.775,
|
||||
}
|
||||
|
||||
|
||||
class FillerItemData:
|
||||
name: str
|
||||
type: str
|
||||
rarity: FillerItemRarity
|
||||
civ_name: str
|
||||
|
||||
def __init__(self, data: GoodyHutRewardData):
|
||||
self.name = data["Name"]
|
||||
self.rarity = FillerItemRarity(data["Rarity"])
|
||||
self.civ_name = data["Type"]
|
||||
|
||||
|
||||
filler_data: Dict[str, FillerItemData] = {
|
||||
item["Name"]: FillerItemData(item) for item in get_goody_hut_rewards_data()
|
||||
}
|
||||
|
||||
|
||||
class CivVIItemData:
|
||||
civ_vi_id: int
|
||||
classification: ItemClassification
|
||||
name: str
|
||||
code: int
|
||||
cost: int
|
||||
item_type: CivVICheckType
|
||||
progressive_name: Optional[str]
|
||||
civ_name: Optional[str]
|
||||
era: Optional[EraType]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
civ_vi_id: int,
|
||||
cost: int,
|
||||
item_type: CivVICheckType,
|
||||
id_offset: int,
|
||||
classification: ItemClassification,
|
||||
progressive_name: Optional[str],
|
||||
civ_name: Optional[str] = None,
|
||||
era: Optional[EraType] = None,
|
||||
):
|
||||
self.classification = classification
|
||||
self.civ_vi_id = civ_vi_id
|
||||
self.name = name
|
||||
self.code = civ_vi_id + CIV_VI_AP_ITEM_ID_BASE + id_offset
|
||||
self.cost = cost
|
||||
self.item_type = item_type
|
||||
self.progressive_name = progressive_name
|
||||
self.civ_name = civ_name
|
||||
self.era = era
|
||||
|
||||
|
||||
class CivVIEvent(Item):
|
||||
game: str = "Civilization VI"
|
||||
|
||||
|
||||
class CivVIItem(Item):
|
||||
game: str = "Civilization VI"
|
||||
civ_vi_id: int
|
||||
item_type: CivVICheckType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
item: CivVIItemData,
|
||||
player: int,
|
||||
classification: Optional[ItemClassification] = None,
|
||||
):
|
||||
super().__init__(
|
||||
item.name, classification or item.classification, item.code, player
|
||||
)
|
||||
self.civ_vi_id = item.civ_vi_id
|
||||
self.item_type = item.item_type
|
||||
|
||||
|
||||
def format_item_name(name: str) -> str:
|
||||
name_parts = name.split("_")
|
||||
return " ".join([part.capitalize() for part in name_parts])
|
||||
|
||||
|
||||
_items_by_civ_name: Dict[str, CivVIItemData] = {}
|
||||
|
||||
|
||||
def get_item_by_civ_name(
|
||||
item_name: str, item_table: Dict[str, "CivVIItemData"]
|
||||
) -> "CivVIItemData":
|
||||
"""Gets the names of the items in the item_table"""
|
||||
if not _items_by_civ_name:
|
||||
for item in item_table.values():
|
||||
if item.civ_name:
|
||||
_items_by_civ_name[item.civ_name] = item
|
||||
|
||||
try:
|
||||
return _items_by_civ_name[item_name]
|
||||
except KeyError as e:
|
||||
raise KeyError(f"Item {item_name} not found in item_table") from e
|
||||
|
||||
|
||||
def _generate_tech_items(
|
||||
id_base: int, required_items: List[str], progressive_items: Dict[str, str]
|
||||
) -> Dict[str, CivVIItemData]:
|
||||
# Generate Techs
|
||||
existing_techs = get_existing_techs_data()
|
||||
tech_table: Dict[str, CivVIItemData] = {}
|
||||
|
||||
tech_id = 0
|
||||
for tech in existing_techs:
|
||||
classification = ItemClassification.useful
|
||||
name = tech["Name"]
|
||||
civ_name = tech["Type"]
|
||||
if civ_name in required_items:
|
||||
classification = ItemClassification.progression
|
||||
progressive_name = None
|
||||
check_type = CivVICheckType.TECH
|
||||
if civ_name in progressive_items.keys():
|
||||
progressive_name = format_item_name(progressive_items[civ_name])
|
||||
|
||||
tech_table[name] = CivVIItemData(
|
||||
name=name,
|
||||
civ_vi_id=tech_id,
|
||||
cost=tech["Cost"],
|
||||
item_type=check_type,
|
||||
id_offset=id_base,
|
||||
classification=classification,
|
||||
progressive_name=progressive_name,
|
||||
civ_name=civ_name,
|
||||
era=EraType(tech["EraType"]),
|
||||
)
|
||||
|
||||
tech_id += 1
|
||||
|
||||
return tech_table
|
||||
|
||||
|
||||
def _generate_civics_items(
|
||||
id_base: int, required_items: List[str], progressive_items: Dict[str, str]
|
||||
) -> Dict[str, CivVIItemData]:
|
||||
civic_id = 0
|
||||
civic_table: Dict[str, CivVIItemData] = {}
|
||||
existing_civics = get_existing_civics_data()
|
||||
|
||||
for civic in existing_civics:
|
||||
name = civic["Name"]
|
||||
civ_name = civic["Type"]
|
||||
progressive_name = None
|
||||
check_type = CivVICheckType.CIVIC
|
||||
|
||||
if civ_name in progressive_items.keys():
|
||||
progressive_name = format_item_name(progressive_items[civ_name])
|
||||
|
||||
classification = ItemClassification.useful
|
||||
if civ_name in required_items:
|
||||
classification = ItemClassification.progression
|
||||
|
||||
civic_table[name] = CivVIItemData(
|
||||
name=name,
|
||||
civ_vi_id=civic_id,
|
||||
cost=civic["Cost"],
|
||||
item_type=check_type,
|
||||
id_offset=id_base,
|
||||
classification=classification,
|
||||
progressive_name=progressive_name,
|
||||
civ_name=civ_name,
|
||||
era=EraType(civic["EraType"]),
|
||||
)
|
||||
|
||||
civic_id += 1
|
||||
|
||||
return civic_table
|
||||
|
||||
|
||||
def _generate_progressive_district_items(id_base: int) -> Dict[str, CivVIItemData]:
|
||||
progressive_table: Dict[str, CivVIItemData] = {}
|
||||
progressive_id_base = 0
|
||||
progressive_items = get_progressive_districts_data()
|
||||
for item_name in progressive_items.keys():
|
||||
classification = (
|
||||
ItemClassification.useful
|
||||
if item_name in NON_PROGRESSION_DISTRICTS
|
||||
else ItemClassification.progression
|
||||
)
|
||||
name = format_item_name(item_name)
|
||||
progressive_table[name] = CivVIItemData(
|
||||
name=name,
|
||||
civ_vi_id=progressive_id_base,
|
||||
cost=0,
|
||||
item_type=CivVICheckType.PROGRESSIVE_DISTRICT,
|
||||
id_offset=id_base,
|
||||
classification=classification,
|
||||
progressive_name=None,
|
||||
civ_name=item_name,
|
||||
)
|
||||
progressive_id_base += 1
|
||||
return progressive_table
|
||||
|
||||
|
||||
def _generate_progressive_era_items(id_base: int) -> Dict[str, CivVIItemData]:
|
||||
"""Generates the single progressive district item"""
|
||||
era_table: Dict[str, CivVIItemData] = {}
|
||||
# Generate progressive eras
|
||||
progressive_era_name = format_item_name("PROGRESSIVE_ERA")
|
||||
era_table[progressive_era_name] = CivVIItemData(
|
||||
name=progressive_era_name,
|
||||
civ_vi_id=0,
|
||||
cost=0,
|
||||
item_type=CivVICheckType.ERA,
|
||||
id_offset=id_base,
|
||||
classification=ItemClassification.progression,
|
||||
progressive_name=None,
|
||||
civ_name="PROGRESSIVE_ERA",
|
||||
)
|
||||
return era_table
|
||||
|
||||
|
||||
def _generate_goody_hut_items(id_base: int) -> Dict[str, CivVIItemData]:
|
||||
# Generate goody hut items
|
||||
goody_huts = {
|
||||
item["Name"]: FillerItemData(item) for item in get_goody_hut_rewards_data()
|
||||
}
|
||||
goody_table: Dict[str, CivVIItemData] = {}
|
||||
goody_base = 0
|
||||
for value in goody_huts.values():
|
||||
goody_table[value.name] = CivVIItemData(
|
||||
name=value.name,
|
||||
civ_vi_id=goody_base,
|
||||
cost=0,
|
||||
item_type=CivVICheckType.GOODY,
|
||||
id_offset=id_base,
|
||||
classification=ItemClassification.filler,
|
||||
progressive_name=None,
|
||||
civ_name=value.civ_name,
|
||||
)
|
||||
goody_base += 1
|
||||
return goody_table
|
||||
|
||||
|
||||
def generate_item_table() -> Dict[str, CivVIItemData]:
|
||||
era_required_items = get_era_required_items_data()
|
||||
required_items: List[str] = []
|
||||
for value in era_required_items.values():
|
||||
required_items += value
|
||||
|
||||
progressive_items = get_flat_progressive_districts()
|
||||
|
||||
item_table: Dict[str, CivVIItemData] = {}
|
||||
|
||||
def get_id_base():
|
||||
return len(item_table.keys())
|
||||
|
||||
item_table.update(
|
||||
**_generate_tech_items(get_id_base(), required_items, progressive_items)
|
||||
)
|
||||
item_table.update(
|
||||
**_generate_civics_items(get_id_base(), required_items, progressive_items)
|
||||
)
|
||||
item_table.update(**_generate_progressive_district_items(get_id_base()))
|
||||
item_table.update(**_generate_progressive_era_items(get_id_base()))
|
||||
item_table.update(**_generate_goody_hut_items(get_id_base()))
|
||||
|
||||
return item_table
|
||||
|
||||
|
||||
def get_items_by_type(
|
||||
item_type: CivVICheckType, item_table: Dict[str, CivVIItemData]
|
||||
) -> List[CivVIItemData]:
|
||||
"""
|
||||
Returns a list of items that match the given item type
|
||||
"""
|
||||
return [item for item in item_table.values() if item.item_type == item_type]
|
||||
|
||||
|
||||
fillers_by_rarity: Dict[FillerItemRarity, List[FillerItemData]] = {
|
||||
rarity: [item for item in filler_data.values() if item.rarity == rarity]
|
||||
for rarity in FillerItemRarity
|
||||
}
|
||||
|
||||
|
||||
def get_random_filler_by_rarity(
|
||||
world: "CivVIWorld", rarity: FillerItemRarity
|
||||
) -> FillerItemData:
|
||||
"""
|
||||
Returns a random filler item by rarity
|
||||
"""
|
||||
return world.random.choice(fillers_by_rarity[rarity])
|
||||
21
worlds/civ_6/LICENSE.md
Normal file
21
worlds/civ_6/LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright © 2024 tanjo3
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
156
worlds/civ_6/Locations.py
Normal file
156
worlds/civ_6/Locations.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict
|
||||
from BaseClasses import Location, Region
|
||||
|
||||
from .Data import get_boosts_data, get_new_civics_data, get_new_techs_data
|
||||
|
||||
from .Enum import CivVICheckType, EraType
|
||||
|
||||
CIV_VI_AP_LOCATION_ID_BASE = 5041000
|
||||
|
||||
# Locs that should not have progression items
|
||||
GOODY_HUT_LOCATION_NAMES = [
|
||||
"GOODY_HUT_1",
|
||||
"GOODY_HUT_2",
|
||||
"GOODY_HUT_3",
|
||||
"GOODY_HUT_4",
|
||||
"GOODY_HUT_5",
|
||||
"GOODY_HUT_6",
|
||||
"GOODY_HUT_7",
|
||||
"GOODY_HUT_8",
|
||||
"GOODY_HUT_9",
|
||||
"GOODY_HUT_10",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CivVILocationData:
|
||||
name: str
|
||||
cost: int
|
||||
uiTreeRow: int
|
||||
civ_id: int
|
||||
era_type: str
|
||||
location_type: CivVICheckType
|
||||
|
||||
game: str = "Civilization VI"
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return self.civ_id + CIV_VI_AP_LOCATION_ID_BASE
|
||||
|
||||
|
||||
class CivVILocation(Location):
|
||||
game: str = "Civilization VI"
|
||||
location_type: CivVICheckType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
player: int,
|
||||
name: str = "",
|
||||
address: Optional[int] = None,
|
||||
parent: Optional[Region] = None,
|
||||
):
|
||||
super().__init__(player, name, address, parent)
|
||||
category = name.split("_")[0]
|
||||
if "victory" in category:
|
||||
self.location_type = CivVICheckType.EVENT
|
||||
else:
|
||||
self.location_type = CivVICheckType(category)
|
||||
|
||||
|
||||
def generate_flat_location_table() -> Dict[str, CivVILocationData]:
|
||||
"""
|
||||
Generates a flat location table in the following format:
|
||||
{
|
||||
"TECH_AP_ANCIENT_00": CivVILocationData,
|
||||
"TECH_AP_ANCIENT_01": CivVILocationData,
|
||||
"CIVIC_AP_ANCIENT_00": CivVILocationData,
|
||||
...
|
||||
}
|
||||
"""
|
||||
era_locations = generate_era_location_table()
|
||||
flat_locations: Dict[str, CivVILocationData] = {}
|
||||
for locations in era_locations.values():
|
||||
for location_id, location_data in locations.items():
|
||||
flat_locations[location_id] = location_data
|
||||
return flat_locations
|
||||
|
||||
|
||||
def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
|
||||
"""
|
||||
Uses the data from existing_tech.json to generate a location table in the following format:
|
||||
{
|
||||
"ERA_ANCIENT": {
|
||||
"TECH_AP_ANCIENT_00": CivVILocationData,
|
||||
"TECH_AP_ANCIENT_01": CivVILocationData,
|
||||
"CIVIC_AP_ANCIENT_00": CivVILocationData,
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
new_techs = get_new_techs_data()
|
||||
era_locations: Dict[str, Dict[str, CivVILocationData]] = defaultdict(dict)
|
||||
id_base = 0
|
||||
# Techs
|
||||
for data in new_techs:
|
||||
era_type = data["EraType"]
|
||||
era_locations[era_type][data["Type"]] = CivVILocationData(
|
||||
data["Type"],
|
||||
data["Cost"],
|
||||
data["UITreeRow"],
|
||||
id_base,
|
||||
era_type,
|
||||
CivVICheckType.TECH,
|
||||
)
|
||||
id_base += 1
|
||||
# Civics
|
||||
new_civics = get_new_civics_data()
|
||||
|
||||
for data in new_civics:
|
||||
era_type = data["EraType"]
|
||||
era_locations[era_type][data["Type"]] = CivVILocationData(
|
||||
data["Type"],
|
||||
data["Cost"],
|
||||
data["UITreeRow"],
|
||||
id_base,
|
||||
era_type,
|
||||
CivVICheckType.CIVIC,
|
||||
)
|
||||
id_base += 1
|
||||
|
||||
# Eras
|
||||
for era in EraType:
|
||||
|
||||
if era == EraType.ERA_ANCIENT:
|
||||
continue
|
||||
|
||||
era_locations[era.name][era.name] = CivVILocationData(
|
||||
era.name, 0, 0, id_base, era.name, CivVICheckType.ERA
|
||||
)
|
||||
id_base += 1
|
||||
|
||||
# Goody Huts, defaults to 10 goody huts as location checks (rarely will a player get more than this)
|
||||
for i in range(10):
|
||||
era_locations[EraType.ERA_ANCIENT.value]["GOODY_HUT_" + str(i + 1)] = (
|
||||
CivVILocationData(
|
||||
"GOODY_HUT_" + str(i + 1),
|
||||
0,
|
||||
0,
|
||||
id_base,
|
||||
EraType.ERA_ANCIENT.value,
|
||||
CivVICheckType.GOODY,
|
||||
)
|
||||
)
|
||||
id_base += 1
|
||||
# Boosts
|
||||
boosts = get_boosts_data()
|
||||
for boost in boosts:
|
||||
location = CivVILocationData(
|
||||
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
|
||||
)
|
||||
era_locations["ERA_ANCIENT"][boost.Type] = location
|
||||
id_base += 1
|
||||
|
||||
return era_locations
|
||||
130
worlds/civ_6/Options.py
Normal file
130
worlds/civ_6/Options.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import (
|
||||
Choice,
|
||||
DefaultOnToggle,
|
||||
OptionSet,
|
||||
PerGameCommonOptions,
|
||||
Range,
|
||||
StartInventoryPool,
|
||||
Toggle,
|
||||
)
|
||||
from .Enum import CivVIHintClassification
|
||||
|
||||
|
||||
class ProgressionStyle(Choice):
|
||||
"""
|
||||
**Districts Only**: Each tech/civic that would normally unlock a district or building now has a logical progression.
|
||||
Example: TECH_BRONZE_WORKING is now PROGRESSIVE_ENCAMPMENT
|
||||
|
||||
**Eras and Districts**: Players will be defeated if they play until the world era advances beyond the currently unlocked maximum era.
|
||||
Unlocked eras can be seen in both the tech and civic trees. Includes all progressive districts.
|
||||
|
||||
**None**: No progressive items will be included. This means you can get district upgrades that won't be usable until the relevant district is unlocked.
|
||||
"""
|
||||
|
||||
rich_text_doc = True
|
||||
display_name = "Progression Style"
|
||||
option_districts_only = 0
|
||||
option_eras_and_districts = 1
|
||||
option_none = 2
|
||||
default = option_districts_only
|
||||
|
||||
|
||||
class ShuffleGoodyHuts(DefaultOnToggle):
|
||||
"""Shuffles the goody hut rewards.
|
||||
Goody huts will only contain junk items and locations are checked sequentially (First goody hut gives GOODY_HUT_1, second gives GOODY_HUT_2, etc.).
|
||||
"""
|
||||
|
||||
display_name = "Shuffle Goody Hut Rewards"
|
||||
|
||||
|
||||
class BoostSanity(Toggle):
|
||||
"""Boosts for Civics/Techs are location checks. Boosts can now be triggered even if the item has already been
|
||||
researched.
|
||||
|
||||
**Note**: If a boost is dependent upon a unit that is now obsolete, you can click to toggle on/off the relevant tech in
|
||||
the tech tree."""
|
||||
|
||||
rich_text_doc = True
|
||||
display_name = "Boostsanity"
|
||||
|
||||
|
||||
class ResearchCostMultiplier(Range):
|
||||
"""Multiplier for research cost of techs and civics, higher values make research more expensive."""
|
||||
|
||||
display_name = "Tech/Civic Cost Multiplier"
|
||||
range_start = 50
|
||||
range_end = 150
|
||||
default = 100
|
||||
|
||||
|
||||
class PreHintItems(OptionSet):
|
||||
"""Controls what items from the tech/civics trees are pre-hinted for the multiworld.
|
||||
**Progression**: Include Progression items in hints
|
||||
**Useful**: Include Useful items in hints
|
||||
**Filler**: Include Filler items in hints
|
||||
"""
|
||||
|
||||
display_name = "Tech/Civic Tree pre-hinted Items"
|
||||
valid_keys = {classification.value for classification in CivVIHintClassification} # type: ignore
|
||||
|
||||
|
||||
class HideItemNames(Toggle):
|
||||
"""Each Tech and Civic Location will have a title of 'Unrevealed' until its prereqs have been researched. Note that
|
||||
hints will still be precollected if that option is enabled."""
|
||||
|
||||
display_name = "Hide Item Names"
|
||||
|
||||
|
||||
class InGameFlagProgressionItems(DefaultOnToggle):
|
||||
"""If enabled, an advisor icon will be added to any location that contains a progression item."""
|
||||
|
||||
display_name = "Advisor Indicates Progression Items"
|
||||
|
||||
|
||||
class CivDeathLink(Toggle):
|
||||
"""If enabled, losing a unit will trigger a death link effect on other players in the multiworld. When a death link is received, the player will receive the effect specified in 'Death Link Effect'."""
|
||||
|
||||
display_name = "Death Link"
|
||||
|
||||
|
||||
class DeathLinkEffect(OptionSet):
|
||||
"""What happens when a unit dies.
|
||||
|
||||
**Unit Killed**: A random unit will be killed when a death link is received.
|
||||
|
||||
**Faith**: Faith will be decreased by the amount specified in 'Death Link Effect Percent'.
|
||||
|
||||
**Gold**: Gold will be decreased by the amount specified in 'Death Link Effect Percent'.
|
||||
|
||||
**Era Score**: Era score is decreased by 1.
|
||||
"""
|
||||
|
||||
rich_text_doc = True
|
||||
display_name = "Death Link Effect"
|
||||
valid_keys = ["Unit Killed", "Faith", "Gold", "Era Score"] # type: ignore
|
||||
default = frozenset({"Unit Killed"})
|
||||
|
||||
|
||||
class DeathLinkEffectPercent(Range):
|
||||
"""The percentage of the effect that will be applied. Only applicable for Gold and Faith effects."""
|
||||
|
||||
display_name = "Death Link Effect Percent"
|
||||
default = 20
|
||||
range_start = 1
|
||||
range_end = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class CivVIOptions(PerGameCommonOptions):
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
progression_style: ProgressionStyle
|
||||
shuffle_goody_hut_rewards: ShuffleGoodyHuts
|
||||
boostsanity: BoostSanity
|
||||
research_cost_multiplier: ResearchCostMultiplier
|
||||
pre_hint_items: PreHintItems
|
||||
hide_item_names: HideItemNames
|
||||
advisor_show_progression_items: InGameFlagProgressionItems
|
||||
death_link: CivDeathLink
|
||||
death_link_effect: DeathLinkEffect
|
||||
death_link_effect_percent: DeathLinkEffectPercent
|
||||
35
worlds/civ_6/ProgressiveDistricts.py
Normal file
35
worlds/civ_6/ProgressiveDistricts.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .Data import get_progressive_districts_data
|
||||
|
||||
_flat_progressive_districts: Optional[Dict[str, str]] = {}
|
||||
|
||||
|
||||
def get_flat_progressive_districts() -> Dict[str, str]:
|
||||
"""Returns a dictionary of all items that are associated with a progressive item.
|
||||
Key is the item name ("TECH_WRITING") and the value is the associated progressive
|
||||
item ("PROGRESSIVE_CAMPUS")"""
|
||||
if _flat_progressive_districts:
|
||||
return _flat_progressive_districts
|
||||
|
||||
progressive_districts = get_progressive_districts_data()
|
||||
flat_progressive_districts: Dict[str, str] = {}
|
||||
for key, value in progressive_districts.items():
|
||||
for item in value:
|
||||
flat_progressive_districts[item] = key
|
||||
return flat_progressive_districts
|
||||
|
||||
|
||||
def convert_items_to_progressive_items(items: List[str]):
|
||||
"""converts a list of items to instead be their associated progressive item if
|
||||
they have one. ["TECH_MINING", "TECH_WRITING"] -> ["TECH_MINING", "PROGRESSIVE_CAMPUS]
|
||||
"""
|
||||
flat_progressive_districts = get_flat_progressive_districts()
|
||||
return [flat_progressive_districts.get(item, item) for item in items]
|
||||
|
||||
|
||||
def convert_item_to_progressive_item(item: str):
|
||||
"""converts an items to instead be its associated progressive item if
|
||||
it has one. "TECH_WRITING" -> "PROGRESSIVE_CAMPUS"""
|
||||
flat_progressive_districts = get_flat_progressive_districts()
|
||||
return flat_progressive_districts.get(item, item)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user