Add host-authoritative multiplayer fishing

This commit is contained in:
Alexander Sellite 2026-07-29 16:29:25 -04:00
parent b0e2e84ba4
commit d3e3bf4053
17 changed files with 1672 additions and 23 deletions

View file

@ -11,7 +11,7 @@ func start_host(
var enet_peer := ENetMultiplayerPeer.new()
if not bind_address.is_empty() and bind_address != "*":
enet_peer.set_bind_ip(bind_address)
var error: Error = enet_peer.create_server(port, max_clients, 3)
var error: Error = enet_peer.create_server(port, max_clients, 5)
if error != OK:
transport_error.emit("Unable to host UDP port %d." % port)
return error
@ -27,7 +27,7 @@ func connect_to_route(route: ConnectionRoute) -> Error:
return ERR_INVALID_PARAMETER
var endpoint: ConnectionEndpoint = route.direct_endpoint
var enet_peer := ENetMultiplayerPeer.new()
var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 3)
var error: Error = enet_peer.create_client(endpoint.host, endpoint.port, 5)
if error != OK:
transport_error.emit(
"Unable to connect to %s." % endpoint.normalized_display

View file

@ -0,0 +1,38 @@
class_name NetworkFishingAttempt
extends RefCounted
enum Phase {
CASTING,
WAITING_FOR_BITE,
FIGHTING,
PENDING_CAPACITY,
CAUGHT,
ESCAPED,
CANCELLED,
}
var owner_peer_id: int = 0
var request_id: String = ""
var attempt_id: String = ""
var result_id: String = ""
var session_id: String = ""
var phase: Phase = Phase.CASTING
var origin: Vector3
var target: Vector3
var bobber_position: Vector3
var fish_id: StringName
var encounter_seed: int = 0
var bite_time_remaining: float = 0.0
var withdrawal_progress: float = 0.0
var reel_speed: float = 0.0
var barrier_damage: int = 1
var input_held: bool = false
var last_input_sequence: int = 0
var catch_payload: Dictionary = {}
var capacity_nonce: String = ""
var capacity_deadline: float = 0.0
var controller: CatchController
func is_terminal() -> bool:
return phase in [Phase.CAUGHT, Phase.ESCAPED, Phase.CANCELLED]

View file

@ -0,0 +1 @@
uid://c4c00yp1546af

View file

@ -0,0 +1,142 @@
class_name NetworkFishingProtocol
extends RefCounted
const MAX_ID_LENGTH: int = 96
const MAX_EVIDENCE_FISH_IDS: int = 256
const MAX_CAST_DISTANCE: float = 30.0
const MAX_REEL_SPEED: float = 10.0
const MAX_BARRIER_DAMAGE: int = 100
const INPUT_CHANNEL: int = 3
const SNAPSHOT_CHANNEL: int = 4
const INPUT_RATE: float = 1.0 / 25.0
const SNAPSHOT_RATE: float = 1.0 / 15.0
enum Outcome {
CATCH,
ESCAPE,
CANCELLED,
}
static func validate_cast_request(data: Variant) -> String:
if typeof(data) != TYPE_DICTIONARY:
return "Malformed fishing request."
var payload: Dictionary = data
for key: String in [
"request_id",
"session_id",
"origin",
"target",
"charge",
"rod_id",
"reel_speed",
"barrier_damage",
"bite_multiplier",
"rarity_multipliers",
"discovered_fish_ids",
"capacity_available",
]:
if not payload.has(key):
return "Malformed fishing request."
if (
typeof(payload["request_id"]) != TYPE_STRING
or typeof(payload["session_id"]) != TYPE_STRING
or typeof(payload["origin"]) != TYPE_ARRAY
or typeof(payload["target"]) != TYPE_ARRAY
or typeof(payload["charge"]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(payload["rod_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]
or typeof(payload["reel_speed"]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(payload["barrier_damage"]) != TYPE_INT
or typeof(payload["bite_multiplier"]) not in [TYPE_FLOAT, TYPE_INT]
or typeof(payload["rarity_multipliers"]) != TYPE_ARRAY
or typeof(payload["discovered_fish_ids"]) != TYPE_ARRAY
or typeof(payload["capacity_available"]) != TYPE_BOOL
):
return "Malformed fishing request."
var request_id: String = payload["request_id"]
var session_id: String = payload["session_id"]
var origin: Array = payload["origin"]
var target: Array = payload["target"]
var charge: float = float(payload["charge"])
var reel_speed: float = float(payload["reel_speed"])
var barrier_damage: int = payload["barrier_damage"]
var bite_multiplier: float = float(payload["bite_multiplier"])
var rarity: Array = payload["rarity_multipliers"]
var discovered: Array = payload["discovered_fish_ids"]
if (
request_id.is_empty()
or request_id.length() > MAX_ID_LENGTH
or session_id.is_empty()
or session_id.length() > MAX_ID_LENGTH
or origin.size() != 3
or target.size() != 3
or not _array_is_finite_vector3(origin)
or not _array_is_finite_vector3(target)
or not is_finite(charge)
or charge < 0.0
or charge > 1.01
or not is_finite(reel_speed)
or reel_speed <= 0.0
or reel_speed > MAX_REEL_SPEED
or barrier_damage < 1
or barrier_damage > MAX_BARRIER_DAMAGE
or not is_finite(bite_multiplier)
or bite_multiplier < 0.1
or bite_multiplier > 10.0
or rarity.size() > 16
or discovered.size() > MAX_EVIDENCE_FISH_IDS
or str(payload["rod_id"]).is_empty()
or str(payload["rod_id"]).length() > 96
):
return "Fishing request values are outside allowed limits."
for value: Variant in rarity:
if (
typeof(value) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(value))
or float(value) < 0.0
or float(value) > 10.0
):
return "Fishing rarity evidence is invalid."
for value: Variant in discovered:
if (
typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]
or str(value).is_empty()
or str(value).length() > 96
):
return "Fishing discovery evidence is invalid."
return ""
static func validate_input(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var payload: Dictionary = data
return (
typeof(payload.get("attempt_id")) == TYPE_STRING
and not str(payload["attempt_id"]).is_empty()
and str(payload["attempt_id"]).length() <= MAX_ID_LENGTH
and typeof(payload.get("sequence")) == TYPE_INT
and int(payload["sequence"]) > 0
and typeof(payload.get("held")) == TYPE_BOOL
and typeof(payload.get("pressed")) == TYPE_BOOL
)
static func vector3_to_array(value: Vector3) -> Array[float]:
return [value.x, value.y, value.z]
static func array_to_vector3(value: Array) -> Vector3:
if value.size() != 3:
return Vector3.INF
return Vector3(float(value[0]), float(value[1]), float(value[2]))
static func _array_is_finite_vector3(value: Array) -> bool:
for component: Variant in value:
if (
typeof(component) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(component))
):
return false
return true

View file

@ -0,0 +1 @@
uid://cbihol2i6e2iw

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
uid://b4v56htdn1y2l

View file

@ -1,7 +1,7 @@
class_name NetworkProtocol
extends RefCounted
const PROTOCOL_VERSION: int = 1
const PROTOCOL_VERSION: int = 2
const GAME_BUILD: String = "prealpha"
const MAX_DISPLAY_NAME_LENGTH: int = 48
const MAX_PROFILE_ID_LENGTH: int = 96
@ -99,7 +99,10 @@ static func make_server_hello(
"server_display_name": "NETFISHING",
"player_count": player_count,
"max_players": max_players,
"capability_flags": PackedStringArray(["movement_v1"]),
"capability_flags": PackedStringArray([
"movement_v1",
"fishing_v1",
]),
}

View file

@ -254,6 +254,26 @@ func can_use_host_gameplay() -> bool:
return is_host()
func is_gameplay_session_active() -> bool:
return state in [State.PRIVATE_HOST, State.OPEN_HOST, State.JOINED_CLIENT]
func is_authenticated_peer(peer_id: int) -> bool:
return _registry.has_peer(peer_id)
func get_session_id() -> String:
return _session_id
func get_operation_generation() -> int:
return _operation_generation
func get_local_peer_id() -> int:
return multiplayer.get_unique_id() if is_gameplay_session_active() else 0
func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0
if (