Skip to content

Commit

Permalink
Merge branch 'main' into lttp_bomb_logic_2
Browse files Browse the repository at this point in the history
# Conflicts:
#	worlds/alttp/__init__.py
  • Loading branch information
Alchav committed Dec 11, 2023
2 parents 32ab793 + 13122ab commit f0ec73b
Show file tree
Hide file tree
Showing 70 changed files with 879 additions and 376 deletions.
15 changes: 11 additions & 4 deletions BaseClasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@ def has_beaten_game(self, state: CollectionState, player: Optional[int] = None)
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
Expand All @@ -504,7 +504,7 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None):
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:
Expand All @@ -524,12 +524,19 @@ def can_beat_game(self, starting_state: Optional[CollectionState] = None):

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):
Expand Down
41 changes: 20 additions & 21 deletions Fill.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,36 +550,36 @@ 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.
# Define a threshold value based on the player with the most available locations.
# 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]
Expand Down Expand Up @@ -658,7 +658,7 @@ def item_percentage(player: int, num: int) -> float:
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):
Expand All @@ -675,7 +675,7 @@ def item_percentage(player: int, num: int) -> float:
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()
Expand All @@ -687,42 +687,41 @@ def item_percentage(player: int, num: int) -> float:

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)
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
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):
Expand All @@ -736,7 +735,7 @@ def item_percentage(player: int, num: int) -> float:
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.")
Expand Down
11 changes: 11 additions & 0 deletions Main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 6 additions & 0 deletions WebHostLib/customserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ async def main():
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):
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion WebHostLib/static/assets/weighted-options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)){
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/pageWrapper.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/supportedGames.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ <h2 class="collapse-toggle" data-game="{{ game_name }}">
{% 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>
Expand Down
8 changes: 4 additions & 4 deletions ZillionClient.py
Original file line number Diff line number Diff line change
@@ -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, \
Expand All @@ -10,7 +10,7 @@
import Utils
from Utils import async_start

import colorama # type: ignore
import colorama

from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
Expand Down Expand Up @@ -45,7 +45,7 @@ def __call__(self, rooms: List[List[int]]) -> None: ...

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]
Expand Down Expand Up @@ -278,7 +278,7 @@ def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
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)
Expand Down
2 changes: 1 addition & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
90 changes: 90 additions & 0 deletions docs/tests.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions docs/world api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand All @@ -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).
Loading

0 comments on commit f0ec73b

Please sign in to comment.