Skip to content

Commit

Permalink
Merge branch 'refs/heads/main' into fork/per_game_datapackage
Browse files Browse the repository at this point in the history
# Conflicts:
#	worlds/AutoWorld.py
  • Loading branch information
ThePhar committed May 23, 2024
2 parents e608684 + e1ff507 commit db0500b
Show file tree
Hide file tree
Showing 32 changed files with 544 additions and 315 deletions.
2 changes: 1 addition & 1 deletion WebHostLib/templates/playerOptions/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
<div class="option-entry">
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}" value="{{ group_name }}" {{ "checked" if grop_name in option.default }} />
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}" value="{{ group_name }}" {{ "checked" if group_name in option.default }} />
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
</div>
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion WebHostLib/templates/weightedOptions/macros.html
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
{% for group_name in world.location_name_groups.keys()|sort %}
{% if group_name != "Everywhere" %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}||{{ group_name }}" value="1" {{ "checked" if grop_name in option.default }} />
<input type="checkbox" id="{{ option_name }}-{{ group_name }}" name="{{ option_name }}||{{ group_name }}" value="1" {{ "checked" if group_name in option.default }} />
<label for="{{ option_name }}-{{ group_name }}">{{ group_name }}</label>
</div>
{% endif %}
Expand Down
107 changes: 47 additions & 60 deletions docs/world api.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,53 @@ class RLWeb(WebWorld):
# ...
```

* `location_descriptions` (optional) WebWorlds can provide a map that contains human-friendly descriptions of locations
or location groups.

```python
# locations.py
location_descriptions = {
"Red Potion #6": "In a secret destructible block under the second stairway",
"L2 Spaceship": """
The group of all items in the spaceship in Level 2.
This doesn't include the item on the spaceship door, since it can be
accessed without the Spaceship Key.
"""
}

# __init__.py
from worlds.AutoWorld import WebWorld
from .locations import location_descriptions


class MyGameWeb(WebWorld):
location_descriptions = location_descriptions
```

* `item_descriptions` (optional) WebWorlds can provide a map that contains human-friendly descriptions of items or item
groups.

```python
# items.py
item_descriptions = {
"Red Potion": "A standard health potion",
"Spaceship Key": """
The key to the spaceship in Level 2.
This is necessary to get to the Star Realm.
""",
}

# __init__.py
from worlds.AutoWorld import WebWorld
from .items import item_descriptions


class MyGameWeb(WebWorld):
item_descriptions = item_descriptions
```

### MultiWorld Object

The `MultiWorld` object references the whole multiworld (all items and locations for all players) and is accessible
Expand Down Expand Up @@ -178,36 +225,6 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
required, and will prevent progression and useful items from being placed at excluded locations.

#### Documenting Locations

Worlds can optionally provide a `location_descriptions` map which contains human-friendly descriptions of locations and
location groups. These descriptions will show up in location-selection options on the options pages.

```python
# locations.py

location_descriptions = {
"Red Potion #6": "In a secret destructible block under the second stairway",
"L2 Spaceship":
"""
The group of all items in the spaceship in Level 2.
This doesn't include the item on the spaceship door, since it can be accessed without the Spaceship Key.
"""
}
```

```python
# __init__.py

from worlds.AutoWorld import World
from .locations import location_descriptions


class MyGameWorld(World):
location_descriptions = location_descriptions
```

### Items

Items are all things that can "drop" for your game. This may be RPG items like weapons, or technologies you normally
Expand All @@ -232,36 +249,6 @@ Other classifications include:
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres

#### Documenting Items

Worlds can optionally provide an `item_descriptions` map which contains human-friendly descriptions of items and item
groups. These descriptions will show up in item-selection options on the options pages.

```python
# items.py

item_descriptions = {
"Red Potion": "A standard health potion",
"Spaceship Key":
"""
The key to the spaceship in Level 2.
This is necessary to get to the Star Realm.
"""
}
```

```python
# __init__.py

from worlds.AutoWorld import World
from .items import item_descriptions


class MyGameWorld(World):
item_descriptions = item_descriptions
```

### Events

An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
Expand Down
9 changes: 0 additions & 9 deletions test/general/test_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,6 @@ def test_items_in_datapackage(self):
for item in multiworld.itempool:
self.assertIn(item.name, world_type.item_name_to_id)

def test_item_descriptions_have_valid_names(self):
"""Ensure all item descriptions match an item name or item group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
self.assertIn(name, valid_names,
"All item descriptions must match defined item names")

def test_itempool_not_modified(self):
"""Test that worlds don't modify the itempool after `create_items`"""
gen_steps = ("generate_early", "create_regions", "create_items")
Expand Down
9 changes: 0 additions & 9 deletions test/general/test_locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,3 @@ def test_location_group(self):
for location in locations:
self.assertIn(location, world_type.location_name_to_id)
self.assertNotIn(group_name, world_type.location_name_to_id)

def test_location_descriptions_have_valid_names(self):
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
self.assertIn(name, valid_names,
"All location descriptions must match defined location names")
23 changes: 23 additions & 0 deletions test/webhost/test_descriptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import unittest

from worlds.AutoWorld import AutoWorldRegister


class TestWebDescriptions(unittest.TestCase):
def test_item_descriptions_have_valid_names(self) -> None:
"""Ensure all item descriptions match an item name or item group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.web.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
self.assertIn(name, valid_names,
"All item descriptions must match defined item names")

def test_location_descriptions_have_valid_names(self) -> None:
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.web.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
self.assertIn(name, valid_names,
"All location descriptions must match defined location names")
48 changes: 10 additions & 38 deletions worlds/AutoWorld.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
import hashlib
import logging
import pathlib
from random import Random
import re
import sys
import time
from random import Random
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping,
Optional, Set, TextIO, Tuple, TYPE_CHECKING, Type, Union)
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)

from Options import (
ExcludeLocations, ItemLinks, LocalItems, NonLocalItems, OptionGroup, PerGameCommonOptions,
Expand Down Expand Up @@ -55,17 +54,12 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["item_descriptions"] = {name: _normalize_description(description) for name, description
in dct.get("item_descriptions", {}).items()}
dct["item_descriptions"]["Everything"] = "All items in the entire game."

dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
dct["location_descriptions"] = {name: _normalize_description(description) for name, description
in dct.get("location_descriptions", {}).items()}
dct["location_descriptions"]["Everywhere"] = "All locations in the entire game."

# move away from get_required_client_version function
if "game" in dct:
Expand Down Expand Up @@ -226,6 +220,12 @@ class WebWorld(metaclass=WebWorldRegister):
option_groups: ClassVar[List[OptionGroup]] = []
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""

location_descriptions: Dict[str, str] = {}
"""An optional map from location names (or location group names) to brief descriptions for users."""

item_descriptions: Dict[str, str] = {}
"""An optional map from item names (or item group names) to brief descriptions for users."""


class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
Expand All @@ -252,23 +252,9 @@ class World(metaclass=AutoWorldRegister):
item_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}"""

item_descriptions: ClassVar[Dict[str, str]] = {}
"""An optional map from item names (or item group names) to brief descriptions for users.
Individual newlines and indentation will be collapsed into spaces before these descriptions are
displayed. This may cover only a subset of items.
"""

location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""

location_descriptions: ClassVar[Dict[str, str]] = {}
"""An optional map from location names (or location group names) to brief descriptions for users.
Individual newlines and indentation will be collapsed into spaces before these descriptions are
displayed. This may cover only a subset of locations.
"""

required_client_version: Tuple[int, int, int] = (0, 1, 6)
"""
override this if changes to a world break forward-compatibility of the client
Expand Down Expand Up @@ -559,17 +545,3 @@ def data_package_checksum(data: "GamesPackage") -> str:
assert sorted(data) == list(data), "Data not ordered"
from NetUtils import encode
return hashlib.sha1(encode(data).encode()).hexdigest()


def _normalize_description(description):
"""
Normalizes a description in item_descriptions or location_descriptions.
This allows authors to write descritions with nice indentation and line lengths in their world
definitions without having it affect the rendered format.
"""
# First, collapse the whitespace around newlines and the ends of the description.
description = re.sub(r' *\n *', '\n', description.strip())
# Next, condense individual newlines into spaces.
description = re.sub(r'(?<!\n)\n(?!\n)', ' ', description)
return description
2 changes: 1 addition & 1 deletion worlds/_bizhawk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ async def connect(ctx: BizHawkContext) -> bool:
return True
except (TimeoutError, ConnectionRefusedError):
continue

# No ports worked
ctx.streams = None
ctx.connection_status = ConnectionStatus.NOT_CONNECTED
Expand Down
14 changes: 6 additions & 8 deletions worlds/_bizhawk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
A module containing the BizHawkClient base class and metaclass
"""


from __future__ import annotations

import abc
Expand All @@ -12,14 +11,13 @@

if TYPE_CHECKING:
from .context import BizHawkClientContext
else:
BizHawkClientContext = object


def launch_client(*args) -> None:
from .context import launch
launch_subprocess(launch, name="BizHawkClient")


component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
file_identifier=SuffixIdentifier())
components.append(component)
Expand Down Expand Up @@ -56,7 +54,7 @@ def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any])
return new_class

@staticmethod
async def get_handler(ctx: BizHawkClientContext, system: str) -> Optional[BizHawkClient]:
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
if system in systems:
for handler in handlers.values():
Expand All @@ -77,7 +75,7 @@ class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
"""The file extension(s) this client is meant to open and patch (e.g. ".apz3")"""

@abc.abstractmethod
async def validate_rom(self, ctx: BizHawkClientContext) -> bool:
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
"""Should return whether the currently loaded ROM should be handled by this client. You might read the game name
from the ROM header, for example. This function will only be asked to validate ROMs from the system set by the
client class, so you do not need to check the system yourself.
Expand All @@ -86,18 +84,18 @@ async def validate_rom(self, ctx: BizHawkClientContext) -> bool:
as necessary (such as setting `ctx.game = self.game`, modifying `ctx.items_handling`, etc...)."""
...

async def set_auth(self, ctx: BizHawkClientContext) -> None:
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
"""Should set ctx.auth in anticipation of sending a `Connected` packet. You may override this if you store slot
name in your patched ROM. If ctx.auth is not set after calling, the player will be prompted to enter their
username."""
pass

@abc.abstractmethod
async def game_watcher(self, ctx: BizHawkClientContext) -> None:
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
"""Runs on a loop with the approximate interval `ctx.watcher_timeout`. The currently loaded ROM is guaranteed
to have passed your validator when this function is called, and the emulator is very likely to be connected."""
...

def on_package(self, ctx: BizHawkClientContext, cmd: str, args: dict) -> None:
def on_package(self, ctx: "BizHawkClientContext", cmd: str, args: dict) -> None:
"""For handling packages from the server. Called from `BizHawkClientContext.on_package`."""
pass
8 changes: 4 additions & 4 deletions worlds/_bizhawk/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
checking or launching the client, otherwise it will probably cause circular import issues.
"""


import asyncio
import enum
import subprocess
Expand Down Expand Up @@ -77,7 +76,7 @@ def on_package(self, cmd, args):
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)

async def server_auth(self, password_requested: bool = False):
async def server_auth(self, password_requested: bool=False):
self.password_requested = password_requested

if self.bizhawk_ctx.connection_status != ConnectionStatus.CONNECTED:
Expand All @@ -103,7 +102,7 @@ async def server_auth(self, password_requested: bool = False):
await self.send_connect()
self.auth_status = AuthStatus.PENDING

async def disconnect(self, allow_autoreconnect: bool = False):
async def disconnect(self, allow_autoreconnect: bool=False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED
await super().disconnect(allow_autoreconnect)

Expand Down Expand Up @@ -148,7 +147,8 @@ async def _game_watcher(ctx: BizHawkClientContext):
script_version = await get_script_version(ctx.bizhawk_ctx)

if script_version != EXPECTED_SCRIPT_VERSION:
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but got {script_version}. Disconnecting.")
logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but "
f"got {script_version}. Disconnecting.")
disconnect(ctx.bizhawk_ctx)
continue

Expand Down
Loading

0 comments on commit db0500b

Please sign in to comment.