diff options
| author | Anson Bridges <bridges.anson@gmail.com> | 2025-08-15 23:04:40 -0700 |
|---|---|---|
| committer | Anson Bridges <bridges.anson@gmail.com> | 2025-08-15 23:04:40 -0700 |
| commit | f087c6a98b1da55525a6e3c1d7c82477f82eb5cd (patch) | |
| tree | 0e2b517bedb3dd475c2b82a1b05800e5b7593854 | |
| parent | d558a9add0e183219a7a9ff482807bdcd677e21a (diff) | |
Game Coordinator now mostly (~90%) functional
| -rw-r--r-- | export_presets.cfg | 59 | ||||
| -rw-r--r-- | network/GameCoordinatorTester.gd | 144 | ||||
| -rw-r--r-- | network/GameCoordinatorTester.tscn | 19 | ||||
| -rw-r--r-- | network/WSClient.tscn | 6 | ||||
| -rw-r--r-- | network/WebSocketTest.gd | 17 | ||||
| -rw-r--r-- | network/WebSocketTest.tscn | 12 | ||||
| -rw-r--r-- | network/websocket_client.gd | 33 | ||||
| -rw-r--r-- | pages/GameTable.tscn | 1 | ||||
| -rw-r--r-- | project.godot | 6 | ||||
| -rw-r--r-- | resources/external/client_ws_test.py | 12 | ||||
| -rw-r--r-- | resources/external/game_coordinator.py | 483 | ||||
| -rw-r--r-- | resources/external/websocket_test.py | 15 | ||||
| -rw-r--r-- | scripts/ServerBrowser.gd | 30 |
13 files changed, 679 insertions, 158 deletions
diff --git a/export_presets.cfg b/export_presets.cfg index e69de29..c34ad69 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -0,0 +1,59 @@ +[preset.0] + +name="HTML5" +platform="HTML5" +runnable=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="" +script_export_mode=1 +script_encryption_key="" + +[preset.0.options] + +custom_template/debug="" +custom_template/release="" +variant/export_type=0 +vram_texture_compression/for_desktop=true +vram_texture_compression/for_mobile=false +html/export_icon=true +html/custom_html_shell="" +html/head_include="" +html/canvas_resize_policy=2 +html/focus_canvas_on_start=true +html/experimental_virtual_keyboard=false +progressive_web_app/enabled=false +progressive_web_app/offline_page="" +progressive_web_app/display=1 +progressive_web_app/orientation=0 +progressive_web_app/icon_144x144="" +progressive_web_app/icon_180x180="" +progressive_web_app/icon_512x512="" +progressive_web_app/background_color=Color( 0, 0, 0, 1 ) + +[preset.1] + +name="Linux/X11" +platform="Linux/X11" +runnable=true +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="" +export_path="./ATC.x86_64" +script_export_mode=1 +script_encryption_key="" + +[preset.1.options] + +custom_template/debug="" +custom_template/release="" +binary_format/architecture="x86_64" +binary_format/embed_pck=false +texture_format/bptc=false +texture_format/s3tc=true +texture_format/etc=false +texture_format/etc2=false +texture_format/no_bptc_fallbacks=true diff --git a/network/GameCoordinatorTester.gd b/network/GameCoordinatorTester.gd new file mode 100644 index 0000000..12f92ab --- /dev/null +++ b/network/GameCoordinatorTester.gd @@ -0,0 +1,144 @@ +extends Control + + +onready var ws_client_template : PackedScene = preload("res://network/WSClient.tscn") + +var game_coordinator_url : String = "ws://192.168.7.112:8181" + +var gc_client_menus : Dictionary = {} +var latest_gccid : int = 0 + +const HOST_JSON_TEMPLATE_STR : String = '{ "lobby_name" : "",\n "game_type" : "test",\n "username" : "",\n "private" : false,\n "password" : "",\n "max_players" : 4 } ' +const JOIN_JSON_TEMPLATE_STR : String = '{ "lobby_id" : "",\n "game_type" : "test",\n "username" : "",\n "password" : "",\n "args": {} }' + +func _ready(): + pass + + +func _process(_delta): + + # read from all open sockets + for gccid in gc_client_menus.keys(): + var gc_client_menu = gc_client_menus[gccid] + gc_client_menu["state"].text = str( gc_client_menu["ws_client"].state ) + if gc_client_menu["ws_client"].state == 2: + var recv_message = gc_client_menu["ws_client"].receive(true) # true argument means expect + convert to JSON + if recv_message: + gc_client_menu["output"].text += str(recv_message) + "\n" + +func host_lobby(gccid : int): + var gccm = gc_client_menus[gccid] + var json_parse_result : JSONParseResult = JSON.parse(gccm["input"].text) + if json_parse_result.error: + gccm["input"].text = HOST_JSON_TEMPLATE_STR # fill with template for ease if invalid json is provided + return + else: + var message = json_parse_result.result + message["type"] = "host_lobby" + gccm["ws_client"].sock_connect_to_url(game_coordinator_url) + gccm["ws_client"].send_json(message) + +func join_lobby(gccid : int): + var gccm = gc_client_menus[gccid] + var json_parse_result : JSONParseResult = JSON.parse(gccm["input"].text) + if json_parse_result.error: + gccm["input"].text = JOIN_JSON_TEMPLATE_STR # fill with template for ease if invalid json is provided + return + else: + var message = json_parse_result.result + message["type"] = "join_lobby" + gccm["ws_client"].sock_connect_to_url(game_coordinator_url) + gccm["ws_client"].send_json(message) + +func send_message(gccid : int): + var gccm = gc_client_menus[gccid] + var json_parse_result : JSONParseResult = JSON.parse(gccm["input"].text) + if json_parse_result.error: + gccm["input"].text = JOIN_JSON_TEMPLATE_STR # fill with template for ease if invalid json is provided + return + else: + var message = json_parse_result.result + gccm["ws_client"].send_json(message) + +func disconnect_client(gccid : int): + var gccm : Dictionary = gc_client_menus[gccid] + var disconnect_message = {"type" : "lobby_control", "command" : "disconnect"} + gccm["ws_client"].send_json(disconnect_message) + +func reset_ws_client(gccid : int): + var gccm : Dictionary = gc_client_menus[gccid] + + gccm["ws_client"].queue_free() + gccm["ws_client"] = ws_client_template.instance() + gccm["menu"].add_child( gccm["ws_client"] ) + + gccm["host"].disabled = false + gccm["join"].disabled = false + gccm["disconnect"].disabled = true + gccm["send"].disabled = true + + +func delete_gc_client(gccid : int): + var gc_client_menu : Dictionary = gc_client_menus[gccid] + gc_client_menu["parent"].queue_free() # remove HBoxContainer and all of its children, which together represent a gc client + gc_client_menus.erase(gccid) + +func create_gc_client(): + latest_gccid += 1 + var new_gc_index : int = latest_gccid + # container for all buttons + var gc_client_menu : HBoxContainer = HBoxContainer.new() + + var ws_client : Node = ws_client_template.instance() + gc_client_menu.add_child(ws_client) + + var delete_button : Button = Button.new() + delete_button.text = "X" + delete_button.connect("pressed", self, "delete_gc_client", [new_gc_index]) + gc_client_menu.add_child(delete_button) + + var connection_state : Label = Label.new() + connection_state.text = "0" + gc_client_menu.add_child(connection_state) + + var input : TextEdit = TextEdit.new() + input.rect_min_size = Vector2(400, 100) + gc_client_menu.add_child(input) + + var output : TextEdit = TextEdit.new() + output.text = "---CONNECTION OUTPUT---\n" + output.rect_min_size = Vector2(400, 100) + output.readonly = true + gc_client_menu.add_child(output) + + var join_btn : Button = Button.new() + join_btn.text = "Join" + join_btn.connect("pressed", self, "join_lobby", [new_gc_index]) + gc_client_menu.add_child(join_btn) + + var host_btn : Button = Button.new() + host_btn.text = "Host" + host_btn.connect("pressed", self, "host_lobby", [new_gc_index]) + gc_client_menu.add_child(host_btn) + + var send_msg_btn : Button = Button.new() + send_msg_btn.text = "Send" + #send_msg_btn.disabled = true + send_msg_btn.connect("pressed", self, "send_message", [new_gc_index]) + gc_client_menu.add_child(send_msg_btn) + + var disconnect_btn : Button = Button.new() + disconnect_btn.text = "Disconnect" + #disconnect_btn.disabled = true + disconnect_btn.connect("pressed", self, "disconnect_client", [new_gc_index]) + gc_client_menu.add_child(disconnect_btn) + + var reset_btn : Button = Button.new() + reset_btn.text = "Reset WS" + reset_btn.disabled = true + reset_btn.connect("pressed", self, "reset_ws_client", [new_gc_index]) + gc_client_menu.add_child(reset_btn) + + var gc_client : Dictionary = {"parent": gc_client_menu, "state" : connection_state, "ws_client" : ws_client, "join" : join_btn, "host" : host_btn, "send" : send_msg_btn, "disconnect" : disconnect_btn, "reset" : reset_btn, "input" : input, "output" : output} + gc_client_menus[new_gc_index] = gc_client + add_child(gc_client_menu) diff --git a/network/GameCoordinatorTester.tscn b/network/GameCoordinatorTester.tscn new file mode 100644 index 0000000..9b34186 --- /dev/null +++ b/network/GameCoordinatorTester.tscn @@ -0,0 +1,19 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://network/GameCoordinatorTester.gd" type="Script" id=2] + +[node name="GameCoordinatorTester" type="VBoxContainer"] +anchor_right = 1.0 +anchor_bottom = 1.0 +script = ExtResource( 2 ) + +[node name="ControlsHBox" type="HBoxContainer" parent="."] +margin_right = 1600.0 +margin_bottom = 20.0 + +[node name="AddClientBtn" type="Button" parent="ControlsHBox"] +margin_right = 77.0 +margin_bottom = 20.0 +text = "Add client" + +[connection signal="pressed" from="ControlsHBox/AddClientBtn" to="." method="create_gc_client"] diff --git a/network/WSClient.tscn b/network/WSClient.tscn new file mode 100644 index 0000000..2224ed5 --- /dev/null +++ b/network/WSClient.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=2] + +[ext_resource path="res://network/websocket_client.gd" type="Script" id=1] + +[node name="WSClient" type="Node"] +script = ExtResource( 1 ) diff --git a/network/WebSocketTest.gd b/network/WebSocketTest.gd deleted file mode 100644 index 49520dd..0000000 --- a/network/WebSocketTest.gd +++ /dev/null @@ -1,17 +0,0 @@ -extends Node - -onready var client = $WebSocketClient -var sent = false - -func _ready(): - client.sock_connect_to_url("ws://127.0.0.1:8181") - -func _process(_delta): - if client.state == 2 and not sent: - client.send("test string".to_utf8(), true) - client.send(JSON.print({"items" : [1, "test_dictionary"]}).to_utf8(), true) - sent = true - if client.state == 2: - var message = client.receive() - if message: - print(message) diff --git a/network/WebSocketTest.tscn b/network/WebSocketTest.tscn deleted file mode 100644 index 3b02263..0000000 --- a/network/WebSocketTest.tscn +++ /dev/null @@ -1,12 +0,0 @@ -[gd_scene load_steps=3 format=2] - -[ext_resource path="res://network/WebSocketTest.gd" type="Script" id=1] -[ext_resource path="res://network/websocket_client.gd" type="Script" id=2] - -[node name="WebSocketTest" type="Spatial"] -script = ExtResource( 1 ) - -[node name="WebSocketClient" type="Node" parent="."] -script = ExtResource( 2 ) - -[node name="Camera" type="Camera" parent="."] diff --git a/network/websocket_client.gd b/network/websocket_client.gd index 4583dc3..d10f518 100644 --- a/network/websocket_client.gd +++ b/network/websocket_client.gd @@ -5,6 +5,7 @@ var socket: WebSocketPeer = null var state: int = 0 # -1 = CONNECTION_FAILED, 0 = CONNECTION_DISCONNECTED, 1 = CONNECTION_CONNECTING, 2 = CONNECTION_CONNECTED, 3 = CONNECTION_DISCONNECTING var id +var message_queue : Array = [] # messages to be sent upon connection func _ready(): socket_client = WebSocketClient.new() @@ -15,6 +16,7 @@ func _ready(): func sock_connect_to_url(url): print("Connecting to %s..." % url) + message_queue.clear() var error = socket_client.connect_to_url(url) if error != OK: return error @@ -30,7 +32,12 @@ func sock_close(code = 1000, reason = ""): func on_connection_success(protocol): print("WebSocket connection success with protocol %s." % protocol) socket = socket_client.get_peer(1) + socket.set_write_mode(WebSocketPeer.WRITE_MODE_TEXT) # defaults to text mode state = 2 # CONNECTED + + while len(message_queue) > 0: + var msg = message_queue.pop_at(0) + send(msg) func on_connection_close_success(clean): print("WebSocket closed successfully.") @@ -45,29 +52,31 @@ func on_connection_error(): # connection failed socket = null state = -1 # DISCONNECT DIRTY -func send(message, as_string=false): - if state != 2: return null - if as_string: - socket.set_write_mode(WebSocketPeer.WRITE_MODE_TEXT) - return socket.put_packet(message) - else: - socket.set_write_mode(WebSocketPeer.WRITE_MODE_BINARY) - return socket.put_packet(var2bytes(message)) +func send(message, as_bytes=false) -> int: + if state != 2: + message_queue.push_back(message) + return -1 + return socket.put_packet(message) + func send_json(message) -> int: - if state != 2: return -1 + if state != 2: + message_queue.push_back(JSON.print(message).to_utf8()) + return -1 var message_json = JSON.print(message).to_utf8() - socket.set_write_mode(WebSocketPeer.WRITE_MODE_TEXT) return socket.put_packet(message_json) func receive(string_to_json=false): if state != 2: return null if socket.get_available_packet_count() < 1: return null - print("receive") var packet : PoolByteArray = socket.get_packet() if socket.was_string_packet(): var message = packet.get_string_from_utf8() - if string_to_json: message = JSON.parse(message) + if string_to_json: + var json = JSON.parse(message) + if json.error: + return null + message = json.result return message return bytes2var(packet) diff --git a/pages/GameTable.tscn b/pages/GameTable.tscn index 1e3e3db..25a4c16 100644 --- a/pages/GameTable.tscn +++ b/pages/GameTable.tscn @@ -24,7 +24,6 @@ size = Vector2( 30, 30 ) [node name="GameTable" type="Spatial"] script = ExtResource( 1 ) -game_difficulty = "hard" [node name="Board" type="Spatial" parent="."] diff --git a/project.godot b/project.godot index 400300a..68af7ba 100644 --- a/project.godot +++ b/project.godot @@ -11,6 +11,7 @@ config_version=4 [application] config/name="ATC" +run/main_scene="res://network/GameCoordinatorTester.tscn" boot_splash/show_image=false config/icon="res://icon.png" @@ -18,6 +19,11 @@ config/icon="res://icon.png" Globals="*res://scripts/Globals.gd" +[display] + +window/size/width=1600 +window/size/height=900 + [gui] common/drop_mouse_on_gui_input_disabled=true diff --git a/resources/external/client_ws_test.py b/resources/external/client_ws_test.py index 9bf5a52..5c65070 100644 --- a/resources/external/client_ws_test.py +++ b/resources/external/client_ws_test.py @@ -7,11 +7,13 @@ from websockets.asyncio.client import connect async def hello(): - async with connect("wss://echo.websocket.org", subprotocols=["lws-mirror-protocol"]) as websocket: - await websocket.send("Hello world!") - message = await websocket.recv() - print(message) - + async with connect("ws://127.0.0.1:8181") as websocket: + while True: + await websocket.send("Hello world!") + message = await websocket.recv() + print(message) + await asyncio.sleep(1) + if __name__ == "__main__": asyncio.run(hello())
\ No newline at end of file diff --git a/resources/external/game_coordinator.py b/resources/external/game_coordinator.py index 6904cb0..bd2eb9c 100644 --- a/resources/external/game_coordinator.py +++ b/resources/external/game_coordinator.py @@ -14,148 +14,455 @@ import json import secrets import sys import random +import time +import websockets from websockets.asyncio.server import serve DEFAULT_PORT = 8181 -DEFAULT_IP = "127.0.0.1" - -LOBBY_STATES = [ "LOBBY", "UNDERWAY", "FINISHED" ] -GAME_STATES = [ "SETUP", "ACTION", "PLACEMENT" ] - -GAMES = {} -# placeholder -#GAMES = {"1b32b1" : {"game_name" : "Game 1", "lobby_state" : "LOBBY", "current_players" : 1, "max_players" : 4, "private" : False}, -# "ab1b27" : {"game_name" : "Patrick's Game", "lobby_state" : "UNDERWAY", "current_players" : 3, "max_players" : 3, "private" : True}, -# "7a7df8" : {"game_name" : "New Jersey ATC", "lobby_state" : "FINISHED", "current_players" : 2, "max_players" : 2, "private" : False}} - -async def select_valid_color(game, color_id, color_id_alt): - taken_ids = set(GAMES[game_id]["players"].keys()) - color_okay = True - for player_color in taken_ids: - if color_id == player_color: - color_okay = False - break - if color_okay: - return color_id - color_okay = True - for player_color in taken_ids: - if color_id_alt == player_color: - color_okay = False +DEFAULT_IP = "192.168.7.112" + +LOBBY_CONTROL_COMMANDS = ["disconnect", "kick_player", "get_lobby_info", "deny_player", "accept_player", "end_lobby", "set_lobby_locked", "set_lobby_state", "set_paused"] +HOST_LOBBY_REQD_FIELDS = ["lobby_name", "game_type", "username", "private", "password", "max_players"] +HOST_LOBBY_REQD_FIELD_TYPES = [str, str, str, bool, str, int] +DISCONNECT_GRACE_PERIOD = 20 # time before removing +PAUSED_GRACE_PERIOD = 300 # lobby can be set to paused mode to enable players to be disconnected for up to this number of seconds +JOIN_REQUEST_TIME = 8 +MAX_JOIN_REQUESTS = 3 # maximum number of times a client can ask the game host to join without receiving a response before the lobby is marked as closed +LOBBY_CLOSING_TIME = 5 +SOCKET_GRACE_PERIOD = 2 + +LOBBIES = {} + +# Send active games for server browser purposes +async def send_lobby_list(websocket): + lobbies = {"type" : "lobby_list", "lobbies" : []} + for lobby_id, lobby_info in LOBBIES.items(): + current_lobby_info = {"id" : lobby_id, "lobby_name" : lobby_info["lobby_name"], "state" : lobby_info["lobby_state"], "current_players" : len(lobby_info["players"]), "max_players" : lobby_info["max_players"], "private" : lobby_info["private"]} + lobbies["lobbies"].append( current_lobby_info ) + await websocket.send(json.dumps(lobbies)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) # wait 2 seconds before ending connection to allow packet to be sent + + +""" LOBBY FUNCTIONS """ + +# get information of all connected players to lobby for +async def get_player_info(lobby): + players = [] + for player_id, player in lobby["players"].items(): + if "dead" in player: continue # skip to-be-deleted players + players.append( {"username" : player["username"], "player_id" : player_id, "connection_status" : player["connection_status"], "is_host" : player["is_host"], "waiting" : True if "waiting" in player else False } ) + return players + +# send message to all active players within lobby +async def broadcast_active(lobby, message): + for player_id in lobby["players"]: + player = lobby["players"][player_id] + if player["connection_status"] == 1 and ("waiting" not in player): + try: + await player["socket"].send(json.dumps(message)) + except websockets.exceptions.ConnectionClosed: + # shouldn't happen since disconnected players should update the connection_status variable + print(f"Couldn't broadcast to player {player['player_id']}: socket closed.") + +# create player object +async def create_player(websocket, username, is_host=False, waiting_args={}): + player = {"socket" : websocket, "username" : username, "is_host" : is_host, "connection_status" : 1, "player_id" : secrets.token_urlsafe(8), "rejoin_key" : secrets.token_urlsafe(10)} + if not is_host: + player["waiting"] = True + player["waiting_since"] = int(time.time()) + player["waiting_retries"] = 0 + player["waiting_args"] = waiting_args + return player + +# finally delete player, and if necessary shut down server or change host +async def remove_player(lobby, player_id): + if not lobby: return + lobby_id = lobby["lobby_id"] + + was_host = lobby["players"][player_id]["is_host"] + print(f"Removing player {player_id} from lobby {lobby['lobby_id']}.") + del lobby["players"][player_id] + + if len(lobby["players"]) < 1 and (lobby_id in LOBBIES) and ("dead" not in lobby): + print(f"Destroying lobby {lobby['lobby_id']}: no remaining players.") + del LOBBIES[lobby["lobby_id"]] + + elif was_host: # host must be transferred + new_host_id = None + + for player_id in lobby["players"]: + if "waiting" in lobby["players"][player_id] or ("dead" in lobby["players"][player_id]) or (lobby["players"][player_id]["connection_status"] != 1): + continue + new_host_id = player_id break - if color_okay: - return color_id_alt + if not new_host_id: + if (lobby_id in LOBBIES) and ("dead" not in lobby): + print(f"No valid remaining host, destroying lobby...") + await end_lobby(lobby["lobby_id"]) + return + lobby["players"][new_host_id]["is_host"] = True + lobby["host"] = lobby["players"][new_host_id] + + player_info = await get_player_info(lobby) + message = {"type" : "announcement_change_host", "message" : "Lobby host has been changed.", "player_id" : new_host_id, "current_players" : player_info} + await broadcast_active(lobby, message) + +# disconnect player, notifying rest of lobby +async def disconnect_player(lobby, player_id, reason='disconnect'): + dc_player = lobby["players"][player_id] + dc_player["connection_status"] = 0 + if "dead" not in dc_player: + response = {"type" : "disconnected", "message" : f"Disconnected. Reason: {reason}"} + try: + await dc_player["socket"].send(json.dumps(response)) + except: + pass + lobby["players"][player_id]["dead"] = True + + player_info = await get_player_info(lobby) + announcement = {"type" : "announcement_player_disconnect", "reason" : reason, "player_id" : player_id, "username" : dc_player["username"], "current_players" : player_info} + await broadcast_active(lobby, announcement) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + await lobby["players"][player_id]["socket"].close() - available_colors = list(set(range(10)) ^ taken_ids) - return random.choice(available_colors) + await remove_player(lobby, player_id) -# Send active games for server browser purposes -async def send_game_list(websocket): - games = {"type" : "game_list", "games" : []} - for game_id, game_info in GAMES.items(): - current_game_info = {"id" : game_id, "game_name" : game_info["game_name"], "state" : game_info["lobby_state"], "current_players" : len(game_info["players"]), "max_players" : game_info["max_players"], "private" : game_info["private"]} - games["games"].append( current_game_info ) - await websocket.send(json.dumps(games)) - - async for message in websocket: # wait for acknowledgment - print(json.loads(message)) - return - # end of connection (closes automatically) +# called when `for message in websocket` loop ended without a reason determiend by the rest of the GC logic +async def connection_lost(lobby, player_id): + if player_id in lobby["players"]: + lobby["players"][player_id]["connection_status"] = 0 + await asyncio.sleep( DISCONNECT_GRACE_PERIOD if not lobby["paused"] else PAUSED_GRACE_PERIOD ) + if player_id in lobby["players"] and lobby["players"][player_id]["connection_status"] == 0: + lobby["players"][player_id]["dead"] = True + await disconnect_player(lobby, player_id, 'connection_lost') + +# notify all players in lobby that it is ending, then delete it and all sockets attached to it +async def end_lobby(lobby_id): + if lobby_id not in LOBBIES or ("dead" in LOBBIES[lobby_id]): return + LOBBIES[lobby_id]["dead"] = True + lobby = LOBBIES[lobby_id] + message = {"type" : "lobby_closing"} + for player_id in lobby["players"]: + player = lobby["players"][player_id] + if player["connection_status"] == 1: await player["socket"].send(json.dumps(message)) + + await asyncio.sleep(LOBBY_CLOSING_TIME) + + for player_id in lobby["players"]: + try: + lobby["players"][player_id]["dead"] = True + await lobby["players"][player_id]["socket"].close() + except: + pass + + print(f"Destroyed lobby: {lobby["lobby_name"]} ({lobby_id})") + del LOBBIES[lobby_id] -async def lobby(websocket, game, player_id): +# notify host that player is requesting to join +async def send_join_request_to_host(lobby, player_id): + player = lobby["players"][player_id] + join_request = {"type" : "join_request", "player_id" : player_id, "username" : player["username"], "args" : player["waiting_args"]} + player["waiting_since"] = int(time.time()) + player["waiting_retries"] += 1 + if lobby["host"]["connection_status"] == 1: await lobby["host"]["socket"].send(json.dumps(join_request)) +# main logic loop for connected clients +async def lobby_loop(websocket, lobby, player): + player_id = player["player_id"] + lobby_id = lobby["lobby_id"] async for message in websocket: event = json.loads(message) - if event["type"] == "game_control": + # handle messages sent from connected players yet unconfirmed by host + if "waiting" in player: + response = { "type" : "waiting_notification", "message" : "You have not yet been accepted by the host." } + await websocket.send(json.dumps(response)) + if ( int(time.time()) - player["waiting_since"] ) > JOIN_REQUEST_TIME: + if player["waiting_retries"] > MAX_JOIN_REQUESTS: + await end_lobby(lobby_id) # does this need to be awaited? + return True + await send_join_request_to_host(lobby, player_id) + continue # deny any other requests + + + # host can kick players, accept players, and lock/unlock/end the game + # all players can disconnect and query lobby status (includes player info) + if event["type"] == "lobby_control": + if "command" not in event or (event["command"] not in LOBBY_CONTROL_COMMANDS): + response = {"type" : "gc_error", "message" : "Missing or invalid 'lobby_control' command."} + await websocket.send(json.dumps(response)) + continue + if event["command"] == "disconnect": + await disconnect_player(lobby, player_id) + return True # end of connection + elif event["command"] == "get_lobby_info": + player_info = await get_player_info(lobby) + response = {"type" : "lobby_status", "state" : lobby["lobby_state"], "player_count" : []} # TODO + await websocket.send(json.dumps(response)) + continue + + if not player["is_host"]: + response = {"type" : "gc_error", "message" : f"GC ERROR: Only host has access to command {event['command']}."} + await websocket.send(json.dumps(response)) + continue # following commands only available to game host + + if event["command"] == "accept_player": + if ("player_id" not in event) or (event["player_id"] not in lobby["players"]): + response = {"type" : "gc_error", "message" : "GC ERROR: No player ID given, or given player ID is not in the lobby."} + await websocket.send(json.dumps(response)) + continue + + accepted_id = event["player_id"] + if "waiting" not in lobby["players"][accepted_id]: + response = {"type" : "gc_error", "message" : "GC ERROR: Player already accepted."} + await websocket.send(json.dumps(response)) + continue + accepted_player = lobby["players"][accepted_id] + del accepted_player["waiting"] + del accepted_player["waiting_args"] + del accepted_player["waiting_since"] + del accepted_player["waiting_retries"] + notification = { "type" : "join_ok" } + + await accepted_player["socket"].send(json.dumps(notification)) # notify waiting player of acceptance + + elif event["command"] == "deny_player": + if ("player_id" not in event) or (event["player_id"] not in lobby["players"]): + response = {"type" : "gc_error", "message" : "GC ERROR: No player ID given, or given player ID is not in the lobby."} + await websocket.send(json.dumps(response)) + continue + denied_id = event["player_id"] + denied_player = lobby["players"][denied_id] + reason_text = (" (" + event["reason"] + ")") if "reason" in event else "" + await disconnect_player(lobby, denied_id, "denied"+reason_text) + elif event["command"] == "kick_player": + if ("player_id" not in event) or (event["player_id"] not in lobby["players"]): + response = {"type" : "gc_error", "message" : "GC ERROR: No player ID given, or given player ID is not in the lobby."} + await websocket.send(json.dumps(response)) + continue + kicked_id = event["player_id"] + await disconnect_player(lobby, kicked_id, "kicked") + elif event["command"] == "set_lobby_locked": + if "locked" not in event or (type(event["locked"]) is not bool): + response = {"type" : "gc_error", "message" : "Missing or invalid 'locked' argument."} + await websocket.send(json.dumps(response)) + continue + lobby["locked"] = event["locked"] + announcement = { "type" : "announcement_lock_changed", "locked" : event["locked"] } + await broadcast_active(lobby, announcement) + elif event["command"] == "end_lobby": + await end_lobby(lobby_id) + return True # end of lobby, end of loop + elif event["command"] == "set_lobby_state": # TODO + if "state" not in event or (type(event["state"]) is not str): + response = {"type" : "gc_error", "message" : "Missing or invalid 'state' argument."} + await websocket.send(json.dumps(response)) + continue + lobby["lobby_state"] = event["state"] + announcement = { "type" : "announcement_lobby_state_changed", "state" : event["state"] } + await broadcast_active(lobby, announcement) + + continue # ignore subsequent checks + # message forwarding elif event["type"] == "request": - response = {"type" : "request", "data" : event["request_fields"] } - await game["players"][ int(event["source"]) ]["socket"].send( json.dumps(response) ) + response = {"type" : "request", "destination" : player_id, "data" : event["request_fields"] } + await game["players"][ event["source"] ]["socket"].send( json.dumps(response) ) elif event["type"] == "request_response": response = {"type" : "request_response", "data" : event["requested_data"] } - await game["players"][ int(event["destination"]) ]["socket"].send( json.dumps(response) ) + await game["players"][ event["destination"] ]["socket"].send( json.dumps(response) ) elif event["type"] == "broadcast": message = {"type" : "broadcast", "payload" : event["payload"]} for player in game["players"]: - if player != player_id: await player["socket"].send(json.dumps(message)) - + if player["connection_status"] == 1 and ( (player != player_id) or ("to_self" in event) ): await player["socket"].send(json.dumps(message)) + + if lobby_id not in LOBBIES: return True # lobby closed + if player_id not in LOBBIES[lobby_id]["players"] or ("dead" in LOBBIES[lobby_id]["players"][player_id]): return True # player removed + return False # socket not closed cordially -async def join_game(websocket, event): - for required_field in ["game_id", "username", "color_id", "color_id_alt"]: +# join existing lobby +async def join_lobby(websocket, event): + for required_field in ["lobby_id", "game_type", "username"]: if required_field not in event: - response = {"type" : "error", "message" : f"Missing field: '{required_field}'."} + response = {"type" : "gc_error", "message" : f"GC ERROR: Missing required field: '{required_field}'."} await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) return # close connection - game_id = event["game_id"] - if game_id not in GAMES: - response = {"type" : "error", "message" : f"Game '{game_id}' does not exist."} + lobby_id = event["lobby_id"] + if lobby_id not in LOBBIES: + response = {"type" : "gc_error", "message" : f"GC ERROR: Lobby '{lobby_id}' does not exist."} await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) return # close connection + lobby = LOBBIES[lobby_id] - if len(GAMES[game_id]["players"]) >= GAMES[game_id]["max_players"]: - response = {"type" : "error", "message" : f"Server full."} + if str(event["game_type"]) != lobby["game_type"]: + response = {"type" : "gc_error", "message" : f"GC ERROR: Mismatched game types. Expected {LOBBIES[lobby_id]['game_type']}, received {event['game_type']}."} + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + return # close connection + + if len(lobby["players"]) >= lobby["max_players"]: + response = {"type" : "gc_error", "message" : "GC ERROR: Lobby full."} + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + return # close connection + + if lobby["locked"] and ("rejoin_key" not in event): + response = {"type" : "gc_error", "message" : "GC ERROR: Lobby is locked, no new connections are being accepted."} await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) return # close connection - player_color = select_valid_color(GAMES[game_id], int(event["color_id"]), int(event["color_id_alt"])) + if lobby["private"] and (event["password"] != lobby["password"]) and ("rejoin_key" not in event): + response = {"type" : "gc_error", "message" : "GC ERROR: Incorrect password for private lobby."} + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + return # close connection + + if "rejoin_key" in event: + for player_id in lobby["players"]: + player = lobby["players"][player_id] + if player["rejoin_key"] == event["rejoin_key"] and (player["connection_status"] == 0): + player["socket"] = websocket + player["connection_status"] = 1 + response = { "type" : "rejoin_ok" } + await websocket.send(json.dumps(response)) + print(f"Player ({player_id}) reconnected successfully.") + + player_info = await get_player_info(lobby) + notification = {"type" : "announcement_player_rejoin", "current_players" : player_info} + await broadcast_active(lobby, notification) + + try: + if await lobby_loop( websocket, LOBBIES[ lobby_id ], player ): + return + except websockets.exceptions.ConnectionClosed: + print(f"{player["player_id"]} lost connection suddenly!") + await connection_lost( LOBBIES[lobby_id], player["player_id"] ) + return + + print(f"{player["player_id"]} lost connection.") + await connection_lost( LOBBIES[lobby_id], player["player_id"] ) + return # end of socket + + # Could not rejoin + response = {"type" : "gc_error", "message" : "GC ERROR: Could not rejoin with given key."} + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + return # end of socket + + # args are optional, thus they are not required to be specified + new_player = await create_player( websocket, event["username"], is_host=False, waiting_args= ( event["args"] if "args" in event else {} ) ) + + lobby["players"][new_player["player_id"]] = new_player # add player to lobby + await send_join_request_to_host(lobby, new_player["player_id"]) # request join confirmation from host - response = {"type" : "JOIN_OK", "game_id" : game_id, "color_id" : player_color} + player_info = await get_player_info(lobby) + notification = {"type" : "announcement_player_joining", "current_players" : player_info} + await broadcast_active(lobby, notification) + response = { "type" : "join_request_ok", "player_id" : new_player["player_id"], "rejoin_key" : new_player["rejoin_key"] } await websocket.send(json.dumps(response)) + try: - await lobby(websocket, GAMES[game_id], player_color) - finally: - del GAMES[game_id] + if await lobby_loop( websocket, LOBBIES[ lobby_id ], new_player ): + return + except websockets.exceptions.ConnectionClosed: + print(f"{new_player["player_id"]} lost connection suddenly!") + await connection_lost( LOBBIES[lobby_id], new_player["player_id"] ) + return + + print(f"{new_player["player_id"]} lost connection.") + await connection_lost( LOBBIES[lobby_id], new_player["player_id"] ) -async def create_game(websocket, event): - for required_field in ["game_name", "username", "color_id", "private", "password", "max_players"]: +# host a new lobby +async def host_lobby(websocket, event): + for reqd_i, required_field in enumerate(HOST_LOBBY_REQD_FIELDS): if required_field not in event: - response = {"type" : "error", "message" : f"Missing field: '{required_field}'."} + response = {"type" : "gc_error", "message" : f"GC ERROR: Missing required field: '{required_field}'."} await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) return # close connection + try: + test_argument_type = HOST_LOBBY_REQD_FIELD_TYPES[reqd_i](event[required_field]) + except: + response = { "type" : "gc_error", "message" : f"GC_ERROR: Invalid type for field '{required_field}'." } + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + return # close connection - new_game = { "game_name" : event["game_name"], "lobby_state" : "LOBBY", "is_board_generated" : False, "max_players" : event["max_players"], "players" : {} } - event["color_id"] = int(event["color_id"]) - host_player = {"socket" : websocket, "is_host" : True, "username" : event["username"] } - new_game["host_id"] = host_ - new_game["players"][event["color_id"]] = host_player - game_id = secrets.token_urlsafe(12) - GAMES[game_id] = new_game + host_player = await create_player(websocket, event["username"], is_host=True) - response = {"type" : "HOST_OK", "game_id" : game_id} + lobby_id = secrets.token_urlsafe(12) + new_lobby = { "lobby_id" : lobby_id, + "lobby_name" : event["lobby_name"], + "game_type" : event["game_type"], + "private" : event["private"], + "password" : event["password"], + "lobby_state" : "LOBBY", + "max_players" : int(event["max_players"]), + "host" : host_player, # accessible in both 'players' and its own property + "players" : { host_player["player_id"] : host_player }, + "locked" : False, + "paused" : False # paused games have a longer grace period before players with interrupted connections are kicked + } + + print(f"Created {'private ' if event["private"] else ''}lobby: {event['lobby_name']} ({lobby_id})") + LOBBIES[ lobby_id ] = new_lobby + + response = { "type" : "host_ok", + "lobby_id" : lobby_id, + "player_id" : host_player["player_id"], + "rejoin_key" : host_player["rejoin_key"] + } await websocket.send(json.dumps(response)) try: - await lobby(websocket, GAMES[game_id], event["color_id"]) - finally: - del GAMES[game_id] - + if await lobby_loop( websocket, LOBBIES[ lobby_id ], host_player ): + return + except websockets.exceptions.ConnectionClosed: + print(f"{host_player["player_id"]} lost connection suddenly!") + await connection_lost( LOBBIES[lobby_id], host_player["player_id"] ) + return + + print(f"{host_player["player_id"]} lost connection.") + await connection_lost( LOBBIES[lobby_id], host_player["player_id"] ) +# handle incoming connections async def new_connection_handler(websocket): - print("Client connected.") message = await websocket.recv() event = json.loads(message) + if "type" not in event: + response = {"type" : "gc_error", "message" : "GC ERROR: No message type specified."} + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) + return - if event["type"] == "list_open_games": + if event["type"] == "list_open_lobbies": print("List of games requested.") - await send_game_list(websocket) - elif event["type"] == "create_game": - await create_game(websocket) - elif event["type"] == "join_game": - await join_game(websocket, event) - elif event["type"] == "watch_game": - await watch_game(websocket, event) - + await send_lobby_list(websocket) + elif event["type"] == "host_lobby": + print("Lobby creation requested.") + await host_lobby(websocket, event) + elif event["type"] == "join_lobby": + print("Lobby join requested.") + await join_lobby(websocket, event) + else: + response = {"type" : "gc_error", "message" : "GC ERROR: Invalid message type received."} + await websocket.send(json.dumps(response)) + await asyncio.sleep(SOCKET_GRACE_PERIOD) +# start server async def main(ip, port): async with serve(new_connection_handler, ip, port) as server: - print("serving...") + print(f"Server starting at {ip}:{port}.") await server.serve_forever() - +# customize server from CLI arguments if __name__ == "__main__": IP = DEFAULT_IP PORT = DEFAULT_PORT @@ -172,4 +479,4 @@ if __name__ == "__main__": print("Port must be an integer.") quit() - asyncio.run(main(IP, PORT))
\ No newline at end of file + asyncio.run(main(IP, PORT)) diff --git a/resources/external/websocket_test.py b/resources/external/websocket_test.py index 9c29fcf..25ebcff 100644 --- a/resources/external/websocket_test.py +++ b/resources/external/websocket_test.py @@ -3,16 +3,25 @@ """Echo server using the asyncio API.""" import asyncio +import sys from websockets.asyncio.server import serve DEFAULT_PORT = 8181 DEFAULT_IP = "127.0.0.1" +async def delayed_func(): + await asyncio.sleep(5) + print("after delay") async def echo(websocket): print("client connected") - async for message in websocket: - await websocket.send(message) + try: + async for message in websocket: + if message == "Hello world!": + asyncio.run(delayed_func()) + await websocket.send(message) + finally: + print("client DC") async def main(ip, port): @@ -35,6 +44,6 @@ if __name__ == "__main__": PORT = int(args[arg_i]) except: print("Port must be an integer.") - return + quit() asyncio.run(main(IP, PORT))
\ No newline at end of file diff --git a/scripts/ServerBrowser.gd b/scripts/ServerBrowser.gd index 2a1492e..3c59b9b 100644 --- a/scripts/ServerBrowser.gd +++ b/scripts/ServerBrowser.gd @@ -8,7 +8,7 @@ enum { ANY, GAME_LIST, HOST_RESPONSE, JOIN_RESPONSE, PASSWORD_RESPONSE } var game_ids = [] # change this!!! -var game_coordinator_url = "ws://127.0.0.1:8181" +var game_coordinator_url = "ws://192.168.7.112:8181" var awaiting_connection = false var expecting = [] var queued_messages = [] @@ -21,7 +21,7 @@ func join_game(): $HostPopup.visible = false $HostButton.disabled = true $RefreshButton.disabled = true - var message = { "type" : "join_game" } + var message = { "type" : "join_lobby" } if ws_client.state != 2: ws_client.sock_connect_to_url(game_coordinator_url) queued_messages.push_back( message ) @@ -37,35 +37,25 @@ func refresh_game_list(): game_ids.clear() game_list.clear() - var message = {"type" : "list_open_games"} + var message = {"type" : "list_open_lobbies"} if ws_client.state != 2: ws_client.sock_connect_to_url(game_coordinator_url) - queued_messages.push_back( message ) - awaiting_connection = true + ws_client.send_json( message ) else: ws_client.send_json( message ) func add_games_to_list(games): for game in games: - var game_str = game["game_name"] + " (" + str(int(game["current_players"])) + "/" + str(int(game["max_players"])) + ") (" +game["state"]+ ")" + (" (PRIVATE)" if game["private"] else "") + var game_str = game["lobby_name"] + " (" + str(int(game["current_players"])) + "/" + str(int(game["max_players"])) + ") (" +game["state"]+ ")" + (" (PRIVATE)" if game["private"] else "") game_list.add_item( game_str, null, true if game["state"] == "LOBBY" else false ) game_ids.append( game["id"] ) -func handle_gc_message(msg): - if msg == null or msg.error: return - msg = msg.result - if msg["type"] == "game_list": - add_games_to_list(msg["games"]) - ws_client.send_json({"type" : "ack"}) - if msg["type"] == "error": - print(msg["message"]) func _process(_delta): $GameCoordinatorStatus.text = "Game Coordinator Connection: " + str(ws_client.state) if ws_client.state == 2: - if awaiting_connection: - awaiting_connection = false - for queued_message in queued_messages: - ws_client.send_json( queued_message ) - - handle_gc_message(ws_client.receive(true)) + var recv_message = ws_client.receive(true) # true argument means expect + convert to JSON + if recv_message: + if "type" in recv_message and recv_message["type"] == "lobby_list": + add_games_to_list(recv_message["lobbies"]) + |
