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: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python 3.8 - name: Set up Python 3.9
uses: actions/setup-python@v1 uses: actions/setup-python@v1
with: with:
python-version: 3.8 python-version: 3.9
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip

View File

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

View File

@ -11,7 +11,7 @@ import websockets
import Utils import Utils
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("TextClient") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
@ -39,13 +39,13 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool: def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server""" """Connect to a MultiWorld Server"""
self.ctx.server_address = None 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 return True
def _cmd_disconnect(self) -> bool: def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server""" """Disconnect from a MultiWorld Server"""
self.ctx.server_address = None self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect()) asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True return True
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
@ -81,6 +81,16 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.") self.output("No missing location checks found.")
return True 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): def _cmd_ready(self):
self.ctx.ready = not self.ctx.ready self.ctx.ready = not self.ctx.ready
if self.ctx.ready: if self.ctx.ready:
@ -89,10 +99,10 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") 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): 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(): class CommonContext():
@ -149,7 +159,7 @@ class CommonContext():
self.set_getters(network_data_package) self.set_getters(network_data_package)
# execution # execution
self.keep_alive_task = asyncio.create_task(keep_alive(self)) self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property @property
def total_locations(self) -> typing.Optional[int]: def total_locations(self) -> typing.Optional[int]:
@ -230,13 +240,24 @@ class CommonContext():
self.password = await self.console_input() self.password = await self.console_input()
return self.password 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): async def console_input(self):
self.input_requests += 1 self.input_requests += 1
return await self.input_queue.get() return await self.input_queue.get()
async def connect(self, address=None): async def connect(self, address=None):
await self.disconnect() 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): def on_print(self, args: dict):
logger.info(args["text"]) logger.info(args["text"])
@ -271,7 +292,7 @@ class CommonContext():
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""): 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() self.last_death_link = time.time()
await self.send_msgs([{ await self.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"], "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): 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) """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() await ctx.connection_closed()
if ctx.server_address: if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s") 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 ctx.current_reconnect_delay *= 2
async def server_autoreconnect(ctx: CommonContext): async def server_autoreconnect(ctx: CommonContext):
await asyncio.sleep(ctx.current_reconnect_delay) await asyncio.sleep(ctx.current_reconnect_delay)
if ctx.server_address and ctx.server_task is None: 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): async def process_server_cmd(ctx: CommonContext, args: dict):
@ -534,6 +576,7 @@ if __name__ == '__main__':
class TextContext(CommonContext): class TextContext(CommonContext):
tags = {"AP", "IgnoreGame"} tags = {"AP", "IgnoreGame"}
game = "Archipelago"
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@ -542,11 +585,7 @@ if __name__ == '__main__':
logger.info('Enter slot name:') logger.info('Enter slot name:')
self.auth = await self.console_input() self.auth = await self.console_input()
await self.send_msgs([{"cmd": 'Connect', await self.send_connect()
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}])
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == "Connected": if cmd == "Connected":
@ -555,7 +594,7 @@ if __name__ == '__main__':
async def main(args): async def main(args):
ctx = TextContext(args.connect, args.password) 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: if gui_enabled:
input_task = None input_task = None
from kvui import TextManager from kvui import TextManager
@ -566,16 +605,7 @@ if __name__ == '__main__':
ui_task = None ui_task = None
await ctx.exit_event.wait() await ctx.exit_event.wait()
ctx.server_address = None await ctx.shutdown()
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
if ui_task: if ui_task:
await ui_task await ui_task

View File

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

View File

@ -90,7 +90,8 @@ def main(args=None, callback=ERmain):
except Exception as e: except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from 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] 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: if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta") raise Exception("Cannot mix --samesettings with --meta")
else: else:
@ -126,7 +127,7 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name erargs.outputname = seed_name
erargs.outputpath = args.outputpath 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.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_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) player_path_cache[player] = player_files.get(player, args.weights_file_path)
if meta_weights: if meta_weights:
for player, path in player_path_cache.items(): for category_name, category_dict in meta_weights.items():
weights_cache[path].setdefault("meta_ignore", []) for key in category_dict:
for key in meta_weights: option = get_choice(key, category_dict)
option = get_choice(key, meta_weights) if option is not None:
if option is not None: for player, path in player_path_cache.items():
for player, path in player_path_cache.items(): if category_name is None:
players_meta = weights_cache[path].get("meta_ignore", []) weights_cache[path][key] = option
if key not in players_meta: elif category_name not in weights_cache[path]:
weights_cache[path][key] = option raise Exception(f"Meta: Category {category_name} is not present in {path}.")
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]: else:
weights_cache[path][key] = option weights_cache[path][category_name][key] = option
name_counter = Counter() name_counter = Counter()
erargs.player_settings = {} 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: if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][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} checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)} 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'} \ 'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address) 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: elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld: 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)) 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) client = Client(websocket, ctx)
ctx.endpoints.append(client) ctx.endpoints.append(client)
@ -591,10 +591,12 @@ def get_status_string(ctx: Context, team: int):
text = "Player Status on your team:" text = "Player Status on your team:"
for slot in ctx.locations: for slot in ctx.locations:
connected = len(ctx.clients[team][slot]) 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])})" 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 "." 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'}" \ 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 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]): 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 = set(locations) - ctx.location_checks[team, slot]
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
if new_locations: if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations: for location in new_locations:
if location in ctx.locations[slot]: item_id, target_player = ctx.locations[slot][location]
item_id, target_player = ctx.locations[slot][location] new_item = NetworkItem(item_id, location, slot)
new_item = NetworkItem(item_id, location, slot) if target_player != slot or slot in ctx.remote_items:
if target_player != slot or slot in ctx.remote_items: get_received_items(ctx, team, target_player).append(new_item)
get_received_items(ctx, team, target_player).append(new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % ( logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id), 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))) ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player) info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text]) ctx.broadcast_team(team, [info_text])
ctx.location_checks[team, slot] |= new_locations ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx) send_new_items(ctx)
ctx.broadcast(ctx.clients[team][slot], [{ ctx.broadcast(ctx.clients[team][slot], [{
"cmd": "RoomUpdate", "cmd": "RoomUpdate",
"hint_points": get_slot_points(ctx, team, slot), "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() ctx.save()
@ -1242,6 +1244,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
game = ctx.games[slot] game = ctx.games[slot]
if "IgnoreGame" not in args["tags"] and args['game'] != game: if "IgnoreGame" not in args["tags"] and args['game'] != game:
errors.add('InvalidGame') errors.add('InvalidGame')
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
# only exact version match allowed # only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple: 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.auth = False # swapping Team/Slot
client.team = team client.team = team
client.slot = slot 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.client_ids[client.team, client.slot] = args["uuid"]
ctx.clients[team][slot].append(client) ctx.clients[team][slot].append(client)
client.version = args['version'] 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) await ctx.send_msgs(client, reply)
elif cmd == "GetDataPackage": elif cmd == "GetDataPackage":
exclusions = set(args.get("exclusions", [])) exclusions = args.get("exclusions", [])
if exclusions: if exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items() games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions} if name not in exclusions}
package = network_data_package.copy() package = network_data_package.copy()
@ -1680,7 +1684,7 @@ async def main(args: argparse.Namespace):
ctx.init_save(not args.disable_save) 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) ping_interval=None)
ip = args.host if args.host else Utils.get_public_ipv4() ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,

View File

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

View File

@ -1,3 +1,5 @@
# TODO: convert this into a system like AutoWorld
import bsdiff4 import bsdiff4
import yaml import yaml
import os import os
@ -14,16 +16,25 @@ current_patch_version = 3
GAME_ALTTP = "A Link to the Past" GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid" 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: def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP: if game == GAME_ALTTP:
from worlds.alttp.Rom import JAP10HASH from worlds.alttp.Rom import JAP10HASH as HASH
elif game == GAME_SM: 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: 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 = yaml.dump({"meta": metadata,
"patch": patch, "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 # minimum version of patch system expected for patching to be successful
"compatible_version": 3, "compatible_version": 3,
"version": current_patch_version, "version": current_patch_version,
"base_checksum": JAP10HASH}) "base_checksum": HASH})
return patch.encode(encoding="utf-8-sig") return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes: 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: if metadata is None:
metadata = {} metadata = {}
patch = bsdiff4.diff(get_base_rom_bytes(), rom) patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game) 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]: 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")) data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"] 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: 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.") 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)] rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash) data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc" target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data 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]: def create_rom_file(patch_file: str) -> Tuple[dict, str]:
data, target, patched_data = create_rom_bytes(patch_file) data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f: with open(target, "wb") as f:

View File

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

View File

@ -122,16 +122,25 @@ parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader) 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 @cache_argsless
def get_public_ipv4() -> str: def get_public_ipv4() -> str:
import socket import socket
import urllib.request import urllib.request
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try: 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: except Exception as e:
try: 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: except:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out 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 socket
import urllib.request import urllib.request
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try: 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: except Exception as e:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available 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", "sni": "SNI",
"rom_start": True, "rom_start": True,
}, },
"soe_options": {
"rom_file": "Secret of Evermore (USA).sfc",
},
"lttp_options": { "lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI", "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", 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) loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = local_path("logs") log_folder = local_path("logs")
os.makedirs(log_folder, exist_ok=True) 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( root_logger.addHandler(
logging.StreamHandler(sys.stdout) 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) abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4()) room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit() commit()
return redirect(url_for("hostRoom", room=room.id)) return redirect(url_for("host_room", room=room.id))
def _read_log(path: str): def _read_log(path: str):
@ -159,7 +159,7 @@ def display_log(room: UUID):
@app.route('/room/<suuid:room>', methods=['GET', 'POST']) @app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def hostRoom(room: UUID): def host_room(room: UUID):
room = Room.get(id=room) room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
@ -175,20 +175,17 @@ def hostRoom(room: UUID):
return render_template("hostRoom.html", room=room) 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') @app.route('/favicon.ico')
def favicon(): def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'), return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon') 'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord') @app.route('/discord')
def discord(): def discord():
return redirect("https://discord.gg/archipelago") return redirect("https://discord.gg/archipelago")
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it 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 import app, Generation, STATE_QUEUED, Seed, STATE_ERROR
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta
@api_endpoints.route('/generate', methods=['POST']) @api_endpoints.route('/generate', methods=['POST'])
@ -35,9 +36,6 @@ def generate_api():
if "race" in json_data: if "race" in json_data:
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"])) 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: if not options:
return {"text": "No options found. Expected file attachment or json weights." return {"text": "No options found. Expected file attachment or json weights."
}, 400 }, 400
@ -45,7 +43,8 @@ def generate_api():
if len(options) > app.config["MAX_ROLL"]: if len(options) > app.config["MAX_ROLL"]:
return {"text": "Max size of multiworld exceeded", return {"text": "Max size of multiworld exceeded",
"detail": app.config["MAX_ROLL"]}, 409 "detail": app.config["MAX_ROLL"]}, 409
meta = get_meta(meta_options_source)
meta["race"] = race
results, gen_options = roll_options(options) results, gen_options = roll_options(options)
if any(type(result) == str for result in results.values()): if any(type(result) == str for result in results.values()):
return {"text": str(results), return {"text": str(results),
@ -54,7 +53,7 @@ def generate_api():
gen = Generation( gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible # 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"]) owner=session["_id"])
commit() commit()
return {"text": f"Generation of seed {gen.id} started successfully.", 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 flask import send_file, Response, render_template
from pony.orm import select 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 from WebHostLib import app, Slot, Room, Seed, cache
import zipfile import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>") @app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
def download_patch(room_id, patch_id): def download_patch(room_id, patch_id):
patch = Slot.get(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 = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = io.BytesIO(patch_data) 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) 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") 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>") @app.route("/slot_file/<suuid:room_id>/<int:player_id>")
def download_slot_file(room_id, player_id: int): def download_slot_file(room_id, player_id: int):
room = Room.get(id=room_id) 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 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', methods=['GET', 'POST'])
@app.route('/generate/<race>', methods=['GET', 'POST']) @app.route('/generate/<race>', methods=['GET', 'POST'])
def generate(race=False): def generate(race=False):
@ -35,9 +45,9 @@ def generate(race=False):
else: else:
results, gen_options = roll_options(options) results, gen_options = roll_options(options)
# get form data -> server settings # get form data -> server settings
hint_cost = int(request.form.get("hint_cost", 10)) meta = get_meta(request.form)
forfeit_mode = request.form.get("forfeit_mode", "goal") meta["race"] = race
meta = {"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}
if race: if race:
meta["item_cheat"] = False meta["item_cheat"] = False
meta["remaining"] = False meta["remaining"] = False

View File

@ -40,7 +40,7 @@ class Seed(db.Entity):
creation_time = Required(datetime, default=lambda: datetime.utcnow()) creation_time = Required(datetime, default=lambda: datetime.utcnow())
slots = Set(Slot) slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True) 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): class Command(db.Entity):
@ -53,5 +53,5 @@ class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4) id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID) owner = Required(UUID)
options = Required(buffer, lazy=True) 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) state = Required(int, default=0, index=True)

View File

@ -49,7 +49,7 @@ def create():
game_options = {} game_options = {}
for option_name, option in world.options.items(): for option_name, option in world.options.items():
if option.options: if option.options:
this_option = { game_options[option_name] = this_option = {
"type": "select", "type": "select",
"displayName": option.displayname if hasattr(option, "displayname") else option_name, "displayName": option.displayname if hasattr(option, "displayname") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!", "description": option.__doc__ if option.__doc__ else "Please document me!",
@ -66,7 +66,10 @@ def create():
if sub_option_id == option.default: if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name 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"): elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = { 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. supported by Archipelago but not listed in the installation check the relevant tutorial.
## Generating a game ## 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 ### 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. 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. 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. This contains the patch files and relevant mods for the players as well as the serverdata for the host.
## Hosting a multiworld ## Hosting a multiworld
### Uploading the seed to the website ### 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 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. 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 10. Enter `localhost` into the server address box
11. Click "Connect" 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 ## Allowing Other People to Join Your Game
1. Ensure your Archipelago Client is running. 1. Ensure your Archipelago Client is running.
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client. 2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.

View File

@ -4,7 +4,7 @@
"tutorials": [ "tutorials": [
{ {
"name": "Multiworld Setup Tutorial", "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": [ "files": [
{ {
"language": "English", "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", "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": [ "files": [
{ {
"language": "English", "language": "English",

View File

@ -1,10 +1,10 @@
# A Link to the Past Randomizer Setup Guide # A Link to the Past Randomizer Setup Guide
## Required Software ## 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) [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 - 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 LttPClient) - [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and SNIClient)
- Hardware or software capable of loading and playing SNES ROM files - Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI - An emulator capable of connecting to SNI
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz), ([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
@ -76,7 +76,7 @@ Firewall.
4. In the new window, click **Browse...** 4. In the new window, click **Browse...**
5. Select the connector lua file included with your client 5. Select the connector lua file included with your client
- Z3Client users should download `sniConnector.lua` from the client download page - 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 ##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following 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. 4. Click the button to open a new Lua script.
5. Select the `sniConnector.lua` file you downloaded above 5. Select the `sniConnector.lua` file you downloaded above
- Z3Client users should download `sniConnector.lua` from the client download page - 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 #### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not 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? ### 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 ```yaml
description: Default Ocarina of Time Template # Used to describe your yaml. Useful if you have multiple files 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 # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit

View File

@ -34,7 +34,14 @@
<table> <table>
<tbody> <tbody>
<tr> <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> <td>
<select name="forfeit_mode" id="forfeit_mode"> <select name="forfeit_mode" id="forfeit_mode">
<option value="auto">Automatic on goal completion</option> <option value="auto">Automatic on goal completion</option>
@ -46,12 +53,49 @@
</td> </td>
</tr> </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> <tr>
<td> <td>
<label for="hint_cost"> Hint Cost:</label> <label for="hint_cost"> Hint Cost:</label>
<span <span
class="interactive" class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname> data-tooltip="After gathering this many checks, players can !hint <itemname>
to get the location of that hint item.">(?) to get the location of that hint item.">(?)
</span> </span>
</td> </td>

View File

@ -20,7 +20,7 @@
later, later,
you can simply refresh this page and the server will be started again.<br> you can simply refresh this page and the server will be started again.<br>
{% if room.last_port %} {% 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 %} in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %}
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% 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> <td><a href="{{ url_for("download_spoiler", seed_id=seed.id) }}">Download</a></td>
</tr> </tr>
{% endif %} {% 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> <tr>
<td>Files:&nbsp;</td> <td>Rooms:&nbsp;</td>
<td> <td>
<ul> {% call macros.list_rooms(rooms) %}
{% for slot in seed.slots %}
<li> <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> </li>
{% endcall %}
{% endfor %}
</ul>
</td> </td>
</tr> </tr>
{% endif %}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

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

View File

@ -1,9 +1,9 @@
<TabbedPanel> <TabbedPanel>
tab_width: 200 tab_width: 200
<Row@Label>: <SelectableLabel>:
canvas.before: canvas.before:
Color: 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: Rectangle:
size: self.size size: self.size
pos: self.pos pos: self.pos
@ -13,10 +13,10 @@
font_size: dp(20) font_size: dp(20)
markup: True markup: True
<UILog>: <UILog>:
viewclass: 'Row' viewclass: 'SelectableLabel'
scroll_y: 0 scroll_y: 0
effect_cls: "ScrollEffect" effect_cls: "ScrollEffect"
RecycleBoxLayout: SelectableRecycleBoxLayout:
default_size: None, dp(20) default_size: None, dp(20)
default_size_hint: 1, None default_size_hint: 1, None
size_hint_y: 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. | | hint_points | int | New argument. The client's current hint points. |
| players | list\[NetworkPlayer\] | Changed argument. Always sends all players, whether connected or not. | | 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. | | 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. 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 Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components] [Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728 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 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/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: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; 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/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 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/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; 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 Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs] [Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; 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 Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
[Code] [Code]
const const
SHCONTCH_NOPROGRESSBOX = 4; SHCONTCH_NOPROGRESSBOX = 4;
@ -221,6 +220,19 @@ var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage; 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; function CheckRom(name: string; hash: string): string;
var rom: string; var rom: string;
begin begin
@ -229,8 +241,8 @@ begin
if Length(rom) > 0 then if Length(rom) > 0 then
begin begin
log('existing ROM found'); log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(rom), hash))); log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetMD5OfFile(rom), hash) = 0 then if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin begin
log('existing ROM verified'); log('existing ROM verified');
Result := rom; Result := rom;
@ -317,7 +329,16 @@ begin
MinecraftDownloadPage.Hide; MinecraftDownloadPage.Hide;
end; end;
Result := True; 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; Result := True;
end; end;
@ -327,7 +348,7 @@ begin
Result := lttprom Result := lttprom
else if Assigned(LttPRomFilePage) then else if Assigned(LttPRomFilePage) then
begin begin
R := CompareStr(GetMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -343,7 +364,7 @@ begin
Result := smrom Result := smrom
else if Assigned(SMRomFilePage) then else if Assigned(SMRomFilePage) then
begin begin
R := CompareStr(GetMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -359,8 +380,7 @@ begin
Result := soerom Result := soerom
else if Assigned(SoERomFilePage) then else if Assigned(SoERomFilePage) then
begin begin
R := CompareStr(GetMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
log(GetMD5OfFile(SoEROMFilePage.Values[0]))
if R <> 0 then if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -374,7 +394,7 @@ function GetOoTROMPath(Param: string): string;
begin begin
if Length(ootrom) > 0 then if Length(ootrom) > 0 then
Result := ootrom Result := ootrom
else if Assigned(OoTROMFilePage) then else if (Assigned(OoTROMFilePage)) then
begin begin
R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f'); R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f');
if R <> 0 then if R <> 0 then
@ -417,4 +437,4 @@ begin
Result := not (WizardIsComponentSelected('generator/soe')); Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot')); 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 Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components] [Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728 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 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/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: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; 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/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 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/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time Adjuster"; 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 Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs] [Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; 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 Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
[Code] [Code]
const const
SHCONTCH_NOPROGRESSBOX = 4; SHCONTCH_NOPROGRESSBOX = 4;
@ -221,6 +220,19 @@ var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage; 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; function CheckRom(name: string; hash: string): string;
var rom: string; var rom: string;
begin begin
@ -229,8 +241,8 @@ begin
if Length(rom) > 0 then if Length(rom) > 0 then
begin begin
log('existing ROM found'); log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(rom), hash))); log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetMD5OfFile(rom), hash) = 0 then if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin begin
log('existing ROM verified'); log('existing ROM verified');
Result := rom; Result := rom;
@ -317,7 +329,16 @@ begin
MinecraftDownloadPage.Hide; MinecraftDownloadPage.Hide;
end; end;
Result := True; 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; Result := True;
end; end;
@ -327,7 +348,7 @@ begin
Result := lttprom Result := lttprom
else if Assigned(LttPRomFilePage) then else if Assigned(LttPRomFilePage) then
begin begin
R := CompareStr(GetMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -343,7 +364,7 @@ begin
Result := smrom Result := smrom
else if Assigned(SMRomFilePage) then else if Assigned(SMRomFilePage) then
begin begin
R := CompareStr(GetMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
@ -359,8 +380,7 @@ begin
Result := soerom Result := soerom
else if Assigned(SoERomFilePage) then else if Assigned(SoERomFilePage) then
begin begin
R := CompareStr(GetMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
log(GetMD5OfFile(SoEROMFilePage.Values[0]))
if R <> 0 then if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); 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 logging
import typing import typing
import asyncio import asyncio
import sys
os.environ["KIVY_NO_CONSOLELOG"] = "1" os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1"
@ -11,6 +10,8 @@ os.environ["KIVY_LOG_ENABLE"] = "0"
from kivy.app import App from kivy.app import App
from kivy.core.window import Window 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.base import ExceptionHandler, ExceptionManager, Config, Clock
from kivy.factory import Factory from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty from kivy.properties import BooleanProperty, ObjectProperty
@ -25,6 +26,10 @@ from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar from kivy.uix.progressbar import ProgressBar
from kivy.utils import escape_markup from kivy.utils import escape_markup
from kivy.lang import Builder 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 import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart from NetUtils import JSONtoTextParser, JSONMessagePart
@ -140,6 +145,46 @@ class ContainerLayout(FloatLayout):
pass 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): class GameManager(App):
logging_pairs = [ logging_pairs = [
("Client", "Archipelago"), ("Client", "Archipelago"),
@ -164,7 +209,8 @@ class GameManager(App):
# top part # top part
server_label = ServerLabel() server_label = ServerLabel()
connect_layout.add_widget(server_label) 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) self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
connect_layout.add_widget(self.server_connect_bar) 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) 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 = Button(height=30, text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action) info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button) bottom_layout.add_widget(info_button)
textinput = TextInput(size_hint_y=None, height=30, multiline=False) self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False)
textinput.bind(on_text_validate=self.on_message) self.textinput.bind(on_text_validate=self.on_message)
bottom_layout.add_widget(textinput)
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.grid.add_widget(bottom_layout)
self.commandprocessor("/help") self.commandprocessor("/help")
Clock.schedule_interval(self.update_texts, 1 / 30) Clock.schedule_interval(self.update_texts, 1 / 30)
self.container.add_widget(self.grid) self.container.add_widget(self.grid)
self.catch_unhandled_exceptions()
return self.container 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): def update_texts(self, dt):
if self.ctx.server: if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \ self.title = self.base_title + " " + Utils.__version__ + \
@ -242,7 +276,11 @@ class GameManager(App):
self.progressbar.value = 0 self.progressbar.value = 0
def command_button_action(self, button): 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): def connect_button_action(self, button):
if self.ctx.server: if self.ctx.server:
@ -269,6 +307,9 @@ class GameManager(App):
self.ctx.input_queue.put_nowait(input_text) self.ctx.input_queue.put_nowait(input_text)
elif input_text: elif input_text:
self.commandprocessor(input_text) self.commandprocessor(input_text)
Clock.schedule_once(textinput.text_focus)
except Exception as e: except Exception as e:
logging.getLogger("Client").exception(e) logging.getLogger("Client").exception(e)
@ -304,7 +345,7 @@ class TextManager(GameManager):
class LogtoUI(logging.Handler): class LogtoUI(logging.Handler):
def __init__(self, on_log): def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.DEBUG) super(LogtoUI, self).__init__(logging.INFO)
self.on_log = on_log self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None: 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 # 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, # 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 # 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 meta_description: Meta-Mystery file with the intention of having similar-length completion times for a hopefully better experience
null: null:
progression_balancing: # Progression balancing tries to make sure that the player has *something* towards any players goal in each "sphere" 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 open: 60
inverted: 10 inverted: 10
null: 10 # Maintain individual world states 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. triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces.
extra: 0 # available = triforce_pieces_extra + triforce_pieces_required extra: 0 # available = triforce_pieces_extra + triforce_pieces_required
percentage: 0 # available = (triforce_pieces_percentage /100) * 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", __all__ = {"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name", "lookup_any_location_id_to_name",
"network_data_package"} "network_data_package",
"AutoWorldRegister"}
# import all submodules to trigger AutoWorldRegister # import all submodules to trigger AutoWorldRegister
for file in os.scandir(os.path.dirname(__file__)): 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}\nTheres always\nmoney in the\nBanana Stand>",
"{C:GREEN}\n \nJust walk away\n >", "{C:GREEN}\n \nJust walk away\n >",
"{C:GREEN}\neverybody is\nlooking for\nsomething >", "{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}\nThe gnome asks\nyou to guess\nhis name. >",
"{C:GREEN}\nI heard beans\non toast is a\ngreat meal. >", "{C:GREEN}\nI heard beans\non toast is a\ngreat meal. >",
"{C:GREEN}\n> Sweetcorn\non pizza is a\ngreat choice.", "{C:GREEN}\n> Sweetcorn\non pizza is a\ngreat choice.",

View File

@ -47,10 +47,16 @@ recipe_time_scales = {
Options.RecipeTime.option_vanilla: None 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): def generate_mod(world, output_directory: str):
player = world.player player = world.player
multiworld = world.world 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: with template_load_lock:
if not data_final_template: if not data_final_template:
mod_template_folder = os.path.join(os.path.dirname(__file__), "data", "mod_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") data_final_template = template_env.get_template("data-final-fixes.lua")
locale_template = template_env.get_template(r"locale/en/locale.cfg") locale_template = template_env.get_template(r"locale/en/locale.cfg")
control_template = template_env.get_template("control.lua") control_template = template_env.get_template("control.lua")
settings_template = template_env.get_template("settings.lua")
# get data for templates # get data for templates
player_names = {x: multiworld.player_name[x] for x in multiworld.player_ids} player_names = {x: multiworld.player_name[x] for x in multiworld.player_ids}
locations = [] 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(), "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_cost_scale": tech_cost_scale, "custom_technologies": multiworld.worlds[player].custom_technologies,
"tech_tree_layout_prerequisites": multiworld.tech_tree_layout_prerequisites[player], "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, "starting_items": multiworld.starting_items[player], "recipes": recipes,
"random": random, "flop_random": flop_random, "random": random, "flop_random": flop_random,
"static_nodes": multiworld.worlds[player].static_nodes, "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}, "free_sample_blacklist": {item : 1 for item in free_sample_blacklist},
"progressive_technology_table": {tech.name : tech.progressive for tech in "progressive_technology_table": {tech.name : tech.progressive for tech in
progressive_technology_table.values()}, 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: if getattr(multiworld, "silo")[player].value == Options.Silo.option_randomize_recipe:
template_data["free_sample_blacklist"]["rocket-silo"] = 1 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) control_code = control_template.render(**template_data)
data_template_code = data_template.render(**template_data) data_template_code = data_template.render(**template_data)
data_final_fixes_code = data_final_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__) mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)
en_locale_dir = os.path.join(mod_dir, "locale", "en") 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) f.write(data_final_fixes_code)
with open(os.path.join(mod_dir, "control.lua"), "wt") as f: with open(os.path.join(mod_dir, "control.lua"), "wt") as f:
f.write(control_code) 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) locale_content = locale_template.render(**template_data)
with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f: with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f:
f.write(locale_content) f.write(locale_content)

View File

@ -55,6 +55,14 @@ class Silo(Choice):
default = 0 default = 0
class Satellite(Choice):
"""Ingredients to craft satellite."""
displayname = "Satellite"
option_vanilla = 0
option_randomize_recipe = 1
default = 0
class FreeSamples(Choice): class FreeSamples(Choice):
"""Get free items with your technologies.""" """Get free items with your technologies."""
displayname = "Free Samples" displayname = "Free Samples"
@ -91,13 +99,25 @@ class TechTreeInformation(Choice):
class RecipeTime(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" displayname = "Recipe Time"
option_vanilla = 0 option_vanilla = 0
option_fast = 1 option_fast = 1
option_normal = 2 option_normal = 2
option_slow = 4 option_slow = 4
option_chaos = 5 option_chaos = 5
option_new_fast = 6
option_new_normal = 7
option_new_slow = 8
class Progressive(Choice): class Progressive(Choice):
@ -289,6 +309,7 @@ factorio_options: typing.Dict[str, type(Option)] = {
"tech_tree_layout": TechTreeLayout, "tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost, "tech_cost": TechCost,
"silo": Silo, "silo": Silo,
"satellite": Satellite,
"free_samples": FreeSamples, "free_samples": FreeSamples,
"tech_tree_information": TechTreeInformation, "tech_tree_information": TechTreeInformation,
"starting_items": FactorioStartItems, "starting_items": FactorioStartItems,

View File

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

View File

@ -1,15 +1,16 @@
import collections import collections
import typing
from ..AutoWorld import World from ..AutoWorld import World
from BaseClasses import Region, Entrance, Location, Item 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, \ 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, \ 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 get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies
from .Shapes import get_shapes from .Shapes import get_shapes
from .Mod import generate_mod from .Mod import generate_mod
from .Options import factorio_options, Silo, TechTreeInformation from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation
import logging import logging
@ -32,13 +33,17 @@ class Factorio(World):
game: str = "Factorio" game: str = "Factorio"
static_nodes = {"automation", "logistics", "rocket-silo"} static_nodes = {"automation", "logistics", "rocket-silo"}
custom_recipes = {} custom_recipes = {}
additional_advancement_technologies = set() advancement_technologies: typing.Set[str]
item_name_to_id = all_items item_name_to_id = all_items
location_name_to_id = base_tech_table location_name_to_id = base_tech_table
data_version = 5 data_version = 5
def __init__(self, world, player: int):
super(Factorio, self).__init__(world, player)
self.advancement_technologies = set()
def generate_basic(self): def generate_basic(self):
player = self.player player = self.player
want_progressives = collections.defaultdict(lambda: self.world.progressive[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)) locations=locations: all(state.can_reach(loc) for loc in locations))
silo_recipe = None if self.world.silo[self.player].value == Silo.option_spawn \ silo_recipe = None if self.world.silo[self.player].value == Silo.option_spawn \
else self.custom_recipes["rocket-silo"] \ else self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \
if "rocket-silo" in self.custom_recipes \
else next(iter(all_product_sources.get("rocket-silo"))) else next(iter(all_product_sources.get("rocket-silo")))
part_recipe = self.custom_recipes["rocket-part"] 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) world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player)
for technology in for technology in
victory_tech_names) victory_tech_names)
@ -189,12 +196,12 @@ class Factorio(World):
fallback_pool = [] fallback_pool = []
# fill all but one slot with random ingredients, last with a good match # 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: if remaining_num_ingredients == 1:
max_raw = 1.1 * remaining_raw max_raw = 1.1 * remaining_raw
min_raw = 0.9 * remaining_raw min_raw = 0.9 * remaining_raw
max_energy = 1.1 * remaining_energy max_energy = 1.1 * remaining_energy
min_energy = 1.1 * remaining_energy min_energy = 0.9 * remaining_energy
else: else:
max_raw = remaining_raw * 0.75 max_raw = remaining_raw * 0.75
min_raw = (remaining_raw - max_raw) / remaining_num_ingredients min_raw = (remaining_raw - max_raw) / remaining_num_ingredients
@ -226,7 +233,7 @@ class Factorio(World):
# fill failed slots with whatever we got # fill failed slots with whatever we got
pool = fallback_pool pool = fallback_pool
while remaining_num_ingredients > 0 and len(pool) > 0: while remaining_num_ingredients > 0 and pool:
ingredient = pool.pop() ingredient = pool.pop()
if ingredient not in recipes: if ingredient not in recipes:
logging.warning(f"missing recipe for {ingredient}") logging.warning(f"missing recipe for {ingredient}")
@ -264,8 +271,6 @@ class Factorio(World):
{valid_pool[x]: 10 for x in range(3)}, {valid_pool[x]: 10 for x in range(3)},
original_rocket_part.products, original_rocket_part.products,
original_rocket_part.energy)} 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]: if self.world.recipe_ingredients[self.player]:
valid_pool = [] valid_pool = []
@ -278,31 +283,45 @@ class Factorio(World):
for _ in original.ingredients: for _ in original.ingredients:
new_ingredients[valid_pool.pop()] = 1 new_ingredients[valid_pool.pop()] = 1
new_recipe = Recipe(pack, original.category, new_ingredients, original.products, original.energy) 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 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 = [] valid_pool = []
for pack in sorted(self.world.max_science_pack[self.player].get_allowed_packs()): for pack in sorted(self.world.max_science_pack[self.player].get_allowed_packs()):
valid_pool += sorted(science_pack_pools[pack]) 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) if self.world.silo[self.player].value == Silo.option_randomize_recipe:
self.additional_advancement_technologies |= {tech.name for tech in new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool.copy(),
new_recipe.recursive_unlocking_technologies} factor=(self.world.max_science_pack[self.player].value + 1) / 7)
self.custom_recipes["rocket-silo"] = new_recipe 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 # handle marking progressive techs as advancement
prog_add = set() prog_add = set()
for tech in self.additional_advancement_technologies: for tech in self.advancement_technologies:
if tech in tech_to_progressive_lookup: if tech in tech_to_progressive_lookup:
prog_add.add(tech_to_progressive_lookup[tech]) 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: def create_item(self, name: str) -> Item:
if name in tech_table: if name in tech_table:
return FactorioItem(name, name in advancement_technologies or return FactorioItem(name, name in self.advancement_technologies,
name in self.additional_advancement_technologies,
tech_table[name], self.player) tech_table[name], self.player)
item = FactorioItem(name, False, all_items[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 }}" SEED_NAME = "{{ seed_name }}"
FREE_SAMPLE_BLACKLIST = {{ dict_to_lua(free_sample_blacklist) }} FREE_SAMPLE_BLACKLIST = {{ dict_to_lua(free_sample_blacklist) }}
TRAP_EVO_FACTOR = {{ evolution_trap_increase }} / 100 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 CURRENTLY_DEATH_LOCK = 0
@ -76,6 +83,27 @@ function on_force_destroyed(event)
global.forcedata[event.force.name] = nil global.forcedata[event.force.name] = nil
end 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 -- Initialize player data, either from them joining the game or them already being part of the game when the mod was
-- added.` -- added.`
function on_player_created(event) function on_player_created(event)
@ -107,8 +135,19 @@ end
script.on_event(defines.events.on_player_removed, on_player_removed) script.on_event(defines.events.on_player_removed, on_player_removed)
function on_rocket_launched(event) function on_rocket_launched(event)
global.forcedata[event.rocket.force.name]['victory'] = 1 if event.rocket and event.rocket.valid and global.forcedata[event.rocket.force.name]['victory'] == 0 then
dumpInfo(event.rocket.force) 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 end
script.on_event(defines.events.on_rocket_launched, on_rocket_launched) script.on_event(defines.events.on_rocket_launched, on_rocket_launched)
@ -198,6 +237,10 @@ script.on_init(function()
e.player_index = index e.player_index = index
on_player_created(e) on_player_created(e)
end end
if remote.interfaces["silo_script"] then
remote.call("silo_script", "set_no_victory", true)
end
end) end)
-- hook into researches done -- hook into researches done
@ -366,18 +409,19 @@ function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores)
end end
if DEATH_LINK == 1 then script.on_event(defines.events.on_entity_died, function(event)
script.on_event(defines.events.on_entity_died, function(event) if DEATH_LINK == 0 then
if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event return
return end
end if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event
return
end
local force = event.entity.force local force = event.entity.force
global.forcedata[force.name].death_link_tick = game.tick global.forcedata[force.name].death_link_tick = game.tick
dumpInfo(force) dumpInfo(force)
kill_players(force) kill_players(force)
end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}}) end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}})
end
-- add / commands -- add / commands
@ -392,7 +436,8 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress
local data_collection = { local data_collection = {
["research_done"] = research_done, ["research_done"] = research_done,
["victory"] = chain_lookup(global, "forcedata", force.name, "victory"), ["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 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.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."})
game.play_sound({path="utility/research_completed"}) game.play_sound({path="utility/research_completed"})
tech.researched = true tech.researched = true
return
end end
return
elseif progressive_technologies[item_name] ~= nil then elseif progressive_technologies[item_name] ~= nil then
if global.index_sync[index] == nil then -- not yet received prog item if global.index_sync[index] == nil then -- not yet received prog item
global.index_sync[index] = item_name 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 elseif force.technologies[item_name] ~= nil then
tech = force.technologies[item_name] tech = force.technologies[item_name]
if tech ~= nil then 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 global.index_sync[index] = tech
if tech.researched ~= true then if tech.researched ~= true then
game.print({"", "Received [technology=" .. tech.name .. "] from ", source}) game.print({"", "Received [technology=" .. tech.name .. "] from ", source})

View File

@ -100,6 +100,20 @@ function adjust_energy(recipe_name, factor)
end end
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-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-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) 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) }}) adjust_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_scale) }})
{%- endif %} {%- endif %}
{%- endfor -%} {%- 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 %} {% endif %}
{%- if silo==2 %} {%- if silo==2 %}

View File

@ -22,4 +22,10 @@ ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends somet
{%- else %} {%- 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 }}". 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 -%} {%- 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 # Incompatible option handling
# ER and glitched logic are not compatible; glitched takes priority # ER and glitched logic are not compatible; glitched takes priority
if self.logic_rules == 'glitched': if self.logic_rules == 'glitched':
self.shuffle_interior_entrances = False self.shuffle_interior_entrances = 'off'
self.shuffle_grotto_entrances = False self.shuffle_grotto_entrances = False
self.shuffle_dungeon_entrances = False self.shuffle_dungeon_entrances = False
self.shuffle_overworld_entrances = False self.shuffle_overworld_entrances = False

View File

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

View File

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

View File

@ -2,16 +2,15 @@ import os, importlib
from logic.logic import Logic from logic.logic import Logic
from patches.common.patches import patches, additional_PLMs from patches.common.patches import patches, additional_PLMs
from utils.parameters import appDir from utils.parameters import appDir
from Utils import is_frozen
class PatchAccess(object): class PatchAccess(object):
def __init__(self): def __init__(self):
# load all ips patches # load all ips patches
self.patchesPath = {} 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): for patch in os.listdir(commonDir):
self.patchesPath[patch] = 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): for patch in os.listdir(logicDir):
self.patchesPath[patch] = logicDir self.patchesPath[patch] = logicDir

View File

@ -327,7 +327,7 @@ class VariaRandomizer:
preset = loadRandoPreset(world, self.player, args) preset = loadRandoPreset(world, self.player, args)
# use the skill preset from the rando preset # 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: 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 diff preset given, load it
if args.paramsFileName is not None: if args.paramsFileName is not None:
@ -352,7 +352,7 @@ class VariaRandomizer:
sys.exit(-1) sys.exit(-1)
else: else:
preset = 'default' 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 from logic.smbool import SMBool
import os import os
import sys import sys
from pathlib import Path
# the different difficulties available # the different difficulties available
easy = 1 easy = 1
@ -60,7 +61,7 @@ def diff4solver(difficulty):
return "mania" return "mania"
# allow multiple local repo # allow multiple local repo
appDir = sys.path[0] appDir = Path(__file__).parents[4]
def isKnows(knows): def isKnows(knows):
return knows[0:len('__')] != '__' and knows[0] == knows[0].upper() 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 Knows, Settings, Controller, isKnows, isSettings, isButton
from utils.parameters import easy, medium, hard, harder, hardcore, mania, text2diff from utils.parameters import easy, medium, hard, harder, hardcore, mania, text2diff
from logic.smbool import SMBool from logic.smbool import SMBool
from Utils import is_frozen
def isStdPreset(preset): def isStdPreset(preset):
return preset in ['newbie', 'casual', 'regular', 'veteran', 'expert', 'master', 'samus', 'solution', 'Season_Races', 'SMRAT2021'] return preset in ['newbie', 'casual', 'regular', 'veteran', 'expert', 'master', 'samus', 'solution', 'Season_Races', 'SMRAT2021']
def getPresetDir(preset): def getPresetDir(preset) -> str:
if isStdPreset(preset): 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: 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): def removeChars(string, toRemove):
return re.sub('[{}]+'.format(toRemove), '', string) return re.sub('[{}]+'.format(toRemove), '', string)

View File

@ -5,6 +5,7 @@ from Utils import get_options, output_path
import typing import typing
import lzma import lzma
import os import os
import os.path
import threading import threading
try: try:
@ -200,11 +201,18 @@ class SoEWorld(World):
line = f'{loc.type},{loc.index}:{item.type},{item.index}\n' line = f'{loc.type},{loc.index}:{item.type},{item.index}\n'
f.write(line.encode('utf-8')) 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, if not os.path.exists(rom_file):
flags, money, exp)): 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() raise RuntimeError()
with lzma.LZMAFile(patch_file, 'wb') as f: 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: except:
raise raise
finally: 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 2', 1337088),
LocationData('Refugee Camp', 'Refugee camp storage chest 1', 1337089), LocationData('Refugee Camp', 'Refugee camp storage chest 1', 1337089),
LocationData('Forest', 'Refugee camp roof', 1337090), 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', 'Green platform secret', 1337092, lambda state: state._timespinner_can_break_walls(world, player)),
LocationData('Forest', 'Rats guarded chest', 1337093), LocationData('Forest', 'Rats guarded chest', 1337093),
LocationData('Forest', 'Waterfall chest 1', 1337094, lambda state: state.has('Water Mask', player)), 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', 'Out of the way', 1337139),
LocationData('Castle Keep', 'Killed Twins', EventId, lambda state: state._timespinner_has_timestop(world, player)), 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', '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 (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', 'Above the gap', 1337143),
LocationData('Royal towers', 'Under the ice mage', 1337144), 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: 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)) 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: 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) 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 (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 (upper)')
connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (lower)') 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)', '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)', '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)) connect(world, player, names, 'Varndagroth tower right (lower)', 'Militairy Fortress', lambda state: state._timespinner_can_kill_all_3_bosses(world, player))