mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 19:33:25 -07:00
added zillion again...
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -201,6 +201,7 @@ Thumbs.db
|
||||
<<<<<<< HEAD
|
||||
/worlds/zillion
|
||||
/worlds/zillion
|
||||
<<<<<<< HEAD
|
||||
worlds/zillion/__init__.py
|
||||
worlds/zillion/__init__.py
|
||||
worlds/zillion/__init__.py
|
||||
@@ -224,3 +225,5 @@ worlds/zillion/test/TestOptions.py
|
||||
worlds/zillion/test/TestReproducibleRandom.py
|
||||
=======
|
||||
>>>>>>> parent of 10ea81dc (Update .gitignore)
|
||||
=======
|
||||
>>>>>>> parent of fe196db0 (Removed zillion because it doesn't work)
|
||||
|
||||
441
worlds/zillion/__init__.py
Normal file
441
worlds/zillion/__init__.py
Normal file
@@ -0,0 +1,441 @@
|
||||
from collections import deque, Counter
|
||||
from contextlib import redirect_stdout
|
||||
import functools
|
||||
import settings
|
||||
import threading
|
||||
import typing
|
||||
from typing import Any, Dict, List, Set, Tuple, Optional
|
||||
import os
|
||||
import logging
|
||||
|
||||
from BaseClasses import ItemClassification, LocationProgressType, \
|
||||
MultiWorld, Item, CollectionState, Entrance, Tutorial
|
||||
|
||||
from .gen_data import GenData
|
||||
from .logic import cs_to_zz_locs
|
||||
from .region import ZillionLocation, ZillionRegion
|
||||
from .options import ZillionOptions, validate, z_option_groups
|
||||
from .id_maps import ZillionSlotInfo, get_slot_info, item_name_to_id as _item_name_to_id, \
|
||||
loc_name_to_id as _loc_name_to_id, make_id_to_others, \
|
||||
zz_reg_name_to_reg_name, base_id
|
||||
from .item import ZillionItem
|
||||
from .patch import ZillionPatch
|
||||
|
||||
from zilliandomizer.randomizer import Randomizer as ZzRandomizer
|
||||
from zilliandomizer.system import System
|
||||
from zilliandomizer.logic_components.items import RESCUE, items as zz_items, Item as ZzItem
|
||||
from zilliandomizer.logic_components.locations import Location as ZzLocation, Req
|
||||
from zilliandomizer.options import Chars
|
||||
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
|
||||
|
||||
class ZillionSettings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the Zillion US rom"""
|
||||
description = "Zillion US ROM File"
|
||||
copy_to = "Zillion (UE) [!].sms"
|
||||
assert ZillionPatch.hash
|
||||
md5s = [ZillionPatch.hash]
|
||||
|
||||
class RomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching)
|
||||
True for operating system default program
|
||||
Alternatively, a path to a program to open the .sfc file with
|
||||
RetroArch doesn't make it easy to launch a game from the command line.
|
||||
You have to know the path to the emulator core library on the user's computer.
|
||||
"""
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: typing.Union[RomStart, bool] = RomStart("retroarch")
|
||||
|
||||
|
||||
class ZillionWebWorld(WebWorld):
|
||||
theme = "stone"
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to playing Zillion randomizer.",
|
||||
"English",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["beauxq"]
|
||||
)]
|
||||
|
||||
option_groups = z_option_groups
|
||||
|
||||
|
||||
class ZillionWorld(World):
|
||||
"""
|
||||
Zillion is a metroidvania style game released in 1987 for the 8-bit Sega Master System.
|
||||
|
||||
It's based on the anime Zillion (赤い光弾ジリオン, Akai Koudan Zillion).
|
||||
"""
|
||||
game = "Zillion"
|
||||
web = ZillionWebWorld()
|
||||
|
||||
options_dataclass = ZillionOptions
|
||||
options: ZillionOptions # type: ignore
|
||||
|
||||
settings: typing.ClassVar[ZillionSettings] # type: ignore
|
||||
# these type: ignore are because of this issue: https://github.com/python/typing/discussions/1486
|
||||
|
||||
topology_present = True # indicate if world type has any meaningful layout/pathing
|
||||
|
||||
# map names to their IDs
|
||||
item_name_to_id = _item_name_to_id
|
||||
location_name_to_id = _loc_name_to_id
|
||||
|
||||
# increment this every time something in your world's names/id mappings changes.
|
||||
# While this is set to 0 in *any* AutoWorld, the entire DataPackage is considered in testing mode and will be
|
||||
# retrieved by clients on every connection.
|
||||
data_version = 1
|
||||
|
||||
logger: logging.Logger
|
||||
|
||||
class LogStreamInterface:
|
||||
logger: logging.Logger
|
||||
buffer: List[str]
|
||||
|
||||
def __init__(self, logger: logging.Logger) -> None:
|
||||
self.logger = logger
|
||||
self.buffer = []
|
||||
|
||||
def write(self, msg: str) -> None:
|
||||
if msg.endswith('\n'):
|
||||
self.buffer.append(msg[:-1])
|
||||
self.logger.debug("".join(self.buffer))
|
||||
self.buffer = []
|
||||
else:
|
||||
self.buffer.append(msg)
|
||||
|
||||
def flush(self) -> None:
|
||||
pass
|
||||
|
||||
lsi: LogStreamInterface
|
||||
|
||||
id_to_zz_item: Optional[Dict[int, ZzItem]] = None
|
||||
zz_system: System
|
||||
_item_counts: "Counter[str]" = Counter()
|
||||
"""
|
||||
These are the items counts that will be in the game,
|
||||
which might be different from the item counts the player asked for in options
|
||||
(if the player asked for something invalid).
|
||||
"""
|
||||
my_locations: List[ZillionLocation] = []
|
||||
""" This is kind of a cache to avoid iterating through all the multiworld locations in logic. """
|
||||
slot_data_ready: threading.Event
|
||||
""" This event is set in `generate_output` when the data is ready for `fill_slot_data` """
|
||||
|
||||
def __init__(self, world: MultiWorld, player: int):
|
||||
super().__init__(world, player)
|
||||
self.logger = logging.getLogger("Zillion")
|
||||
self.lsi = ZillionWorld.LogStreamInterface(self.logger)
|
||||
self.zz_system = System()
|
||||
self.slot_data_ready = threading.Event()
|
||||
|
||||
def _make_item_maps(self, start_char: Chars) -> None:
|
||||
_id_to_name, _id_to_zz_id, id_to_zz_item = make_id_to_others(start_char)
|
||||
self.id_to_zz_item = id_to_zz_item
|
||||
|
||||
def generate_early(self) -> None:
|
||||
if not hasattr(self.multiworld, "zillion_logic_cache"):
|
||||
setattr(self.multiworld, "zillion_logic_cache", {})
|
||||
|
||||
zz_op, item_counts = validate(self.options)
|
||||
|
||||
if zz_op.early_scope:
|
||||
self.multiworld.early_items[self.player]["Scope"] = 1
|
||||
|
||||
self._item_counts = item_counts
|
||||
|
||||
with redirect_stdout(self.lsi): # type: ignore
|
||||
self.zz_system.make_randomizer(zz_op)
|
||||
|
||||
self.zz_system.seed(self.multiworld.seed)
|
||||
self.zz_system.make_map()
|
||||
|
||||
# just in case the options changed anything (I don't think they do)
|
||||
assert self.zz_system.randomizer, "init failed"
|
||||
for zz_name in self.zz_system.randomizer.locations:
|
||||
if zz_name != 'main':
|
||||
assert self.zz_system.randomizer.loc_name_2_pretty[zz_name] in self.location_name_to_id, \
|
||||
f"{self.zz_system.randomizer.loc_name_2_pretty[zz_name]} not in location map"
|
||||
|
||||
self._make_item_maps(zz_op.start_char)
|
||||
|
||||
def create_regions(self) -> None:
|
||||
assert self.zz_system.randomizer, "generate_early hasn't been called"
|
||||
assert self.id_to_zz_item, "generate_early hasn't been called"
|
||||
p = self.player
|
||||
w = self.multiworld
|
||||
self.my_locations = []
|
||||
|
||||
self.zz_system.randomizer.place_canister_gun_reqs()
|
||||
# low probability that place_canister_gun_reqs() results in empty 1st sphere
|
||||
# testing code to force low probability event:
|
||||
# for zz_room_name in ["r01c2", "r02c0", "r02c7", "r03c5"]:
|
||||
# for zz_loc in self.zz_system.randomizer.regions[zz_room_name].locations:
|
||||
# zz_loc.req.gun = 2
|
||||
if len(self.zz_system.randomizer.get_locations(Req(gun=1, jump=1))) == 0:
|
||||
self.logger.info("Zillion avoided rare empty 1st sphere.")
|
||||
for zz_loc in self.zz_system.randomizer.regions["r03c5"].locations:
|
||||
zz_loc.req.gun = 1
|
||||
assert len(self.zz_system.randomizer.get_locations(Req(gun=1, jump=1))) != 0
|
||||
|
||||
start = self.zz_system.randomizer.regions['start']
|
||||
|
||||
all: Dict[str, ZillionRegion] = {}
|
||||
for here_zz_name, zz_r in self.zz_system.randomizer.regions.items():
|
||||
here_name = "Menu" if here_zz_name == "start" else zz_reg_name_to_reg_name(here_zz_name)
|
||||
all[here_name] = ZillionRegion(zz_r, here_name, here_name, p, w)
|
||||
self.multiworld.regions.append(all[here_name])
|
||||
|
||||
limited_skill = Req(gun=3, jump=3, skill=self.zz_system.randomizer.options.skill, hp=940, red=1, floppy=126)
|
||||
queue = deque([start])
|
||||
done: Set[str] = set()
|
||||
while len(queue):
|
||||
zz_here = queue.popleft()
|
||||
here_name = "Menu" if zz_here.name == "start" else zz_reg_name_to_reg_name(zz_here.name)
|
||||
if here_name in done:
|
||||
continue
|
||||
here = all[here_name]
|
||||
|
||||
for zz_loc in zz_here.locations:
|
||||
# if local gun reqs didn't place "keyword" item
|
||||
if not zz_loc.item:
|
||||
|
||||
def access_rule_wrapped(zz_loc_local: ZzLocation,
|
||||
p: int,
|
||||
zz_r: ZzRandomizer,
|
||||
id_to_zz_item: Dict[int, ZzItem],
|
||||
cs: CollectionState) -> bool:
|
||||
accessible = cs_to_zz_locs(cs, p, zz_r, id_to_zz_item)
|
||||
return zz_loc_local in accessible
|
||||
|
||||
access_rule = functools.partial(access_rule_wrapped,
|
||||
zz_loc, self.player, self.zz_system.randomizer, self.id_to_zz_item)
|
||||
|
||||
loc_name = self.zz_system.randomizer.loc_name_2_pretty[zz_loc.name]
|
||||
loc = ZillionLocation(zz_loc, self.player, loc_name, here)
|
||||
loc.access_rule = access_rule
|
||||
if not (limited_skill >= zz_loc.req):
|
||||
loc.progress_type = LocationProgressType.EXCLUDED
|
||||
self.options.exclude_locations.value.add(loc.name)
|
||||
here.locations.append(loc)
|
||||
self.my_locations.append(loc)
|
||||
|
||||
for zz_dest in zz_here.connections.keys():
|
||||
dest_name = "Menu" if zz_dest.name == 'start' else zz_reg_name_to_reg_name(zz_dest.name)
|
||||
dest = all[dest_name]
|
||||
exit = Entrance(p, f"{here_name} to {dest_name}", here)
|
||||
here.exits.append(exit)
|
||||
exit.connect(dest)
|
||||
|
||||
queue.append(zz_dest)
|
||||
done.add(here.name)
|
||||
|
||||
def create_items(self) -> None:
|
||||
if not self.id_to_zz_item:
|
||||
self._make_item_maps("JJ")
|
||||
self.logger.warning("warning: called `create_items` without calling `generate_early` first")
|
||||
assert self.id_to_zz_item, "failed to get item maps"
|
||||
|
||||
# in zilliandomizer, the Randomizer class puts empties in the item pool to fill space,
|
||||
# but here in AP, empties are in the options from options.validate
|
||||
item_counts = self._item_counts
|
||||
self.logger.debug(item_counts)
|
||||
|
||||
for item_name, item_id in self.item_name_to_id.items():
|
||||
zz_item = self.id_to_zz_item[item_id]
|
||||
if item_id >= (4 + base_id): # normal item
|
||||
if item_name in item_counts:
|
||||
count = item_counts[item_name]
|
||||
self.logger.debug(f"Zillion Items: {item_name} {count}")
|
||||
self.multiworld.itempool += [self.create_item(item_name) for _ in range(count)]
|
||||
elif item_id < (3 + base_id) and zz_item.code == RESCUE:
|
||||
# One of the 3 rescues will not be in the pool and its zz_item will be 'empty'.
|
||||
self.logger.debug(f"Zillion Items: {item_name} 1")
|
||||
self.multiworld.itempool.append(self.create_item(item_name))
|
||||
|
||||
def set_rules(self) -> None:
|
||||
# logic for this game is in create_regions
|
||||
pass
|
||||
|
||||
def generate_basic(self) -> None:
|
||||
assert self.zz_system.randomizer, "generate_early hasn't been called"
|
||||
# main location name is an alias
|
||||
main_loc_name = self.zz_system.randomizer.loc_name_2_pretty[self.zz_system.randomizer.locations['main'].name]
|
||||
|
||||
self.multiworld.get_location(main_loc_name, self.player)\
|
||||
.place_locked_item(self.create_item("Win"))
|
||||
self.multiworld.completion_condition[self.player] = \
|
||||
lambda state: state.has("Win", self.player)
|
||||
|
||||
@staticmethod
|
||||
def stage_generate_basic(multiworld: MultiWorld, *args: Any) -> None:
|
||||
# item link pools are about to be created in main
|
||||
# JJ can't be an item link unless all the players share the same start_char
|
||||
# (The reason for this is that the JJ ZillionItem will have a different ZzItem depending
|
||||
# on whether the start char is Apple or Champ, and the logic depends on that ZzItem.)
|
||||
for group in multiworld.groups.values():
|
||||
# TODO: remove asserts on group when we can specify which members of TypedDict are optional
|
||||
assert "game" in group
|
||||
if group["game"] == "Zillion":
|
||||
assert "item_pool" in group
|
||||
item_pool = group["item_pool"]
|
||||
to_stay: Chars = "JJ"
|
||||
if "JJ" in item_pool:
|
||||
assert "players" in group
|
||||
group_players = group["players"]
|
||||
players_start_chars: List[Tuple[int, Chars]] = []
|
||||
for player in group_players:
|
||||
z_world = multiworld.worlds[player]
|
||||
assert isinstance(z_world, ZillionWorld)
|
||||
players_start_chars.append((player, z_world.options.start_char.get_char()))
|
||||
start_char_counts = Counter(sc for _, sc in players_start_chars)
|
||||
# majority rules
|
||||
if start_char_counts["Apple"] > start_char_counts["Champ"]:
|
||||
to_stay = "Apple"
|
||||
elif start_char_counts["Champ"] > start_char_counts["Apple"]:
|
||||
to_stay = "Champ"
|
||||
else: # equal
|
||||
choices: Tuple[Chars, ...] = ("Apple", "Champ")
|
||||
to_stay = multiworld.random.choice(choices)
|
||||
|
||||
for p, sc in players_start_chars:
|
||||
if sc != to_stay:
|
||||
group_players.remove(p)
|
||||
assert "world" in group
|
||||
group_world = group["world"]
|
||||
assert isinstance(group_world, ZillionWorld)
|
||||
group_world._make_item_maps(to_stay)
|
||||
|
||||
def post_fill(self) -> None:
|
||||
"""Optional Method that is called after regular fill. Can be used to do adjustments before output generation.
|
||||
This happens before progression balancing, so the items may not be in their final locations yet."""
|
||||
|
||||
self.zz_system.post_fill()
|
||||
|
||||
def finalize_item_locations(self) -> GenData:
|
||||
"""
|
||||
sync zilliandomizer item locations with AP item locations
|
||||
|
||||
return the data needed to generate output
|
||||
"""
|
||||
|
||||
assert self.zz_system.randomizer, "generate_early hasn't been called"
|
||||
|
||||
# debug_zz_loc_ids: Dict[str, int] = {}
|
||||
empty = zz_items[4]
|
||||
multi_item = empty # a different patcher method differentiates empty from ap multi item
|
||||
multi_items: Dict[str, Tuple[str, str]] = {} # zz_loc_name to (item_name, player_name)
|
||||
for z_loc in self.multiworld.get_locations(self.player):
|
||||
assert isinstance(z_loc, ZillionLocation)
|
||||
# debug_zz_loc_ids[z_loc.zz_loc.name] = id(z_loc.zz_loc)
|
||||
if z_loc.item is None:
|
||||
self.logger.warning("generate_output location has no item - is that ok?")
|
||||
z_loc.zz_loc.item = empty
|
||||
elif z_loc.item.player == self.player:
|
||||
z_item = z_loc.item
|
||||
assert isinstance(z_item, ZillionItem)
|
||||
z_loc.zz_loc.item = z_item.zz_item
|
||||
else: # another player's item
|
||||
# print(f"put multi item in {z_loc.zz_loc.name}")
|
||||
z_loc.zz_loc.item = multi_item
|
||||
multi_items[z_loc.zz_loc.name] = (
|
||||
z_loc.item.name,
|
||||
self.multiworld.get_player_name(z_loc.item.player)
|
||||
)
|
||||
# debug_zz_loc_ids.sort()
|
||||
# for name, id_ in debug_zz_loc_ids.items():
|
||||
# print(id_)
|
||||
# print("size:", len(debug_zz_loc_ids))
|
||||
|
||||
# debug_loc_to_id: Dict[str, int] = {}
|
||||
# regions = self.zz_randomizer.regions
|
||||
# for region in regions.values():
|
||||
# for loc in region.locations:
|
||||
# if loc.name not in self.zz_randomizer.locations:
|
||||
# print(f"region {region.name} had location {loc.name} not in locations")
|
||||
# debug_loc_to_id[loc.name] = id(loc)
|
||||
|
||||
# verify that every location got an item
|
||||
for zz_loc in self.zz_system.randomizer.locations.values():
|
||||
assert zz_loc.item, (
|
||||
f"location {self.zz_system.randomizer.loc_name_2_pretty[zz_loc.name]} "
|
||||
f"in world {self.player} didn't get an item"
|
||||
)
|
||||
|
||||
game_id = self.multiworld.player_name[self.player].encode() + b'\x00' + self.multiworld.seed_name[-6:].encode()
|
||||
|
||||
return GenData(multi_items, self.zz_system.get_game(), game_id)
|
||||
|
||||
def generate_output(self, output_directory: str) -> None:
|
||||
"""This method gets called from a threadpool, do not use multiworld.random here.
|
||||
If you need any last-second randomization, use self.random instead."""
|
||||
try:
|
||||
gen_data = self.finalize_item_locations()
|
||||
except BaseException:
|
||||
raise
|
||||
finally:
|
||||
self.slot_data_ready.set()
|
||||
|
||||
out_file_base = self.multiworld.get_out_file_name_base(self.player)
|
||||
|
||||
patch_file_name = os.path.join(output_directory, f"{out_file_base}{ZillionPatch.patch_file_ending}")
|
||||
patch = ZillionPatch(patch_file_name,
|
||||
player=self.player,
|
||||
player_name=self.multiworld.player_name[self.player],
|
||||
gen_data_str=gen_data.to_json())
|
||||
patch.write()
|
||||
|
||||
self.logger.debug(f"Zillion player {self.player} finished generate_output")
|
||||
|
||||
def fill_slot_data(self) -> ZillionSlotInfo: # json of WebHostLib.models.Slot
|
||||
"""Fill in the `slot_data` field in the `Connected` network package.
|
||||
This is a way the generator can give custom data to the client.
|
||||
The client will receive this as JSON in the `Connected` response."""
|
||||
|
||||
# TODO: share a TypedDict data structure with client
|
||||
|
||||
# TODO: tell client which canisters are keywords
|
||||
# so it can open and get those when restoring doors
|
||||
|
||||
self.slot_data_ready.wait()
|
||||
assert self.zz_system.randomizer, "didn't get randomizer from generate_early"
|
||||
game = self.zz_system.get_game()
|
||||
return get_slot_info(game.regions, game.char_order[0], game.loc_name_2_pretty)
|
||||
|
||||
# def modify_multidata(self, multidata: Dict[str, Any]) -> None:
|
||||
# """For deeper modification of server multidata."""
|
||||
# # not modifying multidata, just want to call this at the end of the generation process
|
||||
# cache = getattr(self.multiworld, "zillion_logic_cache")
|
||||
# import sys
|
||||
# print(sys.getsizeof(cache))
|
||||
|
||||
# end of ordered Main.py calls
|
||||
|
||||
def create_item(self, name: str) -> Item:
|
||||
"""Create an item for this world type and player.
|
||||
Warning: this may be called with self.multiworld = None, for example by MultiServer"""
|
||||
item_id = _item_name_to_id[name]
|
||||
|
||||
if not self.id_to_zz_item:
|
||||
self._make_item_maps("JJ")
|
||||
self.logger.warning("warning: called `create_item` without calling `generate_early` first")
|
||||
assert self.id_to_zz_item, "failed to get item maps"
|
||||
|
||||
classification = ItemClassification.filler
|
||||
zz_item = self.id_to_zz_item[item_id]
|
||||
if zz_item.required:
|
||||
classification = ItemClassification.progression
|
||||
if not zz_item.is_progression:
|
||||
classification = ItemClassification.progression_skip_balancing
|
||||
|
||||
z_item = ZillionItem(name, classification, item_id, self.player, zz_item)
|
||||
return z_item
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
"""Called when the item pool needs to be filled with additional items to match location count."""
|
||||
return "Empty"
|
||||
513
worlds/zillion/client.py
Normal file
513
worlds/zillion/client.py
Normal file
@@ -0,0 +1,513 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import pkgutil
|
||||
import platform
|
||||
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, cast
|
||||
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, \
|
||||
ClientCommandProcessor, logger, get_base_parser
|
||||
from NetUtils import ClientStatus
|
||||
from Utils import async_start
|
||||
|
||||
import colorama
|
||||
|
||||
from zilliandomizer.zri.memory import Memory, RescueInfo
|
||||
from zilliandomizer.zri import events
|
||||
from zilliandomizer.utils.loc_name_maps import id_to_loc
|
||||
from zilliandomizer.options import Chars
|
||||
|
||||
from .id_maps import loc_name_to_id, make_id_to_others
|
||||
from .config import base_id
|
||||
|
||||
|
||||
class ZillionCommandProcessor(ClientCommandProcessor):
|
||||
ctx: "ZillionContext"
|
||||
|
||||
def _cmd_sms(self) -> None:
|
||||
""" Tell the client that Zillion is running in RetroArch. """
|
||||
logger.info("ready to look for game")
|
||||
self.ctx.look_for_retroarch.set()
|
||||
|
||||
def _cmd_map(self) -> None:
|
||||
""" Toggle view of the map tracker. """
|
||||
self.ctx.ui_toggle_map()
|
||||
|
||||
|
||||
class ToggleCallback(Protocol):
|
||||
def __call__(self) -> None: ...
|
||||
|
||||
|
||||
class SetRoomCallback(Protocol):
|
||||
def __call__(self, rooms: List[List[int]]) -> None: ...
|
||||
|
||||
|
||||
class ZillionContext(CommonContext):
|
||||
game = "Zillion"
|
||||
command_processor = ZillionCommandProcessor
|
||||
items_handling = 1 # receive items from other players
|
||||
|
||||
known_name: Optional[str]
|
||||
""" This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """
|
||||
|
||||
from_game: "asyncio.Queue[events.EventFromGame]"
|
||||
to_game: "asyncio.Queue[events.EventToGame]"
|
||||
ap_local_count: int
|
||||
""" local checks watched by server """
|
||||
next_item: int
|
||||
""" index in `items_received` """
|
||||
ap_id_to_name: Dict[int, str]
|
||||
ap_id_to_zz_id: Dict[int, int]
|
||||
start_char: Chars = "JJ"
|
||||
rescues: Dict[int, RescueInfo] = {}
|
||||
loc_mem_to_id: Dict[int, int] = {}
|
||||
got_room_info: asyncio.Event
|
||||
""" flag for connected to server """
|
||||
got_slot_data: asyncio.Event
|
||||
""" serves as a flag for whether I am logged in to the server """
|
||||
|
||||
look_for_retroarch: asyncio.Event
|
||||
"""
|
||||
There is a bug in Python in Windows
|
||||
https://github.com/python/cpython/issues/91227
|
||||
that makes it so if I look for RetroArch before it's ready,
|
||||
it breaks the asyncio udp transport system.
|
||||
|
||||
As a workaround, we don't look for RetroArch until this event is set.
|
||||
"""
|
||||
|
||||
ui_toggle_map: ToggleCallback
|
||||
ui_set_rooms: SetRoomCallback
|
||||
""" parameter is y 16 x 8 numbers to show in each room """
|
||||
|
||||
def __init__(self,
|
||||
server_address: str,
|
||||
password: str) -> None:
|
||||
super().__init__(server_address, password)
|
||||
self.known_name = None
|
||||
self.from_game = asyncio.Queue()
|
||||
self.to_game = asyncio.Queue()
|
||||
self.got_room_info = asyncio.Event()
|
||||
self.got_slot_data = asyncio.Event()
|
||||
self.ui_toggle_map = lambda: None
|
||||
self.ui_set_rooms = lambda rooms: None
|
||||
|
||||
self.look_for_retroarch = asyncio.Event()
|
||||
if platform.system() != "Windows":
|
||||
# asyncio udp bug is only on Windows
|
||||
self.look_for_retroarch.set()
|
||||
|
||||
self.reset_game_state()
|
||||
|
||||
def reset_game_state(self) -> None:
|
||||
for _ in range(self.from_game.qsize()):
|
||||
self.from_game.get_nowait()
|
||||
for _ in range(self.to_game.qsize()):
|
||||
self.to_game.get_nowait()
|
||||
self.got_slot_data.clear()
|
||||
|
||||
self.ap_local_count = 0
|
||||
self.next_item = 0
|
||||
self.ap_id_to_name = {}
|
||||
self.ap_id_to_zz_id = {}
|
||||
self.rescues = {}
|
||||
self.loc_mem_to_id = {}
|
||||
|
||||
self.locations_checked.clear()
|
||||
self.missing_locations.clear()
|
||||
self.checked_locations.clear()
|
||||
self.finished_game = False
|
||||
self.items_received.clear()
|
||||
|
||||
# override
|
||||
def on_deathlink(self, data: Dict[str, Any]) -> None:
|
||||
self.to_game.put_nowait(events.DeathEventToGame())
|
||||
return super().on_deathlink(data)
|
||||
|
||||
# override
|
||||
async def server_auth(self, password_requested: bool = False) -> None:
|
||||
if password_requested and not self.password:
|
||||
await super().server_auth(password_requested)
|
||||
if not self.auth:
|
||||
logger.info('waiting for connection to game...')
|
||||
return
|
||||
logger.info("logging in to server...")
|
||||
await self.send_connect()
|
||||
|
||||
# override
|
||||
def run_gui(self) -> None:
|
||||
from kvui import GameManager
|
||||
from kivy.core.text import Label as CoreLabel
|
||||
from kivy.graphics import Ellipse, Color, Rectangle
|
||||
from kivy.graphics.texture import Texture
|
||||
from kivy.uix.layout import Layout
|
||||
from kivy.uix.image import CoreImage
|
||||
from kivy.uix.widget import Widget
|
||||
|
||||
class ZillionManager(GameManager):
|
||||
logging_pairs = [
|
||||
("Client", "Archipelago")
|
||||
]
|
||||
base_title = "Archipelago Zillion Client"
|
||||
|
||||
class MapPanel(Widget):
|
||||
MAP_WIDTH: ClassVar[int] = 281
|
||||
|
||||
map_background: CoreImage
|
||||
_number_textures: List[Texture] = []
|
||||
rooms: List[List[int]] = []
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
FILE_NAME = "empty-zillion-map-row-col-labels-281.png"
|
||||
image_file_data = pkgutil.get_data(__name__, FILE_NAME)
|
||||
if not image_file_data:
|
||||
raise FileNotFoundError(f"{__name__=} {FILE_NAME=}")
|
||||
data = io.BytesIO(image_file_data)
|
||||
self.map_background = CoreImage(data, ext="png")
|
||||
assert self.map_background.texture.size[0] == ZillionManager.MapPanel.MAP_WIDTH
|
||||
|
||||
self.rooms = [[0 for _ in range(8)] for _ in range(16)]
|
||||
|
||||
self._make_numbers()
|
||||
self.update_map()
|
||||
|
||||
self.bind(pos=self.update_map)
|
||||
# self.bind(size=self.update_bg)
|
||||
|
||||
def _make_numbers(self) -> None:
|
||||
self._number_textures = []
|
||||
for n in range(10):
|
||||
label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1))
|
||||
label.refresh()
|
||||
self._number_textures.append(label.texture)
|
||||
|
||||
def update_map(self, *args: Any) -> None:
|
||||
self.canvas.clear()
|
||||
|
||||
with self.canvas:
|
||||
Color(1, 1, 1, 1)
|
||||
Rectangle(texture=self.map_background.texture,
|
||||
pos=self.pos,
|
||||
size=self.map_background.texture.size)
|
||||
for y in range(16):
|
||||
for x in range(8):
|
||||
num = self.rooms[15 - y][x]
|
||||
if num > 0:
|
||||
Color(0, 0, 0, 0.4)
|
||||
pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24]
|
||||
Ellipse(size=[22, 22], pos=pos)
|
||||
Color(1, 1, 1, 1)
|
||||
pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24]
|
||||
num_texture = self._number_textures[num]
|
||||
Rectangle(texture=num_texture, size=num_texture.size, pos=pos)
|
||||
|
||||
def build(self) -> Layout:
|
||||
container = super().build()
|
||||
self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=ZillionManager.MapPanel.MAP_WIDTH)
|
||||
self.main_area_container.add_widget(self.map_widget)
|
||||
return container
|
||||
|
||||
def toggle_map_width(self) -> None:
|
||||
if self.map_widget.width == 0:
|
||||
self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH
|
||||
else:
|
||||
self.map_widget.width = 0
|
||||
self.container.do_layout()
|
||||
|
||||
def set_rooms(self, rooms: List[List[int]]) -> None:
|
||||
self.map_widget.rooms = rooms
|
||||
self.map_widget.update_map()
|
||||
|
||||
self.ui = ZillionManager(self)
|
||||
self.ui_toggle_map = lambda: self.ui.toggle_map_width()
|
||||
self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms)
|
||||
run_co: Coroutine[Any, Any, None] = self.ui.async_run()
|
||||
self.ui_task = asyncio.create_task(run_co, name="UI")
|
||||
|
||||
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
|
||||
self.room_item_numbers_to_ui()
|
||||
if cmd == "Connected":
|
||||
logger.info("logged in to Archipelago server")
|
||||
if "slot_data" not in args:
|
||||
logger.warning("`Connected` packet missing `slot_data`")
|
||||
return
|
||||
slot_data = args["slot_data"]
|
||||
|
||||
if "start_char" not in slot_data:
|
||||
logger.warning("invalid Zillion `Connected` packet, `slot_data` missing `start_char`")
|
||||
return
|
||||
self.start_char = slot_data['start_char']
|
||||
if self.start_char not in {"Apple", "Champ", "JJ"}:
|
||||
logger.warning("invalid Zillion `Connected` packet, "
|
||||
f"`slot_data` `start_char` has invalid value: {self.start_char}")
|
||||
|
||||
if "rescues" not in slot_data:
|
||||
logger.warning("invalid Zillion `Connected` packet, `slot_data` missing `rescues`")
|
||||
return
|
||||
rescues = slot_data["rescues"]
|
||||
self.rescues = {}
|
||||
for rescue_id, json_info in rescues.items():
|
||||
assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}"
|
||||
# TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch?
|
||||
assert json_info["start_char"] == self.start_char, \
|
||||
f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}'
|
||||
ri = RescueInfo(json_info["start_char"],
|
||||
json_info["room_code"],
|
||||
json_info["mask"])
|
||||
self.rescues[0 if rescue_id == "0" else 1] = ri
|
||||
|
||||
if "loc_mem_to_id" not in slot_data:
|
||||
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`")
|
||||
return
|
||||
loc_mem_to_id = slot_data["loc_mem_to_id"]
|
||||
self.loc_mem_to_id = {}
|
||||
for mem_str, id_str in loc_mem_to_id.items():
|
||||
mem = int(mem_str)
|
||||
id_ = int(id_str)
|
||||
room_i = mem // 256
|
||||
assert 0 <= room_i < 74
|
||||
assert id_ in id_to_loc
|
||||
self.loc_mem_to_id[mem] = id_
|
||||
|
||||
if len(self.loc_mem_to_id) != 394:
|
||||
logger.warning("invalid Zillion `Connected` packet, "
|
||||
f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}")
|
||||
|
||||
self.got_slot_data.set()
|
||||
|
||||
payload = {
|
||||
"cmd": "Get",
|
||||
"keys": [f"zillion-{self.auth}-doors"]
|
||||
}
|
||||
async_start(self.send_msgs([payload]))
|
||||
elif cmd == "Retrieved":
|
||||
if "keys" not in args:
|
||||
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
|
||||
return
|
||||
keys = cast(Dict[str, Optional[str]], args["keys"])
|
||||
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)
|
||||
self.to_game.put_nowait(events.DoorEventToGame(doors))
|
||||
elif cmd == "RoomInfo":
|
||||
self.seed_name = args["seed_name"]
|
||||
self.got_room_info.set()
|
||||
|
||||
def room_item_numbers_to_ui(self) -> None:
|
||||
rooms = [[0 for _ in range(8)] for _ in range(16)]
|
||||
for loc_id in self.missing_locations:
|
||||
loc_id_small = loc_id - base_id
|
||||
loc_name = id_to_loc[loc_id_small]
|
||||
y = ord(loc_name[0]) - 65
|
||||
x = ord(loc_name[2]) - 49
|
||||
if y == 9 and x == 5:
|
||||
# don't show main computer in numbers
|
||||
continue
|
||||
assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}"
|
||||
rooms[y][x] += 1
|
||||
# TODO: also add locations with locals lost from loading save state or reset
|
||||
self.ui_set_rooms(rooms)
|
||||
|
||||
def process_from_game_queue(self) -> None:
|
||||
if self.from_game.qsize():
|
||||
event_from_game = self.from_game.get_nowait()
|
||||
if isinstance(event_from_game, events.AcquireLocationEventFromGame):
|
||||
server_id = event_from_game.id + base_id
|
||||
loc_name = id_to_loc[event_from_game.id]
|
||||
self.locations_checked.add(server_id)
|
||||
if server_id in self.missing_locations:
|
||||
self.ap_local_count += 1
|
||||
n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win
|
||||
logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})')
|
||||
async_start(self.send_msgs([
|
||||
{"cmd": 'LocationChecks', "locations": [server_id]}
|
||||
]))
|
||||
else:
|
||||
# This will happen a lot in Zillion,
|
||||
# because all the key words are local and unwatched by the server.
|
||||
logger.debug(f"DEBUG: {loc_name} not in missing")
|
||||
elif isinstance(event_from_game, events.DeathEventFromGame):
|
||||
async_start(self.send_death())
|
||||
elif isinstance(event_from_game, events.WinEventFromGame):
|
||||
if not self.finished_game:
|
||||
async_start(self.send_msgs([
|
||||
{"cmd": 'LocationChecks', "locations": [loc_name_to_id["J-6 bottom far left"]]},
|
||||
{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}
|
||||
]))
|
||||
self.finished_game = True
|
||||
elif isinstance(event_from_game, events.DoorEventFromGame):
|
||||
if self.auth:
|
||||
doors_b64 = base64.b64encode(event_from_game.doors).decode()
|
||||
payload = {
|
||||
"cmd": "Set",
|
||||
"key": f"zillion-{self.auth}-doors",
|
||||
"operations": [{"operation": "replace", "value": doors_b64}]
|
||||
}
|
||||
async_start(self.send_msgs([payload]))
|
||||
else:
|
||||
logger.warning(f"WARNING: unhandled event from game {event_from_game}")
|
||||
|
||||
def process_items_received(self) -> None:
|
||||
if len(self.items_received) > self.next_item:
|
||||
zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received]
|
||||
for index in range(self.next_item, len(self.items_received)):
|
||||
ap_id = self.items_received[index].item
|
||||
from_name = self.player_names[self.items_received[index].player]
|
||||
# TODO: colors in this text, like sni client?
|
||||
logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}')
|
||||
self.to_game.put_nowait(
|
||||
events.ItemEventToGame(zz_item_ids)
|
||||
)
|
||||
self.next_item = len(self.items_received)
|
||||
|
||||
|
||||
def name_seed_from_ram(data: bytes) -> Tuple[str, str]:
|
||||
""" returns player name, and end of seed string """
|
||||
if len(data) == 0:
|
||||
# no connection to game
|
||||
return "", "xxx"
|
||||
null_index = data.find(b'\x00')
|
||||
if null_index == -1:
|
||||
logger.warning(f"invalid game id in rom {repr(data)}")
|
||||
null_index = len(data)
|
||||
name = data[:null_index].decode()
|
||||
null_index_2 = data.find(b'\x00', null_index + 1)
|
||||
if null_index_2 == -1:
|
||||
null_index_2 = len(data)
|
||||
seed_name = data[null_index + 1:null_index_2].decode()
|
||||
|
||||
return name, seed_name
|
||||
|
||||
|
||||
async def zillion_sync_task(ctx: ZillionContext) -> None:
|
||||
logger.info("started zillion sync task")
|
||||
|
||||
# to work around the Python bug where we can't check for RetroArch
|
||||
if not ctx.look_for_retroarch.is_set():
|
||||
logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.")
|
||||
await asyncio.wait((
|
||||
asyncio.create_task(ctx.look_for_retroarch.wait()),
|
||||
asyncio.create_task(ctx.exit_event.wait())
|
||||
), return_when=asyncio.FIRST_COMPLETED)
|
||||
|
||||
last_log = ""
|
||||
|
||||
def log_no_spam(msg: str) -> None:
|
||||
nonlocal last_log
|
||||
if msg != last_log:
|
||||
last_log = msg
|
||||
logger.info(msg)
|
||||
|
||||
# to only show this message once per client run
|
||||
help_message_shown = False
|
||||
|
||||
with Memory(ctx.from_game, ctx.to_game) as memory:
|
||||
while not ctx.exit_event.is_set():
|
||||
ram = await memory.read()
|
||||
game_id = memory.get_rom_to_ram_data(ram)
|
||||
name, seed_end = name_seed_from_ram(game_id)
|
||||
if len(name):
|
||||
if name == ctx.known_name:
|
||||
ctx.auth = name
|
||||
# this is the name we know
|
||||
if ctx.server and ctx.server.socket: # type: ignore
|
||||
if ctx.got_room_info.is_set():
|
||||
if ctx.seed_name and ctx.seed_name.endswith(seed_end):
|
||||
# correct seed
|
||||
if memory.have_generation_info():
|
||||
log_no_spam("everything connected")
|
||||
await memory.process_ram(ram)
|
||||
ctx.process_from_game_queue()
|
||||
ctx.process_items_received()
|
||||
else: # no generation info
|
||||
if ctx.got_slot_data.is_set():
|
||||
memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id)
|
||||
ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \
|
||||
make_id_to_others(ctx.start_char)
|
||||
ctx.next_item = 0
|
||||
ctx.ap_local_count = len(ctx.checked_locations)
|
||||
else: # no slot data yet
|
||||
async_start(ctx.send_connect())
|
||||
log_no_spam("logging in to server...")
|
||||
await asyncio.wait((
|
||||
asyncio.create_task(ctx.got_slot_data.wait()),
|
||||
asyncio.create_task(ctx.exit_event.wait()),
|
||||
asyncio.create_task(asyncio.sleep(6))
|
||||
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
|
||||
else: # not correct seed name
|
||||
log_no_spam("incorrect seed - did you mix up roms?")
|
||||
else: # no room info
|
||||
# If we get here, it looks like `RoomInfo` packet got lost
|
||||
log_no_spam("waiting for room info from server...")
|
||||
else: # server not connected
|
||||
log_no_spam("waiting for server connection...")
|
||||
else: # new game
|
||||
log_no_spam("connected to new game")
|
||||
await ctx.disconnect()
|
||||
ctx.reset_server_state()
|
||||
ctx.seed_name = None
|
||||
ctx.got_room_info.clear()
|
||||
ctx.reset_game_state()
|
||||
memory.reset_game_state()
|
||||
|
||||
ctx.auth = name
|
||||
ctx.known_name = name
|
||||
async_start(ctx.connect())
|
||||
await asyncio.wait((
|
||||
asyncio.create_task(ctx.got_room_info.wait()),
|
||||
asyncio.create_task(ctx.exit_event.wait()),
|
||||
asyncio.create_task(asyncio.sleep(6))
|
||||
), return_when=asyncio.FIRST_COMPLETED)
|
||||
else: # no name found in game
|
||||
if not help_message_shown:
|
||||
logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.')
|
||||
help_message_shown = True
|
||||
log_no_spam("looking for connection to game...")
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
await asyncio.sleep(0.09375)
|
||||
logger.info("zillion sync task ending")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
parser = get_base_parser()
|
||||
parser.add_argument('diff_file', default="", type=str, nargs="?",
|
||||
help='Path to a .apzl Archipelago Binary Patch file')
|
||||
# SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
|
||||
args = parser.parse_args()
|
||||
print(args)
|
||||
|
||||
if args.diff_file:
|
||||
import Patch
|
||||
logger.info("patch file was supplied - creating sms rom...")
|
||||
meta, rom_file = Patch.create_rom_file(args.diff_file)
|
||||
if "server" in meta:
|
||||
args.connect = meta["server"]
|
||||
logger.info(f"wrote rom file to {rom_file}")
|
||||
|
||||
ctx = ZillionContext(args.connect, args.password)
|
||||
if ctx.server_task is None:
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
ctx.run_gui()
|
||||
ctx.run_cli()
|
||||
|
||||
sync_task = asyncio.create_task(zillion_sync_task(ctx))
|
||||
|
||||
await ctx.exit_event.wait()
|
||||
|
||||
ctx.server_address = None
|
||||
logger.debug("waiting for sync task to end")
|
||||
await sync_task
|
||||
logger.debug("sync task ended")
|
||||
await ctx.shutdown()
|
||||
|
||||
|
||||
def launch() -> None:
|
||||
colorama.init()
|
||||
asyncio.run(main())
|
||||
colorama.deinit()
|
||||
1
worlds/zillion/config.py
Normal file
1
worlds/zillion/config.py
Normal file
@@ -0,0 +1 @@
|
||||
base_id = 8675309
|
||||
82
worlds/zillion/docs/en_Zillion.md
Normal file
82
worlds/zillion/docs/en_Zillion.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Zillion
|
||||
|
||||
Zillion is a metroidvania-style game released in 1987 for the 8-bit Sega Master System.
|
||||
|
||||
It's based on the anime Zillion (赤い光弾ジリオン, Akai Koudan Zillion).
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
The [player options page for this game](../player-options) contains all the options you need to configure and export a config file.
|
||||
|
||||
## What changes are made to this game?
|
||||
|
||||
The way the original game lets the player choose who to level up has a few drawbacks in a multiworld randomizer:
|
||||
- Possible softlock from making bad choices (example: nobody has jump 3 when it's required)
|
||||
- In multiworld, you won't be able to choose because you won't know it's coming beforehand.
|
||||
|
||||
So this randomizer uses a new level-up system:
|
||||
- Everyone levels up together (even if they're not rescued yet).
|
||||
- You can choose how many opa-opas are required for a level up.
|
||||
- You can set a max level from 1 to 8.
|
||||
- The currently active character is still the only one that gets the health refill.
|
||||
|
||||
---
|
||||
|
||||
You can set these options to choose when characters will be able to attain certain jump levels:
|
||||
|
||||
```
|
||||
jump levels
|
||||
|
||||
vanilla balanced low restrictive
|
||||
|
||||
jj ap ch jj ap ch jj ap ch jj ap ch
|
||||
2 3 1 1 2 1 1 1 1 1 1 1
|
||||
2 3 1 2 2 1 1 2 1 1 1 1
|
||||
2 3 1 2 3 1 2 2 1 1 2 1
|
||||
2 3 1 2 3 2 2 3 1 1 2 1
|
||||
3 3 2 3 3 2 2 3 2 2 2 1
|
||||
3 3 2 3 3 2 3 3 2 2 2 1
|
||||
3 3 3 3 3 3 3 3 2 2 3 1
|
||||
3 3 3 3 3 3 3 3 3 2 3 2
|
||||
```
|
||||
|
||||
Note that in "restrictive" mode, Apple is the only one that can get jump level 3.
|
||||
|
||||
---
|
||||
|
||||
You can set these options to choose when characters will be able to attain certain Zillion power (gun) levels:
|
||||
|
||||
```
|
||||
zillion power
|
||||
|
||||
vanilla balanced low restrictive
|
||||
|
||||
jj ap ch jj ap ch jj ap ch jj ap ch
|
||||
1 1 3 1 1 2 1 1 1 1 1 1
|
||||
2 2 3 2 1 2 1 1 2 1 1 2
|
||||
3 3 3 2 2 3 2 1 2 2 1 2
|
||||
3 2 3 2 1 3 2 1 3
|
||||
3 3 3 2 2 3 2 2 3
|
||||
3 2 3
|
||||
3 3 3
|
||||
```
|
||||
|
||||
Note that in "restrictive" mode, Champ is the only one that can get Zillion power level 3.
|
||||
|
||||
## What does another world's item look like in Zillion?
|
||||
|
||||
Canisters retain their original appearance, so you won't know if an item belongs to another player until you collect it.
|
||||
|
||||
When you collect an item, you see the name of the player it goes to. You can see in the client log what item was
|
||||
collected.
|
||||
|
||||
## When the player receives an item, what happens?
|
||||
|
||||
The item collect sound is played. You can see in the client log what item was received.
|
||||
|
||||
## Unique Local Commands
|
||||
|
||||
The following commands are only available when using the ZillionClient to play with Archipelago.
|
||||
|
||||
- `/sms` Tell the client that Zillion is running in RetroArch.
|
||||
- `/map` Toggle view of the map tracker.
|
||||
107
worlds/zillion/docs/setup_en.md
Normal file
107
worlds/zillion/docs/setup_en.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Zillion Setup Guide
|
||||
|
||||
## Required Software
|
||||
|
||||
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
|
||||
|
||||
- RetroArch 1.10.3 or newer from: [RetroArch Website](https://retroarch.com?page=platforms).
|
||||
|
||||
- Your legally obtained Zillion ROM file, named `Zillion (UE) [!].sms`
|
||||
|
||||
## Installation Procedures
|
||||
|
||||
### RetroArch
|
||||
|
||||
RetroArch 1.9.x will not work, as it is older than 1.10.3.
|
||||
|
||||
1. Enter the RetroArch main menu screen.
|
||||
2. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and install one of these cores:
|
||||
- "Sega - MS/GG (SMS Plus GX)"
|
||||
- "Sega - MS/GG/MD/CD (Genesis Plus GX)
|
||||
3. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
|
||||
4. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
|
||||
Network Command Port at 55355.
|
||||
|
||||

|
||||
|
||||
### Linux Setup
|
||||
|
||||
Put your Zillion ROM file in the Archipelago directory in your home directory.
|
||||
|
||||
### Windows Setup
|
||||
|
||||
1. Download and install [Archipelago](<https://github.com/ArchipelagoMW/Archipelago/releases/latest>). **The installer
|
||||
file is located in the assets section at the bottom of the version information.**
|
||||
2. The first time you do local generation or patch your game, you will be asked to locate your base ROM file.
|
||||
This is the Zillion ROM file mentioned above in Required Software. This only needs to be done once.
|
||||
|
||||
---
|
||||
# Play
|
||||
|
||||
## Create a Config (.yaml) File
|
||||
|
||||
### What is a config file and why do I need one?
|
||||
|
||||
See the guide on setting up a basic YAML at the Archipelago setup
|
||||
guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en)
|
||||
|
||||
### Where do I get a config file?
|
||||
|
||||
The [player options page](/games/Zillion/player-options) on the website allows you to configure your personal options and export a config file from
|
||||
them.
|
||||
|
||||
### Verifying your config file
|
||||
|
||||
If you would like to validate your config file to make sure it works, you may do so on the [YAML Validator page](/check).
|
||||
|
||||
## Generating a Single-Player Game
|
||||
|
||||
1. Navigate to the [player options page](/games/Zillion/player-options), configure your options, and click the "Generate Game" button.
|
||||
2. A "Seed Info" page will appear.
|
||||
3. Click the "Create New Room" link.
|
||||
4. A server page will appear. Download your patch file from this page.
|
||||
5. Patch your ROM file.
|
||||
- Linux
|
||||
- In the launcher, choose "Open Patch" and select your patch file.
|
||||
- Windows
|
||||
- Double-click on your patch file.
|
||||
The Zillion Client will launch automatically, and create your ROM in the location of the patch file.
|
||||
6. Open the ROM in RetroArch using the core "SMS Plus GX" or "Genesis Plus GX".
|
||||
- For a single player game, any emulator (or a Sega Master System) can be used, but there are additional features with RetroArch and the Zillion Client.
|
||||
- If you press reset or restore a save state and return to the surface in the game, the Zillion Client will keep open all the doors that you have opened.
|
||||
|
||||
## Joining a MultiWorld Game
|
||||
|
||||
1. Provide your config (yaml) file to the host and obtain your patch file.
|
||||
- When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done, the host will provide you with either a link to download your patch file, or with a zip file containing everyone's patch files. Your patch file should have a `.apzl` extension.
|
||||
- If you activate the "room generation" option in your config (yaml), you might want to tell your host that the generation will take longer than normal. It takes approximately 20 seconds longer for each Zillion player that enables this option.
|
||||
2. Create your ROM.
|
||||
- Linux
|
||||
- In the Archipelago Launcher, choose "Open Patch" and select your `.apzl` patch file.
|
||||
- Windows
|
||||
- Put your patch file on your desktop or somewhere convenient, and double click it.
|
||||
- This should automatically launch the client, and will also create your ROM in the same place as your patch file.
|
||||
3. Connect to the client.
|
||||
- Use RetroArch to open the ROM that was generated.
|
||||
- Be sure to select the **SMS Plus GX** core or the **Genesis Plus GX** core. These cores will allow external tools to read RAM data.
|
||||
4. Connect to the Archipelago Server.
|
||||
- The patch file which launched your client should have automatically connected you to the AP Server. There are a few reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it into the "Server" input field then press enter.
|
||||
- The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected".
|
||||
5. Play the game.
|
||||
- When the client shows both Game and Server as connected, you're ready to begin playing. Congratulations on successfully joining a multiworld game!
|
||||
|
||||
## Hosting a MultiWorld game
|
||||
|
||||
The recommended way to host a game is to use our hosting service. The process is relatively simple:
|
||||
|
||||
1. Collect config files from your players.
|
||||
2. Create a zip file containing your players' config files.
|
||||
3. Upload that zip file to the [Generation page](/generate).
|
||||
- Generate page: [WebHost Seed Generation Page](/generate)
|
||||
4. Wait a moment while the seed is generated.
|
||||
5. When the seed is generated, a "Seed Info" page will appear.
|
||||
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players, so
|
||||
they may download their patch files from there.
|
||||
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
|
||||
players in the game. Any observers may also be given the link to this page.
|
||||
8. Once all players have joined, you may begin playing.
|
||||
BIN
worlds/zillion/empty-zillion-map-row-col-labels-281.png
Normal file
BIN
worlds/zillion/empty-zillion-map-row-col-labels-281.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
35
worlds/zillion/gen_data.py
Normal file
35
worlds/zillion/gen_data.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from zilliandomizer.game import Game as ZzGame
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenData:
|
||||
""" data passed from generation to patcher """
|
||||
|
||||
multi_items: Dict[str, Tuple[str, str]]
|
||||
""" zz_loc_name to (item_name, player_name) """
|
||||
zz_game: ZzGame
|
||||
game_id: bytes
|
||||
""" the byte string used to detect the rom """
|
||||
|
||||
def to_json(self) -> str:
|
||||
""" serialized data from generation needed to patch rom """
|
||||
jsonable = {
|
||||
"multi_items": self.multi_items,
|
||||
"zz_game": self.zz_game.to_jsonable(),
|
||||
"game_id": list(self.game_id)
|
||||
}
|
||||
return json.dumps(jsonable)
|
||||
|
||||
@staticmethod
|
||||
def from_json(gen_data_str: str) -> "GenData":
|
||||
""" the reverse of `to_json` """
|
||||
from_json = json.loads(gen_data_str)
|
||||
return GenData(
|
||||
from_json["multi_items"],
|
||||
ZzGame.from_jsonable(from_json["zz_game"]),
|
||||
bytes(from_json["game_id"])
|
||||
)
|
||||
158
worlds/zillion/id_maps.py
Normal file
158
worlds/zillion/id_maps.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Iterable, Mapping, Tuple, TypedDict
|
||||
|
||||
from zilliandomizer.logic_components.items import (
|
||||
Item as ZzItem,
|
||||
KEYWORD,
|
||||
NORMAL,
|
||||
RESCUE,
|
||||
item_name_to_id as zz_item_name_to_zz_id,
|
||||
items as zz_items,
|
||||
item_name_to_item as zz_item_name_to_zz_item,
|
||||
)
|
||||
from zilliandomizer.logic_components.regions import RegionData
|
||||
from zilliandomizer.low_resources.item_rooms import item_room_codes
|
||||
from zilliandomizer.options import Chars
|
||||
from zilliandomizer.utils.loc_name_maps import loc_to_id as pretty_loc_name_to_id
|
||||
from zilliandomizer.utils import parse_loc_name, parse_reg_name
|
||||
from zilliandomizer.zri.memory import RescueInfo
|
||||
|
||||
from .config import base_id as base_id
|
||||
|
||||
item_name_to_id = {
|
||||
"Apple": 0 + base_id,
|
||||
"Champ": 1 + base_id,
|
||||
"JJ": 2 + base_id,
|
||||
"Win": 3 + base_id,
|
||||
"Empty": 4 + base_id,
|
||||
"ID Card": 5 + base_id,
|
||||
"Red ID Card": 6 + base_id,
|
||||
"Floppy Disk": 7 + base_id,
|
||||
"Bread": 8 + base_id,
|
||||
"Opa-Opa": 9 + base_id,
|
||||
"Zillion": 10 + base_id,
|
||||
"Scope": 11 + base_id,
|
||||
}
|
||||
|
||||
|
||||
_zz_rescue_0 = zz_item_name_to_zz_item["rescue_0"]
|
||||
_zz_rescue_1 = zz_item_name_to_zz_item["rescue_1"]
|
||||
_zz_empty = zz_item_name_to_zz_item["empty"]
|
||||
|
||||
|
||||
def make_id_to_others(start_char: Chars) -> Tuple[
|
||||
Dict[int, str], Dict[int, int], Dict[int, ZzItem]
|
||||
]:
|
||||
""" returns id_to_name, id_to_zz_id, id_to_zz_item """
|
||||
id_to_name: Dict[int, str] = {}
|
||||
id_to_zz_id: Dict[int, int] = {}
|
||||
id_to_zz_item: Dict[int, ZzItem] = {}
|
||||
|
||||
if start_char == "JJ":
|
||||
name_to_zz_item = {
|
||||
"Apple": _zz_rescue_0,
|
||||
"Champ": _zz_rescue_1,
|
||||
"JJ": _zz_empty
|
||||
}
|
||||
elif start_char == "Apple":
|
||||
name_to_zz_item = {
|
||||
"Apple": _zz_empty,
|
||||
"Champ": _zz_rescue_1,
|
||||
"JJ": _zz_rescue_0
|
||||
}
|
||||
else: # Champ
|
||||
name_to_zz_item = {
|
||||
"Apple": _zz_rescue_0,
|
||||
"Champ": _zz_empty,
|
||||
"JJ": _zz_rescue_1
|
||||
}
|
||||
|
||||
for name, ap_id in item_name_to_id.items():
|
||||
id_to_name[ap_id] = name
|
||||
|
||||
if ap_id >= 4 + base_id:
|
||||
index = ap_id - base_id
|
||||
zz_item = zz_items[index]
|
||||
assert zz_item.id == index and zz_item.name == name
|
||||
elif ap_id < 3 + base_id:
|
||||
# rescue
|
||||
assert name in {"Apple", "Champ", "JJ"}
|
||||
zz_item = name_to_zz_item[name]
|
||||
else: # main
|
||||
zz_item = zz_item_name_to_zz_item["main"]
|
||||
|
||||
id_to_zz_id[ap_id] = zz_item_name_to_zz_id[zz_item.debug_name]
|
||||
id_to_zz_item[ap_id] = zz_item
|
||||
|
||||
return id_to_name, id_to_zz_id, id_to_zz_item
|
||||
|
||||
|
||||
def make_room_name(row: int, col: int) -> str:
|
||||
return f"{chr(ord('A') + row - 1)}-{col + 1}"
|
||||
|
||||
|
||||
loc_name_to_id: Dict[str, int] = {
|
||||
name: id_ + base_id
|
||||
for name, id_ in pretty_loc_name_to_id.items()
|
||||
}
|
||||
|
||||
|
||||
def zz_reg_name_to_reg_name(zz_reg_name: str) -> str:
|
||||
if zz_reg_name[0] == 'r' and zz_reg_name[3] == 'c':
|
||||
row, col = parse_reg_name(zz_reg_name)
|
||||
end = zz_reg_name[5:]
|
||||
return f"{make_room_name(row, col)} {end.upper()}"
|
||||
return zz_reg_name
|
||||
|
||||
|
||||
class ClientRescue(TypedDict):
|
||||
start_char: Chars
|
||||
room_code: int
|
||||
mask: int
|
||||
|
||||
|
||||
class ZillionSlotInfo(TypedDict):
|
||||
start_char: Chars
|
||||
rescues: Dict[str, ClientRescue]
|
||||
loc_mem_to_id: Dict[int, int]
|
||||
""" memory location of canister to Archipelago location id number """
|
||||
|
||||
|
||||
def get_slot_info(regions: Iterable[RegionData],
|
||||
start_char: Chars,
|
||||
loc_name_to_pretty: Mapping[str, str]) -> ZillionSlotInfo:
|
||||
items_placed_in_map_index: Dict[int, int] = defaultdict(int)
|
||||
rescue_locations: Dict[int, RescueInfo] = {}
|
||||
loc_memory_to_loc_id: Dict[int, int] = {}
|
||||
for region in regions:
|
||||
for loc in region.locations:
|
||||
assert loc.item, ("There should be an item placed in every location before "
|
||||
f"writing slot info. {loc.name} is missing item.")
|
||||
if loc.item.code in {KEYWORD, NORMAL, RESCUE}:
|
||||
row, col, _y, _x = parse_loc_name(loc.name)
|
||||
map_index = row * 8 + col
|
||||
item_no = items_placed_in_map_index[map_index]
|
||||
room_code = item_room_codes[map_index]
|
||||
|
||||
r = room_code
|
||||
m = 1 << item_no
|
||||
if loc.item.code == RESCUE:
|
||||
rescue_locations[loc.item.id] = RescueInfo(start_char, r, m)
|
||||
loc_memory = (r << 7) | m
|
||||
loc_memory_to_loc_id[loc_memory] = pretty_loc_name_to_id[loc_name_to_pretty[loc.name]]
|
||||
items_placed_in_map_index[map_index] += 1
|
||||
|
||||
rescues: Dict[str, ClientRescue] = {}
|
||||
for i in (0, 1):
|
||||
if i in rescue_locations:
|
||||
ri = rescue_locations[i]
|
||||
rescues[str(i)] = {
|
||||
"start_char": ri.start_char,
|
||||
"room_code": ri.room_code,
|
||||
"mask": ri.mask
|
||||
}
|
||||
return {
|
||||
"start_char": start_char,
|
||||
"rescues": rescues,
|
||||
"loc_mem_to_id": loc_memory_to_loc_id
|
||||
}
|
||||
12
worlds/zillion/item.py
Normal file
12
worlds/zillion/item.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from BaseClasses import Item, ItemClassification as IC
|
||||
from zilliandomizer.logic_components.items import Item as ZzItem
|
||||
|
||||
|
||||
class ZillionItem(Item):
|
||||
game = "Zillion"
|
||||
__slots__ = ("zz_item",)
|
||||
zz_item: ZzItem
|
||||
|
||||
def __init__(self, name: str, classification: IC, code: int, player: int, zz_item: ZzItem) -> None:
|
||||
super().__init__(name, classification, code, player)
|
||||
self.zz_item = zz_item
|
||||
81
worlds/zillion/logic.py
Normal file
81
worlds/zillion/logic.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from typing import Dict, FrozenSet, Tuple, List, Counter as _Counter
|
||||
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
from zilliandomizer.logic_components.items import Item, items
|
||||
from zilliandomizer.logic_components.locations import Location
|
||||
from zilliandomizer.randomizer import Randomizer
|
||||
|
||||
from .item import ZillionItem
|
||||
from .id_maps import item_name_to_id
|
||||
|
||||
zz_empty = items[4]
|
||||
|
||||
# TODO: unit tests for these
|
||||
|
||||
|
||||
def set_randomizer_locs(cs: CollectionState, p: int, zz_r: Randomizer) -> int:
|
||||
"""
|
||||
sync up zilliandomizer locations with archipelago locations
|
||||
|
||||
returns a hash of the player and of the set locations with their items
|
||||
"""
|
||||
from . import ZillionWorld
|
||||
z_world = cs.multiworld.worlds[p]
|
||||
assert isinstance(z_world, ZillionWorld)
|
||||
|
||||
_hash = p
|
||||
for z_loc in z_world.my_locations:
|
||||
zz_name = z_loc.zz_loc.name
|
||||
zz_item = z_loc.item.zz_item \
|
||||
if isinstance(z_loc.item, ZillionItem) and z_loc.item.player == p \
|
||||
else zz_empty
|
||||
zz_r.locations[zz_name].item = zz_item
|
||||
_hash += (hash(zz_name) * (z_loc.zz_loc.req.gun + 2)) ^ hash(zz_item)
|
||||
return _hash
|
||||
|
||||
|
||||
def item_counts(cs: CollectionState, p: int) -> Tuple[Tuple[str, int], ...]:
|
||||
"""
|
||||
the zilliandomizer items that player p has collected
|
||||
|
||||
((item_name, count), (item_name, count), ...)
|
||||
"""
|
||||
return tuple((item_name, cs.count(item_name, p)) for item_name in item_name_to_id)
|
||||
|
||||
|
||||
LogicCacheType = Dict[int, Tuple[Dict[int, _Counter[str]], FrozenSet[Location]]]
|
||||
""" { hash: (cs.prog_items, accessible_locations) } """
|
||||
|
||||
|
||||
def cs_to_zz_locs(cs: CollectionState, p: int, zz_r: Randomizer, id_to_zz_item: Dict[int, Item]) -> FrozenSet[Location]:
|
||||
"""
|
||||
given an Archipelago `CollectionState`,
|
||||
returns frozenset of accessible zilliandomizer locations
|
||||
"""
|
||||
# caching this function because it would be slow
|
||||
logic_cache: LogicCacheType = getattr(cs.multiworld, "zillion_logic_cache", {})
|
||||
_hash = set_randomizer_locs(cs, p, zz_r)
|
||||
counts = item_counts(cs, p)
|
||||
_hash += hash(counts)
|
||||
|
||||
if _hash in logic_cache and logic_cache[_hash][0] == cs.prog_items:
|
||||
# print("cache hit")
|
||||
return logic_cache[_hash][1]
|
||||
|
||||
# print("cache miss")
|
||||
have_items: List[Item] = []
|
||||
for name, count in counts:
|
||||
have_items.extend([id_to_zz_item[item_name_to_id[name]]] * count)
|
||||
# have_req is the result of converting AP CollectionState to zilliandomizer collection state
|
||||
have_req = zz_r.make_ability(have_items)
|
||||
|
||||
# This `get_locations` is where the core of the logic comes in.
|
||||
# It takes a zilliandomizer collection state (a set of the abilities that I have)
|
||||
# and returns list of all the zilliandomizer locations I can access with those abilities.
|
||||
tr = frozenset(zz_r.get_locations(have_req))
|
||||
|
||||
# save result in cache
|
||||
logic_cache[_hash] = (cs.prog_items.copy(), tr)
|
||||
|
||||
return tr
|
||||
399
worlds/zillion/options.py
Normal file
399
worlds/zillion/options.py
Normal file
@@ -0,0 +1,399 @@
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Dict, Tuple
|
||||
from typing_extensions import TypeGuard # remove when Python >= 3.10
|
||||
|
||||
from Options import Choice, DefaultOnToggle, NamedRange, OptionGroup, PerGameCommonOptions, Range, Toggle
|
||||
|
||||
from zilliandomizer.options import (
|
||||
Options as ZzOptions, char_to_gun, char_to_jump, ID,
|
||||
VBLR as ZzVBLR, Chars, ItemCounts as ZzItemCounts
|
||||
)
|
||||
from zilliandomizer.options.parsing import validate as zz_validate
|
||||
|
||||
|
||||
class ZillionContinues(NamedRange):
|
||||
"""
|
||||
number of continues before game over
|
||||
|
||||
game over teleports you to your ship, keeping items and open doors
|
||||
"""
|
||||
default = 3
|
||||
range_start = 0
|
||||
range_end = 21
|
||||
display_name = "continues"
|
||||
special_range_names = {
|
||||
"vanilla": 3,
|
||||
"infinity": 21
|
||||
}
|
||||
|
||||
|
||||
class ZillionFloppyReq(Range):
|
||||
""" how many floppy disks are required """
|
||||
range_start = 0
|
||||
range_end = 8
|
||||
default = 5
|
||||
display_name = "floppies required"
|
||||
|
||||
|
||||
class VBLR(Choice):
|
||||
option_vanilla = 0
|
||||
option_balanced = 1
|
||||
option_low = 2
|
||||
option_restrictive = 3
|
||||
default = 1
|
||||
|
||||
def to_zz_vblr(self) -> ZzVBLR:
|
||||
def is_vblr(o: str) -> TypeGuard[ZzVBLR]:
|
||||
"""
|
||||
This function is because mypy doesn't support narrowing with `in`,
|
||||
https://github.com/python/mypy/issues/12535
|
||||
so this is the only way I see to get type narrowing to `Literal`.
|
||||
"""
|
||||
return o in ("vanilla", "balanced", "low", "restrictive")
|
||||
|
||||
key = self.current_key
|
||||
assert is_vblr(key), f"{key=}"
|
||||
return key
|
||||
|
||||
|
||||
class ZillionGunLevels(VBLR):
|
||||
"""
|
||||
Zillion gun power for the number of Zillion power ups you pick up
|
||||
|
||||
For "restrictive", Champ is the only one that can get Zillion gun power level 3.
|
||||
"""
|
||||
display_name = "gun levels"
|
||||
|
||||
|
||||
class ZillionJumpLevels(VBLR):
|
||||
"""
|
||||
jump levels for each character level
|
||||
|
||||
For "restrictive", Apple is the only one that can get jump level 3.
|
||||
"""
|
||||
display_name = "jump levels"
|
||||
|
||||
|
||||
class ZillionRandomizeAlarms(DefaultOnToggle):
|
||||
""" whether to randomize the locations of alarm sensors """
|
||||
display_name = "randomize alarms"
|
||||
|
||||
|
||||
class ZillionMaxLevel(Range):
|
||||
""" the highest level you can get """
|
||||
range_start = 3
|
||||
range_end = 8
|
||||
default = 8
|
||||
display_name = "max level"
|
||||
|
||||
|
||||
class ZillionOpasPerLevel(Range):
|
||||
"""
|
||||
how many Opa-Opas are required to level up
|
||||
|
||||
Lower makes you level up faster.
|
||||
"""
|
||||
range_start = 1
|
||||
range_end = 5
|
||||
default = 2
|
||||
display_name = "Opa-Opas per level"
|
||||
|
||||
|
||||
class ZillionStartChar(Choice):
|
||||
""" which character you start with """
|
||||
option_jj = 0
|
||||
option_apple = 1
|
||||
option_champ = 2
|
||||
display_name = "start character"
|
||||
default = "random"
|
||||
|
||||
_name_capitalization: ClassVar[Dict[int, Chars]] = {
|
||||
option_jj: "JJ",
|
||||
option_apple: "Apple",
|
||||
option_champ: "Champ",
|
||||
}
|
||||
|
||||
def get_char(self) -> Chars:
|
||||
return ZillionStartChar._name_capitalization[self.value]
|
||||
|
||||
|
||||
class ZillionIDCardCount(Range):
|
||||
"""
|
||||
how many ID Cards are in the game
|
||||
|
||||
Vanilla is 63
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 42
|
||||
display_name = "ID Card count"
|
||||
|
||||
|
||||
class ZillionBreadCount(Range):
|
||||
"""
|
||||
how many Breads are in the game
|
||||
|
||||
Vanilla is 33
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 50
|
||||
display_name = "Bread count"
|
||||
|
||||
|
||||
class ZillionOpaOpaCount(Range):
|
||||
"""
|
||||
how many Opa-Opas are in the game
|
||||
|
||||
Vanilla is 26
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 26
|
||||
display_name = "Opa-Opa count"
|
||||
|
||||
|
||||
class ZillionZillionCount(Range):
|
||||
"""
|
||||
how many Zillion gun power ups are in the game
|
||||
|
||||
Vanilla is 6
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 8
|
||||
display_name = "Zillion power up count"
|
||||
|
||||
|
||||
class ZillionFloppyDiskCount(Range):
|
||||
"""
|
||||
how many Floppy Disks are in the game
|
||||
|
||||
Vanilla is 5
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 7
|
||||
display_name = "Floppy Disk count"
|
||||
|
||||
|
||||
class ZillionScopeCount(Range):
|
||||
"""
|
||||
how many Scopes are in the game
|
||||
|
||||
Vanilla is 4
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 4
|
||||
display_name = "Scope count"
|
||||
|
||||
|
||||
class ZillionRedIDCardCount(Range):
|
||||
"""
|
||||
how many Red ID Cards are in the game
|
||||
|
||||
Vanilla is 1
|
||||
|
||||
maximum total for all items is 144
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 126
|
||||
default = 2
|
||||
display_name = "Red ID Card count"
|
||||
|
||||
|
||||
class ZillionEarlyScope(Toggle):
|
||||
""" make sure Scope is available early """
|
||||
display_name = "early scope"
|
||||
|
||||
|
||||
class ZillionSkill(Range):
|
||||
"""
|
||||
the difficulty level of the game
|
||||
|
||||
higher skill:
|
||||
- can require more precise platforming movement
|
||||
- lowers your defense
|
||||
- gives you less time to escape at the end
|
||||
"""
|
||||
range_start = 0
|
||||
range_end = 5
|
||||
default = 2
|
||||
|
||||
|
||||
class ZillionStartingCards(NamedRange):
|
||||
"""
|
||||
how many ID Cards to start the game with
|
||||
|
||||
Refilling at the ship also ensures you have at least this many cards.
|
||||
0 gives vanilla behavior.
|
||||
"""
|
||||
default = 2
|
||||
range_start = 0
|
||||
range_end = 10
|
||||
display_name = "starting cards"
|
||||
special_range_names = {
|
||||
"vanilla": 0
|
||||
}
|
||||
|
||||
|
||||
class ZillionRoomGen(Toggle):
|
||||
""" whether to generate rooms with random terrain """
|
||||
display_name = "room generation"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZillionOptions(PerGameCommonOptions):
|
||||
continues: ZillionContinues
|
||||
floppy_req: ZillionFloppyReq
|
||||
gun_levels: ZillionGunLevels
|
||||
jump_levels: ZillionJumpLevels
|
||||
randomize_alarms: ZillionRandomizeAlarms
|
||||
max_level: ZillionMaxLevel
|
||||
start_char: ZillionStartChar
|
||||
opas_per_level: ZillionOpasPerLevel
|
||||
id_card_count: ZillionIDCardCount
|
||||
bread_count: ZillionBreadCount
|
||||
opa_opa_count: ZillionOpaOpaCount
|
||||
zillion_count: ZillionZillionCount
|
||||
floppy_disk_count: ZillionFloppyDiskCount
|
||||
scope_count: ZillionScopeCount
|
||||
red_id_card_count: ZillionRedIDCardCount
|
||||
early_scope: ZillionEarlyScope
|
||||
skill: ZillionSkill
|
||||
starting_cards: ZillionStartingCards
|
||||
room_gen: ZillionRoomGen
|
||||
|
||||
|
||||
z_option_groups = [
|
||||
OptionGroup("item counts", [
|
||||
ZillionIDCardCount, ZillionBreadCount, ZillionOpaOpaCount, ZillionZillionCount,
|
||||
ZillionFloppyDiskCount, ZillionScopeCount, ZillionRedIDCardCount
|
||||
])
|
||||
]
|
||||
|
||||
|
||||
def convert_item_counts(ic: "Counter[str]") -> ZzItemCounts:
|
||||
tr: ZzItemCounts = {
|
||||
ID.card: ic["ID Card"],
|
||||
ID.red: ic["Red ID Card"],
|
||||
ID.floppy: ic["Floppy Disk"],
|
||||
ID.bread: ic["Bread"],
|
||||
ID.gun: ic["Zillion"],
|
||||
ID.opa: ic["Opa-Opa"],
|
||||
ID.scope: ic["Scope"],
|
||||
ID.empty: ic["Empty"],
|
||||
}
|
||||
return tr
|
||||
|
||||
|
||||
def validate(options: ZillionOptions) -> "Tuple[ZzOptions, Counter[str]]":
|
||||
"""
|
||||
adjusts options to make game completion possible
|
||||
|
||||
`options` parameter is ZillionOptions object that was put on my world by the core
|
||||
"""
|
||||
|
||||
skill = options.skill.value
|
||||
|
||||
jump_option = options.jump_levels.to_zz_vblr()
|
||||
required_level = char_to_jump["Apple"][jump_option].index(3) + 1
|
||||
if skill == 0:
|
||||
# because of hp logic on final boss
|
||||
required_level = 8
|
||||
|
||||
gun_option = options.gun_levels.to_zz_vblr()
|
||||
guns_required = char_to_gun["Champ"][gun_option].index(3)
|
||||
|
||||
floppy_req = options.floppy_req
|
||||
|
||||
item_counts = Counter({
|
||||
"ID Card": options.id_card_count,
|
||||
"Bread": options.bread_count,
|
||||
"Opa-Opa": options.opa_opa_count,
|
||||
"Zillion": options.zillion_count,
|
||||
"Floppy Disk": options.floppy_disk_count,
|
||||
"Scope": options.scope_count,
|
||||
"Red ID Card": options.red_id_card_count
|
||||
})
|
||||
minimums = Counter({
|
||||
"ID Card": 0,
|
||||
"Bread": 0,
|
||||
"Opa-Opa": required_level - 1,
|
||||
"Zillion": guns_required,
|
||||
"Floppy Disk": floppy_req.value,
|
||||
"Scope": 0,
|
||||
"Red ID Card": 1
|
||||
})
|
||||
for key in minimums:
|
||||
item_counts[key] = max(minimums[key], item_counts[key])
|
||||
max_movables = 144 - sum(minimums.values())
|
||||
movables = item_counts - minimums
|
||||
while sum(movables.values()) > max_movables:
|
||||
# logging.warning("zillion options validate: player options item counts too high")
|
||||
total = sum(movables.values())
|
||||
scaler = max_movables / total
|
||||
for key in movables:
|
||||
movables[key] = int(movables[key] * scaler)
|
||||
item_counts = movables + minimums
|
||||
|
||||
# now have required items, and <= 144
|
||||
|
||||
# now fill remaining with empty
|
||||
total = sum(item_counts.values())
|
||||
diff = 144 - total
|
||||
if "Empty" not in item_counts:
|
||||
item_counts["Empty"] = 0
|
||||
item_counts["Empty"] += diff
|
||||
assert sum(item_counts.values()) == 144
|
||||
|
||||
max_level = options.max_level
|
||||
max_level.value = max(required_level, max_level.value)
|
||||
|
||||
opas_per_level = options.opas_per_level
|
||||
while (opas_per_level.value > 1) and (1 + item_counts["Opa-Opa"] // opas_per_level.value < max_level.value):
|
||||
# logging.warning(
|
||||
# "zillion options validate: option opas_per_level incompatible with options max_level and opa_opa_count"
|
||||
# )
|
||||
opas_per_level.value -= 1
|
||||
|
||||
# that should be all of the level requirements met
|
||||
|
||||
starting_cards = options.starting_cards
|
||||
|
||||
room_gen = options.room_gen
|
||||
|
||||
zz_item_counts = convert_item_counts(item_counts)
|
||||
zz_op = ZzOptions(
|
||||
zz_item_counts,
|
||||
jump_option,
|
||||
gun_option,
|
||||
opas_per_level.value,
|
||||
max_level.value,
|
||||
False, # tutorial
|
||||
skill,
|
||||
options.start_char.get_char(),
|
||||
floppy_req.value,
|
||||
options.continues.value,
|
||||
bool(options.randomize_alarms.value),
|
||||
bool(options.early_scope.value),
|
||||
True, # balance defense
|
||||
starting_cards.value,
|
||||
bool(room_gen.value)
|
||||
)
|
||||
zz_validate(zz_op)
|
||||
return zz_op, item_counts
|
||||
83
worlds/zillion/patch.py
Normal file
83
worlds/zillion/patch.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import os
|
||||
from typing import Any, BinaryIO, Optional, cast
|
||||
import zipfile
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
import Utils
|
||||
from worlds.Files import APAutoPatchInterface
|
||||
|
||||
from zilliandomizer.patch import Patcher
|
||||
|
||||
from .gen_data import GenData
|
||||
|
||||
USHASH = 'd4bf9e7bcf9a48da53785d2ae7bc4270'
|
||||
|
||||
|
||||
class ZillionPatch(APAutoPatchInterface):
|
||||
hash = USHASH
|
||||
game = "Zillion"
|
||||
patch_file_ending = ".apzl"
|
||||
result_file_ending = ".sms"
|
||||
|
||||
gen_data_str: str
|
||||
""" JSON encoded """
|
||||
|
||||
def __init__(self, *args: Any, gen_data_str: str = "", **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.gen_data_str = gen_data_str
|
||||
|
||||
@classmethod
|
||||
def get_source_data(cls) -> bytes:
|
||||
with open(get_base_rom_path(), "rb") as stream:
|
||||
return read_rom(stream)
|
||||
|
||||
@override
|
||||
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
super().write_contents(opened_zipfile)
|
||||
opened_zipfile.writestr("gen_data.json",
|
||||
self.gen_data_str,
|
||||
compress_type=zipfile.ZIP_DEFLATED)
|
||||
|
||||
@override
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
|
||||
super().read_contents(opened_zipfile)
|
||||
self.gen_data_str = opened_zipfile.read("gen_data.json").decode()
|
||||
|
||||
def patch(self, target: str) -> None:
|
||||
self.read()
|
||||
write_rom_from_gen_data(self.gen_data_str, target)
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: Optional[str] = None) -> str:
|
||||
options = Utils.get_options()
|
||||
if not file_name:
|
||||
file_name = cast(str, options["zillion_options"]["rom_file"])
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.user_path(file_name)
|
||||
return file_name
|
||||
|
||||
|
||||
def read_rom(stream: BinaryIO) -> bytes:
|
||||
""" reads rom into bytearray """
|
||||
data = stream.read()
|
||||
# I'm not aware of any sms header.
|
||||
return data
|
||||
|
||||
|
||||
def write_rom_from_gen_data(gen_data_str: str, output_rom_file_name: str) -> None:
|
||||
""" take the output of `GenData.to_json`, and create rom from it """
|
||||
gen_data = GenData.from_json(gen_data_str)
|
||||
|
||||
base_rom_path = get_base_rom_path()
|
||||
zz_patcher = Patcher(base_rom_path)
|
||||
|
||||
zz_patcher.write_locations(gen_data.zz_game.regions, gen_data.zz_game.char_order[0])
|
||||
zz_patcher.all_fixes_and_options(gen_data.zz_game)
|
||||
zz_patcher.set_external_item_interface(gen_data.zz_game.char_order[0], gen_data.zz_game.options.max_level)
|
||||
zz_patcher.set_multiworld_items(gen_data.multi_items)
|
||||
zz_patcher.set_rom_to_ram_data(gen_data.game_id)
|
||||
|
||||
patched_rom_bytes = zz_patcher.get_patched_bytes()
|
||||
with open(output_rom_file_name, "wb") as binary_file:
|
||||
binary_file.write(patched_rom_bytes)
|
||||
0
worlds/zillion/py.typed
Normal file
0
worlds/zillion/py.typed
Normal file
48
worlds/zillion/region.py
Normal file
48
worlds/zillion/region.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from typing import Optional
|
||||
from BaseClasses import MultiWorld, Region, Location, Item, CollectionState
|
||||
from zilliandomizer.logic_components.regions import Region as ZzRegion
|
||||
from zilliandomizer.logic_components.locations import Location as ZzLocation
|
||||
from zilliandomizer.logic_components.items import RESCUE
|
||||
|
||||
from .id_maps import loc_name_to_id
|
||||
from .item import ZillionItem
|
||||
|
||||
|
||||
class ZillionRegion(Region):
|
||||
zz_r: ZzRegion
|
||||
|
||||
def __init__(self, zz_r: ZzRegion,
|
||||
name: str,
|
||||
hint: str,
|
||||
player: int,
|
||||
multiworld: MultiWorld) -> None:
|
||||
super().__init__(name, player, multiworld, hint)
|
||||
self.zz_r = zz_r
|
||||
|
||||
|
||||
class ZillionLocation(Location):
|
||||
zz_loc: ZzLocation
|
||||
game: str = "Zillion"
|
||||
|
||||
def __init__(self,
|
||||
zz_loc: ZzLocation,
|
||||
player: int,
|
||||
name: str,
|
||||
parent: Optional[Region] = None) -> None:
|
||||
loc_id = loc_name_to_id[name]
|
||||
super().__init__(player, name, loc_id, parent)
|
||||
self.zz_loc = zz_loc
|
||||
|
||||
# override
|
||||
def can_fill(self, state: CollectionState, item: Item, check_access: bool = True) -> bool:
|
||||
saved_gun_req = -1
|
||||
if isinstance(item, ZillionItem) \
|
||||
and item.zz_item.code == RESCUE \
|
||||
and self.player == item.player:
|
||||
# RESCUE removes the gun requirement from a location.
|
||||
saved_gun_req = self.zz_loc.req.gun
|
||||
self.zz_loc.req.gun = 0
|
||||
super_result = super().can_fill(state, item, check_access)
|
||||
if saved_gun_req != -1:
|
||||
self.zz_loc.req.gun = saved_gun_req
|
||||
return super_result
|
||||
2
worlds/zillion/requirements.txt
Normal file
2
worlds/zillion/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
zilliandomizer @ git+https://github.com/beauxq/zilliandomizer@b36a23b5a138c78732ac8efb5b5ca8b0be07dcff#0.7.0
|
||||
typing-extensions>=4.7, <5
|
||||
149
worlds/zillion/test/TestGoal.py
Normal file
149
worlds/zillion/test/TestGoal.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from . import ZillionTestBase
|
||||
|
||||
|
||||
class TestGoalVanilla(ZillionTestBase):
|
||||
options = {
|
||||
"start_char": "JJ",
|
||||
"jump_levels": "vanilla",
|
||||
"gun_levels": "vanilla",
|
||||
"floppy_disk_count": 7,
|
||||
"floppy_req": 6,
|
||||
}
|
||||
|
||||
def test_floppies(self) -> None:
|
||||
self.collect_by_name(["Apple", "Champ", "Red ID Card"])
|
||||
self.assertBeatable(False) # 0 floppies
|
||||
floppies = self.get_items_by_name("Floppy Disk")
|
||||
win = self.get_item_by_name("Win")
|
||||
self.collect(floppies[:-2]) # 1 too few
|
||||
self.assertEqual(self.count("Floppy Disk"), 5)
|
||||
self.assertBeatable(False)
|
||||
self.collect(floppies[-2:-1]) # exact
|
||||
self.assertEqual(self.count("Floppy Disk"), 6)
|
||||
self.assertBeatable(True)
|
||||
self.remove([win]) # reset
|
||||
self.collect(floppies[-1:]) # 1 extra
|
||||
self.assertEqual(self.count("Floppy Disk"), 7)
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_with_everything(self) -> None:
|
||||
self.collect_by_name(["Apple", "Champ", "Red ID Card", "Floppy Disk"])
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_no_jump(self) -> None:
|
||||
self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def test_no_gun(self) -> None:
|
||||
self.ensure_gun_3_requirement()
|
||||
self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
def test_no_red(self) -> None:
|
||||
self.collect_by_name(["Apple", "Champ", "Floppy Disk"])
|
||||
self.assertBeatable(False)
|
||||
|
||||
|
||||
class TestGoalBalanced(ZillionTestBase):
|
||||
options = {
|
||||
"start_char": "JJ",
|
||||
"jump_levels": "balanced",
|
||||
"gun_levels": "balanced",
|
||||
}
|
||||
|
||||
def test_jump(self) -> None:
|
||||
self.collect_by_name(["Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
opas = self.get_items_by_name("Opa-Opa")
|
||||
self.collect(opas[:1]) # too few
|
||||
self.assertEqual(self.count("Opa-Opa"), 1)
|
||||
self.assertBeatable(False)
|
||||
self.collect(opas[1:])
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_guns(self) -> None:
|
||||
self.ensure_gun_3_requirement()
|
||||
self.collect_by_name(["Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
guns = self.get_items_by_name("Zillion")
|
||||
self.collect(guns[:1]) # too few
|
||||
self.assertEqual(self.count("Zillion"), 1)
|
||||
self.assertBeatable(False)
|
||||
self.collect(guns[1:])
|
||||
self.assertBeatable(True)
|
||||
|
||||
|
||||
class TestGoalRestrictive(ZillionTestBase):
|
||||
options = {
|
||||
"start_char": "JJ",
|
||||
"jump_levels": "restrictive",
|
||||
"gun_levels": "restrictive",
|
||||
}
|
||||
|
||||
def test_jump(self) -> None:
|
||||
self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
self.collect_by_name("Opa-Opa")
|
||||
self.assertBeatable(False) # with all opas, jj champ can't jump
|
||||
self.collect_by_name("Apple")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_guns(self) -> None:
|
||||
self.ensure_gun_3_requirement()
|
||||
self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
self.collect_by_name("Zillion")
|
||||
self.assertBeatable(False) # with all guns, jj apple can't gun
|
||||
self.collect_by_name("Champ")
|
||||
self.assertBeatable(True)
|
||||
|
||||
|
||||
class TestGoalAppleStart(ZillionTestBase):
|
||||
""" creation of character rescue items has some special interactions with logic """
|
||||
options = {
|
||||
"start_char": "Apple",
|
||||
"jump_levels": "balanced",
|
||||
"gun_levels": "low",
|
||||
"zillion_count": 5
|
||||
}
|
||||
|
||||
def test_guns_jj_first(self) -> None:
|
||||
""" with low gun levels, 5 Zillion is enough to get JJ to gun 3 """
|
||||
self.ensure_gun_3_requirement()
|
||||
self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
self.collect_by_name("Zillion")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_guns_zillions_first(self) -> None:
|
||||
""" with low gun levels, 5 Zillion is enough to get JJ to gun 3 """
|
||||
self.ensure_gun_3_requirement()
|
||||
self.collect_by_name(["Zillion", "Red ID Card", "Floppy Disk", "Opa-Opa"])
|
||||
self.assertBeatable(False) # not enough gun
|
||||
self.collect_by_name("JJ")
|
||||
self.assertBeatable(True)
|
||||
|
||||
|
||||
class TestGoalChampStart(ZillionTestBase):
|
||||
""" creation of character rescue items has some special interactions with logic """
|
||||
options = {
|
||||
"start_char": "Champ",
|
||||
"jump_levels": "low",
|
||||
"gun_levels": "balanced",
|
||||
"opa_opa_count": 5,
|
||||
"opas_per_level": 1
|
||||
}
|
||||
|
||||
def test_jump_jj_first(self) -> None:
|
||||
""" with low jump levels, 5 level-ups is enough to get JJ to jump 3 """
|
||||
self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
self.collect_by_name("Opa-Opa")
|
||||
self.assertBeatable(True)
|
||||
|
||||
def test_jump_opa_first(self) -> None:
|
||||
""" with low jump levels, 5 level-ups is enough to get JJ to jump 3 """
|
||||
self.collect_by_name(["Opa-Opa", "Red ID Card", "Floppy Disk", "Zillion"])
|
||||
self.assertBeatable(False) # not enough jump
|
||||
self.collect_by_name("JJ")
|
||||
self.assertBeatable(True)
|
||||
30
worlds/zillion/test/TestOptions.py
Normal file
30
worlds/zillion/test/TestOptions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from . import ZillionTestBase
|
||||
|
||||
from worlds.zillion.options import ZillionJumpLevels, ZillionGunLevels, ZillionOptions, validate
|
||||
from zilliandomizer.options import VBLR_CHOICES
|
||||
|
||||
|
||||
class OptionsTest(ZillionTestBase):
|
||||
auto_construct = False
|
||||
|
||||
def test_validate_default(self) -> None:
|
||||
self.world_setup()
|
||||
options = self.multiworld.worlds[1].options
|
||||
assert isinstance(options, ZillionOptions)
|
||||
validate(options)
|
||||
|
||||
def test_vblr_ap_to_zz(self) -> None:
|
||||
""" all of the valid values for the AP options map to valid values for ZZ options """
|
||||
for option_name, vblr_class in (
|
||||
("jump_levels", ZillionJumpLevels),
|
||||
("gun_levels", ZillionGunLevels)
|
||||
):
|
||||
for value in vblr_class.name_lookup.values():
|
||||
self.options = {option_name: value}
|
||||
self.world_setup()
|
||||
options = self.multiworld.worlds[1].options
|
||||
assert isinstance(options, ZillionOptions)
|
||||
zz_options, _item_counts = validate(options)
|
||||
assert getattr(zz_options, option_name) in VBLR_CHOICES
|
||||
|
||||
# TODO: test validate with invalid combinations of options
|
||||
29
worlds/zillion/test/TestReproducibleRandom.py
Normal file
29
worlds/zillion/test/TestReproducibleRandom.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from typing import cast
|
||||
from . import ZillionTestBase
|
||||
|
||||
from worlds.zillion import ZillionWorld
|
||||
|
||||
|
||||
class SeedTest(ZillionTestBase):
|
||||
auto_construct = False
|
||||
|
||||
def test_reproduce_seed(self) -> None:
|
||||
self.world_setup(42)
|
||||
z_world = cast(ZillionWorld, self.multiworld.worlds[1])
|
||||
r = z_world.zz_system.randomizer
|
||||
assert r
|
||||
randomized_requirements_first = tuple(
|
||||
location.req.gun
|
||||
for location in r.locations.values()
|
||||
)
|
||||
|
||||
self.world_setup(42)
|
||||
z_world = cast(ZillionWorld, self.multiworld.worlds[1])
|
||||
r = z_world.zz_system.randomizer
|
||||
assert r
|
||||
randomized_requirements_second = tuple(
|
||||
location.req.gun
|
||||
for location in r.locations.values()
|
||||
)
|
||||
|
||||
assert randomized_requirements_first == randomized_requirements_second
|
||||
20
worlds/zillion/test/__init__.py
Normal file
20
worlds/zillion/test/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from typing import cast
|
||||
from test.bases import WorldTestBase
|
||||
from worlds.zillion import ZillionWorld
|
||||
|
||||
|
||||
class ZillionTestBase(WorldTestBase):
|
||||
game = "Zillion"
|
||||
|
||||
def ensure_gun_3_requirement(self) -> None:
|
||||
"""
|
||||
There's a low probability that gun 3 is not required.
|
||||
|
||||
This makes sure that gun 3 is required by making all the canisters
|
||||
in O-7 (including key word canisters) require gun 3.
|
||||
"""
|
||||
zz_world = cast(ZillionWorld, self.multiworld.worlds[1])
|
||||
assert zz_world.zz_system.randomizer
|
||||
for zz_loc_name, zz_loc in zz_world.zz_system.randomizer.locations.items():
|
||||
if zz_loc_name.startswith("r15c6"):
|
||||
zz_loc.req.gun = 3
|
||||
Reference in New Issue
Block a user