LttP: Implement DeathLink
This commit is contained in:
parent
8ff01ca979
commit
0e0cc0ad16
|
@ -5,6 +5,7 @@ import urllib.parse
|
|||
import sys
|
||||
import os
|
||||
import typing
|
||||
import time
|
||||
|
||||
import websockets
|
||||
|
||||
|
@ -135,6 +136,8 @@ class CommonContext():
|
|||
self.input_queue = asyncio.Queue()
|
||||
self.input_requests = 0
|
||||
|
||||
self.last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# game state
|
||||
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
|
||||
self.exit_event = asyncio.Event()
|
||||
|
@ -256,6 +259,20 @@ class CommonContext():
|
|||
except Exception as e: # safeguard against permissions that may be implemented in the future
|
||||
logger.exception(e)
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def send_death(self):
|
||||
self.last_death_link = time.time()
|
||||
await self.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": self.last_death_link,
|
||||
"source": self.player_names[self.slot]
|
||||
}
|
||||
}])
|
||||
|
||||
|
||||
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)
|
||||
|
@ -461,7 +478,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
|
|||
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
|
||||
|
||||
elif cmd == "Bounced":
|
||||
pass
|
||||
tags = args.get("tags", [])
|
||||
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
|
||||
ctx.on_deathlink(args["data"])
|
||||
|
||||
else:
|
||||
logger.debug(f"unknown command {cmd}")
|
||||
|
|
|
@ -58,7 +58,6 @@ class FactorioContext(CommonContext):
|
|||
self.rcon_client = None
|
||||
self.awaiting_bridge = False
|
||||
self.write_data_path = None
|
||||
self.last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
self.death_link_tick: int = 0 # last send death link on Factorio layer
|
||||
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
|
||||
|
||||
|
@ -102,6 +101,10 @@ class FactorioContext(CommonContext):
|
|||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
if self.rcon_client:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd in {"Connected", "RoomUpdate"}:
|
||||
# catch up sync anything that is already cleared.
|
||||
|
@ -109,12 +112,6 @@ class FactorioContext(CommonContext):
|
|||
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
|
||||
item_name in args["checked_locations"]})
|
||||
|
||||
elif cmd == "Bounced":
|
||||
if self.rcon_client:
|
||||
tags = args.get("tags", [])
|
||||
if "DeathLink" in tags and self.last_death_link != args["data"]["time"]:
|
||||
self.rcon_client.send_command(f"/ap-deathlink {args['data']['source']}")
|
||||
|
||||
|
||||
async def game_watcher(ctx: FactorioContext):
|
||||
bridge_logger = logging.getLogger("FactorioWatcher")
|
||||
|
@ -150,14 +147,8 @@ async def game_watcher(ctx: FactorioContext):
|
|||
death_link_tick = data.get("death_link_tick", 0)
|
||||
if death_link_tick != ctx.death_link_tick:
|
||||
ctx.death_link_tick = death_link_tick
|
||||
ctx.last_death_link = time.time()
|
||||
await ctx.send_msgs([{
|
||||
"cmd": "Bounce", "tags": ["DeathLink"],
|
||||
"data": {
|
||||
"time": ctx.last_death_link,
|
||||
"source": ctx.player_names[ctx.slot]
|
||||
}
|
||||
}])
|
||||
await ctx.send_death()
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from __future__ import annotations
|
||||
import argparse
|
||||
import atexit
|
||||
|
||||
exit_func = atexit.register(input, "Press enter to close.")
|
||||
import threading
|
||||
import time
|
||||
|
@ -12,13 +14,10 @@ import logging
|
|||
import asyncio
|
||||
from json import loads, dumps
|
||||
|
||||
from Utils import get_item_name_from_id
|
||||
|
||||
|
||||
import ModuleUpdate
|
||||
|
||||
ModuleUpdate.update()
|
||||
|
||||
from Utils import get_item_name_from_id
|
||||
import colorama
|
||||
|
||||
from NetUtils import *
|
||||
|
@ -35,7 +34,10 @@ snes_logger = logging.getLogger("SNES")
|
|||
|
||||
from MultiServer import mark_raw
|
||||
|
||||
|
||||
class LttPCommandProcessor(ClientCommandProcessor):
|
||||
ctx: Context
|
||||
|
||||
def _cmd_slow_mode(self, toggle: str = ""):
|
||||
"""Toggle slow mode, which limits how fast you send / receive items."""
|
||||
if toggle:
|
||||
|
@ -47,17 +49,18 @@ class LttPCommandProcessor(ClientCommandProcessor):
|
|||
|
||||
@mark_raw
|
||||
def _cmd_snes(self, snes_options: str = "") -> bool:
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices; and a SNES device number if more than one SNES is detected"""
|
||||
|
||||
"""Connect to a snes. Optionally include network address of a snes to connect to,
|
||||
otherwise show available devices; and a SNES device number if more than one SNES is detected"""
|
||||
|
||||
snes_address = self.ctx.snes_address
|
||||
snes_device_number = -1
|
||||
|
||||
|
||||
options = snes_options.split()
|
||||
num_options = len(options)
|
||||
|
||||
|
||||
if num_options > 0:
|
||||
snes_address = options[0]
|
||||
|
||||
|
||||
if num_options > 1:
|
||||
try:
|
||||
snes_device_number = int(options[1])
|
||||
|
@ -94,6 +97,7 @@ class Context(CommonContext):
|
|||
self.snes_request_lock = asyncio.Lock()
|
||||
self.snes_write_buffer = []
|
||||
self.snes_connector_lock = threading.Lock()
|
||||
self.death_state = False # for death link flop behaviour
|
||||
|
||||
self.awaiting_rom = False
|
||||
self.rom = None
|
||||
|
@ -109,7 +113,7 @@ class Context(CommonContext):
|
|||
raise Exception('Invalid ROM detected, '
|
||||
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
|
||||
|
||||
async def server_auth(self, password_requested):
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
await super(Context, self).server_auth(password_requested)
|
||||
if self.rom is None:
|
||||
|
@ -121,10 +125,17 @@ class Context(CommonContext):
|
|||
self.auth = self.rom
|
||||
auth = base64.b64encode(self.rom).decode()
|
||||
await self.send_msgs([{"cmd": 'Connect',
|
||||
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
|
||||
}])
|
||||
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
|
||||
'tags': self.tags,
|
||||
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
|
||||
}])
|
||||
|
||||
def on_deathlink(self, data: dict):
|
||||
snes_buffered_write(self, WRAM_START+0xF36D, bytes([0]))
|
||||
snes_buffered_write(self, WRAM_START+0x0373, bytes([8]))
|
||||
asyncio.create_task(snes_flush_writes(self))
|
||||
self.death_state = True
|
||||
snes_logger.info(f"Received DeathLink from {data['source']}")
|
||||
|
||||
|
||||
def color_item(item_id: int, green: bool = False) -> str:
|
||||
|
@ -147,6 +158,7 @@ ROMNAME_SIZE = 0x15
|
|||
|
||||
INGAME_MODES = {0x07, 0x09, 0x0b}
|
||||
ENDGAME_MODES = {0x19, 0x1a}
|
||||
DEATH_MODES = {0x12}
|
||||
|
||||
SAVEDATA_START = WRAM_START + 0xF000
|
||||
SAVEDATA_SIZE = 0x500
|
||||
|
@ -162,6 +174,8 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
|
|||
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
|
||||
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
|
||||
|
||||
DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x18008D # 1 byte
|
||||
|
||||
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
|
||||
|
||||
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
||||
|
@ -385,7 +399,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
|
|||
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
|
||||
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
|
||||
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_uw.items()}
|
||||
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
|
||||
|
||||
location_table_npc = {'Mushroom': 0x1000,
|
||||
'King Zora': 0x2,
|
||||
|
@ -401,7 +415,7 @@ location_table_npc = {'Mushroom': 0x1000,
|
|||
'Stumpy': 0x8,
|
||||
'Bombos Tablet': 0x200}
|
||||
|
||||
location_table_npc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_npc.items()}
|
||||
location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()}
|
||||
|
||||
location_table_ow = {'Flute Spot': 0x2a,
|
||||
'Sunken Treasure': 0x3b,
|
||||
|
@ -416,14 +430,15 @@ location_table_ow = {'Flute Spot': 0x2a,
|
|||
'Bumper Cave Ledge': 0x4a,
|
||||
'Floating Island': 0x5}
|
||||
|
||||
location_table_ow_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_ow.items()}
|
||||
location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()}
|
||||
|
||||
location_table_misc = {'Bottle Merchant': (0x3c9, 0x2),
|
||||
'Purple Chest': (0x3c9, 0x10),
|
||||
"Link's Uncle": (0x3c6, 0x1),
|
||||
'Hobo': (0x3c9, 0x1)}
|
||||
|
||||
location_table_misc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_misc.items()}
|
||||
location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()}
|
||||
|
||||
|
||||
class SNESState(enum.IntEnum):
|
||||
SNES_DISCONNECTED = 0
|
||||
|
@ -446,10 +461,11 @@ def launch_sni(ctx: Context):
|
|||
if os.path.isfile(sni_path):
|
||||
snes_logger.info(f"Attempting to start {sni_path}")
|
||||
import subprocess
|
||||
if Utils.is_frozen(): # if it spawns a visible console, may as well populate it
|
||||
if Utils.is_frozen(): # if it spawns a visible console, may as well populate it
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path))
|
||||
else:
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL)
|
||||
else:
|
||||
snes_logger.info(
|
||||
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
|
||||
|
@ -500,12 +516,11 @@ async def get_snes_devices(ctx: Context):
|
|||
reply = loads(await socket.recv())
|
||||
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
|
||||
|
||||
|
||||
await socket.close()
|
||||
return devices
|
||||
|
||||
|
||||
async def snes_connect(ctx: Context, address, deviceIndex = -1):
|
||||
async def snes_connect(ctx: Context, address, deviceIndex=-1):
|
||||
global SNES_RECONNECT_DELAY
|
||||
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
|
||||
if ctx.rom:
|
||||
|
@ -534,7 +549,8 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1):
|
|||
device = devices[ctx.snes_attached_device[0]]
|
||||
elif numDevices > 1:
|
||||
if deviceIndex == -1:
|
||||
snes_logger.info("Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
|
||||
snes_logger.info(
|
||||
"Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
|
||||
|
||||
for idx, availableDevice in enumerate(devices):
|
||||
snes_logger.info(str(idx + 1) + ": " + availableDevice)
|
||||
|
@ -544,7 +560,7 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1):
|
|||
|
||||
else:
|
||||
device = devices[deviceIndex - 1]
|
||||
|
||||
|
||||
if device is None:
|
||||
await snes_disconnect(ctx)
|
||||
return
|
||||
|
@ -676,6 +692,7 @@ async def snes_write(ctx: Context, write_list):
|
|||
for address, data in write_list:
|
||||
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
|
||||
if ctx.snes_socket is not None:
|
||||
snes_logger.info((PutAddress_Request, data))
|
||||
await ctx.snes_socket.send(dumps(PutAddress_Request))
|
||||
await ctx.snes_socket.send(data)
|
||||
else:
|
||||
|
@ -712,7 +729,8 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
|||
new_locations.append(location_id)
|
||||
ctx.locations_checked.add(location_id)
|
||||
location = ctx.location_name_getter(location_id)
|
||||
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
snes_logger.info(
|
||||
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
|
||||
try:
|
||||
if roomid in location_shop_ids:
|
||||
|
@ -782,7 +800,6 @@ async def track_locations(ctx: Context, roomid, roomdata):
|
|||
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
|
||||
new_check(location_id)
|
||||
|
||||
|
||||
if new_locations:
|
||||
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
|
||||
|
||||
|
@ -804,6 +821,17 @@ async def game_watcher(ctx: Context):
|
|||
continue
|
||||
|
||||
ctx.rom = rom
|
||||
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1)
|
||||
if death_link:
|
||||
death_link = bool(death_link[0])
|
||||
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:
|
||||
snes_logger.info("Forcing reconnect to set DeathLink state.")
|
||||
await ctx.disconnect() # set correct tags
|
||||
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
|
||||
ctx.locations_checked = set()
|
||||
ctx.locations_scouted = set()
|
||||
|
@ -817,6 +845,14 @@ async def game_watcher(ctx: Context):
|
|||
await ctx.disconnect()
|
||||
|
||||
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
|
||||
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
|
||||
snes_logger.info((ctx.last_death_link + 1 < time.time(), ctx.last_death_link, time.time()))
|
||||
if gamemode[0] in DEATH_MODES:
|
||||
if not ctx.death_state: # new death
|
||||
await ctx.send_death()
|
||||
ctx.death_state = True
|
||||
else:
|
||||
ctx.death_state = False # reset death state, so next death can trigger
|
||||
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
|
||||
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
|
||||
if gamemode is None or gameend is None or game_timer is None or \
|
||||
|
@ -891,6 +927,7 @@ async def run_game(romfile):
|
|||
subprocess.Popen([auto_start, romfile],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
|
||||
async def main():
|
||||
multiprocessing.freeze_support()
|
||||
parser = argparse.ArgumentParser()
|
||||
|
|
|
@ -388,6 +388,11 @@ class ExcludeLocations(OptionSet):
|
|||
verify_location_name = True
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
displayname = "Death Link"
|
||||
|
||||
|
||||
per_game_common_options = {
|
||||
"local_items": LocalItems,
|
||||
"non_local_items": NonLocalItems,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import typing
|
||||
|
||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle
|
||||
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink
|
||||
|
||||
|
||||
class Logic(Choice):
|
||||
|
@ -292,6 +292,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
|
|||
"music": Music,
|
||||
"reduceflashing": ReduceFlashing,
|
||||
"triforcehud": TriforceHud,
|
||||
"glitch_boots": DefaultOnToggle
|
||||
"glitch_boots": DefaultOnToggle,
|
||||
"death_link": DeathLink
|
||||
|
||||
}
|
||||
|
|
|
@ -1645,6 +1645,7 @@ def patch_rom(world, rom, player, enemized):
|
|||
# remote items flag, does not currently work
|
||||
rom.write_byte(0x18637C, int(world.worlds[player].remote_items))
|
||||
|
||||
rom.write_byte(0x18008D, int(world.death_link[player]))
|
||||
# set rom name
|
||||
# 21 bytes
|
||||
from Main import __version__
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
import typing
|
||||
|
||||
from Options import Choice, OptionDict, ItemDict, Option, DefaultOnToggle, Range, Toggle
|
||||
from Options import Choice, OptionDict, ItemDict, Option, DefaultOnToggle, Range, DeathLink
|
||||
from schema import Schema, Optional, And, Or
|
||||
|
||||
# schema helpers
|
||||
|
@ -284,11 +284,6 @@ class ImportedBlueprint(DefaultOnToggle):
|
|||
displayname = "Blueprints"
|
||||
|
||||
|
||||
class DeathLink(Toggle):
|
||||
"""When you die, everyone dies. Of course the reverse is true too."""
|
||||
displayname = "Death Link"
|
||||
|
||||
|
||||
factorio_options: typing.Dict[str, type(Option)] = {
|
||||
"max_science_pack": MaxSciencePack,
|
||||
"tech_tree_layout": TechTreeLayout,
|
||||
|
|
Loading…
Reference in New Issue