LttP: Implement DeathLink

This commit is contained in:
Fabian Dill 2021-11-01 19:37:47 +01:00
parent 8ff01ca979
commit 0e0cc0ad16
7 changed files with 99 additions and 50 deletions

View File

@ -5,6 +5,7 @@ import urllib.parse
import sys import sys
import os import os
import typing import typing
import time
import websockets import websockets
@ -135,6 +136,8 @@ class CommonContext():
self.input_queue = asyncio.Queue() self.input_queue = asyncio.Queue()
self.input_requests = 0 self.input_requests = 0
self.last_death_link: float = time.time() # last send/received death link on AP layer
# game state # game state
self.player_names: typing.Dict[int: str] = {0: "Archipelago"} self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.exit_event = asyncio.Event() 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 except Exception as e: # safeguard against permissions that may be implemented in the future
logger.exception(e) 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): 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)
@ -461,7 +478,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}") logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
elif cmd == "Bounced": 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: else:
logger.debug(f"unknown command {cmd}") logger.debug(f"unknown command {cmd}")

View File

@ -58,7 +58,6 @@ class FactorioContext(CommonContext):
self.rcon_client = None self.rcon_client = None
self.awaiting_bridge = False self.awaiting_bridge = False
self.write_data_path = None 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.death_link_tick: int = 0 # last send death link on Factorio layer
self.factorio_json_text_parser = FactorioJSONtoTextParser(self) 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] " self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}") 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): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}: if cmd in {"Connected", "RoomUpdate"}:
# catch up sync anything that is already cleared. # 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 self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]}) 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): async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher") bridge_logger = logging.getLogger("FactorioWatcher")
@ -150,14 +147,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
ctx.last_death_link = time.time() await ctx.send_death()
await ctx.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"],
"data": {
"time": ctx.last_death_link,
"source": ctx.player_names[ctx.slot]
}
}])
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
except Exception as e: except Exception as e:

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import argparse import argparse
import atexit import atexit
exit_func = atexit.register(input, "Press enter to close.") exit_func = atexit.register(input, "Press enter to close.")
import threading import threading
import time import time
@ -12,13 +14,10 @@ import logging
import asyncio import asyncio
from json import loads, dumps from json import loads, dumps
from Utils import get_item_name_from_id
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
from Utils import get_item_name_from_id
import colorama import colorama
from NetUtils import * from NetUtils import *
@ -35,7 +34,10 @@ snes_logger = logging.getLogger("SNES")
from MultiServer import mark_raw from MultiServer import mark_raw
class LttPCommandProcessor(ClientCommandProcessor): class LttPCommandProcessor(ClientCommandProcessor):
ctx: Context
def _cmd_slow_mode(self, toggle: str = ""): def _cmd_slow_mode(self, toggle: str = ""):
"""Toggle slow mode, which limits how fast you send / receive items.""" """Toggle slow mode, which limits how fast you send / receive items."""
if toggle: if toggle:
@ -47,17 +49,18 @@ class LttPCommandProcessor(ClientCommandProcessor):
@mark_raw @mark_raw
def _cmd_snes(self, snes_options: str = "") -> bool: 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_address = self.ctx.snes_address
snes_device_number = -1 snes_device_number = -1
options = snes_options.split() options = snes_options.split()
num_options = len(options) num_options = len(options)
if num_options > 0: if num_options > 0:
snes_address = options[0] snes_address = options[0]
if num_options > 1: if num_options > 1:
try: try:
snes_device_number = int(options[1]) snes_device_number = int(options[1])
@ -94,6 +97,7 @@ class Context(CommonContext):
self.snes_request_lock = asyncio.Lock() self.snes_request_lock = asyncio.Lock()
self.snes_write_buffer = [] self.snes_write_buffer = []
self.snes_connector_lock = threading.Lock() self.snes_connector_lock = threading.Lock()
self.death_state = False # for death link flop behaviour
self.awaiting_rom = False self.awaiting_rom = False
self.rom = None self.rom = None
@ -109,7 +113,7 @@ class Context(CommonContext):
raise Exception('Invalid ROM detected, ' raise Exception('Invalid ROM detected, '
'please verify that you have loaded the correct rom and reconnect your snes (/snes)') '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: if password_requested and not self.password:
await super(Context, self).server_auth(password_requested) await super(Context, self).server_auth(password_requested)
if self.rom is None: if self.rom is None:
@ -121,10 +125,17 @@ class Context(CommonContext):
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_msgs([{"cmd": 'Connect',
'password': self.password, 'name': auth, 'version': Utils.version_tuple, 'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': self.tags, 'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past" '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: def color_item(item_id: int, green: bool = False) -> str:
@ -147,6 +158,7 @@ ROMNAME_SIZE = 0x15
INGAME_MODES = {0x07, 0x09, 0x0b} INGAME_MODES = {0x07, 0x09, 0x0b}
ENDGAME_MODES = {0x19, 0x1a} ENDGAME_MODES = {0x19, 0x1a}
DEATH_MODES = {0x12}
SAVEDATA_START = WRAM_START + 0xF000 SAVEDATA_START = WRAM_START + 0xF000
SAVEDATA_SIZE = 0x500 SAVEDATA_SIZE = 0x500
@ -162,6 +174,8 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes 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_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), 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 - Pre-Moldorm Chest': (0x3d, 0x40),
'Ganons Tower - Validation Chest': (0x4d, 0x10)} '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, location_table_npc = {'Mushroom': 0x1000,
'King Zora': 0x2, 'King Zora': 0x2,
@ -401,7 +415,7 @@ location_table_npc = {'Mushroom': 0x1000,
'Stumpy': 0x8, 'Stumpy': 0x8,
'Bombos Tablet': 0x200} '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, location_table_ow = {'Flute Spot': 0x2a,
'Sunken Treasure': 0x3b, 'Sunken Treasure': 0x3b,
@ -416,14 +430,15 @@ location_table_ow = {'Flute Spot': 0x2a,
'Bumper Cave Ledge': 0x4a, 'Bumper Cave Ledge': 0x4a,
'Floating Island': 0x5} '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), location_table_misc = {'Bottle Merchant': (0x3c9, 0x2),
'Purple Chest': (0x3c9, 0x10), 'Purple Chest': (0x3c9, 0x10),
"Link's Uncle": (0x3c6, 0x1), "Link's Uncle": (0x3c6, 0x1),
'Hobo': (0x3c9, 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): class SNESState(enum.IntEnum):
SNES_DISCONNECTED = 0 SNES_DISCONNECTED = 0
@ -446,10 +461,11 @@ def launch_sni(ctx: Context):
if os.path.isfile(sni_path): if os.path.isfile(sni_path):
snes_logger.info(f"Attempting to start {sni_path}") snes_logger.info(f"Attempting to start {sni_path}")
import subprocess 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)) subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path))
else: 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: else:
snes_logger.info( snes_logger.info(
f"Attempt to start SNI was aborted as path {sni_path} was not found, " 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()) reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
await socket.close() await socket.close()
return devices return devices
async def snes_connect(ctx: Context, address, deviceIndex = -1): async def snes_connect(ctx: Context, address, deviceIndex=-1):
global SNES_RECONNECT_DELAY global SNES_RECONNECT_DELAY
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED: if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
if ctx.rom: if ctx.rom:
@ -534,7 +549,8 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1):
device = devices[ctx.snes_attached_device[0]] device = devices[ctx.snes_attached_device[0]]
elif numDevices > 1: elif numDevices > 1:
if deviceIndex == -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): for idx, availableDevice in enumerate(devices):
snes_logger.info(str(idx + 1) + ": " + availableDevice) snes_logger.info(str(idx + 1) + ": " + availableDevice)
@ -544,7 +560,7 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1):
else: else:
device = devices[deviceIndex - 1] device = devices[deviceIndex - 1]
if device is None: if device is None:
await snes_disconnect(ctx) await snes_disconnect(ctx)
return return
@ -676,6 +692,7 @@ async def snes_write(ctx: Context, write_list):
for address, data in write_list: for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None: 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(dumps(PutAddress_Request))
await ctx.snes_socket.send(data) await ctx.snes_socket.send(data)
else: else:
@ -712,7 +729,8 @@ async def track_locations(ctx: Context, roomid, roomdata):
new_locations.append(location_id) new_locations.append(location_id)
ctx.locations_checked.add(location_id) ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(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: try:
if roomid in location_shop_ids: 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: if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id) new_check(location_id)
if new_locations: if new_locations:
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
@ -804,6 +821,17 @@ async def game_watcher(ctx: Context):
continue continue
ctx.rom = rom 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: 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()
@ -817,6 +845,14 @@ async def game_watcher(ctx: Context):
await ctx.disconnect() await ctx.disconnect()
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) 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) gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
if gamemode is None or gameend is None or game_timer is None or \ 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], subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def main(): async def main():
multiprocessing.freeze_support() multiprocessing.freeze_support()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()

View File

@ -388,6 +388,11 @@ class ExcludeLocations(OptionSet):
verify_location_name = True 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 = { per_game_common_options = {
"local_items": LocalItems, "local_items": LocalItems,
"non_local_items": NonLocalItems, "non_local_items": NonLocalItems,

View File

@ -1,6 +1,6 @@
import typing import typing
from Options import Choice, Range, Option, Toggle, DefaultOnToggle from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink
class Logic(Choice): class Logic(Choice):
@ -292,6 +292,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
"music": Music, "music": Music,
"reduceflashing": ReduceFlashing, "reduceflashing": ReduceFlashing,
"triforcehud": TriforceHud, "triforcehud": TriforceHud,
"glitch_boots": DefaultOnToggle "glitch_boots": DefaultOnToggle,
"death_link": DeathLink
} }

View File

@ -1645,6 +1645,7 @@ def patch_rom(world, rom, player, enemized):
# remote items flag, does not currently work # remote items flag, does not currently work
rom.write_byte(0x18637C, int(world.worlds[player].remote_items)) rom.write_byte(0x18637C, int(world.worlds[player].remote_items))
rom.write_byte(0x18008D, int(world.death_link[player]))
# set rom name # set rom name
# 21 bytes # 21 bytes
from Main import __version__ from Main import __version__

View File

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import typing 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 from schema import Schema, Optional, And, Or
# schema helpers # schema helpers
@ -284,11 +284,6 @@ class ImportedBlueprint(DefaultOnToggle):
displayname = "Blueprints" 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)] = { factorio_options: typing.Dict[str, type(Option)] = {
"max_science_pack": MaxSciencePack, "max_science_pack": MaxSciencePack,
"tech_tree_layout": TechTreeLayout, "tech_tree_layout": TechTreeLayout,