Compare commits

..

38 Commits

Author SHA1 Message Date
NewSoupVi
9657f92932 Update docs/world maintainer.md
Co-authored-by: RoobyRoo <thegreenrobby@gmail.com>
2026-02-09 00:06:59 +01:00
NewSoupVi
35e667791f Update docs/world maintainer.md
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2026-02-08 20:49:03 +01:00
NewSoupVi
ce7c54ef9d Docs: Add ability for world maintainers to opt into allowing core to merge small PRs without their approval 2025-12-02 00:14:15 +01:00
black-sliver
ac84b272c5 CI: update appimage runtime to fix problems with sleep (#5706)
also updates appimagetool.
Old tool should be compatible, but there are 2 bug fixes in it.
2025-12-01 01:25:06 +01:00
Phaneros
e8a63abfa4 weights: Fixing negatives and zeroes disappearing from option dicts updated by triggers (#5677) 2025-11-30 13:36:36 +01:00
Silvris
3fa2745c37 OptionCreator: pre-RC1 fixes (#5680)
* fix str default on text choice

* fix range with default random

* forgot module update

* handle namedrange default special

* handle option group of options we should not render

* Update OptionsCreator.py

* Update OptionsCreator.py

* grammar
2025-11-30 01:23:13 +01:00
Doug Hoskisson
775065715d SNIClient: new SnesReader interface (#5155)
* SNIClient: new SnesReader interface

* fix Python 3.8 compatibility
`bisect_right`

* move to worlds
because we don't have good separation importable modules and entry points

* `read` gives object that contains data

* remove python 3.10 implementation and update typing

* remove obsolete comment

* freeze _MemRead and assert type of get parameter

* some optimization in `SnesData.get`

* pass context to `read` so that we can have a static instance of `SnesReader`

* add docstring to `SnesReader`

* remove unused import

* break big reads into chunks

* some minor improvements

- `dataclass` instead of `NamedTuple` for `Read`
- comprehension in `SnesData.__init__`
- `slots` for dataclasses

* update chunk size to 2048
2025-11-30 01:22:35 +01:00
Doug Hoskisson
4e608b13ae Docs: fix name of "Build APWorlds" component (#5703) 2025-11-30 01:18:11 +01:00
Colin
886cc68051 Timespinner: Exclude Removed Location from Web Tracker (#5701) 2025-11-29 19:13:43 +01:00
Ziktofel
146a314d22 SC2: Update Infested Banshee description to be more clear when the Burrow is unlocked #5685 2025-11-29 19:12:29 +01:00
Phaneros
18cf1bce36 sc2: Item group fixes and new item groups (#5679)
* sc2: Fixing missing buildings in Terran buildings group; adding sc1 and melee unit groups

* sc2: Removing out-of-place comment
2025-11-29 19:12:04 +01:00
wildham
f7e3f4e589 [FFMQ] Bugfix: Fix missing logic rule for Frozen Fields > Aquaria access 2025-11-29 19:11:07 +01:00
BlastSlimey
9f9765b78d shapez: Fix logic bug with vanilla shapes and floating layers #5623 2025-11-29 19:10:37 +01:00
Scipio Wright
8ae1a7da32 TUNIC: Fix fuse rule in lower zig #5621 2025-11-29 19:09:55 +01:00
Mysteryem
08ea3fe225 ALTTP: Fix setting Beat Agahnim 1 event twice (#5617)
alttp was setting the `Beat Agahnim 1` event onto the `Agahnim 1` location twice.

I was debugging a multiworld generation issue with various custom worlds, where, for debugging purposes, I changed `multiworld.push_item` to make it crash like `location.place_locked_item` when the location was already filled, which also identified this minor issue in alttp.
2025-11-29 19:09:30 +01:00
massimilianodelliubaldini
b81be6b4fc Jak and Daxter: Second attempt at fixing trade tests. #5599 2025-11-29 19:08:39 +01:00
LiquidCat64
f1aca0fc46 CVCotM: Add a client safeguard in case the player doesn't have Dash Boots #5500 2025-11-29 19:07:02 +01:00
Exempt-Medic
d88fe99780 DS3: Update/Fix Excluded Locations Logging (#5220)
* DS3: Fix Excluded Locations in Spoiler Log

* Update __init__.py

* update wording

* Comment out failing code
2025-11-29 19:04:07 +01:00
Carter Hesterman
360a1384f2 Civ6: Fix issue with names including civ-breaking characters (#5204) 2025-11-29 19:02:15 +01:00
Duck
d089b00ad5 Core: Add spaces in concatenated strings #5697 2025-11-29 18:52:08 +01:00
Duck
c05a2adc38 Wargroove: Add space in concatenated string #5696 2025-11-29 18:51:20 +01:00
Duck
7631242621 MLSS: Add space in concatenated string #5694 2025-11-29 18:50:34 +01:00
Duck
df48c3e718 KH1: Add space in concatenated string #5693 2025-11-29 18:48:46 +01:00
Duck
9a755e64b2 Jak and Daxter: Add space in concatenated string #5692 2025-11-29 18:48:23 +01:00
Duck
34d362a003 CV64/CVCotM: Add spaces in concatenated strings (#5691)
* Possible space removal

* Add spaces

* Missed one

* Revert removals, use newline
2025-11-29 18:47:54 +01:00
Duck
b75cce5d41 TLOZ: Add space in concatenated string #5690 2025-11-29 18:47:17 +01:00
threeandthreee
a07faca2d9 LADX: catch exception after closing magpie #5687 2025-11-29 18:46:22 +01:00
Phaneros
8a1a715dc4 SC2: logic fixes minor bugs (#5660)
* Pulsars no longer count as basic anti-air for protoss.
  * This is in response to player feedback that they were just too weak DPS-wise
* Haven's Fall (P) logic loosened slightly.
  * Void rays are now a one-unit solution to the rule
  * Scouts are now considered a one-unit solution to the rule
  * Two-unit solutions are now considered standard rather than advanced
  * Caladrius is now listed as an anti-muta unit for the two-unit solutions
  * This was discussed in the #SC2-dev channel.
    * Snarky did some testing and found that void rays were barely any worse than destroyers at handling mutas, and destroyers are already listed as a one-unit solution.
    * Snarky also found that scouts could mostly solo the mission at low skill level
    * Note that this rule only applies to the "beating the infestations" part of the mission; there are additional requirements for beating it, including a competent comp.
* The Host (T) now also can use SoA abilities if SoA presence is set to `any_race_lotv`, not just `everywhere`
2025-11-29 01:46:41 +01:00
Duck
60a192b1b6 ALttP/Factorio: Add spaces in concatenated strings (#5564)
* Add them

* Revert "Add them"

This reverts commit 82be86191f.

* Re-add ALttP/Factorio
2025-11-27 19:51:23 +01:00
NewSoupVi
3b721e0365 Tests: Move hosting test to APQuest #5671 2025-11-26 20:55:35 +01:00
Benny D
3e16c20fce PyCharm: fix the apworld builder run config (#5678)
* fix the apworld builder pycharm runner

* Update Build APWorld.run.xml

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-11-26 12:45:12 +01:00
Emerassi
ec2c39e82f Docs: Improve the documentation for priority locations to mention de-prioritized (#5631)
* Update the descriptions for priority and exclude locations to be more clear.

* Revision on priority

* Moved my change over to the documentation instead of the generated yaml comment.

* update per vi feedback

* Trying a 2 sentence approach

* more details!

* Update options api.md

* Update options api.md
2025-11-26 01:00:25 +01:00
NewSoupVi
23d319247f APQuest: Fix import of Protocol from bokeh instead of typing (#5674)
* APQuest: Fix import of Protocol from bokeh instead of typing

* bump world version
2025-11-25 23:45:55 +01:00
Fabian Dill
c2c488410f Core: Fix typo in docstring for hint_points in commonclient (#5673) 2025-11-25 22:40:57 +01:00
Fabian Dill
8ea49e76db Core: updates of requirements (#5672) 2025-11-25 22:40:32 +01:00
Phaneros
d834ecec6a SC2: Fix bugs and issues around excluded/unexcluded (#5644) 2025-11-25 20:44:07 +01:00
threeandthreee
f3000a89d4 LADX: Give better feedback during patching (#5401) 2025-11-25 20:42:55 +01:00
qwint
aa2774a5d5 Tests: Move world dependencies in tests to APQuest #5668 2025-11-25 19:26:37 +01:00
63 changed files with 1206 additions and 656 deletions

View File

@@ -24,10 +24,10 @@ env:
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'

View File

@@ -12,10 +12,10 @@ env:
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated.
APPIMAGE_FORK: 'PopTracker'
APPIMAGETOOL_VERSION: 'r-2025-10-19'
APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: 'r-2025-08-11'
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'
APPIMAGETOOL_VERSION: 'r-2025-11-18'
APPIMAGETOOL_X86_64_HASH: '4577a452b30af2337123fbb383aea154b618e51ad5448c3b62085cbbbfbfd9a2'
APPIMAGE_RUNTIME_VERSION: 'r-2025-11-07'
APPIMAGE_RUNTIME_X86_64_HASH: '27ddd3f78e483fc5f7856e413d7c17092917f8c35bfe3318a0d378aa9435ad17'
permissions: # permissions required for attestation
id-token: 'write'

View File

@@ -12,8 +12,8 @@
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/Launcher.py" />
<option name="PARAMETERS" value="&quot;Build APWorlds&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />

View File

@@ -323,7 +323,7 @@ class CommonContext:
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
"""Current available Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""

View File

@@ -347,7 +347,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
elif isinstance(new_value, list):
cleaned_value.extend(new_value)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) + Counter(new_value))
counter_value = Counter(cleaned_value)
counter_value.update(new_value)
cleaned_value = dict(counter_value)
else:
raise Exception(f"Cannot apply merge to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")
@@ -361,7 +363,9 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
for element in new_value:
cleaned_value.remove(element)
elif isinstance(new_value, dict):
cleaned_value = dict(Counter(cleaned_value) - Counter(new_value))
counter_value = Counter(cleaned_value)
counter_value.subtract(new_value)
cleaned_value = dict(counter_value)
else:
raise Exception(f"Cannot apply remove to non-dict, set, or list type {option_name},"
f" received {type(new_value).__name__}.")

View File

@@ -493,7 +493,7 @@ class Context:
self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0)
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, "
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})

View File

@@ -1545,6 +1545,7 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
default = ()
supports_weighting = False
display_name = "Plando Items"
visibility = Visibility.template | Visibility.spoiler
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))

View File

@@ -1,3 +1,9 @@
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from kvui import (ThemedApp, ScrollBox, MainLayout, ContainerLayout, dp, Widget, MDBoxLayout, TooltipLabel, MDLabel,
ToggleButton, MarkupDropdown, ResizableTextField)
from kivy.uix.behaviors.button import ButtonBehavior
@@ -330,6 +336,11 @@ class OptionsCreator(ThemedApp):
box.range.slider.dropdown.open()
box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name))
if option.default in option.special_range_names:
# value can get mismatched in this case
box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start),
option.range_end)
box.range.tag.text = str(int(box.range.slider.value))
box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box))
items = [
{
@@ -365,7 +376,7 @@ class OptionsCreator(ThemedApp):
# for some reason this fixes an issue causing some to not open
dropdown.open()
default_random = option.default == "random"
default_string = isinstance(option.default, str)
main_button = VisualChoice(option=option, name=name)
main_button.bind(on_release=open_dropdown)
@@ -377,7 +388,7 @@ class OptionsCreator(ThemedApp):
for choice in option.name_lookup
]
dropdown = MDDropdownMenu(caller=main_button, items=items)
self.options[name] = option.name_lookup[option.default] if not default_random else option.default
self.options[name] = option.name_lookup[option.default] if not default_string else option.default
return main_button
def create_text_choice(self, option: typing.Type[TextChoice], name: str):
@@ -560,8 +571,11 @@ class OptionsCreator(ThemedApp):
groups[group].append((name, option))
for group, options in groups.items():
options = [(name, option) for name, option in options
if name and option.visibility & Visibility.simple_ui]
if not options:
continue # Game Options can be empty if every other option is in another group
# Can also have an option group of options that should not render on simple ui
group_item = MDExpansionPanel(size_hint_y=None)
group_header = MDExpansionPanelHeader(MDListItem(MDListItemSupportingText(text=group),
TrailingPressedIconButton(icon="chevron-right",
@@ -583,8 +597,7 @@ class OptionsCreator(ThemedApp):
group_box.layout.orientation = "vertical"
group_box.layout.spacing = dp(3)
for name, option in options:
if name and option is not Removed and option.visibility & Visibility.simple_ui:
group_content.add_widget(self.create_option(option, name, cls))
group_content.add_widget(self.create_option(option, name, cls))
expansion_box.layout.add_widget(group_item)
self.option_layout.add_widget(expansion_box)
self.game_label.text = f"Game: {self.current_game}"

View File

@@ -959,7 +959,7 @@ if "Timespinner" in network_data_package["games"]:
timespinner_location_ids = {
"Present": list(range(1337000, 1337085)),
"Past": list(range(1337086, 1337175)),
"Past": list(range(1337086, 1337157)) + list(range(1337159, 1337175)),
"Ancient Pyramid": [
1337236,
1337246, 1337247, 1337248, 1337249]

View File

@@ -289,7 +289,7 @@ async def nes_sync_task(ctx: ZeldaContext):
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate "
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)

View File

@@ -6,12 +6,12 @@
tag: tag
MDLabel:
id: tag
text: str(this.option.default) if this.option.default != "random" else this.option.range_start
text: str(this.option.default) if not isinstance(this.option.default, str) else str(this.option.range_start)
MDSlider:
id: slider
min: this.option.range_start
max: this.option.range_end
value: min(max(this.option.default, this.option.range_start), this.option.range_end) if this.option.default != "random" else this.option.range_start
value: min(max(this.option.default, this.option.range_start), this.option.range_end) if not isinstance(this.option.default, str) else this.option.range_start
step: 1
step_point_size: 0
MDSliderHandle:
@@ -23,7 +23,7 @@
text: text
MDButtonText:
id: text
text: this.option.get_option_name(this.option.default if this.option.default != "random" else list(this.option.options.values())[0])
text: this.option.get_option_name(this.option.default if not isinstance(this.option.default, str) else list(this.option.options.values())[0])
theme_text_color: "Primary"
<VisualNamedRange>:
@@ -38,7 +38,7 @@
text: text
MDButtonText:
id: text
text: this.option.special_range_names.get(list(this.option.special_range_names.values()).index(this.option.default)) if this.option.default in this.option.special_range_names else "Custom"
text: this.option.default.title() if this.option.default in this.option.special_range_names else "Custom"
<VisualFreeText>:
multiline: False

View File

@@ -44,9 +44,9 @@ These get automatically added to the `archipelago.json` of an .apworld if it is
["Build apworlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build apworlds" Launcher Component
### "Build APWorlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
In the Archipelago Launcher, there is a "Build APWorlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate

View File

@@ -269,7 +269,8 @@ placed on them.
### PriorityLocations
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them if any are available in
the pool.
the pool. Progression items without a deprioritized flag will be used first when filling priority_locations. Progression items with
a deprioritized flag will be used next.
### ItemLinks
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between

View File

@@ -21,6 +21,29 @@ Unless these are shared between multiple people, we expect the following from ea
of development.
* Let us know of long periods of unavailability.
## Authority
For a Pull Request into a world to be merged, one of the world maintainers of that world has to approve it.
This applies to all Pull Requests, no matter how small, with the sole exception of patching security vulnerabilities.
World maintainers can partially opt out of this,
allowing core maintainers to merge pull requests which they deem critical and "obvious" enough.
There is no one singular definition of what Pull Requests fit this criteria -
You are trusting the core maintainers of Archipelago to be reasonable about their judgement.
Some examples of Pull Requests like this include:
- Fixing a broken link in documentation
- Correcting a typo
- Fixing a crash where the intent of the code is obvious (e.g. an indentation error due to typing 3 spaces instead of 4)
To do this, they can add a comment in [CODEOWNERS](./CODEOWNERS) under their game:
```
# APQuest
# Core is allowed to merge some types of PRs without my approval as described in "world maintainer.md"
/worlds/apquest/ @NewSoupVi
```
## Becoming a World Maintainer
### Adding a World

View File

@@ -525,7 +525,7 @@ def randomize_entrances(
running_time = time.perf_counter() - start_time
if running_time > 1.0:
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player}, "
f"named {world.multiworld.player_name[world.player]}")
return er_state

View File

@@ -1,17 +1,17 @@
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.2
jellyfish>=1.1.3
PyYAML>=6.0.3
jellyfish>=1.2.1
jinja2>=3.1.6
schema>=0.7.7
schema>=0.7.8
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.3.6
certifi>=2025.4.26
cython>=3.0.12
cymem>=2.0.11
orjson>=3.10.15
typing_extensions>=4.12.2
pyshortcuts>=1.9.1
platformdirs>=4.5.0
certifi>=2025.11.12
cython>=3.2.1
cymem>=2.0.13
orjson>=3.11.4
typing_extensions>=4.15.0
pyshortcuts>=1.9.6
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0

View File

@@ -394,11 +394,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
f"World directory {world_directory} has an archipelago.json manifest file, but it "
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:

View File

@@ -44,19 +44,19 @@ class TestOptions(unittest.TestCase):
}],
[{
"name": "ItemLinkGroup",
"item_pool": ["Hammer", "Bow"],
"item_pool": ["Hammer", "Sword"],
"link_replacement": False,
"replacement_item": None,
}]
]
# we really need some sort of test world but generic doesn't have enough items for this
world = AutoWorldRegister.world_types["A Link to the Past"]
world = AutoWorldRegister.world_types["APQuest"]
plando_options = PlandoOptions.from_option_string("bosses")
item_links = [ItemLinks.from_any(item_link_groups[0]), ItemLinks.from_any(item_link_groups[1])]
for link in item_links:
link.verify(world, "tester", plando_options)
self.assertIn("Hammer", link.value[0]["item_pool"])
self.assertIn("Bow", link.value[0]["item_pool"])
self.assertIn("Sword", link.value[0]["item_pool"])
# TODO test that the group created using these options has the items

View File

@@ -37,3 +37,23 @@ class TestPlayerOptions(unittest.TestCase):
self.assertEqual(new_weights["dict_2"]["option_g"], 50)
self.assertEqual(len(new_weights["set_1"]), 2)
self.assertIn("option_d", new_weights["set_1"])
def test_update_dict_supports_negatives_and_zeroes(self):
original_options = {
"dict_1": {"a": 1, "b": -1},
"dict_2": {"a": 1, "b": -1},
}
new_weights = Generate.update_weights(
original_options,
{
"+dict_1": {"a": -2, "b": 2},
"-dict_2": {"a": 1, "b": 2},
},
"Tested",
"",
)
self.assertEqual(new_weights["dict_1"]["a"], -1)
self.assertEqual(new_weights["dict_1"]["b"], 1)
self.assertEqual(new_weights["dict_2"]["a"], 0)
self.assertEqual(new_weights["dict_2"]["b"], -3)
self.assertIn("a", new_weights["dict_2"])

View File

@@ -70,13 +70,13 @@ if __name__ == "__main__":
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
multis = [["APQuest"], ["Temp World"], ["APQuest", "Temp World"]]
p1_games: list[str] = []
data_paths: list[Path | None] = []
rooms: list[str] = []
multidata: Path | None
copy_world("VVVVVV", "Temp World")
copy_world("APQuest", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)} offline")

View File

@@ -20,7 +20,7 @@ def copy(src: str, dst: str) -> None:
src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
if (not src_cls.__file__.endswith(("__init__.py", "world.py")) or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name
@@ -28,11 +28,14 @@ def copy(src: str, dst: str) -> None:
raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
for potential_world_class_file in ("__init__.py", "world.py"):
with open(dst_folder / potential_world_class_file, "r", encoding="utf-8-sig") as f:
contents = f.read()
r_src = re.escape(src)
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + r_src + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None:

View File

@@ -2,8 +2,8 @@ description: Almost blank test yaml
name: Player{NUMBER}
game:
Timespinner: 1 # what else
APQuest: 1 # what else
requires:
version: 0.2.6
Timespinner: {}
APQuest: {}

View File

@@ -1,14 +1,26 @@
from __future__ import annotations
import abc
from bisect import bisect_right
from dataclasses import dataclass
import enum
import logging
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard
from typing import (TYPE_CHECKING, Any, ClassVar, Dict, Generic, Iterable,
Optional, Sequence, Tuple, TypeGuard, TypeVar, Union)
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
if TYPE_CHECKING:
from SNIClient import SNIContext
SNES_READ_CHUNK_SIZE = 2048
"""
note: SNI v0.0.101 currently has a bug where reads from
RetroArch >2048 bytes will only return the last ~2048 bytes read.
https://github.com/alttpo/sni/issues/51
"""
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
components.append(component)
@@ -91,3 +103,119 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
""" override this with code to handle packages from the server """
pass
@dataclass(frozen=True, slots=True, order=True)
class Read:
""" snes memory read - address and size in bytes """
address: int
size: int
@dataclass(frozen=True, slots=True)
class _MemRead:
location: Read
data: bytes
_T_Enum = TypeVar("_T_Enum", bound=enum.Enum)
class SnesData(Generic[_T_Enum]):
_ranges: Sequence[_MemRead]
""" sorted by address """
def __init__(self, ranges: Sequence[tuple[Read, bytes]]) -> None:
self._ranges = [_MemRead(r, d) for r, d in ranges]
def get(self, read: _T_Enum) -> bytes:
assert isinstance(read.value, Read), read.value
address = read.value.address
index = bisect_right(self._ranges, address, key=lambda r: r.location.address) - 1
assert index >= 0, (self._ranges, read.value)
mem_read = self._ranges[index]
sub_index = address - mem_read.location.address
return mem_read.data[sub_index:sub_index + read.value.size]
class SnesReader(Generic[_T_Enum]):
"""
how to use:
```
from enum import Enum
from worlds.AutoSNIClient import Read, SNIClient, SnesReader
class MyGameMemory(Enum):
game_mode = Read(WRAM_START + 0x0998, 1)
send_queue = Read(SEND_QUEUE_START, 8 * 127)
...
snes_reader = SnesReader(MyGameMemory)
snes_data = await snes_reader.read(ctx)
if snes_data is None:
snes_logger.info("error reading from snes")
return
game_mode = snes_data.get(MyGameMemory.game_mode)
```
"""
_ranges: Sequence[Read]
""" sorted by address """
def __init__(self, reads: type[_T_Enum]) -> None:
self._ranges = self._make_ranges(reads)
@staticmethod
def _make_ranges(reads: type[enum.Enum]) -> Sequence[Read]:
unprocessed_reads: list[Read] = []
for e in reads:
assert isinstance(e.value, Read), (reads.__name__, e, e.value)
unprocessed_reads.append(e.value)
unprocessed_reads.sort()
ranges: list[Read] = []
for read in unprocessed_reads:
# v end of the previous range
if len(ranges) == 0 or read.address - (ranges[-1].address + ranges[-1].size) > 255:
ranges.append(read)
else: # combine with previous range
chunk_address = ranges[-1].address
assert read.address >= chunk_address, "sort() didn't work? or something"
original_chunk_size = ranges[-1].size
new_size = max((read.address + read.size) - chunk_address,
original_chunk_size)
ranges[-1] = Read(chunk_address, new_size)
logging.debug(f"{len(ranges)=} {max(r.size for r in ranges)=}")
return ranges
async def read(self, ctx: "SNIContext") -> SnesData[_T_Enum] | None:
"""
returns `None` if reading fails,
otherwise returns the data for the registered `Enum`
"""
from SNIClient import snes_read
reads: list[tuple[Read, bytes]] = []
for r in self._ranges:
if r.size < SNES_READ_CHUNK_SIZE: # most common
response = await snes_read(ctx, r.address, r.size)
if response is None:
return None
reads.append((r, response))
else: # big read
# Problems were reported with big reads,
# so we chunk it into smaller pieces.
read_so_far = 0
collection: list[bytes] = []
while read_so_far < r.size:
remaining_size = r.size - read_so_far
chunk_size = min(SNES_READ_CHUNK_SIZE, remaining_size)
response = await snes_read(ctx, r.address + read_so_far, chunk_size)
if response is None:
return None
collection.append(response)
read_so_far += chunk_size
reads.append((r, b"".join(collection)))
return SnesData(reads)

View File

@@ -291,11 +291,11 @@ if not is_frozen():
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
f"World directory {world_directory} has an archipelago.json manifest file, but it "
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"World directory {world_directory} has an archipelago.json manifest file, but value of the "
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else:
@@ -318,5 +318,5 @@ if not is_frozen():
open_folder(apworlds_folder)
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
components.append(Component("Build APWorlds", func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

View File

@@ -263,7 +263,6 @@ def generate_itempool(world):
('Frog', 'Get Frog'),
('Missing Smith', 'Return Smith'),
('Floodgate', 'Open Floodgate'),
('Agahnim 1', 'Beat Agahnim 1'),
('Flute Activation Spot', 'Activated Flute'),
('Capacity Upgrade Shop', 'Capacity Upgrade Shop')
]

View File

@@ -183,7 +183,7 @@ def check_enemizer(enemizercli):
if getattr(check_enemizer, "done", None):
return
if not os.path.exists(enemizercli) and not os.path.exists(enemizercli + ".exe"):
raise Exception(f"Enemizer not found at {enemizercli}, please install it."
raise Exception(f"Enemizer not found at {enemizercli}, please install it. "
f"Such as https://github.com/Ijwu/Enemizer/releases")
with check_lock:

View File

@@ -1,6 +1,6 @@
{
"game": "APQuest",
"minimum_ap_version": "0.6.4",
"world_version": "1.0.0",
"world_version": "1.0.1",
"authors": ["NewSoupVi"]
}

View File

@@ -2,7 +2,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from math import sqrt
from random import choice, random
from typing import TYPE_CHECKING, Any
from typing import Any
from kivy.core.window import Keyboard, Window
from kivy.graphics import Color, Triangle

View File

@@ -2,9 +2,8 @@ import pkgutil
from collections.abc import Buffer
from enum import Enum
from io import BytesIO
from typing import Literal, NamedTuple, cast
from typing import Literal, NamedTuple, Protocol, cast
from bokeh.protocol import Protocol
from kivy.uix.image import CoreImage
from CommonClient import logger

View File

@@ -16,6 +16,10 @@ def make_data_directory(dir_name: str) -> Path:
gitignore = specific_data_directory / ".gitignore"
with open(gitignore, "w") as f:
f.write("*\n")
f.write(
"""*
!.gitignore
"""
)
return specific_data_directory

View File

@@ -1,6 +1,5 @@
from collections import Counter
from collections.abc import Callable
from typing import TYPE_CHECKING
from .events import Event, LocationClearedEvent, VictoryEvent
from .gameboard import Gameboard

View File

@@ -44,9 +44,10 @@ class CivVIContainer(APPlayerContainer):
opened_zipfile.writestr(filename, yml)
super().write_contents(opened_zipfile)
def sanitize_value(value: str) -> str:
"""Removes values that can cause issues in XML"""
return value.replace('"', "'").replace('&', 'and')
"""Removes values that can cause issues in XML"""
return value.replace('"', "'").replace('&', 'and').replace('{', '').replace('}', '')
def get_cost(world: 'CivVIWorld', location: CivVILocationData) -> int:
@@ -87,8 +88,10 @@ def generate_new_items(world: 'CivVIWorld') -> str:
boost_civics = []
if world.options.boostsanity:
boost_techs = [location for location in locations if location.location_type == CivVICheckType.BOOST and location.name.split("_")[1] == "TECH"]
boost_civics = [location for location in locations if location.location_type == CivVICheckType.BOOST and location.name.split("_")[1] == "CIVIC"]
boost_techs = [location for location in locations if location.location_type ==
CivVICheckType.BOOST and location.name.split("_")[1] == "TECH"]
boost_civics = [location for location in locations if location.location_type ==
CivVICheckType.BOOST and location.name.split("_")[1] == "CIVIC"]
techs += boost_techs
civics += boost_civics

View File

@@ -214,7 +214,7 @@ renon_item_dialogue = {
"\"Banshee Boomerang.\"",
0x10: "No weapon triangle\n"
"advantages with this.",
0x12: "It looks sus? Trust me,"
0x12: "It looks sus? Trust me,\n"
"my wares are genuine.",
0x15: "This non-volatile kind\n"
"is safe to handle.",

View File

@@ -1030,7 +1030,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if CV64_US_10_HASH != basemd5.hexdigest():
raise Exception("Supplied Base Rom does not match known MD5 for Castlevania 64 US 1.0."
raise Exception("Supplied Base Rom does not match known MD5 for Castlevania 64 US 1.0. "
"Get the correct game and version, then dump it.")
setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes)
return base_rom_bytes

View File

@@ -247,6 +247,10 @@ class CastlevaniaCotMClient(BizHawkClient):
await bizhawk.write(ctx.bizhawk_ctx, [(QUEUED_TEXTBOX_1_ADDRESS, [0 for _ in range(12)], "EWRAM")])
return
# If the player doesn't have Dash Boots for whatever reason, put them in their inventory now.
if not magic_items_array[0]:
await bizhawk.write(ctx.bizhawk_ctx, [(MAGIC_ITEMS_ARRAY_START, [1], "EWRAM")])
# Enable DeathLink if it's in our slot_data.
if "DeathLink" not in ctx.tags and ctx.slot_data["death_link"]:
await ctx.update_death_link(True)

View File

@@ -586,7 +586,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
basemd5.update(base_rom_bytes)
# if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH, CVCOTM_VC_US_HASH]:
if basemd5.hexdigest() not in [CVCOTM_CT_US_HASH, CVCOTM_AC_US_HASH]:
raise Exception("Supplied Base ROM does not match known MD5s for Castlevania: Circle of the Moon USA."
raise Exception("Supplied Base ROM does not match known MD5s for Castlevania: Circle of the Moon USA. "
"Get the correct game and version, then dump it.")
setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes)
return base_rom_bytes

View File

@@ -90,6 +90,22 @@ class DarkSouls3World(World):
self.created_regions = set()
self.all_excluded_locations.update(self.options.exclude_locations.value)
# This code doesn't work because tests don't verify options
# Don't consider disabled locations to be AP-excluded
# if not self.options.enable_dlc:
# self.options.exclude_locations.value = {
# location
# for location in self.options.exclude_locations
# if not location_dictionary[location].dlc
# }
# if not self.options.enable_ngp:
# self.options.exclude_locations.value = {
# location for
# location in self.options.exclude_locations
# if not location_dictionary[location].ngp
# }
# Inform Universal Tracker where Yhorm is being randomized to.
if hasattr(self.multiworld, "re_gen_passthrough"):
if "Dark Souls III" in self.multiworld.re_gen_passthrough:
@@ -264,6 +280,13 @@ class DarkSouls3World(World):
):
new_location.progress_type = LocationProgressType.EXCLUDED
else:
# Don't consider non-randomized locations to be AP-excluded
if location.name in excluded:
excluded.remove(location.name)
# Only remove from all_excluded if excluded does not have priority over missable
if not (self.options.missable_location_behavior < self.options.excluded_location_behavior):
self.all_excluded_locations.remove(location.name)
# Don't allow missable duplicates of progression items to be expected progression.
if location.name in self.missable_dupe_prog_locs: continue
@@ -283,11 +306,6 @@ class DarkSouls3World(World):
parent = new_region,
)
new_location.place_locked_item(event_item)
if location.name in excluded:
excluded.remove(location.name)
# Only remove from all_excluded if excluded does not have priority over missable
if not (self.options.missable_location_behavior < self.options.excluded_location_behavior):
self.all_excluded_locations.remove(location.name)
new_region.locations.append(new_location)
@@ -1357,7 +1375,7 @@ class DarkSouls3World(World):
if self.yhorm_location != default_yhorm_location:
text += f"\nYhorm takes the place of {self.yhorm_location.name} in {self.player_name}'s world\n"
if self.options.excluded_location_behavior == "allow_useful":
if self.options.excluded_location_behavior != "forbid_useful":
text += f"\n{self.player_name}'s world excluded: {sorted(self.all_excluded_locations)}\n"
if text:

View File

@@ -584,7 +584,7 @@ def launch(*new_args: str):
# args handling
parser = get_base_parser(description="Optional arguments to Factorio Client follow. "
"Remaining arguments get passed into bound Factorio instance."
"Remaining arguments get passed into bound Factorio instance. "
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')

View File

@@ -155,6 +155,10 @@ def set_rules(self) -> None:
return True
check_foresta(loc.parent_region)
if self.options.map_shuffle or self.options.crest_shuffle:
process_rules(self.multiworld.get_entrance("Subregion Frozen Fields to Subregion Aquaria", self.player),
["SummerAquaria"])
if self.options.logic == "friendly":
process_rules(self.multiworld.get_entrance("Overworld - Ice Pyramid", self.player),
["MagicMirror"])

View File

@@ -601,7 +601,7 @@ async def run_game(ctx: JakAndDaxterContext):
f"Please check your host.yaml file.\n"
f"If the value of 'jakanddaxter_options > auto_detect_root_directory' is true, verify that OpenGOAL "
f"is installed properly.\n"
f"If it is false, check the value of 'jakanddaxter_options > root_directory'."
f"If it is false, check the value of 'jakanddaxter_options > root_directory'. "
f"Verify it is a valid existing path, and all backslashes have been replaced with forward slashes.")
ctx.on_log_error(logger, msg)
return

View File

@@ -7,7 +7,9 @@ class TradesCostNothingTest(JakAndDaxterTestBase):
"global_orbsanity_bundle_size": 10,
"citizen_orb_trade_amount": 0,
"oracle_orb_trade_amount": 0,
"start_inventory": {"Power Cell": 100},
"fire_canyon_cell_count": 0,
"mountain_pass_cell_count": 0,
"lava_tube_cell_count": 0,
}
def test_orb_items_are_filler(self):
@@ -26,7 +28,9 @@ class TradesCostEverythingTest(JakAndDaxterTestBase):
"global_orbsanity_bundle_size": 10,
"citizen_orb_trade_amount": 120,
"oracle_orb_trade_amount": 150,
"start_inventory": {"Power Cell": 100},
"fire_canyon_cell_count": 0,
"mountain_pass_cell_count": 0,
"lava_tube_cell_count": 0,
}
def test_orb_items_are_progression(self):

View File

@@ -30,7 +30,7 @@ class KH1Web(WebWorld):
theme = "ocean"
tutorials = [Tutorial(
"Multiworld Setup Guide",
"A guide to setting up the Kingdom Hearts Randomizer software on your computer."
"A guide to setting up the Kingdom Hearts Randomizer software on your computer. "
"This guide covers single-player, multiworld, and related software.",
"English",
"kh1_en.md",

View File

@@ -1,11 +1,9 @@
import binascii
import importlib.util
import importlib.machinery
import os
import random
import pickle
import Utils
import settings
from collections import defaultdict
from typing import Dict
@@ -65,8 +63,27 @@ from .patches.aesthetics import rgb_to_bin, bin_to_rgb
from .. import Options
class VersionError(Exception):
pass
# Function to generate a final rom, this patches the rom with all required patches
def generateRom(base_rom: bytes, args, patch_data: Dict):
from .. import LinksAwakeningWorld
patcher_version = LinksAwakeningWorld.world_version
generated_version = Utils.tuplize_version(patch_data.get("generated_world_version", "2.0.0"))
if generated_version.major != patcher_version.major or generated_version.minor != patcher_version.minor:
Utils.messagebox(
"Error",
"The apworld version that this patch was generated on is incompatible with your installed world.\n\n"
f"Generated on {generated_version.as_simple_string()}\n"
f"Installed version {patcher_version.as_simple_string()}",
True
)
raise VersionError(
f"The installed world ({patcher_version.as_simple_string()}) is incompatible with the world this patch "
f"was generated on ({generated_version.as_simple_string()})"
)
random.seed(patch_data["seed"] + patch_data["player"])
multi_key = binascii.unhexlify(patch_data["multi_key"].encode())
item_list = pickle.loads(binascii.unhexlify(patch_data["item_list"].encode()))
@@ -85,9 +102,8 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
pymod.prePatch(rom)
if options["gfxmod"]:
user_settings = settings.get_settings()
try:
gfx_mod_file = user_settings["ladx_options"]["gfx_mod_file"]
gfx_mod_file = LinksAwakeningWorld.settings.gfx_mod_file
patches.aesthetics.gfxMod(rom, gfx_mod_file)
except FileNotFoundError:
pass # if user just doesnt provide gfxmod file, let patching continue

View File

@@ -47,6 +47,10 @@ class BadRetroArchResponse(GameboyException):
class BadRetroArchResponse(GameboyException):
pass
class VersionError(Exception):
pass
class LAClientConstants:
@@ -518,7 +522,7 @@ class LinksAwakeningContext(CommonContext):
class LADXManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("Tracker", "Tracker"),
("Tracker", "Tracker"),
]
base_title = f"Links Awakening DX Client {LinksAwakeningWorld.world_version.as_simple_string()} | Archipelago"
@@ -614,11 +618,20 @@ class LinksAwakeningContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
generated_version = Utils.tuplize_version(self.slot_data.get("world_version", "2.0.0"))
client_version = LinksAwakeningWorld.world_version
if generated_version.major != client_version.major:
self.disconnected_intentionally = True
raise VersionError(
f"The installed world ({client_version.as_simple_string()}) is incompatible with "
f"the world this game was generated on ({generated_version.as_simple_string()})"
)
# This is sent to magpie over local websocket to make its own connection
self.slot_data.update({
"server_address": self.server_address,
"slot_name": self.player_names[self.slot],
"password": self.password,
"client_version": client_version.as_simple_string(),
})

View File

@@ -1,4 +1,3 @@
import settings
import worlds.Files
import hashlib
import Utils
@@ -59,6 +58,7 @@ class LADXProcedurePatch(worlds.Files.APProcedurePatch):
def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
item_list = pickle.dumps([item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)])
data_dict = {
"generated_world_version": world.world_version.as_simple_string(),
"out_base": world.multiworld.get_out_file_name_base(patch.player),
"is_race": world.multiworld.is_race,
"seed": world.multiworld.seed,
@@ -125,9 +125,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def get_base_rom_path(file_name: str = "") -> str:
options = settings.get_settings()
from . import LinksAwakeningWorld
if not file_name:
file_name = options["ladx_options"]["rom_file"]
file_name = LinksAwakeningWorld.settings.rom_file
if not os.path.exists(file_name):
file_name = Utils.user_path(file_name)
return file_name

View File

@@ -195,21 +195,24 @@ class MagpieBridge:
async def handler(self, websocket):
self.ws = websocket
while True:
message = json.loads(await websocket.recv())
if message["type"] == "handshake":
logger.info(
f"Connected, supported features: {message['features']}")
self.features = message["features"]
try:
message = json.loads(await websocket.recv())
if message["type"] == "handshake":
logger.info(
f"Connected, supported features: {message['features']}")
self.features = message["features"]
await self.send_handshAck()
await self.send_handshAck()
if message["type"] == "sendFull":
if "items" in self.features:
await self.send_all_inventory()
if "checks" in self.features:
await self.send_all_checks()
if self.use_entrance_tracker():
await self.send_gps(diff=False)
if message["type"] == "sendFull":
if "items" in self.features:
await self.send_all_inventory()
if "checks" in self.features:
await self.send_all_checks()
if self.use_entrance_tracker():
await self.send_gps(diff=False)
except websockets.exceptions.ConnectionClosedOK:
pass
# Translate renamed IDs back to LADXR IDs
@staticmethod

View File

@@ -4,8 +4,10 @@ import os
import typing
import logging
import re
import struct
import settings
import Utils
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World
@@ -50,6 +52,17 @@ class LinksAwakeningSettings(settings.Group):
description = "LADX ROM File"
md5s = [LADXProcedurePatch.hash]
@classmethod
def validate(cls, path: str) -> None:
try:
super().validate(path)
except ValueError:
Utils.messagebox(
"Error",
"Provided rom does not match hash for English 1.0/revision-0 of Link's Awakening DX",
True)
raise
class RomStart(str):
"""
Set this to false to never autostart a rom (such as after patching)
@@ -71,6 +84,24 @@ class LinksAwakeningSettings(settings.Group):
Only .bin or .bdiff files
The same directory will be checked for a matching text modification file
"""
def browse(self, filetypes=None, **kwargs):
filetypes = [("Binary / Patch files", [".bin", ".bdiff"])]
return super().browse(filetypes=filetypes, **kwargs)
@classmethod
def validate(cls, path: str) -> None:
with open(path, "rb", buffering=0) as f:
header, size = struct.unpack("<II", f.read()[:8])
if path.endswith('.bin') and header == 0xDEADBEEF and size < 1024:
# detect extended spritesheets from upstream ladxr
Utils.messagebox(
"Error",
"Extended sprite sheets are not supported. Try again with a different gfxmod file, "
"or provide no file to continue without modifying graphics.",
True)
raise ValueError("Provided gfxmod file is an extended sheet, which is not supported")
rom_file: RomFile = RomFile(RomFile.copy_to)
rom_start: typing.Union[RomStart, bool] = True

View File

@@ -2,5 +2,5 @@
"game": "Links Awakening DX",
"authors": [ "zig", "threeandthree" ],
"minimum_ap_version": "0.6.4",
"world_version": "2.0.0"
"world_version": "2.0.1"
}

View File

@@ -88,7 +88,7 @@ class MLSSClient(BizHawkClient):
if seed not in ctx.seed_name:
logger.info(
"ERROR: The ROM you loaded is for a different game of AP. "
"Please make sure the host has sent you the correct patch file,"
"Please make sure the host has sent you the correct patch file, "
"and that you have opened the correct ROM."
)
raise bizhawk.ConnectorError("Loaded ROM is for Incorrect lobby.")

View File

@@ -374,22 +374,32 @@ def create_and_flag_explicit_item_locks_and_excludes(world: SC2World) -> List[Fi
Handles `excluded_items`, `locked_items`, and `start_inventory`
Returns a list of all possible non-filler items that can be added, with an accompanying flags bitfield.
"""
excluded_items = world.options.excluded_items
unexcluded_items = world.options.unexcluded_items
locked_items = world.options.locked_items
start_inventory = world.options.start_inventory
excluded_items: dict[str, int] = world.options.excluded_items.value
unexcluded_items: dict[str, int] = world.options.unexcluded_items.value
locked_items: dict[str, int] = world.options.locked_items.value
start_inventory: dict[str, int] = world.options.start_inventory.value
key_items = world.custom_mission_order.get_items_to_lock()
def resolve_count(count: Optional[int], max_count: int) -> int:
if count == 0:
def resolve_exclude(count: int, max_count: int) -> int:
if count < 0:
return max_count
if count is None:
return 0
if max_count == 0:
return count
return min(count, max_count)
return count
def resolve_count(count: int, max_count: int, negative_value: int | None = None) -> int:
"""
Handles `count` being out of range.
* If `count > max_count`, returns `max_count`.
* If `count < 0`, returns `negative_value` (returns `max_count` if `negative_value` is unspecified)
"""
if count < 0:
if negative_value is None:
return max_count
return negative_value
if max_count and count > max_count:
return max_count
return count
auto_excludes = {item_name: 1 for item_name in item_groups.legacy_items}
auto_excludes = Counter({item_name: 1 for item_name in item_groups.legacy_items})
if world.options.exclude_overpowered_items.value == ExcludeOverpoweredItems.option_true:
for item_name in item_groups.overpowered_items:
auto_excludes[item_name] = 1
@@ -402,28 +412,29 @@ def create_and_flag_explicit_item_locks_and_excludes(world: SC2World) -> List[Fi
elif item_name in item_groups.nova_equipment:
continue
else:
auto_excludes[item_name] = 0
auto_excludes[item_name] = item_data.quantity
result: List[FilterItem] = []
for item_name, item_data in item_tables.item_table.items():
max_count = item_data.quantity
auto_excluded_count = auto_excludes.get(item_name)
auto_excluded_count = auto_excludes.get(item_name, 0)
excluded_count = excluded_items.get(item_name, auto_excluded_count)
unexcluded_count = unexcluded_items.get(item_name)
locked_count = locked_items.get(item_name)
start_count: Optional[int] = start_inventory.get(item_name)
unexcluded_count = unexcluded_items.get(item_name, 0)
locked_count = locked_items.get(item_name, 0)
start_count = start_inventory.get(item_name, 0)
key_count = key_items.get(item_name, 0)
# specifying 0 in the yaml means exclude / lock all
# start_inventory doesn't allow specifying 0
# not specifying means don't exclude/lock/start
excluded_count = resolve_count(excluded_count, max_count)
unexcluded_count = resolve_count(unexcluded_count, max_count)
# Specifying a negative number in the yaml means exclude / lock / start all.
# In the case of excluded/unexcluded, resolve negatives to max_count before subtracting them,
# and after subtraction resolve negatives to just 0 (when unexcluded > excluded).
excluded_count = resolve_count(
resolve_exclude(excluded_count, max_count) - resolve_exclude(unexcluded_count, max_count),
max_count,
negative_value=0
)
locked_count = resolve_count(locked_count, max_count)
start_count = resolve_count(start_count, max_count)
excluded_count = max(0, excluded_count - unexcluded_count)
# Priority: start_inventory >> locked_items >> excluded_items >> unspecified
if max_count == 0:
if excluded_count:
@@ -486,8 +497,9 @@ def flag_excludes_by_faction_presence(world: SC2World, item_list: List[FilterIte
item.flags |= ItemFilterFlags.FilterExcluded
continue
if not zerg_missions and item.data.race == SC2Race.ZERG:
if item.data.type != item_tables.ZergItemType.Ability \
and item.data.type != ZergItemType.Level:
if (item.data.type != item_tables.ZergItemType.Ability
and item.data.type != ZergItemType.Level
):
item.flags |= ItemFilterFlags.FilterExcluded
continue
if not protoss_missions and item.data.race == SC2Race.PROTOSS:
@@ -641,7 +653,7 @@ def flag_mission_based_item_excludes(world: SC2World, item_list: List[FilterItem
item.flags |= ItemFilterFlags.FilterExcluded
# Remove Spear of Adun passives
if item.name in item_tables.spear_of_adun_castable_passives and not soa_passive_presence:
if item.name in item_groups.spear_of_adun_passives and not soa_passive_presence:
item.flags |= ItemFilterFlags.FilterExcluded
# Remove matchup-specific items if you don't play that matchup

View File

@@ -40,6 +40,7 @@ from .options import (
SpearOfAdunPassivesPresentInNoBuild, EnableVoidTrade, VoidTradeAgeLimit, void_trade_age_limits_ms, VoidTradeWorkers,
DifficultyDamageModifier, MissionOrderScouting, GenericUpgradeResearchSpeedup, MercenaryHighlanders, WarCouncilNerfs,
is_mission_in_soa_presence,
upgrade_included_names,
)
from .mission_order.slot_data import CampaignSlotData, LayoutSlotData, MissionSlotData, MissionOrderObjectSlotData
from .mission_order.entry_rules import SubRuleRuleData, CountMissionsRuleData, MissionEntryRules
@@ -71,10 +72,12 @@ from .mission_tables import (
)
import colorama
from .options import Option, upgrade_included_names
from NetUtils import ClientStatus, NetworkItem, JSONtoTextParser, JSONMessagePart, add_json_item, add_json_location, add_json_text, JSONTypes
from MultiServer import mark_raw
if typing.TYPE_CHECKING:
from Options import Option
pool = concurrent.futures.ThreadPoolExecutor(1)
loop = asyncio.get_event_loop_policy().new_event_loop()
nest_asyncio.apply(loop)

View File

@@ -775,7 +775,7 @@ item_descriptions = {
item_names.BULLFROG_BROODLINGS: "Bullfrogs spawn two broodlings on impact, in addition to unloading their cargo.",
item_names.BULLFROG_HARD_IMPACT: "Bullfrogs deal more damage and stun longer on impact.",
item_names.INFESTED_BANSHEE_BRACED_EXOSKELETON: "Infested Banshees gain +100 life.",
item_names.INFESTED_BANSHEE_RAPID_HIBERNATION: "Infested Banshees regenerate 20 life and energy per second while burrowed.",
item_names.INFESTED_BANSHEE_RAPID_HIBERNATION: "Allows Infested Banshees to Burrow. Infested Banshees regenerate 20 life and energy per second while burrowed.",
item_names.INFESTED_BANSHEE_FLESHFUSED_TARGETING_OPTICS: "Infested Banshees gain +2 range while cloaked.",
item_names.INFESTED_LIBERATOR_CLOUD_DISPERSAL: "Infested Liberators instantly transform into a cloud of microscopic organisms while attacking, reducing the damage they take by 85%.",
item_names.INFESTED_LIBERATOR_VIRAL_CONTAMINATION: "Increases the damage Infested Liberators deal to their primary target by 100%.",

View File

@@ -111,6 +111,9 @@ class ItemGroupNames:
TERRAN_ORIGINAL_PROGRESSIVE_UPGRADES = "Terran Original Progressive Upgrades"
"""Progressive items where level 1 appeared in WoL"""
MENGSK_UNITS = "Mengsk Units"
TERRAN_SC1_UNITS = "Terran SC1 Units"
TERRAN_SC1_BUILDINGS = "Terran SC1 Buildings"
TERRAN_LADDER_UNITS = "Terran Ladder Units"
TERRAN_VETERANCY_UNITS = "Terran Veterancy Units"
ORBITAL_COMMAND_ABILITIES = "Orbital Command Abilities"
WOL_ORBITAL_COMMAND_ABILITIES = "WoL Command Center Abilities"
@@ -154,6 +157,8 @@ class ItemGroupNames:
"""All items from Stukov co-op subfaction"""
INF_TERRAN_UNITS = "Infested Terran Units"
INF_TERRAN_UPGRADES = "Infested Terran Upgrades"
ZERG_SC1_UNITS = "Zerg SC1 Units"
ZERG_LADDER_UNITS = "Zerg Ladder Units"
PROTOSS_ITEMS = "Protoss Items"
PROTOSS_UNITS = "Protoss Units"
@@ -167,6 +172,7 @@ class ItemGroupNames:
LOTV_UNITS = "LotV Units"
LOTV_ITEMS = "LotV Items"
LOTV_GLOBAL_UPGRADES = "LotV Global Upgrades"
SOA_PASSIVES = "SOA Passive Abilities"
SOA_ITEMS = "SOA"
PROTOSS_GLOBAL_UPGRADES = "Protoss Global Upgrades"
PROTOSS_BUILDINGS = "Protoss Buildings"
@@ -175,6 +181,9 @@ class ItemGroupNames:
NERAZIM_UNITS = "Nerazim"
TAL_DARIM_UNITS = "Tal'Darim"
PURIFIER_UNITS = "Purifier"
PROTOSS_SC1_UNITS = "Protoss SC1 Units"
PROTOSS_SC1_BUILDINGS = "Protoss SC1 Buildings"
PROTOSS_LADDER_UNITS = "Protoss Ladder Units"
VANILLA_ITEMS = "Vanilla Items"
OVERPOWERED_ITEMS = "Overpowered Items"
@@ -287,8 +296,14 @@ item_name_groups[ItemGroupNames.WOL_BUILDINGS] = wol_buildings = [
item_names.HIVE_MIND_EMULATOR, item_names.PSI_DISRUPTER,
]
item_name_groups[ItemGroupNames.TERRAN_BUILDINGS] = terran_buildings = [
item_name for item_name, item_data in item_tables.item_table.items()
if item_data.type == item_tables.TerranItemType.Building or item_name in wol_buildings
*[
item_name for item_name, item_data in item_tables.item_table.items()
if item_data.type == item_tables.TerranItemType.Building or item_name in wol_buildings
],
item_names.PSI_SCREEN,
item_names.SONIC_DISRUPTER,
item_names.PSI_INDOCTRINATOR,
item_names.ARGUS_AMPLIFIER,
]
item_name_groups[ItemGroupNames.MENGSK_UNITS] = [
item_names.AEGIS_GUARD, item_names.EMPERORS_SHADOW,
@@ -316,6 +331,41 @@ spider_mine_sources = [
item_names.SIEGE_TANK_SPIDER_MINES,
item_names.RAVEN_SPIDER_MINES,
]
item_name_groups[ItemGroupNames.TERRAN_SC1_UNITS] = [
item_names.MARINE,
item_names.FIREBAT,
item_names.GHOST,
item_names.MEDIC,
item_names.VULTURE,
item_names.SIEGE_TANK,
item_names.GOLIATH,
item_names.WRAITH,
# No dropship
item_names.SCIENCE_VESSEL,
item_names.BATTLECRUISER,
item_names.VALKYRIE,
]
item_name_groups[ItemGroupNames.TERRAN_SC1_BUILDINGS] = [
item_names.BUNKER,
item_names.MISSILE_TURRET,
]
item_name_groups[ItemGroupNames.TERRAN_LADDER_UNITS] = [
item_names.MARINE,
item_names.MARAUDER,
item_names.REAPER,
item_names.GHOST,
item_names.HELLION,
item_names.WIDOW_MINE,
item_names.SIEGE_TANK,
item_names.THOR,
item_names.CYCLONE,
item_names.VIKING,
item_names.MEDIVAC,
item_names.LIBERATOR,
item_names.RAVEN,
item_names.BANSHEE,
item_names.BATTLECRUISER,
]
# Terran Upgrades
item_name_groups[ItemGroupNames.WOL_UPGRADES] = wol_upgrades = [
@@ -596,6 +646,38 @@ item_name_groups[ItemGroupNames.OVERLORD_UPGRADES] = [
item_names.OVERLORD_IMPROVED_OVERLORDS,
item_names.OVERLORD_OVERSEER_ASPECT,
]
item_name_groups[ItemGroupNames.ZERG_SC1_UNITS] = [
item_names.ZERGLING,
item_names.HYDRALISK,
item_names.MUTALISK,
item_names.SCOURGE,
item_names.BROOD_QUEEN,
item_names.DEFILER,
item_names.ULTRALISK,
item_names.HYDRALISK_LURKER_ASPECT,
item_names.MUTALISK_CORRUPTOR_DEVOURER_ASPECT,
item_names.MUTALISK_CORRUPTOR_GUARDIAN_ASPECT,
item_names.DEVOURING_ONES,
item_names.HUNTER_KILLERS,
item_names.TORRASQUE_MERC,
]
item_name_groups[ItemGroupNames.ZERG_LADDER_UNITS] = [
item_names.ZERGLING,
item_names.SWARM_QUEEN, # Replace: Hive Queen
item_names.ZERGLING_BANELING_ASPECT,
item_names.ROACH,
item_names.ROACH_RAVAGER_ASPECT,
item_names.OVERLORD_OVERSEER_ASPECT,
item_names.HYDRALISK,
item_names.HYDRALISK_LURKER_ASPECT,
item_names.MUTALISK,
item_names.CORRUPTOR,
item_names.MUTALISK_CORRUPTOR_VIPER_ASPECT,
item_names.MUTALISK_CORRUPTOR_BROOD_LORD_ASPECT,
item_names.INFESTOR,
item_names.SWARM_HOST,
item_names.ULTRALISK,
]
# Zerg Upgrades
item_name_groups[ItemGroupNames.HOTS_STRAINS] = hots_strains = [
@@ -777,11 +859,21 @@ item_name_groups[ItemGroupNames.PURIFIER_UNITS] = [
item_names.MIRAGE, item_names.DAWNBRINGER, item_names.TRIREME, item_names.TEMPEST,
item_names.CALADRIUS,
]
item_name_groups[ItemGroupNames.SOA_ITEMS] = soa_items = [
item_name_groups[ItemGroupNames.SOA_PASSIVES] = spear_of_adun_passives = [
item_names.RECONSTRUCTION_BEAM,
item_names.OVERWATCH,
item_names.GUARDIAN_SHELL,
]
spear_of_adun_actives = [
*[item_name for item_name, item_data in item_tables.item_table.items() if item_data.type == item_tables.ProtossItemType.Spear_Of_Adun],
item_names.SOA_PROGRESSIVE_PROXY_PYLON,
]
lotv_soa_items = [item_name for item_name in soa_items if item_name != item_names.SOA_PYLON_OVERCHARGE]
item_name_groups[ItemGroupNames.SOA_ITEMS] = soa_items = spear_of_adun_actives + spear_of_adun_passives
lotv_soa_items = [
item_name
for item_name in soa_items
if item_name not in (item_names.SOA_PYLON_OVERCHARGE, item_names.OVERWATCH)
]
item_name_groups[ItemGroupNames.PROTOSS_GLOBAL_UPGRADES] = [
item_name for item_name, item_data in item_tables.item_table.items() if item_data.type == item_tables.ProtossItemType.Solarite_Core
]
@@ -815,6 +907,45 @@ item_name_groups[ItemGroupNames.LOTV_ITEMS] = vanilla_lotv_items = (
+ protoss_generic_upgrades
+ lotv_war_council_upgrades
)
item_name_groups[ItemGroupNames.PROTOSS_SC1_UNITS] = [
item_names.ZEALOT,
item_names.DRAGOON,
item_names.HIGH_TEMPLAR,
item_names.DARK_TEMPLAR,
item_names.DARK_ARCHON,
item_names.DARK_TEMPLAR_DARK_ARCHON_MELD,
# No shuttle
item_names.REAVER,
item_names.OBSERVER,
item_names.SCOUT,
item_names.CARRIER,
item_names.ARBITER,
item_names.CORSAIR,
]
item_name_groups[ItemGroupNames.PROTOSS_SC1_BUILDINGS] = [
item_names.PHOTON_CANNON,
item_names.SHIELD_BATTERY,
]
item_name_groups[ItemGroupNames.PROTOSS_LADDER_UNITS] = [
item_names.ZEALOT,
item_names.STALKER,
item_names.SENTRY,
item_names.ADEPT,
item_names.HIGH_TEMPLAR,
item_names.DARK_TEMPLAR,
item_names.DARK_TEMPLAR_ARCHON_MERGE,
item_names.OBSERVER,
item_names.WARP_PRISM,
item_names.IMMORTAL,
item_names.COLOSSUS,
item_names.DISRUPTOR,
item_names.PHOENIX,
item_names.VOID_RAY,
item_names.ORACLE,
item_names.CARRIER,
item_names.TEMPEST,
item_names.MOTHERSHIP, # Replace: Aiur Mothership
]
item_name_groups[ItemGroupNames.VANILLA_ITEMS] = vanilla_items = (
vanilla_wol_items + vanilla_hots_items + vanilla_lotv_items

View File

@@ -2151,127 +2151,6 @@ not_balanced_starting_units = {
item_names.TEMPEST,
}
# Defense rating table
# Commented defense ratings are handled in LogicMixin
tvx_defense_ratings = {
item_names.SIEGE_TANK: 5,
# "Graduating Range": 1,
item_names.PLANETARY_FORTRESS: 3,
# Bunker w/ Marine/Marauder: 3,
item_names.PERDITION_TURRET: 2,
item_names.DEVASTATOR_TURRET: 2,
item_names.VULTURE: 1,
item_names.BANSHEE: 1,
item_names.BATTLECRUISER: 1,
item_names.LIBERATOR: 4,
item_names.WIDOW_MINE: 1,
# "Concealment (Widow Mine)": 1
}
tvz_defense_ratings = {
item_names.PERDITION_TURRET: 2,
# Bunker w/ Firebat: 2,
item_names.LIBERATOR: -2,
item_names.HIVE_MIND_EMULATOR: 3,
item_names.PSI_DISRUPTER: 3,
}
tvx_air_defense_ratings = {
item_names.MISSILE_TURRET: 2,
}
zvx_defense_ratings = {
# Note that this doesn't include Kerrigan because this is just for race swaps, which doesn't involve her (for now)
item_names.SPINE_CRAWLER: 3,
# w/ Twin Drones: 1
item_names.SWARM_QUEEN: 1,
item_names.SWARM_HOST: 1,
# impaler: 3
# "Hardened Tentacle Spines (Impaler)": 2
# lurker: 1
# "Seismic Spines (Lurker)": 2
# "Adapted Spines (Lurker)": 1
# brood lord : 2
# corpser roach: 1
# creep tumors (swarm queen or overseer): 1
# w/ malignant creep: 1
# tanks with ammo: 5
item_names.INFESTED_BUNKER: 3,
item_names.BILE_LAUNCHER: 2,
}
# zvz_defense_ratings = {
# corpser roach: 1
# primal igniter: 2
# lurker: 1
# w/ adapted spines: -1
# impaler: -1
# }
zvx_air_defense_ratings = {
item_names.SPORE_CRAWLER: 2,
# w/ Twin Drones: 1
item_names.INFESTED_MISSILE_TURRET: 2,
}
pvx_defense_ratings = {
item_names.PHOTON_CANNON: 2,
item_names.KHAYDARIN_MONOLITH: 3,
item_names.SHIELD_BATTERY: 1,
item_names.NEXUS_OVERCHARGE: 2,
item_names.SKYLORD: 1,
item_names.MATRIX_OVERLOAD: 1,
item_names.COLOSSUS: 1,
item_names.VANGUARD: 1,
item_names.REAVER: 1,
}
pvz_defense_ratings = {
item_names.KHAYDARIN_MONOLITH: -2,
item_names.COLOSSUS: 1,
}
terran_passive_ratings = {
item_names.AUTOMATED_REFINERY: 4,
item_names.COMMAND_CENTER_MULE: 4,
item_names.ORBITAL_DEPOTS: 2,
item_names.COMMAND_CENTER_COMMAND_CENTER_REACTOR: 2,
item_names.COMMAND_CENTER_EXTRA_SUPPLIES: 2,
item_names.MICRO_FILTERING: 2,
item_names.TECH_REACTOR: 2
}
zerg_passive_ratings = {
item_names.TWIN_DRONES: 7,
item_names.AUTOMATED_EXTRACTORS: 4,
item_names.VESPENE_EFFICIENCY: 3,
item_names.OVERLORD_IMPROVED_OVERLORDS: 4,
item_names.MALIGNANT_CREEP: 2
}
protoss_passive_ratings = {
item_names.QUATRO: 4,
item_names.ORBITAL_ASSIMILATORS: 4,
item_names.AMPLIFIED_ASSIMILATORS: 3,
item_names.PROBE_WARPIN: 2,
item_names.ELDER_PROBES: 2,
item_names.MATRIX_OVERLOAD: 2
}
soa_energy_ratings = {
item_names.SOA_SOLAR_LANCE: 8,
item_names.SOA_DEPLOY_FENIX: 7,
item_names.SOA_TEMPORAL_FIELD: 6,
item_names.SOA_PROGRESSIVE_PROXY_PYLON: 5, # Requires Lvl 2 (Warp in Reinforcements)
item_names.SOA_SHIELD_OVERCHARGE: 5,
item_names.SOA_ORBITAL_STRIKE: 4
}
soa_passive_ratings = {
item_names.GUARDIAN_SHELL: 4,
item_names.OVERWATCH: 2
}
soa_ultimate_ratings = {
item_names.SOA_TIME_STOP: 4,
item_names.SOA_PURIFIER_BEAM: 3,
item_names.SOA_SOLAR_BOMBARDMENT: 3
}
kerrigan_levels = [
item_name for item_name, item_data in item_table.items()
if item_data.type == ZergItemType.Level and item_data.race == SC2Race.ZERG
@@ -2293,12 +2172,6 @@ spear_of_adun_calldowns = {
item_names.SOA_SOLAR_BOMBARDMENT
}
spear_of_adun_castable_passives = {
item_names.RECONSTRUCTION_BEAM,
item_names.OVERWATCH,
item_names.GUARDIAN_SHELL,
}
nova_equipment = {
*[item_name for item_name, item_data in get_full_item_list().items()
if item_data.type == TerranItemType.Nova_Gear],

View File

@@ -5,14 +5,13 @@ from datetime import timedelta
from Options import (
Choice, Toggle, DefaultOnToggle, OptionSet, Range,
PerGameCommonOptions, Option, VerifyKeys, StartInventory,
PerGameCommonOptions, VerifyKeys, StartInventory,
is_iterable_except_str, OptionGroup, Visibility, ItemDict,
Accessibility, ProgressionBalancing
OptionCounter,
)
from Utils import get_fuzzy_results
from BaseClasses import PlandoOptions
from .item import item_names, item_tables
from .item.item_groups import kerrigan_active_abilities, kerrigan_passives, nova_weapons, nova_gadgets
from .item import item_names, item_tables, item_groups
from .mission_tables import (
SC2Campaign, SC2Mission, lookup_name_to_mission, MissionPools, get_missions_with_any_flags_in_list,
campaign_mission_table, SC2Race, MissionFlag
@@ -700,7 +699,7 @@ class KerriganMaxActiveAbilities(Range):
"""
display_name = "Kerrigan Maximum Active Abilities"
range_start = 0
range_end = len(kerrigan_active_abilities)
range_end = len(item_groups.kerrigan_active_abilities)
default = range_end
@@ -711,7 +710,7 @@ class KerriganMaxPassiveAbilities(Range):
"""
display_name = "Kerrigan Maximum Passive Abilities"
range_start = 0
range_end = len(kerrigan_passives)
range_end = len(item_groups.kerrigan_passives)
default = range_end
@@ -829,7 +828,7 @@ class SpearOfAdunMaxAutocastAbilities(Range):
"""
display_name = "Spear of Adun Maximum Passive Abilities"
range_start = 0
range_end = sum(item.quantity for item_name, item in item_tables.get_full_item_list().items() if item_name in item_tables.spear_of_adun_castable_passives)
range_end = sum(item_tables.item_table[item_name].quantity for item_name in item_groups.spear_of_adun_passives)
default = range_end
@@ -883,7 +882,7 @@ class NovaMaxWeapons(Range):
"""
display_name = "Nova Maximum Weapons"
range_start = 0
range_end = len(nova_weapons)
range_end = len(item_groups.nova_weapons)
default = range_end
@@ -897,7 +896,7 @@ class NovaMaxGadgets(Range):
"""
display_name = "Nova Maximum Gadgets"
range_start = 0
range_end = len(nova_gadgets)
range_end = len(item_groups.nova_gadgets)
default = range_end
@@ -932,33 +931,48 @@ class TakeOverAIAllies(Toggle):
display_name = "Take Over AI Allies"
class Sc2ItemDict(Option[Dict[str, int]], VerifyKeys, Mapping[str, int]):
"""A branch of ItemDict that supports item counts of 0"""
class Sc2ItemDict(OptionCounter, VerifyKeys, Mapping[str, int]):
"""A branch of ItemDict that supports negative item counts"""
default = {}
supports_weighting = False
verify_item_name = True
# convert_name_groups = True
display_name = 'Unnamed dictionary'
minimum_value: int = 0
# Note(phaneros): Limiting minimum to -1 means that if two triggers add -1 to the same item,
# the validation fails. So give trigger people space to stack a bunch of triggers.
min: int = -1000
max: int = 1000
valid_keys = set(item_tables.item_table) | set(item_groups.item_name_groups)
def __init__(self, value: Dict[str, int]):
def __init__(self, value: dict[str, int]):
self.value = {key: val for key, val in value.items()}
@classmethod
def from_any(cls, data: Union[List[str], Dict[str, int]]) -> 'Sc2ItemDict':
def from_any(cls, data: list[str] | dict[str, int]) -> 'Sc2ItemDict':
if isinstance(data, list):
# This is a little default that gets us backwards compatibility with lists.
# It doesn't play nice with trigger merging dicts and lists together, though, so best not to advertise it overmuch.
data = {item: 0 for item in data}
raise ValueError(
f"{cls.display_name}: Cannot convert from list. "
f"Use dict syntax (no dashes, 'value: number' synax)."
)
if isinstance(data, dict):
for key, value in data.items():
if not isinstance(value, int):
raise ValueError(f"Invalid type in '{cls.display_name}': element '{key}' maps to '{value}', expected an integer")
if value < cls.minimum_value:
raise ValueError(f"Invalid value for '{cls.display_name}': element '{key}' maps to {value}, which is less than the minimum ({cls.minimum_value})")
raise ValueError(
f"Invalid type in '{cls.display_name}': "
f"element '{key}' maps to '{value}', expected an integer"
)
if value < cls.min:
raise ValueError(
f"Invalid value for '{cls.display_name}': "
f"element '{key}' maps to {value}, which is less than the minimum ({cls.min})"
)
if value > cls.max:
raise ValueError(f"Invalid value for '{cls.display_name}': "
f"element '{key}' maps to {value}, which is greater than the maximum ({cls.max})"
)
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
raise NotImplementedError(f"{cls.display_name}: Cannot convert from non-dictionary, got {type(data)}")
def verify(self, world: Type['World'], player_name: str, plando_options: PlandoOptions) -> None:
"""Overridden version of function from Options.VerifyKeys for a better error message"""
@@ -974,15 +988,16 @@ class Sc2ItemDict(Option[Dict[str, int]], VerifyKeys, Mapping[str, int]):
self.value = new_value
for item_name in self.value:
if item_name not in world.item_names:
from .item import item_groups
picks = get_fuzzy_results(
item_name,
list(world.item_names) + list(item_groups.ItemGroupNames.get_all_group_names()),
limit=1,
)
raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
raise Exception(
f"Item {item_name} from option {self} "
f"is not a valid item name from {world.game}. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)"
)
def get_option_name(self, value):
return ", ".join(f"{key}: {v}" for key, v in value.items())
@@ -998,25 +1013,25 @@ class Sc2ItemDict(Option[Dict[str, int]], VerifyKeys, Mapping[str, int]):
class Sc2StartInventory(Sc2ItemDict):
"""Start with these items."""
"""Start with these items. Use an amount of -1 to start with all copies of an item."""
display_name = StartInventory.display_name
class LockedItems(Sc2ItemDict):
"""Guarantees that these items will be unlockable, in the amount specified.
Specify an amount of 0 to lock all copies of an item."""
Specify an amount of -1 to lock all copies of an item."""
display_name = "Locked Items"
class ExcludedItems(Sc2ItemDict):
"""Guarantees that these items will not be unlockable, in the amount specified.
Specify an amount of 0 to exclude all copies of an item."""
Specify an amount of -1 to exclude all copies of an item."""
display_name = "Excluded Items"
class UnexcludedItems(Sc2ItemDict):
"""Undoes an item exclusion; useful for whitelisting or fine-tuning a category.
Specify an amount of 0 to unexclude all copies of an item."""
Specify an amount of -1 to unexclude all copies of an item."""
display_name = "Unexcluded Items"

View File

@@ -3,8 +3,7 @@ from typing import Callable, Dict, List, Set, Tuple, TYPE_CHECKING, Iterable
from BaseClasses import Location, ItemClassification
from .item import StarcraftItem, ItemFilterFlags, item_names, item_parents, item_groups
from .item.item_tables import item_table, TerranItemType, ZergItemType, spear_of_adun_calldowns, \
spear_of_adun_castable_passives
from .item.item_tables import item_table, TerranItemType, ZergItemType, spear_of_adun_calldowns
from .options import RequiredTactics
if TYPE_CHECKING:
@@ -272,7 +271,7 @@ class ValidInventory:
self.world.random.shuffle(spear_of_adun_actives)
cull_items_over_maximum(spear_of_adun_actives, self.world.options.spear_of_adun_max_active_abilities.value)
spear_of_adun_autocasts = [item for item in inventory if item.name in spear_of_adun_castable_passives]
spear_of_adun_autocasts = [item for item in inventory if item.name in item_groups.spear_of_adun_passives]
self.world.random.shuffle(spear_of_adun_autocasts)
cull_items_over_maximum(spear_of_adun_autocasts, self.world.options.spear_of_adun_max_passive_abilities.value)

File diff suppressed because it is too large Load Diff

View File

@@ -18,19 +18,19 @@ class TestItemFiltering(Sc2SetupTestBase):
world_options = {
**self.ALL_CAMPAIGNS,
'locked_items': {
item_names.MARINE: 0,
item_names.MARAUDER: 0,
item_names.MARINE: -1,
item_names.MARAUDER: -1,
item_names.MEDIVAC: 1,
item_names.FIREBAT: 1,
item_names.ZEALOT: 0,
item_names.ZEALOT: -1,
item_names.PROGRESSIVE_REGENERATIVE_BIO_STEEL: 2,
},
'excluded_items': {
item_names.MARINE: 0,
item_names.MARAUDER: 0,
item_names.MEDIVAC: 0,
item_names.MARINE: -1,
item_names.MARAUDER: -1,
item_names.MEDIVAC: -1,
item_names.FIREBAT: 1,
item_names.ZERGLING: 0,
item_names.ZERGLING: -1,
item_names.PROGRESSIVE_REGENERATIVE_BIO_STEEL: 2,
}
}
@@ -50,38 +50,38 @@ class TestItemFiltering(Sc2SetupTestBase):
world_options = {
'grant_story_tech': options.GrantStoryTech.option_grant,
'excluded_items': {
item_groups.ItemGroupNames.NOVA_EQUIPMENT: 15,
item_groups.ItemGroupNames.NOVA_EQUIPMENT: -1,
item_names.MARINE_PROGRESSIVE_STIMPACK: 1,
item_names.MARAUDER_PROGRESSIVE_STIMPACK: 2,
item_names.MARINE: 0,
item_names.MARAUDER: 0,
item_names.MARINE: -1,
item_names.MARAUDER: -1,
item_names.REAPER: 1,
item_names.DIAMONDBACK: 0,
item_names.DIAMONDBACK: -1,
item_names.HELLION: 1,
# Additional excludes to increase the likelihood that unexcluded items actually appear
item_groups.ItemGroupNames.STARPORT_UNITS: 0,
item_names.WARHOUND: 0,
item_names.VULTURE: 0,
item_names.WIDOW_MINE: 0,
item_names.THOR: 0,
item_names.GHOST: 0,
item_names.SPECTRE: 0,
item_groups.ItemGroupNames.MENGSK_UNITS: 0,
item_groups.ItemGroupNames.TERRAN_VETERANCY_UNITS: 0,
item_groups.ItemGroupNames.STARPORT_UNITS: -1,
item_names.WARHOUND: -1,
item_names.VULTURE: -1,
item_names.WIDOW_MINE: -1,
item_names.THOR: -1,
item_names.GHOST: -1,
item_names.SPECTRE: -1,
item_groups.ItemGroupNames.MENGSK_UNITS: -1,
item_groups.ItemGroupNames.TERRAN_VETERANCY_UNITS: -1,
},
'unexcluded_items': {
item_names.NOVA_PLASMA_RIFLE: 1, # Necessary to pass logic
item_names.NOVA_PULSE_GRENADES: 0, # Necessary to pass logic
item_names.NOVA_JUMP_SUIT_MODULE: 0, # Necessary to pass logic
item_groups.ItemGroupNames.BARRACKS_UNITS: 0,
item_names.NOVA_PLASMA_RIFLE: 1, # Necessary to pass logic
item_names.NOVA_PULSE_GRENADES: -1, # Necessary to pass logic
item_names.NOVA_JUMP_SUIT_MODULE: -1, # Necessary to pass logic
item_groups.ItemGroupNames.BARRACKS_UNITS: -1,
item_names.NOVA_PROGRESSIVE_STEALTH_SUIT_MODULE: 1,
item_names.HELLION: 1,
item_names.MARINE_PROGRESSIVE_STIMPACK: 1,
item_names.MARAUDER_PROGRESSIVE_STIMPACK: 0,
item_names.MARAUDER_PROGRESSIVE_STIMPACK: -1,
# Additional unexcludes for logic
item_names.MEDIVAC: 0,
item_names.BATTLECRUISER: 0,
item_names.SCIENCE_VESSEL: 0,
item_names.MEDIVAC: -1,
item_names.BATTLECRUISER: -1,
item_names.SCIENCE_VESSEL: -1,
},
# Terran-only
'enabled_campaigns': {
@@ -103,11 +103,29 @@ class TestItemFiltering(Sc2SetupTestBase):
self.assertNotIn(item_names.NOVA_BLAZEFIRE_GUNBLADE, itempool)
self.assertNotIn(item_names.NOVA_ENERGY_SUIT_MODULE, itempool)
def test_exclude_2_beats_unexclude_1(self) -> None:
world_options = {
options.OPTION_NAME[options.ExcludedItems]: {
item_names.MARINE: 2,
},
options.OPTION_NAME[options.UnexcludedItems]: {
item_names.MARINE: 1,
},
# Ensure enough locations that marine doesn't get culled
options.OPTION_NAME[options.SelectedRaces]: {
SC2Race.TERRAN.get_title(),
},
options.OPTION_NAME[options.VictoryCache]: 9,
}
self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool]
self.assertNotIn(item_names.MARINE, itempool)
def test_excluding_groups_excludes_all_items_in_group(self):
world_options = {
'excluded_items': [
item_groups.ItemGroupNames.BARRACKS_UNITS.lower(),
]
'excluded_items': {
item_groups.ItemGroupNames.BARRACKS_UNITS.lower(): -1,
},
}
self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool]
@@ -337,9 +355,9 @@ class TestItemFiltering(Sc2SetupTestBase):
# Options under test
'vanilla_items_only': True,
'unexcluded_items': {
item_names.PROGRESSIVE_FIRE_SUPPRESSION_SYSTEM: 0,
item_names.PROGRESSIVE_FIRE_SUPPRESSION_SYSTEM: -1,
item_names.WARHOUND: 1,
item_groups.ItemGroupNames.TERRAN_STIMPACKS: 0,
item_groups.ItemGroupNames.TERRAN_STIMPACKS: -1,
},
# Avoid options that lock non-vanilla items for logic
'required_tactics': options.RequiredTactics.option_any_units,
@@ -463,12 +481,12 @@ class TestItemFiltering(Sc2SetupTestBase):
},
'required_tactics': options.RequiredTactics.option_no_logic,
'enable_morphling': options.EnableMorphling.option_true,
'excluded_items': [
item_groups.ItemGroupNames.ZERG_UNITS.lower()
],
'unexcluded_items': [
item_groups.ItemGroupNames.ZERG_MORPHS.lower()
]
'excluded_items': {
item_groups.ItemGroupNames.ZERG_UNITS.lower(): -1,
},
'unexcluded_items': {
item_groups.ItemGroupNames.ZERG_MORPHS.lower(): -1,
},
}
self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool]
@@ -486,12 +504,12 @@ class TestItemFiltering(Sc2SetupTestBase):
},
'required_tactics': options.RequiredTactics.option_no_logic,
'enable_morphling': options.EnableMorphling.option_false,
'excluded_items': [
item_groups.ItemGroupNames.ZERG_UNITS.lower()
],
'unexcluded_items': [
item_groups.ItemGroupNames.ZERG_MORPHS.lower()
]
'excluded_items': {
item_groups.ItemGroupNames.ZERG_UNITS.lower(): -1,
},
'unexcluded_items': {
item_groups.ItemGroupNames.ZERG_MORPHS.lower(): -1,
},
}
self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool]
@@ -520,14 +538,14 @@ class TestItemFiltering(Sc2SetupTestBase):
def test_planetary_orbital_module_not_present_without_cc_spells(self) -> None:
world_options = {
"excluded_items": [
item_names.COMMAND_CENTER_MULE,
item_names.COMMAND_CENTER_SCANNER_SWEEP,
item_names.COMMAND_CENTER_EXTRA_SUPPLIES
],
"locked_items": [
item_names.PLANETARY_FORTRESS
]
"excluded_items": {
item_names.COMMAND_CENTER_MULE: -1,
item_names.COMMAND_CENTER_SCANNER_SWEEP: -1,
item_names.COMMAND_CENTER_EXTRA_SUPPLIES: -1,
},
"locked_items": {
item_names.PLANETARY_FORTRESS: -1,
}
}
self.generate_world(world_options)
@@ -931,10 +949,10 @@ class TestItemFiltering(Sc2SetupTestBase):
}
},
'grant_story_levels': options.GrantStoryLevels.option_additive,
'excluded_items': [
item_names.KERRIGAN_LEAPING_STRIKE,
item_names.KERRIGAN_MEND,
]
'excluded_items': {
item_names.KERRIGAN_LEAPING_STRIKE: -1,
item_names.KERRIGAN_MEND: -1,
}
}
self.generate_world(world_options)
itempool = [item.name for item in self.multiworld.itempool]
@@ -1208,7 +1226,7 @@ class TestItemFiltering(Sc2SetupTestBase):
'mission_order': MissionOrder.option_grid,
'maximum_campaign_size': MaximumCampaignSize.range_end,
'exclude_overpowered_items': ExcludeOverpoweredItems.option_true,
'locked_items': [locked_item],
'locked_items': {locked_item: -1},
'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all,
'selected_races': [SC2Race.TERRAN.get_title()],
}
@@ -1249,7 +1267,7 @@ class TestItemFiltering(Sc2SetupTestBase):
'maximum_campaign_size': MaximumCampaignSize.range_end,
'exclude_overpowered_items': ExcludeOverpoweredItems.option_false,
'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all,
'locked_items': {item_name: 0 for item_name in unreleased_items},
'locked_items': {item_name: -1 for item_name in unreleased_items},
}
self.generate_world(world_options)
@@ -1264,7 +1282,7 @@ class TestItemFiltering(Sc2SetupTestBase):
**self.ALL_CAMPAIGNS,
'mission_order': MissionOrder.option_grid,
'maximum_campaign_size': MaximumCampaignSize.range_end,
'excluded_items': [item_name for item_name in item_groups.terran_mercenaries],
'excluded_items': {item_name: -1 for item_name in item_groups.terran_mercenaries},
'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all,
'selected_races': [SC2Race.TERRAN.get_title()],
}
@@ -1280,7 +1298,7 @@ class TestItemFiltering(Sc2SetupTestBase):
'mission_order': MissionOrder.option_grid,
'maximum_campaign_size': MaximumCampaignSize.range_end,
'exclude_overpowered_items': ExcludeOverpoweredItems.option_true,
'unexcluded_items': [item_names.SOA_TIME_STOP],
'unexcluded_items': {item_names.SOA_TIME_STOP: -1},
'enable_race_swap': options.EnableRaceSwapVariants.option_shuffle_all,
}
@@ -1322,7 +1340,7 @@ class TestItemFiltering(Sc2SetupTestBase):
'enabled_campaigns': {
SC2Campaign.WOL.campaign_name
},
'excluded_items': [item_names.MARINE, item_names.MEDIC],
'excluded_items': {item_names.MARINE: -1, item_names.MEDIC: -1},
'shuffle_no_build': False,
'required_tactics': RequiredTactics.option_standard
}

View File

@@ -11,7 +11,7 @@ class ItemFilterTests(Sc2SetupTestBase):
def test_excluding_all_barracks_units_excludes_infantry_upgrades(self) -> None:
world_options = {
'excluded_items': {
item_groups.ItemGroupNames.BARRACKS_UNITS: 0
item_groups.ItemGroupNames.BARRACKS_UNITS: -1,
},
'required_tactics': 'standard',
'min_number_of_upgrades': 1,

View File

@@ -35,10 +35,10 @@ class TestSupportedUseCases(Sc2SetupTestBase):
SC2Campaign.NCO.campaign_name
},
'excluded_items': {
item_groups.ItemGroupNames.TERRAN_UNITS: 0,
item_groups.ItemGroupNames.TERRAN_UNITS: -1,
},
'unexcluded_items': {
item_groups.ItemGroupNames.NCO_UNITS: 0,
item_groups.ItemGroupNames.NCO_UNITS: -1,
},
'max_number_of_upgrades': 2,
}
@@ -81,10 +81,10 @@ class TestSupportedUseCases(Sc2SetupTestBase):
},
'mission_order': options.MissionOrder.option_vanilla_shuffled,
'excluded_items': {
item_groups.ItemGroupNames.TERRAN_ITEMS: 0,
item_groups.ItemGroupNames.TERRAN_ITEMS: -1,
},
'unexcluded_items': {
item_groups.ItemGroupNames.NCO_MAX_PROGRESSIVE_ITEMS: 0,
item_groups.ItemGroupNames.NCO_MAX_PROGRESSIVE_ITEMS: -1,
item_groups.ItemGroupNames.NCO_MIN_PROGRESSIVE_ITEMS: 1,
},
'excluded_missions': [
@@ -398,7 +398,7 @@ class TestSupportedUseCases(Sc2SetupTestBase):
self.generate_world(world_options)
world_item_names = [item.name for item in self.multiworld.itempool]
spear_of_adun_actives = [item_name for item_name in world_item_names if item_name in item_tables.spear_of_adun_calldowns]
spear_of_adun_actives = [item_name for item_name in world_item_names if item_name in item_groups.spear_of_adun_actives]
self.assertLessEqual(len(spear_of_adun_actives), target_number)
@@ -418,7 +418,9 @@ class TestSupportedUseCases(Sc2SetupTestBase):
self.generate_world(world_options)
world_item_names = [item.name for item in self.multiworld.itempool]
spear_of_adun_autocasts = [item_name for item_name in world_item_names if item_name in item_tables.spear_of_adun_castable_passives]
spear_of_adun_autocasts = [
item_name for item_name in world_item_names if item_name in item_groups.spear_of_adun_passives
]
self.assertLessEqual(len(spear_of_adun_autocasts), target_number)
@@ -471,12 +473,12 @@ class TestSupportedUseCases(Sc2SetupTestBase):
],
'required_tactics': options.RequiredTactics.option_any_units,
'excluded_items': {
item_groups.ItemGroupNames.TERRAN_UNITS: 0,
item_groups.ItemGroupNames.ZERG_UNITS: 0,
item_groups.ItemGroupNames.TERRAN_UNITS: -1,
item_groups.ItemGroupNames.ZERG_UNITS: -1,
},
'unexcluded_items': {
item_groups.ItemGroupNames.TERRAN_MERCENARIES: 0,
item_groups.ItemGroupNames.ZERG_MERCENARIES: 0,
item_groups.ItemGroupNames.TERRAN_MERCENARIES: -1,
item_groups.ItemGroupNames.ZERG_MERCENARIES: -1,
},
'start_inventory': {
item_names.PROGRESSIVE_FAST_DELIVERY: 1,

View File

@@ -306,8 +306,8 @@ class ShapezWorld(World):
self.location_count = len(self.included_locations)
# Create regions and entrances based on included locations and player options
self.multiworld.regions.extend(create_shapez_regions(self.player, self.multiworld,
bool(self.options.allow_floating_layers.value),
has_floating = self.options.allow_floating_layers.value or not (self.options.randomize_level_requirements and self.options.randomize_upgrade_requirements)
self.multiworld.regions.extend(create_shapez_regions(self.player, self.multiworld, has_floating,
self.included_locations, self.location_name_to_id,
self.level_logic, self.upgrade_logic,
self.options.early_balancer_tunnel_and_trash.current_key,

View File

@@ -1047,7 +1047,7 @@ def set_er_region_rules(world: "TunicWorld", regions: dict[str, Region], portal_
connecting_region=regions["Rooted Ziggurat Portal Room"])
regions["Rooted Ziggurat Portal Room"].connect(
connecting_region=regions["Rooted Ziggurat Portal"],
rule=lambda state: has_fuses("Activate Ziggurat Fuse", state, world) and has_ability(prayer, state, world))
rule=lambda state: has_ability(prayer, state, world))
regions["Rooted Ziggurat Portal Room"].connect(
connecting_region=regions["Rooted Ziggurat Portal Room Exit"],

View File

@@ -178,7 +178,7 @@ class WargrooveContext(CommonContext):
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata! "
"Boot Wargroove and then close it to attempt to fix this error")
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
save_directory = os.path.join(appdata_wargroove, "save")