forked from ArchipelagoMW/Archipelago
-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Initial client #2
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
e4b0b45
Initial client with logic for maintaining a connection to dolphin
hesto2 6f48543
Add logic to check for whether the game is in a state to receive/send…
hesto2 aad6144
PEP8 formatting
hesto2 bafb566
minor docs updates
hesto2 3de07db
Add inventory status
hesto2 0420d73
add health interface
hesto2 e073378
add ability to add to player inventory
hesto2 2aef647
Setup code for receiving items from Archipelago
hesto2 79d6969
minor log update
hesto2 84547dd
minor format update
hesto2 e27e3de
Add code for updating the artifact layers
hesto2 c51e7cf
add call that will make sure collected artifacts and their associated…
hesto2 899180a
Add sending item logic
hesto2 3ed044b
Add victory condition
hesto2 88256c9
Update suit texture when a new suit is received
hesto2 0af619d
Merge branch 'main' into initial-client
hesto2 87861b3
Add alive functions that work
hesto2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
import asyncio | ||
import logging | ||
import traceback | ||
|
||
from CommonClient import ClientCommandProcessor, CommonContext, get_base_parser, logger, server_loop, gui_enabled | ||
from NetUtils import ClientStatus, NetworkItem | ||
import Utils | ||
from worlds.metroidprime.DolphinClient import DolphinException | ||
from worlds.metroidprime.Locations import METROID_PRIME_LOCATION_BASE, every_location | ||
from worlds.metroidprime.MetroidPrimeInterface import InventoryItemData, MetroidPrimeInterface, MetroidPrimeLevel | ||
|
||
|
||
class MetroidPrimeCommandProcessor(ClientCommandProcessor): | ||
def __init__(self, ctx: CommonContext): | ||
super().__init__(ctx) | ||
|
||
|
||
class MetroidPrimeContext(CommonContext): | ||
current_level_id = 0 | ||
previous_level_id = 0 | ||
command_processor = MetroidPrimeCommandProcessor | ||
game_interface: MetroidPrimeInterface | ||
game = "Metroid Prime" | ||
items_handling = 0b111 | ||
dolphin_sync_task = None | ||
|
||
def __init__(self, server_address, password): | ||
super().__init__(server_address, password) | ||
self.game_interface = MetroidPrimeInterface(logger) | ||
|
||
def on_deathlink(self, data: Utils.Dict[str, Utils.Any]) -> None: | ||
super().on_deathlink(data) | ||
logger.debug("Death link not implemented") | ||
|
||
async def server_auth(self, password_requested: bool = False): | ||
if password_requested and not self.password: | ||
await super(MetroidPrimeContext, self).server_auth(password_requested) | ||
await self.get_username() | ||
await self.send_connect() | ||
|
||
|
||
async def dolphin_sync_task(ctx: MetroidPrimeContext): | ||
logger.info("Starting Dolphin connector") | ||
while not ctx.exit_event.is_set(): | ||
try: | ||
if ctx.game_interface.is_connected() and ctx.game_interface.is_in_playable_state(): | ||
await _handle_game_ready(ctx) | ||
else: | ||
await _handle_game_not_ready(ctx) | ||
except Exception as e: | ||
if isinstance(e, DolphinException): | ||
logger.error(str(e)) | ||
else: | ||
logger.error(traceback.format_exc()) | ||
|
||
logger.info("Attempting to reconnect to Dolphin") | ||
await ctx.disconnect() | ||
await asyncio.sleep(3) | ||
continue | ||
|
||
|
||
def inventory_item_by_network_id(network_id: int, current_inventory: dict[str, InventoryItemData]) -> InventoryItemData: | ||
for item in current_inventory.values(): | ||
if item.code == network_id: | ||
return item | ||
return None | ||
|
||
|
||
def get_total_count_of_item_received(network_id: int, items: list[NetworkItem]) -> int: | ||
count = 0 | ||
for network_item in items: | ||
if network_item.item == network_id: | ||
count += 1 | ||
return count | ||
|
||
|
||
async def handle_checked_location(ctx: MetroidPrimeContext, current_inventory: dict[str, InventoryItemData]): | ||
"""Uses the current amount of UnknownItem1 in inventory as an indicator of which location was checked. This will break if the player collects more than one pickup without having the AP client hooked to the game and server""" | ||
unknown_item1 = current_inventory["UnknownItem1"] | ||
if (unknown_item1.current_amount == 0): | ||
return | ||
checked_location_id = METROID_PRIME_LOCATION_BASE + \ | ||
unknown_item1.current_amount - 1 | ||
logger.debug( | ||
f"Checked location: {checked_location_id} with amount: {unknown_item1.current_amount} ") | ||
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": [checked_location_id]}]) | ||
ctx.game_interface.give_item_to_player(unknown_item1.id, 0, 999) | ||
|
||
|
||
async def handle_receive_items(ctx: MetroidPrimeContext, current_items: dict[str, InventoryItemData]): | ||
# Handle Single Item Upgrades | ||
for network_item in ctx.items_received: | ||
item_data = inventory_item_by_network_id( | ||
network_item.item, current_items) | ||
if item_data is None: | ||
logger.debug( | ||
f"Item with network id {network_item.item} not found in inventory. {network_item}") | ||
continue | ||
if item_data.max_capacity == 1 and item_data.current_amount == 0: | ||
logger.debug(f"Giving item {item_data.name} to player") | ||
ctx.game_interface.give_item_to_player(item_data.id, 1, 1) | ||
|
||
# Handle Missile Expansions | ||
amount_of_missiles_given_per_item = 5 | ||
missile_item = current_items["Missile Expansion"] | ||
num_missile_expansions_received = get_total_count_of_item_received( | ||
missile_item.code, ctx.items_received) | ||
diff = num_missile_expansions_received * \ | ||
amount_of_missiles_given_per_item - missile_item.current_capacity | ||
if diff > 0 and missile_item.current_capacity < missile_item.max_capacity: | ||
new_capacity = min(num_missile_expansions_received * | ||
amount_of_missiles_given_per_item, missile_item.max_capacity) | ||
new_amount = min(missile_item.current_amount + diff, new_capacity) | ||
logger.debug( | ||
f"Setting missile expansion to {new_amount}/{new_capacity} from {missile_item.current_amount}/{missile_item.current_capacity}") | ||
ctx.game_interface.give_item_to_player( | ||
missile_item.id, new_amount, new_capacity) | ||
|
||
# Handle Power Bomb Expansions | ||
power_bomb_item = current_items["Power Bomb Expansion"] | ||
num_power_bombs_received = get_total_count_of_item_received( | ||
power_bomb_item.code, ctx.items_received) | ||
diff = num_power_bombs_received - power_bomb_item.current_capacity | ||
if diff > 0 and power_bomb_item.current_capacity < power_bomb_item.max_capacity: | ||
new_capacity = min(num_power_bombs_received, | ||
power_bomb_item.max_capacity) | ||
new_amount = min(power_bomb_item.current_amount + diff, new_capacity) | ||
logger.debug( | ||
f"Setting power bomb expansions to {new_capacity} from {power_bomb_item.current_capacity}") | ||
ctx.game_interface.give_item_to_player( | ||
power_bomb_item.id, new_capacity, new_capacity) | ||
|
||
# Handle Energy Tanks | ||
energy_tank_item = current_items["Energy Tank"] | ||
num_energy_tanks_received = get_total_count_of_item_received( | ||
energy_tank_item.code, ctx.items_received) | ||
diff = num_energy_tanks_received - energy_tank_item.current_capacity | ||
if diff > 0 and energy_tank_item.current_capacity < energy_tank_item.max_capacity: | ||
new_capacity = min(num_energy_tanks_received, | ||
energy_tank_item.max_capacity) | ||
logger.debug( | ||
f"Setting energy tanks to {new_capacity} from {energy_tank_item.current_capacity}") | ||
ctx.game_interface.give_item_to_player( | ||
energy_tank_item.id, new_capacity, new_capacity) | ||
|
||
# Heal player when they receive a new energy tank | ||
# Player starts with 99 health and each energy tank adds 100 additional | ||
ctx.game_interface.set_current_health(new_capacity * 100.0 + 99) | ||
|
||
# Handle Artifacts | ||
ctx.game_interface.sync_artifact_layers() | ||
|
||
|
||
async def handle_check_goal_complete(ctx: MetroidPrimeContext): | ||
current_level = ctx.game_interface.get_current_level() | ||
if current_level == MetroidPrimeLevel.End_of_Game: | ||
logger.debug("Sending Goal Complete") | ||
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) | ||
|
||
|
||
async def _handle_game_ready(ctx: MetroidPrimeContext): | ||
if ctx.server: | ||
if not ctx.slot: | ||
await asyncio.sleep(1) | ||
return | ||
current_inventory = ctx.game_interface.get_current_inventory() | ||
await handle_receive_items(ctx, current_inventory) | ||
await handle_checked_location(ctx, current_inventory) | ||
await handle_check_goal_complete(ctx) | ||
|
||
if "DeathLink" in ctx.tags: | ||
logger.debug("DeathLink not implemented") | ||
await asyncio.sleep(0.5) | ||
else: | ||
logger.info("Waiting for player to connect to server") | ||
await asyncio.sleep(1) | ||
|
||
|
||
async def _handle_game_not_ready(ctx: MetroidPrimeContext): | ||
"""If the game is not connected or not in a playable state, this will attempt to retry connecting to the game.""" | ||
if not ctx.game_interface.is_connected(): | ||
logger.info("Attempting to connect to Dolphin") | ||
ctx.game_interface.connect_to_game() | ||
elif not ctx.game_interface.is_in_playable_state(): | ||
logger.info( | ||
"Waiting for player to load a save file or start a new game") | ||
await asyncio.sleep(3) | ||
|
||
|
||
def main(connect=None, password=None, name=None): | ||
Utils.init_logging("Metroid Prime Client") | ||
|
||
async def _main(connect, password, name): | ||
ctx = MetroidPrimeContext(connect, password) | ||
ctx.auth = name | ||
ctx.server_task = asyncio.create_task( | ||
server_loop(ctx), name="ServerLoop") | ||
if gui_enabled: | ||
ctx.run_gui() | ||
await asyncio.sleep(1) | ||
|
||
ctx.dolphin_sync_task = asyncio.create_task( | ||
dolphin_sync_task(ctx), name="DolphinSync") | ||
|
||
await ctx.exit_event.wait() | ||
ctx.server_address = None | ||
|
||
await ctx.shutdown() | ||
|
||
if ctx.dolphin_sync_task: | ||
await asyncio.sleep(3) | ||
await ctx.dolphin_sync_task | ||
|
||
import colorama | ||
|
||
colorama.init() | ||
asyncio.run(_main(connect, password, name)) | ||
colorama.deinit() | ||
|
||
|
||
if __name__ == "__main__": | ||
parser = get_base_parser() | ||
parser.add_argument('--name', default=None, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added support for a |
||
help="Slot Name to connect as.") | ||
args = parser.parse_args() | ||
logger.setLevel(logging.DEBUG) | ||
main(args.connect, args.password, args.name) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
from logging import Logger | ||
import dolphin_memory_engine | ||
|
||
|
||
GC_GAME_ID_ADDRESS = 0x80000000 | ||
|
||
|
||
class DolphinException(Exception): | ||
pass | ||
|
||
|
||
class DolphinClient: | ||
dolphin: dolphin_memory_engine | ||
logger: Logger | ||
|
||
def __init__(self, logger): | ||
self.dolphin = dolphin_memory_engine | ||
self.logger = logger | ||
|
||
def is_connected(self): | ||
try: | ||
self.__assert_connected() | ||
return True | ||
except Exception: | ||
return False | ||
|
||
def connect(self): | ||
if not self.dolphin.is_hooked(): | ||
self.dolphin.hook() | ||
if not self.dolphin.is_hooked(): | ||
raise DolphinException( | ||
"Could not connect to Dolphin, verify that you have a game running in the emulator") | ||
|
||
def disconnect(self): | ||
if self.dolphin.is_hooked(): | ||
self.dolphin.un_hook() | ||
|
||
def __assert_connected(self): | ||
"""Custom assert function that returns a DolphinException instead of a generic RuntimeError if the connection is lost""" | ||
try: | ||
self.dolphin.assert_hooked() | ||
# For some reason the dolphin_memory_engine.is_hooked() function doesn't recognize when the game is closed, checking if memory is available will assert the connection is alive | ||
self.dolphin.read_bytes(GC_GAME_ID_ADDRESS, 1) | ||
except RuntimeError as e: | ||
self.disconnect() | ||
raise DolphinException(e) | ||
|
||
def verify_target_address(self, target_address: int, read_size: int): | ||
"""Ensures that the target address is within the valid range for GC memory""" | ||
if target_address < 0x80000000 or target_address + read_size > 0x81800000: | ||
raise DolphinException( | ||
f"{target_address:x} -> {target_address + read_size:x} is not a valid for GC memory" | ||
) | ||
|
||
def read_pointer(self, pointer, offset, byte_count): | ||
self.__assert_connected() | ||
|
||
address = None | ||
try: | ||
address = self.dolphin.follow_pointers(pointer, [0]) | ||
except RuntimeError: | ||
self.logger.error(f"Could not read pointer at {pointer:x}") | ||
return None | ||
|
||
if not self.dolphin.is_hooked(): | ||
raise DolphinException("Dolphin no longer connected") | ||
|
||
address += offset | ||
return self.read_address(address, byte_count) | ||
|
||
def read_address(self, address, bytes_to_read): | ||
self.__assert_connected() | ||
self.verify_target_address(address, bytes_to_read) | ||
result = self.dolphin.read_bytes(address, bytes_to_read) | ||
return result | ||
|
||
def write_pointer(self, pointer, offset, data): | ||
self.__assert_connected() | ||
address = None | ||
try: | ||
address = self.dolphin.follow_pointers(pointer, [0]) | ||
except RuntimeError: | ||
self.logger.error(f"Could not read pointer at {pointer:x}") | ||
return None | ||
|
||
if not self.dolphin.is_hooked(): | ||
raise DolphinException("Dolphin no longer connected") | ||
|
||
address += offset | ||
return self.write_address(address, data) | ||
|
||
def write_address(self, address, data): | ||
self.__assert_connected() | ||
result = self.dolphin.write_bytes(address, data) | ||
return result |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not in love with the size of this function, but I figured rather than creating an abstraction it'd be more pragmatic to keep them separate since each of these 3 different powerups have a slightly different effect when you receive them (gain 5 missiles, gain 1 bomb, restore health). Open to suggestions here though for sure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is an area where we can also gate the player from receiving any Missile or PB expansions if they haven't received the Missile Launcher or Power Bomb Main items yet.