New Game: Zillion (#1081)

* Option RangeWithSpecialMax

* amendment to typing in web options

* compare string with number

* lots of work on zillion

* fix zillion fill logic

* fix a few more issues in zillion fill logic

* can make zillion patch and use it

* put multi items in zillion rom

* work on ZillionClient

* logging and auth in client

* work on sending and receiving items

* implement item_handling flag

* fix locations ids to NuktiServer package

* use rewrite of zri

* cache logic rule data for performance

* use new id maps

* fix some problems with the big recent merge

* ZillionClient: use new context manager for Memory class

* fix ItemClassification for Zillion items
and some debug statements for asserts,
documentation on running scripts for manual testing
type correction in CommonContext

* fix some issues in client, start on docs, put rescue and item ram addresses in slot data

* use new location name system
fix item locations getting out of sync in progression balancing

* zillion client can read slot name from game

* zillion: new item names

* remove extra unneeded import

* newer options (room gen and starting cards)

* update comment in zillion patch

* zillion non static regions

* change some logging, update some comments

* allow ZillionClient to exit in certain situations

* todo note to fix options doc strings

* don't force auto forfeit

* rework validation of floppy requirement and item counts
and fix race condition in generate_output

* reorganize Zillion component structure
with System class

* documentation updates for Zillion

* attempt inno_setup.iss

* remove todo comment for something done

* update comment

* rework item count zillion options
and some small cleanups

* fix location check count

* data package version 1

* Zillion can pass unit tests without rom

* fix freeze if closing ZillionClient while it's waiting for server login

* specify commit hash for zilliandomizer package

* some changes to options validation

* Zillion doors saved on multiworld server

* add missing function in inno_setup
and name of vanilla continues in options

* rework zillion sync task and context

* Apply documentation suggestions from SoldierofOrder

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* update zillion package

* workaround for asyncio udp bug

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 the user asks for it with /sms

* a few of the smaller suggestions from review

* logic only looks at my locations
instead of all the multiworld locations

* some adjustments from pull request discussion
and some unit tests

* patch webhost changes from pull request discussion

* zillion logic tests

* better vblr test

* test interaction of character rescue items with logic

* move unit tests to new worlds folder

* comment improvements

* fix minor logic issue
and add memory read timeout

* capitalization in option display names
Opa-Opa is a proper noun

* redirect zz stdout to debug

* fix option validation bug making unbeatable seeds

* remove line that does nothing

* attach logic cache to world

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <doughoskisson@novuslabs.com>
This commit is contained in:
Doug Hoskisson 2022-10-20 10:41:11 -07:00 committed by GitHub
parent ed76c13961
commit 265ee7098a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 1871 additions and 1 deletions

1
.gitignore vendored
View File

@ -13,6 +13,7 @@
*.z64
*.n64
*.nes
*.sms
*.gb
*.gbc
*.gba

View File

@ -151,6 +151,11 @@ class CommonContext:
hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
@ -288,7 +293,7 @@ class CommonContext:
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address=None):
async def connect(self, address: typing.Optional[str] = None) -> None:
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")

View File

@ -151,6 +151,9 @@ components: Iterable[Component] = (
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),

View File

@ -44,6 +44,13 @@ def update(yes=False, force=False):
wheel = line.split('/')[-1]
name, version, _ = wheel.split('-', 2)
line = f'{name}=={version}'
elif line.startswith('git+https://'):
# extract name and version
end = line.split('/')[-1]
name_hash, egg = end.split("#", 1)
name, _ = name_hash.split("@", 1)
version = egg.split('==')[-1]
line = f'{name}=={version}'
requirements = pkg_resources.parse_requirements(line)
for requirement in requirements:
requirement = str(requirement)

View File

@ -32,6 +32,7 @@ Currently, the following games are supported:
* Pokémon Red and Blue
* Hylics 2
* Overcooked! 2
* Zillion
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@ -295,6 +295,12 @@ def get_default_options() -> OptionsType:
"sni": "SNI",
"rom_start": True,
},
"zillion_options": {
"rom_file": "Zillion (UE) [!].sms",
# 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_start": "retroarch",
},
"pokemon_rb_options": {
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",

View File

@ -75,6 +75,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":

358
ZillionClient.py Normal file
View File

@ -0,0 +1,358 @@
import asyncio
import base64
import platform
from typing import Any, Coroutine, Dict, Optional, Type, cast
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
from NetUtils import ClientStatus
import Utils
import colorama # type: ignore
from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
from zilliandomizer.utils.loc_name_maps import id_to_loc
from zilliandomizer.options import Chars
from zilliandomizer.patch import RescueInfo
from worlds.zillion.id_maps import make_id_to_others
from worlds.zillion.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()
class ZillionContext(CommonContext):
game = "Zillion"
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
items_handling = 1 # receive items from other players
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_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.
"""
def __init__(self,
server_address: str,
password: str) -> None:
super().__init__(server_address, password)
self.from_game = asyncio.Queue()
self.to_game = asyncio.Queue()
self.got_slot_data = asyncio.Event()
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
class ZillionManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zillion Client"
self.ui = ZillionManager(self)
run_co: Coroutine[Any, Any, None] = self.ui.async_run() # type: ignore
# kivy types missing
self.ui_task = asyncio.create_task(run_co, name="UI")
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
if cmd == "Connected":
logger.info("logged in to Archipelago server")
if "slot_data" not in args:
logger.warn("`Connected` packet missing `slot_data`")
return
slot_data = args["slot_data"]
if "start_char" not in slot_data:
logger.warn("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.warn("invalid Zillion `Connected` packet, "
f"`slot_data` `start_char` has invalid value: {self.start_char}")
if "rescues" not in slot_data:
logger.warn("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_
self.got_slot_data.set()
payload = {
"cmd": "Get",
"keys": [f"zillion-{self.auth}-doors"]
}
asyncio.create_task(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[f"zillion-{self.auth}-doors"]
if doors_b64:
logger.info("received door data from server")
doors = base64.b64decode(doors_b64)
self.to_game.put_nowait(events.DoorEventToGame(doors))
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})')
asyncio.create_task(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):
asyncio.create_task(self.send_death())
elif isinstance(event_from_game, events.WinEventFromGame):
if not self.finished_game:
asyncio.create_task(self.send_msgs([
{"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}]
}
asyncio.create_task(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)
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()
name = memory.get_player_name(ram).decode()
if len(name):
if name == ctx.auth:
# this is the name we know
if ctx.server and ctx.server.socket: # type: ignore
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
asyncio.create_task(ctx.send_connect())
log_no_spam("logging in to server...")
await asyncio.wait((
ctx.got_slot_data.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
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.reset_game_state()
memory.reset_game_state()
ctx.auth = name
asyncio.create_task(ctx.connect())
await asyncio.wait((
ctx.got_slot_data.wait(),
ctx.exit_event.wait(),
asyncio.sleep(6)
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
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()
if __name__ == "__main__":
Utils.init_logging("ZillionClient", exception_logger="Client")
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@ -155,3 +155,12 @@ smw_options:
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
zillion_options:
# File name of the Zillion US rom
rom_file: "Zillion (UE) [!].sms"
# 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_start: "retroarch"

View File

@ -59,6 +59,7 @@ Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: ful
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
Name: "server"; Description: "Server"; Types: full hosting
@ -77,6 +78,7 @@ Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Typ
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/zl"; Description: "Zillion"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@ -89,6 +91,7 @@ Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Countr
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
@ -104,6 +107,7 @@ Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: i
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
@ -118,6 +122,7 @@ Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.e
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
@ -129,6 +134,7 @@ Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNI
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
@ -169,6 +175,11 @@ Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Arch
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
@ -254,6 +265,9 @@ var SoERomFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var zlrom: string;
var ZlROMFilePage: TInputFileWizardPage;
var redrom: string;
var RedROMFilePage: TInputFileWizardPage;
@ -273,6 +287,15 @@ begin
end;
end;
function GetSMSMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
@ -292,6 +315,25 @@ begin
end;
end;
function CheckSMSRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function AddRomPage(name: string): TInputFileWizardPage;
begin
Result :=
@ -307,6 +349,7 @@ begin
'.sfc');
end;
function AddGBRomPage(name: string): TInputFileWizardPage;
begin
Result :=
@ -322,6 +365,21 @@ begin
'.gb');
end;
function AddSMSRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SMS ROM files|*.sms|All files|*.*',
'.sms');
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
@ -366,6 +424,8 @@ begin
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
Result := not (ZlROMFilePage.Values[0] = '')
else
Result := True;
end;
@ -466,6 +526,22 @@ begin
Result := '';
end;
function GetZlROMPath(Param: string): string;
begin
if Length(zlrom) > 0 then
Result := zlrom
else if Assigned(ZlROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if R <> 0 then
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := ZlROMFilePage.Values[0]
end
else
Result := '';
end;
function GetRedROMPath(Param: string): string;
begin
if Length(redrom) > 0 then
@ -522,6 +598,10 @@ begin
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if Length(zlrom) = 0 then
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
if Length(redrom) = 0 then
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
@ -547,6 +627,8 @@ begin
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl'));
if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then

View File

@ -0,0 +1,144 @@
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):
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):
self.collect_by_name(["Apple", "Champ", "Red ID Card", "Floppy Disk"])
self.assertBeatable(True)
def test_no_jump(self):
self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk"])
self.assertBeatable(False)
def test_no_gun(self):
self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk"])
self.assertBeatable(False)
def test_no_red(self):
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):
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):
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):
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):
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):
""" with low gun levels, 5 Zillion is enough to get JJ to gun 3 """
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):
""" with low gun levels, 5 Zillion is enough to get JJ to gun 3 """
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):
""" 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):
""" 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)

View File

@ -0,0 +1,26 @@
from test.worlds.zillion import ZillionTestBase
from worlds.zillion.options import ZillionJumpLevels, ZillionGunLevels, validate
from zilliandomizer.options import VBLR_CHOICES
class OptionsTest(ZillionTestBase):
auto_construct = False
def test_validate_default(self) -> None:
self.world_setup()
validate(self.world, 1)
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()
zz_options, _item_counts = validate(self.world, 1)
assert getattr(zz_options, option_name) in VBLR_CHOICES
# TODO: test validate with invalid combinations of options

View File

@ -0,0 +1,5 @@
from test.worlds.test_base import WorldTestBase
class ZillionTestBase(WorldTestBase):
game = "Zillion"

View File

View File

395
worlds/zillion/__init__.py Normal file
View File

@ -0,0 +1,395 @@
from collections import deque, Counter
from contextlib import redirect_stdout
import functools
from typing import Any, Dict, List, Set, Tuple, Optional, cast
import os
import logging
from BaseClasses import ItemClassification, LocationProgressType, \
MultiWorld, Item, CollectionState, RegionType, \
Entrance, Tutorial
from Options import AssembleOptions
from .logic import cs_to_zz_locs
from .region import ZillionLocation, ZillionRegion
from .options import zillion_options, validate
from .id_maps import 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 ZillionDeltaPatch, get_base_rom_path
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 ..AutoWorld import World, WebWorld
class ZillionWebWorld(WebWorld):
theme = "stone"
tutorials = [Tutorial(
"Multiworld Setup Guide",
"A guide to playing Zillion randomizer.",
"English",
"setup_en.md",
"setup/en",
["beauxq"]
)]
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()
option_definitions: Dict[str, AssembleOptions] = zillion_options
topology_present: bool = True # indicate if world type has any meaningful layout/pathing
# map names to their IDs
item_name_to_id: Dict[str, int] = _item_name_to_id
location_name_to_id: Dict[str, int] = _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: int = 1
# NOTE: remote_items and remote_start_inventory are now available in the network protocol for the client to set.
# These values will be removed.
# if a world is set to remote_items, then it just needs to send location checks to the server and the server
# sends back the items
# if a world is set to remote_items = False, then the server never sends an item where receiver == finder,
# the client finds its own items in its own world.
remote_items: bool = False
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. """
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()
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
@classmethod
def stage_assert_generate(cls, world: MultiWorld) -> None:
"""Checks that a game is capable of generating, usually checks for some base file like a ROM.
Not run for unittests since they don't produce output"""
rom_file = get_base_rom_path()
if not os.path.exists(rom_file):
raise FileNotFoundError(rom_file)
def generate_early(self) -> None:
if not hasattr(self.world, "zillion_logic_cache"):
setattr(self.world, "zillion_logic_cache", {})
zz_op, item_counts = validate(self.world, self.player)
self._item_counts = item_counts
rom_dir_name = os.path.dirname(get_base_rom_path())
with redirect_stdout(self.lsi): # type: ignore
self.zz_system.make_patcher(rom_dir_name)
self.zz_system.make_randomizer(zz_op)
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.world
self.my_locations = []
self.zz_system.randomizer.place_canister_gun_reqs()
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, RegionType.Generic, here_name, p, w)
self.world.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.world.exclude_locations[p].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.world.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.world.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.world.get_location(main_loc_name, self.player)\
.place_locked_item(self.create_item("Win"))
self.world.completion_condition[self.player] = \
lambda state: state.has("Win", self.player)
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) -> None:
"""
sync zilliandomizer item locations with AP item locations
"""
assert self.zz_system.randomizer and self.zz_system.patcher, "generate_early hasn't been called"
zz_options = self.zz_system.randomizer.options
# 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 loc in self.world.get_locations():
if loc.player == self.player:
z_loc = cast(ZillionLocation, loc)
# debug_zz_loc_ids[z_loc.zz_loc.name] = id(z_loc.zz_loc)
if z_loc.item is None:
self.logger.warn("generate_output location has no item - is that ok?")
z_loc.zz_loc.item = empty
elif z_loc.item.player == self.player:
z_item = cast(ZillionItem, z_loc.item)
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.world.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"
)
zz_patcher = self.zz_system.patcher
zz_patcher.write_locations(self.zz_system.randomizer.regions,
zz_options.start_char,
self.zz_system.randomizer.loc_name_2_pretty)
zz_patcher.all_fixes_and_options(zz_options)
zz_patcher.set_external_item_interface(zz_options.start_char, zz_options.max_level)
zz_patcher.set_multiworld_items(multi_items)
zz_patcher.set_rom_to_ram_data(self.world.player_name[self.player].replace(' ', '_').encode())
def generate_output(self, output_directory: str) -> None:
"""This method gets called from a threadpool, do not use world.random here.
If you need any last-second randomization, use MultiWorld.slot_seeds[slot] instead."""
self.finalize_item_locations()
assert self.zz_system.patcher, "didn't get patcher from generate_early"
# original_rom_bytes = self.zz_patcher.rom
patched_rom_bytes = self.zz_system.patcher.get_patched_bytes()
out_file_base = self.world.get_out_file_name_base(self.player)
filename = os.path.join(
output_directory,
f'{out_file_base}{ZillionDeltaPatch.result_file_ending}'
)
with open(filename, "wb") as binary_file:
binary_file.write(patched_rom_bytes)
patch = ZillionDeltaPatch(
os.path.splitext(filename)[0] + ZillionDeltaPatch.patch_file_ending,
player=self.player,
player_name=self.world.player_name[self.player],
patched_path=filename
)
patch.write()
os.remove(filename)
def fill_slot_data(self) -> Dict[str, Any]: # 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
zz_patcher = self.zz_system.patcher
assert zz_patcher, "didn't get patcher from generate_early"
assert self.zz_system.randomizer, "didn't get randomizer from generate_early"
rescues: Dict[str, Any] = {}
for i in (0, 1):
if i in zz_patcher.rescue_locations:
ri = zz_patcher.rescue_locations[i]
rescues[str(i)] = {
"start_char": ri.start_char,
"room_code": ri.room_code,
"mask": ri.mask
}
return {
"start_char": self.zz_system.randomizer.options.start_char,
"rescues": rescues,
"loc_mem_to_id": zz_patcher.loc_memory_to_loc_id
}
# 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.world, "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.world = 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"

1
worlds/zillion/config.py Normal file
View File

@ -0,0 +1 @@
base_id = 8675309

View File

@ -0,0 +1,74 @@
# 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 settings page?
The [player settings page for this game](../player-settings) 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.

View File

@ -0,0 +1,104 @@
# Zillion Setup Guide
## Required Software
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). Make sure to check the box for `Zillion Client - Zillion Patch Setup`
- 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 "Sega - MS/GG (SMS 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.
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
### Linux Setup
Put your Zillion ROM file in the Archipelago directory in your home directory.
### Windows Setup
1. During the installation of Archipelago, install the Zillion Client. If you did not do this,
or you are on an older version, you may run the installer again to install the Zillion Client.
2. During setup, you will be asked to locate your base ROM file. This is the Zillion ROM file mentioned above in Required Software.
---
# 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 settings page](/games/Zillion/player-settings) on the website allows you to configure your personal settings 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](/mysterycheck).
## Generating a Single-Player Game
1. Navigate to the [player settings page](/games/Zillion/player-settings), 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".
- 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. This core 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.

93
worlds/zillion/id_maps.py Normal file
View File

@ -0,0 +1,93 @@
from typing import Dict, Tuple
from zilliandomizer.logic_components.items import Item as ZzItem, \
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.options import Chars
from zilliandomizer.utils.loc_name_maps import loc_to_id as pretty_loc_name_to_id
from zilliandomizer.utils import parse_reg_name
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

12
worlds/zillion/item.py Normal file
View 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

77
worlds/zillion/logic.py Normal file
View File

@ -0,0 +1,77 @@
from typing import Dict, FrozenSet, Tuple, cast, List, Counter as _Counter
from BaseClasses import CollectionState
from zilliandomizer.logic_components.locations import Location
from zilliandomizer.randomizer import Randomizer
from zilliandomizer.logic_components.items import Item, items
from .region import ZillionLocation
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
"""
z_world = cs.world.worlds[p]
my_locations = cast(List[ZillionLocation], getattr(z_world, "my_locations"))
_hash = p
for z_loc in 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) ^ 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.item_count(item_name, p)) for item_name in item_name_to_id)
LogicCacheType = Dict[int, Tuple[_Counter[Tuple[str, int]], FrozenSet[Location]]]
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.world, "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

380
worlds/zillion/options.py Normal file
View File

@ -0,0 +1,380 @@
from collections import Counter
# import logging
from typing import TYPE_CHECKING, Any, Dict, Tuple, cast
from Options import AssembleOptions, DefaultOnToggle, Range, SpecialRange, Toggle, Choice
from zilliandomizer.options import \
Options as ZzOptions, char_to_gun, char_to_jump, ID, \
VBLR as ZzVBLR, chars, Chars, ItemCounts as ZzItemCounts
from zilliandomizer.options.parsing import validate as zz_validate
if TYPE_CHECKING:
from BaseClasses import MultiWorld
class ZillionContinues(SpecialRange):
"""
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 ZillionEarlyScope(Toggle):
""" whether to make sure there is a scope available early """
display_name = "early scope"
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
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"
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 ZillionSkill(Range):
""" the difficulty level of the game """
range_start = 0
range_end = 5
default = 2
class ZillionStartingCards(SpecialRange):
"""
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"
zillion_options: Dict[str, AssembleOptions] = {
"continues": ZillionContinues,
# "early_scope": ZillionEarlyScope, # TODO: implement
"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,
"skill": ZillionSkill,
"starting_cards": ZillionStartingCards,
"room_gen": ZillionRoomGen,
}
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(world: "MultiWorld", p: int) -> "Tuple[ZzOptions, Counter[str]]":
"""
adjusts options to make game completion possible
`world` parameter is MultiWorld object that has my options on it
`p` is my player id
"""
for option_name in zillion_options:
assert hasattr(world, option_name), f"Zillion option {option_name} didn't get put in world object"
wo = cast(Any, world) # so I don't need getattr on all the options
skill = wo.skill[p].value
jump_levels = cast(ZillionJumpLevels, wo.jump_levels[p])
jump_option = jump_levels.get_current_option_name().lower()
required_level = char_to_jump["Apple"][cast(ZzVBLR, jump_option)].index(3) + 1
if skill == 0:
# because of hp logic on final boss
required_level = 8
gun_levels = cast(ZillionGunLevels, wo.gun_levels[p])
gun_option = gun_levels.get_current_option_name().lower()
guns_required = char_to_gun["Champ"][cast(ZzVBLR, gun_option)].index(3)
floppy_req = cast(ZillionFloppyReq, wo.floppy_req[p])
card = cast(ZillionIDCardCount, wo.id_card_count[p])
bread = cast(ZillionBreadCount, wo.bread_count[p])
opa = cast(ZillionOpaOpaCount, wo.opa_opa_count[p])
gun = cast(ZillionZillionCount, wo.zillion_count[p])
floppy = cast(ZillionFloppyDiskCount, wo.floppy_disk_count[p])
scope = cast(ZillionScopeCount, wo.scope_count[p])
red = cast(ZillionRedIDCardCount, wo.red_id_card_count[p])
item_counts = Counter({
"ID Card": card,
"Bread": bread,
"Opa-Opa": opa,
"Zillion": gun,
"Floppy Disk": floppy,
"Scope": scope,
"Red ID Card": red
})
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 = cast(ZillionMaxLevel, wo.max_level[p])
max_level.value = max(required_level, max_level.value)
opas_per_level = cast(ZillionOpasPerLevel, wo.opas_per_level[p])
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
start_char = cast(ZillionStartChar, wo.start_char[p])
start_char_name = start_char.get_current_option_name()
if start_char_name == "Jj":
start_char_name = "JJ"
assert start_char_name in chars
start_char_name = cast(Chars, start_char_name)
starting_cards = cast(ZillionStartingCards, wo.starting_cards[p])
room_gen = cast(ZillionRoomGen, wo.room_gen[p])
zz_item_counts = convert_item_counts(item_counts)
zz_op = ZzOptions(
zz_item_counts,
cast(ZzVBLR, jump_option),
cast(ZzVBLR, gun_option),
opas_per_level.value,
max_level.value,
False, # tutorial
skill,
start_char_name,
floppy_req.value,
wo.continues[p].value,
wo.randomize_alarms[p].value,
False, # wo.early_scope[p].value,
True, # balance defense
starting_cards.value,
bool(room_gen.value)
)
zz_validate(zz_op)
return zz_op, item_counts

34
worlds/zillion/patch.py Normal file
View File

@ -0,0 +1,34 @@
from typing import BinaryIO, Optional, cast
import Utils
from worlds.Files import APDeltaPatch
import os
USHASH = 'd4bf9e7bcf9a48da53785d2ae7bc4270'
class ZillionDeltaPatch(APDeltaPatch):
hash = USHASH
game = "Zillion"
patch_file_ending = ".apzl"
result_file_ending = ".sms"
@classmethod
def get_source_data(cls) -> bytes:
with open(get_base_rom_path(), "rb") as stream:
return read_rom(stream)
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

0
worlds/zillion/py.typed Normal file
View File

50
worlds/zillion/region.py Normal file
View File

@ -0,0 +1,50 @@
from typing import Optional
from BaseClasses import MultiWorld, Region, RegionType, 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,
type_: RegionType,
hint: str,
player: int,
world: Optional[MultiWorld] = None) -> None:
super().__init__(name, type_, hint, player, world)
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

View File

@ -0,0 +1 @@
git+https://github.com/beauxq/zilliandomizer@45a45eaca4119a4d06d2c31546ad19f3abd77f63#egg=zilliandomizer==0.4.4