From 34eba2655e3d247e00f0e91ebf33db4673ec9844 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Mon, 18 Oct 2021 22:58:29 +0200 Subject: [PATCH] MultiServer: add !collect and collect_mode CommonClient: make missing and checked location lookups faster FactorioClient: implement reverse grant technologies for collect/forfeit/coop --- CommonClient.py | 8 +- FactorioClient.py | 4 +- MultiServer.py | 84 +++++++++++++++++-- Utils.py | 1 + WebHostLib/customserver.py | 2 +- docs/network protocol.md | 18 +++- host.yaml | 11 ++- worlds/AutoWorld.py | 2 +- worlds/factorio/data/mod_template/control.lua | 7 +- 9 files changed, 116 insertions(+), 21 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 5c52fe8f..a348ec3a 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -117,8 +117,8 @@ class CommonContext(): self.locations_checked: typing.Set[int] = set() self.locations_scouted: typing.Set[int] = set() self.items_received = [] - self.missing_locations: typing.List[int] = [] - self.checked_locations: typing.List[int] = [] + self.missing_locations: typing.Set[int] = set() + self.checked_locations: typing.Set[int] = set() self.locations_info = {} self.input_queue = asyncio.Queue() @@ -389,8 +389,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict): # 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 = args["missing_locations"] - ctx.checked_locations = args["checked_locations"] + ctx.missing_locations = set(args["missing_locations"]) + ctx.checked_locations = set(args["checked_locations"]) elif cmd == 'ReceivedItems': start_index = args["index"] diff --git a/FactorioClient.py b/FactorioClient.py index 382f2279..af321dbc 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -99,9 +99,9 @@ class FactorioContext(CommonContext): f"{text}") def on_package(self, cmd: str, args: dict): - if cmd == "Connected": + if cmd in {"Connected", "RoomUpdate"}: # catch up sync anything that is already cleared. - if args["checked_locations"]: + if "checked_locations" in args and args["checked_locations"]: self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for item_name in args["checked_locations"]}) diff --git a/MultiServer.py b/MultiServer.py index 2f313b3e..7c797094 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -66,12 +66,14 @@ class Context: "password": str, "forfeit_mode": str, "remaining_mode": str, + "collect_mode": str, "item_cheat": bool, "compatibility": int} def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, - hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled", - auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, log_network: bool = False): + hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled", + remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, + log_network: bool = False): super(Context, self).__init__() self.log_network = log_network self.endpoints = [] @@ -86,6 +88,7 @@ class Context: self.allow_forfeits = {} self.remote_items = set() self.remote_start_inventory = set() + # player location_id item_id target_player_id self.locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {} self.host = host self.port = port @@ -102,6 +105,7 @@ class Context: self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set) self.forfeit_mode: str = forfeit_mode self.remaining_mode: str = remaining_mode + self.collect_mode: str = collect_mode self.item_cheat = item_cheat self.running = True self.client_activity_timers: typing.Dict[ @@ -484,6 +488,7 @@ def get_permissions(ctx) -> typing.Dict[str, Permission]: return { "forfeit": Permission.from_text(ctx.forfeit_mode), "remaining": Permission.from_text(ctx.remaining_mode), + "collect": Permission.from_text(ctx.collect_mode) } @@ -558,11 +563,32 @@ def send_new_items(ctx: Context): client.send_index = len(items) +def update_checked_locations(ctx: Context, team: int, slot: int): + for client in ctx.endpoints: + if client.team == team and client.slot == slot: + ctx.send_msgs(client, [{"cmd": "RoomUpdate", "checked_locations": get_checked_checks(ctx, client)}]) + + def forfeit_player(ctx: Context, team: int, slot: int): - # register any locations that are in the multidata + """register any locations that are in the multidata""" all_locations = set(ctx.locations[slot]) ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1)) register_location_checks(ctx, team, slot, all_locations) + update_checked_locations(ctx, team, slot) + + +def collect_player(ctx: Context, team: int, slot: int): + """register any locations that are in the multidata, pointing towards this player""" + all_locations = collections.defaultdict(set) + for source_slot, location_data in ctx.locations.items(): + for location_id, (item_id, target_player_id) in location_data.items(): + if target_player_id == slot: + all_locations[source_slot].add(location_id) + + ctx.notify_all("%s (Team #%d) has collected" % (ctx.player_names[(team, slot)], team + 1)) + for source_player, location_ids in all_locations.items(): + register_location_checks(ctx, team, source_player, location_ids) + update_checked_locations(ctx, team, source_player) def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: @@ -889,6 +915,25 @@ class ClientMessageProcessor(CommonCommandProcessor): " You can ask the server admin for a /forfeit") return False + def _cmd_forfeit(self) -> bool: + """Send your remaining items to yourself""" + if "enabled" in self.ctx.collect_mode: + collect_player(self.ctx, self.client.team, self.client.slot) + return True + elif "disabled" in self.ctx.collect_mode: + self.output( + "Sorry, client collecting has been disabled on this server. You can ask the server admin for a /collect") + return False + else: # is auto or goal + if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: + collect_player(self.ctx, self.client.team, self.client.slot) + return True + else: + self.output( + "Sorry, client collecting requires you to have beaten the game on this server." + " You can ask the server admin for a /collect") + 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": @@ -982,7 +1027,8 @@ class ClientMessageProcessor(CommonCommandProcessor): return True else: world = proxy_worlds[self.ctx.games[self.client.slot]] - item_name, usable, response = get_intended_text(input_text, world.all_names if not explicit_location else world.location_names) + item_name, usable, response = get_intended_text(input_text, + world.all_names if not explicit_location else world.location_names) if usable: if item_name in world.hint_blacklist: self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.") @@ -1238,6 +1284,8 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus) forfeit_player(ctx, client.team, client.slot) elif proxy_worlds[ctx.games[client.slot]].forced_auto_forfeit: forfeit_player(ctx, client.team, client.slot) + if "auto" in ctx.collect_mode: + collect_player(ctx, client.team, client.slot) ctx.client_game_state[client.team, client.slot] = new_status @@ -1317,9 +1365,21 @@ class ServerCommandProcessor(CommonCommandProcessor): self.output(response) return False + @mark_raw + def _cmd_collect(self, player_name: str) -> bool: + """Send out the remaining items to player.""" + seeked_player = player_name.lower() + for (team, slot), name in self.ctx.player_names.items(): + if name.lower() == seeked_player: + collect_player(self.ctx, team, slot) + return True + + self.output(f"Could not find player {player_name} to collect") + return False + @mark_raw def _cmd_forfeit(self, player_name: str) -> bool: - """Send out the remaining items from a player's game to their intended recipients""" + """Send out the remaining items from a player to their intended recipients""" seeked_player = player_name.lower() for (team, slot), name in self.ctx.player_names.items(): if name.lower() == seeked_player: @@ -1423,7 +1483,7 @@ class ServerCommandProcessor(CommonCommandProcessor): return input_text setattr(self.ctx, option_name, attrtype(option)) self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}") - if option_name in {"forfeit_mode", "remaining_mode"}: + if option_name in {"forfeit_mode", "remaining_mode", "collect_mode"}: self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}]) return True else: @@ -1469,6 +1529,15 @@ def parse_args() -> argparse.Namespace: goal: !forfeit can be used after goal completion auto-enabled: !forfeit is available and automatically triggered on goal completion ''') + parser.add_argument('--collect_mode', default=defaults["collect_mode"], nargs='?', + choices=['auto', 'enabled', 'disabled', "goal", "auto-enabled"], help='''\ + Select !collect Accessibility. (default: %(default)s) + auto: Automatic "collect" on goal completion + enabled: !collect is always available + disabled: !collect is never available + goal: !collect can be used after goal completion + auto-enabled: !collect is available and automatically triggered on goal completion + ''') parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?', choices=['enabled', 'disabled', "goal"], help='''\ Select !remaining Accessibility. (default: %(default)s) @@ -1523,7 +1592,8 @@ async def main(args: argparse.Namespace): format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO)) ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, - args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode, + args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.collect_mode, + args.remaining_mode, args.auto_shutdown, args.compatibility, args.log_network) data_filename = args.multidata diff --git a/Utils.py b/Utils.py index 3bfddd2e..27d5d68c 100644 --- a/Utils.py +++ b/Utils.py @@ -180,6 +180,7 @@ def get_default_options() -> dict: "location_check_points": 1, "hint_cost": 10, "forfeit_mode": "goal", + "collect_mode": "disabled", "remaining_mode": "goal", "auto_shutdown": 0, "compatibility": 2, diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 404a096f..c3b0215e 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -48,7 +48,7 @@ class DBCommandProcessor(ServerCommandProcessor): class WebHostContext(Context): def __init__(self): - super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", 0, 2) + super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2) self.main_loop = asyncio.get_running_loop() self.video = {} self.tags = ["AP", "WebHost"] diff --git a/docs/network protocol.md b/docs/network protocol.md index f55435c0..0247304a 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -53,7 +53,7 @@ Sent to clients when they connect to an Archipelago server. | version | NetworkVersion | Object denoting the version of Archipelago which the server is running. See [NetworkVersion](#NetworkVersion) for more details. | | tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` | | password | bool | Denoted whether a password is required to join this room.| -| permissions | dict\[str, Permission\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit" and "remaining". | +| permissions | dict\[str, Permission\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". | | hint_cost | int | The amount of points it costs to receive a hint from the server. | | location_check_points | int | The amount of hint points you receive per item/location check completed. || | players | list\[NetworkPlayer\] | Sent only if the client is properly authenticated (see [Archipelago Connection Handshake](#Archipelago-Connection-Handshake)). Information on the players currently connected to the server. See [NetworkPlayer](#NetworkPlayer) for more details. | @@ -61,7 +61,7 @@ Sent to clients when they connect to an Archipelago server. | datapackage_versions | dict[str, int] | Data versions of the individual games' data packages the server will send. | | seed_name | str | uniquely identifying name of this generation | -#### forfeit_mode +#### forfeit Dictates what is allowed when it comes to a player forfeiting their run. A forfeit is an action which distributes the rest of the items in a player's run to those other players awaiting them. * `auto`: Distributes a player's items to other players when they complete their goal. @@ -70,7 +70,17 @@ Dictates what is allowed when it comes to a player forfeiting their run. A forfe * `disabled`: All forfeit modes disabled. * `goal`: Allows for manual use of forfeit command once a player completes their goal. (Disabled until goal completion) -#### remaining_mode +#### collect +Dictates what is allowed when it comes to a player collecting their run. A collect is an action which sends the rest of the items in a player's run. + +* `auto`: Automatically when they complete their goal. +* `enabled`: Denotes that players may !collect at any time in the game. +* `auto-enabled`: Both of the above options together. +* `disabled`: All collect modes disabled. +* `goal`: Allows for manual use of collect command once a player completes their goal. (Disabled until goal completion) + + +#### remaining Dictates what is allowed when it comes to a player querying the items remaining in their run. * `goal`: Allows a player to query for items remaining in their run but only after they completed their own goal. @@ -365,7 +375,7 @@ class Permission(enum.IntEnum): disabled = 0b000 # 0, completely disables access enabled = 0b001 # 1, allows manual use goal = 0b010 # 2, allows manual use after goal completion - auto = 0b110 # 6, forces use after goal completion, only works for forfeit + auto = 0b110 # 6, forces use after goal completion, only works for forfeit and collect auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time ``` diff --git a/host.yaml b/host.yaml index e8d1ee4c..b8dca81a 100644 --- a/host.yaml +++ b/host.yaml @@ -23,12 +23,21 @@ server_options: # so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint, for a total of 5 hint_cost: 10 # Set to 0 if you want free hints # Forfeit modes + # A Forfeit sends out the remaining items *from* a world that forfeits # "disabled" -> clients can't forfeit, # "enabled" -> clients can always forfeit - # "auto" -> automatic forfeit on goal completion, "goal" -> clients can forfeit after achieving their goal + # "auto" -> automatic forfeit on goal completion # "auto-enabled" -> automatic forfeit on goal completion and manual forfeit is also enabled # "goal" -> forfeit is allowed after goal completion forfeit_mode: "goal" + # Collect modes + # A Collect sends the remaining items *to* a world that collects + # "disabled" -> clients can't collect, + # "enabled" -> clients can always collect + # "auto" -> automatic collect on goal completion, "goal" -> clients can forfeit after achieving their goal + # "auto-enabled" -> automatic collect on goal completion and collect forfeit is also enabled + # "goal" -> collect is allowed after goal completion + collect_mode: "disabled" # Remaining modes # !remaining handling, that tells a client which items remain in their pool # "enabled" -> Client can always ask for remaining items diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 47936d14..97b22d10 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -168,7 +168,7 @@ class World(metaclass=AutoWorldRegister): pass def get_required_client_version(self) -> Tuple[int, int, int]: - return 0, 0, 3 + return 0, 1, 6 # end of Main.py calls diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua index 03380c0d..ccc78222 100644 --- a/worlds/factorio/data/mod_template/control.lua +++ b/worlds/factorio/data/mod_template/control.lua @@ -57,6 +57,7 @@ function on_force_created(event) local data = {} data['earned_samples'] = {{ dict_to_lua(starting_items) }} data["victory"] = 0 + data["checked_technologies"] = {} global.forcedata[event.force] = data {%- if silo == 2 %} check_spawn_silo(force) @@ -200,7 +201,10 @@ end) script.on_event(defines.events.on_research_finished, function(event) local technology = event.research if technology.researched and string.find(technology.name, "ap%-") == 1 then - dumpInfo(technology.force) --is sendable + -- check if it came from the server anyway, then we don't need to double send. + if global.forcedata[technology.force.name]["checked_technologies"][technology.name] ~= nil then + dumpInfo(technology.force) --is sendable + end else if FREE_SAMPLES == 0 then return -- Nothing else to do @@ -389,6 +393,7 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi if index == -1 then -- for coop sync and restoring from an older savegame tech = force.technologies[item_name] if tech.researched ~= true then + global.forcedata[force.name]["checked_technologies"][tech.name] = 1 -- mark as don't send again game.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."}) game.play_sound({path="utility/research_completed"}) tech.researched = true