diff --git a/ChecksFinderClient.py b/ChecksFinderClient.py new file mode 100644 index 00000000..cc78ab34 --- /dev/null +++ b/ChecksFinderClient.py @@ -0,0 +1,658 @@ +from __future__ import annotations +import os +import logging +import asyncio +import urllib.parse +import sys +import typing +import time + +import websockets + +import Utils + +if __name__ == "__main__": + Utils.init_logging("ChecksFinderClient", exception_logger="Client") + +from MultiServer import CommandProcessor +from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission +from Utils import Version, stream_input +from worlds import network_data_package, AutoWorldRegister +from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \ + keep_alive +from worlds.checksfinder import ChecksFinderWorld + + +class ClientCommandProcessor(CommandProcessor): + def __init__(self, ctx: CommonContext): + self.ctx = ctx + + def output(self, text: str): + logger.info(text) + + def _cmd_exit(self) -> bool: + """Close connections and client""" + self.ctx.exit_event.set() + return True + + def _cmd_connect(self, address: str = "") -> bool: + """Connect to a MultiWorld Server""" + self.ctx.server_address = None + asyncio.create_task(self.ctx.connect(address if address else None), name="connecting") + return True + + def _cmd_disconnect(self) -> bool: + """Disconnect from a MultiWorld Server""" + self.ctx.server_address = None + asyncio.create_task(self.ctx.disconnect(), name="disconnecting") + return True + + def _cmd_received(self) -> bool: + """List all received items""" + logger.info(f'{len(self.ctx.items_received)} received items:') + for index, item in enumerate(self.ctx.items_received, 1): + self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}") + return True + + def _cmd_missing(self) -> bool: + """List all missing location checks, from your local game state""" + if not self.ctx.game: + self.output("No game set, cannot determine missing checks.") + return False + count = 0 + checked_count = 0 + for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items(): + if location_id < 0: + continue + if location_id not in self.ctx.locations_checked: + if location_id in self.ctx.missing_locations: + self.output('Missing: ' + location) + count += 1 + elif location_id in self.ctx.checked_locations: + self.output('Checked: ' + location) + count += 1 + checked_count += 1 + + if count: + self.output( + f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}") + else: + self.output("No missing location checks found.") + return True + + def _cmd_items(self): + """List all item names for the currently running game.""" + 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): + """List all location names for the currently running game.""" + 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_resync(self): + """Manually trigger a resync.""" + self.output(f"Syncing items.") + self.ctx.syncing = True + + def _cmd_ready(self): + """Send ready status to server.""" + self.ctx.ready = not self.ctx.ready + if self.ctx.ready: + state = ClientStatus.CLIENT_READY + self.output("Readied up.") + else: + state = ClientStatus.CLIENT_CONNECTED + self.output("Unreadied.") + asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") + + def default(self, raw: str): + raw = self.ctx.on_user_say(raw) + if raw: + asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") + + +class CommonContext(): + tags: typing.Set[str] = {"AP"} + starting_reconnect_delay: int = 5 + current_reconnect_delay: int = starting_reconnect_delay + command_processor: int = ClientCommandProcessor + game = None + ui = None + keep_alive_task = None + items_handling: typing.Optional[int] = None + current_energy_link_value = 0 # to display in UI, gets set by server + + def __init__(self, server_address, password): + # server state + self.send_index: int = 0 + self.server_address = server_address + self.password = password + self.syncing = False + self.awaiting_bridge = False + self.server_task = None + self.server: typing.Optional[Endpoint] = None + self.server_version = Version(0, 0, 0) + self.hint_cost: typing.Optional[int] = None + self.games: typing.Dict[int, str] = {} + self.permissions = { + "forfeit": "disabled", + "collect": "disabled", + "remaining": "disabled", + } + + # own state + self.finished_game = False + self.ready = False + self.team = None + self.slot = None + self.auth = None + self.seed_name = None + + self.locations_checked: typing.Set[int] = set() # local state + self.locations_scouted: typing.Set[int] = set() + self.items_received = [] + self.missing_locations: typing.Set[int] = set() + self.checked_locations: typing.Set[int] = set() # server state + self.locations_info = {} + + 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() + self.watcher_event = asyncio.Event() + + self.slow_mode = False + self.jsontotextparser = JSONtoTextParser(self) + self.set_getters(network_data_package) + + # execution + self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") + + @property + def total_locations(self) -> typing.Optional[int]: + """Will return None until connected.""" + if self.checked_locations or self.missing_locations: + return len(self.checked_locations | self.missing_locations) + + async def connection_closed(self): + self.auth = None + self.items_received = [] + self.locations_info = {} + self.server_version = Version(0, 0, 0) + if self.server and self.server.socket is not None: + await self.server.socket.close() + self.server = None + self.server_task = None + path = os.path.expandvars(r"%localappdata%/ChecksFinder") + for root, dirs, files in os.walk(path): + for file in files: + if file.find("obtain") <= -1: + os.remove(root+"/"+file) + + # noinspection PyAttributeOutsideInit + def set_getters(self, data_package: dict, network=False): + if not network: # local data; check if newer data was already downloaded + local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {}) + if local_package and local_package["version"] > network_data_package["version"]: + data_package: dict = local_package + elif network: # check if data from server is newer + + if data_package["version"] > network_data_package["version"]: + Utils.persistent_store("datapackage", "latest", network_data_package) + + item_lookup: dict = {} + locations_lookup: dict = {} + for game, gamedata in data_package["games"].items(): + for item_name, item_id in gamedata["item_name_to_id"].items(): + item_lookup[item_id] = item_name + for location_name, location_id in gamedata["location_name_to_id"].items(): + locations_lookup[location_id] = location_name + + def get_item_name_from_id(code: int): + return item_lookup.get(code, f'Unknown item (ID:{code})') + + self.item_name_getter = get_item_name_from_id + + def get_location_name_from_address(address: int): + return locations_lookup.get(address, f'Unknown location (ID:{address})') + + self.location_name_getter = get_location_name_from_address + + @property + def endpoints(self): + if self.server: + return [self.server] + else: + return [] + + async def disconnect(self): + if self.server and not self.server.socket.closed: + await self.server.socket.close() + if self.server_task is not None: + await self.server_task + + async def send_msgs(self, msgs): + if not self.server or not self.server.socket.open or self.server.socket.closed: + return + await self.server.socket.send(encode(msgs)) + + def consume_players_package(self, package: typing.List[tuple]): + self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team} + self.player_names[0] = "Archipelago" + + def event_invalid_slot(self): + raise Exception('Invalid Slot; please verify that you have connected to the correct world.') + + def event_invalid_game(self): + raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.') + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + logger.info('Enter the password required to join this game:') + self.password = await self.console_input() + return self.password + + async def send_connect(self, **kwargs): + payload = { + 'cmd': 'Connect', + 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, + 'tags': self.tags, 'items_handling': self.items_handling, + 'uuid': Utils.get_unique_identifier(), 'game': self.game + } + if kwargs: + payload.update(kwargs) + await self.send_msgs([payload]) + + async def console_input(self): + self.input_requests += 1 + return await self.input_queue.get() + + async def connect(self, address=None): + await self.disconnect() + self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") + + def on_print(self, args: dict): + logger.info(args["text"]) + + def on_print_json(self, args: dict): + if self.ui: + self.ui.print_json(args["data"]) + else: + text = self.jsontotextparser(args["data"]) + logger.info(text) + + def on_package(self, cmd: str, args: dict): + pass + + def on_user_say(self, text: str) -> typing.Optional[str]: + """Gets called before sending a Say to the server from the user. + Returned text is sent, or sending is aborted if None is returned.""" + return text + + def update_permissions(self, permissions: typing.Dict[str, int]): + for permission_name, permission_flag in permissions.items(): + try: + flag = Permission(permission_flag) + logger.info(f"{permission_name.capitalize()} permission: {flag.name}") + self.permissions[permission_name] = flag.name + except Exception as e: # safeguard against permissions that may be implemented in the future + logger.exception(e) + + 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() + path = os.path.expandvars(r"%localappdata%/ChecksFinder") + for root, dirs, files in os.walk(path): + for file in files: + if file.find("obtain") <= -1: + os.remove(root+"/"+file) + + # DeathLink hooks + + def on_deathlink(self, data: dict): + """Gets dispatched when a new DeathLink is triggered by another linked player.""" + self.last_death_link = max(data["time"], self.last_death_link) + text = data.get("cause", "") + if text: + logger.info(f"DeathLink: {text}") + else: + logger.info(f"DeathLink: Received from {data['source']}") + + async def send_death(self, death_text: str = ""): + if self.server and self.server.socket: + logger.info("DeathLink: Sending death to your friends...") + 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], + "cause": death_text + } + }]) + + 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 server_loop(ctx: CommonContext, address=None): + cached_address = None + if ctx.server and ctx.server.socket: + logger.error('Already connected') + return + + if address is None: # set through CLI or APBP + address = ctx.server_address + + # Wait for the user to provide a multiworld server address + if not address: + logger.info('Please connect to an Archipelago server.') + return + + address = f"ws://{address}" if "://" not in address else address + port = urllib.parse.urlparse(address).port or 38281 + logger.info(f'Connecting to Archipelago server at {address}') + try: + socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) + ctx.server = Endpoint(socket) + logger.info('Connected') + ctx.server_address = address + ctx.current_reconnect_delay = ctx.starting_reconnect_delay + async for data in ctx.server.socket: + for msg in decode(data): + await process_server_cmd(ctx, msg) + logger.warning('Disconnected from multiworld server, type /connect to reconnect') + except ConnectionRefusedError: + if cached_address: + logger.error('Unable to connect to multiworld server at cached address. ' + 'Please use the connect button above.') + else: + logger.exception('Connection refused by the multiworld server') + except websockets.InvalidURI: + logger.exception('Failed to connect to the multiworld server (invalid URI)') + except (OSError, websockets.InvalidURI): + logger.exception('Failed to connect to the multiworld server') + except Exception as e: + logger.exception('Lost connection to the multiworld server, type /connect to reconnect') + finally: + await ctx.connection_closed() + if ctx.server_address: + logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s") + asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect") + ctx.current_reconnect_delay *= 2 + + +async def process_server_cmd(ctx: CommonContext, args: dict): + try: + cmd = args["cmd"] + except: + logger.exception(f"Could not get command from {args}") + raise + if cmd == 'RoomInfo': + if ctx.seed_name and ctx.seed_name != args["seed_name"]: + logger.info("The server is running a different multiworld than your client is. (invalid seed_name)") + else: + logger.info('--------------------------------') + logger.info('Room Information:') + logger.info('--------------------------------') + version = args["version"] + ctx.server_version = tuple(version) + version = ".".join(str(item) for item in version) + + logger.info(f'Server protocol version: {version}') + logger.info("Server protocol tags: " + ", ".join(args["tags"])) + if args['password']: + logger.info('Password required') + ctx.update_permissions(args.get("permissions", {})) + if "games" in args: + ctx.games = {x: game for x, game in enumerate(args["games"], start=1)} + logger.info( + f"A !hint costs {args['hint_cost']}% of your total location count as points" + f" and you get {args['location_check_points']}" + f" for each location checked. Use !hint for more information.") + ctx.hint_cost = int(args['hint_cost']) + ctx.check_points = int(args['location_check_points']) + + if len(args['players']) < 1: + logger.info('No player connected') + else: + args['players'].sort() + current_team = -1 + logger.info('Connected Players:') + for network_player in args['players']: + if network_player.team != current_team: + logger.info(f' Team #{network_player.team + 1}') + current_team = network_player.team + logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) + if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0: + await ctx.send_msgs([{"cmd": "GetDataPackage"}]) + await ctx.server_auth(args['password']) + + elif cmd == 'DataPackage': + logger.info("Got new ID/Name Datapackage") + ctx.set_getters(args['data'], network=True) + + elif cmd == 'ConnectionRefused': + errors = args["errors"] + if 'InvalidSlot' in errors: + ctx.event_invalid_slot() + elif 'InvalidGame' in errors: + ctx.event_invalid_game() + elif 'SlotAlreadyTaken' in errors: + raise Exception('Player slot already in use for that team') + elif 'IncompatibleVersion' in errors: + raise Exception('Server reported your client version as incompatible') + elif 'InvalidItemsHandling' in errors: + raise Exception('The item handling flags requested by the client are not supported') + # last to check, recoverable problem + elif 'InvalidPassword' in errors: + logger.error('Invalid password') + ctx.password = None + await ctx.server_auth(True) + elif errors: + raise Exception("Unknown connection errors: " + str(errors)) + else: + raise Exception('Connection refused by the multiworld host, no reason provided') + + elif cmd == 'Connected': + ctx.team = args["team"] + ctx.slot = args["slot"] + ctx.consume_players_package(args["players"]) + msgs = [] + if ctx.locations_checked: + msgs.append({"cmd": "LocationChecks", + "locations": list(ctx.locations_checked)}) + if ctx.locations_scouted: + msgs.append({"cmd": "LocationScouts", + "locations": list(ctx.locations_scouted)}) + if msgs: + await ctx.send_msgs(msgs) + if ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + + # Get the server side view of missing as of time of connecting. + # This list is used to only send to the server what is reported as ACTUALLY Missing. + # This also serves to allow an easy visual of what locations were already checked previously + # when /missing is used for the client side view of what is missing. + ctx.missing_locations = set(args["missing_locations"]) + ctx.checked_locations = set(args["checked_locations"]) + for ss in ctx.checked_locations: + filename = f"send{ss}" + with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f: + f.close() + + elif cmd == 'ReceivedItems': + start_index = args["index"] + + if start_index == 0: + ctx.items_received = [] + elif start_index != len(ctx.items_received): + sync_msg = [{'cmd': 'Sync'}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", + "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + if start_index == len(ctx.items_received): + for item in args['items']: + filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item" + with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f: + f.write(str(NetworkItem(*item).item)) + f.close() + ctx.items_received.append(NetworkItem(*item)) + ctx.watcher_event.set() + + elif cmd == 'LocationInfo': + for item, location, player in args['locations']: + if location not in ctx.locations_info: + ctx.locations_info[location] = (item, player) + ctx.watcher_event.set() + + elif cmd == "RoomUpdate": + if "players" in args: + ctx.consume_players_package(args["players"]) + if "hint_points" in args: + ctx.hint_points = args['hint_points'] + if "checked_locations" in args: + checked = set(args["checked_locations"]) + ctx.checked_locations |= checked + ctx.missing_locations -= checked + for ss in ctx.checked_locations: + filename = f"send{ss}" + with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f: + f.close() + if "permissions" in args: + ctx.update_permissions(args["permissions"]) + + elif cmd == 'Print': + ctx.on_print(args) + + elif cmd == 'PrintJSON': + ctx.on_print_json(args) + + elif cmd == 'InvalidPacket': + logger.warning(f"Invalid Packet of {args['type']}: {args['text']}") + + elif cmd == "Bounced": + tags = args.get("tags", []) + # we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this + if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]: + ctx.on_deathlink(args["data"]) + elif cmd == "SetReply": + if args["key"] == "EnergyLink": + ctx.current_energy_link_value = args["value"] + if ctx.ui: + ctx.ui.set_new_energy_link_value() + else: + logger.debug(f"unknown command {cmd}") + + ctx.on_package(cmd, args) + + +async def game_watcher(ctx: CommonContext): + from worlds.checksfinder.Locations import lookup_id_to_name + while not ctx.exit_event.is_set(): + if ctx.syncing == True: + sync_msg = [{'cmd': 'Sync'}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + ctx.syncing = False + path = os.path.expandvars(r"%localappdata%/ChecksFinder") + sending = [] + victory = False + for root, dirs, files in os.walk(path): + for file in files: + if file.find("send") > -1: + st = file.split("send", -1)[1] + sending = sending+[(int(st))] + if file.find("victory") > -1: + victory = True + ctx.locations_checked = sending + message = [{"cmd": 'LocationChecks', "locations": sending}] + await ctx.send_msgs(message) + if not ctx.finished_game and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + +if __name__ == '__main__': + # Text Mode to use !hint and such with games that have no text entry + + class TextContext(CommonContext): + game = "ChecksFinder" + items_handling = 0b111 # full remote + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(TextContext, self).server_auth(password_requested) + if not self.auth: + logger.info('Enter slot name:') + self.auth = await self.console_input() + + await self.send_connect() + + def on_package(self, cmd: str, args: dict): + if cmd == "Connected": + self.game = self.games.get(self.slot, None) + + + async def main(args): + ctx = TextContext(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + input_task = None + if gui_enabled: + from kvui import ChecksFinderManager + ctx.ui = ChecksFinderManager(ctx) + ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI") + else: + ui_task = None + if sys.stdin: + input_task = asyncio.create_task(console_loop(ctx), name="Input") + progression_watcher = asyncio.create_task( + game_watcher(ctx), name="ChecksFinderProgressionWatcher") + + await ctx.exit_event.wait() + ctx.server_address = None + + await progression_watcher + + await ctx.shutdown() + if ui_task: + await ui_task + + if input_task: + input_task.cancel() + + import colorama + + parser = get_base_parser(description="ChecksFinder Client, for text interfacing.") + + args, rest = parser.parse_known_args() + colorama.init() + + loop = asyncio.get_event_loop() + loop.run_until_complete(main(args)) + loop.close() + colorama.deinit() diff --git a/README.md b/README.md index 0d33c5f9..1d46e99b 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Currently, the following games are supported: * VVVVVV * Raft * Super Mario 64 +* ChecksFinder For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/WebHostLib/static/assets/gameInfo/en_ChecksFinder.md b/WebHostLib/static/assets/gameInfo/en_ChecksFinder.md new file mode 100644 index 00000000..3355f624 --- /dev/null +++ b/WebHostLib/static/assets/gameInfo/en_ChecksFinder.md @@ -0,0 +1,23 @@ +# ChecksFinder + +## Where is the settings page? + +The [player settings page for this game](../player-settings) contains all the options you need to configure and export a +config file. + +## What is considered a location check in ChecksFinder? + +Location checks in are completed when the player finds a spot on a board that has the archipelago logo. The bottom of +the screen has a number next to the archipelago logo, that number is how many you can find so far. You can only get as +many checks as you have gained items, plus one to start with being available. + +## When the player receives an item, what happens? + +When the player receives an item in ChecksFinder, it either can map the future boards they play be bigger in width or +height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being +bombs. + +## What is the victory condition? + +Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map +Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received. \ No newline at end of file diff --git a/WebHostLib/static/assets/tutorial/checksfinder/checksfinder_en.md b/WebHostLib/static/assets/tutorial/checksfinder/checksfinder_en.md new file mode 100644 index 00000000..551df781 --- /dev/null +++ b/WebHostLib/static/assets/tutorial/checksfinder/checksfinder_en.md @@ -0,0 +1,45 @@ +# ChecksFinder Randomizer Setup Guide + +## Required Software + +- ChecksFinder from + the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version) +- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) + - (select `ChecksFinder Client` during installation.) + +## Configuring your YAML file + +### What is a YAML file and why do I need one? + +See the guide on setting up a basic YAML at the Archipelago setup +guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en) + +### Where do I get a YAML file? + +You can customize your settings by visiting the [ChecksFinder Player Settings Page](/games/ChecksFinder/player-settings) + +### Generating a ChecksFinder game + +**ChecksFinder is meant to be played _alongside_ another game! You may not be playing it for long periods of time if +you play it with another person!** + +When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done, +the host will provide you with either a link to download your data file, or with a zip file containing everyone's data +files. You do not have a file inside that zip though! + +You need to start ChecksFinder client yourself, it is located within the Archipelago folder. + +### Connect to the MultiServer + +First start ChecksFinder. + +Once both ChecksFinder and the client are started. In the client at the top type in the spot labeled `Server` type the +`Ip Address` and `Port` separated with a `:` symbol. + +The client will then ask for the username you chose, input that in the text box at the bottom of the client. + +### Play the game + +When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a +multiworld game! + diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index 4a5c4cfd..f263fe68 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -517,5 +517,24 @@ ] } ] + }, + { + "gameTitle": "ChecksFinder", + "tutorials": [ + { + "name": "Multiworld Setup Tutorial", + "description": "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers single-player, multiworld, and related software.", + "files": [ + { + "language": "English", + "filename": "checksfinder/checksfinder_en.md", + "link": "checksfinder/checksfinder/en", + "authors": [ + "Mewlif" + ] + } + ] + } + ] } ] diff --git a/kvui.py b/kvui.py index 301b33d7..00d1e3f2 100644 --- a/kvui.py +++ b/kvui.py @@ -378,6 +378,13 @@ class FactorioManager(GameManager): base_title = "Archipelago Factorio Client" +class ChecksFinderManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago ChecksFinder Client" + + class SNIManager(GameManager): logging_pairs = [ ("Client", "Archipelago"), diff --git a/setup.py b/setup.py index c4b6c63b..a5831c49 100644 --- a/setup.py +++ b/setup.py @@ -94,6 +94,8 @@ scripts = { "OoTAdjuster.py": ("ArchipelagoOoTAdjuster", True, icon), # FF1 "FF1Client.py": ("ArchipelagoFF1Client", True, icon), + # ChecksFinder + "ChecksFinderClient.py": ("ArchipelagoChecksFinderClient", True, icon), } exes = [] diff --git a/worlds/checksfinder/Items.py b/worlds/checksfinder/Items.py new file mode 100644 index 00000000..2e862673 --- /dev/null +++ b/worlds/checksfinder/Items.py @@ -0,0 +1,27 @@ +from BaseClasses import Item +import typing + + +class ItemData(typing.NamedTuple): + code: typing.Optional[int] + progression: bool + + +class ChecksFinderItem(Item): + game: str = "ChecksFinder" + + +item_table = { + "Map Width": ItemData(80000, True), + "Map Height": ItemData(80001, True), + "Map Bombs": ItemData(80002, True), +} + +required_items = { +} + +item_frequencies = { + +} + +lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code} diff --git a/worlds/checksfinder/Locations.py b/worlds/checksfinder/Locations.py new file mode 100644 index 00000000..8a2ae07b --- /dev/null +++ b/worlds/checksfinder/Locations.py @@ -0,0 +1,52 @@ +from BaseClasses import Location +import typing + + +class AdvData(typing.NamedTuple): + id: typing.Optional[int] + region: str + + +class ChecksFinderAdvancement(Location): + game: str = "ChecksFinder" + + def __init__(self, player: int, name: str, address: typing.Optional[int], parent): + super().__init__(player, name, address, parent) + self.event = not address + + +advancement_table = { + "Tile 1": AdvData(81000, 'Board'), + "Tile 2": AdvData(81001, 'Board'), + "Tile 3": AdvData(81002, 'Board'), + "Tile 4": AdvData(81003, 'Board'), + "Tile 5": AdvData(81004, 'Board'), + "Tile 6": AdvData(81005, 'Board'), + "Tile 7": AdvData(81006, 'Board'), + "Tile 8": AdvData(81007, 'Board'), + "Tile 9": AdvData(81008, 'Board'), + "Tile 10": AdvData(81009, 'Board'), + "Tile 11": AdvData(81010, 'Board'), + "Tile 12": AdvData(81011, 'Board'), + "Tile 13": AdvData(81012, 'Board'), + "Tile 14": AdvData(81013, 'Board'), + "Tile 15": AdvData(81014, 'Board'), + "Tile 16": AdvData(81015, 'Board'), + "Tile 17": AdvData(81016, 'Board'), + "Tile 18": AdvData(81017, 'Board'), + "Tile 19": AdvData(81018, 'Board'), + "Tile 20": AdvData(81019, 'Board'), + "Tile 21": AdvData(81020, 'Board'), + "Tile 22": AdvData(81021, 'Board'), + "Tile 23": AdvData(81022, 'Board'), + "Tile 24": AdvData(81023, 'Board'), + "Tile 25": AdvData(81024, 'Board'), +} + +exclusion_table = { +} + +events_table = { +} + +lookup_id_to_name: typing.Dict[int, str] = {data.id: item_name for item_name, data in advancement_table.items() if data.id} \ No newline at end of file diff --git a/worlds/checksfinder/Options.py b/worlds/checksfinder/Options.py new file mode 100644 index 00000000..a6701093 --- /dev/null +++ b/worlds/checksfinder/Options.py @@ -0,0 +1,6 @@ +import typing +from Options import Option + + +checksfinder_options: typing.Dict[str, type(Option)] = { +} diff --git a/worlds/checksfinder/Regions.py b/worlds/checksfinder/Regions.py new file mode 100644 index 00000000..faae0b64 --- /dev/null +++ b/worlds/checksfinder/Regions.py @@ -0,0 +1,16 @@ + +def link_checksfinder_structures(world, player): + for (exit, region) in mandatory_connections: + world.get_entrance(exit, player).connect(world.get_region(region, player)) + +# (Region name, list of exits) +checksfinder_regions = [ + ('Menu', ['New Board']), + ('Board',[]), +] + +# (Entrance, region pointed to) +mandatory_connections = [ + ('New Board', 'Board'), +] + diff --git a/worlds/checksfinder/Rules.py b/worlds/checksfinder/Rules.py new file mode 100644 index 00000000..4e126687 --- /dev/null +++ b/worlds/checksfinder/Rules.py @@ -0,0 +1,47 @@ +from ..generic.Rules import set_rule, add_rule +from BaseClasses import MultiWorld +from ..AutoWorld import LogicMixin + + +class ChecksFinderLogic(LogicMixin): + + def _has_total(self, player: int, total: int): + return (self.item_count('Map Width', player)+self.item_count('Map Height', player)+ + self.item_count('Map Bombs', player)) >= total + + +# Sets rules on entrances and advancements that are always applied +def set_rules(world: MultiWorld, player: int): + set_rule(world.get_location(("Tile 6"), player), lambda state: state._has_total(player, 1)) + set_rule(world.get_location(("Tile 7"), player), lambda state: state._has_total(player, 2)) + set_rule(world.get_location(("Tile 8"), player), lambda state: state._has_total(player, 3)) + set_rule(world.get_location(("Tile 9"), player), lambda state: state._has_total(player, 4)) + set_rule(world.get_location(("Tile 10"), player), lambda state: state._has_total(player, 5)) + set_rule(world.get_location(("Tile 11"), player), lambda state: state._has_total(player, 6)) + set_rule(world.get_location(("Tile 12"), player), lambda state: state._has_total(player, 7)) + set_rule(world.get_location(("Tile 13"), player), lambda state: state._has_total(player, 8)) + set_rule(world.get_location(("Tile 14"), player), lambda state: state._has_total(player, 9)) + set_rule(world.get_location(("Tile 15"), player), lambda state: state._has_total(player, 10)) + set_rule(world.get_location(("Tile 16"), player), lambda state: state._has_total(player, 11)) + set_rule(world.get_location(("Tile 17"), player), lambda state: state._has_total(player, 12)) + set_rule(world.get_location(("Tile 18"), player), lambda state: state._has_total(player, 13)) + set_rule(world.get_location(("Tile 19"), player), lambda state: state._has_total(player, 14)) + set_rule(world.get_location(("Tile 20"), player), lambda state: state._has_total(player, 15)) + set_rule(world.get_location(("Tile 21"), player), lambda state: state._has_total(player, 16)) + set_rule(world.get_location(("Tile 22"), player), lambda state: state._has_total(player, 17)) + set_rule(world.get_location(("Tile 23"), player), lambda state: state._has_total(player, 18)) + set_rule(world.get_location(("Tile 24"), player), lambda state: state._has_total(player, 19)) + set_rule(world.get_location(("Tile 25"), player), lambda state: state._has_total(player, 20)) + + +# Sets rules on completion condition +def set_completion_rules(world: MultiWorld, player: int): + + width_req = 10-5 + height_req = 10-5 + bomb_req = 20-5 + completion_requirements = lambda state: \ + state.has("Map Width", player, width_req) and \ + state.has("Map Height", player, height_req) and \ + state.has("Map Bombs", player, bomb_req) + world.completion_condition[player] = lambda state: completion_requirements(state) diff --git a/worlds/checksfinder/__init__.py b/worlds/checksfinder/__init__.py new file mode 100644 index 00000000..b3b5d2a7 --- /dev/null +++ b/worlds/checksfinder/__init__.py @@ -0,0 +1,90 @@ +import os +import json +from base64 import b64encode, b64decode +from math import ceil + +from .Items import ChecksFinderItem, item_table, required_items +from .Locations import ChecksFinderAdvancement, advancement_table, exclusion_table +from .Regions import checksfinder_regions, link_checksfinder_structures +from .Rules import set_rules, set_completion_rules +from worlds.generic.Rules import exclusion_rules + +from BaseClasses import Region, Entrance, Item +from .Options import checksfinder_options +from ..AutoWorld import World + +client_version = 7 + +class ChecksFinderWorld(World): + """ + ChecksFinder is a game where you avoid mines and find checks inside the board + with the mines! You win when you get all your items and beat the board! + """ + game: str = "ChecksFinder" + options = checksfinder_options + topology_present = True + + item_name_to_id = {name: data.code for name, data in item_table.items()} + location_name_to_id = {name: data.id for name, data in advancement_table.items()} + + data_version = 4 + + def _get_checksfinder_data(self): + return { + 'world_seed': self.world.slot_seeds[self.player].getrandbits(32), + 'seed_name': self.world.seed_name, + 'player_name': self.world.get_player_name(self.player), + 'player_id': self.player, + 'client_version': client_version, + 'race': self.world.is_race, + } + + def generate_basic(self): + + # Generate item pool + itempool = [] + # Add all required progression items + for (name, num) in required_items.items(): + itempool += [name] * num + # Add the map width and height stuff + itempool += ["Map Width"] * (10-5) + itempool += ["Map Height"] * (10-5) + # Add the map bombs + itempool += ["Map Bombs"] * (20-5) + # Convert itempool into real items + itempool = [item for item in map(lambda name: self.create_item(name), itempool)] + + # Choose locations to automatically exclude based on settings + exclusion_pool = set() + + self.world.itempool += itempool + + def set_rules(self): + set_rules(self.world, self.player) + set_completion_rules(self.world, self.player) + + def create_regions(self): + def ChecksFinderRegion(region_name: str, exits=[]): + ret = Region(region_name, None, region_name, self.player, self.world) + ret.locations = [ChecksFinderAdvancement(self.player, loc_name, loc_data.id, ret) + for loc_name, loc_data in advancement_table.items() + if loc_data.region == region_name] + for exit in exits: + ret.exits.append(Entrance(self.player, exit, ret)) + return ret + + self.world.regions += [ChecksFinderRegion(*r) for r in checksfinder_regions] + link_checksfinder_structures(self.world, self.player) + + def fill_slot_data(self): + slot_data = self._get_checksfinder_data() + for option_name in checksfinder_options: + option = getattr(self.world, option_name)[self.player] + if slot_data.get(option_name, None) is None and type(option.value) in {str, int}: + slot_data[option_name] = int(option.value) + return slot_data + + def create_item(self, name: str) -> Item: + item_data = item_table[name] + item = ChecksFinderItem(name, item_data.progression, item_data.code, self.player) + return item