netfishing/network/network_protocol.gd

356 lines
11 KiB
GDScript

class_name NetworkProtocol
extends RefCounted
const PROTOCOL_VERSION: int = 3
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
const MAX_PROFILE_ID_LENGTH: int = 96
const MAX_NONCE_LENGTH: int = 96
const MAX_PUBLIC_KEY_LENGTH: int = 8192
const MAX_SIGNATURE_LENGTH: int = 2048
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
# 6 reliable shop transactions, 7 reliable item/equipment/showcase/drawing,
# 8 reliable ordered session chat, 9 reliable private session mail.
const SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6
const ITEM_RELIABLE_CHANNEL: int = 7
const CHAT_RELIABLE_CHANNEL: int = 8
const MAIL_RELIABLE_CHANNEL: int = 9
const ENET_CHANNEL_COUNT: int = 10
const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const WORLD_TIME_CAPABILITY: String = "world_time_v1"
const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1"
const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
const JOBS_CAPABILITY: String = "jobs_v1"
enum RejectionCode {
NONE,
MALFORMED_HANDSHAKE,
PROTOCOL_MISMATCH,
SERVER_FULL,
DUPLICATE_PROFILE,
DUPLICATE_IDENTITY,
INVALID_IDENTITY_PROOF,
AUTHENTICATION_TIMEOUT,
SERVER_SHUTTING_DOWN,
UNSUPPORTED_CLIENT,
BANNED,
CLIENT_OUTDATED,
SERVER_OUTDATED,
VERSION_MISMATCH,
}
static func game_version() -> String:
return str(ProjectSettings.get_setting(
"application/config/version", "unknown"
)).strip_edges()
static func game_version_rejection(
client_version: String,
server_version: String,
) -> RejectionCode:
if client_version == server_version:
return RejectionCode.NONE
var comparison: int = _compare_game_versions(
client_version, server_version
)
if comparison < 0:
return RejectionCode.CLIENT_OUTDATED
if comparison > 0:
return RejectionCode.SERVER_OUTDATED
return RejectionCode.VERSION_MISMATCH
static func _compare_game_versions(first: String, second: String) -> int:
var first_parts: PackedStringArray = first.split("-", true, 1)
var second_parts: PackedStringArray = second.split("-", true, 1)
var first_core: PackedStringArray = first_parts[0].split(".")
var second_core: PackedStringArray = second_parts[0].split(".")
for index: int in maxi(first_core.size(), second_core.size()):
var first_number: int = (
int(first_core[index])
if index < first_core.size() and first_core[index].is_valid_int()
else 0
)
var second_number: int = (
int(second_core[index])
if index < second_core.size() and second_core[index].is_valid_int()
else 0
)
if first_number != second_number:
return -1 if first_number < second_number else 1
var first_rank: int = _prerelease_rank(
first_parts[1] if first_parts.size() > 1 else ""
)
var second_rank: int = _prerelease_rank(
second_parts[1] if second_parts.size() > 1 else ""
)
if first_rank != second_rank:
return -1 if first_rank < second_rank else 1
return 0
static func _prerelease_rank(label: String) -> int:
var normalized: String = label.to_lower()
if normalized.is_empty():
return 5
if normalized.begins_with("dev"):
return 0
if normalized.begins_with("prealpha"):
return 1
if normalized.begins_with("alpha"):
return 2
if normalized.begins_with("beta"):
return 3
if normalized.begins_with("rc"):
return 4
return 2
static func make_identity_hello(
public_key: String,
fingerprint: String,
client_nonce: String,
attempt_id: String,
) -> Dictionary:
return {
"protocol_version": PROTOCOL_VERSION,
"game_version": game_version(),
"public_key": public_key,
"fingerprint": fingerprint,
"client_nonce": client_nonce,
"attempt_id": attempt_id,
"capability_flags": PackedStringArray(["identity_v1"]),
}
static func validate_identity_hello(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var value: Dictionary = data
return (
typeof(value.get("protocol_version")) == TYPE_INT
and typeof(value.get("game_version")) == TYPE_STRING
and not str(value["game_version"]).is_empty()
and str(value["game_version"]).length() <= MAX_GAME_VERSION_LENGTH
and typeof(value.get("public_key")) == TYPE_STRING
and str(value["public_key"]).to_utf8_buffer().size() <= MAX_PUBLIC_KEY_LENGTH
and NetworkIdentityCrypto.valid_fingerprint(value.get("fingerprint"))
and typeof(value.get("client_nonce")) == TYPE_STRING
and str(value["client_nonce"]).length() == 64
and typeof(value.get("attempt_id")) == TYPE_STRING
and str(value["attempt_id"]).length() == 32
and typeof(value.get("capability_flags")) in [
TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
]
)
static func make_client_hello(
profile_id: String,
display_name: String,
client_nonce: String,
cosmetic_snapshot: Dictionary = {},
identity_fingerprint: String = "",
identity_signature: PackedByteArray = PackedByteArray(),
) -> Dictionary:
return {
"protocol_version": PROTOCOL_VERSION,
"game_version": game_version(),
"game_build": GAME_BUILD,
"local_profile_id": profile_id,
"display_name": display_name,
"client_nonce": client_nonce,
"capability_flags": PackedStringArray([
FISH_QUALITY_CAPABILITY,
SURFACE_DRAWING_CAPABILITY,
WORLD_TIME_CAPABILITY,
WORLD_WEATHER_CAPABILITY,
JOBS_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
"identity_signature": identity_signature,
}
static func validate_client_hello(data: Variant) -> String:
if typeof(data) != TYPE_DICTIONARY:
return "Handshake payload is not a dictionary."
var payload: Dictionary = data
for key: String in [
"protocol_version",
"game_version",
"game_build",
"local_profile_id",
"display_name",
"client_nonce",
"capability_flags",
"cosmetic_snapshot",
"identity_fingerprint",
"identity_signature",
]:
if not payload.has(key):
return "Handshake is missing %s." % key
if typeof(payload["protocol_version"]) != TYPE_INT:
return "Protocol version is invalid."
if (
typeof(payload["game_version"]) != TYPE_STRING
or str(payload["game_version"]).is_empty()
or str(payload["game_version"]).length() > MAX_GAME_VERSION_LENGTH
):
return "Game version is invalid."
if typeof(payload["game_build"]) != TYPE_STRING:
return "Game build is invalid."
if typeof(payload["local_profile_id"]) != TYPE_STRING:
return "Profile ID is invalid."
if typeof(payload["display_name"]) != TYPE_STRING:
return "Display name is invalid."
if typeof(payload["client_nonce"]) != TYPE_STRING:
return "Client nonce is invalid."
if typeof(payload["capability_flags"]) not in [
TYPE_PACKED_STRING_ARRAY,
TYPE_ARRAY,
]:
return "Capabilities are invalid."
var capabilities: Variant = payload["capability_flags"]
if capabilities.size() > 32:
return "Capabilities are invalid."
for capability: Variant in capabilities:
if (
typeof(capability) not in [TYPE_STRING, TYPE_STRING_NAME]
or str(capability).is_empty()
or str(capability).length() > 64
):
return "Capabilities are invalid."
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
return "Cosmetic snapshot is invalid."
if (
not NetworkIdentityCrypto.valid_fingerprint(
payload["identity_fingerprint"]
)
or typeof(payload["identity_signature"]) != TYPE_PACKED_BYTE_ARRAY
):
return "Profile identity proof is invalid."
var profile_id: String = payload["local_profile_id"]
var display_name: String = payload["display_name"]
var nonce: String = payload["client_nonce"]
if (
profile_id.is_empty()
or profile_id.length() > MAX_PROFILE_ID_LENGTH
or display_name.is_empty()
or display_name.length() > MAX_DISPLAY_NAME_LENGTH
or nonce.is_empty()
or nonce.length() > MAX_NONCE_LENGTH
):
return "Handshake strings are outside allowed limits."
return ""
static func client_profile_fields(data: Dictionary) -> Array:
var appearance: Dictionary = data.get("cosmetic_snapshot", {})
return [
str(data.get("game_version", "")),
str(data.get("client_nonce", "")),
str(data.get("identity_fingerprint", "")),
str(data.get("local_profile_id", "")),
str(data.get("display_name", "")),
str(appearance.get("species", "")),
CharacterCustomizationCatalog.character_scale_percent(
appearance.get(
CharacterCustomizationCatalog.SCALE_CATEGORY_ID,
CharacterCustomizationCatalog.DEFAULT_CHARACTER_SCALE,
)
),
str(appearance.get("fur_pattern", "")),
str(appearance.get("ears", "")),
str(appearance.get("eyes", "")),
str(appearance.get("nose", "")),
str(appearance.get("mouth", "")),
str(appearance.get("tail", "")),
]
static func make_server_hello(
accepted: bool,
rejection_code: RejectionCode,
session_id: String,
assigned_peer_id: int,
player_count: int,
max_players: int,
server_display_name: String = "NETfishing",
) -> Dictionary:
return {
"accepted": accepted,
"rejection_code": int(rejection_code),
"protocol_version": PROTOCOL_VERSION,
"game_version": game_version(),
"session_id": session_id,
"assigned_peer_id": assigned_peer_id,
"server_display_name": server_display_name,
"player_count": player_count,
"max_players": max_players,
"capability_flags": PackedStringArray([
"movement_v1",
"fishing_v1",
"sale_v1",
"shop_v1",
ART_SHOP_CAPABILITY,
"item_use_v1",
"equipment_v1",
"fish_showcase_v1",
FISH_QUALITY_CAPABILITY,
SURFACE_DRAWING_CAPABILITY,
WORLD_TIME_CAPABILITY,
WORLD_WEATHER_CAPABILITY,
JOBS_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
"identity_v1",
]),
}
static func rejection_text(code: int) -> String:
match code:
RejectionCode.PROTOCOL_MISMATCH:
return "The server uses a different network protocol."
RejectionCode.SERVER_FULL:
return "The server is full."
RejectionCode.DUPLICATE_PROFILE:
return "This legacy local profile is already connected."
RejectionCode.DUPLICATE_IDENTITY:
return "This player identity is already connected."
RejectionCode.INVALID_IDENTITY_PROOF:
return "Player identity authentication failed."
RejectionCode.AUTHENTICATION_TIMEOUT:
return "The server did not finish authentication."
RejectionCode.SERVER_SHUTTING_DOWN:
return "The server is shutting down."
RejectionCode.UNSUPPORTED_CLIENT:
return "This game build is not supported by the server."
RejectionCode.BANNED:
return "You are not permitted to join this server."
RejectionCode.CLIENT_OUTDATED:
return (
"Your NETfishing version is out of date. "
+ "Update the game to join this server."
)
RejectionCode.SERVER_OUTDATED:
return (
"This server is out of date. "
+ "The host needs to update NETfishing."
)
RejectionCode.VERSION_MISMATCH:
return (
"This game and server use different NETfishing versions. "
+ "Update both to the latest release."
)
_:
return "The server rejected the connection."