Merge branch 'ArchipelagoMW:main' into main

This commit is contained in:
Adrian Priestley
2025-01-25 15:06:44 +00:00
committed by GitHub
80 changed files with 995 additions and 658 deletions

View File

@@ -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": [

View File

@@ -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"

View File

@@ -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()
@@ -1041,6 +1049,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
@@ -1082,17 +1116,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()

View File

@@ -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

View File

@@ -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

View File

@@ -560,6 +560,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()

View File

@@ -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.

View File

@@ -1060,21 +1060,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)
@@ -1992,6 +2008,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"])

View File

@@ -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()):

View File

@@ -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

View File

@@ -30,4 +30,4 @@ def get_seeds():
"creation_time": seed.creation_time,
"players": get_players(seed.slots),
})
return jsonify(response)
return jsonify(response)

View File

@@ -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

View File

@@ -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

View File

@@ -261,6 +261,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.

View File

@@ -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:
@@ -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)`
@@ -835,14 +840,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]:

View File

@@ -678,6 +678,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):

View File

@@ -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):

View File

@@ -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(

View 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)

View File

@@ -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])

View File

@@ -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):

View File

@@ -67,7 +67,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 +84,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):

View File

@@ -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")

View File

@@ -5,7 +5,7 @@ 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

View File

@@ -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):

View File

@@ -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": {

View File

@@ -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.

View File

@@ -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]]:

View File

@@ -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

View File

@@ -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":
@@ -180,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]
@@ -233,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.
@@ -262,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"]:
@@ -276,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
@@ -288,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
@@ -326,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

View File

@@ -5,7 +5,7 @@ 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
@@ -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

View File

@@ -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
@@ -43,15 +43,15 @@ class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
auth_status: AuthStatus
password_requested: bool
client_handler: Optional[BizHawkClient]
slot_data: Optional[Dict[str, Any]] = None
rom_hash: Optional[str] = None
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
@@ -241,7 +241,7 @@ def _patch_and_run_game(patch_file: str):
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")

View File

@@ -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.

View File

@@ -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

View File

@@ -1125,7 +1125,7 @@ def set_trock_key_rules(world, player):
for entrance in ['Turtle Rock Dark Room Staircase', 'Turtle Rock (Chain Chomp Room) (North)', 'Turtle Rock (Chain Chomp Room) (South)', 'Turtle Rock Entrance to Pokey Room', 'Turtle Rock (Pokey Room) (South)', 'Turtle Rock (Pokey Room) (North)', 'Turtle Rock Big Key Door']:
set_rule(world.get_entrance(entrance, player), lambda state: False)
all_state = world.get_all_state(use_cache=False)
all_state = world.get_all_state(use_cache=False, allow_partial_entrances=True)
all_state.reachable_regions[player] = set() # wipe reachable regions so that the locked doors actually work
all_state.stale[player] = True

View File

@@ -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.
![Captura de pantalla del ajuste Comandos de red](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
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.

View File

@@ -234,8 +234,7 @@ async def game_watcher(ctx: FactorioContext):
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
else:
data = data["info"]
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
research_data: set[int] = {int(tech_name.split("-")[1]) for tech_name in data["research_done"]}
victory = data["victory"]
await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
@@ -249,7 +248,7 @@ async def game_watcher(ctx: FactorioContext):
f"New researches done: "
f"{[ctx.location_names.lookup_in_game(rid) for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
await ctx.check_locations(research_data)
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick

View File

@@ -280,9 +280,6 @@ class Factorio(World):
self.get_location("Rocket Launch").access_rule = lambda state: all(state.has(technology, player)
for technology in
victory_tech_names)
for tech_name in victory_tech_names:
if not self.multiworld.get_all_state(True).has(tech_name, player):
print(tech_name)
self.multiworld.completion_condition[player] = lambda state: state.has('Victory', player)
def get_recipe(self, name: str) -> Recipe:

View File

@@ -1,4 +1,4 @@
from Options import Choice, FreeText, Toggle, Range, PerGameCommonOptions
from Options import Choice, FreeText, ItemsAccessibility, Toggle, Range, PerGameCommonOptions
from dataclasses import dataclass
@@ -324,6 +324,7 @@ class KaelisMomFightsMinotaur(Toggle):
@dataclass
class FFMQOptions(PerGameCommonOptions):
accessibility: ItemsAccessibility
logic: Logic
brown_boxes: BrownBoxes
sky_coin_mode: SkyCoinMode

View File

@@ -152,14 +152,23 @@ class FFMQWorld(World):
return FFMQItem(name, self.player)
def collect_item(self, state, item, remove=False):
if not item.advancement:
return None
if "Progressive" in item.name:
i = item.code - 256
if remove:
if state.has(self.item_id_to_name[i+1], self.player):
if state.has(self.item_id_to_name[i+2], self.player):
return self.item_id_to_name[i+2]
return self.item_id_to_name[i+1]
return self.item_id_to_name[i]
if state.has(self.item_id_to_name[i], self.player):
if state.has(self.item_id_to_name[i+1], self.player):
return self.item_id_to_name[i+2]
return self.item_id_to_name[i+1]
return self.item_id_to_name[i]
return item.name if item.advancement else None
return item.name
def modify_multidata(self, multidata):
# wait for self.rom_name to be available.

View File

@@ -333,7 +333,7 @@ class PlandoCharmCosts(OptionDict):
continue
try:
self.value[key] = CharmCost.from_any(data).value
except ValueError as ex:
except ValueError:
# will fail schema afterwords
self.value[key] = data

View File

@@ -7,22 +7,22 @@ import itertools
import operator
from collections import defaultdict, Counter
logger = logging.getLogger("Hollow Knight")
from .Items import item_table, lookup_type_to_names, item_name_groups
from .Regions import create_regions
from .Items import item_table, item_name_groups
from .Rules import set_rules, cost_terms, _hk_can_beat_thk, _hk_siblings_ending, _hk_can_beat_radiance
from .Options import hollow_knight_options, hollow_knight_randomize_options, Goal, WhitePalace, CostSanity, \
shop_to_option, HKOptions, GrubHuntGoal
from .ExtractedData import locations, starts, multi_locations, location_to_region_lookup, \
event_names, item_effects, connectors, one_ways, vanilla_shop_costs, vanilla_location_costs
from .ExtractedData import locations, starts, multi_locations, event_names, item_effects, connectors, \
vanilla_shop_costs, vanilla_location_costs
from .Charms import names as charm_names
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, CollectionState
from BaseClasses import Region, Location, MultiWorld, Item, LocationProgressType, Tutorial, ItemClassification, \
CollectionState
from worlds.AutoWorld import World, LogicMixin, WebWorld
from settings import Group, Bool
logger = logging.getLogger("Hollow Knight")
class HollowKnightSettings(Group):
class DisableMapModSpoilers(Bool):
@@ -160,7 +160,7 @@ class HKWeb(WebWorld):
class HKWorld(World):
"""Beneath the fading town of Dirtmouth sleeps a vast, ancient kingdom. Many are drawn beneath the surface,
"""Beneath the fading town of Dirtmouth sleeps a vast, ancient kingdom. Many are drawn beneath the surface,
searching for riches, or glory, or answers to old secrets.
As the enigmatic Knight, youll traverse the depths, unravel its mysteries and conquer its evils.
@@ -209,7 +209,7 @@ class HKWorld(World):
# defaulting so completion condition isn't incorrect before pre_fill
self.grub_count = (
46 if options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
else options.GrubHuntGoal
else options.GrubHuntGoal.value
)
self.grub_player_count = {self.player: self.grub_count}
@@ -231,7 +231,6 @@ class HKWorld(World):
def create_regions(self):
menu_region: Region = create_region(self.multiworld, self.player, 'Menu')
self.multiworld.regions.append(menu_region)
# wp_exclusions = self.white_palace_exclusions()
# check for any goal that godhome events are relevant to
all_event_names = event_names.copy()
@@ -241,21 +240,17 @@ class HKWorld(World):
# Link regions
for event_name in sorted(all_event_names):
#if event_name in wp_exclusions:
# continue
loc = HKLocation(self.player, event_name, None, menu_region)
loc.place_locked_item(HKItem(event_name,
True, #event_name not in wp_exclusions,
True,
None, "Event", self.player))
menu_region.locations.append(loc)
for entry_transition, exit_transition in connectors.items():
#if entry_transition in wp_exclusions:
# continue
if exit_transition:
# if door logic fulfilled -> award vanilla target as event
loc = HKLocation(self.player, entry_transition, None, menu_region)
loc.place_locked_item(HKItem(exit_transition,
True, #exit_transition not in wp_exclusions,
True,
None, "Event", self.player))
menu_region.locations.append(loc)
@@ -292,7 +287,10 @@ class HKWorld(World):
if item_name in junk_replace:
item_name = self.get_filler_item_name()
item = self.create_item(item_name) if not vanilla or location_name == "Start" or self.options.AddUnshuffledLocations else self.create_event(item_name)
item = (self.create_item(item_name)
if not vanilla or location_name == "Start" or self.options.AddUnshuffledLocations
else self.create_event(item_name)
)
if location_name == "Start":
if item_name in randomized_starting_items:
@@ -347,8 +345,8 @@ class HKWorld(World):
randomized = True
_add("Elevator_Pass", "Elevator_Pass", randomized)
for shop, locations in self.created_multi_locations.items():
for _ in range(len(locations), getattr(self.options, shop_to_option[shop]).value):
for shop, shop_locations in self.created_multi_locations.items():
for _ in range(len(shop_locations), getattr(self.options, shop_to_option[shop]).value):
self.create_location(shop)
unfilled_locations += 1
@@ -358,7 +356,7 @@ class HKWorld(World):
# Add additional shop items, as needed.
if additional_shop_items > 0:
shops = list(shop for shop, locations in self.created_multi_locations.items() if len(locations) < 16)
shops = [shop for shop, shop_locations in self.created_multi_locations.items() if len(shop_locations) < 16]
if not self.options.EggShopSlots: # No eggshop, so don't place items there
shops.remove('Egg_Shop')
@@ -380,8 +378,8 @@ class HKWorld(World):
self.sort_shops_by_cost()
def sort_shops_by_cost(self):
for shop, locations in self.created_multi_locations.items():
randomized_locations = list(loc for loc in locations if not loc.vanilla)
for shop, shop_locations in self.created_multi_locations.items():
randomized_locations = [loc for loc in shop_locations if not loc.vanilla]
prices = sorted(
(loc.costs for loc in randomized_locations),
key=lambda costs: (len(costs),) + tuple(costs.values())
@@ -405,7 +403,7 @@ class HKWorld(World):
return {k: v for k, v in weights.items() if v}
random = self.random
hybrid_chance = getattr(self.options, f"CostSanityHybridChance").value
hybrid_chance = getattr(self.options, "CostSanityHybridChance").value
weights = {
data.term: getattr(self.options, f"CostSanity{data.option}Weight").value
for data in cost_terms.values()
@@ -493,7 +491,11 @@ class HKWorld(World):
worlds = [world for world in multiworld.get_game_worlds(cls.game) if world.options.Goal in ["any", "grub_hunt"]]
if worlds:
grubs = [item for item in multiworld.get_items() if item.name == "Grub"]
all_grub_players = [world.player for world in worlds if world.options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]]
all_grub_players = [
world.player
for world in worlds
if world.options.GrubHuntGoal == GrubHuntGoal.special_range_names["all"]
]
if all_grub_players:
group_lookup = defaultdict(set)
@@ -668,8 +670,8 @@ class HKWorld(World):
):
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
else:
for shop_name, locations in hk_world.created_multi_locations.items():
for loc in locations:
for shop_name, shop_locations in hk_world.created_multi_locations.items():
for loc in shop_locations:
spoiler_handle.write(f"\n{loc}: {loc.item} costing {loc.cost_text()}")
def get_multi_location_name(self, base: str, i: typing.Optional[int]) -> str:

View File

@@ -2,7 +2,6 @@ import typing
from argparse import Namespace
from BaseClasses import CollectionState, MultiWorld
from Options import ItemLinks
from test.bases import WorldTestBase
from worlds.AutoWorld import AutoWorldRegister, call_all
from .. import HKWorld

View File

@@ -1,5 +1,6 @@
from . import linkedTestHK, WorldTestBase
from test.bases import WorldTestBase
from Options import ItemLinks
from . import linkedTestHK
class test_grubcount_limited(linkedTestHK, WorldTestBase):

View File

@@ -483,6 +483,8 @@ def create_regions(multiworld: MultiWorld, player: int, options):
for name, data in regions.items():
multiworld.regions.append(create_region(multiworld, player, name, data))
def connect_entrances(multiworld: MultiWorld, player: int):
multiworld.get_entrance("Awakening", player).connect(multiworld.get_region("Awakening", player))
multiworld.get_entrance("Destiny Islands", player).connect(multiworld.get_region("Destiny Islands", player))
multiworld.get_entrance("Traverse Town", player).connect(multiworld.get_region("Traverse Town", player))
@@ -500,6 +502,7 @@ def create_regions(multiworld: MultiWorld, player: int, options):
multiworld.get_entrance("World Map", player).connect(multiworld.get_region("World Map", player))
multiworld.get_entrance("Levels", player).connect(multiworld.get_region("Levels", player))
def create_region(multiworld: MultiWorld, player: int, name: str, data: KH1RegionData):
region = Region(name, player, multiworld)
if data.locations:

View File

@@ -6,7 +6,7 @@ from worlds.AutoWorld import WebWorld, World
from .Items import KH1Item, KH1ItemData, event_item_table, get_items_by_category, item_table, item_name_groups
from .Locations import KH1Location, location_table, get_locations_by_category, location_name_groups
from .Options import KH1Options, kh1_option_groups
from .Regions import create_regions
from .Regions import connect_entrances, create_regions
from .Rules import set_rules
from .Presets import kh1_option_presets
from worlds.LauncherComponents import Component, components, Type, launch_subprocess
@@ -242,6 +242,9 @@ class KH1World(World):
def create_regions(self):
create_regions(self.multiworld, self.player, self.options)
def connect_entrances(self):
connect_entrances(self.multiworld, self.player)
def generate_early(self):
value_names = ["Reports to Open End of the World", "Reports to Open Final Rest Door", "Reports in Pool"]

View File

@@ -5,8 +5,10 @@ ModuleUpdate.update()
import os
import asyncio
import json
import requests
from pymem import pymem
from . import item_dictionary_table, exclusion_item_table, CheckDupingItems, all_locations, exclusion_table, SupportAbility_Table, ActionAbility_Table, all_weapon_slot
from . import item_dictionary_table, exclusion_item_table, CheckDupingItems, all_locations, exclusion_table, \
SupportAbility_Table, ActionAbility_Table, all_weapon_slot
from .Names import ItemName
from .WorldLocations import *
@@ -82,6 +84,7 @@ class KH2Context(CommonContext):
}
self.kh2seedname = None
self.kh2slotdata = None
self.mem_json = None
self.itemamount = {}
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
@@ -178,7 +181,8 @@ class KH2Context(CommonContext):
self.base_accessory_slots = 1
self.base_armor_slots = 1
self.base_item_slots = 3
self.front_ability_slots = [0x2546, 0x2658, 0x276C, 0x2548, 0x254A, 0x254C, 0x265A, 0x265C, 0x265E, 0x276E, 0x2770, 0x2772]
self.front_ability_slots = [0x2546, 0x2658, 0x276C, 0x2548, 0x254A, 0x254C, 0x265A, 0x265C, 0x265E, 0x276E,
0x2770, 0x2772]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -340,12 +344,8 @@ class KH2Context(CommonContext):
self.locations_checked |= new_locations
if cmd in {"DataPackage"}:
self.kh2_loc_name_to_id = args["data"]["games"]["Kingdom Hearts 2"]["location_name_to_id"]
self.lookup_id_to_location = {v: k for k, v in self.kh2_loc_name_to_id.items()}
self.kh2_item_name_to_id = args["data"]["games"]["Kingdom Hearts 2"]["item_name_to_id"]
self.lookup_id_to_item = {v: k for k, v in self.kh2_item_name_to_id.items()}
self.ability_code_list = [self.kh2_item_name_to_id[item] for item in exclusion_item_table["Ability"]]
if "Kingdom Hearts 2" in args["data"]["games"]:
self.data_package_kh2_cache(args)
if "KeybladeAbilities" in self.kh2slotdata.keys():
# sora ability to slot
self.AbilityQuantityDict.update(self.kh2slotdata["KeybladeAbilities"])
@@ -359,24 +359,9 @@ class KH2Context(CommonContext):
self.all_weapon_location_id = set(all_weapon_location_id)
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
if self.kh2_game_version is None:
if self.kh2_read_string(0x09A9830, 4) == "KH2J":
self.kh2_game_version = "STEAM"
self.Now = 0x0717008
self.Save = 0x09A9830
self.Slot1 = 0x2A23518
self.Journal = 0x7434E0
self.Shop = 0x7435D0
elif self.kh2_read_string(0x09A92F0, 4) == "KH2J":
self.kh2_game_version = "EGS"
else:
self.kh2_game_version = None
logger.info("Your game version is out of date. Please update your game via The Epic Games Store or Steam.")
if self.kh2_game_version is not None:
logger.info(f"You are now auto-tracking. {self.kh2_game_version}")
self.kh2connected = True
if not self.kh2:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
self.get_addresses()
except Exception as e:
if self.kh2connected:
@@ -385,6 +370,13 @@ class KH2Context(CommonContext):
self.serverconneced = True
asyncio.create_task(self.send_msgs([{'cmd': 'Sync'}]))
def data_package_kh2_cache(self, args):
self.kh2_loc_name_to_id = args["data"]["games"]["Kingdom Hearts 2"]["location_name_to_id"]
self.lookup_id_to_location = {v: k for k, v in self.kh2_loc_name_to_id.items()}
self.kh2_item_name_to_id = args["data"]["games"]["Kingdom Hearts 2"]["item_name_to_id"]
self.lookup_id_to_item = {v: k for k, v in self.kh2_item_name_to_id.items()}
self.ability_code_list = [self.kh2_item_name_to_id[item] for item in exclusion_item_table["Ability"]]
async def checkWorldLocations(self):
try:
currentworldint = self.kh2_read_byte(self.Now)
@@ -425,7 +417,6 @@ class KH2Context(CommonContext):
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels], 5: ["SummonLevel", SummonLevels]
}
# TODO: remove formDict[i][0] in self.kh2_seed_save_cache["Levels"].keys() after 4.3
for i in range(6):
for location, data in formDict[i][1].items():
formlevel = self.kh2_read_byte(self.Save + data.addrObtained)
@@ -469,9 +460,11 @@ class KH2Context(CommonContext):
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
locationData = self.location_name_to_worlddata[locationName]
if self.kh2_read_byte(self.Save + locationData.addrObtained) & 0x1 << locationData.bitIndex == 0:
if self.kh2_read_byte(
self.Save + locationData.addrObtained) & 0x1 << locationData.bitIndex == 0:
roomData = self.kh2_read_byte(self.Save + locationData.addrObtained)
self.kh2_write_byte(self.Save + locationData.addrObtained, roomData | 0x01 << locationData.bitIndex)
self.kh2_write_byte(self.Save + locationData.addrObtained,
roomData | 0x01 << locationData.bitIndex)
except Exception as e:
if self.kh2connected:
@@ -494,6 +487,9 @@ class KH2Context(CommonContext):
async def give_item(self, item, location):
try:
# todo: ripout all the itemtype stuff and just have one dictionary. the only thing that needs to be tracked from the server/local is abilites
#sleep so we can get the datapackage and not miss any items that were sent to us while we didnt have our item id dicts
while not self.lookup_id_to_item:
await asyncio.sleep(0.5)
itemname = self.lookup_id_to_item[item]
itemdata = self.item_name_to_data[itemname]
# itemcode = self.kh2_item_name_to_id[itemname]
@@ -637,7 +633,8 @@ class KH2Context(CommonContext):
item_data = self.item_name_to_data[item_name]
# if the inventory slot for that keyblade is less than the amount they should have,
# and they are not in stt
if self.kh2_read_byte(self.Save + item_data.memaddr) != 1 and self.kh2_read_byte(self.Save + 0x1CFF) != 13:
if self.kh2_read_byte(self.Save + item_data.memaddr) != 1 and self.kh2_read_byte(
self.Save + 0x1CFF) != 13:
# Checking form anchors for the keyblade to remove extra keyblades
if self.kh2_read_short(self.Save + 0x24F0) == item_data.kh2id \
or self.kh2_read_short(self.Save + 0x32F4) == item_data.kh2id \
@@ -738,7 +735,8 @@ class KH2Context(CommonContext):
item_data = self.item_name_to_data[item_name]
amount_of_items = 0
amount_of_items += self.kh2_seed_save_cache["AmountInvo"]["Magic"][item_name]
if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items and self.kh2_read_byte(self.Shop) in {10, 8}:
if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items and self.kh2_read_byte(
self.Shop) in {10, 8}:
self.kh2_write_byte(self.Save + item_data.memaddr, amount_of_items)
for item_name in master_stat:
@@ -797,7 +795,8 @@ class KH2Context(CommonContext):
# self.kh2_write_byte(self.Save + item_data.memaddr, amount_of_items)
if "PoptrackerVersionCheck" in self.kh2slotdata:
if self.kh2slotdata["PoptrackerVersionCheck"] > 4.2 and self.kh2_read_byte(self.Save + 0x3607) != 1: # telling the goa they are on version 4.3
if self.kh2slotdata["PoptrackerVersionCheck"] > 4.2 and self.kh2_read_byte(
self.Save + 0x3607) != 1: # telling the goa they are on version 4.3
self.kh2_write_byte(self.Save + 0x3607, 1)
except Exception as e:
@@ -806,10 +805,59 @@ class KH2Context(CommonContext):
logger.info(e)
logger.info("line 840")
def get_addresses(self):
if not self.kh2connected and self.kh2 is not None:
if self.kh2_game_version is None:
if self.kh2_read_string(0x09A9830, 4) == "KH2J":
self.kh2_game_version = "STEAM"
self.Now = 0x0717008
self.Save = 0x09A9830
self.Slot1 = 0x2A23518
self.Journal = 0x7434E0
self.Shop = 0x7435D0
elif self.kh2_read_string(0x09A92F0, 4) == "KH2J":
self.kh2_game_version = "EGS"
else:
if self.game_communication_path:
logger.info("Checking with most up to date addresses of github. If file is not found will be downloading datafiles. This might take a moment")
#if mem addresses file is found then check version and if old get new one
kh2memaddresses_path = os.path.join(self.game_communication_path, f"kh2memaddresses.json")
if not os.path.exists(kh2memaddresses_path):
mem_resp = requests.get("https://raw.githubusercontent.com/JaredWeakStrike/KH2APMemoryValues/master/kh2memaddresses.json")
if mem_resp.status_code == 200:
self.mem_json = json.loads(mem_resp.content)
with open(kh2memaddresses_path,
'w') as f:
f.write(json.dumps(self.mem_json, indent=4))
else:
with open(kh2memaddresses_path, 'r') as f:
self.mem_json = json.load(f)
if self.mem_json:
for key in self.mem_json.keys():
if self.kh2_read_string(eval(self.mem_json[key]["GameVersionCheck"]), 4) == "KH2J":
self.Now = eval(self.mem_json[key]["Now"])
self.Save=eval(self.mem_json[key]["Save"])
self.Slot1 = eval(self.mem_json[key]["Slot1"])
self.Journal = eval(self.mem_json[key]["Journal"])
self.Shop = eval(self.mem_json[key]["Shop"])
self.kh2_game_version = key
if self.kh2_game_version is not None:
logger.info(f"You are now auto-tracking {self.kh2_game_version}")
self.kh2connected = True
else:
logger.info("Your game version does not match what the client requires. Check in the "
"kingdom-hearts-2-final-mix channel for more information on correcting the game "
"version.")
self.kh2connected = False
def finishedGame(ctx: KH2Context):
if ctx.kh2slotdata['FinalXemnas'] == 1:
if not ctx.final_xemnas and ctx.kh2_read_byte(ctx.Save + all_world_locations[LocationName.FinalXemnas].addrObtained) \
if not ctx.final_xemnas and ctx.kh2_read_byte(
ctx.Save + all_world_locations[LocationName.FinalXemnas].addrObtained) \
& 0x1 << all_world_locations[LocationName.FinalXemnas].bitIndex > 0:
ctx.final_xemnas = True
# three proofs
@@ -843,7 +891,8 @@ def finishedGame(ctx: KH2Context):
for boss in ctx.kh2slotdata["hitlist"]:
if boss in locations:
ctx.hitlist_bounties += 1
if ctx.hitlist_bounties >= ctx.kh2slotdata["BountyRequired"] or ctx.kh2_seed_save_cache["AmountInvo"]["Amount"]["Bounty"] >= ctx.kh2slotdata["BountyRequired"]:
if ctx.hitlist_bounties >= ctx.kh2slotdata["BountyRequired"] or ctx.kh2_seed_save_cache["AmountInvo"]["Amount"][
"Bounty"] >= ctx.kh2slotdata["BountyRequired"]:
if ctx.kh2_read_byte(ctx.Save + 0x36B3) < 1:
ctx.kh2_write_byte(ctx.Save + 0x36B2, 1)
ctx.kh2_write_byte(ctx.Save + 0x36B3, 1)
@@ -894,24 +943,7 @@ async def kh2_watcher(ctx: KH2Context):
while not ctx.kh2connected and ctx.serverconneced:
await asyncio.sleep(15)
ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
if ctx.kh2 is not None:
if ctx.kh2_game_version is None:
if ctx.kh2_read_string(0x09A9830, 4) == "KH2J":
ctx.kh2_game_version = "STEAM"
ctx.Now = 0x0717008
ctx.Save = 0x09A9830
ctx.Slot1 = 0x2A23518
ctx.Journal = 0x7434E0
ctx.Shop = 0x7435D0
elif ctx.kh2_read_string(0x09A92F0, 4) == "KH2J":
ctx.kh2_game_version = "EGS"
else:
ctx.kh2_game_version = None
logger.info("Your game version is out of date. Please update your game via The Epic Games Store or Steam.")
if ctx.kh2_game_version is not None:
logger.info(f"You are now auto-tracking {ctx.kh2_game_version}")
ctx.kh2connected = True
ctx.get_addresses()
except Exception as e:
if ctx.kh2connected:
ctx.kh2connected = False

View File

@@ -540,7 +540,7 @@ KH2REGIONS: typing.Dict[str, typing.List[str]] = {
LocationName.SephirothFenrir,
LocationName.SephiEventLocation
],
RegionName.CoR: [
RegionName.CoR: [ #todo: make logic for getting these checks.
LocationName.CoRDepthsAPBoost,
LocationName.CoRDepthsPowerCrystal,
LocationName.CoRDepthsFrostCrystal,

View File

@@ -194,8 +194,8 @@ class KH2WorldRules(KH2Rules):
RegionName.Oc: lambda state: self.oc_unlocked(state, 1),
RegionName.Oc2: lambda state: self.oc_unlocked(state, 2),
#twtnw1 is actually the roxas fight region thus roxas requires 1 way to the dawn
RegionName.Twtnw2: lambda state: self.twtnw_unlocked(state, 2),
# These will be swapped and First Visit lock for twtnw is in development.
# RegionName.Twtnw1: lambda state: self.lod_unlocked(state, 2),
RegionName.Ht: lambda state: self.ht_unlocked(state, 1),
@@ -919,8 +919,8 @@ class KH2FightRules(KH2Rules):
# normal:both gap closers,limit 5,reflera,guard,both 2 ground finishers,3 dodge roll,finishing plus
# hard:1 gap closers,reflect, guard,both 1 ground finisher,2 dodge roll,finishing plus
sephiroth_rules = {
"easy": self.kh2_dict_count(easy_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state) and self.kh2_list_any_sum([donald_limit], state) >= 1,
"normal": self.kh2_dict_count(normal_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state) and self.kh2_list_any_sum([donald_limit, gap_closer], state) >= 2,
"easy": self.kh2_dict_count(easy_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state),
"normal": self.kh2_dict_count(normal_sephiroth_tools, state) and self.kh2_can_reach(LocationName.Limitlvl5, state) and self.kh2_list_any_sum([gap_closer], state) >= 1,
"hard": self.kh2_dict_count(hard_sephiroth_tools, state) and self.kh2_list_any_sum([gap_closer, ground_finisher], state) >= 2,
}
return sephiroth_rules[self.fight_logic]

View File

@@ -52,7 +52,7 @@ After Installing the seed click "Mod Loader -> Build/Build and Run". Every slot
<h2 style="text-transform:none";>What the Mod Manager Should Look Like.</h2>
![image](https://i.imgur.com/Si4oZ8w.png)
![image](https://i.imgur.com/N0WJ8Qn.png)
<h2 style="text-transform:none";>Using the KH2 Client</h2>

View File

@@ -85,7 +85,7 @@ keyItemList: typing.List[ItemData] = [
]
subChipList: typing.List[ItemData] = [
ItemData(0xB31018, ItemName.Unlocker, ItemClassification.useful, ItemType.SubChip, 117),
ItemData(0xB31018, ItemName.Unlocker, ItemClassification.progression, ItemType.SubChip, 117),
ItemData(0xB31019, ItemName.Untrap, ItemClassification.filler, ItemType.SubChip, 115),
ItemData(0xB3101A, ItemName.LockEnmy, ItemClassification.filler, ItemType.SubChip, 116),
ItemData(0xB3101B, ItemName.MiniEnrg, ItemClassification.filler, ItemType.SubChip, 112),
@@ -290,7 +290,9 @@ programList: typing.List[ItemData] = [
ItemData(0xB31099, ItemName.WpnLV_plus_Yellow, ItemClassification.filler, ItemType.Program, 35, ProgramColor.Yellow),
ItemData(0xB3109A, ItemName.Press, ItemClassification.progression, ItemType.Program, 20, ProgramColor.White),
ItemData(0xB310B7, ItemName.UnderSht, ItemClassification.useful, ItemType.Program, 30, ProgramColor.White)
ItemData(0xB310B7, ItemName.UnderSht, ItemClassification.useful, ItemType.Program, 30, ProgramColor.White),
ItemData(0xB310E0, ItemName.Humor, ItemClassification.progression, ItemType.Program, 45, ProgramColor.Pink),
ItemData(0xB310E1, ItemName.BlckMnd, ItemClassification.progression, ItemType.Program, 46, ProgramColor.White)
]
zennyList: typing.List[ItemData] = [
@@ -338,8 +340,29 @@ item_frequencies: typing.Dict[str, int] = {
ItemName.zenny_800z: 2,
ItemName.zenny_1000z: 2,
ItemName.zenny_1200z: 2,
ItemName.bugfrag_01: 5,
ItemName.bugfrag_01: 10,
ItemName.bugfrag_10: 5
}
item_groups: typing.Dict[str, typing.Set[str]] = {
"Key Items": {loc.itemName for loc in keyItemList},
"Subchips": {loc.itemName for loc in subChipList},
"Programs": {loc.itemName for loc in programList},
"BattleChips": {loc.itemName for loc in chipList},
"Zenny": {loc.itemName for loc in zennyList},
"BugFrags": {loc.itemName for loc in bugFragList},
"Navi Chips": {
ItemName.Roll_R, ItemName.RollV2_R, ItemName.RollV3_R, ItemName.GutsMan_G, ItemName.GutsManV2_G,
ItemName.GutsManV3_G, ItemName.ProtoMan_B, ItemName.ProtoManV2_B, ItemName.ProtoManV3_B, ItemName.FlashMan_F,
ItemName.FlashManV2_F, ItemName.FlashManV3_F, ItemName.BeastMan_B, ItemName.BeastManV2_B, ItemName.BeastManV3_B,
ItemName.BubblMan_B, ItemName.BubblManV2_B, ItemName.BubblManV3_B, ItemName.DesertMan_D, ItemName.DesertManV2_D,
ItemName.DesertManV3_D, ItemName.PlantMan_P, ItemName.PlantManV2_P, ItemName.PlantManV3_P, ItemName.FlamMan_F,
ItemName.FlamManV2_F, ItemName.FlamManV3_F, ItemName.DrillMan_D, ItemName.DrillManV2_D, ItemName.DrillManV3_D,
ItemName.MetalMan_M, ItemName.MetalManV2_M, ItemName.MetalManV3_M, ItemName.KingMan_K, ItemName.KingManV2_K,
ItemName.KingManV3_K, ItemName.BowlMan_B, ItemName.BowlManV2_B, ItemName.BowlManV3_B
}
}
all_items: typing.List[ItemData] = keyItemList + subChipList + chipList + programList + zennyList + bugFragList
item_table: typing.Dict[str, ItemData] = {item.itemName: item for item in all_items}
items_by_id: typing.Dict[int, ItemData] = {item.code: item for item in all_items}

View File

@@ -221,7 +221,8 @@ overworlds = [
LocationData(LocationName.Hades_Boat_Dock, 0xb310ab, 0x200024c, 0x10, 0x7519B0, 223, [3]),
LocationData(LocationName.WWW_Control_Room_1_Screen, 0xb310ac, 0x200024d, 0x40, 0x7596C4, 222, [3, 4]),
LocationData(LocationName.WWW_Wilys_Desk, 0xb310ad, 0x200024d, 0x2, 0x759384, 229, [3]),
LocationData(LocationName.Undernet_4_Pillar_Prog, 0xb310ae, 0x2000161, 0x1, 0x7746C8, 191, [0, 1])
LocationData(LocationName.Undernet_4_Pillar_Prog, 0xb310ae, 0x2000161, 0x1, 0x7746C8, 191, [0, 1]),
LocationData(LocationName.Serenade, 0xb3110f, 0x2000178, 0x40, 0x7B3C74, 1, [0])
]
jobs = [
@@ -240,7 +241,8 @@ jobs = [
# LocationData(LocationName.Gathering_Data, 0xb310bb, 0x2000300, 0x10, 0x739580, 193, [0]),
LocationData(LocationName.Somebody_please_help, 0xb310bc, 0x2000301, 0x4, 0x73A14C, 193, [0]),
LocationData(LocationName.Looking_for_condor, 0xb310bd, 0x2000301, 0x2, 0x749444, 203, [0]),
LocationData(LocationName.Help_with_rehab, 0xb310be, 0x2000301, 0x1, 0x762CF0, 192, [3]),
LocationData(LocationName.Help_with_rehab, 0xb310be, 0x2000301, 0x1, 0x762CF0, 192, [0]),
LocationData(LocationName.Help_with_rehab_bonus, 0xb3110e, 0x2000301, 0x1, 0x762CF0, 192, [3]),
LocationData(LocationName.Old_Master, 0xb310bf, 0x2000302, 0x80, 0x760E80, 193, [0]),
LocationData(LocationName.Catching_gang_members, 0xb310c0, 0x2000302, 0x40, 0x76EAE4, 193, [0]),
LocationData(LocationName.Please_adopt_a_virus, 0xb310c1, 0x2000302, 0x20, 0x76A4F4, 193, [0]),
@@ -250,7 +252,7 @@ jobs = [
LocationData(LocationName.Hide_and_seek_Second_Child, 0xb310c5, 0x2000188, 0x2, 0x75ADA8, 191, [0]),
LocationData(LocationName.Hide_and_seek_Third_Child, 0xb310c6, 0x2000188, 0x1, 0x75B5EC, 191, [0]),
LocationData(LocationName.Hide_and_seek_Fourth_Child, 0xb310c7, 0x2000189, 0x80, 0x75BEB0, 191, [0]),
LocationData(LocationName.Hide_and_seek_Completion, 0xb310c8, 0x2000302, 0x8, 0x7406A0, 193, [0]),
LocationData(LocationName.Hide_and_seek_Completion, 0xb310c8, 0x2000302, 0x8, 0x742D40, 193, [0]),
LocationData(LocationName.Finding_the_blue_Navi, 0xb310c9, 0x2000302, 0x4, 0x773700, 192, [0]),
LocationData(LocationName.Give_your_support, 0xb310ca, 0x2000302, 0x2, 0x752D80, 192, [0]),
LocationData(LocationName.Stamp_collecting, 0xb310cb, 0x2000302, 0x1, 0x756074, 193, [0]),
@@ -329,10 +331,7 @@ chocolate_shop = [
LocationData(LocationName.Chocolate_Shop_32, 0xb3110d, 0x20001c3, 0x01, 0x73F8FC, 181, [0]),
]
always_excluded_locations = [
LocationName.Undernet_7_PMD,
LocationName.Undernet_7_Northeast_BMD,
LocationName.Undernet_7_Northwest_BMD,
secret_locations = {
LocationName.Secret_1_Northwest_BMD,
LocationName.Secret_1_Northeast_BMD,
LocationName.Secret_1_South_BMD,
@@ -341,19 +340,23 @@ always_excluded_locations = [
LocationName.Secret_2_Island_BMD,
LocationName.Secret_3_Island_BMD,
LocationName.Secret_3_BugFrag_BMD,
LocationName.Secret_3_South_BMD
]
LocationName.Secret_3_South_BMD,
LocationName.Serenade
}
location_groups: typing.Dict[str, typing.Set[str]] = {
"BMDs": {loc.name for loc in bmds},
"PMDs": {loc.name for loc in pmds},
"Jobs": {loc.name for loc in jobs},
"Number Trader": {loc.name for loc in number_traders},
"Bugfrag Trader": {loc.name for loc in chocolate_shop},
"Secret Area": {LocationName.Secret_1_Northwest_BMD, LocationName.Secret_1_Northeast_BMD,
LocationName.Secret_1_South_BMD, LocationName.Secret_2_Upper_BMD, LocationName.Secret_2_Lower_BMD,
LocationName.Secret_2_Island_BMD, LocationName.Secret_3_Island_BMD,
LocationName.Secret_3_BugFrag_BMD, LocationName.Secret_3_South_BMD, LocationName.Serenade},
}
all_locations: typing.List[LocationData] = bmds + pmds + overworlds + jobs + number_traders + chocolate_shop
scoutable_locations: typing.List[LocationData] = [loc for loc in all_locations if loc.hint_flag is not None]
location_table: typing.Dict[str, int] = {locData.name: locData.id for locData in all_locations}
location_data_table: typing.Dict[str, LocationData] = {locData.name: locData for locData in all_locations}
"""
def setup_locations(world, player: int):
# If we later include options to change what gets added to the random pool,
# this is where they would be changed
return {locData.name: locData.id for locData in all_locations}
"""

View File

@@ -173,6 +173,8 @@ class ItemName():
WpnLV_plus_White = "WpnLV+1 (White)"
Press = "Press"
UnderSht = "UnderSht"
Humor = "Humor"
BlckMnd = "BlckMnd"
## Currency
zenny_200z = "200z"

View File

@@ -210,6 +210,7 @@ class LocationName():
WWW_Control_Room_1_Screen = "WWW Control Room 1 Screen"
WWW_Wilys_Desk = "WWW Wily's Desk"
Undernet_4_Pillar_Prog = "Undernet 4 Pillar Prog"
Serenade = "Serenade"
## Numberman Codes
Numberman_Code_01 = "Numberman Code 01"
@@ -261,6 +262,7 @@ class LocationName():
Somebody_please_help = "Job: Somebody, please help!"
Looking_for_condor = "Job: Looking for condor"
Help_with_rehab = "Job: Help with rehab"
Help_with_rehab_bonus = "Job: Help with rehab bonus"
Old_Master = "Job: Old Master"
Catching_gang_members = "Job: Catching gang members"
Please_adopt_a_virus = "Job: Please adopt a virus!"

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from Options import Choice, Range, DefaultOnToggle, PerGameCommonOptions
from Options import Choice, Range, DefaultOnToggle, Toggle, PerGameCommonOptions
class ExtraRanks(Range):
@@ -17,10 +17,17 @@ class ExtraRanks(Range):
class IncludeJobs(DefaultOnToggle):
"""
Whether Jobs can be included in logic.
Whether Jobs can contain progression or useful items.
"""
display_name = "Include Jobs"
class IncludeSecretArea(Toggle):
"""
Whether the Secret Area (including Serenade) can contain progression or useful items.
"""
display_name = "Include Secret Area"
# Possible logic options:
# - Include Number Trader
# - Include Secret Area
@@ -46,5 +53,6 @@ class TradeQuestHinting(Choice):
class MMBN3Options(PerGameCommonOptions):
extra_ranks: ExtraRanks
include_jobs: IncludeJobs
include_secret: IncludeSecretArea
trade_quest_hinting: TradeQuestHinting

View File

@@ -135,6 +135,7 @@ regions = [
LocationName.Somebody_please_help,
LocationName.Looking_for_condor,
LocationName.Help_with_rehab,
LocationName.Help_with_rehab_bonus,
LocationName.Old_Master,
LocationName.Catching_gang_members,
LocationName.Please_adopt_a_virus,
@@ -349,6 +350,7 @@ regions = [
LocationName.Secret_2_Upper_BMD,
LocationName.Secret_3_Island_BMD,
LocationName.Secret_3_South_BMD,
LocationName.Secret_3_BugFrag_BMD
LocationName.Secret_3_BugFrag_BMD,
LocationName.Serenade
])
]

View File

@@ -9,14 +9,14 @@ from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification, Region,
from worlds.AutoWorld import WebWorld, World
from .Rom import MMBN3DeltaPatch, LocalRom, get_base_rom_path
from .Items import MMBN3Item, ItemData, item_table, all_items, item_frequencies, items_by_id, ItemType
from .Items import MMBN3Item, ItemData, item_table, all_items, item_frequencies, items_by_id, ItemType, item_groups
from .Locations import Location, MMBN3Location, all_locations, location_table, location_data_table, \
always_excluded_locations, jobs
secret_locations, jobs, location_groups
from .Options import MMBN3Options
from .Regions import regions, RegionName
from .Names.ItemName import ItemName
from .Names.LocationName import LocationName
from worlds.generic.Rules import add_item_rule
from worlds.generic.Rules import add_item_rule, add_rule
class MMBN3Settings(settings.Group):
@@ -57,12 +57,16 @@ class MMBN3World(World):
settings: typing.ClassVar[MMBN3Settings]
topology_present = False
item_name_to_id = {name: data.code for name, data in item_table.items()}
location_name_to_id = {loc_data.name: loc_data.id for loc_data in all_locations}
excluded_locations: typing.List[str]
excluded_locations: typing.Set[str]
item_frequencies: typing.Dict[str, int]
location_name_groups = location_groups
item_name_groups = item_groups
web = MMBN3Web()
def generate_early(self) -> None:
@@ -74,10 +78,11 @@ class MMBN3World(World):
if self.options.extra_ranks > 0:
self.item_frequencies[ItemName.Progressive_Undernet_Rank] = 8 + self.options.extra_ranks
self.excluded_locations = set()
if not self.options.include_secret:
self.excluded_locations |= secret_locations
if not self.options.include_jobs:
self.excluded_locations = always_excluded_locations + [job.name for job in jobs]
else:
self.excluded_locations = always_excluded_locations
self.excluded_locations |= {job.name for job in jobs}
def create_regions(self) -> None:
"""
@@ -140,19 +145,19 @@ class MMBN3World(World):
if connection == RegionName.SciLab_Cyberworld:
entrance.access_rule = lambda state: \
state.has(ItemName.CSciPas, self.player) or \
state.can_reach(RegionName.SciLab_Overworld, "Region", self.player)
state.can_reach_region(RegionName.SciLab_Overworld, self.player)
self.multiworld.register_indirect_condition(self.get_region(RegionName.SciLab_Overworld), entrance)
if connection == RegionName.Yoka_Cyberworld:
entrance.access_rule = lambda state: \
state.has(ItemName.CYokaPas, self.player) or \
(
state.can_reach(RegionName.SciLab_Overworld, "Region", self.player) and
state.can_reach_region(RegionName.SciLab_Overworld, self.player) and
state.has(ItemName.Press, self.player)
)
self.multiworld.register_indirect_condition(self.get_region(RegionName.SciLab_Overworld), entrance)
if connection == RegionName.Beach_Cyberworld:
entrance.access_rule = lambda state: state.has(ItemName.CBeacPas, self.player) and\
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.register_indirect_condition(self.get_region(RegionName.Yoka_Overworld), entrance)
if connection == RegionName.Undernet:
entrance.access_rule = lambda state: self.explore_score(state) > 8 and\
@@ -198,122 +203,138 @@ class MMBN3World(World):
# Set WWW ID requirements
def has_www_id(state): return state.has(ItemName.WWW_ID, self.player)
self.multiworld.get_location(LocationName.ACDC_1_PMD, self.player).access_rule = has_www_id
self.multiworld.get_location(LocationName.SciLab_1_WWW_BMD, self.player).access_rule = has_www_id
self.multiworld.get_location(LocationName.Yoka_1_WWW_BMD, self.player).access_rule = has_www_id
self.multiworld.get_location(LocationName.Undernet_1_WWW_BMD, self.player).access_rule = has_www_id
add_rule(self.multiworld.get_location(LocationName.ACDC_1_PMD, self.player), has_www_id)
add_rule(self.multiworld.get_location(LocationName.SciLab_1_WWW_BMD, self.player), has_www_id)
add_rule(self.multiworld.get_location(LocationName.Yoka_1_WWW_BMD, self.player), has_www_id)
add_rule(self.multiworld.get_location(LocationName.Undernet_1_WWW_BMD, self.player), has_www_id)
# Set Press Program requirements
def has_press(state): return state.has(ItemName.Press, self.player)
self.multiworld.get_location(LocationName.Yoka_1_PMD, self.player).access_rule = has_press
self.multiworld.get_location(LocationName.Yoka_2_Upper_BMD, self.player).access_rule = has_press
self.multiworld.get_location(LocationName.Beach_2_East_BMD, self.player).access_rule = has_press
self.multiworld.get_location(LocationName.Hades_South_BMD, self.player).access_rule = has_press
self.multiworld.get_location(LocationName.Secret_3_BugFrag_BMD, self.player).access_rule = has_press
self.multiworld.get_location(LocationName.Secret_3_Island_BMD, self.player).access_rule = has_press
add_rule(self.multiworld.get_location(LocationName.Yoka_1_PMD, self.player), has_press)
add_rule(self.multiworld.get_location(LocationName.Yoka_2_Upper_BMD, self.player), has_press)
add_rule(self.multiworld.get_location(LocationName.Beach_2_East_BMD, self.player), has_press)
add_rule(self.multiworld.get_location(LocationName.Hades_South_BMD, self.player), has_press)
add_rule(self.multiworld.get_location(LocationName.Secret_3_BugFrag_BMD, self.player), has_press)
add_rule(self.multiworld.get_location(LocationName.Secret_3_Island_BMD, self.player), has_press)
# Set Purple Mystery Data Unlocker access
def can_unlock(state): return state.can_reach_region(RegionName.SciLab_Overworld, self.player) or \
state.can_reach_region(RegionName.SciLab_Cyberworld, self.player) or \
state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) or \
state.has(ItemName.Unlocker, self.player, 8) # There are 8 PMDs that aren't in one of the above areas
add_rule(self.multiworld.get_location(LocationName.ACDC_1_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Yoka_1_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Beach_1_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Undernet_7_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Mayls_HP_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.SciLab_Dads_Computer_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Zoo_Panda_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Beach_DNN_Security_Panel_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Beach_DNN_Main_Console_PMD, self.player), can_unlock)
add_rule(self.multiworld.get_location(LocationName.Tamakos_HP_PMD, self.player), can_unlock)
# Set Job additional area access
self.multiworld.get_location(LocationName.Please_deliver_this, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player) and \
state.can_reach(RegionName.ACDC_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \
state.can_reach_region(RegionName.ACDC_Cyberworld, self.player)
self.multiworld.get_location(LocationName.My_Navi_is_sick, self.player).access_rule =\
lambda state: \
state.has(ItemName.Recov30_star, self.player)
self.multiworld.get_location(LocationName.Help_me_with_my_son, self.player).access_rule =\
lambda state:\
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player) and \
state.can_reach(RegionName.ACDC_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \
state.can_reach_region(RegionName.ACDC_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Transmission_error, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.get_location(LocationName.Chip_Prices, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Cyberworld, "Region", self.player) and \
state.can_reach(RegionName.SciLab_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) and \
state.can_reach_region(RegionName.SciLab_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Im_broke, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Yoka_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \
state.can_reach_region(RegionName.Yoka_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Rare_chips_for_cheap, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player)
state.can_reach_region(RegionName.ACDC_Overworld, self.player)
self.multiworld.get_location(LocationName.Be_my_boyfriend, self.player).access_rule =\
lambda state: \
state.can_reach(RegionName.Beach_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.Beach_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Will_you_deliver, self.player).access_rule=\
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player) and \
state.can_reach(RegionName.ACDC_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \
state.can_reach_region(RegionName.Beach_Overworld, self.player) and \
state.can_reach_region(RegionName.ACDC_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Somebody_please_help, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player)
state.can_reach_region(RegionName.ACDC_Overworld, self.player)
self.multiworld.get_location(LocationName.Looking_for_condor, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player) and \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \
state.can_reach_region(RegionName.Beach_Overworld, self.player) and \
state.can_reach_region(RegionName.ACDC_Overworld, self.player)
self.multiworld.get_location(LocationName.Help_with_rehab, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Beach_Overworld, self.player)
self.multiworld.get_location(LocationName.Old_Master, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player)
state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \
state.can_reach_region(RegionName.Beach_Overworld, self.player)
self.multiworld.get_location(LocationName.Catching_gang_members, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Cyberworld, "Region", self.player) and \
state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) and \
state.has(ItemName.Press, self.player)
self.multiworld.get_location(LocationName.Please_adopt_a_virus, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.SciLab_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.SciLab_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Legendary_Tomes, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Undernet, "Region", self.player) and \
state.can_reach(RegionName.Deep_Undernet, "Region", self.player) and \
state.can_reach_region(RegionName.Beach_Overworld, self.player) and \
state.can_reach_region(RegionName.Undernet, self.player) and \
state.can_reach_region(RegionName.Deep_Undernet, self.player) and \
state.has_all({ItemName.Press, ItemName.Magnum1_A}, self.player)
self.multiworld.get_location(LocationName.Legendary_Tomes_Treasure, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player) and \
state.can_reach(LocationName.Legendary_Tomes, "Location", self.player)
state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \
state.can_reach_location(LocationName.Legendary_Tomes, self.player)
self.multiworld.get_location(LocationName.Hide_and_seek_First_Child, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.get_location(LocationName.Hide_and_seek_Second_Child, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.get_location(LocationName.Hide_and_seek_Third_Child, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.get_location(LocationName.Hide_and_seek_Fourth_Child, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.get_location(LocationName.Hide_and_seek_Completion, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Yoka_Overworld, self.player)
self.multiworld.get_location(LocationName.Finding_the_blue_Navi, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Undernet, "Region", self.player)
state.can_reach_region(RegionName.Undernet, self.player)
self.multiworld.get_location(LocationName.Give_your_support, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player)
state.can_reach_region(RegionName.Beach_Overworld, self.player)
self.multiworld.get_location(LocationName.Stamp_collecting, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player) and \
state.can_reach(RegionName.ACDC_Cyberworld, "Region", self.player) and \
state.can_reach(RegionName.SciLab_Cyberworld, "Region", self.player) and \
state.can_reach(RegionName.Yoka_Cyberworld, "Region", self.player) and \
state.can_reach(RegionName.Beach_Cyberworld, "Region", self.player)
state.can_reach_region(RegionName.Beach_Overworld, self.player) and \
state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) and \
state.can_reach_region(RegionName.SciLab_Cyberworld, self.player) and \
state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) and \
state.can_reach_region(RegionName.Beach_Cyberworld, self.player)
self.multiworld.get_location(LocationName.Help_with_a_will, self.player).access_rule = \
lambda state: \
state.can_reach(RegionName.ACDC_Overworld, "Region", self.player) and \
state.can_reach(RegionName.ACDC_Cyberworld, "Region", self.player) and \
state.can_reach(RegionName.Yoka_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Yoka_Cyberworld, "Region", self.player) and \
state.can_reach(RegionName.Beach_Overworld, "Region", self.player) and \
state.can_reach(RegionName.Undernet, "Region", self.player)
state.can_reach_region(RegionName.ACDC_Overworld, self.player) and \
state.can_reach_region(RegionName.ACDC_Cyberworld, self.player) and \
state.can_reach_region(RegionName.Yoka_Overworld, self.player) and \
state.can_reach_region(RegionName.Yoka_Cyberworld, self.player) and \
state.can_reach_region(RegionName.Beach_Overworld, self.player) and \
state.can_reach_region(RegionName.Undernet, self.player)
# Set Trade quests
self.multiworld.get_location(LocationName.ACDC_SonicWav_W_Trade, self.player).access_rule =\
@@ -390,6 +411,11 @@ class MMBN3World(World):
self.multiworld.get_location(LocationName.Numberman_Code_31, self.player).access_rule =\
lambda state: self.explore_score(state) > 10
#miscellaneous locations with extra requirements
add_rule(self.multiworld.get_location(LocationName.Comedian, self.player),
lambda state: state.has(ItemName.Humor, self.player))
add_rule(self.multiworld.get_location(LocationName.Villain, self.player),
lambda state: state.has(ItemName.BlckMnd, self.player))
def not_undernet(item): return item.code != item_table[ItemName.Progressive_Undernet_Rank].code or item.player != self.player
self.multiworld.get_location(LocationName.WWW_1_Central_BMD, self.player).item_rule = not_undernet
self.multiworld.get_location(LocationName.WWW_1_East_BMD, self.player).item_rule = not_undernet
@@ -500,24 +526,24 @@ class MMBN3World(World):
Determine roughly how much of the game you can explore to make certain checks not restrict much movement
"""
score = 0
if state.can_reach(RegionName.WWW_Island, "Region", self.player):
if state.can_reach_region(RegionName.WWW_Island, self.player):
return 999
if state.can_reach(RegionName.SciLab_Overworld, "Region", self.player):
if state.can_reach_region(RegionName.SciLab_Overworld, self.player):
score += 3
if state.can_reach(RegionName.SciLab_Cyberworld, "Region", self.player):
if state.can_reach_region(RegionName.SciLab_Cyberworld, self.player):
score += 1
if state.can_reach(RegionName.Yoka_Overworld, "Region", self.player):
if state.can_reach_region(RegionName.Yoka_Overworld, self.player):
score += 2
if state.can_reach(RegionName.Yoka_Cyberworld, "Region", self.player):
if state.can_reach_region(RegionName.Yoka_Cyberworld, self.player):
score += 1
if state.can_reach(RegionName.Beach_Overworld, "Region", self.player):
if state.can_reach_region(RegionName.Beach_Overworld, self.player):
score += 3
if state.can_reach(RegionName.Beach_Cyberworld, "Region", self.player):
if state.can_reach_region(RegionName.Beach_Cyberworld, self.player):
score += 1
if state.can_reach(RegionName.Undernet, "Region", self.player):
if state.can_reach_region(RegionName.Undernet, self.player):
score += 2
if state.can_reach(RegionName.Deep_Undernet, "Region", self.player):
if state.can_reach_region(RegionName.Deep_Undernet, self.player):
score += 1
if state.can_reach(RegionName.Secret_Area, "Region", self.player):
if state.can_reach_region(RegionName.Secret_Area, self.player):
score += 1
return score

View File

@@ -287,7 +287,7 @@ class PokemonEmeraldClient(BizHawkClient):
pokedex_caught_bytes = read_result[0]
game_clear = False
local_checked_locations = set()
local_checked_locations: set[int] = set()
local_set_events = {flag_name: False for flag_name in TRACKER_EVENT_FLAGS}
local_found_key_items = {location_name: False for location_name in KEY_LOCATION_FLAGS}
defeated_legendaries = {legendary_name: False for legendary_name in LEGENDARY_NAMES.values()}
@@ -350,10 +350,7 @@ class PokemonEmeraldClient(BizHawkClient):
self.local_checked_locations = local_checked_locations
if local_checked_locations is not None:
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": list(local_checked_locations),
}])
await ctx.check_locations(local_checked_locations)
# Send game clear
if not ctx.finished_game and game_clear:

View File

@@ -33,6 +33,18 @@ VISITED_EVENT_NAME_TO_ID = {
"EVENT_VISITED_SOUTHERN_ISLAND": 17,
}
BLACKLIST_OPTION_TO_VISITED_EVENT = {
"Slateport City": "EVENT_VISITED_SLATEPORT_CITY",
"Mauville City": "EVENT_VISITED_MAUVILLE_CITY",
"Verdanturf Town": "EVENT_VISITED_VERDANTURF_TOWN",
"Fallarbor Town": "EVENT_VISITED_FALLARBOR_TOWN",
"Lavaridge Town": "EVENT_VISITED_LAVARIDGE_TOWN",
"Fortree City": "EVENT_VISITED_FORTREE_CITY",
"Lilycove City": "EVENT_VISITED_LILYCOVE_CITY",
"Mossdeep City": "EVENT_VISITED_MOSSDEEP_CITY",
"Sootopolis City": "EVENT_VISITED_SOOTOPOLIS_CITY",
"Ever Grande City": "EVENT_VISITED_EVER_GRANDE_CITY",
}
class PokemonEmeraldLocation(Location):
game: str = "Pokemon Emerald"
@@ -129,18 +141,10 @@ def set_free_fly(world: "PokemonEmeraldWorld") -> None:
# If not enabled, set it to Littleroot Town by default
fly_location_name = "EVENT_VISITED_LITTLEROOT_TOWN"
if world.options.free_fly_location:
fly_location_name = world.random.choice([
"EVENT_VISITED_SLATEPORT_CITY",
"EVENT_VISITED_MAUVILLE_CITY",
"EVENT_VISITED_VERDANTURF_TOWN",
"EVENT_VISITED_FALLARBOR_TOWN",
"EVENT_VISITED_LAVARIDGE_TOWN",
"EVENT_VISITED_FORTREE_CITY",
"EVENT_VISITED_LILYCOVE_CITY",
"EVENT_VISITED_MOSSDEEP_CITY",
"EVENT_VISITED_SOOTOPOLIS_CITY",
"EVENT_VISITED_EVER_GRANDE_CITY",
])
blacklisted_locations = set(BLACKLIST_OPTION_TO_VISITED_EVENT[city] for city in world.options.free_fly_blacklist.value)
free_fly_locations = sorted(set(BLACKLIST_OPTION_TO_VISITED_EVENT.values()) - blacklisted_locations)
if free_fly_locations:
fly_location_name = world.random.choice(free_fly_locations)
world.free_fly_location_id = VISITED_EVENT_NAME_TO_ID[fly_location_name]

View File

@@ -725,6 +725,24 @@ class FreeFlyLocation(Toggle):
"""
display_name = "Free Fly Location"
class FreeFlyBlacklist(OptionSet):
"""
Disables specific locations as valid free fly locations.
Has no effect if Free Fly Location is disabled.
"""
display_name = "Free Fly Blacklist"
valid_keys = [
"Slateport City",
"Mauville City",
"Verdanturf Town",
"Fallarbor Town",
"Lavaridge Town",
"Fortree City",
"Lilycove City",
"Mossdeep City",
"Sootopolis City",
"Ever Grande City",
]
class HmRequirements(Choice):
"""
@@ -876,6 +894,7 @@ class PokemonEmeraldOptions(PerGameCommonOptions):
extra_bumpy_slope: ExtraBumpySlope
modify_118: ModifyRoute118
free_fly_location: FreeFlyLocation
free_fly_blacklist: FreeFlyBlacklist
hm_requirements: HmRequirements
turbo_a: TurboA

View File

@@ -245,7 +245,7 @@ class ShiversWorld(World):
storage_items += [self.create_item("Empty") for _ in range(3)]
state = self.multiworld.get_all_state(True)
state = self.multiworld.get_all_state(False)
self.random.shuffle(storage_locs)
self.random.shuffle(storage_items)

View File

@@ -48,6 +48,17 @@ class SM64World(World):
filler_count: int
star_costs: typing.Dict[str, int]
# Spoiler specific variable(s)
star_costs_spoiler_key_maxlen = len(max([
'First Floor Big Star Door',
'Basement Big Star Door',
'Second Floor Big Star Door',
'MIPS 1',
'MIPS 2',
'Endless Stairs',
], key=len))
def generate_early(self):
max_stars = 120
if (not self.options.enable_coin_stars):
@@ -238,3 +249,19 @@ class SM64World(World):
for location in region.locations:
er_hint_data[location.address] = entrance_name
hint_data[self.player] = er_hint_data
def write_spoiler(self, spoiler_handle: typing.TextIO) -> None:
# Write calculated star costs to spoiler.
star_cost_spoiler_header = '\n\n' + self.player_name + ' Star Costs for Super Mario 64:\n\n'
spoiler_handle.write(star_cost_spoiler_header)
# - Reformat star costs dictionary in spoiler to be a bit more readable.
star_costs_spoiler = {}
star_costs_copy = self.star_costs.copy()
star_costs_spoiler['First Floor Big Star Door'] = star_costs_copy['FirstBowserDoorCost']
star_costs_spoiler['Basement Big Star Door'] = star_costs_copy['BasementDoorCost']
star_costs_spoiler['Second Floor Big Star Door'] = star_costs_copy['SecondFloorDoorCost']
star_costs_spoiler['MIPS 1'] = star_costs_copy['MIPS1Cost']
star_costs_spoiler['MIPS 2'] = star_costs_copy['MIPS2Cost']
star_costs_spoiler['Endless Stairs'] = star_costs_copy['StarsToFinish']
for star, cost in star_costs_spoiler.items():
spoiler_handle.write(f"{star:{self.star_costs_spoiler_key_maxlen}s} = {cost}\n")

View File

@@ -87,6 +87,21 @@ class SMZ3World(World):
self.rom_name_available_event = threading.Event()
self.locations: Dict[str, Location] = {}
self.unreachable = []
self.junkItemsNames = [item.name for item in [
ItemType.Arrow,
ItemType.OneHundredRupees,
ItemType.TenArrows,
ItemType.ThreeBombs,
ItemType.OneRupee,
ItemType.FiveRupees,
ItemType.TwentyRupees,
ItemType.FiftyRupees,
ItemType.ThreeHundredRupees,
ItemType.ETank,
ItemType.Missile,
ItemType.Super,
ItemType.PowerBomb
]]
super().__init__(world, player)
@classmethod
@@ -202,6 +217,10 @@ class SMZ3World(World):
SMZ3World.location_names = frozenset(self.smz3World.locationLookup.keys())
self.multiworld.state.smz3state[self.player] = TotalSMZ3Item.Progression([])
if not self.smz3World.Config.Keysanity:
# Dungeons items here are not in the itempool and will be prefilled locally so they must stay local
self.options.non_local_items.value -= frozenset(item_name for item_name in self.item_names if TotalSMZ3Item.Item.IsNameDungeonItem(item_name))
def create_items(self):
self.dungeon = TotalSMZ3Item.Item.CreateDungeonPool(self.smz3World)
@@ -218,8 +237,6 @@ class SMZ3World(World):
progressionItems = self.progression + self.dungeon + self.keyCardsItems + self.SmMapsItems
else:
progressionItems = self.progression
# Dungeons items here are not in the itempool and will be prefilled locally so they must stay local
self.options.non_local_items.value -= frozenset(item_name for item_name in self.item_names if TotalSMZ3Item.Item.IsNameDungeonItem(item_name))
for item in self.keyCardsItems:
self.multiworld.push_precollected(SMZ3Item(item.Type.name, ItemClassification.filler, item.Type, self.item_name_to_id[item.Type.name], self.player, item))

View File

@@ -309,7 +309,7 @@ class SoEOptions(PerGameCommonOptions):
@property
def flags(self) -> str:
flags = ''
flags = 'AGBo' # configures auto-tracker to AP's fill
for field in fields(self):
option = getattr(self, field.name)
if isinstance(option, (EvermizerFlag, EvermizerFlags)):

View File

@@ -1,37 +1,37 @@
pyevermizer==0.48.1 \
--hash=sha256:db85cb4760abfde9d4b566d4613f2eddb8c2ff6f1c202ca0c2c5800bd62c9507 \
--hash=sha256:1c67d0dff0a42b9a037cdb138c0c7b2c776d8d7425830e7fd32f7ebf8f35ac00 \
--hash=sha256:d417f5b0407b063496aca43a65389e3308b6d0933c1d7907f7ecc8a00057903b \
--hash=sha256:abf6560204128783239c8f0fb15059a7c2ff453812f85fb8567766706b7839cc \
--hash=sha256:39e0cba1de1bc108c5b770ebe0fcbf3f6cb05575daf6bebe78c831c74848d101 \
--hash=sha256:a16054ce0d904749ef27ede375c0ca8f420831e28c4e84c67361e8181207f00d \
--hash=sha256:e6de509e4943bcde3e207a3640cad8efe3d8183740b63dc3cdbf5013db0f618b \
--hash=sha256:e9269cf1290ab2967eaac0bc24e658336fb0e1f6612efce8d7ef0e76c1c26200 \
--hash=sha256:f69e244229a110183d36b6a43ca557e716016d17e11265dca4070b8857afdb8d \
--hash=sha256:118d059b8ccd246dafb0a51d0aa8e4543c172f9665378983b9f43c680487732e \
--hash=sha256:185210c68b16351b3add4896ecfc26fe3867dadee9022f6a256e13093cca4a3b \
--hash=sha256:10e281612c38bbec11d35f5c09f5a5174fb884cc60e6f16b6790d854e4346678 \
--hash=sha256:9fc7d7e986243a96e96c1c05a386eb5d2ae4faef1ba810ab7e9e63dd83e86c2b \
--hash=sha256:c26eafc2230dca9e91aaf925a346532586d0f448456437ea4ce5054e15653fd8 \
--hash=sha256:8f96ffc5cfbe17b5c08818052be6f96906a1c9d3911e7bc4fbefee9b9ffa8f15 \
--hash=sha256:e40948cbcaab27aa4febb58054752f83357e81f4a6f088da22a71c4ec9aa7ef2 \
--hash=sha256:d59369cafa5df0fd2ce5cd5656c926e2fc0226a5a67a003d95497d56a0728dd3 \
--hash=sha256:345a25675d92aada5d94bc3f3d3e2946efd940a7228628bf8c05d2853ddda86d \
--hash=sha256:c0aa5054178c5e9900bfcf393c2bffdc69921d165521a3e9e5271528b01ef442 \
--hash=sha256:719d417fc21778d5036c9d25b7ce55582ab6f49da63ab93ec17d75ea6042364c \
--hash=sha256:28e220939850cfd8da16743365b28fa36d5bfc1dc58564789ae415e014ebc354 \
--hash=sha256:770e582000abf64dc7f0c62672e4a1f64729bb20695664c59e29d238398cb865 \
--hash=sha256:61d451b6f7d76fd435a5e9d2df111533e6e43da397a457f310151917318bd175 \
--hash=sha256:1c8b596e246bb8437c7fc6c9bb8d9c2c70bd9942f09b06ada02d2fabe596fa0b \
--hash=sha256:617f3eb0938e71a07b16477529f97fdf64487875462eb2edba6c9820b9686c0a \
--hash=sha256:98d655a256040a3ae6305145a9692a5483ddcfb9b9bbdb78d43f5e93e002a3ae \
--hash=sha256:d565bde7b1eb873badeedc2c9f327b4e226702b571aab2019778d46aa4509572 \
--hash=sha256:e04b89d6edf6ffdbf5c725b0cbf7375c87003378da80e6666818a2b6d59d3fc9 \
--hash=sha256:cc35e72f2a9e438786451f54532ce663ca63aedc3b4a43532f4ee97b45a71ed1 \
--hash=sha256:2e4640a975bf324e75f15edd6450e63db8228e2046b893bbdc47d896d5aec890 \
--hash=sha256:752716024255f13f96e40877b932694a517100a382a13f76c0bed3116b77f6d6 \
--hash=sha256:d36518349132cf2f3f4e5a6b0294db0b40f395daa620b0938227c2c8f5b1213e \
--hash=sha256:b5bca6e7fe5dcccd1e8757db4fb20d4bd998ed2b0f4b9ed26f7407c0a9b48d9f \
--hash=sha256:4663b727d2637ce7713e3db7b68828ca7dc6f03482f4763a055156f3fd16e026 \
--hash=sha256:7732bec7ffb29337418e62f15dc924e229faf09c55b079ad3f46f47eedc10c0d \
--hash=sha256:b83a7a4df24800f82844f6acc6d43cd4673de0c24c9041ab56e57f518defa5a1 \
pyevermizer==0.50.1 \
--hash=sha256:4d1f43d5f8016e7bfcb5cd80b447a4f278b60b1b250a6153e66150230bf280e8 \
--hash=sha256:06af4f66ae1f21932a936bf741a0547bbb8ff92eea8fb8efece6bc1760a8a999 \
--hash=sha256:1ddbc36860704385a767d24364eac6504acc74f185c98b50cf52219c6e0148c6 \
--hash=sha256:61f0adc4f615867e51bfcd7d7c90f19779a61391a995c721e7393005e8413950 \
--hash=sha256:d84761ee03ebdaf011befe01638db1fff128b1c37405088868f0025e064977f3 \
--hash=sha256:0433507dd8ad96375f3b64534faefdf9d325b69a19e108db1414fc75d6e72160 \
--hash=sha256:e8857f719da9eaaa54f564886ff1b36cb89b8ccf08aa6ccca2d5d3c41da0b067 \
--hash=sha256:40e76a30968b1fce3d727b47b2693d4151a9ad29b053a33bf06cde8fa63c3d15 \
--hash=sha256:09ced5349a183656c1f8dcb85e41bdd496d1c5f2bb8f712d12a055d6efa7b917 \
--hash=sha256:162806e7b0156e25612e60d25af68772cf553b3352a5cf31866d838295ccb591 \
--hash=sha256:79750965bc63ffa351c167672b51c32f2a8d3242e07e769f925d1f306564a18d \
--hash=sha256:b1875eb79c8800352f30180db296036d8b512082d6609e2368aa7032c1cf7e27 \
--hash=sha256:7989e6f06c1ea38687a6b14416b179f459282ea81edbb86086d426fe0d63bf7a \
--hash=sha256:8a4c5c62997e7378457624a88c12b27b52d345b365c3cfae7fee77ee46eb7cd0 \
--hash=sha256:a22557f56ada1ace61b781e731e06466c22b6cc605c1aa9dec10e3697b10f5e6 \
--hash=sha256:d1057e70be839e9c3a91f0f173bc795fc0014cf560767d699cc26eba5f5cfc6f \
--hash=sha256:8540bd8e8ec49422b494beece1f6bf4cca61aa452a4c0f85c3a8b77283b24753 \
--hash=sha256:569b98352fc6e1fae85a8c2ee3f2e61276762bc158ac5b7e07a476ee0f9e2617 \
--hash=sha256:1b21eed21eb9338a6e7024b015d0107eaecf78c61f8ece8e6553d77f7f0ba726 \
--hash=sha256:51ff863e92c7b608d464da10c775b5df5ad3651a05c2d316c1d60a46572fdef9 \
--hash=sha256:0f920d745df15e3171412cbda05fc21c9354323d0e8dfc066ed6051fa7df9879 \
--hash=sha256:d78970415fb03c1dd22aef8da7526e5b33eaf4c9848f5cbad843ad159254f526 \
--hash=sha256:50536924bbf702d310b92d307d7c5060f6a4307bf99b61f79571ba2675ebb1ff \
--hash=sha256:1123f8f87ce6415183126842eca1fff98362ff545204adfd4c7b6cf1c396b738 \
--hash=sha256:1b248af5aa7321e46ae05675b15a5993e28311dfabc68cee2e398ce571f28eb2 \
--hash=sha256:a76e9d17ec3af9317b3a9d5e9f9f04aea80a5902c33f6fe82d02381f2fd2cb69 \
--hash=sha256:081ed52f8e1693ca48262cb5a9687ee62c4f9a50c667a487192c72be4c1b7fac \
--hash=sha256:2978aa13826337d59799f41dda41fa4cecd9f59fae8843613230cf298b06fa6e \
--hash=sha256:d377c2fd68c3d529d89ba40a762b6424c3b04c0d58593c02f06adbdf236f72ad \
--hash=sha256:800d6c30eab6ca3ee39a6c297d08cb74cfa5a4bce498aa3f05a612596f8c513b \
--hash=sha256:0cf40413f4b7ae5d561e47706f446b91440a1b74abe33b8fabc995d92c3325ca \
--hash=sha256:97791b8695aa215ef407824d1e6c0582a2a2f89f3a0f254f5d791a5a84a0ad00 \
--hash=sha256:2174db5e4550f94cb63e17584973c9f9afdc23e5230cb556de8bf87bd72145ff \
--hash=sha256:f3a4cd6a9b292e7385722d8200e834a936886136ddaef2069035f7ec5eb50d34 \
--hash=sha256:7646efdf7e091c75dac9aebb6c9faf215de4f6b8567c049944790e43cbe63d51 \
--hash=sha256:cd56cca26ed9675790154dd70402ad28a381fc3c9031bd02eb9b1dad8c317398 \

View File

@@ -1,6 +1,6 @@
import logging
from random import Random
from typing import Dict, Any, Iterable, Optional, Union, List, TextIO
from typing import Dict, Any, Iterable, Optional, List, TextIO
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from Options import PerGameCommonOptions
@@ -88,7 +88,6 @@ class StardewValleyWorld(World):
randomized_entrances: Dict[str, str]
total_progression_items: int
excluded_from_total_progression_items: List[str] = [Event.received_walnuts]
def __init__(self, multiworld: MultiWorld, player: int):
super().__init__(multiworld, player)
@@ -176,7 +175,7 @@ class StardewValleyWorld(World):
if self.options.season_randomization == SeasonRandomization.option_disabled:
for season in season_pool:
self.multiworld.push_precollected(self.create_starting_item(season))
self.multiworld.push_precollected(self.create_item(season))
return
if [item for item in self.multiworld.precollected_items[self.player]
@@ -186,12 +185,12 @@ class StardewValleyWorld(World):
if self.options.season_randomization == SeasonRandomization.option_randomized_not_winter:
season_pool = [season for season in season_pool if season.name != "Winter"]
starting_season = self.create_starting_item(self.random.choice(season_pool))
starting_season = self.create_item(self.random.choice(season_pool))
self.multiworld.push_precollected(starting_season)
def precollect_farm_type_items(self):
if self.options.farm_type == FarmType.option_meadowlands and self.options.building_progression & BuildingProgression.option_progressive:
self.multiworld.push_precollected(self.create_starting_item("Progressive Coop"))
self.multiworld.push_precollected(self.create_item("Progressive Coop"))
def setup_logic_events(self):
def register_event(name: str, region: str, rule: StardewRule):
@@ -271,7 +270,7 @@ class StardewValleyWorld(World):
def get_all_location_names(self) -> List[str]:
return list(location.name for location in self.multiworld.get_locations(self.player))
def create_item(self, item: Union[str, ItemData], override_classification: ItemClassification = None) -> StardewItem:
def create_item(self, item: str | ItemData, override_classification: ItemClassification = None) -> StardewItem:
if isinstance(item, str):
item = item_table[item]
@@ -280,12 +279,6 @@ class StardewValleyWorld(World):
return StardewItem(item.name, override_classification, item.code, self.player)
def create_starting_item(self, item: Union[str, ItemData]) -> StardewItem:
if isinstance(item, str):
item = item_table[item]
return StardewItem(item.name, item.classification, item.code, self.player)
def create_event_location(self, location_data: LocationData, rule: StardewRule = None, item: Optional[str] = None):
if rule is None:
rule = True_()
@@ -393,9 +386,19 @@ class StardewValleyWorld(World):
if not change:
return False
player_state = state.prog_items[self.player]
received_progression_count = player_state[Event.received_progression_item]
received_progression_count += 1
if self.total_progression_items:
# Total progression items is not set until all items are created, but collect will be called during the item creation when an item is precollected.
# We can't update the percentage if we don't know the total progression items, can't divide by 0.
player_state[Event.received_progression_percent] = received_progression_count * 100 // self.total_progression_items
player_state[Event.received_progression_item] = received_progression_count
walnut_amount = self.get_walnut_amount(item.name)
if walnut_amount:
state.prog_items[self.player][Event.received_walnuts] += walnut_amount
player_state[Event.received_walnuts] += walnut_amount
return True
@@ -404,9 +407,18 @@ class StardewValleyWorld(World):
if not change:
return False
player_state = state.prog_items[self.player]
received_progression_count = player_state[Event.received_progression_item]
received_progression_count -= 1
if self.total_progression_items:
# We can't update the percentage if we don't know the total progression items, can't divide by 0.
player_state[Event.received_progression_percent] = received_progression_count * 100 // self.total_progression_items
player_state[Event.received_progression_item] = received_progression_count
walnut_amount = self.get_walnut_amount(item.name)
if walnut_amount:
state.prog_items[self.player][Event.received_walnuts] -= walnut_amount
player_state[Event.received_walnuts] -= walnut_amount
return True

View File

@@ -4,6 +4,7 @@ from typing import Iterable, Union, List, Tuple, Hashable, TYPE_CHECKING
from BaseClasses import CollectionState
from .base import BaseStardewRule, CombinableStardewRule
from .protocol import StardewRule
from ..strings.ap_names.event_names import Event
if TYPE_CHECKING:
from .. import StardewValleyWorld
@@ -87,45 +88,13 @@ class Reach(BaseStardewRule):
return f"Reach {self.resolution_hint} {self.spot}"
@dataclass(frozen=True)
class HasProgressionPercent(CombinableStardewRule):
player: int
percent: int
class HasProgressionPercent(Received):
def __init__(self, player: int, percent: int):
super().__init__(Event.received_progression_percent, player, percent, event=True)
def __post_init__(self):
assert self.percent > 0, "HasProgressionPercent rule must be above 0%"
assert self.percent <= 100, "HasProgressionPercent rule can't require more than 100% of items"
@property
def combination_key(self) -> Hashable:
return HasProgressionPercent.__name__
@property
def value(self):
return self.percent
def __call__(self, state: CollectionState) -> bool:
stardew_world: "StardewValleyWorld" = state.multiworld.worlds[self.player]
total_count = stardew_world.total_progression_items
needed_count = (total_count * self.percent) // 100
player_state = state.prog_items[self.player]
if needed_count <= len(player_state) - len(stardew_world.excluded_from_total_progression_items):
return True
total_count = 0
for item, item_count in player_state.items():
if item in stardew_world.excluded_from_total_progression_items:
continue
total_count += item_count
if total_count >= needed_count:
return True
return False
def evaluate_while_simplifying(self, state: CollectionState) -> Tuple[StardewRule, bool]:
return self, self(state)
assert self.count > 0, "HasProgressionPercent rule must be above 0%"
assert self.count <= 100, "HasProgressionPercent rule can't require more than 100% of items"
def __repr__(self):
return f"Received {self.percent}% progression items"
return f"Received {self.count}% progression items"

View File

@@ -10,3 +10,5 @@ class Event:
victory = event("Victory")
received_walnuts = event("Received Walnuts")
received_progression_item = event("Received Progression Item")
received_progression_percent = event("Received Progression Percent")

View File

@@ -69,14 +69,17 @@ class TestShipsanityEverything(SVTestBase):
def test_all_shipsanity_locations_require_shipping_bin(self):
bin_name = "Shipping Bin"
self.collect_all_except(bin_name)
shipsanity_locations = [location for location in self.get_real_locations() if
LocationTags.SHIPSANITY in location_table[location.name].tags]
shipsanity_locations = [location
for location in self.get_real_locations()
if LocationTags.SHIPSANITY in location_table[location.name].tags]
bin_item = self.create_item(bin_name)
for location in shipsanity_locations:
with self.subTest(location.name):
self.remove(bin_item)
self.assertFalse(self.world.logic.region.can_reach_location(location.name)(self.multiworld.state))
self.multiworld.state.collect(bin_item)
self.collect(bin_item)
shipsanity_rule = self.world.logic.region.can_reach_location(location.name)
self.assert_rule_true(shipsanity_rule, self.multiworld.state)
self.remove(bin_item)

View File

@@ -1,4 +1,4 @@
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set
from typing import Dict, List, Any, Tuple, TypedDict, ClassVar, Union, Set, TextIO
from logging import warning
from BaseClasses import Region, Location, Item, Tutorial, ItemClassification, MultiWorld, CollectionState
from .items import (item_name_to_id, item_table, item_name_groups, fool_tiers, filler_items, slot_data_item_names,
@@ -78,7 +78,8 @@ class TunicWorld(World):
settings: ClassVar[TunicSettings]
item_name_groups = item_name_groups
location_name_groups = location_name_groups
location_name_groups.update(grass_location_name_groups)
for group_name, members in grass_location_name_groups.items():
location_name_groups.setdefault(group_name, set()).update(members)
item_name_to_id = item_name_to_id
location_name_to_id = standard_location_name_to_id.copy()
@@ -241,10 +242,18 @@ class TunicWorld(World):
def create_item(self, name: str, classification: ItemClassification = None) -> TunicItem:
item_data = item_table[name]
# if item_data.combat_ic is None, it'll take item_data.classification instead
itemclass: ItemClassification = ((item_data.combat_ic if self.options.combat_logic else None)
# evaluate alternate classifications based on options
# it'll choose whichever classification isn't None first in this if else tree
itemclass: ItemClassification = (classification
or (item_data.combat_ic if self.options.combat_logic else None)
or (ItemClassification.progression | ItemClassification.useful
if name == "Glass Cannon" and self.options.grass_randomizer
and not self.options.start_with_sword else None)
or (ItemClassification.progression | ItemClassification.useful
if name == "Shield" and self.options.ladder_storage
and not self.options.ladder_storage_without_items else None)
or item_data.classification)
return TunicItem(name, classification or itemclass, self.item_name_to_id[name], self.player)
return TunicItem(name, itemclass, self.item_name_to_id[name], self.player)
def create_items(self) -> None:
tunic_items: List[TunicItem] = []
@@ -277,8 +286,6 @@ class TunicWorld(World):
if self.options.grass_randomizer:
items_to_create["Grass"] = len(grass_location_table)
tunic_items.append(self.create_item("Glass Cannon", ItemClassification.progression))
items_to_create["Glass Cannon"] = 0
for grass_location in excluded_grass_locations:
self.get_location(grass_location).place_locked_item(self.create_item("Grass"))
items_to_create["Grass"] -= len(excluded_grass_locations)
@@ -331,10 +338,11 @@ class TunicWorld(World):
remove_filler(items_to_create[gold_hexagon])
# Sort for deterministic order
for hero_relic in sorted(item_name_groups["Hero Relics"]):
tunic_items.append(self.create_item(hero_relic, ItemClassification.useful))
items_to_create[hero_relic] = 0
if not self.options.combat_logic:
# Sort for deterministic order
for hero_relic in sorted(item_name_groups["Hero Relics"]):
tunic_items.append(self.create_item(hero_relic, ItemClassification.useful))
items_to_create[hero_relic] = 0
if not self.options.ability_shuffling:
# Sort for deterministic order
@@ -349,11 +357,6 @@ class TunicWorld(World):
tunic_items.append(self.create_item(page, ItemClassification.progression | ItemClassification.useful))
items_to_create[page] = 0
# logically relevant if you have ladder storage enabled
if self.options.ladder_storage and not self.options.ladder_storage_without_items:
tunic_items.append(self.create_item("Shield", ItemClassification.progression))
items_to_create["Shield"] = 0
if self.options.maskless:
tunic_items.append(self.create_item("Scavenger Mask", ItemClassification.useful))
items_to_create["Scavenger Mask"] = 0
@@ -411,7 +414,7 @@ class TunicWorld(World):
def stage_pre_fill(cls, multiworld: MultiWorld) -> None:
tunic_fill_worlds: List[TunicWorld] = [world for world in multiworld.get_game_worlds("TUNIC")
if world.options.local_fill.value > 0]
if tunic_fill_worlds:
if tunic_fill_worlds and multiworld.players > 1:
grass_fill: List[TunicItem] = []
non_grass_fill: List[TunicItem] = []
grass_fill_locations: List[Location] = []
@@ -500,6 +503,13 @@ class TunicWorld(World):
state.tunic_need_to_reset_combat_from_remove[self.player] = True
return change
def write_spoiler_header(self, spoiler_handle: TextIO):
if self.options.hexagon_quest and self.options.ability_shuffling:
spoiler_handle.write("\nAbility Unlocks (Hexagon Quest):\n")
for ability in self.ability_unlocks:
# Remove parentheses for better readability
spoiler_handle.write(f'{ability[ability.find("(")+1:ability.find(")")]}: {self.ability_unlocks[ability]} Gold Questagons\n')
def extend_hint_information(self, hint_data: Dict[int, Dict[int, str]]) -> None:
if self.options.entrance_rando:
hint_data.update({self.player: {}})

View File

@@ -7767,8 +7767,10 @@ grass_location_name_to_id: Dict[str, int] = {name: location_base_id + 302 + inde
grass_location_name_groups: Dict[str, Set[str]] = {}
for loc_name, loc_data in grass_location_table.items():
loc_group_name = loc_name.split(" - ", 1)[0] + " Grass"
grass_location_name_groups.setdefault(loc_group_name, set()).add(loc_name)
area_name = loc_name.split(" - ", 1)[0]
# adding it to the normal location group and a grass-only one
grass_location_name_groups.setdefault(area_name, set()).add(loc_name)
grass_location_name_groups.setdefault(area_name + " Grass", set()).add(loc_name)
def can_break_grass(state: CollectionState, world: "TunicWorld") -> bool:

View File

@@ -48,6 +48,7 @@ item_table: Dict[str, TunicItemData] = {
"Gun": TunicItemData(IC.progression | IC.useful, 1, 30, "Weapons"),
"Shield": TunicItemData(IC.useful, 1, 31, combat_ic=IC.progression | IC.useful),
"Dath Stone": TunicItemData(IC.useful, 1, 32),
"Torch": TunicItemData(IC.useful, 0, 156),
"Hourglass": TunicItemData(IC.useful, 1, 33),
"Old House Key": TunicItemData(IC.progression, 1, 34, "Keys"),
"Key": TunicItemData(IC.progression, 2, 35, "Keys"),

View File

@@ -29,7 +29,7 @@ class KeysBehindBosses(Toggle):
display_name = "Keys Behind Bosses"
class AbilityShuffling(Toggle):
class AbilityShuffling(DefaultOnToggle):
"""
Locks the usage of Prayer, Holy Cross*, and the Icebolt combo until the relevant pages of the manual have been found.
If playing Hexagon Quest, abilities are instead randomly unlocked after obtaining 25%, 50%, and 75% of the required Hexagon goal amount.
@@ -173,7 +173,7 @@ class LocalFill(NamedRange):
internal_name = "local_fill"
display_name = "Local Fill Percent"
range_start = 0
range_end = 100
range_end = 98
special_range_names = {
"default": -1
}
@@ -290,30 +290,37 @@ class LogicRules(Choice):
@dataclass
class TunicOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
sword_progression: SwordProgression
start_with_sword: StartWithSword
keys_behind_bosses: KeysBehindBosses
ability_shuffling: AbilityShuffling
shuffle_ladders: ShuffleLadders
entrance_rando: EntranceRando
fixed_shop: FixedShop
fool_traps: FoolTraps
laurels_location: LaurelsLocation
hexagon_quest: HexagonQuest
hexagon_goal: HexagonGoal
extra_hexagon_percentage: ExtraHexagonPercentage
laurels_location: LaurelsLocation
shuffle_ladders: ShuffleLadders
grass_randomizer: GrassRandomizer
local_fill: LocalFill
entrance_rando: EntranceRando
fixed_shop: FixedShop
combat_logic: CombatLogic
lanternless: Lanternless
maskless: Maskless
grass_randomizer: GrassRandomizer
local_fill: LocalFill
laurels_zips: LaurelsZips
ice_grappling: IceGrappling
ladder_storage: LadderStorage
ladder_storage_without_items: LadderStorageWithoutItems
plando_connections: TunicPlandoConnections
logic_rules: LogicRules
tunic_option_groups = [
OptionGroup("Logic Options", [

View File

@@ -1,26 +1,24 @@
from typing import Dict, Set
tunic_regions: Dict[str, Set[str]] = {
"Menu": {"Overworld"},
"Overworld": {"Overworld Holy Cross", "East Forest", "Dark Tomb", "Beneath the Well", "West Garden",
tunic_regions: dict[str, tuple[str]] = {
"Menu": ("Overworld",),
"Overworld": ("Overworld Holy Cross", "East Forest", "Dark Tomb", "Beneath the Well", "West Garden",
"Ruined Atoll", "Eastern Vault Fortress", "Beneath the Vault", "Quarry Back", "Quarry", "Swamp",
"Spirit Arena"},
"Overworld Holy Cross": set(),
"East Forest": set(),
"Dark Tomb": {"West Garden"},
"Beneath the Well": set(),
"West Garden": set(),
"Ruined Atoll": {"Frog's Domain", "Library"},
"Frog's Domain": set(),
"Library": set(),
"Eastern Vault Fortress": {"Beneath the Vault"},
"Beneath the Vault": {"Eastern Vault Fortress"},
"Quarry Back": {"Quarry"},
"Quarry": {"Monastery", "Lower Quarry"},
"Monastery": set(),
"Lower Quarry": {"Rooted Ziggurat"},
"Rooted Ziggurat": set(),
"Swamp": {"Cathedral"},
"Cathedral": set(),
"Spirit Arena": set()
"Spirit Arena"),
"Overworld Holy Cross": tuple(),
"East Forest": tuple(),
"Dark Tomb": ("West Garden",),
"Beneath the Well": tuple(),
"West Garden": tuple(),
"Ruined Atoll": ("Frog's Domain", "Library"),
"Frog's Domain": tuple(),
"Library": tuple(),
"Eastern Vault Fortress": ("Beneath the Vault",),
"Beneath the Vault": ("Eastern Vault Fortress",),
"Quarry Back": ("Quarry",),
"Quarry": ("Monastery", "Lower Quarry"),
"Monastery": tuple(),
"Lower Quarry": ("Rooted Ziggurat",),
"Rooted Ziggurat": tuple(),
"Swamp": ("Cathedral",),
"Cathedral": tuple(),
"Spirit Arena": tuple()
}

View File

@@ -9,8 +9,7 @@ import logging
from typing_extensions import override
from BaseClasses import ItemClassification, LocationProgressType, \
MultiWorld, Item, CollectionState, Entrance, Tutorial
from BaseClasses import LocationProgressType, MultiWorld, Item, CollectionState, Entrance, Tutorial
from .gen_data import GenData
from .logic import ZillionLogicCache
@@ -19,12 +18,13 @@ from .options import ZillionOptions, validate, z_option_groups
from .id_maps import ZillionSlotInfo, get_slot_info, item_name_to_id as _item_name_to_id, \
loc_name_to_id as _loc_name_to_id, make_id_to_others, \
zz_reg_name_to_reg_name, base_id
from .item import ZillionItem
from .item import ZillionItem, get_classification
from .patch import ZillionPatch
from zilliandomizer.system import System
from zilliandomizer.logic_components.items import RESCUE, items as zz_items, Item as ZzItem
from zilliandomizer.logic_components.locations import Location as ZzLocation, Req
from zilliandomizer.map_gen.region_maker import DEAD_END_SUFFIX
from zilliandomizer.options import Chars
from worlds.AutoWorld import World, WebWorld
@@ -119,8 +119,13 @@ class ZillionWorld(World):
"""
my_locations: list[ZillionLocation] = []
""" This is kind of a cache to avoid iterating through all the multiworld locations in logic. """
slot_data_ready: threading.Event
""" This event is set in `generate_output` when the data is ready for `fill_slot_data` """
finalized_gen_data: GenData | None
""" Finalized generation data needed by `generate_output` and by `fill_slot_data`. """
item_locations_finalization_lock: threading.Lock
"""
This lock is used in `generate_output` and `fill_slot_data` to ensure synchronized access to `finalized_gen_data`,
so that whichever is run first can finalize the item locations while the other waits.
"""
logic_cache: ZillionLogicCache | None = None
def __init__(self, world: MultiWorld, player: int) -> None:
@@ -128,7 +133,8 @@ class ZillionWorld(World):
self.logger = logging.getLogger("Zillion")
self.lsi = ZillionWorld.LogStreamInterface(self.logger)
self.zz_system = System()
self.slot_data_ready = threading.Event()
self.finalized_gen_data = None
self.item_locations_finalization_lock = threading.Lock()
def _make_item_maps(self, start_char: Chars) -> None:
_id_to_name, _id_to_zz_id, id_to_zz_item = make_id_to_others(start_char)
@@ -167,6 +173,7 @@ class ZillionWorld(World):
self.logic_cache = logic_cache
w = self.multiworld
self.my_locations = []
dead_end_locations: list[ZillionLocation] = []
self.zz_system.randomizer.place_canister_gun_reqs()
# low probability that place_canister_gun_reqs() results in empty 1st sphere
@@ -219,6 +226,16 @@ class ZillionWorld(World):
here.locations.append(loc)
self.my_locations.append(loc)
if ((
zz_here.name.endswith(DEAD_END_SUFFIX)
) or (
(self.options.map_gen.value != self.options.map_gen.option_full) and
(loc.name in self.options.priority_dead_ends.vanilla_dead_ends)
) or (
loc.name in self.options.priority_dead_ends.always_dead_ends
)):
dead_end_locations.append(loc)
for zz_dest in zz_here.connections.keys():
dest_name = "Menu" if zz_dest.name == "start" else zz_reg_name_to_reg_name(zz_dest.name)
dest = all_regions[dest_name]
@@ -228,6 +245,8 @@ class ZillionWorld(World):
queue.append(zz_dest)
done.add(here.name)
if self.options.priority_dead_ends.value:
self.options.priority_locations.value |= {loc.name for loc in dead_end_locations}
@override
def create_items(self) -> None:
@@ -305,6 +324,19 @@ class ZillionWorld(World):
self.zz_system.post_fill()
def finalize_item_locations_thread_safe(self) -> GenData:
"""
Call self.finalize_item_locations() and cache the result in a thread-safe manner so that either
`generate_output` or `fill_slot_data` can finalize item locations without concern for which of the two functions
is called first.
"""
# The lock is acquired when entering the context manager and released when exiting the context manager.
with self.item_locations_finalization_lock:
# If generation data has yet to be finalized, finalize it.
if self.finalized_gen_data is None:
self.finalized_gen_data = self.finalize_item_locations()
return self.finalized_gen_data
def finalize_item_locations(self) -> GenData:
"""
sync zilliandomizer item locations with AP item locations
@@ -363,12 +395,7 @@ class ZillionWorld(World):
def generate_output(self, output_directory: str) -> None:
"""This method gets called from a threadpool, do not use multiworld.random here.
If you need any last-second randomization, use self.random instead."""
try:
gen_data = self.finalize_item_locations()
except BaseException:
raise
finally:
self.slot_data_ready.set()
gen_data = self.finalize_item_locations_thread_safe()
out_file_base = self.multiworld.get_out_file_name_base(self.player)
@@ -392,9 +419,7 @@ class ZillionWorld(World):
# TODO: tell client which canisters are keywords
# so it can open and get those when restoring doors
self.slot_data_ready.wait()
assert self.zz_system.randomizer, "didn't get randomizer from generate_early"
game = self.zz_system.get_game()
game = self.finalize_item_locations_thread_safe().zz_game
return get_slot_info(game.regions, game.char_order[0], game.loc_name_2_pretty)
# end of ordered Main.py calls
@@ -410,12 +435,8 @@ class ZillionWorld(World):
self.logger.warning("warning: called `create_item` without calling `generate_early` first")
assert self.id_to_zz_item, "failed to get item maps"
classification = ItemClassification.filler
zz_item = self.id_to_zz_item[item_id]
if zz_item.required:
classification = ItemClassification.progression
if not zz_item.is_progression:
classification = ItemClassification.progression_skip_balancing
classification = get_classification(name, zz_item, self._item_counts)
z_item = ZillionItem(name, classification, item_id, self.player, zz_item)
return z_item

View File

@@ -1,6 +1,34 @@
from typing import Counter
from BaseClasses import Item, ItemClassification as IC
from zilliandomizer.logic_components.items import Item as ZzItem
_useful_thresholds = {
"Apple": 9999,
"Champ": 9999,
"JJ": 9999,
"Win": 9999,
"Empty": 0,
"ID Card": 10,
"Red ID Card": 2,
"Floppy Disk": 7,
"Bread": 0,
"Opa-Opa": 20,
"Zillion": 8,
"Scope": 8,
}
""" make the item useful if the number in the item pool is below this number """
def get_classification(name: str, zz_item: ZzItem, item_counts: Counter[str]) -> IC:
classification = IC.filler
if zz_item.required:
classification = IC.progression
if not zz_item.is_progression:
classification = IC.progression_skip_balancing
if item_counts[name] < _useful_thresholds.get(name, 0):
classification |= IC.useful
return classification
class ZillionItem(Item):
game = "Zillion"

View File

@@ -272,6 +272,20 @@ class ZillionMapGen(Choice):
return "full"
class ZillionPriorityDeadEnds(DefaultOnToggle):
"""
Single locations that are in a dead end behind a door
(example: vanilla Apple location)
are prioritized for progression items.
"""
display_name = "priority dead ends"
vanilla_dead_ends: ClassVar = frozenset(("E-5 top far right", "J-4 top left"))
""" dead ends when not generating these rooms """
always_dead_ends: ClassVar = frozenset(("A-6 top right",))
""" dead ends in rooms that never get generated """
@dataclass
class ZillionOptions(PerGameCommonOptions):
continues: ZillionContinues
@@ -293,6 +307,7 @@ class ZillionOptions(PerGameCommonOptions):
skill: ZillionSkill
starting_cards: ZillionStartingCards
map_gen: ZillionMapGen
priority_dead_ends: ZillionPriorityDeadEnds
room_gen: Removed

View File

@@ -1,2 +1,2 @@
zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@33045067f626266850f91c8045b9d3a9f52d02b0#0.9.0
zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@96d9a20f8278cee64bb4db859fbd874e0f332d36#0.9.1
typing-extensions>=4.7, <5

View File

@@ -1,6 +1,7 @@
from . import ZillionTestBase
from ..options import ZillionJumpLevels, ZillionGunLevels, ZillionOptions, validate
from .. import ZillionWorld
from ..options import ZillionJumpLevels, ZillionGunLevels, ZillionOptions, ZillionPriorityDeadEnds, validate
from zilliandomizer.options import VBLR_CHOICES
@@ -28,3 +29,17 @@ class OptionsTest(ZillionTestBase):
assert getattr(zz_options, option_name) in VBLR_CHOICES
# TODO: test validate with invalid combinations of options
class DeadEndsTest(ZillionTestBase):
def test_vanilla_dead_end_names(self) -> None:
z_world = self.multiworld.worlds[1]
assert isinstance(z_world, ZillionWorld)
for loc_name in ZillionPriorityDeadEnds.vanilla_dead_ends:
assert any(loc.name == loc_name for loc in z_world.my_locations), f"{loc_name=} {z_world.my_locations=}"
def test_always_dead_end_names(self) -> None:
z_world = self.multiworld.worlds[1]
assert isinstance(z_world, ZillionWorld)
for loc_name in ZillionPriorityDeadEnds.always_dead_ends:
assert any(loc.name == loc_name for loc in z_world.my_locations), f"{loc_name=} {z_world.my_locations=}"