This commit is contained in:
espeon65536 2021-11-24 17:57:06 -06:00
commit 6641b13511
57 changed files with 976 additions and 1602 deletions

View File

@ -12,10 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@ -12,10 +12,10 @@ jobs:
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@ -11,7 +11,7 @@ import websockets
import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient")
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
@ -39,13 +39,13 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.connect(address if address else None))
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect())
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True
def _cmd_received(self) -> bool:
@ -81,6 +81,16 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_items(self):
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_ready(self):
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
@ -89,10 +99,10 @@ class ClientCommandProcessor(CommandProcessor):
else:
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]))
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str):
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]))
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext():
@ -149,7 +159,7 @@ class CommonContext():
self.set_getters(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self))
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def total_locations(self) -> typing.Optional[int]:
@ -230,13 +240,24 @@ class CommonContext():
self.password = await self.console_input()
return self.password
async def send_connect(self, **kwargs):
payload = {
"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address=None):
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address))
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def on_print(self, args: dict):
logger.info(args["text"])
@ -271,7 +292,7 @@ class CommonContext():
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
logger.info("Sending death to your friends...")
logger.info("DeathLink: Sending death to your friends...")
self.last_death_link = time.time()
await self.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"],
@ -282,6 +303,27 @@ class CommonContext():
}
}])
async def shutdown(self):
self.server_address = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
await self.server_task
while self.input_requests > 0:
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
async def update_death_link(self, death_link):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
else:
self.tags -= {"DeathLink"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
@ -340,14 +382,14 @@ async def server_loop(ctx: CommonContext, address=None):
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx))
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2
async def server_autoreconnect(ctx: CommonContext):
await asyncio.sleep(ctx.current_reconnect_delay)
if ctx.server_address and ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx))
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
async def process_server_cmd(ctx: CommonContext, args: dict):
@ -534,6 +576,7 @@ if __name__ == '__main__':
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame"}
game = "Archipelago"
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@ -542,11 +585,7 @@ if __name__ == '__main__':
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}])
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
@ -555,7 +594,7 @@ if __name__ == '__main__':
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
input_task = None
from kvui import TextManager
@ -566,16 +605,7 @@ if __name__ == '__main__':
ui_task = None
await ctx.exit_event.wait()
ctx.server_address = None
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await ctx.shutdown()
if ui_task:
await ui_task

View File

@ -15,7 +15,7 @@ from queue import Queue
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient")
Utils.init_logging("FactorioClient", exception_logger="Client")
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
get_base_parser
@ -65,22 +65,13 @@ class FactorioContext(CommonContext):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
if not self.auth:
if self.rcon_client:
get_info(self, self.rcon_client) # retrieve current auth code
else:
raise Exception("Cannot connect to a server with unknown own identity, "
"bridge to Factorio first.")
if self.rcon_client:
await get_info(self, self.rcon_client) # retrieve current auth code
else:
raise Exception("Cannot connect to a server with unknown own identity, "
"bridge to Factorio first.")
await self.send_msgs([{
"cmd": 'Connect',
'password': self.password,
'name': self.auth,
'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(),
'game': "Factorio"
}])
await self.send_connect()
def on_print(self, args: dict):
super(FactorioContext, self).on_print(args)
@ -134,6 +125,8 @@ async def game_watcher(ctx: FactorioContext):
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
await ctx.update_death_link(data["death_link"])
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@ -148,7 +141,8 @@ async def game_watcher(ctx: FactorioContext):
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
await ctx.send_death()
if "DeathLink" in ctx.tags:
await ctx.send_death()
await asyncio.sleep(0.1)
@ -234,14 +228,13 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_process.wait(5)
def get_info(ctx, rcon_client):
async def get_info(ctx, rcon_client):
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
ctx.auth = info["slot_name"]
ctx.seed_name = info["seed_name"]
# 0.2.0 addition, not present earlier
death_link = bool(info.get("death_link", False))
if death_link:
ctx.tags.add("DeathLink")
await ctx.update_death_link(death_link)
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
@ -280,7 +273,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if ctx.mod_version == ctx.__class__.mod_version:
raise Exception("No Archipelago mod was loaded. Aborting.")
get_info(ctx, rcon_client)
await get_info(ctx, rcon_client)
await asyncio.sleep(0.01)
except Exception as e:
@ -322,14 +315,7 @@ async def main(args):
await progression_watcher
await factorio_server_task
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await ctx.shutdown()
if ui_task:
await ui_task

View File

@ -90,7 +90,8 @@ def main(args=None, callback=ERmain):
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
del(meta_weights["meta_description"])
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
else:
@ -126,7 +127,7 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
Utils.init_logging(f"Generate_{seed}.txt", loglevel=args.log_level)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
@ -139,17 +140,17 @@ def main(args=None, callback=ERmain):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
if meta_weights:
for player, path in player_path_cache.items():
weights_cache[path].setdefault("meta_ignore", [])
for key in meta_weights:
option = get_choice(key, meta_weights)
if option is not None:
for player, path in player_path_cache.items():
players_meta = weights_cache[path].get("meta_ignore", [])
if key not in players_meta:
weights_cache[path][key] = option
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
weights_cache[path][key] = option
for category_name, category_dict in meta_weights.items():
for key in category_dict:
option = get_choice(key, category_dict)
if option is not None:
for player, path in player_path_cache.items():
if category_name is None:
weights_cache[path][key] = option
elif category_name not in weights_cache[path]:
raise Exception(f"Meta: Category {category_name} is not present in {path}.")
else:
weights_cache[path][category_name][key] = option
name_counter = Counter()
erargs.player_settings = {}

View File

@ -197,8 +197,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
@ -215,6 +213,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:

View File

@ -469,7 +469,7 @@ def update_aliases(ctx: Context, team: int):
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path, ctx: Context):
async def server(websocket, path: str = "/", ctx: Context = None):
client = Client(websocket, ctx)
ctx.endpoints.append(client)
@ -591,10 +591,12 @@ def get_status_string(ctx: Context, team: int):
text = "Player Status on your team:"
for slot in ctx.locations:
connected = len(ctx.clients[team][slot])
death_link = len([client for client in ctx.clients[team][slot] if "DeathLink" in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
death_text = f" {death_link} of which are death link" if connected else ""
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{goal_text} {completion_text}"
f"{death_text}{goal_text} {completion_text}"
return text
@ -652,27 +654,27 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int]):
new_locations = set(locations) - ctx.location_checks[team, slot]
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if location in ctx.locations[slot]:
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx)
ctx.broadcast(ctx.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": locations, # duplicated data, but used for coop
"checked_locations": new_locations, # send back new checks only
}])
ctx.save()
@ -1242,6 +1244,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
game = ctx.games[slot]
if "IgnoreGame" not in args["tags"] and args['game'] != game:
errors.add('InvalidGame')
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple:
@ -1257,9 +1262,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.auth = False # swapping Team/Slot
client.team = team
client.slot = slot
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
ctx.client_ids[client.team, client.slot] = args["uuid"]
ctx.clients[team][slot].append(client)
client.version = args['version']
@ -1283,8 +1286,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage":
exclusions = set(args.get("exclusions", []))
exclusions = args.get("exclusions", [])
if exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions}
package = network_data_package.copy()
@ -1680,7 +1684,7 @@ async def main(args: argparse.Namespace):
ctx.init_save(not args.disable_save)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None,
ping_interval=None)
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,

View File

@ -379,6 +379,7 @@ class StartHints(ItemSet):
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
displayname = "Start Location Hints"
@ -399,7 +400,7 @@ per_game_common_options = {
"start_inventory": StartInventory,
"start_hints": StartHints,
"start_location_hints": StartLocationHints,
"exclude_locations": OptionSet
"exclude_locations": ExcludeLocations
}
if __name__ == "__main__":

View File

@ -1,3 +1,5 @@
# TODO: convert this into a system like AutoWorld
import bsdiff4
import yaml
import os
@ -14,16 +16,25 @@ current_patch_version = 3
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
supported_games = {"A Link to the Past", "Super Metroid"}
GAME_SOE = "Secret of Evermore"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import JAP10HASH
from worlds.alttp.Rom import JAP10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import JAP10HASH
from worlds.sm.Rom import JAP10HASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
else:
raise RuntimeError("Selected game for base rom not found.")
raise RuntimeError(f"Selected game {game} for base rom not found.")
patch = yaml.dump({"meta": metadata,
"patch": patch,
@ -31,21 +42,14 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAM
# minimum version of patch system expected for patching to be successful
"compatible_version": 3,
"version": current_patch_version,
"base_checksum": JAP10HASH})
"base_checksum": HASH})
return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
else:
raise RuntimeError("Selected game for base rom not found.")
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game)
@ -66,27 +70,30 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"]
if game_name in supported_games:
if game_name == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game_name == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
else:
raise Exception(f"No Patch handler for game {game_name}")
elif game_name == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
else:
raise Exception(f"Cannot handle game {game_name}")
if not ignore_version and data["compatible_version"] > current_patch_version:
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
patched_data = bsdiff4.patch(get_base_rom_bytes(), data["patch"])
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def get_base_rom_data(game: str):
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
file_name = Utils.get_options()["soe_options"]["rom"]
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import sys
import threading
import time
import multiprocessing
@ -14,7 +15,7 @@ from json import loads, dumps
from Utils import get_item_name_from_id, init_logging
if __name__ == "__main__":
init_logging("SNIClient")
init_logging("SNIClient", exception_logger="Client")
import colorama
@ -72,7 +73,7 @@ class LttPCommandProcessor(ClientCommandProcessor):
pass
self.ctx.snes_reconnect_address = None
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number))
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
return True
def _cmd_snes_close(self) -> bool:
@ -84,20 +85,17 @@ class LttPCommandProcessor(ClientCommandProcessor):
else:
return False
def _cmd_snes_write(self, address, data):
"""Write the specified byte (base10) to the SNES' memory address (base16)."""
if self.ctx.snes_state != SNESState.SNES_ATTACHED:
self.output("No attached SNES Device.")
return False
snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
asyncio.create_task(snes_flush_writes(self.ctx))
self.output("Data Sent")
return True
def _cmd_test_death(self):
self.ctx.on_deathlink({"source": "Console",
"time": time.time()})
# Left here for quick re-addition for debugging.
# def _cmd_snes_write(self, address, data):
# """Write the specified byte (base10) to the SNES' memory address (base16)."""
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
# self.output("No attached SNES Device.")
# return False
#
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
# asyncio.create_task(snes_flush_writes(self.ctx))
# self.output("Data Sent")
# return True
class Context(CommonContext):
@ -145,12 +143,7 @@ class Context(CommonContext):
self.awaiting_rom = False
self.auth = self.rom
auth = base64.b64encode(self.rom).decode()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(),
'game': self.game
}])
await self.send_connect(name=auth)
def on_deathlink(self, data: dict):
if not self.killing_player_task or self.killing_player_task.done():
@ -896,10 +889,10 @@ async def game_watcher(ctx: Context):
if not ctx.rom:
ctx.finished_game = False
gameName = await snes_read(ctx, SM_ROMNAME_START, 2)
if gameName is None:
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
if game_name is None:
continue
elif gameName == b"SM":
elif game_name == b"SM":
ctx.game = GAME_SM
else:
ctx.game = GAME_ALTTP
@ -912,14 +905,7 @@ async def game_watcher(ctx: Context):
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
death_link = bool(death_link[0] & 0b1)
old_tags = ctx.tags.copy()
if death_link:
ctx.tags.add("DeathLink")
else:
ctx.tags -= {"DeathLink"}
if old_tags != ctx.tags and ctx.server and not ctx.server.socket.closed:
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
@ -1083,14 +1069,24 @@ async def main():
meta, romfile = Patch.create_rom_file(args.diff_file)
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
adjustedromfile = romfile
except Exception as e:
logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
if args.diff_file.endswith(".apsoe"):
import webbrowser
webbrowser.open("http://www.evermizer.com/apclient/")
logging.info("Starting Evermizer Client in your Browser...")
import time
time.sleep(3)
sys.exit()
elif args.diff_file.endswith((".apbp", "apz3")):
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
adjustedromfile = romfile
except Exception as e:
logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
else:
asyncio.create_task(run_game(romfile))
ctx = Context(args.snes, args.connect, args.password)
if ctx.server_task is None:
@ -1105,28 +1101,19 @@ async def main():
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address))
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect")
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait()
if snes_connect_task:
snes_connect_task.cancel()
ctx.server_address = None
ctx.snes_reconnect_address = None
await watcher_task
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
if snes_connect_task:
snes_connect_task.cancel()
await watcher_task
await ctx.shutdown()
if ui_task:
await ui_task

View File

@ -122,16 +122,25 @@ parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
def get_cert_none_ssl_context():
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
@cache_argsless
def get_public_ipv4() -> str:
import socket
import urllib.request
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen('https://checkip.amazonaws.com/').read().decode('utf8').strip()
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
except Exception as e:
try:
ip = urllib.request.urlopen('https://v4.ident.me').read().decode('utf8').strip()
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
except:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
@ -143,8 +152,9 @@ def get_public_ipv6() -> str:
import socket
import urllib.request
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen('https://v6.ident.me').read().decode('utf8').strip()
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
@ -166,6 +176,9 @@ def get_default_options() -> dict:
"sni": "SNI",
"rom_start": True,
},
"soe_options": {
"rom_file": "Secret of Evermore (USA).sfc",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
@ -414,7 +427,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s]: %(message)s"):
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = local_path("logs")
os.makedirs(log_folder, exist_ok=True)
@ -433,3 +446,19 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.addHandler(
logging.StreamHandler(sys.stdout)
)
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
orig_hook = sys.excepthook
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger(exception_logger).exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
sys.excepthook = handle_exception

View File

@ -141,7 +141,7 @@ def new_room(seed: UUID):
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("hostRoom", room=room.id))
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
@ -159,7 +159,7 @@ def display_log(room: UUID):
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def hostRoom(room: UUID):
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
@ -175,20 +175,17 @@ def hostRoom(room: UUID):
return render_template("hostRoom.html", room=room)
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
def hostRoomRedirect(room: UUID):
return redirect(url_for("hostRoom", room=room))
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it

View File

@ -9,6 +9,7 @@ from pony.orm import commit
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@api_endpoints.route('/generate', methods=['POST'])
@ -35,9 +36,6 @@ def generate_api():
if "race" in json_data:
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
hint_cost = int(meta_options_source.get("hint_cost", 10))
forfeit_mode = meta_options_source.get("forfeit_mode", "goal")
if not options:
return {"text": "No options found. Expected file attachment or json weights."
}, 400
@ -45,7 +43,8 @@ def generate_api():
if len(options) > app.config["MAX_ROLL"]:
return {"text": "Max size of multiworld exceeded",
"detail": app.config["MAX_ROLL"]}, 409
meta = get_meta(meta_options_source)
meta["race"] = race
results, gen_options = roll_options(options)
if any(type(result) == str for result in results.values()):
return {"text": str(results),
@ -54,7 +53,7 @@ def generate_api():
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps({"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}), state=STATE_QUEUED,
meta=json.dumps(meta), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",

View File

@ -1,10 +1,11 @@
from flask import send_file, Response, render_template
from pony.orm import select
from Patch import update_patch_data
from Patch import update_patch_data, preferred_endings
from WebHostLib import app, Slot, Room, Seed, cache
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
def download_patch(room_id, patch_id):
patch = Slot.get(id=patch_id)
@ -19,7 +20,8 @@ def download_patch(room_id, patch_id):
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = io.BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}.apbp"
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@ -28,23 +30,6 @@ def download_spoiler(seed_id):
return Response(Seed.get(id=seed_id).spoiler, mimetype="text/plain")
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
def download_raw_patch(seed_id, player_id: int):
seed = Seed.get(id=seed_id)
patch = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not patch:
return "Patch not found"
else:
import io
patch_data = update_patch_data(patch.data, server="")
patch_data = io.BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/slot_file/<suuid:room_id>/<int:player_id>")
def download_slot_file(room_id, player_id: int):
room = Room.get(id=room_id)

View File

@ -20,6 +20,16 @@ from .check import get_yaml_data, roll_options
from .upload import upload_zip_to_db
def get_meta(options_source: dict) -> dict:
meta = {
"hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
"remaining_mode": options_source.get("forfeit_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
}
return meta
@app.route('/generate', methods=['GET', 'POST'])
@app.route('/generate/<race>', methods=['GET', 'POST'])
def generate(race=False):
@ -35,9 +45,9 @@ def generate(race=False):
else:
results, gen_options = roll_options(options)
# get form data -> server settings
hint_cost = int(request.form.get("hint_cost", 10))
forfeit_mode = request.form.get("forfeit_mode", "goal")
meta = {"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}
meta = get_meta(request.form)
meta["race"] = race
if race:
meta["item_cheat"] = False
meta["remaining"] = False

View File

@ -40,7 +40,7 @@ class Seed(db.Entity):
creation_time = Required(datetime, default=lambda: datetime.utcnow())
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags
class Command(db.Entity):
@ -53,5 +53,5 @@ class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID)
options = Required(buffer, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}")
meta = Required(LongStr, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)

View File

@ -49,7 +49,7 @@ def create():
game_options = {}
for option_name, option in world.options.items():
if option.options:
this_option = {
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
@ -66,7 +66,10 @@ def create():
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
game_options[option_name] = this_option
this_option["options"].append({
"name": "Random",
"value": "random",
})
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = {

View File

@ -0,0 +1,160 @@
# Advanced Game Options Guide
The Archipelago system generates games using player configuration files as input. Generally these are going to be
YAML files and each player will have one of these containing their custom settings for the randomized game they want to play.
On the website when you customize your settings from one of the game player settings pages which you can reach from the
[supported games page](/games). Clicking on the export settings button at the bottom will provide you with a pre-filled out
YAML with your options. The player settings page also has an option to download a fully filled out yaml containing every
option with every available setting for the available options.
## YAML Formatting
YAML files are a format of <span data-tooltip="Allegedly.">human-readable</span> markup config files. The basic syntax
of a yaml file will have `root` and then different levels of `nested` text that the generator "reads" in order to determine
your settings. To nest text, the correct syntax is **two spaces over** from its root option. A YAML file can be edited
with whatever text editor you choose to use though I personally recommend that you use [Sublime Text](https://www.sublimetext.com/).
This program out of the box supports the correct formatting for the YAML file, so you will be able to tab and get proper
highlighting for any potential errors made while editing the file. If using any other text editor such as Notepad or
Notepad++ whenever you move to nest an option that it is done with two spaces and not tabs.
Typical YAML format will look as follows:
```yaml
root_option:
nested_option_one:
option_one_setting_one: 1
option_one_setting_two: 0
nested_option_two:
option_two_setting_one: 14
option_two_setting_two: 43
```
In Archipelago YAML options are always written out in full lowercase with underscores separating any words. The numbers
following the colons here are weights. The generator will read the weight of every option the roll that option that many
times, the next option as many times as its numbered and so forth. For the above example `nested_option_one` will have
`option_one_setting_one` 1 time and `option_one_setting_two` 0 times so `option_one_setting_one` is guaranteed to occur.
For `nested_option_two`, `option_two_setting_one` will be rolled 14 times and `option_two_setting_two` will be rolled 43
times against each other. This means `option_two_setting_two` will be more likely to occur but it isn't guaranteed adding
more randomness and "mystery" to your settings. Every configurable setting supports weights.
### Root Options
Currently there are only a few options that are root options. Everything else should be nested within one of these root
options or in some cases nested within other nested options. The only options that should exist in root are `description`,
`name`, `game`, `requires`, `accessibility`, `progression_balancing`, `triggers`, and the name of the games you want
settings for.
* `description` is ignored by the generator and is simply a good way for you to organize if you have multiple files using
this to detail the intention of the file.
* `name` is the player name you would like to use and is used for your slot data to connect with most games. This can also
be filled with multiple names each having a weight to it.
* `game` is where either your chosen game goes or if you would like can be filled with multiple games each with different
weights.
* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this
is good for detailing the version of Archipelago this YAML was prepared for as if it is rolled on an older version may be
missing settings and as such will not work as expected. If any plando is used in the file then requiring it here to ensure
it will be used is good practice.
* `accessibility` determines the level of access to the game the generation will expect you to have in order to reach your
completion goal. This supports `items`, `locations`, and `none` and is set to `locations` by default.
* `items` will guarantee you can acquire all items in your world but may not be able to access all locations. This mostly
comes into play if there is any entrance shuffle in the seed as locations without items in them can be placed in areas
that make them unreachable.
* `none` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but
may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest
in a dungeon in ALTTP making it impossible to get and finish the dungeon.
* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. This
primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that
players almost always have something to do. This can be turned `on` or `off` and is `on` by default.
* `triggers` is one of the more advanced options that allows you to create conditional adjustments. You can read more
about this [here](/tutorial/archipelago/triggers/en).
### Game Options
One of your root settings will be the name of the game you would like to populate with settings in the format
`GameName`. since it is possible to give a weight to any option it is possible to have one file that can generate a seed
for you where you don't know which game you'll play. For these cases you'll want to fill the game options for every game
that can be rolled by these settings. If a game can be rolled it **must** have a settings section even if it is empty.
#### Universal Game Options
Some options in Archipelago can be used by every game but must still be placed within the relevant game's section.
Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints`,
`exclude_locations`, and various [plando options](tutorial/archipelago/plando/en).
* `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be
the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which
will give you 30 rupees.
* `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for
the location without using any hint points.
* `local_items` will force any items you want to be in your world instead of being in another world.
* `non_local_items` is the inverse of `local_items` forcing any items you want to be in another world and won't be located
in your own.
* `start_location_hints` allows you to define a location which you can then hint for to find out what item is located in
it to see how important the location is.
* `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk"
item which isn't necessary for progression to go in these locations.
### Example
```yaml
description: An example using various advanced options
name: Example Player
game: A Link to the Past
requires:
version: 0.2.0
accessibility: none
progression_balancing: on
A Link to the Past:
smallkey_shuffle:
original_dungeon: 1
any_world: 1
start_inventory:
Pegasus Boots: 1
Bombs (3): 2
start_hints:
- Hammer
local_items:
- Bombos
- Ether
- Quake
non_local_items:
- Moon Pearl
start_location_hints:
- Spike Cave
exclude_locations:
- Cave 45
triggers:
- option_category: A Link to the Past
option_name: smallkey_shuffle
option_result: any_world
options:
A Link to the Past:
bigkey_shuffle: any_world
map_shuffle: any_world
compass_shuffle: any_world
```
#### This is a fully functional yaml file that will do all the following things:
* `description` gives us a general overview so if we pull up this file later we can understand the intent.
* `name` is `Example Player` and this will be used in the server console when sending and receiving items.
* `game` is set to `A Link to the Past` meaning that is what game we will play with this file.
* `requires` is set to require release version 0.2.0 or higher.
* `accesibility` is set to `none` which will set this seed to beatable only meaning some locations and items may be
completely inaccessible but the seed will still be completable.
* `progression_balancing` is set on meaning we will likely receive important items earlier increasing the chance of having
things to do.
* `A Link to the Past` defines a location for us to nest all the game options we would like to use for our game `A Link to the Past`.
* `smallkey_shuffle` is an option for A Link to the Past which determines how dungeon small keys are shuffled. In this example
we have a 1/2 chance for them to either be placed in their original dungeon and a 1/2 chance for them to be placed anywhere
amongst the multiworld.
* `start_inventory` defines an area for us to determine what items we would like to start the seed with. For this example
we have:
* `Pegasus Boots: 1` which gives us 1 copy of the Pegasus Boots
* `Bombs (3)` gives us 2 packs of 3 bombs or 6 total bombs
* `start_hints` gives us a starting hint for the hammer available at the beginning of the multiworld which we can use with no cost.
* `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we
have to find it ourselves.
* `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it.
* `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the multiworld
that can be used for no cost.
* `exclude_locations` forces a not important item to be placed on the `Cave 45` location.
* `triggers` allows us to define a trigger such that if our `smallkey_shuffle` option happens to roll the `any_world`
result it will also ensure that `bigkey_shuffle`, `map_shuffle`, and `compass_shuffle` are also forced to the `any_world`
result.

View File

@ -12,6 +12,17 @@ game/games you plan to play are available here go ahead and install these as wel
supported by Archipelago but not listed in the installation check the relevant tutorial.
## Generating a game
### Creating a YAML
In a multiworld there must be one YAML per world. Any number of players can play on each world using either the game's
native coop system or using archipelago's coop support. Each world will hold one slot in the multiworld and will have a
slot name and, if the relevant game requires it, files to associate it with that multiworld. If multiple people plan to
play in one world cooperatively then they will only need one YAML for their coop world, but if each player is planning on
playing their own game then they will each need a YAML. These YAML files can be generated by going to the relevant game's
player settings page, entering the name they want to use for the game, setting the options to what they would like to
play with and then clicking on the export settings button. This will then download a YAML file that will contain all of
these options and this can then be given to whoever is going to generate the game.
### Gather all player YAMLS
All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with.
A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file.
@ -51,6 +62,7 @@ The generator will put a zip folder into your `Archipelago\output` folder with t
This contains the patch files and relevant mods for the players as well as the serverdata for the host.
## Hosting a multiworld
### Uploading the seed to the website
The easiest and most recommended method is to generate the game on the website which will allow you to create a private
room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games.

View File

@ -107,6 +107,9 @@ Archipelago if you chose to include it during the installation process.
10. Enter `localhost` into the server address box
11. Click "Connect"
For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP
server, you can also issue the `!help` command to learn about additional commands like `!hint`.
## Allowing Other People to Join Your Game
1. Ensure your Archipelago Client is running.
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.

View File

@ -4,7 +4,7 @@
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A Guide to setting up the Archipelago software to generate multiworld games on your computer.",
"description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.",
"files": [
{
"language": "English",
@ -16,9 +16,23 @@
}
]
},
{
"name": "Using Advanced Settings",
"description": "A guide to reading yaml files and editing them to fully customize your game.",
"files": [
{
"language": "English",
"filename": "archipelago/advanced_settings_en.md",
"link": "archipelago/advanced_settings/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Triggers Guide",
"description": "A Guide to setting up and using triggers in your game settings.",
"description": "A guide to setting up and using triggers in your game settings.",
"files": [
{
"language": "English",

View File

@ -1,10 +1,10 @@
# A Link to the Past Randomizer Setup Guide
## Required Software
- [Z3Client](https://github.com/ArchipelagoMW/Z3Client/releases) or the LttPClient included with
- [Z3Client](https://github.com/ArchipelagoMW/Z3Client/releases) or the SNIClient included with
[Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- If installing Archipelago, make sure to check the box for LttPClient during install, or SNI will not be included
- [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and LttPClient)
- If installing Archipelago, make sure to check the box for SNIClient -> A Link to the Past Patch Setup during install, or SNI will not be included
- [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and SNIClient)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
@ -76,7 +76,7 @@ Firewall.
4. In the new window, click **Browse...**
5. Select the connector lua file included with your client
- Z3Client users should download `sniConnector.lua` from the client download page
- LttPClient users should look in their Archipelago folder for `/sni/Connector.lua`
- SNIClient users should look in their Archipelago folder for `/sni/Connector.lua`
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
@ -88,7 +88,7 @@ Firewall.
4. Click the button to open a new Lua script.
5. Select the `sniConnector.lua` file you downloaded above
- Z3Client users should download `sniConnector.lua` from the client download page
- LttPClient users should look in their Archipelago folder for `/sni/Connector.lua`
- SNIClient users should look in their Archipelago folder for `/sni/Connector.lua`
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not

View File

@ -28,7 +28,7 @@ can all have different options.
### Where do I get a YAML file?
A basic OOT yaml will look like this. (There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download (Archipelago)[https://github.com/ArchipelagoMW/Archipelago/releases] and look for the sample file in the "Players" folder))
A basic OOT yaml will look like this. There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and look for the sample file in the "Players" folder.
```yaml
description: Default Ocarina of Time Template # Used to describe your yaml. Useful if you have multiple files
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit

View File

@ -34,7 +34,14 @@
<table>
<tbody>
<tr>
<td><label for="forfeit_mode">Forfeit Permission:</label></td>
<td>
<label for="forfeit_mode">Forfeit Permission:</label>
<span
class="interactive"
data-tooltip="A forfeit releases all remaining items from the locations
in your world.">(?)
</span>
</td>
<td>
<select name="forfeit_mode" id="forfeit_mode">
<option value="auto">Automatic on goal completion</option>
@ -46,12 +53,49 @@
</td>
</tr>
<tr>
<td>
<label for="collect_mode">Collect Permission:</label>
<span
class="interactive"
data-tooltip="A collect releases all of your remaining items to you
from across the multiworld.">(?)
</span>
</td>
<td>
<select name="collect_mode" id="collect_mode">
<option value="goal">Allow !collect after goal completion</option>
<option value="auto">Automatic on goal completion</option>
<option value="auto-enabled">Automatic on goal completion and manual !collect</option>
<option value="enabled">Manual !collect</option>
<option value="disabled">Disabled</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="remaining_mode">Remaining Permission:</label>
<span
class="interactive"
data-tooltip="Remaining lists all items still in your world by name only.">(?)
</span>
</td>
<td>
<select name="remaining_mode" id="remaining_mode">
<option value="disabled">Disabled</option>
<option value="goal">Allow !remaining after goal completion</option>
<option value="enabled">Manual !remaining</option>
</select>
</td>
</tr>
<tr>
<td>
<label for="hint_cost"> Hint Cost:</label>
<span
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
to get the location of that hint item.">(?)
</span>
</td>

View File

@ -20,7 +20,7 @@
later,
you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %}
You can connect to this room by using '/connect archipelago.gg:{{ room.last_port }}'
You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}

View File

@ -28,34 +28,16 @@
<td><a href="{{ url_for("download_spoiler", seed_id=seed.id) }}">Download</a></td>
</tr>
{% endif %}
{% if seed.multidata %}
<tr>
<td>Rooms:&nbsp;</td>
<td>
{% call macros.list_rooms(rooms) %}
<li>
<a href="{{ url_for("new_room", seed=seed.id) }}">Create New Room</a>
</li>
{% endcall %}
</td>
</tr>
{% else %}
<tr>
<td>Files:&nbsp;</td>
<td>
<ul>
{% for slot in seed.slots %}
<td>Rooms:&nbsp;</td>
<td>
{% call macros.list_rooms(rooms) %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=slot.player_id) }}">Player {{ slot.player_name }}</a>
<a href="{{ url_for("new_room", seed=seed.id) }}">Create New Room</a>
</li>
{% endfor %}
</ul>
{% endcall %}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>

View File

@ -10,10 +10,7 @@ from pony.orm import flush, select
from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml
accepted_zip_contents = {"patches": ".apbp",
"spoiler": ".txt",
"multidata": ".archipelago"}
from Patch import preferred_endings
banned_zip_contents = (".sfc",)
@ -29,15 +26,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith(".apbp"):
elif file.filename.endswith(tuple(preferred_endings.values())):
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)", 500
return "Old format cannot be uploaded (outdated .apbp)"
metadata = yaml_data["meta"]
slots.add(Slot(data=data, player_name=metadata["player_name"],
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="A Link to the Past"))
game=yaml_data["game"]))
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
@ -66,8 +65,8 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
MultiServer.Context._decompress(multidata)
except:
flash("Could not load multidata. File may be corrupted or incompatible.")
else:
multidata = zfile.open(file).read()
multidata = None
if multidata:
flush() # commit slots
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta),

View File

@ -1,9 +1,9 @@
<TabbedPanel>
tab_width: 200
<Row@Label>:
<SelectableLabel>:
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
Rectangle:
size: self.size
pos: self.pos
@ -13,10 +13,10 @@
font_size: dp(20)
markup: True
<UILog>:
viewclass: 'Row'
viewclass: 'SelectableLabel'
scroll_y: 0
effect_cls: "ScrollEffect"
RecycleBoxLayout:
SelectableRecycleBoxLayout:
default_size: None, dp(20)
default_size_hint: 1, None
size_hint_y: None

View File

@ -140,7 +140,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
| ---- | ---- | ----- |
| hint_points | int | New argument. The client's current hint points. |
| players | list\[NetworkPlayer\] | Changed argument. Always sends all players, whether connected or not. |
| checked_locations | May be a partial update, containing new locations that were checked. |
| checked_locations | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. |
| missing_locations | Should never be sent as an update, if needed is the inverse of checked_locations. |
All arguments for this packet are optional, only changes are sent.

View File

@ -0,0 +1,52 @@
# This is a sample configuration for the Web host.
# If you wish to change any of these, rename this file to config.yaml
# Default values are shown here. Uncomment and change the values as desired.
# TODO
#SELFHOST: true
# Maximum concurrent world gens
#GENERATORS: 8
# TODO
#SELFLAUNCH: true
# TODO
#DEBUG: false
# Web hosting port
#PORT: 80
# Place where uploads go.
#UPLOAD_FOLDER: uploads
# Maximum upload size. Default is 64 megabyte (64 * 1024 * 1024)
#MAX_CONTENT_LENGTH: 67108864
# Secret key used to determine important things like cookie authentication of room/seed page ownership.
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
# SECRET_KEY: "Your secret key here"
# TODO
#JOB_THRESHOLD: 2
# waitress uses one thread for I/O, these are for processing of view that get sent
#WAITRESS_THREADS: 10
# Database provider details:
#PONY:
# provider: "sqlite"
# filename: "ap.db3" # This MUST be the ABSOLUTE PATH to the file.
# create_db: true
# Maximum number of players that are allowed to be rolled on the server. After this limit, one should roll locally and upload the results.
#MAX_ROLL: 20
# TODO
#CACHE_TYPE: "simple"
# TODO
#JSON_AS_ASCII: false
# Patch target. This is the address encoded into the patch that will be used for client auto-connect.
#PATCH_TARGET: archipelago.gg

View File

@ -48,21 +48,21 @@ Name: "playing"; Description: "Installation for playing purposes"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728
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
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
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: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
@ -136,7 +136,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{ap
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
[Code]
const
SHCONTCH_NOPROGRESSBOX = 4;
@ -221,6 +220,19 @@ var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
if Length(data) mod 1024 = 512 then
begin
data := copy(data, 513, Length(data)-512);
end;
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
@ -229,8 +241,8 @@ begin
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(rom), hash)));
if CompareStr(GetMD5OfFile(rom), hash) = 0 then
log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
@ -317,7 +329,16 @@ begin
MinecraftDownloadPage.Hide;
end;
Result := True;
end else
end
else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else
Result := True;
end;
@ -327,7 +348,7 @@ begin
Result := lttprom
else if Assigned(LttPRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -343,7 +364,7 @@ begin
Result := smrom
else if Assigned(SMRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -359,8 +380,7 @@ begin
Result := soerom
else if Assigned(SoERomFilePage) then
begin
R := CompareStr(GetMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
log(GetMD5OfFile(SoEROMFilePage.Values[0]))
R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -374,7 +394,7 @@ function GetOoTROMPath(Param: string): string;
begin
if Length(ootrom) > 0 then
Result := ootrom
else if Assigned(OoTROMFilePage) then
else if (Assigned(OoTROMFilePage)) then
begin
R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f');
if R <> 0 then
@ -417,4 +437,4 @@ begin
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot'));
end;
end;

View File

@ -48,21 +48,21 @@ Name: "playing"; Description: "Installation for playing purposes"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728
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
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
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: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
@ -136,7 +136,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{ap
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
[Code]
const
SHCONTCH_NOPROGRESSBOX = 4;
@ -221,6 +220,19 @@ var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
if Length(data) mod 1024 = 512 then
begin
data := copy(data, 513, Length(data)-512);
end;
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
@ -229,8 +241,8 @@ begin
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(rom), hash)));
if CompareStr(GetMD5OfFile(rom), hash) = 0 then
log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
@ -317,7 +329,16 @@ begin
MinecraftDownloadPage.Hide;
end;
Result := True;
end else
end
else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else
Result := True;
end;
@ -327,7 +348,7 @@ begin
Result := lttprom
else if Assigned(LttPRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -343,7 +364,7 @@ begin
Result := smrom
else if Assigned(SMRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -359,8 +380,7 @@ begin
Result := soerom
else if Assigned(SoERomFilePage) then
begin
R := CompareStr(GetMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
log(GetMD5OfFile(SoEROMFilePage.Values[0]))
R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);

91
kvui.py
View File

@ -2,7 +2,6 @@ import os
import logging
import typing
import asyncio
import sys
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
@ -11,6 +10,8 @@ os.environ["KIVY_LOG_ENABLE"] = "0"
from kivy.app import App
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel
from kivy.base import ExceptionHandler, ExceptionManager, Config, Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
@ -25,6 +26,10 @@ from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar
from kivy.utils import escape_markup
from kivy.lang import Builder
from kivy.uix.recycleview.views import RecycleDataViewBehavior
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart
@ -140,6 +145,46 @@ class ContainerLayout(FloatLayout):
pass
class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
RecycleBoxLayout):
""" Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, Label):
""" Add selection support to the Label """
index = None
selected = BooleanProperty(False)
def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """
self.index = index
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
return True
if self.collide_point(*touch.pos):
if self.selected:
self.parent.clear_selection()
else:
# Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]")))
cmdinput = App.get_running_app().textinput
if not cmdinput.text and text.startswith("Didn't find something that closely matches, did you mean "):
name = Utils.get_text_between(text, "Didn't find something that closely matches, did you mean ",
"? (")
cmdinput.text = f"!hint {name}"
Clipboard.copy(text)
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
self.selected = is_selected
class GameManager(App):
logging_pairs = [
("Client", "Archipelago"),
@ -164,7 +209,8 @@ class GameManager(App):
# top part
server_label = ServerLabel()
connect_layout.add_widget(server_label)
self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False)
self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False,
write_tab=False)
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
@ -201,33 +247,21 @@ class GameManager(App):
info_button = Button(height=30, text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
textinput.bind(on_text_validate=self.on_message)
bottom_layout.add_widget(textinput)
self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message)
def text_focus(event):
"""Needs to be set via delay, as unfocusing happens after on_message"""
self.textinput.focus = True
self.textinput.text_focus = text_focus
bottom_layout.add_widget(self.textinput)
self.grid.add_widget(bottom_layout)
self.commandprocessor("/help")
Clock.schedule_interval(self.update_texts, 1 / 30)
self.container.add_widget(self.grid)
self.catch_unhandled_exceptions()
return self.container
def catch_unhandled_exceptions(self):
"""Relay unhandled exceptions to UI logger."""
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
orig_hook = sys.excepthook
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger("Client").exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
sys.excepthook = handle_exception
def update_texts(self, dt):
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
@ -242,7 +276,11 @@ class GameManager(App):
self.progressbar.value = 0
def command_button_action(self, button):
logging.getLogger("Client").info("/help for client commands and !help for server commands.")
if self.ctx.server:
logging.getLogger("Client").info("/help for client commands and !help for server commands.")
else:
logging.getLogger("Client").info("/help for client commands and once you are connected, "
"!help for server commands.")
def connect_button_action(self, button):
if self.ctx.server:
@ -269,6 +307,9 @@ class GameManager(App):
self.ctx.input_queue.put_nowait(input_text)
elif input_text:
self.commandprocessor(input_text)
Clock.schedule_once(textinput.text_focus)
except Exception as e:
logging.getLogger("Client").exception(e)
@ -304,7 +345,7 @@ class TextManager(GameManager):
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.DEBUG)
super(LogtoUI, self).__init__(logging.INFO)
self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None:

View File

@ -4,12 +4,6 @@
# For example, if a meta.yaml fast_ganon result is rolled, every player will have that fast_ganon goal
# There is the special case of null, which ignores that part of the meta.yaml,
# allowing for a chance for that meta to not take effect
# Players can also have a meta_ignore option to ignore specific options
# Example of ignore that would be in a player's file:
# meta_ignore:
# mode:
# inverted
# This means, if mode is meta-rolled and the result happens to be inverted, then defer to the player's yaml instead.
meta_description: Meta-Mystery file with the intention of having similar-length completion times for a hopefully better experience
null:
progression_balancing: # Progression balancing tries to make sure that the player has *something* towards any players goal in each "sphere"
@ -33,26 +27,6 @@ A Link to the Past:
open: 60
inverted: 10
null: 10 # Maintain individual world states
tower_open:
'0': 8
'1': 7
'2': 6
'3': 5
'4': 4
'5': 3
'6': 2
'7': 1
random: 10 # A different GT open time should not usually result in a vastly different completion time, unless ganon goal and tower_open > ganon_open
ganon_open:
'0': 3
'1': 4
'2': 5
'3': 6
'4': 7
'5': 8
'6': 9
'7': 10
random: 5 # This will mean differing completion times. But leaving it for that surprise effect
triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@ import os
__all__ = {"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name",
"network_data_package"}
"network_data_package",
"AutoWorldRegister"}
# import all submodules to trigger AutoWorldRegister
for file in os.scandir(os.path.dirname(__file__)):

View File

@ -284,7 +284,7 @@ junk_texts = [
"{C:GREEN}\nTheres always\nmoney in the\nBanana Stand>",
"{C:GREEN}\n \nJust walk away\n >",
"{C:GREEN}\neverybody is\nlooking for\nsomething >",
"{C:GREEN}\nSpring Ball\nare behind\nRidley >",
# "{C:GREEN}\nSpring Ball\nare behind\nRidley >", removed as people may assume it's a real hint
"{C:GREEN}\nThe gnome asks\nyou to guess\nhis name. >",
"{C:GREEN}\nI heard beans\non toast is a\ngreat meal. >",
"{C:GREEN}\n> Sweetcorn\non pizza is a\ngreat choice.",

View File

@ -47,10 +47,16 @@ recipe_time_scales = {
Options.RecipeTime.option_vanilla: None
}
recipe_time_ranges = {
Options.RecipeTime.option_new_fast: (0.25, 2),
Options.RecipeTime.option_new_normal: (0.25, 10),
Options.RecipeTime.option_slow: (5, 10)
}
def generate_mod(world, output_directory: str):
player = world.player
multiworld = world.world
global data_final_template, locale_template, control_template, data_template
global data_final_template, locale_template, control_template, data_template, settings_template
with template_load_lock:
if not data_final_template:
mod_template_folder = os.path.join(os.path.dirname(__file__), "data", "mod_template")
@ -60,6 +66,7 @@ def generate_mod(world, output_directory: str):
data_final_template = template_env.get_template("data-final-fixes.lua")
locale_template = template_env.get_template(r"locale/en/locale.cfg")
control_template = template_env.get_template("control.lua")
settings_template = template_env.get_template("settings.lua")
# get data for templates
player_names = {x: multiworld.player_name[x] for x in multiworld.player_ids}
locations = []
@ -91,11 +98,12 @@ def generate_mod(world, output_directory: str):
"mod_name": mod_name, "allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(),
"tech_cost_scale": tech_cost_scale, "custom_technologies": multiworld.worlds[player].custom_technologies,
"tech_tree_layout_prerequisites": multiworld.tech_tree_layout_prerequisites[player],
"slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name,
"slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name, "slot_player": player,
"starting_items": multiworld.starting_items[player], "recipes": recipes,
"random": random, "flop_random": flop_random,
"static_nodes": multiworld.worlds[player].static_nodes,
"recipe_time_scale": recipe_time_scales[multiworld.recipe_time[player].value],
"recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None),
"recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None),
"free_sample_blacklist": {item : 1 for item in free_sample_blacklist},
"progressive_technology_table": {tech.name : tech.progressive for tech in
progressive_technology_table.values()},
@ -107,10 +115,14 @@ def generate_mod(world, output_directory: str):
if getattr(multiworld, "silo")[player].value == Options.Silo.option_randomize_recipe:
template_data["free_sample_blacklist"]["rocket-silo"] = 1
if getattr(multiworld, "satellite")[player].value == Options.Satellite.option_randomize_recipe:
template_data["free_sample_blacklist"]["satellite"] = 1
control_code = control_template.render(**template_data)
data_template_code = data_template.render(**template_data)
data_final_fixes_code = data_final_template.render(**template_data)
settings_code = settings_template.render(**template_data)
mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)
en_locale_dir = os.path.join(mod_dir, "locale", "en")
@ -122,6 +134,8 @@ def generate_mod(world, output_directory: str):
f.write(data_final_fixes_code)
with open(os.path.join(mod_dir, "control.lua"), "wt") as f:
f.write(control_code)
with open(os.path.join(mod_dir, "settings.lua"), "wt") as f:
f.write(settings_code)
locale_content = locale_template.render(**template_data)
with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f:
f.write(locale_content)

View File

@ -55,6 +55,14 @@ class Silo(Choice):
default = 0
class Satellite(Choice):
"""Ingredients to craft satellite."""
displayname = "Satellite"
option_vanilla = 0
option_randomize_recipe = 1
default = 0
class FreeSamples(Choice):
"""Get free items with your technologies."""
displayname = "Free Samples"
@ -91,13 +99,25 @@ class TechTreeInformation(Choice):
class RecipeTime(Choice):
"""randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc."""
"""Randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc.
Fast: 0.25X - 1X
Normal: 0.5X - 2X
Slow: 1X - 4X
Chaos: 0.25X - 4X
New category: ignores vanilla recipe time and rolls new one
New Fast: 0.25 - 2 seconds
New Normal: 0.25 - 10 seconds
New Slow: 5 - 10 seconds
"""
displayname = "Recipe Time"
option_vanilla = 0
option_fast = 1
option_normal = 2
option_slow = 4
option_chaos = 5
option_new_fast = 6
option_new_normal = 7
option_new_slow = 8
class Progressive(Choice):
@ -289,6 +309,7 @@ factorio_options: typing.Dict[str, type(Option)] = {
"tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost,
"silo": Silo,
"satellite": Satellite,
"free_samples": FreeSamples,
"tech_tree_information": TechTreeInformation,
"starting_items": FactorioStartItems,

View File

@ -59,8 +59,8 @@ class Technology(FactorioElement): # maybe make subclass of Location?
def build_rule(self, player: int):
logging.debug(f"Building rules for {self.name}")
return lambda state, technologies=technologies: all(state.has(f"Automated {ingredient}", player)
for ingredient in self.ingredients)
return lambda state: all(state.has(f"Automated {ingredient}", player)
for ingredient in self.ingredients)
def get_prior_technologies(self) -> Set[Technology]:
"""Get Technologies that have to precede this one to resolve tree connections."""
@ -300,19 +300,17 @@ for category_name, machine_name in machine_per_category.items():
required_technologies: Dict[str, FrozenSet[Technology]] = Utils.KeyedDefaultDict(lambda ingredient_name: frozenset(
recursively_get_unlocking_technologies(ingredient_name, unlock_func=unlock)))
advancement_technologies: Set[str] = set()
for ingredient_name in all_ingredient_names:
technologies = required_technologies[ingredient_name]
advancement_technologies |= {technology.name for technology in technologies}
def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe) -> Set[str]:
def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe, satellite_recipe: Recipe) -> Set[str]:
techs = set()
if silo_recipe:
for ingredient in silo_recipe.ingredients:
techs |= recursively_get_unlocking_technologies(ingredient)
for ingredient in part_recipe.ingredients:
techs |= recursively_get_unlocking_technologies(ingredient)
if satellite_recipe:
techs |= satellite_recipe.unlocking_technologies
for ingredient in satellite_recipe.ingredients:
techs |= recursively_get_unlocking_technologies(ingredient)
return {tech.name for tech in techs}
@ -335,8 +333,6 @@ rocket_recipes = {
{"copper-cable": 10, "iron-plate": 10, "wood": 10}
}
advancement_technologies |= {tech.name for tech in required_technologies["rocket-silo"]}
# progressive technologies
# auto-progressive
progressive_rows: Dict[str, Union[List[str], Tuple[str, ...]]] = {}
@ -430,8 +426,6 @@ for root in sorted_rows:
unlocks=any(technology_table[tech].unlocks for tech in progressive))
progressive_tech_table[root] = progressive_technology.factorio_id
progressive_technology_table[root] = progressive_technology
if any(tech in advancement_technologies for tech in progressive):
advancement_technologies.add(root)
tech_to_progressive_lookup: Dict[str, str] = {}
for technology in progressive_technology_table.values():

View File

@ -1,15 +1,16 @@
import collections
import typing
from ..AutoWorld import World
from BaseClasses import Region, Entrance, Location, Item
from .Technologies import base_tech_table, recipe_sources, base_technology_table, advancement_technologies, \
from .Technologies import base_tech_table, recipe_sources, base_technology_table, \
all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, rocket_recipes, \
progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \
get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies
from .Shapes import get_shapes
from .Mod import generate_mod
from .Options import factorio_options, Silo, TechTreeInformation
from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation
import logging
@ -32,13 +33,17 @@ class Factorio(World):
game: str = "Factorio"
static_nodes = {"automation", "logistics", "rocket-silo"}
custom_recipes = {}
additional_advancement_technologies = set()
advancement_technologies: typing.Set[str]
item_name_to_id = all_items
location_name_to_id = base_tech_table
data_version = 5
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
self.advancement_technologies = set()
def generate_basic(self):
player = self.player
want_progressives = collections.defaultdict(lambda: self.world.progressive[player].
@ -137,11 +142,13 @@ class Factorio(World):
locations=locations: all(state.can_reach(loc) for loc in locations))
silo_recipe = None if self.world.silo[self.player].value == Silo.option_spawn \
else self.custom_recipes["rocket-silo"] \
if "rocket-silo" in self.custom_recipes \
else self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \
else next(iter(all_product_sources.get("rocket-silo")))
part_recipe = self.custom_recipes["rocket-part"]
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe)
satellite_recipe = None if self.world.max_science_pack[self.player].value != MaxSciencePack.option_space_science_pack \
else self.custom_recipes["satellite"] if "satellite" in self.custom_recipes \
else next(iter(all_product_sources.get("satellite")))
victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe)
world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player)
for technology in
victory_tech_names)
@ -189,12 +196,12 @@ class Factorio(World):
fallback_pool = []
# fill all but one slot with random ingredients, last with a good match
while remaining_num_ingredients > 0 and len(pool) > 0:
while remaining_num_ingredients > 0 and pool:
if remaining_num_ingredients == 1:
max_raw = 1.1 * remaining_raw
min_raw = 0.9 * remaining_raw
max_energy = 1.1 * remaining_energy
min_energy = 1.1 * remaining_energy
min_energy = 0.9 * remaining_energy
else:
max_raw = remaining_raw * 0.75
min_raw = (remaining_raw - max_raw) / remaining_num_ingredients
@ -226,7 +233,7 @@ class Factorio(World):
# fill failed slots with whatever we got
pool = fallback_pool
while remaining_num_ingredients > 0 and len(pool) > 0:
while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop()
if ingredient not in recipes:
logging.warning(f"missing recipe for {ingredient}")
@ -264,8 +271,6 @@ class Factorio(World):
{valid_pool[x]: 10 for x in range(3)},
original_rocket_part.products,
original_rocket_part.energy)}
self.additional_advancement_technologies = {tech.name for tech in
self.custom_recipes["rocket-part"].recursive_unlocking_technologies}
if self.world.recipe_ingredients[self.player]:
valid_pool = []
@ -278,31 +283,45 @@ class Factorio(World):
for _ in original.ingredients:
new_ingredients[valid_pool.pop()] = 1
new_recipe = Recipe(pack, original.category, new_ingredients, original.products, original.energy)
self.additional_advancement_technologies |= {tech.name for tech in
new_recipe.recursive_unlocking_technologies}
self.custom_recipes[pack] = new_recipe
if self.world.silo[self.player].value == Silo.option_randomize_recipe:
if self.world.silo[self.player].value == Silo.option_randomize_recipe \
or self.world.satellite[self.player].value == Satellite.option_randomize_recipe:
valid_pool = []
for pack in sorted(self.world.max_science_pack[self.player].get_allowed_packs()):
valid_pool += sorted(science_pack_pools[pack])
new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool,
factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.additional_advancement_technologies |= {tech.name for tech in
new_recipe.recursive_unlocking_technologies}
self.custom_recipes["rocket-silo"] = new_recipe
if self.world.silo[self.player].value == Silo.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool.copy(),
factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.custom_recipes["rocket-silo"] = new_recipe
if self.world.satellite[self.player].value == Satellite.option_randomize_recipe:
new_recipe = self.make_balanced_recipe(recipes["satellite"], valid_pool,
factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.custom_recipes["satellite"] = new_recipe
needed_recipes = self.world.max_science_pack[self.player].get_allowed_packs() | {"rocket-part"}
if self.world.silo[self.player] != Silo.option_spawn:
needed_recipes |= {"rocket-silo"}
if self.world.max_science_pack[self.player].value == MaxSciencePack.option_space_science_pack:
needed_recipes |= {"satellite"}
for recipe in needed_recipes:
recipe = self.custom_recipes.get(recipe, recipes[recipe])
self.advancement_technologies |= {tech.name for tech in recipe.unlocking_technologies}
self.advancement_technologies |= {tech.name for tech in recipe.recursive_unlocking_technologies}
# handle marking progressive techs as advancement
prog_add = set()
for tech in self.additional_advancement_technologies:
for tech in self.advancement_technologies:
if tech in tech_to_progressive_lookup:
prog_add.add(tech_to_progressive_lookup[tech])
self.additional_advancement_technologies |= prog_add
self.advancement_technologies |= prog_add
def create_item(self, name: str) -> Item:
if name in tech_table:
return FactorioItem(name, name in advancement_technologies or
name in self.additional_advancement_technologies,
return FactorioItem(name, name in self.advancement_technologies,
tech_table[name], self.player)
item = FactorioItem(name, False, all_items[name], self.player)

View File

@ -8,7 +8,14 @@ SLOT_NAME = "{{ slot_name }}"
SEED_NAME = "{{ seed_name }}"
FREE_SAMPLE_BLACKLIST = {{ dict_to_lua(free_sample_blacklist) }}
TRAP_EVO_FACTOR = {{ evolution_trap_increase }} / 100
DEATH_LINK = {{ death_link | int }}
MAX_SCIENCE_PACK = {{ max_science_pack }}
ARCHIPELAGO_DEATH_LINK_SETTING = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}"
if settings.global[ARCHIPELAGO_DEATH_LINK_SETTING].value then
DEATH_LINK = 1
else
DEATH_LINK = 0
end
CURRENTLY_DEATH_LOCK = 0
@ -76,6 +83,27 @@ function on_force_destroyed(event)
global.forcedata[event.force.name] = nil
end
function on_runtime_mod_setting_changed(event)
local force
if event.player_index == nil then
force = game.forces.player
else
force = game.players[event.player_index].force
end
if event.setting == ARCHIPELAGO_DEATH_LINK_SETTING then
if settings.global[ARCHIPELAGO_DEATH_LINK_SETTING].value then
DEATH_LINK = 1
else
DEATH_LINK = 0
end
if force ~= nil then
dumpInfo(force)
end
end
end
script.on_event(defines.events.on_runtime_mod_setting_changed, on_runtime_mod_setting_changed)
-- Initialize player data, either from them joining the game or them already being part of the game when the mod was
-- added.`
function on_player_created(event)
@ -107,8 +135,19 @@ end
script.on_event(defines.events.on_player_removed, on_player_removed)
function on_rocket_launched(event)
global.forcedata[event.rocket.force.name]['victory'] = 1
dumpInfo(event.rocket.force)
if event.rocket and event.rocket.valid and global.forcedata[event.rocket.force.name]['victory'] == 0 then
if event.rocket.get_item_count("satellite") > 0 or MAX_SCIENCE_PACK < 6 then
global.forcedata[event.rocket.force.name]['victory'] = 1
dumpInfo(event.rocket.force)
game.set_game_state
{
game_finished = true,
player_won = true,
can_continue = true,
victorious_force = event.rocket.force
}
end
end
end
script.on_event(defines.events.on_rocket_launched, on_rocket_launched)
@ -198,6 +237,10 @@ script.on_init(function()
e.player_index = index
on_player_created(e)
end
if remote.interfaces["silo_script"] then
remote.call("silo_script", "set_no_victory", true)
end
end)
-- hook into researches done
@ -366,18 +409,19 @@ function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores)
end
if DEATH_LINK == 1 then
script.on_event(defines.events.on_entity_died, function(event)
if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event
return
end
script.on_event(defines.events.on_entity_died, function(event)
if DEATH_LINK == 0 then
return
end
if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event
return
end
local force = event.entity.force
global.forcedata[force.name].death_link_tick = game.tick
dumpInfo(force)
kill_players(force)
end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}})
end
local force = event.entity.force
global.forcedata[force.name].death_link_tick = game.tick
dumpInfo(force)
kill_players(force)
end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}})
-- add / commands
@ -392,7 +436,8 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress
local data_collection = {
["research_done"] = research_done,
["victory"] = chain_lookup(global, "forcedata", force.name, "victory"),
["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick")
["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick"),
["death_link"] = DEATH_LINK
}
for tech_name, tech in pairs(force.technologies) do
@ -423,8 +468,8 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
game.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."})
game.play_sound({path="utility/research_completed"})
tech.researched = true
return
end
return
elseif progressive_technologies[item_name] ~= nil then
if global.index_sync[index] == nil then -- not yet received prog item
global.index_sync[index] = item_name
@ -442,9 +487,6 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
elseif force.technologies[item_name] ~= nil then
tech = force.technologies[item_name]
if tech ~= nil then
if global.index_sync[index] ~= nil and global.index_sync[index] ~= tech then
game.print("Warning: Desync Detected. Duplicate/Missing items may occur.")
end
global.index_sync[index] = tech
if tech.researched ~= true then
game.print({"", "Received [technology=" .. tech.name .. "] from ", source})

View File

@ -100,6 +100,20 @@ function adjust_energy(recipe_name, factor)
end
end
function set_energy(recipe_name, energy)
local recipe = data.raw.recipe[recipe_name]
if (recipe.normal ~= nil) then
recipe.normal.energy_required = energy
end
if (recipe.expensive ~= nil) then
recipe.expensive.energy_required = energy
end
if (recipe.expensive == nil and recipe.normal == nil) then
recipe.energy_required = energy
end
end
data.raw["assembling-machine"]["assembling-machine-1"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories)
data.raw["assembling-machine"]["assembling-machine-2"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories)
data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes)
@ -144,6 +158,12 @@ data:extend{new_tree_copy}
adjust_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_scale) }})
{%- endif %}
{%- endfor -%}
{% elif recipe_time_range %}
{%- for recipe_name, recipe in recipes.items() %}
{%- if recipe.category != "mining" %}
set_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_range) }})
{%- endif %}
{%- endfor -%}
{% endif %}
{%- if silo==2 %}

View File

@ -22,4 +22,10 @@ ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends somet
{%- else %}
ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends something to someone. For purposes of hints, this location is called "{{ original_tech_name }}".
{%- endif -%}
{% endfor %}
{% endfor %}
[mod-setting-name]
archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Death Link
[mod-setting-description]
archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Kill other players in the same Archipelago Multiworld that also have Death Link turned on, when you die.

View File

@ -0,0 +1,12 @@
data:extend({
{
type = "bool-setting",
name = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}",
setting_type = "runtime-global",
{% if death_link %}
default_value = true
{% else %}
default_value = false
{% endif %}
}
})

View File

@ -147,7 +147,7 @@ class OOTWorld(World):
# Incompatible option handling
# ER and glitched logic are not compatible; glitched takes priority
if self.logic_rules == 'glitched':
self.shuffle_interior_entrances = False
self.shuffle_interior_entrances = 'off'
self.shuffle_grotto_entrances = False
self.shuffle_dungeon_entrances = False
self.shuffle_overworld_entrances = False

View File

@ -2,7 +2,7 @@ import Utils
from Patch import read_rom
JAP10HASH = '21f3e98df4780ee1c667b84e57d88675'
ROM_PLAYER_LIMIT = 255
ROM_PLAYER_LIMIT = 65535
import hashlib
import os

View File

@ -159,6 +159,9 @@ class SMWorld(World):
def getWord(self, w):
return (w & 0x00FF, (w & 0xFF00) >> 8)
def getWordArray(self, w):
return [w & 0x00FF, (w & 0xFF00) >> 8]
# used for remote location Credits Spoiler of local items
class DummyLocation:
@ -232,7 +235,10 @@ class SMWorld(World):
multiWorldItems = {}
idx = 0
itemId = 0
self.playerIDMap = {}
playerIDCount = 0 # 0 is for "Archipelago" server
for itemLoc in self.world.get_locations():
romPlayerID = itemLoc.item.player if itemLoc.item.player <= ROM_PLAYER_LIMIT else 0
if itemLoc.player == self.player and locationsDict[itemLoc.name].Id != None:
if itemLoc.item.type in ItemManager.Items:
itemId = ItemManager.Items[itemLoc.item.type].Id
@ -240,12 +246,21 @@ class SMWorld(World):
itemId = ItemManager.Items['ArchipelagoItem'].Id + idx
multiWorldItems[0x029EA3 + idx*64] = self.convertToROMItemName(itemLoc.item.name)
idx += 1
if (romPlayerID > 0 and romPlayerID not in self.playerIDMap.keys()):
playerIDCount += 1
self.playerIDMap[romPlayerID] = playerIDCount
(w0, w1) = self.getWord(0 if itemLoc.item.player == self.player else 1)
(w2, w3) = self.getWord(itemId)
(w4, w5) = self.getWord(itemLoc.item.player if itemLoc.item.player <= ROM_PLAYER_LIMIT else 0)
(w4, w5) = self.getWord(romPlayerID)
(w6, w7) = self.getWord(0 if itemLoc.item.advancement else 1)
multiWorldLocations[0x1C6000 + locationsDict[itemLoc.name].Id*8] = [w0, w1, w2, w3, w4, w5, w6, w7]
if itemLoc.item.player == self.player:
if (romPlayerID > 0 and romPlayerID not in self.playerIDMap.keys()):
playerIDCount += 1
self.playerIDMap[romPlayerID] = playerIDCount
itemSprites = ["off_world_prog_item.bin", "off_world_item.bin"]
idx = 0
@ -260,21 +275,24 @@ class SMWorld(World):
openTourianGreyDoors = {0x07C823 + 5: [0x0C], 0x07C831 + 5: [0x0C]}
deathLink = {0x277f04: [int(self.world.death_link[self.player])]}
playerNames = {}
playerNameIDMap = {}
playerNames[0x1C5000] = "Archipelago".upper().center(16).encode()
playerNameIDMap[0x1C5800] = self.getWordArray(0)
for key,value in self.playerIDMap.items():
playerNames[0x1C5000 + value * 16] = self.world.player_name[key][:16].upper().center(16).encode()
playerNameIDMap[0x1C5800 + value * 2] = self.getWordArray(key)
patchDict = { 'MultiWorldLocations': multiWorldLocations,
'MultiWorldItems': multiWorldItems,
'offworldSprites': offworldSprites,
'openTourianGreyDoors': openTourianGreyDoors,
'deathLink': deathLink}
'deathLink': deathLink,
'PlayerName': playerNames,
'PlayerNameIDMap': playerNameIDMap}
romPatcher.applyIPSPatchDict(patchDict)
playerNames = {}
playerNames[0x1C5000] = "Archipelago".upper().center(16).encode()
for p in range(1, min(self.world.players, ROM_PLAYER_LIMIT) + 1):
playerNames[0x1C5000 + p * 16] = self.world.player_name[p][:16].upper().center(16).encode()
romPatcher.applyIPSPatch('PlayerName', { 'PlayerName': playerNames })
# set rom name
# 21 bytes
from Main import __version__

View File

@ -2,16 +2,15 @@ import os, importlib
from logic.logic import Logic
from patches.common.patches import patches, additional_PLMs
from utils.parameters import appDir
from Utils import is_frozen
class PatchAccess(object):
def __init__(self):
# load all ips patches
self.patchesPath = {}
commonDir = os.path.join(appDir, 'lib' if is_frozen() else '', 'worlds/sm/variaRandomizer/patches/common/ips/')
commonDir = os.path.join(appDir, 'worlds/sm/variaRandomizer/patches/common/ips/')
for patch in os.listdir(commonDir):
self.patchesPath[patch] = commonDir
logicDir = os.path.join(appDir, 'lib' if is_frozen() else '', 'worlds/sm/variaRandomizer/patches/{}/ips/'.format(Logic.patches))
logicDir = os.path.join(appDir, 'worlds/sm/variaRandomizer/patches/{}/ips/'.format(Logic.patches))
for patch in os.listdir(logicDir):
self.patchesPath[patch] = logicDir

View File

@ -327,7 +327,7 @@ class VariaRandomizer:
preset = loadRandoPreset(world, self.player, args)
# use the skill preset from the rando preset
if preset is not None and preset != 'custom' and preset != 'varia_custom' and args.paramsFileName is None:
args.paramsFileName = '{}/{}/{}.json'.format(appDir, getPresetDir(preset), preset)
args.paramsFileName = os.path.join(appDir, getPresetDir(preset), preset+".json")
# if diff preset given, load it
if args.paramsFileName is not None:
@ -352,7 +352,7 @@ class VariaRandomizer:
sys.exit(-1)
else:
preset = 'default'
PresetLoader.factory('{}/{}/{}.json'.format(appDir, getPresetDir('casual'), 'casual')).load(self.player)
PresetLoader.factory(os.path.join(appDir, getPresetDir('casual'), 'casual.json')).load(self.player)

View File

@ -1,6 +1,7 @@
from logic.smbool import SMBool
import os
import sys
from pathlib import Path
# the different difficulties available
easy = 1
@ -60,7 +61,7 @@ def diff4solver(difficulty):
return "mania"
# allow multiple local repo
appDir = sys.path[0]
appDir = Path(__file__).parents[4]
def isKnows(knows):
return knows[0:len('__')] != '__' and knows[0] == knows[0].upper()

View File

@ -1,19 +1,17 @@
import os, json, sys, re, random
import os, json, re, random
from utils.parameters import Knows, Settings, Controller, isKnows, isSettings, isButton
from utils.parameters import easy, medium, hard, harder, hardcore, mania, text2diff
from logic.smbool import SMBool
from Utils import is_frozen
def isStdPreset(preset):
return preset in ['newbie', 'casual', 'regular', 'veteran', 'expert', 'master', 'samus', 'solution', 'Season_Races', 'SMRAT2021']
def getPresetDir(preset):
def getPresetDir(preset) -> str:
if isStdPreset(preset):
return 'lib/worlds/sm/variaRandomizer/standard_presets' if is_frozen() else 'worlds/sm/variaRandomizer/standard_presets'
return 'worlds/sm/variaRandomizer/standard_presets'
else:
return 'lib/worlds/sm/variaRandomizer/community_presets' if is_frozen() else 'worlds/sm/variaRandomizer/community_presets'
return 'worlds/sm/variaRandomizer/community_presets'
def removeChars(string, toRemove):
return re.sub('[{}]+'.format(toRemove), '', string)

View File

@ -5,6 +5,7 @@ from Utils import get_options, output_path
import typing
import lzma
import os
import os.path
import threading
try:
@ -200,11 +201,18 @@ class SoEWorld(World):
line = f'{loc.type},{loc.index}:{item.type},{item.index}\n'
f.write(line.encode('utf-8'))
if (pyevermizer.main(rom_file, out_file, placement_file, self.world.seed_name, self.connect_name, self.evermizer_seed,
flags, money, exp)):
if not os.path.exists(rom_file):
raise FileNotFoundError(rom_file)
if (pyevermizer.main(rom_file, out_file, placement_file, self.world.seed_name, self.connect_name,
self.evermizer_seed, flags, money, exp)):
raise RuntimeError()
with lzma.LZMAFile(patch_file, 'wb') as f:
f.write(generate_patch(rom_file, out_file))
f.write(generate_patch(rom_file, out_file,
{
# used by WebHost
"player_name": self.world.player_name[self.player],
"player_id": self.player
}))
except:
raise
finally:

View File

@ -106,7 +106,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L
LocationData('Refugee Camp', 'Refugee camp storage chest 2', 1337088),
LocationData('Refugee Camp', 'Refugee camp storage chest 1', 1337089),
LocationData('Forest', 'Refugee camp roof', 1337090),
LocationData('Forest', 'Bat jump chest', 1337091, lambda state: state._timespinner_has_doublejump_of_npc(world, player) or state._timespinner_has_forwarddash_doublejump(world, player)),
LocationData('Forest', 'Bat jump chest', 1337091, lambda state: state._timespinner_has_doublejump_of_npc(world, player) or state._timespinner_has_forwarddash_doublejump(world, player) or state._timespinner_has_fastjump_on_npc(world, player)),
LocationData('Forest', 'Green platform secret', 1337092, lambda state: state._timespinner_can_break_walls(world, player)),
LocationData('Forest', 'Rats guarded chest', 1337093),
LocationData('Forest', 'Waterfall chest 1', 1337094, lambda state: state.has('Water Mask', player)),
@ -158,7 +158,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L
LocationData('Castle Keep', 'Out of the way', 1337139),
LocationData('Castle Keep', 'Killed Twins', EventId, lambda state: state._timespinner_has_timestop(world, player)),
LocationData('Castle Keep', 'Twins', 1337140, lambda state: state._timespinner_has_timestop(world, player)),
LocationData('Castle Keep', 'Royal guard tiny room', 1337141, lambda state: state._timespinner_has_doublejump(world, player)),
LocationData('Castle Keep', 'Royal guard tiny room', 1337141, lambda state: state._timespinner_has_doublejump(world, player) or state._timespinner_has_fastjump_on_npc(world,player)),
LocationData('Royal towers (lower)', 'Royal tower floor secret', 1337142, lambda state: state._timespinner_has_doublejump(world, player) and state._timespinner_can_break_walls(world, player)),
LocationData('Royal towers', 'Above the gap', 1337143),
LocationData('Royal towers', 'Under the ice mage', 1337144),

View File

@ -15,6 +15,9 @@ class TimespinnerLogic(LogicMixin):
def _timespinner_has_doublejump_of_npc(self, world: MultiWorld, player: int) -> bool:
return self._timespinner_has_upwarddash(world, player) or (self.has('Timespinner Wheel', player) and self._timespinner_has_doublejump(world, player))
def _timespinner_has_fastjump_on_npc(self, world: MultiWorld, player: int) -> bool:
return self.has_all(['Timespinner Wheel', 'Talaria Attachment'], player)
def _timespinner_has_multiple_small_jumps_of_npc(self, world: MultiWorld, player: int) -> bool:
return self.has('Timespinner Wheel', player) or self._timespinner_has_upwarddash(world, player)

View File

@ -72,7 +72,7 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData
connect(world, player, names, 'Varndagroth tower right (upper)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player))
connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (upper)')
connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (lower)')
connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower left')
connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower left', lambda state: state._timespinner_has_keycard_B(world, player))
connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player))
connect(world, player, names, 'Varndagroth tower right (lower)', 'Sealed Caves (Sirens)', lambda state: state._timespinner_has_keycard_B(world, player) and state.has('Elevator Keycard', player))
connect(world, player, names, 'Varndagroth tower right (lower)', 'Militairy Fortress', lambda state: state._timespinner_can_kill_all_3_bosses(world, player))