Compare commits

...

83 Commits

Author SHA1 Message Date
CaitSith2
2524ddc075 Dark Souls 3: Use options_dataclass 2023-12-24 17:30:53 -08:00
CaitSith2
d545b78803 Clique: Use options_dataclass 2023-12-24 17:30:17 -08:00
CaitSith2
88b1c94eb2 ChecksFinder: use options_dataclass 2023-12-24 17:29:30 -08:00
CaitSith2
7742d5d804 BumperStickers: Use options_dataclass 2023-12-24 17:29:04 -08:00
CaitSith2
d3e148dcc6 Blasphemous: Use options_dataclass 2023-12-24 17:28:34 -08:00
CaitSith2
b5fccde913 Didn't mean to include this debugging line. 2023-12-24 16:50:40 -08:00
CaitSith2
55e9b0687a Factorio: Options assigned as data_class 2023-12-24 16:49:05 -08:00
CaitSith2
79e1bf351e Adventure: options assigned as data_class 2023-12-24 16:47:54 -08:00
CaitSith2
fcfea9d9aa Adventure: use options.name instead of multiworld.name[players] 2023-12-24 15:22:07 -08:00
CaitSith2
cfc5508f06 ds3: use options.name instead of multiworld.name[player] 2023-12-24 14:10:33 -08:00
CaitSith2
62cb5f1fc2 fix flake8(push) hopefully 2023-12-24 13:43:19 -08:00
CaitSith2
7e70b16656 Clique: use options.name instead of multiworld.name 2023-12-24 13:34:25 -08:00
CaitSith2
7b486b3380 BumpStick: use options.name instead of multiworld.name 2023-12-24 13:33:32 -08:00
CaitSith2
09cac0a685 Core: use options.option_name instead of multiworld.option_name 2023-12-24 12:24:31 -08:00
CaitSith2
12c583533d Blasphemous: use options.option_name instead of multiworld.option_name 2023-12-24 11:43:28 -08:00
CaitSith2
c5af28a649 Factorio: use options.option_name instead of multiworld.option_name 2023-12-24 11:41:23 -08:00
CaitSith2
bb0a0f2aca Factorio: Fix unbeatable seeds where a science pack needs chemical plant (#2613) 2023-12-22 20:02:49 -08:00
Fabian Dill
0d929b81e8 Factorio: fix files from mod base directory not being grabbed correctly in non-apworld (#2603) 2023-12-21 04:26:41 +01:00
Fabian Dill
8842f5d5c7 Core: make update_reachable_regions local variables more wordy (#2522) 2023-12-21 04:11:11 +01:00
Star Rauchenberger
817197c14d Lingo: Tests no longer disable forced good item (#2602)
The static class with the "disable forced good item" field is gone. Now, certain tests that want to check for specific access progression can run a method that removes the forced good item and adds it back to the pool. Tests that don't care about this will collect the forced good item like normal. This should prevent the intermittent fill failures on complex doors unit tests, since the forced good item should provide enough locations to fill in.
2023-12-18 09:46:24 -06:00
Alchav
c8adadb08b Pokémon R/B: Fix Flash learnable logic (#2615) 2023-12-18 09:39:04 -06:00
Zach Parks
a549af8304 Hollow Knight: Add additional DeathLink option and add ExtraPlatforms option. (#2545) 2023-12-17 10:11:40 -06:00
Fabian Dill
4979314825 Webhost: open graph support for /room (#2580)
* WebHost: add Open Graph metadata to /room

* WebHost: Open Graph cleanup
2023-12-17 00:08:40 -05:00
Silvris
f958af4067 Adventure: Fix KeyError on Retrieved (#2560) 2023-12-16 22:22:51 +01:00
Aaron Wagener
7dff09dc1a Options: set old options api before the world is created (#2378) 2023-12-16 22:21:05 +01:00
lordlou
c56cbd0474 SM: item link replacement fix (#2597) 2023-12-16 04:28:54 +01:00
PoryGone
6c4fdc985d SA2B: Fix Weapons Bed - Omochao 2 Logic (#2605) 2023-12-16 04:16:36 +01:00
Alchav
b500cf600c FFMQ: Actually fix the spellbook option (#2594) 2023-12-16 04:16:13 +01:00
Alchav
394633558f ALTTP: Restore allow_excluded (#2607)
Restores allow_excluded to the dungeon fill_restrictive call, which was apparently removed by mistake during merge conflict resolution
2023-12-15 20:39:09 +01:00
Alchav
3e3af385fa Pokémon R/B: client locations import (#2596) 2023-12-13 23:57:14 +01:00
Yussur Mustafa Oraji
ff556bf4cc sm64ex: Fix server (#2599) 2023-12-13 23:46:46 +01:00
Alchav
a3b0476b4b LTTP: Boss rule fix (#2600) 2023-12-13 23:34:36 +01:00
Zach Parks
0eefe9e936 WebHost: Some refactors and additional checks when uploading files. (#2549) 2023-12-12 20:12:16 -06:00
Aaron Wagener
db1d195cb0 Hollow Knight: remove unused option check (#2595) 2023-12-12 20:11:10 -06:00
Bryce Wilson
45fa9a8f9e BizHawkClient: Add SGB to systems using explicit vblank callback (#2593) 2023-12-12 05:48:20 +01:00
Alchav
e9317d4031 FFMQR: Fix Empty Kaeli Companion Event Location and Spellbook option (#2591) 2023-12-12 02:39:38 +01:00
Aaron Wagener
d9d282c925 Tests: test that the datapackage after generation is still valid (#2575) 2023-12-12 02:14:44 +01:00
Fabian Dill
13122ab466 Core: remove start_inventory_from_pool from early_items (#2579) 2023-12-10 20:42:41 +01:00
Fabian Dill
e8f96dabe8 Core: faster prog balance (#2586)
* Core: rename world to multiworld in balance_multiworld_progression

* Core: small optimization to progression balance speed
2023-12-10 20:42:07 +01:00
Fabian Dill
1a05bad612 Core: update modules (#2551) 2023-12-10 20:38:49 +01:00
NewSoupVi
8142564156 The Witness: Fix non-deterministic hints (#2514) 2023-12-10 20:36:55 +01:00
NewSoupVi
e2109dba50 The Witness: Fix Logic Error for Keep Pressure Plates 2 EP in puzzle_randomization: none (#2515) 2023-12-10 20:35:46 +01:00
Yussur Mustafa Oraji
3a09677333 sm64ex: Fix generations (#2583) 2023-12-10 20:31:43 +01:00
Star Rauchenberger
d3b09bde12 Lingo: Fix entrance checking being broken on default settings (#2506)
The most serious issue this PR addresses is that entrances that use doors without items (a small subset of doors when door shuffle is on, but *every* door when door shuffle is off, which is the default) underestimate the requirements needed to use that entrance. The logic would calculate the panels needed to open the door, but would neglect to keep track of the rooms those panels were in, meaning that doors would be considered openable if you had the colors needed to solve a panel that's in a room you have no access to.

Another issue is that, previously, logic would always consider the "ANOTHER TRY" panel accessible for the purposes of the LEVEL 2 panel hunt. This could result in seeds where the player is expected to have exactly the correct number of solves to reach LEVEL 2, but in reality is short by one because ANOTHER TRY itself is not revealed until the panel hunt is complete. This change marks ANOTHER TRY as non-counting, because even though it is technically a counting panel in-game, it can never contribute to the LEVEL 2 panel hunt. This issue could also apply to THE MASTER, since it is the only other counting panel with special access rules, although it is much less likely. This change adds special handling for counting THE MASTER. These issues were possible to manifest whenever the LEVEL 2 panel hunt was enabled, which it is by default.

Smaller logic issues also fixed in this PR:

* The Orange Tower Basement MASTERY panel was marked as requiring the mastery doors to be opened, when it was actually possible to get it without them by using a painting to get into the room.
* The Pilgrim Room painting item was incorrectly being marked as a filler item, despite it being progression.
* There has been another update to the game that adds connections between areas that were previously not connected. These changes were additive, which is why they are not critical.
* The panel stacks in the rhyme room now require both colours on each panel.
2023-12-10 19:15:42 +01:00
Rjosephson
01d0c05259 RoR2: Remove begin with loop (#2518) 2023-12-10 19:12:46 +01:00
Fabian Dill
19b8624818 Factorio: remove staging folder for mod assembly (#2519) 2023-12-10 19:11:57 +01:00
Alchav
1312884fa2 Pokémon R/B: Fix Silph Co 6F Hostage (#2524)
Fixes an issue where the Silph Co 6F hostage check becomes unavailable if Giovanni has been defeated on 11F. This is due to the NPC having separate scripts depending on whether Giovanni was defeated. The code for the check has been moved to before the branch.
2023-12-10 19:10:09 +01:00
lordlou
6cd5abdc11 SMZ3: KeyTH check fix (#2574) 2023-12-10 19:07:56 +01:00
JaredWeakStrike
6b0eb7da79 KH2: RC1 Bug Fixes (#2530)
Changes the finished_game to new variable so now it only checks the game's memory and if it has sent the finished flag before
Fixed ag2 not requiring 1 of each black magic
Fix hitlist if you exclude summon level 7 and have summon levels option turned off
2023-12-10 18:58:52 +01:00
Doug Hoskisson
b0a09f67f4 Core: some typing and documentation in BaseClasses.py (#2589) 2023-12-10 06:43:17 +01:00
Fabian Dill
c3184e7b19 Factorio: fix wrong parent class for FactorioStartItems (#2587) 2023-12-10 00:10:01 -05:00
t3hf1gm3nt
3214cef6cf TLOZ: Fix starting weapon possibly getting overwritten by triforce fragments (#2578)
As discovered by this bug report https://discord.com/channels/731205301247803413/1182522267687731220 it's currently possible to accidentally have the starting weapon of a player overwritten by a triforce fragment if TriforceLocations is set to dungeons and StartingPosition is set to dangerous. This fix makes sure to remove the location of a placed starting weapon if said location is in a dungeon from the pool of possible locations that triforce fragments can be placed in this circumstance.
2023-12-10 04:23:40 +01:00
Alchav
f10431779b ALTTP: Ensure all Hyrule Castle keys are local in Standard (#2582) 2023-12-09 19:33:51 +01:00
JaredWeakStrike
a9a6c72d2c KH2: Fix events in datapackage (#2576) 2023-12-08 22:39:24 +01:00
PoryGone
9351fb45ca SA2B: Fix KeyError on Unexpected Characters in Slot Names (#2571)
There were no safeguards on characters being used as keys into a conversion dict. Now there are.
2023-12-08 07:17:12 +01:00
beauxq
abfc2ddfed Zillion: fix retrieved packet processing 2023-12-07 22:27:46 +01:00
NewSoupVi
bf801a1efe The Witness: Fix Symmetry Island Upper Panel logic (2nd try)
I got lazy and didn't properly test the last fix.

Big apologies, I got a bit panicked with all the logic errors that were being found.
2023-12-07 20:16:22 +01:00
Bryce Wilson
5bd022138b Pokemon Emerald: Fix missing rule for 2 items on Route 120 (#2570)
Two items on Route 120 are on the other side of a pond but were considered accessible in logic without Surf.


Creates a new separate region for these two items and adds a rule for being able to Surf to get to this region. Also adds the items to the existing surf test.
2023-12-07 20:15:38 +01:00
Aaron Wagener
69ae12823a The Messenger: bump required client version (#2544)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2023-12-07 08:23:05 +01:00
Aaron Wagener
57001ced0f The Messenger: remove old links and update relevant ones (#2542) 2023-12-07 08:22:12 +01:00
NewSoupVi
3fa01a41cd The Witness: Fix unreachable locations on certain settings (Keep PP2 EP, Theater Flowers EP) (#2499)
Basically, the function for "checking entrances both ways" only checked one way. This resulted in unreachable locations.

This affects Expert seeds with (non-remote doors and specific types of EP Shuffle), as well as seeds with non-remote doors + specific types of disabled panels + specific types of EP Shuffle.

Also includes two changes that makes spoiler logs nicer (not creating unnecessary events).
2023-12-07 06:36:46 +01:00
Alchav
87252c14aa FFMQ: Update to FFMQR 1.5 (#2568)
FFMQR was just updated to 1.5, adding a number of new options. This brings these updates to AP.
2023-12-06 18:24:59 +01:00
Fabian Dill
56ac6573f1 WebHost: fix room shutdown (#2554)
Currently when a room shuts down while clients are connected it instantly spins back up. This fixes that behaviour categorically.
I still don't know why or when this problem started, but it's certainly wreaking havok on prod.
2023-12-06 18:24:13 +01:00
Doug Hoskisson
d8004f82ef Zillion: some typing fixes (#2534)
`colorama` has type stubs when it didn't before

`ZillionDeltaPatch.hash` annotated type could be `None` but md5s doesn't allow `None`

type of `CollectionState.prog_items` changed

`WorldTestBase` moved

all of the following are related to this issue:
https://github.com/python/typing/discussions/1486

CommonContext for `command_processor` (is invalid without specifying immutable - but I don't need it anyway)

ZillionWorld options and settings (is invalid without specifying immutable - but I do need it)
2023-12-06 18:23:43 +01:00
NewSoupVi
597f94dc22 The Witness: Add all the Challenge panels to Challenge exclusion list (#2564)
Just a small cleanup where right now, the logic still considers the entirety of the challenge "solvable" except for Challenge Vault Box
2023-12-06 18:22:11 +01:00
Aaron Wagener
49e1fd0b79 The Messenger: ease rule on key of strength a bit (#2541)
Makes the logic for accessing key of strength just a tiny bit easier since a few players said it was really difficult.
2023-12-06 18:20:18 +01:00
Yussur Mustafa Oraji
530617c9a7 sm64ex: Refactor Regions (#2546)
Refactors region code to remove references to course index.
There were bugs somewhere, but I dont know where tbh.
This fixes them but leaves logic otherwise intact, and much cleaner to look at as there's one list less to take care of.

Additionally, this fixes stopping the clock from Big Boos Haunt.
2023-12-06 18:19:03 +01:00
NewSoupVi
229a263131 The Witness: Fix logic error with Symmetry Island Upper in doors: panels (broken seed reported) (#2565)
Door entities think they can be solved without any other panels needing to be solved.

Usually, this is true, because they no longer need to be "powered on" by a previous panel.
However, there are some entities that need another entity to be powered/solved for a different reason.
In this case, Symmetry Island Lower Left set opens the latches that block your ability to solve the panel. The panel itself actually starts on. Playing doors: panels does not change this, unlike usually where dependencies like this get removed by playing that mode.

In the long term, I want to somehow be able to "mark" dependencies as "environmental" or "power based" so I can distinguish them properly.
2023-12-06 18:17:27 +01:00
NewSoupVi
a861ede8b3 The Witness: Fix various incorrect symbol requirements in Vanilla Puzzles (#2543)
* Fix Vanilla First Floor Left

* More vanilla logic fixes

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2023-12-04 16:26:00 +01:00
el-u
b7111eeccc lufia2ac: fix disappearing Ancient key (#2537)
Since the coop update, the Ancient key (which is always the reward for defeating the boss) would disappear when leaving the cave, making it impossible to open the locked door behind the Ancient Cave entrance counter. While this is basically cosmetic and has no adverse effects on the multiworld (as the door does not lead to any multiworld locations and is only accessible after defeating the final boss anyway), players may still want to enter this room as part of a ritual to celebrate their victory.

Why does this happen? The game keeps track of two different inventories, one for outside and another one for the cave dive. When entering or leaving the cave, important things such as blue chest items and Iris treasures are automatically copied to the other inventory. However, it turns out that the Ancient key doesn't participate in this mechanism. Instead, the script that runs when exiting the cave checks whether event flag 0xC3 is set, and if it is on, it calls a script action that adds the key item to the outside inventory. (Whether or not the player actually had the key item in their in-cave inventory is not checked at all; only the flag matters.)

In the unmodified game, that flag is set by the cutscene script that awards the key. It actually sets two event flags, 0xC3 and 0xD1. The latter is used by the game when trying to display the boss in the cafe basement and is used by AP as the indicator that the boss goal was completed. With the coop update, the event script method that created the key was intercepted and modified to send out a location check instead. That location always has the Ancient key as a fixed item placement; the benefit of handling it as a remote item is that in this way the key essentially serves as a signal that transmits the information of the boss' defeat to all clients cooping on the slot. When receiving the key, however, the custom ASM did only set flag 0xD1. As part of the bugfix, it is now changed to set flag 0xC3 as well.

But that alone is still not enough to make it work. The subroutine that is called by the game to create the key when exiting the cave with flag 0xC3 is the same subroutine that gets called in the cutscene that originally tried to award the key. But that's the one that has been rewritten to send the location check instead. So instead of creating the key when leaving the cave, it would just send the same location check again, effectively doing nothing. Therefore, the other part of the bugfix is to only intercept this subroutine if the player is currently on the Ancient Cave Final Floor (where the cutscene takes place), thus making it possible to recreate the key item when exiting.
2023-12-04 00:06:52 +01:00
Star Rauchenberger
39a92e98c6 Lingo: Default color shuffle to on (#2548)
* Lingo: Default color shuffle on

* Raise error if no progression in multiworld
2023-12-04 00:06:11 +01:00
zig-for
a83bf2f616 LADX: Fix bug with Webhost usage (#2556)
We were using data created in init when we never called init
2023-12-03 21:24:35 +01:00
Alchav
e8ceb12281 Pokémon RB: Fix connection names + missing connection (#2553) 2023-12-02 18:40:38 +01:00
Aaron Wagener
6e38126add Webhost: fix options page redirects (#2540) 2023-12-01 14:20:24 -06:00
Fabian Dill
5e5018dd64 WebHost: flash each message only once (#2547) 2023-12-01 21:19:41 +01:00
Aaron Wagener
c7d4c2f63c Docs: Add documentation on writing and running tests (#2348)
* Docs: Add documentation on writing and running tests

* review improvements

* sliver requests
2023-12-01 10:26:27 +01:00
agilbert1412
80fed1c6fb Stardew Valley: Fixed potential softlock with walnut purchases if Entrance Randomizer locks access to the field office (#2261)
* - Added logic rules for reaching, then completing, the field office in order to be allowed to spend significant amounts of walnuts

* - Revert moving a method for some reason
2023-11-30 09:32:32 +01:00
Brooty Johnson
b9ce2052c5 DS3: update setup guide to preserve downpatching instructions (#2531)
* update DS3 setup guide to preserve downpatching instructions

we want to preserve this on the AP site as the future of the speedsouls wiki is unknown and may disappear at any time.

* Update worlds/dark_souls_3/docs/setup_en.md

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>

* Update setup_en.md

---------

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>
2023-11-30 09:29:55 +01:00
Chris Wilson
a83501a2a0 Fix a bug in weighted-settings causing accepted range values to be exclusive of outer range (#2535) 2023-11-29 22:57:40 -05:00
t3hf1gm3nt
6c5f8250fb TLOZ: Use the proper location name lookup (#2529) 2023-11-29 00:19:42 -06:00
el-u
39969abd6a WebHostLib: fix NamedRange in options presets (#2528) 2023-11-28 17:11:17 -06:00
Bryce Wilson
737686a88d BizHawkClient: Use local_path when autolaunching BizHawk with lua script (#2526)
* BizHawkClient: Change autolaunch path to lua script to use local_path

* BizHawkClient: Remove unnecessary call to os.path.join and linting
2023-11-28 22:56:27 +01:00
Bryce Wilson
ce2f9312ca BizHawkClient: Change open_connection to use 127.0.0.1 instead of localhost (#2525)
When using localhost on mac, both ipv4 and ipv6 are tried and raise separate errors
which are combined by asyncio and difficult/inelegant to handle.

Python 3.12 adds the argument all_errors, which would make this easier.
2023-11-28 22:50:12 +01:00
117 changed files with 1575 additions and 912 deletions

View File

@@ -115,11 +115,12 @@ class AdventureContext(CommonContext):
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]

View File

@@ -252,15 +252,20 @@ class MultiWorld():
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
# TODO - remove this section once all worlds use options dataclasses
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
for option_key in world_type.options_dataclass.type_hints:
option_values = getattr(args, option_key, {})
setattr(self, option_key, option_values)
# TODO - remove this loop once all worlds use options dataclasses
options_dataclass: typing.Type[Options.PerGameCommonOptions] = self.worlds[player].options_dataclass
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
@@ -491,7 +496,7 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, starting_state: Optional[CollectionState] = None):
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -504,7 +509,7 @@ class MultiWorld():
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere = set()
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
@@ -524,12 +529,19 @@ class MultiWorld():
return False
def get_spheres(self):
def get_spheres(self) -> Iterator[Set[Location]]:
"""
yields a set of locations for each logical sphere
If there are unreachable locations, the last sphere of reachable
locations is followed by an empty set, and then a set of all of the
unreachable locations.
"""
state = CollectionState(self)
locations = set(self.get_filled_locations())
while locations:
sphere = set()
sphere: Set[Location] = set()
for location in locations:
if location.can_reach(state):
@@ -639,34 +651,34 @@ class CollectionState():
def update_reachable_regions(self, player: int):
self.stale[player] = False
rrp = self.reachable_regions[player]
bc = self.blocked_connections[player]
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
queue = deque(self.blocked_connections[player])
start = self.multiworld.get_region('Menu', player)
start = self.multiworld.get_region("Menu", player)
# init on first call - this can't be done on construction since the regions don't exist yet
if start not in rrp:
rrp.add(start)
bc.update(start.exits)
if start not in reachable_regions:
reachable_regions.add(start)
blocked_connections.update(start.exits)
queue.extend(start.exits)
# run BFS on all connections, and keep track of those blocked by missing items
while queue:
connection = queue.popleft()
new_region = connection.connected_region
if new_region in rrp:
bc.remove(connection)
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
rrp.add(new_region)
bc.remove(connection)
bc.update(new_region.exits)
reachable_regions.add(new_region)
blocked_connections.remove(connection)
blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
if new_entrance in bc and new_entrance not in queue:
if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
def copy(self) -> CollectionState:

41
Fill.py
View File

@@ -550,7 +550,7 @@ def flood_items(world: MultiWorld) -> None:
break
def balance_multiworld_progression(world: MultiWorld) -> None:
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
@@ -558,28 +558,28 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: world.worlds[player].options.progression_balancing / 100
for player in world.player_ids
if world.worlds[player].options.progression_balancing > 0
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
@@ -658,7 +658,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
if multiworld.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
@@ -675,7 +675,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
world.random.shuffle(items_to_test)
multiworld.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
@@ -687,8 +687,8 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
reducing_state.sweep_for_events(locations=locations_to_test)
if world.has_beaten_game(balancing_state):
if not world.has_beaten_game(reducing_state):
if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -696,33 +696,32 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
replaced_items = False
old_moved_item_count = moved_item_count
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
world.random.shuffle(replacement_locations)
multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
world.random.shuffle(items_to_replace)
multiworld.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for new_location in replacement_locations:
for i, new_location in enumerate(replacement_locations):
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
replacement_locations.remove(new_location)
replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
if replaced_items:
if old_moved_item_count < moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
@@ -736,7 +735,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
if world.has_beaten_game(state):
if multiworld.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")

16
Main.py
View File

@@ -117,6 +117,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = world.early_items[player].get(item_name, 0)
if early:
world.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = world.early_local_items[player].get(item_name, 0)
if local_early:
world.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
@@ -156,10 +167,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
if any(getattr(world.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value for player in world.player_ids):
new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
player: getattr(world.worlds[player].options, "start_inventory_from_pool", StartInventoryPool({})).value.copy()
for player in world.player_ids}
for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player]
for count in items.values():

View File

@@ -779,6 +779,25 @@ def deprecate(message: str):
import warnings
warnings.warn(message)
class DeprecateDict(dict):
log_message: str
should_error: bool
def __init__(self, message, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message)
elif __debug__:
import warnings
warnings.warn(self.log_message)
return super().__getitem__(item)
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327

View File

@@ -1,3 +1,4 @@
import os
import zipfile
import base64
from typing import Union, Dict, Set, Tuple
@@ -6,13 +7,7 @@ from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from WebHostLib import app
banned_zip_contents = (".sfc",)
def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -51,33 +46,41 @@ def mysterycheck():
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for uploaded_file in files:
# if user does not select file, browser also
# submit an empty part without filename
if uploaded_file.filename == '':
return 'No selected file'
if banned_file(uploaded_file.filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
"Your file was deleted.")
# If the user does not select file, the browser will still submit an empty string without a file name.
elif uploaded_file.filename == "":
return "No selected file."
elif uploaded_file.filename in options:
return f'Conflicting files named {uploaded_file.filename} submitted'
elif uploaded_file and allowed_file(uploaded_file.filename):
return f"Conflicting files named {uploaded_file.filename} submitted."
elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
with zipfile.ZipFile(uploaded_file, 'r') as zfile:
infolist = zfile.infolist()
uploaded_file.seek(0) # offset from is_zipfile check
with zipfile.ZipFile(uploaded_file, "r") as zfile:
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return ("Uploaded data contained a rom file, "
"which is likely to contain copyrighted material. "
"Your file was deleted.")
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
if base_filename.endswith(".archipelago"):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported."
elif banned_file(base_filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
"material. Your file was deleted.")
# Ignore dot-files.
elif not base_filename.startswith(".") and allowed_options(base_filename):
options[file.filename] = zfile.open(file, "r").read()
else:
options[uploaded_file.filename] = uploaded_file.read()
if not options:
return "Did not find a .yaml file to process."
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return options

View File

@@ -205,6 +205,12 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
# ensure auto launch is on the same page in regard to room activity.
with db_session:
room: Room = Room.get(id=ctx.room_id)
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
logging.info("Shutting down")
with Locker(room_id):

View File

@@ -5,5 +5,5 @@ Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.2.2; python_version >= '3.9'
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -369,7 +369,7 @@ const setPresets = (optionsData, presetName) => {
break;
}
case 'special_range': {
case 'named_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);

View File

@@ -576,7 +576,7 @@ class GameSettings {
option = parseInt(option, 10);
let optionAcceptable = false;
if ((option > setting.min) && (option < setting.max)) {
if ((option >= setting.min) && (option <= setting.max)) {
optionAcceptable = true;
}
if (setting.hasOwnProperty('value_names') && Object.values(setting.value_names).includes(option)){

View File

@@ -3,6 +3,16 @@
{% block head %}
<title>Multiworld {{ room.id|suuid }}</title>
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
<meta name="og:site_name" content="Archipelago">
<meta property="og:title" content="Multiworld {{ room.id|suuid }}">
<meta property="og:type" content="website" />
{% if room.seed.slots|length < 2 %}
<meta property="og:description" content="{{ room.seed.slots|length }} Player World
{% if room.last_port != -1 %}running on {{ config['HOST_ADDRESS'] }} with port {{ room.last_port }}{% endif %}">
{% else %}
<meta property="og:description" content="{{ room.seed.slots|length }} Players Multiworld
{% if room.last_port != -1 %}running on {{ config['HOST_ADDRESS'] }} with port {{ room.last_port }}{% endif %}">
{% endif %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %}

View File

@@ -16,7 +16,7 @@
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages %}
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>

View File

@@ -53,7 +53,7 @@
{% endif %}
{% if world.web.options_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.settings_page }}">Options Page</a>
<a href="{{ world.web.options_page }}">Options Page</a>
{% elif world.web.options_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_options", game=game_name) }}">Options Page</a>

View File

@@ -19,7 +19,22 @@ from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot, GameDataPackage
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
banned_extensions = (".sfc", ".z64", ".n64", ".nes", ".smc", ".sms", ".gb", ".gbc", ".gba")
allowed_options_extensions = (".yaml", ".json", ".yml", ".txt", ".zip")
allowed_generation_extensions = (".archipelago", ".zip")
def allowed_options(filename: str) -> bool:
return filename.endswith(allowed_options_extensions)
def allowed_generation(filename: str) -> bool:
return filename.endswith(allowed_generation_extensions)
def banned_file(filename: str) -> bool:
return filename.endswith(banned_extensions)
def process_multidata(compressed_multidata, files={}):
decompressed_multidata = MultiServer.Context.decompress(compressed_multidata)
@@ -61,8 +76,8 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if not owner:
owner = session["_id"]
infolist = zfile.infolist()
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains .yaml files. "
if all(allowed_options(file.filename) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains options files. "
'Did you mean to <a href="/generate">generate a game</a>?'))
return
@@ -73,7 +88,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Load files.
for file in infolist:
handler = AutoPatchRegister.get_handler(file.filename)
if file.filename.endswith(banned_zip_contents):
if banned_file(file.filename):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
@@ -136,35 +151,34 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("No multidata was found in the zip file, which is required.")
@app.route('/uploads', methods=['GET', 'POST'])
@app.route("/uploads", methods=["GET", "POST"])
def uploads():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
flash('No file part')
if request.method == "POST":
# check if the POST request has a file part.
if "file" not in request.files:
flash("No file part in POST request.")
else:
file = request.files['file']
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
flash('No selected file')
elif file and allowed_file(file.filename):
if zipfile.is_zipfile(file):
with zipfile.ZipFile(file, 'r') as zfile:
uploaded_file = request.files["file"]
# If the user does not select file, the browser will still submit an empty string without a file name.
if uploaded_file.filename == "":
flash("No selected file.")
elif uploaded_file and allowed_generation(uploaded_file.filename):
if zipfile.is_zipfile(uploaded_file):
with zipfile.ZipFile(uploaded_file, "r") as zfile:
try:
res = upload_zip_to_db(zfile)
except VersionException:
flash(f"Could not load multidata. Wrong Version detected.")
else:
if type(res) == str:
if res is str:
return res
elif res:
return redirect(url_for("view_seed", seed=res.id))
else:
file.seek(0) # offset from is_zipfile check
uploaded_file.seek(0) # offset from is_zipfile check
# noinspection PyBroadException
try:
multidata = file.read()
multidata = uploaded_file.read()
slots, multidata = process_multidata(multidata)
except Exception as e:
flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})")
@@ -182,7 +196,3 @@ def user_content():
rooms = select(room for room in Room if room.owner == session["_id"])
seeds = select(seed for seed in Seed if seed.owner == session["_id"])
return render_template("userContent.html", rooms=rooms, seeds=seeds)
def allowed_file(filename):
return filename.endswith(('.archipelago', ".zip"))

View File

@@ -13,7 +13,6 @@ from typing import List
import Utils
from Utils import async_start
from worlds import lookup_any_location_id_to_name
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser
@@ -153,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations]
checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop]
@@ -191,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
locations_checked = []
location = None
for location in ctx.missing_locations:
location_name = lookup_any_location_id_to_name[location]
location_name = ctx.location_names[location]
if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]]

View File

@@ -1,7 +1,7 @@
import asyncio
import base64
import platform
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, cast
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
@@ -10,7 +10,7 @@ from NetUtils import ClientStatus
import Utils
from Utils import async_start
import colorama # type: ignore
import colorama
from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
@@ -45,7 +45,7 @@ class SetRoomCallback(Protocol):
class ZillionContext(CommonContext):
game = "Zillion"
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
command_processor = ZillionCommandProcessor
items_handling = 1 # receive items from other players
known_name: Optional[str]
@@ -278,7 +278,7 @@ class ZillionContext(CommonContext):
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
return
keys = cast(Dict[str, Optional[str]], args["keys"])
doors_b64 = keys[f"zillion-{self.auth}-doors"]
doors_b64 = keys.get(f"zillion-{self.auth}-doors", None)
if doors_b64:
logger.info("received door data from server")
doors = base64.b64decode(doors_b64)

View File

@@ -585,7 +585,7 @@ else
-- misaligned, so for GB and GBC we explicitly set the callback on
-- vblank instead.
-- https://github.com/TASEmulators/BizHawk/issues/3711
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" or emu.getsystemid() == "SGB" then
event.onmemoryexecute(tick, 0x40, "tick", "System Bus")
else
event.onframeend(tick)

View File

@@ -7,7 +7,7 @@ Contributions are welcome. We have a few requests for new contributors:
* **Ensure that critical changes are covered by tests.**
It is strongly recommended that unit tests are used to avoid regression and to ensure everything is still working.
If you wish to contribute by adding a new game, please take a look at the [logic unit test documentation](/docs/world%20api.md#tests).
If you wish to contribute by adding a new game, please take a look at the [logic unit test documentation](/docs/tests.md).
If you wish to contribute to the website, please take a look at [these tests](/test/webhost).
* **Do not introduce unit test failures/regressions.**

90
docs/tests.md Normal file
View File

@@ -0,0 +1,90 @@
# Archipelago Unit Testing API
This document covers some of the generic tests available using Archipelago's unit testing system, as well as some basic
steps on how to write your own.
## Generic Tests
Some generic tests are run on every World to ensure basic functionality with default options. These basic tests can be
found in the [general test directory](/test/general).
## Defining World Tests
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
for your world tests can be created in this file that you can then import into other modules.
### WorldTestBase
In order to test basic functionality of varying options, as well as to test specific edge cases or that certain
interactions in the world interact as expected, you will want to use the [WorldTestBase](/test/bases.py). This class
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
options combinations.
Example `/worlds/<my_game>/test/__init__.py`:
```python
from test.bases import WorldTestBase
class MyGameTestBase(WorldTestBase):
game = "My Game"
```
The basic tests that WorldTestBase comes with include `test_all_state_can_reach_everything`,
`test_empty_state_can_reach_something`, and `test_fill`. These test that with all collected items everything is
reachable, with no collected items at least something is reachable, and that a valid multiworld can be completed with
all steps being called, respectively.
### Writing Tests
#### Using WorldTestBase
Adding runs for the basic tests for a different option combination is as easy as making a new module in the test
package, creating a class that inherits from your game's TestBase, and defining the options in a dict as a field on the
class. The new module should be named `test_<something>.py` and have at least one class inheriting from the base, or
define its own testing methods. Newly defined test methods should follow standard PEP8 snake_case format and also start
with `test_`.
Example `/worlds/<my_game>/test/test_chest_access.py`:
```python
from . import MyGameTestBase
class TestChestAccess(MyGameTestBase):
options = {
"difficulty": "easy",
"final_boss_hp": 4000,
}
def test_sword_chests(self) -> None:
"""Test locations that require a sword"""
locations = ["Chest1", "Chest2"]
items = [["Sword"]]
# This tests that the provided locations aren't accessible without the provided items, but can be accessed once
# the items are obtained.
# This will also check that any locations not provided don't have the same dependency requirement.
# Optionally, passing only_check_listed=True to the method will only check the locations provided.
self.assertAccessDependency(locations, items)
```
When tests are run, this class will create a multiworld with a single player having the provided options, and run the
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
overridden. For more information on what methods are available to your class, check the
[WorldTestBase definition](/test/bases.py#L104).
#### Alternatives to WorldTestBase
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first.
## Running Tests
In PyCharm, running all tests can be done by right-clicking the root `test` directory and selecting `run Python tests`.
If you do not have pytest installed, you may get import failures. To solve this, edit the run configuration, and set the
working directory of the run to the Archipelago directory. If you only want to run your world's defined tests, repeat
the steps for the test directory within your world.

View File

@@ -870,7 +870,7 @@ TestBase, and can then define options to test in the class body, and run tests i
Example `__init__.py`
```python
from test.test_base import WorldTestBase
from test.bases import WorldTestBase
class MyGameTestBase(WorldTestBase):
@@ -879,7 +879,7 @@ class MyGameTestBase(WorldTestBase):
Next using the rules defined in the above `set_rules` we can test that the chests have the correct access rules.
Example `testChestAccess.py`
Example `test_chest_access.py`
```python
from . import MyGameTestBase
@@ -899,3 +899,5 @@ class TestChestAccess(MyGameTestBase):
# this will test that chests 3-5 can't be accessed without any weapon, but can be with just one of them.
self.assertAccessDependency(locations, items)
```
For more information on tests check the [tests doc](tests.md).

View File

@@ -1,13 +1,13 @@
colorama>=0.4.5
websockets>=11.0.3
websockets>=12.0
PyYAML>=6.0.1
jellyfish>=1.0.3
jinja2>=3.1.2
schema>=0.7.5
kivy>=2.2.0
kivy>=2.2.1
bsdiff4>=1.2.4
platformdirs>=4.0.0
certifi>=2023.11.17
cython>=3.0.5
cython>=3.0.6
cymem>=2.0.8
orjson>=3.9.10

View File

@@ -1,5 +1,8 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from Fill import distribute_items_restrictive
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
class TestIDs(unittest.TestCase):
@@ -66,3 +69,34 @@ class TestIDs(unittest.TestCase):
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))
def test_postgen_datapackage(self):
"""Generates a solo multiworld and checks that the datapackage is still valid"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
multiworld = setup_solo_multiworld(world_type)
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
datapackage = world_type.get_data_package_data()
for item_group, item_names in datapackage["item_name_groups"].items():
self.assertIsInstance(item_group, str,
f"item_name_group names should be strings: {item_group}")
for item_name in item_names:
self.assertIsInstance(item_name, str,
f"{item_name}, in group {item_group} is not a string")
for loc_group, loc_names in datapackage["location_name_groups"].items():
self.assertIsInstance(loc_group, str,
f"location_name_group names should be strings: {loc_group}")
for loc_name in loc_names:
self.assertIsInstance(loc_name, str,
f"{loc_name}, in group {loc_group} is not a string")
for item_name, item_id in datapackage["item_name_to_id"].items():
self.assertIsInstance(item_name, str,
f"{item_name} is not a valid item name for item_name_to_id")
self.assertIsInstance(item_id, int,
f"{item_id} for {item_name} should be an int")
for loc_name, loc_id in datapackage["location_name_to_id"].items():
self.assertIsInstance(loc_name, str,
f"{loc_name} is not a valid item name for location_name_to_id")
self.assertIsInstance(loc_id, int,
f"{loc_id} for {loc_name} should be an int")

View File

@@ -77,6 +77,10 @@ class AutoWorldRegister(type):
# create missing options_dataclass from legacy option_definitions
# TODO - remove this once all worlds use options dataclasses
if "options_dataclass" not in dct and "option_definitions" in dct:
# TODO - switch to deprecate after a version
if __debug__:
from warnings import warn
warn("Assigning options through option_definitions is now deprecated. Use options_dataclass instead.")
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
bases=(PerGameCommonOptions,))

View File

@@ -97,7 +97,7 @@ async def connect(ctx: BizHawkContext) -> bool:
for port in ports:
try:
ctx.streams = await asyncio.open_connection("localhost", port)
ctx.streams = await asyncio.open_connection("127.0.0.1", port)
ctx.connection_status = ConnectionStatus.TENTATIVE
ctx._port = port
return True

View File

@@ -208,19 +208,30 @@ async def _run_game(rom: str):
if auto_start is True:
emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path
subprocess.Popen([emuhawk_path, "--lua=data/lua/connector_bizhawk_generic.lua", os.path.realpath(rom)],
cwd=Utils.local_path("."),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
subprocess.Popen(
[
emuhawk_path,
f"--lua={Utils.local_path('data', 'lua', 'connector_bizhawk_generic.lua')}",
os.path.realpath(rom),
],
cwd=Utils.local_path("."),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
elif isinstance(auto_start, str):
import shlex
subprocess.Popen([*shlex.split(auto_start), os.path.realpath(rom)],
cwd=Utils.local_path("."),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
subprocess.Popen(
[
*shlex.split(auto_start),
os.path.realpath(rom)
],
cwd=Utils.local_path("."),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
async def _patch_and_run_game(patch_file: str):

View File

@@ -2,7 +2,9 @@ from __future__ import annotations
from typing import Dict
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
from dataclasses import dataclass
class FreeincarnateMax(Range):
@@ -224,21 +226,20 @@ class StartCastle(Choice):
default = option_yellow
adventure_option_definitions: Dict[str, type(Option)] = {
"dragon_slay_check": DragonSlayCheck,
"death_link": DeathLink,
"bat_logic": BatLogic,
"freeincarnate_max": FreeincarnateMax,
"dragon_rando_type": DragonRandoType,
"connector_multi_slot": ConnectorMultiSlot,
"yorgle_speed": YorgleStartingSpeed,
"yorgle_min_speed": YorgleMinimumSpeed,
"grundle_speed": GrundleStartingSpeed,
"grundle_min_speed": GrundleMinimumSpeed,
"rhindle_speed": RhindleStartingSpeed,
"rhindle_min_speed": RhindleMinimumSpeed,
"difficulty_switch_a": DifficultySwitchA,
"difficulty_switch_b": DifficultySwitchB,
"start_castle": StartCastle,
}
@dataclass
class AdventureOptions(PerGameCommonOptions):
dragon_slay_check: DragonSlayCheck
death_link: DeathLink
bat_logic: BatLogic
freeincarnate_max: FreeincarnateMax
dragon_rando_type: DragonRandoType
connector_multi_slot: ConnectorMultiSlot
yorgle_speed: YorgleStartingSpeed
yorgle_min_speed: YorgleMinimumSpeed
grundle_speed: GrundleStartingSpeed
grundle_min_speed: GrundleMinimumSpeed
rhindle_speed: RhindleStartingSpeed
rhindle_min_speed: RhindleMinimumSpeed
difficulty_switch_a: DifficultySwitchA
difficulty_switch_b: DifficultySwitchB
start_castle: StartCastle

View File

@@ -1,5 +1,6 @@
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
from Options import PerGameCommonOptions
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
@@ -24,7 +25,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
connect(world, player, target, source, rule, True)
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
for name, locdata in location_table.items():
locdata.get_position(multiworld.random)
@@ -76,7 +77,7 @@ def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> Non
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
multiworld.regions.append(credits_room_far_side)
dragon_slay_check = multiworld.dragon_slay_check[player].value
dragon_slay_check = options.dragon_slay_check.value
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
for name, location_data in location_table.items():

View File

@@ -6,7 +6,8 @@ from BaseClasses import LocationProgressType
def set_rules(self) -> None:
world = self.multiworld
use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic
options = self.options
use_bat_logic = options.bat_logic.value == BatLogic.option_use_logic
set_rule(world.get_entrance("YellowCastlePort", self.player),
lambda state: state.has("Yellow Key", self.player))
@@ -28,7 +29,7 @@ def set_rules(self) -> None:
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
dragon_slay_check = world.dragon_slay_check[self.player].value
dragon_slay_check = options.dragon_slay_check.value
if dragon_slay_check:
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
set_rule(world.get_location("Slay Yorgle", self.player),

View File

@@ -15,7 +15,7 @@ from Options import AssembleOptions
from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive
from worlds.generic.Rules import add_rule, set_rule
from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
from .Options import AdventureOptions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
AdventureAutoCollectLocation
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
@@ -109,7 +109,8 @@ class AdventureWorld(World):
game: ClassVar[str] = "Adventure"
web: ClassVar[WebWorld] = AdventureWeb()
option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
options = AdventureOptions
options_dataclass = AdventureOptions
settings: ClassVar[AdventureSettings]
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
@@ -150,18 +151,18 @@ class AdventureWorld(World):
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
self.rom_name.extend([0] * (21 - len(self.rom_name)))
self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value
self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value
self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value
self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value
self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value
self.grundle_speed = self.multiworld.grundle_speed[self.player].value
self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value
self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value
self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value
self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value
self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value
self.start_castle = self.multiworld.start_castle[self.player].value
self.dragon_rando_type = self.options.dragon_rando_type.value
self.dragon_slay_check = self.options.dragon_slay_check.value
self.connector_multi_slot = self.options.connector_multi_slot.value
self.yorgle_speed = self.options.yorgle_speed.value
self.yorgle_min_speed = self.options.yorgle_min_speed.value
self.grundle_speed = self.options.grundle_speed.value
self.grundle_min_speed = self.options.grundle_min_speed.value
self.rhindle_speed = self.options.rhindle_speed.value
self.rhindle_min_speed = self.options.rhindle_min_speed.value
self.difficulty_switch_a = self.options.difficulty_switch_a.value
self.difficulty_switch_b = self.options.difficulty_switch_b.value
self.start_castle = self.options.start_castle.value
self.created_items = 0
if self.dragon_slay_check == 0:
@@ -228,7 +229,7 @@ class AdventureWorld(World):
extra_filler_count = num_locations - self.created_items
# traps would probably go here, if enabled
freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
freeincarnate_max = self.options.freeincarnate_max.value
actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
self.created_items += actual_freeincarnates
@@ -248,7 +249,7 @@ class AdventureWorld(World):
self.created_items += 1
def create_regions(self) -> None:
create_regions(self.multiworld, self.player, self.dragon_rooms)
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
set_rules = set_rules
@@ -355,7 +356,7 @@ class AdventureWorld(World):
auto_collect_locations: [AdventureAutoCollectLocation] = []
local_item_to_location: {int, int} = {}
bat_no_touch_locs: [LocationData] = []
bat_logic: int = self.multiworld.bat_logic[self.player].value
bat_logic: int = self.options.bat_logic.value
try:
rom_deltas: { int, int } = {}
self.place_dragons(rom_deltas)
@@ -411,7 +412,7 @@ class AdventureWorld(World):
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
rom_deltas[item_position_data_start] = 0xff
if self.multiworld.connector_multi_slot[self.player].value:
if self.options.connector_multi_slot.value:
rom_deltas[connector_port_offset] = (self.player & 0xff)
else:
rom_deltas[connector_port_offset] = 0

View File

@@ -264,7 +264,7 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
if loc in all_state_base.events:
all_state_base.events.remove(loc)
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True,
fill_restrictive(multiworld, all_state_base, locations, in_dungeon_items, True, True, allow_excluded=True,
name="LttP Dungeon Items")

View File

@@ -682,8 +682,6 @@ def get_pool_core(world, player: int):
key_location = world.random.choice(key_locations)
place_item(key_location, "Small Key (Universal)")
pool = pool[:-3]
if world.key_drop_shuffle[player]:
pass # pool.extend([item_to_place] * (len(key_drop_data) - 1))
return (pool, placed_items, precollected_items, clock_mode, treasure_hunt_count, treasure_hunt_icon,
additional_pieces_to_place)

View File

@@ -136,7 +136,8 @@ def mirrorless_path_to_castle_courtyard(world, player):
def set_defeat_dungeon_boss_rule(location):
# Lambda required to defer evaluation of dungeon.boss since it will change later if boss shuffle is used
set_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
def set_always_allow(spot, rule):
spot.always_allow = rule

View File

@@ -289,12 +289,17 @@ class ALTTPWorld(World):
self.waterfall_fairy_bottle_fill = self.random.choice(bottle_options)
self.pyramid_fairy_bottle_fill = self.random.choice(bottle_options)
if multiworld.mode[player] == 'standard' \
and multiworld.smallkey_shuffle[player] \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_universal \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_own_dungeons \
and multiworld.smallkey_shuffle[player] != smallkey_shuffle.option_start_with:
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
if multiworld.mode[player] == 'standard':
if multiworld.smallkey_shuffle[player]:
if (multiworld.smallkey_shuffle[player] not in
(smallkey_shuffle.option_universal, smallkey_shuffle.option_own_dungeons,
smallkey_shuffle.option_start_with)):
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
self.multiworld.local_items[self.player].value.add("Small Key (Hyrule Castle)")
self.multiworld.non_local_items[self.player].value.discard("Small Key (Hyrule Castle)")
if multiworld.bigkey_shuffle[player]:
self.multiworld.local_items[self.player].value.add("Big Key (Hyrule Castle)")
self.multiworld.non_local_items[self.player].value.discard("Big Key (Hyrule Castle)")
# system for sharing ER layouts
self.er_seed = str(multiworld.random.randint(0, 2 ** 64))

View File

@@ -1,4 +1,5 @@
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool
from Options import Choice, Toggle, DefaultOnToggle, DeathLink, StartInventoryPool, PerGameCommonOptions
from dataclasses import dataclass
import random
@@ -163,26 +164,26 @@ class BlasphemousDeathLink(DeathLink):
Note that Guilt Fragments will not appear when killed by Death Link."""
blasphemous_options = {
"prie_dieu_warp": PrieDieuWarp,
"skip_cutscenes": SkipCutscenes,
"corpse_hints": CorpseHints,
"difficulty": Difficulty,
"penitence": Penitence,
"starting_location": StartingLocation,
"ending": Ending,
"skip_long_quests": SkipLongQuests,
"thorn_shuffle" : ThornShuffle,
"dash_shuffle": DashShuffle,
"wall_climb_shuffle": WallClimbShuffle,
"reliquary_shuffle": ReliquaryShuffle,
"boots_of_pleading": CustomItem1,
"purified_hand": CustomItem2,
"start_wheel": StartWheel,
"skill_randomizer": SkillRando,
"enemy_randomizer": EnemyRando,
"enemy_groups": EnemyGroups,
"enemy_scaling": EnemyScaling,
"death_link": BlasphemousDeathLink,
"start_inventory": StartInventoryPool
}
@dataclass
class BlasphemousOptions(PerGameCommonOptions):
prie_dieu_warp: PrieDieuWarp
skip_cutscenes: SkipCutscenes
corpse_hints: CorpseHints
difficulty: Difficulty
penitence: Penitence
starting_location: StartingLocation
ending: Ending
skip_long_quests: SkipLongQuests
thorn_shuffle : ThornShuffle
dash_shuffle: DashShuffle
wall_climb_shuffle: WallClimbShuffle
reliquary_shuffle: ReliquaryShuffle
boots_of_pleading: CustomItem1
purified_hand: CustomItem2
start_wheel: StartWheel
skill_randomizer: SkillRando
enemy_randomizer: EnemyRando
enemy_groups: EnemyGroups
enemy_scaling: EnemyScaling
death_link: BlasphemousDeathLink
start_inventory: StartInventoryPool

View File

@@ -497,8 +497,9 @@ def chalice_rooms(state: CollectionState, player: int, number: int) -> bool:
def rules(blasphemousworld):
world = blasphemousworld.multiworld
player = blasphemousworld.player
logic = world.difficulty[player].value
enemy = world.enemy_randomizer[player].value
options = blasphemousworld.options
logic = options.difficulty.value
enemy = options.enemy_randomizer.value
# D01Z01S01 (The Holy Line)
@@ -2488,7 +2489,7 @@ def rules(blasphemousworld):
# D04Z02S01 (Mother of Mothers)
# Items
if world.purified_hand[player]:
if options.purified_hand:
set_rule(world.get_location("MoM: Western room ledge", player),
lambda state: (
state.has("D04Z02S01[N]", player)
@@ -4093,7 +4094,7 @@ def rules(blasphemousworld):
# D17Z01S04 (Brotherhood of the Silent Sorrow)
# Items
if world.boots_of_pleading[player]:
if options.boots_of_pleading:
set_rule(world.get_location("BotSS: 2nd meeting with Redento", player),
lambda state: redento(state, blasphemousworld, player, 2))
# Doors

View File

@@ -7,7 +7,7 @@ from .Locations import location_table
from .Rooms import room_table, door_table
from .Rules import rules
from worlds.generic.Rules import set_rule, add_rule
from .Options import blasphemous_options
from .Options import BlasphemousOptions
from .Vanilla import unrandomized_dict, junk_locations, thorn_set, skill_dict
@@ -39,7 +39,8 @@ class BlasphemousWorld(World):
location_name_to_game_id = {loc["name"]: loc["game_id"] for loc in location_table}
item_name_groups = group_table
option_definitions = blasphemous_options
options = BlasphemousOptions
options_dataclass = BlasphemousOptions
required_client_version = (0, 4, 2)
@@ -73,60 +74,61 @@ class BlasphemousWorld(World):
def generate_early(self):
options = self.options
world = self.multiworld
player = self.player
if not world.starting_location[player].randomized:
if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
if not options.starting_location.randomized:
if options.starting_location.value == 6 and options.difficulty.value < 2:
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {options.starting_location}"
" cannot be chosen if Difficulty is lower than Hard.")
if (world.starting_location[player].value == 0 or world.starting_location[player].value == 6) \
and world.dash_shuffle[player]:
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
if (options.starting_location.value == 0 or options.starting_location.value == 6) \
and options.dash_shuffle:
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {options.starting_location}"
" cannot be chosen if Shuffle Dash is enabled.")
if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {world.starting_location[player]}"
if options.starting_location.value == 3 and options.wall_climb_shuffle:
raise Exception(f"[Blasphemous - '{world.get_player_name(player)}'] {options.starting_location}"
" cannot be chosen if Shuffle Wall Climb is enabled.")
else:
locations: List[int] = [ 0, 1, 2, 3, 4, 5, 6 ]
invalid: bool = False
if world.difficulty[player].value < 2:
if options.difficulty.value < 2:
locations.remove(6)
if world.dash_shuffle[player]:
if options.dash_shuffle:
locations.remove(0)
if 6 in locations:
locations.remove(6)
if world.wall_climb_shuffle[player]:
if options.wall_climb_shuffle:
locations.remove(3)
if world.starting_location[player].value == 6 and world.difficulty[player].value < 2:
if options.starting_location.value == 6 and options.difficulty.value < 2:
invalid = True
if (world.starting_location[player].value == 0 or world.starting_location[player].value == 6) \
and world.dash_shuffle[player]:
if (options.starting_location.value == 0 or options.starting_location.value == 6) \
and options.dash_shuffle:
invalid = True
if world.starting_location[player].value == 3 and world.wall_climb_shuffle[player]:
if options.starting_location.value == 3 and options.wall_climb_shuffle:
invalid = True
if invalid:
world.starting_location[player].value = world.random.choice(locations)
options.starting_location.value = world.random.choice(locations)
if not world.dash_shuffle[player]:
if not options.dash_shuffle:
world.push_precollected(self.create_item("Dash Ability"))
if not world.wall_climb_shuffle[player]:
if not options.wall_climb_shuffle:
world.push_precollected(self.create_item("Wall Climb Ability"))
if world.skip_long_quests[player]:
if options.skip_long_quests:
for loc in junk_locations:
world.exclude_locations[player].value.add(loc)
options.exclude_locations.value.add(loc)
start_rooms: Dict[int, str] = {
0: "D17Z01S01",
@@ -138,12 +140,12 @@ class BlasphemousWorld(World):
6: "D20Z02S09"
}
self.start_room = start_rooms[world.starting_location[player].value]
self.start_room = start_rooms[options.starting_location.value]
def create_items(self):
options = self.options
world = self.multiworld
player = self.player
removed: int = 0
to_remove: List[str] = [
@@ -157,46 +159,46 @@ class BlasphemousWorld(World):
skipped_items = []
junk: int = 0
for item, count in world.start_inventory[player].value.items():
for item, count in options.start_inventory.value.items():
for _ in range(count):
skipped_items.append(item)
junk += 1
skipped_items.extend(unrandomized_dict.values())
if world.thorn_shuffle[player] == 2:
if options.thorn_shuffle == 2:
for i in range(8):
skipped_items.append("Thorn Upgrade")
if world.dash_shuffle[player]:
if options.dash_shuffle:
skipped_items.append(to_remove[removed])
removed += 1
elif not world.dash_shuffle[player]:
elif not options.dash_shuffle:
skipped_items.append("Dash Ability")
if world.wall_climb_shuffle[player]:
if options.wall_climb_shuffle:
skipped_items.append(to_remove[removed])
removed += 1
elif not world.wall_climb_shuffle[player]:
elif not options.wall_climb_shuffle:
skipped_items.append("Wall Climb Ability")
if not world.reliquary_shuffle[player]:
if not options.reliquary_shuffle:
skipped_items.extend(reliquary_set)
elif world.reliquary_shuffle[player]:
elif options.reliquary_shuffle:
for i in range(3):
skipped_items.append(to_remove[removed])
removed += 1
if not world.boots_of_pleading[player]:
if not options.boots_of_pleading:
skipped_items.append("Boots of Pleading")
if not world.purified_hand[player]:
if not options.purified_hand:
skipped_items.append("Purified Hand of the Nun")
if world.start_wheel[player]:
if options.start_wheel:
skipped_items.append("The Young Mason's Wheel")
if not world.skill_randomizer[player]:
if not options.skill_randomizer:
skipped_items.extend(skill_dict.values())
counter = Counter(skipped_items)
@@ -219,23 +221,24 @@ class BlasphemousWorld(World):
def pre_fill(self):
options = self.options
world = self.multiworld
player = self.player
self.place_items_from_dict(unrandomized_dict)
if world.thorn_shuffle[player] == 2:
if options.thorn_shuffle == 2:
self.place_items_from_set(thorn_set, "Thorn Upgrade")
if world.start_wheel[player]:
if options.start_wheel:
world.get_location("Beginning gift", player)\
.place_locked_item(self.create_item("The Young Mason's Wheel"))
if not world.skill_randomizer[player]:
if not options.skill_randomizer:
self.place_items_from_dict(skill_dict)
if world.thorn_shuffle[player] == 1:
world.local_items[player].value.add("Thorn Upgrade")
if options.thorn_shuffle == 1:
options.local_items.value.add("Thorn Upgrade")
def place_items_from_set(self, location_set: Set[str], name: str):
@@ -251,6 +254,7 @@ class BlasphemousWorld(World):
def create_regions(self) -> None:
options = self.options
player = self.player
world = self.multiworld
@@ -282,9 +286,9 @@ class BlasphemousWorld(World):
})
for index, loc in enumerate(location_table):
if not world.boots_of_pleading[player] and loc["name"] == "BotSS: 2nd meeting with Redento":
if not options.boots_of_pleading and loc["name"] == "BotSS: 2nd meeting with Redento":
continue
if not world.purified_hand[player] and loc["name"] == "MoM: Western room ledge":
if not options.purified_hand and loc["name"] == "MoM: Western room ledge":
continue
region: Region = world.get_region(loc["room"], player)
@@ -310,9 +314,9 @@ class BlasphemousWorld(World):
victory.place_locked_item(self.create_event("Victory"))
world.get_region("D07Z01S03", player).locations.append(victory)
if world.ending[self.player].value == 1:
if options.ending.value == 1:
set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8))
elif world.ending[self.player].value == 2:
elif options.ending.value == 2:
set_rule(victory, lambda state: state.has("Thorn Upgrade", player, 8) and
state.has("Holy Wound of Abnegation", player))
@@ -332,11 +336,12 @@ class BlasphemousWorld(World):
locations = []
doors: Dict[str, str] = {}
options = self.options
world = self.multiworld
player = self.player
thorns: bool = True
if world.thorn_shuffle[player].value == 2:
if options.thorn_shuffle.value == 2:
thorns = False
for loc in world.get_filled_locations(player):
@@ -354,28 +359,28 @@ class BlasphemousWorld(World):
locations.append(data)
config = {
"LogicDifficulty": world.difficulty[player].value,
"StartingLocation": world.starting_location[player].value,
"LogicDifficulty": options.difficulty.value,
"StartingLocation": options.starting_location.value,
"VersionCreated": "AP",
"UnlockTeleportation": bool(world.prie_dieu_warp[player].value),
"AllowHints": bool(world.corpse_hints[player].value),
"AllowPenitence": bool(world.penitence[player].value),
"UnlockTeleportation": bool(options.prie_dieu_warp.value),
"AllowHints": bool(options.corpse_hints.value),
"AllowPenitence": bool(options.penitence.value),
"ShuffleReliquaries": bool(world.reliquary_shuffle[player].value),
"ShuffleBootsOfPleading": bool(world.boots_of_pleading[player].value),
"ShufflePurifiedHand": bool(world.purified_hand[player].value),
"ShuffleDash": bool(world.dash_shuffle[player].value),
"ShuffleWallClimb": bool(world.wall_climb_shuffle[player].value),
"ShuffleReliquaries": bool(options.reliquary_shuffle.value),
"ShuffleBootsOfPleading": bool(options.boots_of_pleading.value),
"ShufflePurifiedHand": bool(options.purified_hand.value),
"ShuffleDash": bool(options.dash_shuffle.value),
"ShuffleWallClimb": bool(options.wall_climb_shuffle.value),
"ShuffleSwordSkills": bool(world.skill_randomizer[player].value),
"ShuffleSwordSkills": bool(options.skill_randomizer.value),
"ShuffleThorns": thorns,
"JunkLongQuests": bool(world.skip_long_quests[player].value),
"StartWithWheel": bool(world.start_wheel[player].value),
"JunkLongQuests": bool(options.skip_long_quests.value),
"StartWithWheel": bool(options.start_wheel.value),
"EnemyShuffleType": world.enemy_randomizer[player].value,
"MaintainClass": bool(world.enemy_groups[player].value),
"AreaScaling": bool(world.enemy_scaling[player].value),
"EnemyShuffleType": options.enemy_randomizer.value,
"MaintainClass": bool(options.enemy_groups.value),
"AreaScaling": bool(options.enemy_scaling.value),
"BossShuffleType": 0,
"DoorShuffleType": 0
@@ -385,8 +390,8 @@ class BlasphemousWorld(World):
"locations": locations,
"doors": doors,
"cfg": config,
"ending": world.ending[self.player].value,
"death_link": bool(world.death_link[self.player].value)
"ending": options.ending.value,
"death_link": bool(options.death_link.value)
}
return slot_data

View File

@@ -4,7 +4,8 @@
# https://opensource.org/licenses/MIT
import typing
from Options import Option, Range
from Options import Option, Range, PerGameCommonOptions
from dataclasses import dataclass
class TaskAdvances(Range):
@@ -69,12 +70,12 @@ class KillerTrapWeight(Range):
default = 0
bumpstik_options: typing.Dict[str, type(Option)] = {
"task_advances": TaskAdvances,
"turners": Turners,
"paint_cans": PaintCans,
"trap_count": Traps,
"rainbow_trap_weight": RainbowTrapWeight,
"spinner_trap_weight": SpinnerTrapWeight,
"killer_trap_weight": KillerTrapWeight
}
@dataclass
class BumpStikOptions(PerGameCommonOptions):
task_advances: TaskAdvances
turners: Turners
paint_cans: PaintCans
trap_count: Traps
rainbow_trap_weight: RainbowTrapWeight
spinner_trap_weight: SpinnerTrapWeight
killer_trap_weight: KillerTrapWeight

View File

@@ -43,7 +43,8 @@ class BumpStikWorld(World):
required_client_version = (0, 3, 8)
option_definitions = bumpstik_options
options = BumpStikOptions
options_dataclass = BumpStikOptions
def __init__(self, world: MultiWorld, player: int):
super(BumpStikWorld, self).__init__(world, player)
@@ -86,13 +87,13 @@ class BumpStikWorld(World):
return "Nothing"
def generate_early(self):
self.task_advances = self.multiworld.task_advances[self.player].value
self.turners = self.multiworld.turners[self.player].value
self.paint_cans = self.multiworld.paint_cans[self.player].value
self.traps = self.multiworld.trap_count[self.player].value
self.rainbow_trap_weight = self.multiworld.rainbow_trap_weight[self.player].value
self.spinner_trap_weight = self.multiworld.spinner_trap_weight[self.player].value
self.killer_trap_weight = self.multiworld.killer_trap_weight[self.player].value
self.task_advances = self.options.task_advances.value
self.turners = self.options.turners.value
self.paint_cans = self.options.paint_cans.value
self.traps = self.options.trap_count.value
self.rainbow_trap_weight = self.options.rainbow_trap_weight.value
self.spinner_trap_weight = self.options.spinner_trap_weight.value
self.killer_trap_weight = self.options.killer_trap_weight.value
def create_regions(self):
create_regions(self.multiworld, self.player)

View File

@@ -1,6 +1,7 @@
import typing
from Options import Option
from Options import Option, PerGameCommonOptions
from dataclasses import dataclass
checksfinder_options: typing.Dict[str, type(Option)] = {
}
@dataclass
class ChecksFinderOptions(PerGameCommonOptions):
pass

View File

@@ -1,9 +1,10 @@
from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification
from .Items import ChecksFinderItem, item_table, required_items
from .Locations import ChecksFinderAdvancement, advancement_table, exclusion_table
from .Options import checksfinder_options
from .Options import ChecksFinderOptions
from .Rules import set_rules, set_completion_rules
from ..AutoWorld import World, WebWorld
from dataclasses import fields
client_version = 7
@@ -26,7 +27,8 @@ class ChecksFinderWorld(World):
with the mines! You win when you get all your items and beat the board!
"""
game: str = "ChecksFinder"
option_definitions = checksfinder_options
options = ChecksFinderOptions
options_dataclass = ChecksFinderOptions
topology_present = True
web = ChecksFinderWeb()
@@ -79,8 +81,8 @@ class ChecksFinderWorld(World):
def fill_slot_data(self):
slot_data = self._get_checksfinder_data()
for option_name in checksfinder_options:
option = getattr(self.multiworld, option_name)[self.player]
for option_name in [field.name for field in fields(ChecksFinderOptions)]:
option = getattr(self.options, option_name)
if slot_data.get(option_name, None) is None and type(option.value) in {str, int}:
slot_data[option_name] = int(option.value)
return slot_data

View File

@@ -1,6 +1,7 @@
from typing import Callable, Dict, NamedTuple, Optional
from BaseClasses import Item, ItemClassification, MultiWorld
from BaseClasses import Item, ItemClassification
from .Options import CliqueOptions
class CliqueItem(Item):
@@ -10,7 +11,7 @@ class CliqueItem(Item):
class CliqueItemData(NamedTuple):
code: Optional[int] = None
type: ItemClassification = ItemClassification.filler
can_create: Callable[[MultiWorld, int], bool] = lambda multiworld, player: True
can_create: Callable[[CliqueOptions], bool] = lambda options: True
item_data_table: Dict[str, CliqueItemData] = {
@@ -21,11 +22,11 @@ item_data_table: Dict[str, CliqueItemData] = {
"Button Activation": CliqueItemData(
code=69696968,
type=ItemClassification.progression,
can_create=lambda multiworld, player: bool(getattr(multiworld, "hard_mode")[player]),
can_create=lambda options: bool(getattr(options, "hard_mode")),
),
"A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData(
code=69696967,
can_create=lambda multiworld, player: False # Only created from `get_filler_item_name`.
can_create=lambda options: False # Only created from `get_filler_item_name`.
),
"The Urge to Push": CliqueItemData(
type=ItemClassification.progression,

View File

@@ -1,6 +1,8 @@
from typing import Callable, Dict, NamedTuple, Optional
from BaseClasses import Location, MultiWorld
from BaseClasses import Location
from .Options import CliqueOptions
class CliqueLocation(Location):
@@ -10,7 +12,7 @@ class CliqueLocation(Location):
class CliqueLocationData(NamedTuple):
region: str
address: Optional[int] = None
can_create: Callable[[MultiWorld, int], bool] = lambda multiworld, player: True
can_create: Callable[[CliqueOptions], bool] = lambda options: True
locked_item: Optional[str] = None
@@ -22,7 +24,7 @@ location_data_table: Dict[str, CliqueLocationData] = {
"The Item on the Desk": CliqueLocationData(
region="The Button Realm",
address=69696968,
can_create=lambda multiworld, player: bool(getattr(multiworld, "hard_mode")[player]),
can_create=lambda options: bool(getattr(options, "hard_mode")),
),
"In the Player's Mind": CliqueLocationData(
region="The Button Realm",

View File

@@ -1,6 +1,7 @@
from typing import Dict
from Options import Choice, Option, Toggle
from Options import Choice, Option, Toggle, PerGameCommonOptions
from dataclasses import dataclass
class HardMode(Toggle):
@@ -25,10 +26,12 @@ class ButtonColor(Choice):
option_black = 11
clique_options: Dict[str, type(Option)] = {
"color": ButtonColor,
"hard_mode": HardMode,
@dataclass
class CliqueOptions(PerGameCommonOptions):
color: ButtonColor
hard_mode: HardMode
# DeathLink is always on. Always.
# "death_link": DeathLink,
}
# death_link: DeathLink

View File

@@ -1,10 +1,11 @@
from typing import Callable
from BaseClasses import CollectionState, MultiWorld
from BaseClasses import CollectionState
from .Options import CliqueOptions
def get_button_rule(multiworld: MultiWorld, player: int) -> Callable[[CollectionState], bool]:
if getattr(multiworld, "hard_mode")[player]:
def get_button_rule(options: CliqueOptions, player: int) -> Callable[[CollectionState], bool]:
if getattr(options, "hard_mode"):
return lambda state: state.has("Button Activation", player)
return lambda state: True

View File

@@ -4,7 +4,7 @@ from BaseClasses import Region, Tutorial
from worlds.AutoWorld import WebWorld, World
from .Items import CliqueItem, item_data_table, item_table
from .Locations import CliqueLocation, location_data_table, location_table, locked_locations
from .Options import clique_options
from .Options import CliqueOptions
from .Regions import region_data_table
from .Rules import get_button_rule
@@ -29,7 +29,8 @@ class CliqueWorld(World):
game = "Clique"
data_version = 3
web = CliqueWebWorld()
option_definitions = clique_options
options = CliqueOptions
options_dataclass = CliqueOptions
location_name_to_id = location_table
item_name_to_id = item_table
@@ -39,7 +40,7 @@ class CliqueWorld(World):
def create_items(self) -> None:
item_pool: List[CliqueItem] = []
for name, item in item_data_table.items():
if item.code and item.can_create(self.multiworld, self.player):
if item.code and item.can_create(self.options):
item_pool.append(self.create_item(name))
self.multiworld.itempool += item_pool
@@ -55,27 +56,27 @@ class CliqueWorld(World):
region = self.multiworld.get_region(region_name, self.player)
region.add_locations({
location_name: location_data.address for location_name, location_data in location_data_table.items()
if location_data.region == region_name and location_data.can_create(self.multiworld, self.player)
if location_data.region == region_name and location_data.can_create(self.options)
}, CliqueLocation)
region.add_exits(region_data_table[region_name].connecting_regions)
# Place locked locations.
for location_name, location_data in locked_locations.items():
# Ignore locations we never created.
if not location_data.can_create(self.multiworld, self.player):
if not location_data.can_create(self.options):
continue
locked_item = self.create_item(location_data_table[location_name].locked_item)
self.multiworld.get_location(location_name, self.player).place_locked_item(locked_item)
# Set priority location for the Big Red Button!
self.multiworld.priority_locations[self.player].value.add("The Big Red Button")
self.options.priority_locations.value.add("The Big Red Button")
def get_filler_item_name(self) -> str:
return "A Cool Filler Item (No Satisfaction Guaranteed)"
def set_rules(self) -> None:
button_rule = get_button_rule(self.multiworld, self.player)
button_rule = get_button_rule(self.options, self.player)
self.multiworld.get_location("The Big Red Button", self.player).access_rule = button_rule
self.multiworld.get_location("In the Player's Mind", self.player).access_rule = button_rule
@@ -88,5 +89,5 @@ class CliqueWorld(World):
def fill_slot_data(self):
return {
"color": getattr(self.multiworld, "color")[self.player].current_key
"color": getattr(self.options, "color").current_key
}

View File

@@ -1,6 +1,7 @@
import typing
from Options import Toggle, DefaultOnToggle, Option, Range, Choice, ItemDict, DeathLink
from Options import Toggle, DefaultOnToggle, Option, Range, Choice, ItemDict, DeathLink, PerGameCommonOptions
from dataclasses import dataclass
class RandomizeWeaponLocations(DefaultOnToggle):
@@ -200,36 +201,36 @@ class EnableDLCOption(Toggle):
display_name = "Enable DLC"
dark_souls_options: typing.Dict[str, Option] = {
"enable_weapon_locations": RandomizeWeaponLocations,
"enable_shield_locations": RandomizeShieldLocations,
"enable_armor_locations": RandomizeArmorLocations,
"enable_ring_locations": RandomizeRingLocations,
"enable_spell_locations": RandomizeSpellLocations,
"enable_key_locations": RandomizeKeyLocations,
"enable_boss_locations": RandomizeBossSoulLocations,
"enable_npc_locations": RandomizeNPCLocations,
"enable_misc_locations": RandomizeMiscLocations,
"enable_health_upgrade_locations": RandomizeHealthLocations,
"enable_progressive_locations": RandomizeProgressiveLocationsOption,
"pool_type": PoolTypeOption,
"guaranteed_items": GuaranteedItemsOption,
"auto_equip": AutoEquipOption,
"lock_equip": LockEquipOption,
"no_weapon_requirements": NoWeaponRequirementsOption,
"randomize_infusion": RandomizeInfusionOption,
"randomize_infusion_percentage": RandomizeInfusionPercentageOption,
"randomize_weapon_level": RandomizeWeaponLevelOption,
"randomize_weapon_level_percentage": RandomizeWeaponLevelPercentageOption,
"min_levels_in_5": MinLevelsIn5WeaponPoolOption,
"max_levels_in_5": MaxLevelsIn5WeaponPoolOption,
"min_levels_in_10": MinLevelsIn10WeaponPoolOption,
"max_levels_in_10": MaxLevelsIn10WeaponPoolOption,
"early_banner": EarlySmallLothricBanner,
"late_basin_of_vows": LateBasinOfVowsOption,
"late_dlc": LateDLCOption,
"no_spell_requirements": NoSpellRequirementsOption,
"no_equip_load": NoEquipLoadOption,
"death_link": DeathLink,
"enable_dlc": EnableDLCOption,
}
@dataclass
class DarkSouls3Options(PerGameCommonOptions):
enable_weapon_locations: RandomizeWeaponLocations
enable_shield_locations: RandomizeShieldLocations
enable_armor_locations: RandomizeArmorLocations
enable_ring_locations: RandomizeRingLocations
enable_spell_locations: RandomizeSpellLocations
enable_key_locations: RandomizeKeyLocations
enable_boss_locations: RandomizeBossSoulLocations
enable_npc_locations: RandomizeNPCLocations
enable_misc_locations: RandomizeMiscLocations
enable_health_upgrade_locations: RandomizeHealthLocations
enable_progressive_locations: RandomizeProgressiveLocationsOption
pool_type: PoolTypeOption
guaranteed_items: GuaranteedItemsOption
auto_equip: AutoEquipOption
lock_equip: LockEquipOption
no_weapon_requirements: NoWeaponRequirementsOption
randomize_infusion: RandomizeInfusionOption
randomize_infusion_percentage: RandomizeInfusionPercentageOption
randomize_weapon_level: RandomizeWeaponLevelOption
randomize_weapon_level_percentage: RandomizeWeaponLevelPercentageOption
min_levels_in_5: MinLevelsIn5WeaponPoolOption
max_levels_in_5: MaxLevelsIn5WeaponPoolOption
min_levels_in_10: MinLevelsIn10WeaponPoolOption
max_levels_in_10: MaxLevelsIn10WeaponPoolOption
early_banner: EarlySmallLothricBanner
late_basin_of_vows: LateBasinOfVowsOption
late_dlc: LateDLCOption
no_spell_requirements: NoSpellRequirementsOption
no_equip_load: NoEquipLoadOption
death_link: DeathLink
enable_dlc: EnableDLCOption

View File

@@ -9,7 +9,7 @@ from worlds.generic.Rules import set_rule, add_rule, add_item_rule
from .Items import DarkSouls3Item, DS3ItemCategory, item_dictionary, key_item_names, item_descriptions
from .Locations import DarkSouls3Location, DS3LocationCategory, location_tables, location_dictionary
from .Options import RandomizeWeaponLevelOption, PoolTypeOption, EarlySmallLothricBanner, dark_souls_options
from .Options import RandomizeWeaponLevelOption, PoolTypeOption, EarlySmallLothricBanner, DarkSouls3Options
class DarkSouls3Web(WebWorld):
@@ -43,7 +43,8 @@ class DarkSouls3World(World):
"""
game: str = "Dark Souls III"
option_definitions = dark_souls_options
options = DarkSouls3Options
options_dataclass = DarkSouls3Options
topology_present: bool = True
web = DarkSouls3Web()
data_version = 8
@@ -72,47 +73,47 @@ class DarkSouls3World(World):
def generate_early(self):
if self.multiworld.enable_weapon_locations[self.player] == Toggle.option_true:
if self.options.enable_weapon_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.WEAPON)
if self.multiworld.enable_shield_locations[self.player] == Toggle.option_true:
if self.options.enable_shield_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.SHIELD)
if self.multiworld.enable_armor_locations[self.player] == Toggle.option_true:
if self.options.enable_armor_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.ARMOR)
if self.multiworld.enable_ring_locations[self.player] == Toggle.option_true:
if self.options.enable_ring_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.RING)
if self.multiworld.enable_spell_locations[self.player] == Toggle.option_true:
if self.options.enable_spell_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.SPELL)
if self.multiworld.enable_npc_locations[self.player] == Toggle.option_true:
if self.options.enable_npc_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.NPC)
if self.multiworld.enable_key_locations[self.player] == Toggle.option_true:
if self.options.enable_key_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.KEY)
if self.multiworld.early_banner[self.player] == EarlySmallLothricBanner.option_early_global:
self.multiworld.early_items[self.player]['Small Lothric Banner'] = 1
elif self.multiworld.early_banner[self.player] == EarlySmallLothricBanner.option_early_local:
self.multiworld.local_early_items[self.player]['Small Lothric Banner'] = 1
if self.multiworld.enable_boss_locations[self.player] == Toggle.option_true:
if self.options.early_banner == EarlySmallLothricBanner.option_early_global:
self.options.early_items['Small Lothric Banner'] = 1
elif self.options.early_banner == EarlySmallLothricBanner.option_early_local:
self.options.local_early_items['Small Lothric Banner'] = 1
if self.options.enable_boss_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.BOSS)
if self.multiworld.enable_misc_locations[self.player] == Toggle.option_true:
if self.options.enable_misc_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.MISC)
if self.multiworld.enable_health_upgrade_locations[self.player] == Toggle.option_true:
if self.options.enable_health_upgrade_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.HEALTH)
if self.multiworld.enable_progressive_locations[self.player] == Toggle.option_true:
if self.options.enable_progressive_locations == Toggle.option_true:
self.enabled_location_categories.add(DS3LocationCategory.PROGRESSIVE_ITEM)
def create_regions(self):
progressive_location_table = []
if self.multiworld.enable_progressive_locations[self.player]:
if self.options.enable_progressive_locations:
progressive_location_table = [] + \
location_tables["Progressive Items 1"] + \
location_tables["Progressive Items 2"] + \
location_tables["Progressive Items 3"] + \
location_tables["Progressive Items 4"]
if self.multiworld.enable_dlc[self.player].value:
if self.options.enable_dlc.value:
progressive_location_table += location_tables["Progressive Items DLC"]
if self.multiworld.enable_health_upgrade_locations[self.player]:
if self.options.enable_health_upgrade_locations:
progressive_location_table += location_tables["Progressive Items Health"]
# Create Vanilla Regions
@@ -146,7 +147,7 @@ class DarkSouls3World(World):
regions["Consumed King's Garden"].locations.append(potd_location)
# Create DLC Regions
if self.multiworld.enable_dlc[self.player]:
if self.options.enable_dlc:
regions.update({region_name: self.create_region(region_name, location_tables[region_name]) for region_name in [
"Painted World of Ariandel 1",
"Painted World of Ariandel 2",
@@ -192,7 +193,7 @@ class DarkSouls3World(World):
create_connection("Consumed King's Garden", "Untended Graves")
# Connect DLC Regions
if self.multiworld.enable_dlc[self.player]:
if self.options.enable_dlc:
create_connection("Cathedral of the Deep", "Painted World of Ariandel 1")
create_connection("Painted World of Ariandel 1", "Painted World of Ariandel 2")
create_connection("Painted World of Ariandel 2", "Dreg Heap")
@@ -240,7 +241,7 @@ class DarkSouls3World(World):
def create_items(self):
dlc_enabled = self.multiworld.enable_dlc[self.player] == Toggle.option_true
dlc_enabled = self.options.enable_dlc == Toggle.option_true
itempool_by_category = {category: [] for category in self.enabled_location_categories}
@@ -254,7 +255,7 @@ class DarkSouls3World(World):
itempool_by_category[location.category].append(location.default_item_name)
# Replace each item category with a random sample of items of those types
if self.multiworld.pool_type[self.player] == PoolTypeOption.option_various:
if self.options.pool_type == PoolTypeOption.option_various:
def create_random_replacement_list(item_categories: Set[DS3ItemCategory], num_items: int):
candidates = [
item.name for item
@@ -300,7 +301,7 @@ class DarkSouls3World(World):
# A list of items we can replace
removable_items = [item for item in itempool if item.classification != ItemClassification.progression]
guaranteed_items = self.multiworld.guaranteed_items[self.player].value
guaranteed_items = self.options.guaranteed_items.value
for item_name in guaranteed_items:
# Break early just in case nothing is removable (if user is trying to guarantee more
# items than the pool can hold, for example)
@@ -384,22 +385,22 @@ class DarkSouls3World(World):
state.has("Cinders of a Lord - Aldrich", self.player) and
state.has("Cinders of a Lord - Lothric Prince", self.player))
if self.multiworld.late_basin_of_vows[self.player] == Toggle.option_true:
if self.options.late_basin_of_vows == Toggle.option_true:
add_rule(self.multiworld.get_entrance("Go To Lothric Castle", self.player),
lambda state: state.has("Small Lothric Banner", self.player))
# DLC Access Rules Below
if self.multiworld.enable_dlc[self.player]:
if self.options.enable_dlc:
set_rule(self.multiworld.get_entrance("Go To Ringed City", self.player),
lambda state: state.has("Small Envoy Banner", self.player))
# If key items are randomized, must have contraption key to enter second half of Ashes DLC
# If key items are not randomized, Contraption Key is guaranteed to be accessible before it is needed
if self.multiworld.enable_key_locations[self.player] == Toggle.option_true:
if self.options.enable_key_locations == Toggle.option_true:
add_rule(self.multiworld.get_entrance("Go To Painted World of Ariandel 2", self.player),
lambda state: state.has("Contraption Key", self.player))
if self.multiworld.late_dlc[self.player] == Toggle.option_true:
if self.options.late_dlc == Toggle.option_true:
add_rule(self.multiworld.get_entrance("Go To Painted World of Ariandel 1", self.player),
lambda state: state.has("Small Doll", self.player))
@@ -407,7 +408,7 @@ class DarkSouls3World(World):
set_rule(self.multiworld.get_location("PC: Cinders of a Lord - Yhorm the Giant", self.player),
lambda state: state.has("Storm Ruler", self.player))
if self.multiworld.enable_ring_locations[self.player] == Toggle.option_true:
if self.options.enable_ring_locations == Toggle.option_true:
set_rule(self.multiworld.get_location("ID: Bellowing Dragoncrest Ring", self.player),
lambda state: state.has("Jailbreaker's Key", self.player))
set_rule(self.multiworld.get_location("ID: Covetous Gold Serpent Ring", self.player),
@@ -415,7 +416,7 @@ class DarkSouls3World(World):
set_rule(self.multiworld.get_location("UG: Hornet Ring", self.player),
lambda state: state.has("Small Lothric Banner", self.player))
if self.multiworld.enable_npc_locations[self.player] == Toggle.option_true:
if self.options.enable_npc_locations == Toggle.option_true:
set_rule(self.multiworld.get_location("HWL: Greirat's Ashes", self.player),
lambda state: state.has("Cell Key", self.player))
set_rule(self.multiworld.get_location("HWL: Blue Tearstone Ring", self.player),
@@ -431,11 +432,11 @@ class DarkSouls3World(World):
set_rule(self.multiworld.get_location("ID: Karla's Trousers", self.player),
lambda state: state.has("Jailer's Key Ring", self.player))
if self.multiworld.enable_misc_locations[self.player] == Toggle.option_true:
if self.options.enable_misc_locations == Toggle.option_true:
set_rule(self.multiworld.get_location("ID: Prisoner Chief's Ashes", self.player),
lambda state: state.has("Jailer's Key Ring", self.player))
if self.multiworld.enable_boss_locations[self.player] == Toggle.option_true:
if self.options.enable_boss_locations == Toggle.option_true:
set_rule(self.multiworld.get_location("PC: Soul of Yhorm the Giant", self.player),
lambda state: state.has("Storm Ruler", self.player))
set_rule(self.multiworld.get_location("HWL: Soul of the Dancer", self.player),
@@ -443,7 +444,7 @@ class DarkSouls3World(World):
# Lump Soul of the Dancer in with LC for locations that should not be reachable
# before having access to US. (Prevents requiring getting Basin to fight Dancer to get SLB to go to US)
if self.multiworld.late_basin_of_vows[self.player] == Toggle.option_true:
if self.options.late_basin_of_vows == Toggle.option_true:
add_rule(self.multiworld.get_location("HWL: Soul of the Dancer", self.player),
lambda state: state.has("Small Lothric Banner", self.player))
@@ -453,10 +454,10 @@ class DarkSouls3World(World):
set_rule(self.multiworld.get_location("LC: Grand Archives Key", self.player), gotthard_corpse_rule)
if self.multiworld.enable_weapon_locations[self.player] == Toggle.option_true:
if self.options.enable_weapon_locations == Toggle.option_true:
set_rule(self.multiworld.get_location("LC: Gotthard Twinswords", self.player), gotthard_corpse_rule)
self.multiworld.completion_condition[self.player] = lambda state: \
self.options.completion_condition = lambda state: \
state.has("Cinders of a Lord - Abyss Watcher", self.player) and \
state.has("Cinders of a Lord - Yhorm the Giant", self.player) and \
state.has("Cinders of a Lord - Aldrich", self.player) and \
@@ -470,13 +471,13 @@ class DarkSouls3World(World):
name_to_ds3_code = {item.name: item.ds3_code for item in item_dictionary.values()}
# Randomize some weapon upgrades
if self.multiworld.randomize_weapon_level[self.player] != RandomizeWeaponLevelOption.option_none:
if self.options.randomize_weapon_level != RandomizeWeaponLevelOption.option_none:
# if the user made an error and set a min higher than the max we default to the max
max_5 = self.multiworld.max_levels_in_5[self.player]
min_5 = min(self.multiworld.min_levels_in_5[self.player], max_5)
max_10 = self.multiworld.max_levels_in_10[self.player]
min_10 = min(self.multiworld.min_levels_in_10[self.player], max_10)
weapon_level_percentage = self.multiworld.randomize_weapon_level_percentage[self.player]
max_5 = self.options.max_levels_in_5
min_5 = min(self.options.min_levels_in_5, max_5)
max_10 = self.options.max_levels_in_10
min_10 = min(self.options.min_levels_in_10, max_10)
weapon_level_percentage = self.options.randomize_weapon_level_percentage
for item in item_dictionary.values():
if self.multiworld.per_slot_randoms[self.player].randint(0, 99) < weapon_level_percentage:
@@ -486,8 +487,8 @@ class DarkSouls3World(World):
name_to_ds3_code[item.name] += self.multiworld.per_slot_randoms[self.player].randint(min_10, max_10)
# Randomize some weapon infusions
if self.multiworld.randomize_infusion[self.player] == Toggle.option_true:
infusion_percentage = self.multiworld.randomize_infusion_percentage[self.player]
if self.options.randomize_infusion == Toggle.option_true:
infusion_percentage = self.options.randomize_infusion_percentage
for item in item_dictionary.values():
if item.category in {DS3ItemCategory.WEAPON_UPGRADE_10_INFUSIBLE, DS3ItemCategory.SHIELD_INFUSIBLE}:
if self.multiworld.per_slot_randoms[self.player].randint(0, 99) < infusion_percentage:
@@ -518,22 +519,22 @@ class DarkSouls3World(World):
slot_data = {
"options": {
"enable_weapon_locations": self.multiworld.enable_weapon_locations[self.player].value,
"enable_shield_locations": self.multiworld.enable_shield_locations[self.player].value,
"enable_armor_locations": self.multiworld.enable_armor_locations[self.player].value,
"enable_ring_locations": self.multiworld.enable_ring_locations[self.player].value,
"enable_spell_locations": self.multiworld.enable_spell_locations[self.player].value,
"enable_key_locations": self.multiworld.enable_key_locations[self.player].value,
"enable_boss_locations": self.multiworld.enable_boss_locations[self.player].value,
"enable_npc_locations": self.multiworld.enable_npc_locations[self.player].value,
"enable_misc_locations": self.multiworld.enable_misc_locations[self.player].value,
"auto_equip": self.multiworld.auto_equip[self.player].value,
"lock_equip": self.multiworld.lock_equip[self.player].value,
"no_weapon_requirements": self.multiworld.no_weapon_requirements[self.player].value,
"death_link": self.multiworld.death_link[self.player].value,
"no_spell_requirements": self.multiworld.no_spell_requirements[self.player].value,
"no_equip_load": self.multiworld.no_equip_load[self.player].value,
"enable_dlc": self.multiworld.enable_dlc[self.player].value
"enable_weapon_locations": self.options.enable_weapon_locations.value,
"enable_shield_locations": self.options.enable_shield_locations.value,
"enable_armor_locations": self.options.enable_armor_locations.value,
"enable_ring_locations": self.options.enable_ring_locations.value,
"enable_spell_locations": self.options.enable_spell_locations.value,
"enable_key_locations": self.options.enable_key_locations.value,
"enable_boss_locations": self.options.enable_boss_locations.value,
"enable_npc_locations": self.options.enable_npc_locations.value,
"enable_misc_locations": self.options.enable_misc_locations.value,
"auto_equip": self.options.auto_equip.value,
"lock_equip": self.options.lock_equip.value,
"no_weapon_requirements": self.options.no_weapon_requirements.value,
"death_link": self.options.death_link.value,
"no_spell_requirements": self.options.no_spell_requirements.value,
"no_equip_load": self.options.no_equip_load.value,
"enable_dlc": self.options.enable_dlc.value
},
"seed": self.multiworld.seed_name, # to verify the server's multiworld
"slot": self.multiworld.player_name[self.player], # to connect to server

View File

@@ -21,7 +21,20 @@ This client has only been tested with the Official Steam version of the game at
## Downpatching Dark Souls III
Follow instructions from the [speedsouls wiki](https://wiki.speedsouls.com/darksouls3:Downpatching) to download version 1.15. Your download command, including the correct depot and manifest ids, will be "download_depot 374320 374321 4471176929659548333"
To downpatch DS3 for use with Archipelago, use the following instructions from the speedsouls wiki database.
1. Launch Steam (in online mode).
2. Press the Windows Key + R. This will open the Run window.
3. Open the Steam console by typing the following string: steam://open/console , Steam should now open in Console Mode.
4. Insert the string of the depot you wish to download. For the AP supported v1.15, you will want to use: download_depot 374320 374321 4471176929659548333.
5. Steam will now download the depot. Note: There is no progress bar of the download in Steam, but it is still downloading in the background.
6. Turn off auto-updates in Steam by right-clicking Dark Souls III in your library > Properties > Updates > set "Automatic Updates" to "Only update this game when I launch it" (or change the value for AutoUpdateBehavior to 1 in "\Steam\steamapps\appmanifest_374320.acf").
7. Back up your existing game folder in "\Steam\steamapps\common\DARK SOULS III".
8. Return back to Steam console. Once the download is complete, it should say so along with the temporary local directory in which the depot has been stored. This is usually something like "\Steam\steamapps\content\app_XXXXXX\depot_XXXXXX". Back up this game folder as well.
9. Delete your existing game folder in "\Steam\steamapps\common\DARK SOULS III", then replace it with your game folder in "\Steam\steamapps\content\app_XXXXXX\depot_XXXXXX".
10. Back up and delete your save file "DS30000.sl2" in AppData. AppData is hidden by default. To locate it, press Windows Key + R, type %appdata% and hit enter or: open File Explorer > View > Hidden Items and follow "C:\Users\your username\AppData\Roaming\DarkSoulsIII\numbers".
11. If you did all these steps correctly, you should be able to confirm your game version in the upper left corner after launching Dark Souls III.
## Installing the Archipelago mod

View File

@@ -5,7 +5,9 @@ import os
import shutil
import threading
import zipfile
from typing import Optional, TYPE_CHECKING, Any, List, Callable, Tuple
from typing import Optional, TYPE_CHECKING, Any, List, Callable, Tuple, Union
from dataclasses import fields
import datetime
import jinja2
@@ -63,7 +65,7 @@ recipe_time_ranges = {
class FactorioModFile(worlds.Files.APContainer):
game = "Factorio"
compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives
writing_tasks: List[Callable[[], Tuple[str, str]]]
writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]]
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
@@ -88,6 +90,7 @@ class FactorioModFile(worlds.Files.APContainer):
def generate_mod(world: "Factorio", output_directory: str):
player = world.player
multiworld = world.multiworld
options = world.options
global data_final_template, locale_template, control_template, data_template, settings_template
with template_load_lock:
if not data_final_template:
@@ -129,44 +132,42 @@ def generate_mod(world: "Factorio", output_directory: str):
"base_tech_table": base_tech_table,
"tech_to_progressive_lookup": tech_to_progressive_lookup,
"mod_name": mod_name,
"allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(),
"allowed_science_packs": options.max_science_pack.get_allowed_packs(),
"custom_technologies": multiworld.worlds[player].custom_technologies,
"tech_tree_layout_prerequisites": world.tech_tree_layout_prerequisites,
"slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name,
"slot_player": player,
"starting_items": multiworld.starting_items[player], "recipes": recipes,
"starting_items": options.starting_items, "recipes": recipes,
"random": random, "flop_random": flop_random,
"recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None),
"recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None),
"recipe_time_scale": recipe_time_scales.get(options.recipe_time.value, None),
"recipe_time_range": recipe_time_ranges.get(options.recipe_time.value, None),
"free_sample_blacklist": {item: 1 for item in free_sample_exclusions},
"progressive_technology_table": {tech.name: tech.progressive for tech in
progressive_technology_table.values()},
"custom_recipes": world.custom_recipes,
"max_science_pack": multiworld.max_science_pack[player].value,
"max_science_pack": options.max_science_pack.value,
"liquids": fluids,
"goal": multiworld.goal[player].value,
"energy_link": multiworld.energy_link[player].value,
"goal": options.goal.value,
"energy_link": options.energy_link.value,
"useless_technologies": useless_technologies,
"chunk_shuffle": multiworld.chunk_shuffle[player].value if hasattr(multiworld, "chunk_shuffle") else 0,
"chunk_shuffle": options.chunk_shuffle.value if datetime.datetime.today().month == 4 else 0,
}
for factorio_option in Options.factorio_options:
for factorio_option in [field.name for field in fields(Options.FactorioOptions)]:
if factorio_option in ["free_sample_blacklist", "free_sample_whitelist"]:
continue
template_data[factorio_option] = getattr(multiworld, factorio_option)[player].value
template_data[factorio_option] = getattr(options, factorio_option).value
if getattr(multiworld, "silo")[player].value == Options.Silo.option_randomize_recipe:
if getattr(options, "silo").value == Options.Silo.option_randomize_recipe:
template_data["free_sample_blacklist"]["rocket-silo"] = 1
if getattr(multiworld, "satellite")[player].value == Options.Satellite.option_randomize_recipe:
if getattr(options, "satellite").value == Options.Satellite.option_randomize_recipe:
template_data["free_sample_blacklist"]["satellite"] = 1
template_data["free_sample_blacklist"].update({item: 1 for item in multiworld.free_sample_blacklist[player].value})
template_data["free_sample_blacklist"].update({item: 0 for item in multiworld.free_sample_whitelist[player].value})
template_data["free_sample_blacklist"].update({item: 1 for item in options.free_sample_blacklist.value})
template_data["free_sample_blacklist"].update({item: 0 for item in options.free_sample_whitelist.value})
mod_dir = os.path.join(output_directory, versioned_mod_name)
zf_path = os.path.join(mod_dir + ".zip")
zf_path = os.path.join(output_directory, versioned_mod_name + ".zip")
mod = FactorioModFile(zf_path, player=player, player_name=multiworld.player_name[player])
if world.zip_path:
@@ -177,7 +178,13 @@ def generate_mod(world: "Factorio", output_directory: str):
mod.writing_tasks.append(lambda arcpath=versioned_mod_name+"/"+path_part, content=zf.read(file):
(arcpath, content))
else:
shutil.copytree(os.path.join(os.path.dirname(__file__), "data", "mod"), mod_dir, dirs_exist_ok=True)
basepath = os.path.join(os.path.dirname(__file__), "data", "mod")
for dirpath, dirnames, filenames in os.walk(basepath):
base_arc_path = (versioned_mod_name+"/"+os.path.relpath(dirpath, basepath)).rstrip("/.\\")
for filename in filenames:
mod.writing_tasks.append(lambda arcpath=base_arc_path+"/"+filename,
file_path=os.path.join(dirpath, filename):
(arcpath, open(file_path, "rb").read()))
mod.writing_tasks.append(lambda: (versioned_mod_name + "/data.lua",
data_template.render(**template_data)))
@@ -197,5 +204,3 @@ def generate_mod(world: "Factorio", output_directory: str):
# write the mod file
mod.write()
# clean up
shutil.rmtree(mod_dir)

View File

@@ -1,10 +1,10 @@
from __future__ import annotations
import typing
import datetime
from Options import Choice, OptionDict, OptionSet, ItemDict, Option, DefaultOnToggle, Range, DeathLink, Toggle, \
StartInventoryPool
from Options import Choice, OptionDict, OptionSet, Option, DefaultOnToggle, Range, DeathLink, Toggle, \
StartInventoryPool, PerGameCommonOptions
from schema import Schema, Optional, And, Or
from dataclasses import dataclass
# schema helpers
FloatRange = lambda low, high: And(Or(int, float), lambda f: low <= f <= high)
@@ -207,10 +207,9 @@ class RecipeIngredientsOffset(Range):
range_end = 5
class FactorioStartItems(ItemDict):
class FactorioStartItems(OptionDict):
"""Mapping of Factorio internal item-name to amount granted on start."""
display_name = "Starting Items"
verify_item_name = False
default = {"burner-mining-drill": 19, "stone-furnace": 19}
@@ -423,50 +422,44 @@ class EnergyLink(Toggle):
display_name = "EnergyLink"
factorio_options: typing.Dict[str, type(Option)] = {
"max_science_pack": MaxSciencePack,
"goal": Goal,
"tech_tree_layout": TechTreeLayout,
"min_tech_cost": MinTechCost,
"max_tech_cost": MaxTechCost,
"tech_cost_distribution": TechCostDistribution,
"tech_cost_mix": TechCostMix,
"ramping_tech_costs": RampingTechCosts,
"silo": Silo,
"satellite": Satellite,
"free_samples": FreeSamples,
"tech_tree_information": TechTreeInformation,
"starting_items": FactorioStartItems,
"free_sample_blacklist": FactorioFreeSampleBlacklist,
"free_sample_whitelist": FactorioFreeSampleWhitelist,
"recipe_time": RecipeTime,
"recipe_ingredients": RecipeIngredients,
"recipe_ingredients_offset": RecipeIngredientsOffset,
"imported_blueprints": ImportedBlueprint,
"world_gen": FactorioWorldGen,
"progressive": Progressive,
"teleport_traps": TeleportTrapCount,
"grenade_traps": GrenadeTrapCount,
"cluster_grenade_traps": ClusterGrenadeTrapCount,
"artillery_traps": ArtilleryTrapCount,
"atomic_rocket_traps": AtomicRocketTrapCount,
"attack_traps": AttackTrapCount,
"evolution_traps": EvolutionTrapCount,
"evolution_trap_increase": EvolutionTrapIncrease,
"death_link": DeathLink,
"energy_link": EnergyLink,
"start_inventory_from_pool": StartInventoryPool,
}
# spoilers below. If you spoil it for yourself, please at least don't spoil it for anyone else.
if datetime.datetime.today().month == 4:
class ChunkShuffle(Toggle):
"""Entrance Randomizer."""
display_name = "Chunk Shuffle"
class ChunkShuffle(Toggle):
"""Entrance Randomizer.
2023 April Fool's option. Shuffles chunk border transitions.
Only valid during the Month of April. Forced off otherwise."""
if datetime.datetime.today().day > 1:
ChunkShuffle.__doc__ += """
2023 April Fool's option. Shuffles chunk border transitions."""
factorio_options["chunk_shuffle"] = ChunkShuffle
@dataclass
class FactorioOptions(PerGameCommonOptions):
max_science_pack: MaxSciencePack
goal: Goal
tech_tree_layout: TechTreeLayout
min_tech_cost: MinTechCost
max_tech_cost: MaxTechCost
tech_cost_distribution: TechCostDistribution
tech_cost_mix: TechCostMix
ramping_tech_costs: RampingTechCosts
silo: Silo
satellite: Satellite
free_samples: FreeSamples
tech_tree_information: TechTreeInformation
starting_items: FactorioStartItems
free_sample_blacklist: FactorioFreeSampleBlacklist
free_sample_whitelist: FactorioFreeSampleWhitelist
recipe_time: RecipeTime
recipe_ingredients: RecipeIngredients
recipe_ingredients_offset: RecipeIngredientsOffset
imported_blueprints: ImportedBlueprint
world_gen: FactorioWorldGen
progressive: Progressive
teleport_traps: TeleportTrapCount
grenade_traps: GrenadeTrapCount
cluster_grenade_traps: ClusterGrenadeTrapCount
artillery_traps: ArtilleryTrapCount
atomic_rocket_traps: AtomicRocketTrapCount
attack_traps: AttackTrapCount
evolution_traps: EvolutionTrapCount
evolution_trap_increase: EvolutionTrapIncrease
death_link: DeathLink
energy_link: EnergyLink
start_inventory_from_pool: StartInventoryPool
chunk_shuffle: ChunkShuffle

View File

@@ -20,10 +20,10 @@ def _sorter(location: "FactorioScienceLocation"):
def get_shapes(factorio_world: "Factorio") -> Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]]:
options = factorio_world.options
world = factorio_world.multiworld
player = factorio_world.player
prerequisites: Dict["FactorioScienceLocation", Set["FactorioScienceLocation"]] = {}
layout = world.tech_tree_layout[player].value
layout = options.tech_tree_layout.value
locations: List["FactorioScienceLocation"] = sorted(factorio_world.science_locations, key=lambda loc: loc.name)
world.random.shuffle(locations)

View File

@@ -11,7 +11,7 @@ from worlds.LauncherComponents import Component, components, Type, launch_subpro
from worlds.generic import Rules
from .Locations import location_pools, location_table
from .Mod import generate_mod
from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution
from .Options import FactorioOptions, MaxSciencePack, Silo, Satellite, TechTreeInformation, Goal, TechCostDistribution, TechCostMix
from .Shapes import get_shapes
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, \
@@ -88,6 +88,9 @@ class Factorio(World):
location_pool: typing.List[FactorioScienceLocation]
advancement_technologies: typing.Set[str]
options = FactorioOptions
options_dataclass = FactorioOptions
web = FactorioWeb()
item_name_to_id = all_items
@@ -117,11 +120,11 @@ class Factorio(World):
def generate_early(self) -> None:
# if max < min, then swap max and min
if self.multiworld.max_tech_cost[self.player] < self.multiworld.min_tech_cost[self.player]:
self.multiworld.min_tech_cost[self.player].value, self.multiworld.max_tech_cost[self.player].value = \
self.multiworld.max_tech_cost[self.player].value, self.multiworld.min_tech_cost[self.player].value
self.tech_mix = self.multiworld.tech_cost_mix[self.player]
self.skip_silo = self.multiworld.silo[self.player].value == Silo.option_spawn
if self.options.max_tech_cost < self.options.min_tech_cost:
self.options.min_tech_cost.value, self.options.max_tech_cost.value = \
self.options.max_tech_cost.value, self.options.min_tech_cost.value
self.tech_mix = self.options.tech_cost_mix
self.skip_silo = self.options.silo.value == Silo.option_spawn
def create_regions(self):
player = self.player
@@ -132,17 +135,17 @@ class Factorio(World):
nauvis = Region("Nauvis", player, self.multiworld)
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
self.multiworld.evolution_traps[player] + \
self.multiworld.attack_traps[player] + \
self.multiworld.teleport_traps[player] + \
self.multiworld.grenade_traps[player] + \
self.multiworld.cluster_grenade_traps[player] + \
self.multiworld.atomic_rocket_traps[player] + \
self.multiworld.artillery_traps[player]
self.options.evolution_traps + \
self.options.attack_traps + \
self.options.teleport_traps + \
self.options.grenade_traps + \
self.options.cluster_grenade_traps + \
self.options.atomic_rocket_traps + \
self.options.artillery_traps
location_pool = []
for pack in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
for pack in sorted(self.options.max_science_pack.get_allowed_packs()):
location_pool.extend(location_pools[pack])
try:
location_names = self.multiworld.random.sample(location_pool, location_count)
@@ -151,11 +154,11 @@ class Factorio(World):
raise Exception("Too many traps for too few locations. Either decrease the trap count, "
f"or increase the location count (higher max science pack). (Player {self.player})") from e
self.science_locations = [FactorioScienceLocation(player, loc_name, self.location_name_to_id[loc_name], nauvis)
self.science_locations = [FactorioScienceLocation(player, loc_name, self.location_name_to_id[loc_name], nauvis, self.options.tech_cost_mix)
for loc_name in location_names]
distribution: TechCostDistribution = self.multiworld.tech_cost_distribution[self.player]
min_cost = self.multiworld.min_tech_cost[self.player]
max_cost = self.multiworld.max_tech_cost[self.player]
distribution: TechCostDistribution = self.options.tech_cost_distribution
min_cost = self.options.min_tech_cost
max_cost = self.options.max_tech_cost
if distribution == distribution.option_even:
rand_values = (random.randint(min_cost, max_cost) for _ in self.science_locations)
else:
@@ -164,7 +167,7 @@ class Factorio(World):
distribution.option_high: max_cost}[distribution.value]
rand_values = (random.triangular(min_cost, max_cost, mode) for _ in self.science_locations)
rand_values = sorted(rand_values)
if self.multiworld.ramping_tech_costs[self.player]:
if self.options.ramping_tech_costs:
def sorter(loc: FactorioScienceLocation):
return loc.complexity, loc.rel_cost
else:
@@ -179,7 +182,7 @@ class Factorio(World):
event = FactorioItem("Victory", ItemClassification.progression, None, player)
location.place_locked_item(event)
for ingredient in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
for ingredient in sorted(self.options.max_science_pack.get_allowed_packs()):
location = FactorioLocation(player, f"Automate {ingredient}", None, nauvis)
nauvis.locations.append(location)
event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player)
@@ -195,10 +198,10 @@ class Factorio(World):
traps = ("Evolution", "Attack", "Teleport", "Grenade", "Cluster Grenade", "Artillery", "Atomic Rocket")
for trap_name in traps:
self.multiworld.itempool.extend(self.create_item(f"{trap_name} Trap") for _ in
range(getattr(self.multiworld,
f"{trap_name.lower().replace(' ', '_')}_traps")[player]))
range(getattr(self.options,
f"{trap_name.lower().replace(' ', '_')}_traps")))
want_progressives = collections.defaultdict(lambda: self.multiworld.progressive[player].
want_progressives = collections.defaultdict(lambda: self.options.progressive.
want_progressives(self.multiworld.random))
cost_sorted_locations = sorted(self.science_locations, key=lambda location: location.name)
@@ -206,7 +209,7 @@ class Factorio(World):
"logistics": 1,
"rocket-silo": -1}
loc: FactorioScienceLocation
if self.multiworld.tech_tree_information[player] == TechTreeInformation.option_full:
if self.options.tech_tree_information == TechTreeInformation.option_full:
# mark all locations as pre-hinted
for loc in self.science_locations:
loc.revealed = True
@@ -237,16 +240,17 @@ class Factorio(World):
player = self.player
shapes = get_shapes(self)
for ingredient in self.multiworld.max_science_pack[self.player].get_allowed_packs():
for ingredient in self.options.max_science_pack.get_allowed_packs():
location = world.get_location(f"Automate {ingredient}", player)
if self.multiworld.recipe_ingredients[self.player]:
if self.options.recipe_ingredients:
custom_recipe = self.custom_recipes[ingredient]
location.access_rule = lambda state, ingredient=ingredient, custom_recipe=custom_recipe: \
(ingredient not in technology_table or state.has(ingredient, player)) and \
all(state.has(technology.name, player) for sub_ingredient in custom_recipe.ingredients
for technology in required_technologies[sub_ingredient])
for technology in required_technologies[sub_ingredient]) and \
all(state.has(technology.name, player) for technology in required_technologies[custom_recipe.crafting_machine])
else:
location.access_rule = lambda state, ingredient=ingredient: \
all(state.has(technology.name, player) for technology in required_technologies[ingredient])
@@ -260,16 +264,16 @@ class Factorio(World):
prerequisites: all(state.can_reach(loc) for loc in locations))
silo_recipe = None
if self.multiworld.silo[self.player] == Silo.option_spawn:
if self.options.silo == Silo.option_spawn:
silo_recipe = self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \
else next(iter(all_product_sources.get("rocket-silo")))
part_recipe = self.custom_recipes["rocket-part"]
satellite_recipe = None
if self.multiworld.goal[self.player] == Goal.option_satellite:
if self.options.goal == Goal.option_satellite:
satellite_recipe = self.custom_recipes["satellite"] if "satellite" in self.custom_recipes \
else next(iter(all_product_sources.get("satellite")))
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe)
if self.multiworld.silo[self.player] != Silo.option_spawn:
if self.options.silo != Silo.option_spawn:
victory_tech_names.add("rocket-silo")
world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player)
for technology in
@@ -278,12 +282,12 @@ class Factorio(World):
world.completion_condition[player] = lambda state: state.has('Victory', player)
def generate_basic(self):
map_basic_settings = self.multiworld.world_gen[self.player].value["basic"]
map_basic_settings = self.options.world_gen.value["basic"]
if map_basic_settings.get("seed", None) is None: # allow seed 0
# 32 bit uint
map_basic_settings["seed"] = self.multiworld.per_slot_randoms[self.player].randint(0, 2 ** 32 - 1)
start_location_hints: typing.Set[str] = self.multiworld.start_location_hints[self.player].value
start_location_hints: typing.Set[str] = self.options.start_location_hints.value
for loc in self.science_locations:
# show start_location_hints ingame
@@ -307,8 +311,6 @@ class Factorio(World):
return super(Factorio, self).collect_item(state, item, remove)
option_definitions = factorio_options
@classmethod
def stage_write_spoiler(cls, world, spoiler_handle):
factorio_players = world.get_game_players(cls.game)
@@ -436,25 +438,25 @@ class Factorio(World):
def set_custom_technologies(self):
custom_technologies = {}
allowed_packs = self.multiworld.max_science_pack[self.player].get_allowed_packs()
allowed_packs = self.options.max_science_pack.get_allowed_packs()
for technology_name, technology in base_technology_table.items():
custom_technologies[technology_name] = technology.get_custom(self.multiworld, allowed_packs, self.player)
return custom_technologies
def set_custom_recipes(self):
ingredients_offset = self.multiworld.recipe_ingredients_offset[self.player]
ingredients_offset = self.options.recipe_ingredients_offset
original_rocket_part = recipes["rocket-part"]
science_pack_pools = get_science_pack_pools()
valid_pool = sorted(science_pack_pools[self.multiworld.max_science_pack[self.player].get_max_pack()] & valid_ingredients)
valid_pool = sorted(science_pack_pools[self.options.max_science_pack.get_max_pack()] & valid_ingredients)
self.multiworld.random.shuffle(valid_pool)
self.custom_recipes = {"rocket-part": Recipe("rocket-part", original_rocket_part.category,
{valid_pool[x]: 10 for x in range(3 + ingredients_offset)},
original_rocket_part.products,
original_rocket_part.energy)}
if self.multiworld.recipe_ingredients[self.player]:
if self.options.recipe_ingredients:
valid_pool = []
for pack in self.multiworld.max_science_pack[self.player].get_ordered_science_packs():
for pack in self.options.max_science_pack.get_ordered_science_packs():
valid_pool += sorted(science_pack_pools[pack])
self.multiworld.random.shuffle(valid_pool)
if pack in recipes: # skips over space science pack
@@ -462,23 +464,23 @@ class Factorio(World):
ingredients_offset)
self.custom_recipes[pack] = new_recipe
if self.multiworld.silo[self.player].value == Silo.option_randomize_recipe \
or self.multiworld.satellite[self.player].value == Satellite.option_randomize_recipe:
if self.options.silo.value == Silo.option_randomize_recipe \
or self.options.satellite.value == Satellite.option_randomize_recipe:
valid_pool = set()
for pack in sorted(self.multiworld.max_science_pack[self.player].get_allowed_packs()):
for pack in sorted(self.options.max_science_pack.get_allowed_packs()):
valid_pool |= science_pack_pools[pack]
if self.multiworld.silo[self.player].value == Silo.option_randomize_recipe:
if self.options.silo.value == Silo.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(
recipes["rocket-silo"], valid_pool,
factor=(self.multiworld.max_science_pack[self.player].value + 1) / 7,
factor=(self.options.max_science_pack.value + 1) / 7,
ingredients_offset=ingredients_offset)
self.custom_recipes["rocket-silo"] = new_recipe
if self.multiworld.satellite[self.player].value == Satellite.option_randomize_recipe:
if self.options.satellite.value == Satellite.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(
recipes["satellite"], valid_pool,
factor=(self.multiworld.max_science_pack[self.player].value + 1) / 7,
factor=(self.options.max_science_pack.value + 1) / 7,
ingredients_offset=ingredients_offset)
self.custom_recipes["satellite"] = new_recipe
bridge = "ap-energy-bridge"
@@ -486,16 +488,16 @@ class Factorio(World):
Recipe(bridge, "crafting", {"replace_1": 1, "replace_2": 1, "replace_3": 1,
"replace_4": 1, "replace_5": 1, "replace_6": 1},
{bridge: 1}, 10),
sorted(science_pack_pools[self.multiworld.max_science_pack[self.player].get_ordered_science_packs()[0]]),
sorted(science_pack_pools[self.options.max_science_pack.get_ordered_science_packs()[0]]),
ingredients_offset=ingredients_offset)
for ingredient_name in new_recipe.ingredients:
new_recipe.ingredients[ingredient_name] = self.multiworld.random.randint(50, 500)
self.custom_recipes[bridge] = new_recipe
needed_recipes = self.multiworld.max_science_pack[self.player].get_allowed_packs() | {"rocket-part"}
if self.multiworld.silo[self.player] != Silo.option_spawn:
needed_recipes = self.options.max_science_pack.get_allowed_packs() | {"rocket-part"}
if self.options.silo != Silo.option_spawn:
needed_recipes |= {"rocket-silo"}
if self.multiworld.goal[self.player].value == Goal.option_satellite:
if self.options.goal.value == Goal.option_satellite:
needed_recipes |= {"satellite"}
for recipe in needed_recipes:
@@ -537,7 +539,7 @@ class FactorioScienceLocation(FactorioLocation):
ingredients: typing.Dict[str, int]
count: int = 0
def __init__(self, player: int, name: str, address: int, parent: Region):
def __init__(self, player: int, name: str, address: int, parent: Region, tech_cost_mix: TechCostMix):
super(FactorioScienceLocation, self).__init__(player, name, address, parent)
# "AP-{Complexity}-{Cost}"
self.complexity = int(self.name[3]) - 1
@@ -545,7 +547,7 @@ class FactorioScienceLocation(FactorioLocation):
self.ingredients = {Factorio.ordered_science_packs[self.complexity]: 1}
for complexity in range(self.complexity):
if parent.multiworld.tech_cost_mix[self.player] > parent.multiworld.random.randint(0, 99):
if tech_cost_mix > parent.multiworld.random.randint(0, 99):
self.ingredients[Factorio.ordered_science_packs[complexity]] = 1
@property

View File

@@ -187,6 +187,7 @@ item_table = {
"Pazuzu 5F": ItemData(None, ItemClassification.progression),
"Pazuzu 6F": ItemData(None, ItemClassification.progression),
"Dark King": ItemData(None, ItemClassification.progression),
"Tristam Bone Item Given": ItemData(None, ItemClassification.progression),
#"Barred": ItemData(None, ItemClassification.progression),
}

View File

@@ -1,4 +1,4 @@
from Options import Choice, FreeText, Toggle
from Options import Choice, FreeText, Toggle, Range
class Logic(Choice):
@@ -131,6 +131,21 @@ class EnemizerAttacks(Choice):
default = 0
class EnemizerGroups(Choice):
"""Set which enemy groups will be affected by Enemizer."""
display_name = "Enemizer Groups"
option_mobs_only = 0
option_mobs_and_bosses = 1
option_mobs_bosses_and_dark_king = 2
default = 1
class ShuffleResWeakType(Toggle):
"""Resistance and Weakness types are shuffled for all enemies."""
display_name = "Shuffle Resistance/Weakness Types"
default = 0
class ShuffleEnemiesPositions(Toggle):
"""Instead of their original position in a given map, enemies are randomly placed."""
display_name = "Shuffle Enemies' Positions"
@@ -231,6 +246,81 @@ class BattlefieldsBattlesQuantities(Choice):
option_random_one_through_ten = 6
class CompanionLevelingType(Choice):
"""Set how companions gain levels.
Quests: Complete each companion's individual quest for them to promote to their second version.
Quests Extended: Each companion has four exclusive quests, leveling each time a quest is completed.
Save the Crystals (All): Each time a Crystal is saved, all companions gain levels.
Save the Crystals (Individual): Each companion will level to their second version when a specific Crystal is saved.
Benjamin Level: Companions' level tracks Benjamin's."""
option_quests = 0
option_quests_extended = 1
option_save_crystals_individual = 2
option_save_crystals_all = 3
option_benjamin_level = 4
option_benjamin_level_plus_5 = 5
option_benjamin_level_plus_10 = 6
default = 0
display_name = "Companion Leveling Type"
class CompanionSpellbookType(Choice):
"""Update companions' spellbook.
Standard: Original game spellbooks.
Extended: Add some extra spells. Tristam gains Exit and Quake and Reuben gets Blizzard.
Random Balanced: Randomize the spellbooks with an appropriate mix of spells.
Random Chaos: Randomize the spellbooks in total free-for-all."""
option_standard = 0
option_extended = 1
option_random_balanced = 2
option_random_chaos = 3
default = 0
display_name = "Companion Spellbook Type"
class StartingCompanion(Choice):
"""Set a companion to start with.
Random Companion: Randomly select one companion.
Random Plus None: Randomly select a companion, with the possibility of none selected."""
display_name = "Starting Companion"
default = 0
option_none = 0
option_kaeli = 1
option_tristam = 2
option_phoebe = 3
option_reuben = 4
option_random_companion = 5
option_random_plus_none = 6
class AvailableCompanions(Range):
"""Select randomly which companions will join your party. Unavailable companions can still be reached to get their items and complete their quests if needed.
Note: If a Starting Companion is selected, it will always be available, regardless of this setting."""
display_name = "Available Companions"
default = 4
range_start = 0
range_end = 4
class CompanionsLocations(Choice):
"""Set the primary location of companions. Their secondary location is always the same.
Standard: Companions will be at the same locations as in the original game.
Shuffled: Companions' locations are shuffled amongst themselves.
Shuffled Extended: Add all the Temples, as well as Phoebe's House and the Rope Bridge as possible locations."""
display_name = "Companions' Locations"
default = 0
option_standard = 0
option_shuffled = 1
option_shuffled_extended = 2
class KaelisMomFightsMinotaur(Toggle):
"""Transfer Kaeli's requirements (Tree Wither, Elixir) and the two items she's giving to her mom.
Kaeli will be available to join the party right away without the Tree Wither."""
display_name = "Kaeli's Mom Fights Minotaur"
default = 0
option_definitions = {
"logic": Logic,
"brown_boxes": BrownBoxes,
@@ -238,12 +328,21 @@ option_definitions = {
"shattered_sky_coin_quantity": ShatteredSkyCoinQuantity,
"starting_weapon": StartingWeapon,
"progressive_gear": ProgressiveGear,
"leveling_curve": LevelingCurve,
"starting_companion": StartingCompanion,
"available_companions": AvailableCompanions,
"companions_locations": CompanionsLocations,
"kaelis_mom_fight_minotaur": KaelisMomFightsMinotaur,
"companion_leveling_type": CompanionLevelingType,
"companion_spellbook_type": CompanionSpellbookType,
"enemies_density": EnemiesDensity,
"enemies_scaling_lower": EnemiesScalingLower,
"enemies_scaling_upper": EnemiesScalingUpper,
"bosses_scaling_lower": BossesScalingLower,
"bosses_scaling_upper": BossesScalingUpper,
"enemizer_attacks": EnemizerAttacks,
"enemizer_groups": EnemizerGroups,
"shuffle_res_weak_types": ShuffleResWeakType,
"shuffle_enemies_position": ShuffleEnemiesPositions,
"progressive_formations": ProgressiveFormations,
"doom_castle_mode": DoomCastle,
@@ -253,6 +352,5 @@ option_definitions = {
"crest_shuffle": CrestShuffle,
"shuffle_battlefield_rewards": ShuffleBattlefieldRewards,
"map_shuffle_seed": MapShuffleSeed,
"leveling_curve": LevelingCurve,
"battlefields_battles_quantities": BattlefieldsBattlesQuantities,
}

View File

@@ -35,46 +35,58 @@ def generate_output(self, output_directory):
"item_name": location.item.name})
def cc(option):
return option.current_key.title().replace("_", "").replace("OverworldAndDungeons", "OverworldDungeons")
return option.current_key.title().replace("_", "").replace("OverworldAndDungeons",
"OverworldDungeons").replace("MobsAndBosses", "MobsBosses").replace("MobsBossesAndDarkKing",
"MobsBossesDK").replace("BenjaminLevelPlus", "BenPlus").replace("BenjaminLevel", "BenPlus0").replace(
"RandomCompanion", "Random")
def tf(option):
return True if option else False
options = deepcopy(settings_template)
options["name"] = self.multiworld.player_name[self.player]
option_writes = {
"enemies_density": cc(self.multiworld.enemies_density[self.player]),
"chests_shuffle": "Include",
"shuffle_boxes_content": self.multiworld.brown_boxes[self.player] == "shuffle",
"npcs_shuffle": "Include",
"battlefields_shuffle": "Include",
"logic_options": cc(self.multiworld.logic[self.player]),
"shuffle_enemies_position": tf(self.multiworld.shuffle_enemies_position[self.player]),
"enemies_scaling_lower": cc(self.multiworld.enemies_scaling_lower[self.player]),
"enemies_scaling_upper": cc(self.multiworld.enemies_scaling_upper[self.player]),
"bosses_scaling_lower": cc(self.multiworld.bosses_scaling_lower[self.player]),
"bosses_scaling_upper": cc(self.multiworld.bosses_scaling_upper[self.player]),
"enemizer_attacks": cc(self.multiworld.enemizer_attacks[self.player]),
"leveling_curve": cc(self.multiworld.leveling_curve[self.player]),
"battles_quantity": cc(self.multiworld.battlefields_battles_quantities[self.player]) if
self.multiworld.battlefields_battles_quantities[self.player].value < 5 else
"RandomLow" if
self.multiworld.battlefields_battles_quantities[self.player].value == 5 else
"RandomHigh",
"shuffle_battlefield_rewards": tf(self.multiworld.shuffle_battlefield_rewards[self.player]),
"random_starting_weapon": True,
"progressive_gear": tf(self.multiworld.progressive_gear[self.player]),
"tweaked_dungeons": tf(self.multiworld.tweak_frustrating_dungeons[self.player]),
"doom_castle_mode": cc(self.multiworld.doom_castle_mode[self.player]),
"doom_castle_shortcut": tf(self.multiworld.doom_castle_shortcut[self.player]),
"sky_coin_mode": cc(self.multiworld.sky_coin_mode[self.player]),
"sky_coin_fragments_qty": cc(self.multiworld.shattered_sky_coin_quantity[self.player]),
"enable_spoilers": False,
"progressive_formations": cc(self.multiworld.progressive_formations[self.player]),
"map_shuffling": cc(self.multiworld.map_shuffle[self.player]),
"crest_shuffle": tf(self.multiworld.crest_shuffle[self.player]),
}
"enemies_density": cc(self.multiworld.enemies_density[self.player]),
"chests_shuffle": "Include",
"shuffle_boxes_content": self.multiworld.brown_boxes[self.player] == "shuffle",
"npcs_shuffle": "Include",
"battlefields_shuffle": "Include",
"logic_options": cc(self.multiworld.logic[self.player]),
"shuffle_enemies_position": tf(self.multiworld.shuffle_enemies_position[self.player]),
"enemies_scaling_lower": cc(self.multiworld.enemies_scaling_lower[self.player]),
"enemies_scaling_upper": cc(self.multiworld.enemies_scaling_upper[self.player]),
"bosses_scaling_lower": cc(self.multiworld.bosses_scaling_lower[self.player]),
"bosses_scaling_upper": cc(self.multiworld.bosses_scaling_upper[self.player]),
"enemizer_attacks": cc(self.multiworld.enemizer_attacks[self.player]),
"leveling_curve": cc(self.multiworld.leveling_curve[self.player]),
"battles_quantity": cc(self.multiworld.battlefields_battles_quantities[self.player]) if
self.multiworld.battlefields_battles_quantities[self.player].value < 5 else
"RandomLow" if
self.multiworld.battlefields_battles_quantities[self.player].value == 5 else
"RandomHigh",
"shuffle_battlefield_rewards": tf(self.multiworld.shuffle_battlefield_rewards[self.player]),
"random_starting_weapon": True,
"progressive_gear": tf(self.multiworld.progressive_gear[self.player]),
"tweaked_dungeons": tf(self.multiworld.tweak_frustrating_dungeons[self.player]),
"doom_castle_mode": cc(self.multiworld.doom_castle_mode[self.player]),
"doom_castle_shortcut": tf(self.multiworld.doom_castle_shortcut[self.player]),
"sky_coin_mode": cc(self.multiworld.sky_coin_mode[self.player]),
"sky_coin_fragments_qty": cc(self.multiworld.shattered_sky_coin_quantity[self.player]),
"enable_spoilers": False,
"progressive_formations": cc(self.multiworld.progressive_formations[self.player]),
"map_shuffling": cc(self.multiworld.map_shuffle[self.player]),
"crest_shuffle": tf(self.multiworld.crest_shuffle[self.player]),
"enemizer_groups": cc(self.multiworld.enemizer_groups[self.player]),
"shuffle_res_weak_type": tf(self.multiworld.shuffle_res_weak_types[self.player]),
"companion_leveling_type": cc(self.multiworld.companion_leveling_type[self.player]),
"companion_spellbook_type": cc(self.multiworld.companion_spellbook_type[self.player]),
"starting_companion": cc(self.multiworld.starting_companion[self.player]),
"available_companions": ["Zero", "One", "Two",
"Three", "Four"][self.multiworld.available_companions[self.player].value],
"companions_locations": cc(self.multiworld.companions_locations[self.player]),
"kaelis_mom_fight_minotaur": tf(self.multiworld.kaelis_mom_fight_minotaur[self.player]),
}
for option, data in option_writes.items():
options["Final Fantasy Mystic Quest"][option][data] = 1
@@ -83,7 +95,7 @@ def generate_output(self, output_directory):
'utf8')
self.rom_name_available_event.set()
setup = {"version": "1.4", "name": self.multiworld.player_name[self.player], "romname": rom_name, "seed":
setup = {"version": "1.5", "name": self.multiworld.player_name[self.player], "romname": rom_name, "seed":
hex(self.multiworld.per_slot_randoms[self.player].randint(0, 0xFFFFFFFF)).split("0x")[1].upper()}
starting_items = [output_item_name(item) for item in self.multiworld.precollected_items[self.player]]

View File

@@ -67,10 +67,10 @@ def create_regions(self):
self.multiworld.regions.append(create_region(self.multiworld, self.player, room["name"], room["id"],
[FFMQLocation(self.player, object["name"], location_table[object["name"]] if object["name"] in
location_table else None, object["type"], object["access"],
self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for
object in room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in
("BattlefieldGp", "BattlefieldXp") and (object["type"] != "Box" or
self.multiworld.brown_boxes[self.player] == "include")], room["links"]))
self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for object in
room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in ("BattlefieldGp",
"BattlefieldXp") and (object["type"] != "Box" or self.multiworld.brown_boxes[self.player] == "include") and
not (object["name"] == "Kaeli Companion" and not object["on_trigger"])], room["links"]))
dark_king_room = self.multiworld.get_region("Doom Castle Dark King Room", self.player)
dark_king = FFMQLocation(self.player, "Dark King", None, "Trigger", [])

View File

@@ -108,8 +108,10 @@ class FFMQWorld(World):
map_shuffle = multiworld.map_shuffle[world.player].value
crest_shuffle = multiworld.crest_shuffle[world.player].current_key
battlefield_shuffle = multiworld.shuffle_battlefield_rewards[world.player].current_key
companion_shuffle = multiworld.companions_locations[world.player].value
kaeli_mom = multiworld.kaelis_mom_fight_minotaur[world.player].current_key
query = f"s={seed}&m={map_shuffle}&c={crest_shuffle}&b={battlefield_shuffle}"
query = f"s={seed}&m={map_shuffle}&c={crest_shuffle}&b={battlefield_shuffle}&cs={companion_shuffle}&km={kaeli_mom}"
if query in rooms_data:
world.rooms = rooms_data[query]

View File

@@ -827,12 +827,12 @@
id: 164
area: 47
coordinates: [14, 6]
teleporter: [16, 2]
teleporter: [98, 8] # Script for reuben, original value [16, 2]
- name: Fireburg - Hotel
id: 165
area: 47
coordinates: [20, 8]
teleporter: [17, 2]
teleporter: [96, 8] # It's a script now for tristam, original value [17, 2]
- name: Fireburg - GrenadeMan House Script
id: 166
area: 47
@@ -1178,6 +1178,16 @@
area: 60
coordinates: [2, 7]
teleporter: [123, 0]
- name: Lava Dome Pointless Room - Visit Quest Script 1
id: 490
area: 60
coordinates: [4, 4]
teleporter: [99, 8]
- name: Lava Dome Pointless Room - Visit Quest Script 2
id: 491
area: 60
coordinates: [4, 5]
teleporter: [99, 8]
- name: Lava Dome Lower Moon Helm Room - Left Entrance
id: 235
area: 60
@@ -1568,6 +1578,11 @@
area: 79
coordinates: [2, 45]
teleporter: [174, 0]
- name: Mount Gale - Visit Quest
id: 494
area: 79
coordinates: [44, 7]
teleporter: [101, 8]
- name: Windia - Main Entrance 1
id: 312
area: 80
@@ -1613,11 +1628,11 @@
area: 80
coordinates: [21, 39]
teleporter: [30, 5]
- name: Windia - INN's Script # Change to teleporter
- name: Windia - INN's Script # Change to teleporter / Change back to script!
id: 321
area: 80
coordinates: [18, 34]
teleporter: [31, 2] # Original value [79, 8]
teleporter: [97, 8] # Original value [79, 8] > [31, 2]
- name: Windia - Vendor House
id: 322
area: 80
@@ -1697,7 +1712,7 @@
id: 337
area: 82
coordinates: [45, 24]
teleporter: [215, 0]
teleporter: [102, 8] # Changed to script, original value [215, 0]
- name: Windia Inn Lobby - Exit
id: 338
area: 82
@@ -1998,6 +2013,16 @@
area: 95
coordinates: [29, 37]
teleporter: [70, 8]
- name: Light Temple - Visit Quest Script 1
id: 492
area: 95
coordinates: [34, 39]
teleporter: [100, 8]
- name: Light Temple - Visit Quest Script 2
id: 493
area: 95
coordinates: [35, 39]
teleporter: [100, 8]
- name: Ship Dock - Mobius Teleporter Script
id: 397
area: 96

View File

@@ -309,13 +309,13 @@
location: "WindiaBattlefield01"
location_slot: "WindiaBattlefield01"
type: "BattlefieldXp"
access: []
access: ["SandCoin", "RiverCoin"]
- name: "South of Windia Battlefield"
object_id: 0x14
location: "WindiaBattlefield02"
location_slot: "WindiaBattlefield02"
type: "BattlefieldXp"
access: []
access: ["SandCoin", "RiverCoin"]
links:
- target_room: 9 # Focus Tower Windia
location: "FocusTowerWindia"
@@ -739,7 +739,7 @@
object_id: 0x2E
type: "Box"
access: []
- name: "Kaeli 1"
- name: "Kaeli Companion"
object_id: 0
type: "Trigger"
on_trigger: ["Kaeli1"]
@@ -838,7 +838,7 @@
- name: Sand Temple
id: 24
game_objects:
- name: "Tristam Sand Temple"
- name: "Tristam Companion"
object_id: 0
type: "Trigger"
on_trigger: ["Tristam"]
@@ -883,6 +883,11 @@
object_id: 2
type: "NPC"
access: ["Tristam"]
- name: "Tristam Bone Dungeon Item Given"
object_id: 0
type: "Trigger"
on_trigger: ["TristamBoneItemGiven"]
access: ["Tristam"]
links:
- target_room: 25
entrance: 59
@@ -1080,7 +1085,7 @@
object_id: 0x40
type: "Box"
access: []
- name: "Phoebe"
- name: "Phoebe Companion"
object_id: 0
type: "Trigger"
on_trigger: ["Phoebe1"]
@@ -1846,11 +1851,11 @@
access: []
- target_room: 77
entrance: 164
teleporter: [16, 2]
teleporter: [98, 8] # original value [16, 2]
access: []
- target_room: 82
entrance: 165
teleporter: [17, 2]
teleporter: [96, 8] # original value [17, 2]
access: []
- target_room: 208
access: ["Claw"]
@@ -1875,7 +1880,7 @@
object_id: 14
type: "NPC"
access: ["ReubenDadSaved"]
- name: "Reuben"
- name: "Reuben Companion"
object_id: 0
type: "Trigger"
on_trigger: ["Reuben1"]
@@ -1951,12 +1956,7 @@
- name: "Fireburg - Tristam"
object_id: 10
type: "NPC"
access: []
- name: "Tristam Fireburg"
object_id: 0
type: "Trigger"
on_trigger: ["Tristam"]
access: []
access: ["Tristam", "TristamBoneItemGiven"]
links:
- target_room: 76
entrance: 177
@@ -3183,7 +3183,7 @@
access: []
- target_room: 163
entrance: 321
teleporter: [31, 2]
teleporter: [97, 8]
access: []
- target_room: 165
entrance: 322
@@ -3292,7 +3292,7 @@
access: []
- target_room: 164
entrance: 337
teleporter: [215, 0]
teleporter: [102, 8]
access: []
- name: Windia Inn Beds
id: 164

View File

@@ -73,14 +73,57 @@ Final Fantasy Mystic Quest:
Chaos: 0
SelfDestruct: 0
SimpleShuffle: 0
enemizer_groups:
MobsOnly: 0
MobsBosses: 0
MobsBossesDK: 0
shuffle_res_weak_type:
true: 0
false: 0
leveling_curve:
Half: 0
Normal: 0
OneAndHalf: 0
Double: 0
DoubleHalf: 0
DoubleAndHalf: 0
Triple: 0
Quadruple: 0
companion_leveling_type:
Quests: 0
QuestsExtended: 0
SaveCrystalsIndividual: 0
SaveCrystalsAll: 0
BenPlus0: 0
BenPlus5: 0
BenPlus10: 0
companion_spellbook_type:
Standard: 0
Extended: 0
RandomBalanced: 0
RandomChaos: 0
starting_companion:
None: 0
Kaeli: 0
Tristam: 0
Phoebe: 0
Reuben: 0
Random: 0
RandomPlusNone: 0
available_companions:
Zero: 0
One: 0
Two: 0
Three: 0
Four: 0
Random14: 0
Random04: 0
companions_locations:
Standard: 0
Shuffled: 0
ShuffledExtended: 0
kaelis_mom_fight_minotaur:
true: 0
false: 0
battles_quantity:
Ten: 0
Seven: 0

View File

@@ -15,9 +15,9 @@ else:
def locality_needed(world: MultiWorld) -> bool:
for player in world.player_ids:
if world.local_items[player].value:
if world.worlds[player].options.local_items.value:
return True
if world.non_local_items[player].value:
if world.worlds[player].options.non_local_items.value:
return True
# Group
@@ -40,12 +40,12 @@ def locality_rules(world: MultiWorld):
forbid_data[sender][receiver].update(items)
for receiving_player in world.player_ids:
local_items: typing.Set[str] = world.local_items[receiving_player].value
local_items: typing.Set[str] = world.worlds[receiving_player].options.local_items.value
if local_items:
for sending_player in world.player_ids:
if receiving_player != sending_player:
forbid(sending_player, receiving_player, local_items)
non_local_items: typing.Set[str] = world.non_local_items[receiving_player].value
non_local_items: typing.Set[str] = world.worlds[receiving_player].options.non_local_items.value
if non_local_items:
forbid(receiving_player, receiving_player, non_local_items)

View File

@@ -2,7 +2,7 @@ import typing
from .ExtractedData import logic_options, starts, pool_options
from .Rules import cost_terms
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink
from .Charms import vanilla_costs, names as charm_names
if typing.TYPE_CHECKING:
@@ -402,22 +402,34 @@ class WhitePalace(Choice):
default = 0
class DeathLink(Choice):
class ExtraPlatforms(DefaultOnToggle):
"""Places additional platforms to make traveling throughout Hallownest more convenient."""
class DeathLinkShade(Choice):
"""Sets whether to create a shade when you are killed by a DeathLink and how to handle your existing shade, if any.
vanilla: DeathLink deaths function like any other death and overrides your existing shade (including geo), if any.
shadeless: DeathLink deaths do not spawn shades. Your existing shade (including geo), if any, is untouched.
shade: DeathLink deaths spawn a shade if you do not have an existing shade. Otherwise, it acts like shadeless.
* This option has no effect if DeathLink is disabled.
** Self-death shade behavior is not changed; if a self-death normally creates a shade in vanilla, it will override
your existing shade, if any.
"""
When you die, everyone dies. Of course the reverse is true too.
When enabled, choose how incoming deathlinks are handled:
vanilla: DeathLink kills you and is just like any other death. RIP your previous shade and geo.
shadeless: DeathLink kills you, but no shade spawns and no geo is lost. Your previous shade, if any, is untouched.
shade: DeathLink functions like a normal death if you do not already have a shade, shadeless otherwise.
"""
option_off = 0
alias_no = 0
alias_true = 1
alias_on = 1
alias_yes = 1
option_vanilla = 0
option_shadeless = 1
option_vanilla = 2
option_shade = 3
option_shade = 2
default = 2
class DeathLinkBreaksFragileCharms(Toggle):
"""Sets if fragile charms break when you are killed by a DeathLink.
* This option has no effect if DeathLink is disabled.
** Self-death fragile charm behavior is not changed; if a self-death normally breaks fragile charms in vanilla, it
will continue to do so.
"""
class StartingGeo(Range):
@@ -476,7 +488,8 @@ hollow_knight_options: typing.Dict[str, type(Option)] = {
**{
option.__name__: option
for option in (
StartLocation, Goal, WhitePalace, StartingGeo, DeathLink,
StartLocation, Goal, WhitePalace, ExtraPlatforms, StartingGeo,
DeathLink, DeathLinkShade, DeathLinkBreaksFragileCharms,
MinimumGeoPrice, MaximumGeoPrice,
MinimumGrubPrice, MaximumGrubPrice,
MinimumEssencePrice, MaximumEssencePrice,
@@ -488,7 +501,7 @@ hollow_knight_options: typing.Dict[str, type(Option)] = {
LegEaterShopSlots, GrubfatherRewardSlots,
SeerRewardSlots, ExtraShopSlots,
SplitCrystalHeart, SplitMothwingCloak, SplitMantisClaw,
CostSanity, CostSanityHybridChance,
CostSanity, CostSanityHybridChance
)
},
**cost_sanity_weights

View File

@@ -419,17 +419,16 @@ class HKWorld(World):
def set_rules(self):
world = self.multiworld
player = self.player
if world.logic[player] != 'nologic':
goal = world.Goal[player]
if goal == Goal.option_hollowknight:
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player)
elif goal == Goal.option_siblings:
world.completion_condition[player] = lambda state: state._hk_siblings_ending(player)
elif goal == Goal.option_radiance:
world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player)
else:
# Any goal
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) or state._hk_can_beat_radiance(player)
goal = world.Goal[player]
if goal == Goal.option_hollowknight:
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player)
elif goal == Goal.option_siblings:
world.completion_condition[player] = lambda state: state._hk_siblings_ending(player)
elif goal == Goal.option_radiance:
world.completion_condition[player] = lambda state: state._hk_can_beat_radiance(player)
else:
# Any goal
world.completion_condition[player] = lambda state: state._hk_can_beat_thk(player) or state._hk_can_beat_radiance(player)
set_rules(self)

View File

@@ -29,6 +29,7 @@ class KH2Context(CommonContext):
self.kh2_local_items = None
self.growthlevel = None
self.kh2connected = False
self.kh2_finished_game = False
self.serverconneced = False
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
@@ -833,9 +834,9 @@ async def kh2_watcher(ctx: KH2Context):
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
if finishedGame(ctx, message) and not ctx.kh2_finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
ctx.kh2_finished_game = True
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game Connection lost. waiting 15 seconds until trying to reconnect.")

View File

@@ -1020,10 +1020,9 @@ def create_regions(self):
multiworld.regions += [create_region(multiworld, player, active_locations, region, locations) for region, locations in
KH2REGIONS.items()]
# fill the event locations with events
multiworld.worlds[player].item_name_to_id.update({event_name: None for event_name in Events_Table})
for location, item in event_location_to_item.items():
multiworld.get_location(location, player).place_locked_item(
multiworld.worlds[player].create_item(item))
multiworld.worlds[player].create_event_item(item))
def connect_regions(self):

View File

@@ -224,7 +224,7 @@ class KH2WorldRules(KH2Rules):
RegionName.Pl2: lambda state: self.pl_unlocked(state, 2),
RegionName.Ag: lambda state: self.ag_unlocked(state, 1),
RegionName.Ag2: lambda state: self.ag_unlocked(state, 2),
RegionName.Ag2: lambda state: self.ag_unlocked(state, 2) and self.kh2_has_all([ItemName.FireElement,ItemName.BlizzardElement,ItemName.ThunderElement],state),
RegionName.Bc: lambda state: self.bc_unlocked(state, 1),
RegionName.Bc2: lambda state: self.bc_unlocked(state, 2),

View File

@@ -119,11 +119,15 @@ class KH2World(World):
item_classification = ItemClassification.useful
else:
item_classification = ItemClassification.filler
created_item = KH2Item(name, item_classification, self.item_name_to_id[name], self.player)
return created_item
def create_event_item(self, name: str) -> Item:
item_classification = ItemClassification.progression
created_item = KH2Item(name, item_classification, None, self.player)
return created_item
def create_items(self) -> None:
"""
Fills ItemPool and manages schmovement, random growth, visit locking and random starting visit locking.
@@ -461,7 +465,7 @@ class KH2World(World):
if location in self.random_super_boss_list:
self.random_super_boss_list.remove(location)
if not self.options.SummonLevelLocationToggle:
if not self.options.SummonLevelLocationToggle and LocationName.Summonlvl7 in self.random_super_boss_list:
self.random_super_boss_list.remove(LocationName.Summonlvl7)
# Testing if the player has the right amount of Bounties for Completion.

View File

@@ -349,18 +349,19 @@ class GfxMod(FreeText, LADXROption):
normal = ''
default = 'Link'
__spriteDir: str = Utils.local_path(os.path.join('data', 'sprites','ladx'))
__spriteFiles: typing.DefaultDict[str, typing.List[str]] = defaultdict(list)
__spriteDir: str = None
extensions = [".bin", ".bdiff", ".png", ".bmp"]
for file in os.listdir(__spriteDir):
name, extension = os.path.splitext(file)
if extension in extensions:
__spriteFiles[name].append(file)
def __init__(self, value: str):
super().__init__(value)
if not GfxMod.__spriteDir:
GfxMod.__spriteDir = Utils.local_path(os.path.join('data', 'sprites','ladx'))
for file in os.listdir(GfxMod.__spriteDir):
name, extension = os.path.splitext(file)
if extension in self.extensions:
GfxMod.__spriteFiles[name].append(file)
def verify(self, world, player_name: str, plando_options) -> None:
if self.value == "Link" or self.value in GfxMod.__spriteFiles:

View File

@@ -1,6 +1,8 @@
"""
Archipelago init file for Lingo
"""
from logging import warning
from BaseClasses import Item, ItemClassification, Tutorial
from worlds.AutoWorld import WebWorld, World
from .items import ALL_ITEM_TABLE, LingoItem
@@ -9,7 +11,6 @@ from .options import LingoOptions
from .player_logic import LingoPlayerLogic
from .regions import create_regions
from .static_logic import Room, RoomEntrance
from .testing import LingoTestOptions
class LingoWebWorld(WebWorld):
@@ -49,6 +50,14 @@ class LingoWorld(World):
player_logic: LingoPlayerLogic
def generate_early(self):
if not (self.options.shuffle_doors or self.options.shuffle_colors):
if self.multiworld.players == 1:
warning(f"{self.multiworld.get_player_name(self.player)}'s Lingo world doesn't have any progression"
f" items. Please turn on Door Shuffle or Color Shuffle if that doesn't seem right.")
else:
raise Exception(f"{self.multiworld.get_player_name(self.player)}'s Lingo world doesn't have any"
f" progression items. Please turn on Door Shuffle or Color Shuffle.")
self.player_logic = LingoPlayerLogic(self)
def create_regions(self):
@@ -94,9 +103,11 @@ class LingoWorld(World):
classification = item.classification
if hasattr(self, "options") and self.options.shuffle_paintings and len(item.painting_ids) > 0\
and len(item.door_ids) == 0 and all(painting_id not in self.player_logic.painting_mapping
for painting_id in item.painting_ids):
for painting_id in item.painting_ids)\
and "pilgrim_painting2" not in item.painting_ids:
# If this is a "door" that just moves one or more paintings, and painting shuffle is on and those paintings
# go nowhere, then this item should not be progression.
# go nowhere, then this item should not be progression. The Pilgrim Room painting is special and needs to be
# excluded from this.
classification = ItemClassification.filler
return LingoItem(name, classification, item.code, self.player)

View File

@@ -373,6 +373,7 @@
ANOTHER TRY:
id: Entry Room/Panel_advance
tag: topwhite
non_counting: True # This is a counting panel in-game, but it can never count towards the LEVEL 2 panel hunt.
LEVEL 2:
# We will set up special rules for this in code.
id: EndPanel/Panel_level_2
@@ -1033,6 +1034,8 @@
Hallway Room (3): True
Hallway Room (4): True
Hedge Maze: True # through the door to the sectioned-off part of the hedge maze
Cellar:
door: Lookout Entrance
panels:
MASSACRED:
id: Palindrome Room/Panel_massacred_sacred
@@ -1168,11 +1171,21 @@
- KEEP
- BAILEY
- TOWER
Lookout Entrance:
id: Cross Room Doors/Door_missing
location_name: Outside The Agreeable - Lookout Panels
panels:
- NORTH
- WINTER
- DIAMONDS
- FIRE
paintings:
- id: panda_painting
orientation: south
- id: eyes_yellow_painting
orientation: east
- id: pencil_painting7
orientation: north
progression:
Progressive Hallway Room:
- Hallway Door
@@ -2043,7 +2056,7 @@
door: Sixth Floor
Cellar:
room: Room Room
door: Shortcut to Fifth Floor
door: Cellar Exit
Welcome Back Area:
door: Welcome Back
Art Gallery:
@@ -2302,9 +2315,6 @@
id: Master Room/Panel_mastery_mastery3
tag: midwhite
hunt: True
required_door:
room: Orange Tower Seventh Floor
door: Mastery
THE LIBRARY:
id: EndPanel/Panel_library
check: True
@@ -2675,6 +2685,10 @@
Outside The Undeterred: True
Outside The Agreeable: True
Outside The Wanderer: True
The Observant: True
Art Gallery: True
The Scientific: True
Cellar: True
Orange Tower Fifth Floor:
room: Orange Tower Fifth Floor
door: Welcome Back
@@ -2991,8 +3005,7 @@
PATS:
id: Rhyme Room/Panel_wrath_path
colors: purple
tag: midpurp and rhyme
copy_to_sign: sign15
tag: forbid
KNIGHT:
id: Rhyme Room/Panel_knight_write
colors: purple
@@ -3158,6 +3171,8 @@
door: Painting Shortcut
painting: True
Room Room: True # trapdoor
Outside The Agreeable:
painting: True
panels:
UNOPEN:
id: Truncate Room/Panel_unopened_open
@@ -6299,17 +6314,22 @@
SKELETON:
id: Double Room/Panel_bones_syn
tag: syn rhyme
colors: purple
subtag: bot
link: rhyme BONES
REPENTANCE:
id: Double Room/Panel_sentence_rhyme
colors: purple
colors:
- purple
- blue
tag: whole rhyme
subtag: top
link: rhyme SENTENCE
WORD:
id: Double Room/Panel_sentence_whole
colors: blue
colors:
- purple
- blue
tag: whole rhyme
subtag: bot
link: rhyme SENTENCE
@@ -6321,6 +6341,7 @@
link: rhyme DREAM
FANTASY:
id: Double Room/Panel_dream_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme DREAM
@@ -6332,6 +6353,7 @@
link: rhyme MYSTERY
SECRET:
id: Double Room/Panel_mystery_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme MYSTERY
@@ -6386,25 +6408,33 @@
door: Nines
FERN:
id: Double Room/Panel_return_rhyme
colors: purple
colors:
- purple
- black
tag: ant rhyme
subtag: top
link: rhyme RETURN
STAY:
id: Double Room/Panel_return_ant
colors: black
colors:
- purple
- black
tag: ant rhyme
subtag: bot
link: rhyme RETURN
FRIEND:
id: Double Room/Panel_descend_rhyme
colors: purple
colors:
- purple
- black
tag: ant rhyme
subtag: top
link: rhyme DESCEND
RISE:
id: Double Room/Panel_descend_ant
colors: black
colors:
- purple
- black
tag: ant rhyme
subtag: bot
link: rhyme DESCEND
@@ -6416,6 +6446,7 @@
link: rhyme JUMP
BOUNCE:
id: Double Room/Panel_jump_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme JUMP
@@ -6427,6 +6458,7 @@
link: rhyme FALL
PLUNGE:
id: Double Room/Panel_fall_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme FALL
@@ -6456,13 +6488,17 @@
panels:
BIRD:
id: Double Room/Panel_word_rhyme
colors: purple
colors:
- purple
- blue
tag: whole rhyme
subtag: top
link: rhyme WORD
LETTER:
id: Double Room/Panel_word_whole
colors: blue
colors:
- purple
- blue
tag: whole rhyme
subtag: bot
link: rhyme WORD
@@ -6474,6 +6510,7 @@
link: rhyme HIDDEN
CONCEALED:
id: Double Room/Panel_hidden_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme HIDDEN
@@ -6485,6 +6522,7 @@
link: rhyme SILENT
MUTE:
id: Double Room/Panel_silent_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme SILENT
@@ -6531,6 +6569,7 @@
link: rhyme BLOCKED
OBSTRUCTED:
id: Double Room/Panel_blocked_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme BLOCKED
@@ -6542,6 +6581,7 @@
link: rhyme RISE
SWELL:
id: Double Room/Panel_rise_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme RISE
@@ -6553,6 +6593,7 @@
link: rhyme ASCEND
CLIMB:
id: Double Room/Panel_ascend_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme ASCEND
@@ -6564,6 +6605,7 @@
link: rhyme DOUBLE
DUPLICATE:
id: Double Room/Panel_double_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme DOUBLE
@@ -6642,6 +6684,7 @@
link: rhyme CHILD
KID:
id: Double Room/Panel_child_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme CHILD
@@ -6653,6 +6696,7 @@
link: rhyme CRYSTAL
QUARTZ:
id: Double Room/Panel_crystal_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme CRYSTAL
@@ -6664,6 +6708,7 @@
link: rhyme CREATIVE
INNOVATIVE (Bottom):
id: Double Room/Panel_creative_syn
colors: purple
tag: syn rhyme
subtag: bot
link: rhyme CREATIVE
@@ -6882,7 +6927,7 @@
event: True
panels:
- WALL (1)
Shortcut to Fifth Floor:
Cellar Exit:
id:
- Tower Room Area Doors/Door_panel_basement
- Tower Room Area Doors/Door_panel_basement2
@@ -6895,7 +6940,10 @@
door: Excavation
Orange Tower Fifth Floor:
room: Room Room
door: Shortcut to Fifth Floor
door: Cellar Exit
Outside The Agreeable:
room: Outside The Agreeable
door: Lookout Entrance
Outside The Wise:
entrances:
Orange Tower Sixth Floor:
@@ -7319,49 +7367,65 @@
link: change GRAVITY
PART:
id: Chemistry Room/Panel_physics_2
colors: blue
colors:
- blue
- red
tag: blue mid red bot
subtag: mid
link: xur PARTICLE
MATTER:
id: Chemistry Room/Panel_physics_1
colors: red
colors:
- blue
- red
tag: blue mid red bot
subtag: bot
link: xur PARTICLE
ELECTRIC:
id: Chemistry Room/Panel_physics_6
colors: purple
colors:
- purple
- red
tag: purple mid red bot
subtag: mid
link: xpr ELECTRON
ATOM (1):
id: Chemistry Room/Panel_physics_3
colors: red
colors:
- purple
- red
tag: purple mid red bot
subtag: bot
link: xpr ELECTRON
NEUTRAL:
id: Chemistry Room/Panel_physics_7
colors: purple
colors:
- purple
- red
tag: purple mid red bot
subtag: mid
link: xpr NEUTRON
ATOM (2):
id: Chemistry Room/Panel_physics_4
colors: red
colors:
- purple
- red
tag: purple mid red bot
subtag: bot
link: xpr NEUTRON
PROPEL:
id: Chemistry Room/Panel_physics_8
colors: purple
colors:
- purple
- red
tag: purple mid red bot
subtag: mid
link: xpr PROTON
ATOM (3):
id: Chemistry Room/Panel_physics_5
colors: red
colors:
- purple
- red
tag: purple mid red bot
subtag: bot
link: xpr PROTON

View File

@@ -1064,6 +1064,9 @@ doors:
Hallway Door:
item: 444459
location: 445214
Lookout Entrance:
item: 444579
location: 445271
Dread Hallway:
Tenacious Entrance:
item: 444462
@@ -1402,7 +1405,7 @@ doors:
item: 444570
location: 445266
Room Room:
Shortcut to Fifth Floor:
Cellar Exit:
item: 444571
location: 445076
Outside The Wise:

View File

@@ -32,7 +32,7 @@ class LocationChecks(Choice):
option_insanity = 2
class ShuffleColors(Toggle):
class ShuffleColors(DefaultOnToggle):
"""If on, an item is added to the pool for every puzzle color (besides White).
You will need to unlock the requisite colors in order to be able to solve puzzles of that color."""
display_name = "Shuffle Colors"

View File

@@ -6,7 +6,6 @@ from .options import LocationChecks, ShuffleDoors, VictoryCondition
from .static_logic import DOORS_BY_ROOM, Door, PAINTINGS, PAINTINGS_BY_ROOM, PAINTING_ENTRANCES, PAINTING_EXITS, \
PANELS_BY_ROOM, PROGRESSION_BY_ROOM, REQUIRED_PAINTING_ROOMS, REQUIRED_PAINTING_WHEN_NO_DOORS_ROOMS, RoomAndDoor, \
RoomAndPanel
from .testing import LingoTestOptions
if TYPE_CHECKING:
from . import LingoWorld
@@ -190,6 +189,25 @@ class LingoPlayerLogic:
if item.should_include(world):
self.real_items.append(name)
# Calculate the requirements for the fake pilgrimage.
fake_pilgrimage = [
["Second Room", "Exit Door"], ["Crossroads", "Tower Entrance"],
["Orange Tower Fourth Floor", "Hot Crusts Door"], ["Outside The Initiated", "Shortcut to Hub Room"],
["Orange Tower First Floor", "Shortcut to Hub Room"], ["Directional Gallery", "Shortcut to The Undeterred"],
["Orange Tower First Floor", "Salt Pepper Door"], ["Hub Room", "Crossroads Entrance"],
["Champion's Rest", "Shortcut to The Steady"], ["The Bearer", "Shortcut to The Bold"],
["Art Gallery", "Exit"], ["The Tenacious", "Shortcut to Hub Room"],
["Outside The Agreeable", "Tenacious Entrance"]
]
pilgrimage_reqs = AccessRequirements()
for door in fake_pilgrimage:
door_object = DOORS_BY_ROOM[door[0]][door[1]]
if door_object.event or world.options.shuffle_doors == ShuffleDoors.option_none:
pilgrimage_reqs.merge(self.calculate_door_requirements(door[0], door[1], world))
else:
pilgrimage_reqs.doors.add(RoomAndDoor(door[0], door[1]))
self.door_reqs.setdefault("Pilgrim Antechamber", {})["Pilgrimage"] = pilgrimage_reqs
# Create the paintings mapping, if painting shuffle is on.
if painting_shuffle:
# Shuffle paintings until we get something workable.
@@ -205,7 +223,7 @@ class LingoPlayerLogic:
"kind of logic error.")
if door_shuffle != ShuffleDoors.option_none and location_classification != LocationClassification.insanity \
and not early_color_hallways and LingoTestOptions.disable_forced_good_item is False:
and not early_color_hallways is False:
# If shuffle doors is on, force a useful item onto the HI panel. This may not necessarily get you out of BK,
# but the goal is to allow you to reach at least one more check. The non-painting ones are hardcoded right
# now. We only allow the entrance to the Pilgrim Room if color shuffle is off, because otherwise there are
@@ -369,11 +387,9 @@ class LingoPlayerLogic:
door_object = DOORS_BY_ROOM[room][door]
for req_panel in door_object.panels:
if req_panel.room is not None and req_panel.room != room:
access_reqs.rooms.add(req_panel.room)
sub_access_reqs = self.calculate_panel_requirements(room if req_panel.room is None else req_panel.room,
req_panel.panel, world)
panel_room = room if req_panel.room is None else req_panel.room
access_reqs.rooms.add(panel_room)
sub_access_reqs = self.calculate_panel_requirements(panel_room, req_panel.panel, world)
access_reqs.merge(sub_access_reqs)
self.door_reqs[room][door] = access_reqs
@@ -397,8 +413,8 @@ class LingoPlayerLogic:
unhindered_panels_by_color: dict[Optional[str], int] = {}
for panel_name, panel_data in room_data.items():
# We won't count non-counting panels.
if panel_data.non_counting:
# We won't count non-counting panels. THE MASTER has special access rules and is handled separately.
if panel_data.non_counting or panel_name == "THE MASTER":
continue
# We won't coalesce any panels that have requirements beyond colors. To simplify things for now, we will

View File

@@ -4,7 +4,7 @@ from BaseClasses import Entrance, ItemClassification, Region
from .items import LingoItem
from .locations import LingoLocation
from .player_logic import LingoPlayerLogic
from .rules import lingo_can_use_entrance, lingo_can_use_pilgrimage, make_location_lambda
from .rules import lingo_can_use_entrance, make_location_lambda
from .static_logic import ALL_ROOMS, PAINTINGS, Room, RoomAndDoor
if TYPE_CHECKING:
@@ -25,15 +25,6 @@ def create_region(room: Room, world: "LingoWorld", player_logic: LingoPlayerLogi
return new_region
def handle_pilgrim_room(regions: Dict[str, Region], world: "LingoWorld", player_logic: LingoPlayerLogic) -> None:
target_region = regions["Pilgrim Antechamber"]
source_region = regions["Outside The Agreeable"]
source_region.connect(
target_region,
"Pilgrimage",
lambda state: lingo_can_use_pilgrimage(state, world, player_logic))
def connect_entrance(regions: Dict[str, Region], source_region: Region, target_region: Region, description: str,
door: Optional[RoomAndDoor], world: "LingoWorld", player_logic: LingoPlayerLogic):
connection = Entrance(world.player, description, source_region)
@@ -91,7 +82,9 @@ def create_regions(world: "LingoWorld", player_logic: LingoPlayerLogic) -> None:
connect_entrance(regions, regions[entrance.room], regions[room.name], entrance_name, entrance.door, world,
player_logic)
handle_pilgrim_room(regions, world, player_logic)
# Add the fake pilgrimage.
connect_entrance(regions, regions["Outside The Agreeable"], regions["Pilgrim Antechamber"], "Pilgrimage",
RoomAndDoor("Pilgrim Antechamber", "Pilgrimage"), world, player_logic)
if early_color_hallways:
regions["Starting Room"].connect(regions["Outside The Undeterred"], "Early Color Hallways")

View File

@@ -17,23 +17,6 @@ def lingo_can_use_entrance(state: CollectionState, room: str, door: RoomAndDoor,
return _lingo_can_open_door(state, effective_room, door.door, world, player_logic)
def lingo_can_use_pilgrimage(state: CollectionState, world: "LingoWorld", player_logic: LingoPlayerLogic):
fake_pilgrimage = [
["Second Room", "Exit Door"], ["Crossroads", "Tower Entrance"],
["Orange Tower Fourth Floor", "Hot Crusts Door"], ["Outside The Initiated", "Shortcut to Hub Room"],
["Orange Tower First Floor", "Shortcut to Hub Room"], ["Directional Gallery", "Shortcut to The Undeterred"],
["Orange Tower First Floor", "Salt Pepper Door"], ["Hub Room", "Crossroads Entrance"],
["Champion's Rest", "Shortcut to The Steady"], ["The Bearer", "Shortcut to The Bold"],
["Art Gallery", "Exit"], ["The Tenacious", "Shortcut to Hub Room"],
["Outside The Agreeable", "Tenacious Entrance"]
]
for entrance in fake_pilgrimage:
if not _lingo_can_open_door(state, entrance[0], entrance[1], world, player_logic):
return False
return True
def lingo_can_use_location(state: CollectionState, location: PlayerLocation, world: "LingoWorld",
player_logic: LingoPlayerLogic):
return _lingo_can_satisfy_requirements(state, location.access, world, player_logic)
@@ -56,6 +39,12 @@ def lingo_can_use_level_2_location(state: CollectionState, world: "LingoWorld",
counted_panels += panel_count
if counted_panels >= world.options.level_2_requirement.value - 1:
return True
# THE MASTER has to be handled separately, because it has special access rules.
if state.can_reach("Orange Tower Seventh Floor", "Region", world.player)\
and lingo_can_use_mastery_location(state, world, player_logic):
counted_panels += 1
if counted_panels >= world.options.level_2_requirement.value - 1:
return True
return False

View File

@@ -3,10 +3,13 @@ from . import LingoTestBase
class TestRequiredRoomLogic(LingoTestBase):
options = {
"shuffle_doors": "complex"
"shuffle_doors": "complex",
"shuffle_colors": "false",
}
def test_pilgrim_first(self) -> None:
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("The Seeker", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Pilgrim Antechamber", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Pilgrim Room", "Region", self.player))
@@ -27,6 +30,8 @@ class TestRequiredRoomLogic(LingoTestBase):
self.assertTrue(self.can_reach_location("The Seeker - Achievement"))
def test_hidden_first(self) -> None:
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("The Seeker", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Pilgrim Room", "Region", self.player))
self.assertFalse(self.can_reach_location("The Seeker - Achievement"))
@@ -49,10 +54,13 @@ class TestRequiredRoomLogic(LingoTestBase):
class TestRequiredDoorLogic(LingoTestBase):
options = {
"shuffle_doors": "complex"
"shuffle_doors": "complex",
"shuffle_colors": "false",
}
def test_through_rhyme(self) -> None:
self.remove_forced_good_item()
self.assertFalse(self.can_reach_location("Rhyme Room - Circle/Looped Square Wall"))
self.collect_by_name("Starting Room - Rhyme Room Entrance")
@@ -62,6 +70,8 @@ class TestRequiredDoorLogic(LingoTestBase):
self.assertTrue(self.can_reach_location("Rhyme Room - Circle/Looped Square Wall"))
def test_through_hidden(self) -> None:
self.remove_forced_good_item()
self.assertFalse(self.can_reach_location("Rhyme Room - Circle/Looped Square Wall"))
self.collect_by_name("Starting Room - Rhyme Room Entrance")
@@ -76,10 +86,13 @@ class TestRequiredDoorLogic(LingoTestBase):
class TestSimpleDoors(LingoTestBase):
options = {
"shuffle_doors": "simple"
"shuffle_doors": "simple",
"shuffle_colors": "false",
}
def test_requirement(self):
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("Outside The Wanderer", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player))

View File

@@ -8,6 +8,8 @@ class TestProgressiveOrangeTower(LingoTestBase):
}
def test_from_welcome_back(self) -> None:
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("Orange Tower First Floor", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Orange Tower Second Floor", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player))
@@ -83,6 +85,8 @@ class TestProgressiveOrangeTower(LingoTestBase):
self.assertTrue(self.multiworld.state.can_reach("Orange Tower Seventh Floor", "Region", self.player))
def test_from_hub_room(self) -> None:
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("Orange Tower First Floor", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Orange Tower Second Floor", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Orange Tower Third Floor", "Region", self.player))

View File

@@ -7,6 +7,8 @@ class TestComplexProgressiveHallwayRoom(LingoTestBase):
}
def test_item(self):
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("Outside The Agreeable", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Hallway Room (2)", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Hallway Room (3)", "Region", self.player))
@@ -58,6 +60,8 @@ class TestSimpleHallwayRoom(LingoTestBase):
}
def test_item(self):
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("Outside The Agreeable", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Hallway Room (2)", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Hallway Room (3)", "Region", self.player))
@@ -81,10 +85,13 @@ class TestSimpleHallwayRoom(LingoTestBase):
class TestProgressiveArtGallery(LingoTestBase):
options = {
"shuffle_doors": "complex"
"shuffle_doors": "complex",
"shuffle_colors": "false",
}
def test_item(self):
self.remove_forced_good_item()
self.assertFalse(self.multiworld.state.can_reach("Art Gallery", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Art Gallery (Second Floor)", "Region", self.player))
self.assertFalse(self.multiworld.state.can_reach("Art Gallery (Third Floor)", "Region", self.player))

View File

@@ -1,7 +1,6 @@
from typing import ClassVar
from test.bases import WorldTestBase
from .. import LingoTestOptions
class LingoTestBase(WorldTestBase):
@@ -9,5 +8,10 @@ class LingoTestBase(WorldTestBase):
player: ClassVar[int] = 1
def world_setup(self, *args, **kwargs):
LingoTestOptions.disable_forced_good_item = True
super().world_setup(*args, **kwargs)
def remove_forced_good_item(self):
location = self.multiworld.get_location("Second Room - Good Luck", self.player)
self.remove(location.item)
self.multiworld.itempool.append(location.item)
self.multiworld.state.events.add(location)

View File

@@ -1,2 +0,0 @@
class LingoTestOptions:
disable_forced_good_item: bool = False

View File

@@ -40,7 +40,7 @@ mentioned_panels = Set[]
door_groups = {}
directives = Set["entrances", "panels", "doors", "paintings", "progression"]
panel_directives = Set["id", "required_room", "required_door", "required_panel", "colors", "check", "exclude_reduce", "tag", "link", "subtag", "achievement", "copy_to_sign", "non_counting"]
panel_directives = Set["id", "required_room", "required_door", "required_panel", "colors", "check", "exclude_reduce", "tag", "link", "subtag", "achievement", "copy_to_sign", "non_counting", "hunt"]
door_directives = Set["id", "painting_id", "panels", "item_name", "location_name", "skip_location", "skip_item", "group", "include_reduce", "junk_item", "event"]
painting_directives = Set["id", "enter_only", "exit_only", "orientation", "required_door", "required", "required_when_no_doors", "move", "req_blocked", "req_blocked_when_no_doors"]

View File

@@ -170,6 +170,9 @@ pullpc
ScriptTX:
STA $7FD4F1 ; (overwritten instruction)
LDA $05AC ; load map number
CMP.b #$F1 ; check if ancient cave final floor
BNE +
REP #$20
LDA $7FD4EF ; read script item id
CMP.w #$01C2 ; test for ancient key
@@ -261,6 +264,9 @@ SpecialItemGet:
BRA ++
+: CMP.w #$01C2 ; ancient key
BNE +
LDA.w #$0008
ORA $0796
STA $0796 ; set ancient key EV flag ($C3)
LDA.w #$0200
ORA $0797
STA $0797 ; set boss item EV flag ($D1)

View File

@@ -62,7 +62,7 @@ class MessengerWorld(World):
"Money Wrench",
], base_offset)}
required_client_version = (0, 4, 1)
required_client_version = (0, 4, 2)
web = MessengerWeb()

View File

@@ -1,12 +1,10 @@
# The Messenger
## Quick Links
- [Setup](../../../../tutorial/The%20Messenger/setup/en)
- [Settings Page](../../../../games/The%20Messenger/player-settings)
- [Setup](/tutorial/The%20Messenger/setup/en)
- [Options Page](/games/The%20Messenger/player-options)
- [Courier Github](https://github.com/Brokemia/Courier)
- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod)
- [The Messenger Randomizer AP Github](https://github.com/alwaysintreble/TheMessengerRandomizerModAP)
- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker)
- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack)
## What does randomization do in this game?

View File

@@ -1,16 +1,15 @@
# The Messenger Randomizer Setup Guide
## Quick Links
- [Game Info](../../../../games/The%20Messenger/info/en)
- [Settings Page](../../../../games/The%20Messenger/player-settings)
- [Game Info](/games/The%20Messenger/info/en)
- [Options Page](/games/The%20Messenger/player-options)
- [Courier Github](https://github.com/Brokemia/Courier)
- [The Messenger Randomizer AP Github](https://github.com/alwaysintreble/TheMessengerRandomizerModAP)
- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker)
- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack)
## Installation
1. Read the [Game Info Page](../../../../games/The%20Messenger/info/en) for how the game works, caveats and known issues
1. Read the [Game Info Page](/games/The%20Messenger/info/en) for how the game works, caveats and known issues
2. Download and install Courier Mod Loader using the instructions on the release page
* [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases)
3. Download and install the randomizer mod

View File

@@ -63,7 +63,10 @@ class MessengerRules:
"Searing Crags Seal - Triple Ball Spinner": self.has_vertical,
"Searing Crags - Astral Tea Leaves":
lambda state: state.can_reach("Ninja Village - Astral Seed", "Location", self.player),
"Searing Crags - Key of Strength": lambda state: state.has("Power Thistle", self.player),
"Searing Crags - Key of Strength": lambda state: state.has("Power Thistle", self.player)
and (self.has_dart(state)
or (self.has_wingsuit(state)
and self.can_destroy_projectiles(state))),
# glacial peak
"Glacial Peak Seal - Ice Climbers": self.has_dart,
"Glacial Peak Seal - Projectile Spike Pit": self.can_destroy_projectiles,

View File

@@ -1106,21 +1106,30 @@
"parent_map": "MAP_ROUTE120",
"locations": [
"ITEM_ROUTE_120_NUGGET",
"ITEM_ROUTE_120_FULL_HEAL",
"ITEM_ROUTE_120_REVIVE",
"ITEM_ROUTE_120_HYPER_POTION",
"HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2",
"HIDDEN_ITEM_ROUTE_120_ZINC"
],
"events": [],
"exits": [
"REGION_ROUTE120/NORTH",
"REGION_ROUTE120/SOUTH_PONDS",
"REGION_ROUTE121/WEST"
],
"warps": [
"MAP_ROUTE120:0/MAP_ANCIENT_TOMB:0"
]
},
"REGION_ROUTE120/SOUTH_PONDS": {
"parent_map": "MAP_ROUTE120",
"locations": [
"HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2",
"ITEM_ROUTE_120_FULL_HEAL"
],
"events": [],
"exits": [],
"warps": []
},
"REGION_ROUTE121/WEST": {
"parent_map": "MAP_ROUTE121",
"locations": [

View File

@@ -626,6 +626,10 @@ def set_rules(world: "PokemonEmeraldWorld") -> None:
get_entrance("REGION_ROUTE120/NORTH_POND_SHORE -> REGION_ROUTE120/NORTH_POND"),
can_surf
)
set_rule(
get_entrance("REGION_ROUTE120/SOUTH -> REGION_ROUTE120/SOUTH_PONDS"),
can_surf
)
# Route 121
set_rule(

View File

@@ -44,13 +44,17 @@ class TestScorchedSlabPond(PokemonEmeraldTestBase):
class TestSurf(PokemonEmeraldTestBase):
options = {
"npc_gifts": Toggle.option_true
"npc_gifts": Toggle.option_true,
"hidden_items": Toggle.option_true,
"require_itemfinder": Toggle.option_false
}
def test_inaccessible_with_no_surf(self) -> None:
self.assertFalse(self.can_reach_location(location_name_to_label("ITEM_PETALBURG_CITY_ETHER")))
self.assertFalse(self.can_reach_location(location_name_to_label("NPC_GIFT_RECEIVED_SOOTHE_BELL")))
self.assertFalse(self.can_reach_location(location_name_to_label("ITEM_LILYCOVE_CITY_MAX_REPEL")))
self.assertFalse(self.can_reach_location(location_name_to_label("HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2")))
self.assertFalse(self.can_reach_location(location_name_to_label("ITEM_ROUTE_120_FULL_HEAL")))
self.assertFalse(self.can_reach_entrance("REGION_ROUTE118/WATER -> REGION_ROUTE118/EAST"))
self.assertFalse(self.can_reach_entrance("REGION_ROUTE119/UPPER -> REGION_FORTREE_CITY/MAIN"))
self.assertFalse(self.can_reach_entrance("MAP_FORTREE_CITY:3/MAP_FORTREE_CITY_MART:0"))
@@ -60,6 +64,8 @@ class TestSurf(PokemonEmeraldTestBase):
self.assertTrue(self.can_reach_location(location_name_to_label("ITEM_PETALBURG_CITY_ETHER")))
self.assertTrue(self.can_reach_location(location_name_to_label("NPC_GIFT_RECEIVED_SOOTHE_BELL")))
self.assertTrue(self.can_reach_location(location_name_to_label("ITEM_LILYCOVE_CITY_MAX_REPEL")))
self.assertTrue(self.can_reach_location(location_name_to_label("HIDDEN_ITEM_ROUTE_120_RARE_CANDY_2")))
self.assertTrue(self.can_reach_location(location_name_to_label("ITEM_ROUTE_120_FULL_HEAL")))
self.assertTrue(self.can_reach_entrance("REGION_ROUTE118/WATER -> REGION_ROUTE118/EAST"))
self.assertTrue(self.can_reach_entrance("REGION_ROUTE119/UPPER -> REGION_FORTREE_CITY/MAIN"))
self.assertTrue(self.can_reach_entrance("MAP_FORTREE_CITY:3/MAP_FORTREE_CITY_MART:0"))

View File

@@ -414,7 +414,7 @@ class PokemonRedBlueWorld(World):
> 7) or (self.multiworld.door_shuffle[self.player] not in ("off", "simple")))):
intervene_move = "Cut"
elif ((not logic.can_learn_hm(test_state, "Flash", self.player)) and self.multiworld.dark_rock_tunnel_logic[self.player]
and (((self.multiworld.accessibility[self.player] != "minimal" and
and (((self.multiworld.accessibility[self.player] != "minimal" or
(self.multiworld.trainersanity[self.player] or self.multiworld.extra_key_items[self.player])) or
self.multiworld.door_shuffle[self.player]))):
intervene_move = "Flash"

View File

@@ -6,7 +6,7 @@ from NetUtils import ClientStatus
from worlds._bizhawk.client import BizHawkClient
from worlds._bizhawk import read, write, guarded_write
from worlds.pokemon_rb.locations import location_data
from .locations import location_data
logger = logging.getLogger("Client")

View File

@@ -1631,7 +1631,7 @@ def create_regions(self):
connect(multiworld, player, "Cerulean City", "Route 24", one_way=True)
connect(multiworld, player, "Cerulean City", "Cerulean City-T", lambda state: state.has("Help Bill", player))
connect(multiworld, player, "Cerulean City-Outskirts", "Cerulean City", one_way=True)
connect(multiworld, player, "Cerulean City-Outskirts", "Cerulean City", lambda state: logic.can_cut(state, player))
connect(multiworld, player, "Cerulean City", "Cerulean City-Outskirts", lambda state: logic.can_cut(state, player), one_way=True)
connect(multiworld, player, "Cerulean City-Outskirts", "Route 9", lambda state: logic.can_cut(state, player))
connect(multiworld, player, "Cerulean City-Outskirts", "Route 5")
connect(multiworld, player, "Cerulean Cave B1F", "Cerulean Cave B1F-E", lambda state: logic.can_surf(state, player), one_way=True)
@@ -1707,7 +1707,6 @@ def create_regions(self):
connect(multiworld, player, "Route 12-S", "Route 12-Grass", lambda state: logic.can_cut(state, player), one_way=True)
connect(multiworld, player, "Route 12-L", "Lavender Town")
connect(multiworld, player, "Route 10-S", "Lavender Town")
connect(multiworld, player, "Route 8-W", "Saffron City")
connect(multiworld, player, "Route 8", "Lavender Town")
connect(multiworld, player, "Pokemon Tower 6F", "Pokemon Tower 6F-S", lambda state: state.has("Silph Scope", player) or (state.has("Buy Poke Doll", player) and state.multiworld.poke_doll_skip[player]))
connect(multiworld, player, "Route 8", "Route 8-Grass", lambda state: logic.can_cut(state, player), one_way=True)
@@ -1831,7 +1830,8 @@ def create_regions(self):
connect(multiworld, player, "Silph Co 6F", "Silph Co 6F-SW", lambda state: logic.card_key(state, 6, player))
connect(multiworld, player, "Silph Co 7F", "Silph Co 7F-E", lambda state: logic.card_key(state, 7, player))
connect(multiworld, player, "Silph Co 7F-SE", "Silph Co 7F-E", lambda state: logic.card_key(state, 7, player))
connect(multiworld, player, "Silph Co 8F", "Silph Co 8F-W", lambda state: logic.card_key(state, 8, player))
connect(multiworld, player, "Silph Co 8F", "Silph Co 8F-W", lambda state: logic.card_key(state, 8, player), one_way=True, name="Silph Co 8F to Silph Co 8F-W (Card Key)")
connect(multiworld, player, "Silph Co 8F-W", "Silph Co 8F", lambda state: logic.card_key(state, 8, player), one_way=True, name="Silph Co 8F-W to Silph Co 8F (Card Key)")
connect(multiworld, player, "Silph Co 9F", "Silph Co 9F-SW", lambda state: logic.card_key(state, 9, player))
connect(multiworld, player, "Silph Co 9F-NW", "Silph Co 9F-SW", lambda state: logic.card_key(state, 9, player))
connect(multiworld, player, "Silph Co 10F", "Silph Co 10F-SE", lambda state: logic.card_key(state, 10, player))
@@ -1864,22 +1864,23 @@ def create_regions(self):
# access to any part of a city will enable flying to the Pokemon Center
connect(multiworld, player, "Cerulean City-Cave", "Cerulean City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Cerulean City-Badge House Backyard", "Cerulean City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Cerulean City-T", "Cerulean City", lambda state: logic.can_fly(state, player), one_way=True, name="Cerulean City-T to Cerulean City (Fly)")
connect(multiworld, player, "Fuchsia City-Good Rod House Backyard", "Fuchsia City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Saffron City-G", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Saffron City-Pidgey", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Saffron City-Silph", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Saffron City-Copycat", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Celadon City-G", "Celadon City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Vermilion City-G", "Vermilion City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Vermilion City-Dock", "Vermilion City", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Cinnabar Island-G", "Cinnabar Island", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Cinnabar Island-M", "Cinnabar Island", lambda state: logic.can_fly(state, player), one_way=True)
connect(multiworld, player, "Saffron City-G", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True, name="Saffron City-G to Saffron City (Fly)")
connect(multiworld, player, "Saffron City-Pidgey", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True, name="Saffron City-Pidgey to Saffron City (Fly)")
connect(multiworld, player, "Saffron City-Silph", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True, name="Saffron City-Silph to Saffron City (Fly)")
connect(multiworld, player, "Saffron City-Copycat", "Saffron City", lambda state: logic.can_fly(state, player), one_way=True, name="Saffron City-Copycat to Saffron City (Fly)")
connect(multiworld, player, "Celadon City-G", "Celadon City", lambda state: logic.can_fly(state, player), one_way=True, name="Celadon City-G to Celadon City (Fly)")
connect(multiworld, player, "Vermilion City-G", "Vermilion City", lambda state: logic.can_fly(state, player), one_way=True, name="Vermilion City-G to Vermilion City (Fly)")
connect(multiworld, player, "Vermilion City-Dock", "Vermilion City", lambda state: logic.can_fly(state, player), one_way=True, name="Vermilion City-Dock to Vermilion City (Fly)")
connect(multiworld, player, "Cinnabar Island-G", "Cinnabar Island", lambda state: logic.can_fly(state, player), one_way=True, name="Cinnabar Island-G to Cinnabar Island (Fly)")
connect(multiworld, player, "Cinnabar Island-M", "Cinnabar Island", lambda state: logic.can_fly(state, player), one_way=True, name="Cinnabar Island-M to Cinnabar Island (Fly)")
# drops
connect(multiworld, player, "Seafoam Islands 1F", "Seafoam Islands B1F", one_way=True)
connect(multiworld, player, "Seafoam Islands 1F", "Seafoam Islands B1F-NE", one_way=True)
connect(multiworld, player, "Seafoam Islands B1F", "Seafoam Islands B2F-NW", one_way=True)
connect(multiworld, player, "Seafoam Islands 1F", "Seafoam Islands B1F", one_way=True, name="Seafoam Islands 1F to Seafoam Islands B1F (Drop)")
connect(multiworld, player, "Seafoam Islands 1F", "Seafoam Islands B1F-NE", one_way=True, name="Seafoam Islands 1F to Seafoam Islands B1F-NE (Drop)")
connect(multiworld, player, "Seafoam Islands B1F", "Seafoam Islands B2F-NW", one_way=True, name="Seafoam Islands 1F to Seafoam Islands B2F-NW (Drop)")
connect(multiworld, player, "Seafoam Islands B1F-NE", "Seafoam Islands B2F-NE", one_way=True)
connect(multiworld, player, "Seafoam Islands B2F-NW", "Seafoam Islands B3F", lambda state: logic.can_strength(state, player) and state.has("Seafoam Exit Boulder", player, 6), one_way=True)
connect(multiworld, player, "Seafoam Islands B2F-NE", "Seafoam Islands B3F", lambda state: logic.can_strength(state, player) and state.has("Seafoam Exit Boulder", player, 6), one_way=True)
@@ -1888,7 +1889,7 @@ def create_regions(self):
# If you haven't dropped the boulders, you'll go straight to B4F
connect(multiworld, player, "Seafoam Islands B2F-NW", "Seafoam Islands B4F-W", one_way=True)
connect(multiworld, player, "Seafoam Islands B2F-NE", "Seafoam Islands B4F-W", one_way=True)
connect(multiworld, player, "Seafoam Islands B3F", "Seafoam Islands B4F", one_way=True)
connect(multiworld, player, "Seafoam Islands B3F", "Seafoam Islands B4F", one_way=True, name="Seafoam Islands B1F to Seafoam Islands B4F (Drop)")
connect(multiworld, player, "Seafoam Islands B3F", "Seafoam Islands B4F-W", lambda state: logic.can_surf(state, player), one_way=True)
connect(multiworld, player, "Pokemon Mansion 3F-SE", "Pokemon Mansion 2F", one_way=True)
connect(multiworld, player, "Pokemon Mansion 3F-SE", "Pokemon Mansion 1F-SE", one_way=True)
@@ -1944,7 +1945,8 @@ def create_regions(self):
connect(multiworld, player, region.name, entrance_data["to"]["map"],
lambda state: logic.rock_tunnel(state, player), one_way=True)
else:
connect(multiworld, player, region.name, entrance_data["to"]["map"], one_way=True)
connect(multiworld, player, region.name, entrance_data["to"]["map"], one_way=True,
name=entrance_data["name"] if "name" in entrance_data else None)
forced_connections = set()

View File

@@ -168,12 +168,12 @@ rom_addresses = {
"Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_0_ITEM": 0x1a61c,
"Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_1_ITEM": 0x1a62a,
"Trainersanity_EVENT_BEAT_SILPH_CO_6F_TRAINER_2_ITEM": 0x1a638,
"Event_SKC6F": 0x1a666,
"Warps_SilphCo6F": 0x1a741,
"Missable_Silph_Co_6F_Item_1": 0x1a791,
"Missable_Silph_Co_6F_Item_2": 0x1a798,
"Path_Pallet_Oak": 0x1a91e,
"Path_Pallet_Player": 0x1a92b,
"Event_SKC6F": 0x1a659,
"Warps_SilphCo6F": 0x1a737,
"Missable_Silph_Co_6F_Item_1": 0x1a787,
"Missable_Silph_Co_6F_Item_2": 0x1a78e,
"Path_Pallet_Oak": 0x1a914,
"Path_Pallet_Player": 0x1a921,
"Warps_CinnabarIsland": 0x1c026,
"Warps_Route1": 0x1c0e9,
"Option_Extra_Key_Items_B": 0x1ca46,

View File

@@ -103,12 +103,10 @@ class RiskOfRainWorld(World):
if self.options.dlc_sotv:
environment_offset_table = shift_by_offset(environment_sotv_table, environment_offset)
environments_pool = {**environments_pool, **environment_offset_table}
environments_to_precollect = 5 if self.options.begin_with_loop else 1
# percollect environments for each stage (or just stage 1)
for i in range(environments_to_precollect):
unlock = self.random.choices(list(environment_available_orderedstages_table[i].keys()), k=1)
self.multiworld.push_precollected(self.create_item(unlock[0]))
environments_pool.pop(unlock[0])
# percollect starting environment for stage 1
unlock = self.random.choices(list(environment_available_orderedstages_table[0].keys()), k=1)
self.multiworld.push_precollected(self.create_item(unlock[0]))
environments_pool.pop(unlock[0])
# Generate item pool
itempool: List[str] = ["Beads of Fealty", "Radar Scanner"]

View File

@@ -142,14 +142,6 @@ class FinalStageDeath(Toggle):
display_name = "Final Stage Death is Win"
class BeginWithLoop(Toggle):
"""
Enable to precollect a full loop of environments.
Only has an effect with Explore Mode.
"""
display_name = "Begin With Loop"
class DLC_SOTV(Toggle):
"""
Enable if you are using SOTV DLC.
@@ -385,7 +377,6 @@ class ROR2Options(PerGameCommonOptions):
total_revivals: TotalRevivals
start_with_revive: StartWithRevive
final_stage_death: FinalStageDeath
begin_with_loop: BeginWithLoop
dlc_sotv: DLC_SOTV
death_link: DeathLink
item_pickup_step: ItemPickupStep

View File

@@ -638,7 +638,7 @@ def set_mission_upgrade_rules_standard(multiworld: MultiWorld, world: World, pla
add_rule(multiworld.get_location(LocationName.radical_highway_omo_2, player),
lambda state: state.has(ItemName.shadow_air_shoes, player))
add_rule(multiworld.get_location(LocationName.weapons_bed_omo_2, player),
lambda state: state.has(ItemName.eggman_jet_engine, player))
lambda state: state.has(ItemName.eggman_large_cannon, player))
add_rule(multiworld.get_location(LocationName.mission_street_omo_3, player),
lambda state: state.has(ItemName.tails_booster, player))

View File

@@ -619,7 +619,7 @@ class SA2BWorld(World):
for name in name_list_base:
for char_idx in range(7):
if char_idx < len(name):
name_list_s.append(chao_name_conversion[name[char_idx]])
name_list_s.append(chao_name_conversion.get(name[char_idx], 0x5F))
else:
name_list_s.append(0x00)

Some files were not shown because too many files have changed in this diff Show More