diff --git a/MultiServer.py b/MultiServer.py index 1414b7dc..e2e49e8d 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -28,6 +28,9 @@ from Utils import get_item_name_from_id, get_location_name_from_address, Receive console_names = frozenset(set(Items.item_table) | set(Regions.location_table)) +CLIENT_PLAYING = 0 +CLIENT_GOAL = 1 + class Client: version: typing.List[int] = [0, 0, 0] @@ -58,7 +61,7 @@ class Client: class Context: def __init__(self, host: str, port: int, password: str, location_check_points: int, hint_cost: int, - item_cheat: bool, forfeit_allowed): + item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled"): self.data_filename = None self.save_filename = None self.disable_save = False @@ -79,11 +82,12 @@ class Context: self.location_check_points = location_check_points self.hints_used = collections.defaultdict(int) self.hints: typing.Dict[typing.Tuple[int, int], typing.Set[Utils.Hint]] = collections.defaultdict(set) - self.forfeit_allowed = forfeit_allowed + self.forfeit_mode: str = forfeit_mode + self.remaining_mode: str = remaining_mode self.item_cheat = item_cheat self.running = True self.client_activity_timers = {} - self.client_finished_game = {} + self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int) self.commandprocessor = ServerCommandProcessor(self) def get_save(self) -> dict: @@ -94,6 +98,7 @@ class Context: "hints": tuple((key, list(value)) for key, value in self.hints.items()), "location_checks": tuple((key, tuple(value)) for key, value in self.location_checks.items()), "name_aliases": tuple((key, value) for key, value in self.name_aliases.items()), + "client_game_state": tuple((key, value) for key, value in self.client_game_state.items()) } return d @@ -122,6 +127,8 @@ class Context: self.hints[team, hint.finding_player].add(hint) if "name_aliases" in savedata: self.name_aliases.update({tuple(key): value for key, value in savedata["name_aliases"]}) + if "client_game_state" in savedata: + self.client_game_state.update({tuple(key): value for key, value in savedata["client_game_state"]}) self.location_checks.update({tuple(key): set(value) for key, value in savedata["location_checks"]}) logging.info(f'Loaded save file with {sum([len(p) for p in received_items.values()])} received items ' f'for {len(received_items)} players') @@ -313,6 +320,13 @@ def forfeit_player(ctx: Context, team: int, slot: int): register_location_checks(ctx, team, slot, all_locations) +def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: + items = [] + for (location, location_slot) in ctx.locations: + if location_slot == slot and location not in ctx.location_checks[team, slot]: + items.append(ctx.locations[location, slot][0]) # item ID + return sorted(items) + def register_location_checks(ctx: Context, team: int, slot: int, locations): ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc) found_items = False @@ -524,13 +538,50 @@ class ClientMessageProcessor(CommandProcessor): def _cmd_forfeit(self) -> bool: """Surrender and send your remaining items out to their recipients""" - if self.ctx.forfeit_allowed: + if self.ctx.forfeit_mode == "enabled": forfeit_player(self.ctx, self.client.team, self.client.slot) return True - else: + elif self.ctx.forfeit_mode == "disabled": self.output( "Sorry, client forfeiting has been disabled on this server. You can ask the server admin for a /forfeit") return False + else: # is auto or goal + if self.ctx.client_game_state[self.client.team, self.client.slot] == CLIENT_GOAL: + forfeit_player(self.ctx, self.client.team, self.client.slot) + return True + else: + self.output( + "Sorry, client forfeiting requires you to have beaten the game on this server." + " You can ask the server admin for a /forfeit") + return False + + def _cmd_remaining(self) -> bool: + """List remaining items in your game, but not their location or recipient""" + if self.ctx.remaining_mode == "enabled": + remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) + if remaining_item_ids: + self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item") + for item_id in remaining_item_ids)) + else: + self.output("No remaining items found.") + return True + elif self.ctx.remaining_mode == "disabled": + self.output( + "Sorry, !remaining has been disabled on this server.") + return False + else: # is goal + if self.ctx.client_game_state[self.client.team, self.client.slot] == CLIENT_GOAL: + remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) + if remaining_item_ids: + self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item") + for item_id in remaining_item_ids)) + else: + self.output("No remaining items found.") + return True + else: + self.output( + "Sorry, !remaining requires you to have beaten the game on this server") + return False def _cmd_countdown(self, seconds: str = "10") -> bool: """Start a countdown in seconds""" @@ -739,11 +790,11 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args): client.tags = args elif cmd == 'GameFinished': - if (client.team, client.slot) not in ctx.client_finished_game: + if (client.team, client.slot) not in ctx.client_game_state: finished_msg = f'{client.name} (Team #{client.team + 1}) has found the triforce.' notify_all(ctx, finished_msg) print(finished_msg) - ctx.client_finished_game[client.team, client.slot] = True + ctx.client_game_state[client.team, client.slot] = CLIENT_GOAL # TODO: Add auto-forfeit code here if cmd == 'Say': @@ -787,6 +838,7 @@ class ServerCommandProcessor(CommandProcessor): def _cmd_save(self) -> bool: """Save current state to multidata""" save(self.ctx) + self.output("Game saved") return True def _cmd_players(self) -> bool: @@ -919,7 +971,21 @@ def parse_args() -> argparse.Namespace: parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int) parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true') parser.add_argument('--port_forward', default=defaults["port_forward"], action='store_true') - parser.add_argument('--disable_client_forfeit', default=defaults["disable_client_forfeit"], action='store_true') + parser.add_argument('--forfeit_mode', default=defaults["forfeit_mode"], nargs='?', + choices=['auto', 'enabled', 'disabled', "goal"], help='''\ + Select !forfeit Accessibility. (default: %(default)s) + auto: Automatic "forfeit" on goal completion + enabled: !forfeit is always available + disabled: !forfeit is never available + goal: !forfeit can be used after goal completion + ''') + parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?', + choices=['enabled', 'disabled', "goal"], help='''\ + Select !remaining Accessibility. (default: %(default)s) + enabled: !remaining is always available + disabled: !remaining is never available + goal: !remaining can be used after goal completion + ''') args = parser.parse_args() return args @@ -931,7 +997,7 @@ async def main(args: argparse.Namespace): portforwardtask = asyncio.create_task(forward_port(args.port)) ctx = Context(args.host, args.port, args.password, args.location_check_points, args.hint_cost, - not args.disable_item_cheat, not args.disable_client_forfeit) + not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode) ctx.data_filename = args.multidata diff --git a/host.yaml b/host.yaml index 3d54f2d2..af78c906 100644 --- a/host.yaml +++ b/host.yaml @@ -23,8 +23,16 @@ server_options: location_check_points: 1 #point cost to receive a hint via !hint for players hint_cost: 1000 #set to 0 if you want free hints - #disable client forfeit - disable_client_forfeit: false + #forfeit modes: "disabled" -> clients can't forfeit, "enabled" -> clients can always forfeit + # "auto" -> automatic forfeit on goal completion, "goal" -> clients can forfeit after achieving their goal + # warning: only Berserker's Multiworld clients of version 2.1+ send game beaten information + forfeit_mode: "goal" + # !remaining handling, that tells a client which items remain in their pool + # "enabled" -> client can always ask for remaining items + # "disabled" -> client can never ask for remaining items + # "goal" -> client can ask for remaining items after goal completion + # warning: only Berserker's Multiworld clients of version 2.1+ send game beaten information + remaining_mode: "goal" #options for MultiMystery.py multi_mystery_options: #teams, however, note that there is currently no way to supply names for teams 2+ through MultiMystery