Core: expose world version to world classes and yaml (#5484)

* support version on new manifest

* apply world version from manifest

* Update Generate.py

* docs

* reduce mm2 version again

* wrong version

* validate game in world_types

* Update Generate.py

* let unknown game fall through to later exception

* hide real world version behind property

* named tuple is immutable

* write minimum world version to template yaml, fix gen edge cases

* punctuation

* check for world version in autoworldregister

* missed one

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
This commit is contained in:
Silvris
2025-09-30 19:47:08 -05:00
committed by GitHub
parent 4893ac3e51
commit 33b485c0c3
9 changed files with 69 additions and 10 deletions

View File

@@ -486,7 +486,22 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if required_plando_options: if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, " raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
f"which is not enabled.") f"which is not enabled.")
games = requirements.get("game", {})
for game, version in games.items():
if game not in AutoWorldRegister.world_types:
continue
if not version:
raise Exception(f"Invalid version for game {game}: {version}.")
if isinstance(version, str):
version = {"min": version}
if "min" in version and tuplize_version(version["min"]) > AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is at least {version['min']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
if "max" in version and tuplize_version(version["max"]) < AutoWorldRegister.world_types[game].world_version:
raise Exception(f"Settings reports required version of world \"{game}\" is no later than {version['max']}, "
f"however world is of version "
f"{AutoWorldRegister.world_types[game].world_version.as_simple_string()}.")
ret = argparse.Namespace() ret = argparse.Namespace()
for option_key in Options.PerGameCommonOptions.type_hints: for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints: if option_key in weights and option_key not in Options.CommonOptions.type_hints:

View File

@@ -59,7 +59,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | " logger.info(f" {name:{longest_name}}: "
f"v{cls.world_version.as_simple_string()} |"
f"Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}") f"Locations: {len(cls.location_names):{location_count}}")
del item_count, location_count del item_count, location_count

View File

@@ -1710,7 +1710,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from jinja2 import Template from jinja2 import Template
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
from Utils import local_path, __version__ from Utils import local_path, __version__, tuplize_version
full_path: str full_path: str
@@ -1753,7 +1753,10 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
res = template.render( res = template.render(
option_groups=option_groups, option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, __version__=__version__,
game=game_name,
world_version=world.world_version.as_simple_string(),
yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
cleandoc=cleandoc, cleandoc=cleandoc,
) )

View File

@@ -33,6 +33,10 @@ description: {{ yaml_dump("Default %s Template" % game) }}
game: {{ yaml_dump(game) }} game: {{ yaml_dump(game) }}
requires: requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected. version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
{%- if world_version != "0.0.0" %}
game:
{{ yaml_dump(game) }}: {{ world_version }} # Version of the world required for this yaml to work as expected.
{%- endif %}
{%- macro range_option(option) %} {%- macro range_option(option) %}
# You can define additional values between the minimum and maximum values. # You can define additional values between the minimum and maximum values.

View File

@@ -12,7 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState from BaseClasses import CollectionState
from Utils import deprecate from Utils import Version
if TYPE_CHECKING: if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
@@ -75,6 +75,10 @@ class AutoWorldRegister(type):
if "required_client_version" in base.__dict__: if "required_client_version" in base.__dict__:
dct["required_client_version"] = max(dct["required_client_version"], dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["required_client_version"]) base.__dict__["required_client_version"])
if "world_version" in dct:
if dct["world_version"] != Version(0, 0, 0):
raise RuntimeError(f"{name} is attempting to set 'world_version' from within the class. world_version "
f"can only be set from manifest.")
# construct class # construct class
new_class = super().__new__(mcs, name, bases, dct) new_class = super().__new__(mcs, name, bases, dct)
@@ -337,6 +341,8 @@ class World(metaclass=AutoWorldRegister):
"""If loaded from a .apworld, this is the Path to it.""" """If loaded from a .apworld, this is the Path to it."""
__file__: ClassVar[str] __file__: ClassVar[str]
"""path it was loaded from""" """path it was loaded from"""
world_version: ClassVar[Version] = Version(0, 0, 0)
"""Optional world version loaded from archipelago.json"""
def __init__(self, multiworld: "MultiWorld", player: int): def __init__(self, multiworld: "MultiWorld", player: int):
assert multiworld is not None assert multiworld is not None

View File

@@ -7,10 +7,11 @@ import warnings
import zipimport import zipimport
import time import time
import dataclasses import dataclasses
import json
from typing import List from typing import List
from NetUtils import DataPackage from NetUtils import DataPackage
from Utils import local_path, user_path, Version, version_tuple from Utils import local_path, user_path, Version, version_tuple, tuplize_version
local_folder = os.path.dirname(__file__) local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds") user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
@@ -111,8 +112,25 @@ for world_source in world_sources:
else: else:
world_source.load() world_source.load()
from .AutoWorld import AutoWorldRegister from .AutoWorld import AutoWorldRegister
for world_source in world_sources:
if not world_source.is_zip:
# look for manifest
manifest = {}
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames:
if file.endswith("archipelago.json"):
manifest = json.load(open(os.path.join(dirpath, file), "r"))
break
if manifest:
break
game = manifest.get("game")
if game in AutoWorldRegister.world_types:
AutoWorldRegister.world_types[game].world_version = Version(*tuplize_version(manifest.get("world_version",
"0.0.0")))
if apworlds: if apworlds:
# encapsulation for namespace / gc purposes # encapsulation for namespace / gc purposes
def load_apworlds() -> None: def load_apworlds() -> None:
@@ -164,6 +182,10 @@ if apworlds:
add_as_failed_to_load=False) add_as_failed_to_load=False)
else: else:
apworld_source.load() apworld_source.load()
if apworld.game in AutoWorldRegister.world_types:
# world could fail to load at this point
if apworld.world_version:
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
load_apworlds() load_apworlds()
del load_apworlds del load_apworlds

View File

@@ -81,7 +81,8 @@ are `description`, `name`, `game`, `requires`, and the name of the games you wan
* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this * `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this
is good for detailing the version of Archipelago this YAML was prepared for. If it is rolled on an older version, is good for detailing the version of Archipelago this YAML was prepared for. If it is rolled on an older version,
options may be missing and as such it will not work as expected. If any plando is used in the file then requiring it options may be missing and as such it will not work as expected. If any plando is used in the file then requiring it
here to ensure it will be used is good practice. here to ensure it will be used is good practice. Specific versions of custom worlds can also be required, ensuring
that the generator is using a compatible version.
## Game Options ## Game Options
@@ -165,7 +166,9 @@ game:
A Link to the Past: 10 A Link to the Past: 10
Timespinner: 10 Timespinner: 10
requires: requires:
version: 0.4.1 version: 0.6.4
game:
A Link to the Past: 0.6.4
A Link to the Past: A Link to the Past:
accessibility: minimal accessibility: minimal
progression_balancing: 50 progression_balancing: 50
@@ -229,7 +232,7 @@ Timespinner:
* `name` is `Example Player` and this will be used in the server console when sending and receiving items. * `name` is `Example Player` and this will be used in the server console when sending and receiving items.
* `game` has an equal chance of being either `A Link to the Past` or `Timespinner` with a 10/20 chance for each. This is * `game` has an equal chance of being either `A Link to the Past` or `Timespinner` with a 10/20 chance for each. This is
because each game has a weight of 10 and the total of all weights is 20. because each game has a weight of 10 and the total of all weights is 20.
* `requires` is set to required release version 0.3.2 or higher. * `requires` is set to require Archipelago release version 0.6.4 or higher, as well as A Link to the Past version 0.6.4.
* `accessibility` for both games is set to `minimal` which will set this seed to beatable only, so some locations and * `accessibility` for both games is set to `minimal` which will set this seed to beatable only, so some locations and
items may be completely inaccessible but the seed will still be completable. items may be completely inaccessible but the seed will still be completable.
* `progression_balancing` for both games is set to 50, the default value, meaning we will likely receive important items * `progression_balancing` for both games is set to 50, the default value, meaning we will likely receive important items

View File

@@ -96,7 +96,6 @@ class MM2World(World):
location_name_groups = location_groups location_name_groups = location_groups
web = MM2WebWorld() web = MM2WebWorld()
rom_name: bytearray rom_name: bytearray
world_version: Tuple[int, int, int] = (0, 3, 2)
wily_5_weapons: Dict[int, List[int]] wily_5_weapons: Dict[int, List[int]]
def __init__(self, multiworld: MultiWorld, player: int): def __init__(self, multiworld: MultiWorld, player: int):

View File

@@ -0,0 +1,5 @@
{
"game": "Mega Man 2",
"world_version": "0.3.2",
"minimum_ap_version": "0.6.4"
}