Core: allow loading worlds from zip modules (#747)

* Core: allow loading worlds from zip modules
RoR2: make it zipimport compatible (remove relative imports beyond local top-level)

* WebHost: add support for .apworld
This commit is contained in:
Fabian Dill 2022-08-15 23:52:03 +02:00 committed by GitHub
parent 086295adbb
commit ca83905d9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 104 additions and 46 deletions

View File

@ -20,8 +20,7 @@ import Utils
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client") Utils.init_logging("FactorioClient", exception_logger="Client")
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \ from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
get_base_parser
from MultiServer import mark_raw from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart

View File

@ -42,20 +42,40 @@ def get_app():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json import json
import shutil import shutil
from worlds.AutoWorld import AutoWorldRegister import pathlib
import zipfile
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister, __file__
worlds = {} worlds = {}
data = [] data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs') target_path = os.path.join(base_target_path, game)
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game) os.makedirs(target_path, exist_ok=True)
if world.is_zip:
zipfile_path = pathlib.Path(world.__file__).parents[1]
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
with zipfile.ZipFile(zipfile_path) as zf:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path) files = os.listdir(source_path)
for file in files: for file in files:
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file)) shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game # build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []} game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials: for tutorial in world.web.tutorials:

View File

@ -2,10 +2,13 @@ from __future__ import annotations
import logging import logging
import sys import sys
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, TYPE_CHECKING
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
from Options import Option from Options import Option
from BaseClasses import CollectionState
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial
class AutoWorldRegister(type): class AutoWorldRegister(type):
@ -41,8 +44,11 @@ class AutoWorldRegister(type):
# construct class # construct class
new_class = super().__new__(mcs, name, bases, dct) new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct: if "game" in dct:
if dct["game"] in AutoWorldRegister.world_types:
raise RuntimeError(f"""Game {dct["game"]} already registered.""")
AutoWorldRegister.world_types[dct["game"]] = new_class AutoWorldRegister.world_types[dct["game"]] = new_class
new_class.__file__ = sys.modules[new_class.__module__].__file__ new_class.__file__ = sys.modules[new_class.__module__].__file__
new_class.is_zip = ".apworld" in new_class.__file__
return new_class return new_class
@ -62,12 +68,12 @@ class AutoLogicRegister(type):
return new_class return new_class
def call_single(world: MultiWorld, method_name: str, player: int, *args: Any) -> Any: def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
method = getattr(world.worlds[player], method_name) method = getattr(world.worlds[player], method_name)
return method(*args) return method(*args)
def call_all(world: MultiWorld, method_name: str, *args: Any) -> None: def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None:
world_types: Set[AutoWorldRegister] = set() world_types: Set[AutoWorldRegister] = set()
for player in world.player_ids: for player in world.player_ids:
world_types.add(world.worlds[player].__class__) world_types.add(world.worlds[player].__class__)
@ -79,7 +85,7 @@ def call_all(world: MultiWorld, method_name: str, *args: Any) -> None:
stage_callable(world, *args) stage_callable(world, *args)
def call_stage(world: MultiWorld, method_name: str, *args: Any) -> None: def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None:
world_types = {world.worlds[player].__class__ for player in world.player_ids} world_types = {world.worlds[player].__class__ for player in world.player_ids}
for world_type in world_types: for world_type in world_types:
stage_callable = getattr(world_type, f"stage_{method_name}", None) stage_callable = getattr(world_type, f"stage_{method_name}", None)
@ -97,7 +103,7 @@ class WebWorld:
# docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial # docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial
# class is to be used for one guide. # class is to be used for one guide.
tutorials: List[Tutorial] tutorials: List["Tutorial"]
# Choose a theme for your /game/* pages # Choose a theme for your /game/* pages
# Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone # Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone
@ -159,8 +165,11 @@ class World(metaclass=AutoWorldRegister):
# Hide World Type from various views. Does not remove functionality. # Hide World Type from various views. Does not remove functionality.
hidden: bool = False hidden: bool = False
# see WebWorld for options
web: WebWorld = WebWorld()
# autoset on creation: # autoset on creation:
world: MultiWorld world: "MultiWorld"
player: int player: int
# automatically generated # automatically generated
@ -170,9 +179,10 @@ class World(metaclass=AutoWorldRegister):
item_names: Set[str] # set of all potential item names item_names: Set[str] # set of all potential item names
location_names: Set[str] # set of all potential location names location_names: Set[str] # set of all potential location names
web: WebWorld = WebWorld() is_zip: bool # was loaded from a .apworld ?
__file__: str # path it was loaded from
def __init__(self, world: MultiWorld, player: int): def __init__(self, world: "MultiWorld", player: int):
self.world = world self.world = world
self.player = player self.player = player
@ -207,12 +217,12 @@ class World(metaclass=AutoWorldRegister):
@classmethod @classmethod
def fill_hook(cls, def fill_hook(cls,
progitempool: List[Item], progitempool: List["Item"],
nonexcludeditempool: List[Item], nonexcludeditempool: List["Item"],
localrestitempool: Dict[int, List[Item]], localrestitempool: Dict[int, List["Item"]],
nonlocalrestitempool: Dict[int, List[Item]], nonlocalrestitempool: Dict[int, List["Item"]],
restitempool: List[Item], restitempool: List["Item"],
fill_locations: List[Location]) -> None: fill_locations: List["Location"]) -> None:
"""Special method that gets called as part of distribute_items_restrictive (main fill). """Special method that gets called as part of distribute_items_restrictive (main fill).
This gets called once per present world type.""" This gets called once per present world type."""
pass pass
@ -250,7 +260,7 @@ class World(metaclass=AutoWorldRegister):
# end of ordered Main.py calls # end of ordered Main.py calls
def create_item(self, name: str) -> Item: def create_item(self, name: str) -> "Item":
"""Create an item for this world type and player. """Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer""" Warning: this may be called with self.world = None, for example by MultiServer"""
raise NotImplementedError raise NotImplementedError
@ -261,7 +271,7 @@ class World(metaclass=AutoWorldRegister):
return self.world.random.choice(tuple(self.item_name_to_id.keys())) return self.world.random.choice(tuple(self.item_name_to_id.keys()))
# decent place to implement progressive items, in most cases can stay as-is # decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: CollectionState, item: Item, remove: bool = False) -> Optional[str]: def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]:
"""Collect an item name into state. For speed reasons items that aren't logically useful get skipped. """Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
Collect None to skip item. Collect None to skip item.
:param state: CollectionState to collect into :param state: CollectionState to collect into
@ -272,18 +282,18 @@ class World(metaclass=AutoWorldRegister):
return None return None
# called to create all_state, return Items that are created during pre_fill # called to create all_state, return Items that are created during pre_fill
def get_pre_fill_items(self) -> List[Item]: def get_pre_fill_items(self) -> List["Item"]:
return [] return []
# following methods should not need to be overridden. # following methods should not need to be overridden.
def collect(self, state: CollectionState, item: Item) -> bool: def collect(self, state: "CollectionState", item: "Item") -> bool:
name = self.collect_item(state, item) name = self.collect_item(state, item)
if name: if name:
state.prog_items[name, self.player] += 1 state.prog_items[name, self.player] += 1
return True return True
return False return False
def remove(self, state: CollectionState, item: Item) -> bool: def remove(self, state: "CollectionState", item: "Item") -> bool:
name = self.collect_item(state, item, True) name = self.collect_item(state, item, True)
if name: if name:
state.prog_items[name, self.player] -= 1 state.prog_items[name, self.player] -= 1
@ -292,7 +302,7 @@ class World(metaclass=AutoWorldRegister):
return True return True
return False return False
def create_filler(self) -> Item: def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name()) return self.create_item(self.get_filler_item_name())

View File

@ -1,29 +1,57 @@
import importlib import importlib
import zipimport
import os import os
import typing
__all__ = {"lookup_any_item_id_to_name", folder = os.path.dirname(__file__)
__all__ = {
"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name", "lookup_any_location_id_to_name",
"network_data_package", "network_data_package",
"AutoWorldRegister"} "AutoWorldRegister",
"world_sources",
"folder",
}
if typing.TYPE_CHECKING:
from .AutoWorld import World
class WorldSource(typing.NamedTuple):
path: str # typically relative path from this module
is_zip: bool = False
# find potential world containers, currently folders and zip-importable .apworld's
world_sources: typing.List[WorldSource] = []
file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly
for file in os.scandir(folder):
if not file.name.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders
if file.is_dir():
world_sources.append(WorldSource(file.name))
elif file.is_file() and file.name.endswith(".apworld"):
world_sources.append(WorldSource(file.name, is_zip=True))
# import all submodules to trigger AutoWorldRegister # import all submodules to trigger AutoWorldRegister
world_folders = [] world_sources.sort()
for file in os.scandir(os.path.dirname(__file__)): for world_source in world_sources:
if file.is_dir(): if world_source.is_zip:
world_folders.append(file.name)
world_folders.sort() importer = zipimport.zipimporter(os.path.join(folder, world_source.path))
for world in world_folders: importer.load_module(world_source.path.split(".", 1)[0])
if not world.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders else:
importlib.import_module(f".{world}", "worlds") importlib.import_module(f".{world_source.path}", "worlds")
from .AutoWorld import AutoWorldRegister
lookup_any_item_id_to_name = {} lookup_any_item_id_to_name = {}
lookup_any_location_id_to_name = {} lookup_any_location_id_to_name = {}
games = {} games = {}
from .AutoWorld import AutoWorldRegister
for world_name, world in AutoWorldRegister.world_types.items(): for world_name, world in AutoWorldRegister.world_types.items():
games[world_name] = { games[world_name] = {
"item_name_to_id" : world.item_name_to_id, "item_name_to_id": world.item_name_to_id,
"location_name_to_id": world.location_name_to_id, "location_name_to_id": world.location_name_to_id,
"version": world.data_version, "version": world.data_version,
# seems clients don't actually want this. Keeping it here in case someone changes their mind. # seems clients don't actually want this. Keeping it here in case someone changes their mind.
@ -41,5 +69,6 @@ network_data_package = {
if any(not world.data_version for world in AutoWorldRegister.world_types.values()): if any(not world.data_version for world in AutoWorldRegister.world_types.values()):
network_data_package["version"] = 0 network_data_package["version"] = 0
import logging import logging
logging.warning(f"Datapackage is in custom mode. Custom Worlds: " logging.warning(f"Datapackage is in custom mode. Custom Worlds: "
f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}") f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}")

View File

@ -1,5 +1,5 @@
from BaseClasses import MultiWorld from BaseClasses import MultiWorld
from ..generic.Rules import set_rule, add_rule from worlds.generic.Rules import set_rule, add_rule
def set_rules(world: MultiWorld, player: int): def set_rules(world: MultiWorld, player: int):

View File

@ -5,7 +5,7 @@ from .Rules import set_rules
from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial from BaseClasses import Region, RegionType, Entrance, Item, ItemClassification, MultiWorld, Tutorial
from .Options import ror2_options from .Options import ror2_options
from ..AutoWorld import World, WebWorld from worlds.AutoWorld import World, WebWorld
client_version = 1 client_version = 1