summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnson Bridges <bridges.anson@gmail.com>2025-08-15 23:04:40 -0700
committerAnson Bridges <bridges.anson@gmail.com>2025-08-15 23:04:40 -0700
commitf087c6a98b1da55525a6e3c1d7c82477f82eb5cd (patch)
tree0e2b517bedb3dd475c2b82a1b05800e5b7593854
parentd558a9add0e183219a7a9ff482807bdcd677e21a (diff)
Game Coordinator now mostly (~90%) functional
-rw-r--r--export_presets.cfg59
-rw-r--r--network/GameCoordinatorTester.gd144
-rw-r--r--network/GameCoordinatorTester.tscn19
-rw-r--r--network/WSClient.tscn6
-rw-r--r--network/WebSocketTest.gd17
-rw-r--r--network/WebSocketTest.tscn12
-rw-r--r--network/websocket_client.gd33
-rw-r--r--pages/GameTable.tscn1
-rw-r--r--project.godot6
-rw-r--r--resources/external/client_ws_test.py12
-rw-r--r--resources/external/game_coordinator.py483
-rw-r--r--resources/external/websocket_test.py15
-rw-r--r--scripts/ServerBrowser.gd30
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"])
+