Load authored pattern masks by body component, add custom fur colors and the themed picker UI, and advance the signed profile protocol to version 6.
350 lines
10 KiB
GDScript
350 lines
10 KiB
GDScript
class_name NetworkProtocol
|
|
extends RefCounted
|
|
|
|
const PROTOCOL_VERSION: int = 6
|
|
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"
|
|
const WORLD_SPAWN_CAPABILITY: String = "world_spawn_envelope_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,
|
|
WORLD_SPAWN_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", {})
|
|
var result: Array = [
|
|
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", "")),
|
|
]
|
|
result.append_array(
|
|
CharacterCustomizationCatalog.appearance_signature_values(appearance)
|
|
)
|
|
return result
|
|
|
|
|
|
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,
|
|
WORLD_SPAWN_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."
|