Compare commits

...

8 Commits

Author SHA1 Message Date
NewSoupVi
d729b00b22 CODEOWNERS: Move OoT to "unmaintained"
https://discord.com/channels/731205301247803413/1214608557077700720/1253206955879694336

Espeon might come back, but still, this world acts as unmaintained right now, so we should make this change, and then change it back if/when he's back.

@espeon65536 Just so you're aware of this change as well
2024-09-06 18:35:50 +02:00
Danaël V.
f9fc6944d3 Docs: Removing #archipelago-dev from places (#3876)
* Cleaning up (#4)

Cleanup

* Changed channel name

* Changed channel name

* Update docs/world maintainer.md

* Update docs/world maintainer.md
2024-09-05 22:55:19 +02:00
qwint
e984583e5e HK: speed up collect (a bit) (#3886)
* speed up collect, will be obsolete after #3786

* vi's a meanie
2024-09-05 21:19:37 +02:00
Exempt-Medic
7e03a87608 DOCS: Option Visibility and removing SpecialRange (#3889)
* Update options api.md

* Update options api.md

* Update docs/options api.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

---------

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2024-09-05 21:18:58 +02:00
NewSoupVi
456bc481a3 Docs: Specify process for adding a world maintainer to an existing world (#3884)
* Docs: Specify process for adding a world maintainer to an existing world

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Update world maintainer.md

* Rewrite by BadMagic

* Update docs/world maintainer.md

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 21:16:44 +02:00
NewSoupVi
b4752cd32d The Witness: Implement "Variety" puzzles mode (#3239)
* Variety Rando (But WitnessLogicVariety.txt is wrong

* Actually variety the variety file (Ty Exempt-Medic <3)

* This will be preopened

* Tooltip explaining the different difficulties

* Remove ?, those were correct

* Less efficient but easier to follow

* Parentheses

* Fix some reqs

* Not Arrows in Variety

* Oops

* Happy medic, I made a wacky solution

* there we go

* Lint oops

* There

* that copy is unnecessary

* Turns out that copy is necessary still

* yes

* lol

* Rename to Umbra Variety

* missed one

* Erase the Eraser

* Fix remaining instances of 'variety' and don't have a symbol item on the gate in variety

* reorder difficulties

* inbetween

* ruff

* Fix Variety Invis requirements

* Fix wooden beams variety

* Fix PP2 variety

* Mirror changes from 'Variety Mode Puzzle Change 3.2.3'

* These also have Symmetry

* merge error prevention

* Update worlds/witness/data/static_items.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* no elif after return

* add variety to the symbol requirement bleed test

* Add variety to one of the 'other settings' unit tests

* Add Variety minimal symbols unittest

* oops

* I did the dumb again

* .

* Incorporate changes from other PR into WitnesLogicVariety.txt

* Update worlds/witness/data/WitnessLogicVariety.txt

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update worlds/witness/data/WitnessLogicVariety.txt

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update the reqs as well haha

* Another difference, thanks Medic :§

* Wait no, this one was right

* lol

* apply changes to WitnessLogicVariety.txt

* Add most recent Variety changes

* oof

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 17:10:09 +02:00
Fabian Dill
ceec51b9e1 Core: Region handling customization (#3682) 2024-09-05 16:32:45 +02:00
NewSoupVi
d3312287a8 Docs: Mention indirect_conditions and that they are a *hard requirement* (with a few sharp exception cases) (#3552)
* Docs: Mention indirect_conditions and that they are a *hard requirement* (with hard exception cases)

I definitely don't feel like I wrote this in the best way, or in the best place, but it is a precedent that I think is necessary so we can treat it as "the law of the land".

* oops

* Update world api.md

* Update world api.md

* Update world api.md

* Update docs/world api.md

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* I like within more here

* Update docs/world api.md

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update world api.md

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 13:53:34 +02:00
24 changed files with 1429 additions and 55 deletions

View File

@@ -692,17 +692,25 @@ class CollectionState():
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
self.stale[player] = False self.stale[player] = False
world: AutoWorld.World = self.multiworld.worlds[player]
reachable_regions = self.reachable_regions[player] reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
queue = deque(self.blocked_connections[player]) queue = deque(self.blocked_connections[player])
start = self.multiworld.get_region("Menu", player) start: Region = world.get_region(world.origin_region_name)
# init on first call - this can't be done on construction since the regions don't exist yet # init on first call - this can't be done on construction since the regions don't exist yet
if start not in reachable_regions: if start not in reachable_regions:
reachable_regions.add(start) reachable_regions.add(start)
blocked_connections.update(start.exits) self.blocked_connections[player].update(start.exits)
queue.extend(start.exits) queue.extend(start.exits)
if world.explicit_indirect_conditions:
self._update_reachable_regions_explicit_indirect_conditions(player, queue)
else:
self._update_reachable_regions_auto_indirect_conditions(player, queue)
def _update_reachable_regions_explicit_indirect_conditions(self, player: int, queue: deque):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
# run BFS on all connections, and keep track of those blocked by missing items # run BFS on all connections, and keep track of those blocked by missing items
while queue: while queue:
connection = queue.popleft() connection = queue.popleft()
@@ -722,6 +730,29 @@ class CollectionState():
if new_entrance in blocked_connections and new_entrance not in queue: if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance) queue.append(new_entrance)
def _update_reachable_regions_auto_indirect_conditions(self, player: int, queue: deque):
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
new_connection: bool = True
# run BFS on all connections, and keep track of those blocked by missing items
while new_connection:
new_connection = False
while queue:
connection = queue.popleft()
new_region = connection.connected_region
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"
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))
new_connection = True
# sweep for indirect connections, mostly Entrance.can_reach(unrelated_Region)
queue.extend(blocked_connections)
def copy(self) -> CollectionState: def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld) ret = CollectionState(self.multiworld)
ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()} ret.prog_items = {player: counter.copy() for player, counter in self.prog_items.items()}

View File

@@ -77,4 +77,4 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder: You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord. If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord.

View File

@@ -118,9 +118,6 @@
# Noita # Noita
/worlds/noita/ @ScipioWright @heinermann /worlds/noita/ @ScipioWright @heinermann
# Ocarina of Time
/worlds/oot/ @espeon65536
# Old School Runescape # Old School Runescape
/worlds/osrs @digiholic /worlds/osrs @digiholic
@@ -230,6 +227,9 @@
# Links Awakening DX # Links Awakening DX
# /worlds/ladx/ # /worlds/ladx/
# Ocarina of Time
# /worlds/oot/
## Disabled Unmaintained Worlds ## Disabled Unmaintained Worlds
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are # The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are

View File

@@ -24,7 +24,7 @@ display as `Value1` on the webhost.
files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a files, and both will resolve as `value1`. This should be used when changing options around, i.e. changing a Toggle to a
Choice, and defining `alias_true = option_full`. Choice, and defining `alias_true = option_full`.
- All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or - All options with a fixed set of possible values (i.e. those which inherit from `Toggle`, `(Text)Choice` or
`(Named/Special)Range`) support `random` as a generic option. `random` chooses from any of the available values for that `(Named)Range`) support `random` as a generic option. `random` chooses from any of the available values for that
option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`. option, and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
However, you can override `from_text` and handle `text == "random"` to customize its behavior or However, you can override `from_text` and handle `text == "random"` to customize its behavior or
implement it for additional option types. implement it for additional option types.
@@ -129,6 +129,23 @@ class Difficulty(Choice):
default = 1 default = 1
``` ```
### Option Visibility
Every option has a Visibility IntFlag, defaulting to `all` (`0b1111`). This lets you choose where the option will be
displayed. This only impacts where options are displayed, not how they can be used. Hidden options are still valid
options in a yaml. The flags are as follows:
* `none` (`0b0000`): This option is not shown anywhere
* `template` (`0b0001`): This option shows up in template yamls
* `simple_ui` (`0b0010`): This option shows up on the options page
* `complex_ui` (`0b0100`): This option shows up on the advanced/weighted options page
* `spoiler` (`0b1000`): This option shows up in spoiler logs
```python
from Options import Choice, Visibility
class HiddenChoiceOption(Choice):
visibility = Visibility.none
```
### Option Groups ### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment

View File

@@ -38,7 +38,7 @@ Recommended steps
* Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details. * Refer to [Windows Compilers on the python wiki](https://wiki.python.org/moin/WindowsCompilers) for details.
Generally, selecting the box for "Desktop Development with C++" will provide what you need. Generally, selecting the box for "Desktop Development with C++" will provide what you need.
* Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on * Build tools are not required if all modules are installed pre-compiled. Pre-compiled modules are pinned on
[Discord in #archipelago-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808) [Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/) * It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm * Run Generate.py which will prompt installation of missing modules, press enter to confirm

View File

@@ -303,6 +303,31 @@ generation (entrance randomization).
An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state` An access rule is a function that returns `True` or `False` for a `Location` or `Entrance` based on the current `state`
(items that have been collected). (items that have been collected).
The two possible ways to make a [CollectionRule](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10) are:
- `def rule(state: CollectionState) -> bool:`
- `lambda state: ... boolean expression ...`
An access rule can be assigned through `set_rule(location, rule)`.
Access rules usually check for one of two things.
- Items that have been collected (e.g. `state.has("Sword", player)`)
- Locations, Regions or Entrances that have been reached (e.g. `state.can_reach_region("Boss Room")`)
Keep in mind that entrances and locations implicitly check for the accessibility of their parent region, so you do not need to check explicitly for it.
#### An important note on Entrance access rules:
When using `state.can_reach` within an entrance access condition, you must also use `multiworld.register_indirect_condition`.
For efficiency reasons, every time reachable regions are searched, every entrance is only checked once in a somewhat non-deterministic order.
This is fine when checking for items using `state.has`, because items do not change during a region sweep.
However, `state.can_reach` checks for the very same thing we are updating: Regions.
This can lead to non-deterministic behavior and, in the worst case, even generation failures.
Even doing `state.can_reach_location` or `state.can_reach_entrance` is problematic, as these functions call `state.can_reach_region` on the respective parent region.
**Therefore, it is considered unsafe to perform `state.can_reach` from within an access condition for an entrance**, unless you are checking for something that sits in the source region of the entrance.
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
### Item Rules ### Item Rules
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
@@ -630,7 +655,7 @@ def set_rules(self) -> None:
Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or Custom methods can be defined for your logic rules. The access rule that ultimately gets assigned to the Location or
Entrance should be Entrance should be
a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L9). a [`CollectionRule`](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/generic/Rules.py#L10).
Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other Typically, this is done by defining a lambda expression on demand at the relevant bit, typically calling other
functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly. functions, but this can also be achieved by defining a method with the appropriate format and assigning it directly.
For an example, see [The Messenger](/worlds/messenger/rules.py). For an example, see [The Messenger](/worlds/messenger/rules.py).

View File

@@ -26,8 +26,17 @@ Unless these are shared between multiple people, we expect the following from ea
### Adding a World ### Adding a World
When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you When we merge your world into the core Archipelago repository, you automatically become world maintainer unless you
nominate someone else (i.e. there are multiple devs). You can define who is allowed to approve changes to your world nominate someone else (i.e. there are multiple devs).
in the [CODEOWNERS](/docs/CODEOWNERS) document.
### Being added as a maintainer to an existing implementation
At any point, a world maintainer can approve the addition of another maintainer to their world.
In order to do this, either an existing maintainer or the new maintainer must open a PR updating the
[CODEOWNERS](/docs/CODEOWNERS) file.
This change must be approved by all existing maintainers of the affected world, the new maintainer candidate, and
one core maintainer.
To help the core team review the change, information about the new maintainer and their contributions should be
included in the PR description.
### Getting Voted ### Getting Voted
@@ -35,7 +44,7 @@ When a world is unmaintained, the [core maintainers](https://github.com/orgs/Arc
can vote for a new maintainer if there is a candidate. can vote for a new maintainer if there is a candidate.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative. For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 1 week, but can end early if the majority is reached earlier. The time limit is 1 week, but can end early if the majority is reached earlier.
Voting shall be conducted on Discord in #archipelago-dev. Voting shall be conducted on Discord in #ap-core-dev.
## Dropping out ## Dropping out
@@ -51,7 +60,7 @@ for example when they become unreachable.
For a vote to pass, the majority of participating core maintainers must vote in the affirmative. For a vote to pass, the majority of participating core maintainers must vote in the affirmative.
The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and The time limit is 2 weeks, but can end early if the majority is reached earlier AND the world maintainer was pinged and
made their case or was pinged and has been unreachable for more than 2 weeks already. made their case or was pinged and has been unreachable for more than 2 weeks already.
Voting shall be conducted on Discord in #archipelago-dev. Commits that are a direct result of the voting shall include Voting shall be conducted on Discord in #ap-core-dev. Commits that are a direct result of the voting shall include
date, voting members and final result in the commit message. date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds ## Handling of Unmaintained Worlds

View File

@@ -292,6 +292,14 @@ class World(metaclass=AutoWorldRegister):
web: ClassVar[WebWorld] = WebWorld() web: ClassVar[WebWorld] = WebWorld()
"""see WebWorld for options""" """see WebWorld for options"""
origin_region_name: str = "Menu"
"""Name of the Region from which accessibility is tested."""
explicit_indirect_conditions: bool = True
"""If True, the world implementation is supposed to use MultiWorld.register_indirect_condition() correctly.
If False, everything is rechecked at every step, which is slower computationally,
but may be desirable in complex/dynamic worlds."""
multiworld: "MultiWorld" multiworld: "MultiWorld"
"""autoset on creation. The MultiWorld object for the currently generating multiworld.""" """autoset on creation. The MultiWorld object for the currently generating multiworld."""
player: int player: int

View File

@@ -101,6 +101,7 @@ class Factorio(World):
tech_tree_layout_prerequisites: typing.Dict[FactorioScienceLocation, typing.Set[FactorioScienceLocation]] tech_tree_layout_prerequisites: typing.Dict[FactorioScienceLocation, typing.Set[FactorioScienceLocation]]
tech_mix: int = 0 tech_mix: int = 0
skip_silo: bool = False skip_silo: bool = False
origin_region_name = "Nauvis"
science_locations: typing.List[FactorioScienceLocation] science_locations: typing.List[FactorioScienceLocation]
settings: typing.ClassVar[FactorioSettings] settings: typing.ClassVar[FactorioSettings]
@@ -125,9 +126,6 @@ class Factorio(World):
def create_regions(self): def create_regions(self):
player = self.player player = self.player
random = self.multiworld.random random = self.multiworld.random
menu = Region("Menu", player, self.multiworld)
crash = Entrance(player, "Crash Land", menu)
menu.exits.append(crash)
nauvis = Region("Nauvis", player, self.multiworld) nauvis = Region("Nauvis", player, self.multiworld)
location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \ location_count = len(base_tech_table) - len(useless_technologies) - self.skip_silo + \
@@ -184,8 +182,7 @@ class Factorio(World):
event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player) event = FactorioItem(f"Automated {ingredient}", ItemClassification.progression, None, player)
location.place_locked_item(event) location.place_locked_item(event)
crash.connect(nauvis) self.multiworld.regions.append(nauvis)
self.multiworld.regions += [menu, nauvis]
def create_items(self) -> None: def create_items(self) -> None:
player = self.player player = self.player

View File

@@ -601,11 +601,11 @@ class HKWorld(World):
if change: if change:
for effect_name, effect_value in item_effects.get(item.name, {}).items(): for effect_name, effect_value in item_effects.get(item.name, {}).items():
state.prog_items[item.player][effect_name] += effect_value state.prog_items[item.player][effect_name] += effect_value
if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}: if item.name in {"Left_Mothwing_Cloak", "Right_Mothwing_Cloak"}:
if state.prog_items[item.player].get('RIGHTDASH', 0) and \ if state.prog_items[item.player].get('RIGHTDASH', 0) and \
state.prog_items[item.player].get('LEFTDASH', 0): state.prog_items[item.player].get('LEFTDASH', 0):
(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"]) = \ (state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"]) = \
([max(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"])] * 2) ([max(state.prog_items[item.player]["RIGHTDASH"], state.prog_items[item.player]["LEFTDASH"])] * 2)
return change return change
def remove(self, state, item: HKItem) -> bool: def remove(self, state, item: HKItem) -> bool:

View File

@@ -45,7 +45,7 @@ class SubnauticaWorld(World):
options_dataclass = options.SubnauticaOptions options_dataclass = options.SubnauticaOptions
options: options.SubnauticaOptions options: options.SubnauticaOptions
required_client_version = (0, 5, 0) required_client_version = (0, 5, 0)
origin_region_name = "Planet 4546B"
creatures_to_scan: List[str] creatures_to_scan: List[str]
def generate_early(self) -> None: def generate_early(self) -> None:
@@ -66,13 +66,9 @@ class SubnauticaWorld(World):
creature_pool, self.options.creature_scans.value) creature_pool, self.options.creature_scans.value)
def create_regions(self): def create_regions(self):
# Create Regions # Create Region
menu_region = Region("Menu", self.player, self.multiworld)
planet_region = Region("Planet 4546B", self.player, self.multiworld) planet_region = Region("Planet 4546B", self.player, self.multiworld)
# Link regions together
menu_region.connect(planet_region, "Lifepod 5")
# Create regular locations # Create regular locations
location_names = itertools.chain((location["name"] for location in locations.location_table.values()), location_names = itertools.chain((location["name"] for location in locations.location_table.values()),
(creature + creatures.suffix for creature in self.creatures_to_scan)) (creature + creatures.suffix for creature in self.creatures_to_scan))
@@ -93,11 +89,8 @@ class SubnauticaWorld(World):
# make the goal event the victory "item" # make the goal event the victory "item"
location.item.name = "Victory" location.item.name = "Victory"
# Register regions to multiworld # Register region to multiworld
self.multiworld.regions += [ self.multiworld.regions.append(planet_region)
menu_region,
planet_region
]
# refer to rules.py # refer to rules.py
set_rules = set_rules set_rules = set_rules

View File

@@ -204,11 +204,10 @@ class WitnessWorld(World):
] ]
if early_items: if early_items:
random_early_item = self.random.choice(early_items) random_early_item = self.random.choice(early_items)
if ( mode = self.options.puzzle_randomization
self.options.puzzle_randomization == "sigma_expert" if mode == "sigma_expert" or mode == "umbra_variety" or self.options.victory_condition == "panel_hunt":
or self.options.victory_condition == "panel_hunt" # In Expert and Variety, only tag the item as early, rather than forcing it onto the gate.
): # Same with panel hunt, since the Tutorial Gate Open panel is used for something else
# In Expert and Panel Hunt, only tag the item as early, rather than forcing it onto the gate.
self.multiworld.local_early_items[self.player][random_early_item] = 1 self.multiworld.local_early_items[self.player][random_early_item] = 1
else: else:
# Force the item onto the tutorial gate check and remove it from our random pool. # Force the item onto the tutorial gate check and remove it from our random pool.

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,22 @@ ITEM_GROUPS: Dict[str, Set[str]] = {}
# item list during get_progression_items. # item list during get_progression_items.
_special_usefuls: List[str] = ["Puzzle Skip"] _special_usefuls: List[str] = ["Puzzle Skip"]
ALWAYS_GOOD_SYMBOL_ITEMS: Set[str] = {"Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"}
MODE_SPECIFIC_GOOD_ITEMS: Dict[str, Set[str]] = {
"none": set(),
"sigma_normal": set(),
"sigma_expert": {"Triangles"},
"umbra_variety": {"Triangles"}
}
MODE_SPECIFIC_GOOD_DISCARD_ITEMS: Dict[str, Set[str]] = {
"none": {"Triangles"},
"sigma_normal": {"Triangles"},
"sigma_expert": {"Arrows"},
"umbra_variety": set() # Variety Discards use both Arrows and Triangles, so neither of them are that useful alone
}
def populate_items() -> None: def populate_items() -> None:
for item_name, definition in static_witness_logic.ALL_ITEMS.items(): for item_name, definition in static_witness_logic.ALL_ITEMS.items():

View File

@@ -17,6 +17,7 @@ from .utils import (
get_items, get_items,
get_sigma_expert_logic, get_sigma_expert_logic,
get_sigma_normal_logic, get_sigma_normal_logic,
get_umbra_variety_logic,
get_vanilla_logic, get_vanilla_logic,
logical_or_witness_rules, logical_or_witness_rules,
parse_lambda, parse_lambda,
@@ -292,6 +293,11 @@ def get_sigma_expert() -> StaticWitnessLogicObj:
return StaticWitnessLogicObj(get_sigma_expert_logic()) return StaticWitnessLogicObj(get_sigma_expert_logic())
@cache_argsless
def get_umbra_variety() -> StaticWitnessLogicObj:
return StaticWitnessLogicObj(get_umbra_variety_logic())
def __getattr__(name: str) -> StaticWitnessLogicObj: def __getattr__(name: str) -> StaticWitnessLogicObj:
if name == "vanilla": if name == "vanilla":
return get_vanilla() return get_vanilla()
@@ -299,6 +305,8 @@ def __getattr__(name: str) -> StaticWitnessLogicObj:
return get_sigma_normal() return get_sigma_normal()
if name == "sigma_expert": if name == "sigma_expert":
return get_sigma_expert() return get_sigma_expert()
if name == "umbra_variety":
return get_umbra_variety()
raise AttributeError(f"module '{__name__}' has no attribute '{name}'") raise AttributeError(f"module '{__name__}' has no attribute '{name}'")

View File

@@ -215,6 +215,10 @@ def get_sigma_expert_logic() -> List[str]:
return get_adjustment_file("WitnessLogicExpert.txt") return get_adjustment_file("WitnessLogicExpert.txt")
def get_umbra_variety_logic() -> List[str]:
return get_adjustment_file("WitnessLogicVariety.txt")
def get_vanilla_logic() -> List[str]: def get_vanilla_logic() -> List[str]:
return get_adjustment_file("WitnessLogicVanilla.txt") return get_adjustment_file("WitnessLogicVanilla.txt")

View File

@@ -53,7 +53,7 @@ def get_always_hint_items(world: "WitnessWorld") -> List[str]:
wincon = world.options.victory_condition wincon = world.options.victory_condition
if discards: if discards:
if difficulty == "sigma_expert": if difficulty == "sigma_expert" or difficulty == "umbra_variety":
always.append("Arrows") always.append("Arrows")
else: else:
always.append("Triangles") always.append("Triangles")

View File

@@ -250,10 +250,15 @@ class PanelHuntDiscourageSameAreaFactor(Range):
class PuzzleRandomization(Choice): class PuzzleRandomization(Choice):
""" """
Puzzles in this randomizer are randomly generated. This option changes the difficulty/types of puzzles. Puzzles in this randomizer are randomly generated. This option changes the difficulty/types of puzzles.
"Sigma Normal" randomizes puzzles close to their original mechanics and difficulty.
"Sigma Expert" is an entirely new experience with extremely difficult random puzzles. Do not underestimate this mode, it is brutal.
"Umbra Variety" focuses on unique symbol combinations not featured in the original game. It is harder than Sigma Normal, but easier than Sigma Expert.
"None" means that the puzzles are unchanged from the original game.
""" """
display_name = "Puzzle Randomization" display_name = "Puzzle Randomization"
option_sigma_normal = 0 option_sigma_normal = 0
option_sigma_expert = 1 option_sigma_expert = 1
option_umbra_variety = 3
option_none = 2 option_none = 2

View File

@@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Dict, List, Set, cast
from BaseClasses import Item, ItemClassification, MultiWorld from BaseClasses import Item, ItemClassification, MultiWorld
from .data import static_items as static_witness_items from .data import static_items as static_witness_items
from .data import static_logic as static_witness_logic
from .data.item_definition_classes import ( from .data.item_definition_classes import (
DoorItemDefinition, DoorItemDefinition,
ItemCategory, ItemCategory,
@@ -155,16 +154,12 @@ class WitnessPlayerItems:
""" """
output: Set[str] = set() output: Set[str] = set()
if self._world.options.shuffle_symbols: if self._world.options.shuffle_symbols:
output = {"Dots", "Black/White Squares", "Symmetry", "Shapers", "Stars"} discards_on = self._world.options.shuffle_discarded_panels
mode = self._world.options.puzzle_randomization.current_key
if self._world.options.shuffle_discarded_panels: output = static_witness_items.ALWAYS_GOOD_SYMBOL_ITEMS | static_witness_items.MODE_SPECIFIC_GOOD_ITEMS[mode]
if self._world.options.puzzle_randomization == "sigma_expert": if discards_on:
output.add("Arrows") output |= static_witness_items.MODE_SPECIFIC_GOOD_DISCARD_ITEMS[mode]
else:
output.add("Triangles")
# Replace progressive items with their parents.
output = {static_witness_logic.get_parent_progressive_item(item) for item in output}
# Remove items that are mentioned in any plando options. (Hopefully, in the future, plando will get resolved # Remove items that are mentioned in any plando options. (Hopefully, in the future, plando will get resolved
# before create_items so that we'll be able to check placed items instead of just removing all items mentioned # before create_items so that we'll be able to check placed items instead of just removing all items mentioned

View File

@@ -87,12 +87,14 @@ class WitnessPlayerLogic:
self.DIFFICULTY = world.options.puzzle_randomization self.DIFFICULTY = world.options.puzzle_randomization
self.REFERENCE_LOGIC: StaticWitnessLogicObj self.REFERENCE_LOGIC: StaticWitnessLogicObj
if self.DIFFICULTY == "sigma_expert": if self.DIFFICULTY == "sigma_normal":
self.REFERENCE_LOGIC = static_witness_logic.sigma_normal
elif self.DIFFICULTY == "sigma_expert":
self.REFERENCE_LOGIC = static_witness_logic.sigma_expert self.REFERENCE_LOGIC = static_witness_logic.sigma_expert
elif self.DIFFICULTY == "umbra_variety":
self.REFERENCE_LOGIC = static_witness_logic.umbra_variety
elif self.DIFFICULTY == "none": elif self.DIFFICULTY == "none":
self.REFERENCE_LOGIC = static_witness_logic.vanilla self.REFERENCE_LOGIC = static_witness_logic.vanilla
else:
self.REFERENCE_LOGIC = static_witness_logic.sigma_normal
self.CONNECTIONS_BY_REGION_NAME_THEORETICAL: Dict[str, Set[Tuple[str, WitnessRule]]] = copy.deepcopy( self.CONNECTIONS_BY_REGION_NAME_THEORETICAL: Dict[str, Set[Tuple[str, WitnessRule]]] = copy.deepcopy(
self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME self.REFERENCE_LOGIC.STATIC_CONNECTIONS_BY_REGION_NAME

View File

@@ -30,6 +30,8 @@ class WitnessPlayerRegions:
self.reference_logic = static_witness_logic.sigma_normal self.reference_logic = static_witness_logic.sigma_normal
elif difficulty == "sigma_expert": elif difficulty == "sigma_expert":
self.reference_logic = static_witness_logic.sigma_expert self.reference_logic = static_witness_logic.sigma_expert
elif difficulty == "umbra_variety":
self.reference_logic = static_witness_logic.umbra_variety
else: else:
self.reference_logic = static_witness_logic.vanilla self.reference_logic = static_witness_logic.vanilla

View File

@@ -96,6 +96,39 @@ class TestSymbolsRequiredToWinElevatorVanilla(WitnessTestBase):
self.assert_can_beat_with_minimally(exact_requirement) self.assert_can_beat_with_minimally(exact_requirement)
class TestSymbolsRequiredToWinElevatorVariety(WitnessTestBase):
options = {
"shuffle_lasers": True,
"mountain_lasers": 1,
"victory_condition": "elevator",
"early_symbol_item": False,
"puzzle_randomization": "umbra_variety",
}
def test_symbols_to_win(self) -> None:
"""
In symbol shuffle, the only way to reach the Elevator is through Mountain Entry by descending the Mountain.
This requires a very specific set of symbol items per puzzle randomization mode.
In this case, we check Variety Puzzles.
"""
exact_requirement = {
"Monastery Laser": 1,
"Progressive Dots": 2,
"Progressive Stars": 2,
"Progressive Symmetry": 1,
"Black/White Squares": 1,
"Colored Squares": 1,
"Shapers": 1,
"Rotated Shapers": 1,
"Eraser": 1,
"Triangles": 1,
"Arrows": 1,
}
self.assert_can_beat_with_minimally(exact_requirement)
class TestPanelsRequiredToWinElevator(WitnessTestBase): class TestPanelsRequiredToWinElevator(WitnessTestBase):
options = { options = {
"shuffle_lasers": True, "shuffle_lasers": True,

View File

@@ -54,6 +54,7 @@ class TestMaxEntityShuffle(WitnessTestBase):
class TestPostgameGroupedDoors(WitnessTestBase): class TestPostgameGroupedDoors(WitnessTestBase):
options = { options = {
"puzzle_randomization": "umbra_variety",
"shuffle_postgame": True, "shuffle_postgame": True,
"shuffle_discarded_panels": True, "shuffle_discarded_panels": True,
"shuffle_doors": "doors", "shuffle_doors": "doors",

View File

@@ -46,6 +46,9 @@ class TestSymbolRequirementsMultiworld(WitnessMultiworldTestBase):
{ {
"puzzle_randomization": "none", "puzzle_randomization": "none",
}, },
{
"puzzle_randomization": "umbra_variety",
}
] ]
common_options = { common_options = {
@@ -63,12 +66,15 @@ class TestSymbolRequirementsMultiworld(WitnessMultiworldTestBase):
self.assertFalse(self.get_items_by_name("Arrows", 1)) self.assertFalse(self.get_items_by_name("Arrows", 1))
self.assertTrue(self.get_items_by_name("Arrows", 2)) self.assertTrue(self.get_items_by_name("Arrows", 2))
self.assertFalse(self.get_items_by_name("Arrows", 3)) self.assertFalse(self.get_items_by_name("Arrows", 3))
self.assertTrue(self.get_items_by_name("Arrows", 4))
with self.subTest("Test that Discards ask for Triangles in normal, but Arrows in expert."): with self.subTest("Test that Discards ask for Triangles in normal, but Arrows in expert."):
desert_discard = "0x17CE7" desert_discard = "0x17CE7"
triangles = frozenset({frozenset({"Triangles"})}) triangles = frozenset({frozenset({"Triangles"})})
arrows = frozenset({frozenset({"Arrows"})}) arrows = frozenset({frozenset({"Arrows"})})
both = frozenset({frozenset({"Triangles", "Arrows"})})
self.assertEqual(self.multiworld.worlds[1].player_logic.REQUIREMENTS_BY_HEX[desert_discard], triangles) self.assertEqual(self.multiworld.worlds[1].player_logic.REQUIREMENTS_BY_HEX[desert_discard], triangles)
self.assertEqual(self.multiworld.worlds[2].player_logic.REQUIREMENTS_BY_HEX[desert_discard], arrows) self.assertEqual(self.multiworld.worlds[2].player_logic.REQUIREMENTS_BY_HEX[desert_discard], arrows)
self.assertEqual(self.multiworld.worlds[3].player_logic.REQUIREMENTS_BY_HEX[desert_discard], triangles) self.assertEqual(self.multiworld.worlds[3].player_logic.REQUIREMENTS_BY_HEX[desert_discard], triangles)
self.assertEqual(self.multiworld.worlds[4].player_logic.REQUIREMENTS_BY_HEX[desert_discard], both)