Merge remote-tracking branch 'origin/main'
# Conflicts: # export_presets.cfg # scripts/build_playtest.sh
This commit is contained in:
commit
9729c278dc
54 changed files with 1627 additions and 261 deletions
|
|
@ -46,6 +46,14 @@ derives trusted context from registered peers, authoritative regions, and
|
|||
server-owned state before mutating inventory, wallet, progression, or shared
|
||||
world state. See [`decisions/0001-host-authority.md`](decisions/0001-host-authority.md).
|
||||
|
||||
Moderation follows the same boundary. Player hosts may grant session-scoped
|
||||
operator status to an authenticated identity. Dedicated servers derive
|
||||
operators from their configured fingerprint allowlist. Operator status is
|
||||
replicated for presentation, but kick, ban, unban, and artwork-reset requests
|
||||
are always reauthorized against the authenticated sender by the host. Only a
|
||||
player host can grant or revoke operators; operators cannot moderate the host
|
||||
or another operator.
|
||||
|
||||
Protocol compatibility is defined in `network/network_protocol.gd`. A visible
|
||||
release version is not a reason to change the protocol number.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,playtest/*,scripts/*"
|
||||
export_path="builds/v0.6.5-alpha/windows-x86_64/NETfishing.exe"
|
||||
export_path="builds/v0.6.7-alpha/windows-x86_64/NETfishing.exe"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -35,11 +35,11 @@ application/modify_resources=true
|
|||
application/icon="res://art/exported/system_icons/netfishing.ico"
|
||||
application/console_wrapper_icon=""
|
||||
application/icon_interpolation=4
|
||||
application/file_version="0.6.5.0"
|
||||
application/product_version="0.6.5.0"
|
||||
application/file_version="0.6.7.0"
|
||||
application/product_version="0.6.7.0"
|
||||
application/company_name="Woofmeow"
|
||||
application/product_name="NETfishing"
|
||||
application/file_description="NETfishing v0.6.5-alpha"
|
||||
application/file_description="NETfishing v0.6.7-alpha"
|
||||
application/copyright="Copyright © 2026 Alexander Sellite and Rheannon Eisworth"
|
||||
application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md"
|
||||
application/export_angle=0
|
||||
|
|
@ -62,7 +62,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,playtest/*,scripts/*"
|
||||
export_path="builds/v0.6.5-alpha/linux-arm64/NETfishing.arm64"
|
||||
export_path="builds/v0.6.7-alpha/linux-arm64/NETfishing.arm64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -92,7 +92,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,playtest/*,scripts/*"
|
||||
export_path="builds/v0.6.5-alpha/macos/NETfishing.zip"
|
||||
export_path="builds/v0.6.7-alpha/macos/NETfishing.zip"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -108,8 +108,8 @@ custom_template/release=""
|
|||
application/bundle_identifier="io.woofmeow.netfishing"
|
||||
application/icon="res://art/exported/system_icons/netfishing_1024.png"
|
||||
application/icon_interpolation=0
|
||||
application/short_version="0.6.4"
|
||||
application/version="0.6.4"
|
||||
application/short_version="0.6.7"
|
||||
application/version="0.6.7"
|
||||
application/architecture="universal"
|
||||
codesign/enable=false
|
||||
notarization/enable=false
|
||||
|
|
@ -125,7 +125,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,playtest/*,scripts/*,tests/*"
|
||||
export_path="builds/v0.6.5-alpha/server-linux-x86_64/NETfishingServer.x86_64"
|
||||
export_path="builds/v0.6.7-alpha/server-linux-x86_64/NETfishingServer.x86_64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -155,7 +155,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,playtest/*,scripts/*"
|
||||
export_path="builds/v0.6.5-alpha/android/NETfishing.apk"
|
||||
export_path="builds/v0.6.7-alpha/android/NETfishing.apk"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
@ -176,8 +176,8 @@ architectures/armeabi-v7a=false
|
|||
architectures/arm64-v8a=true
|
||||
architectures/x86=false
|
||||
architectures/x86_64=false
|
||||
version/code=60500
|
||||
version/name="v0.6.5-alpha"
|
||||
version/code=60700
|
||||
version/name="v0.6.7-alpha"
|
||||
package/unique_name="io.woofmeow.netfishing"
|
||||
package/name="NETfishing"
|
||||
package/signed=true
|
||||
|
|
@ -214,7 +214,7 @@ custom_features=""
|
|||
export_filter="all_resources"
|
||||
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
|
||||
exclude_filter="builds/*,playtest/*,scripts/*"
|
||||
export_path="builds/v0.6.5-alpha/linux-x86_64/NETfishing.x86_64"
|
||||
export_path="builds/v0.6.7-alpha/linux-x86_64/NETfishing.x86_64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
encryption_exclude_filters=""
|
||||
|
|
|
|||
|
|
@ -23,16 +23,11 @@ const MINNOW_ROLL_WEIGHTS: Array[float] = [34.0, 27.0, 20.0, 13.0, 6.0]
|
|||
const SARDINE_ROLL_WEIGHTS: Array[float] = [25.0, 28.0, 23.0, 16.0, 8.0]
|
||||
const LUMINOUS_ROE_ROLL_WEIGHTS: Array[float] = [16.0, 26.0, 26.0, 20.0, 12.0]
|
||||
const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0]
|
||||
# Legacy profile multiplier retained for serialized/profile compatibility.
|
||||
# New encounters use the weighted quality/rarity/weight bands below.
|
||||
# Legacy multiplier helpers remain available for compatibility. New encounters
|
||||
# roll directly within the authored quality bands below.
|
||||
const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25]
|
||||
# Barrier health is weighted 70% by quality, 20% by rarity, and 10% by the
|
||||
# catch's position in its authored weight range. The upper bounds define the
|
||||
# intended per-barrier challenge bands; a small seeded variance keeps barriers
|
||||
# from feeling identical without crossing those bands.
|
||||
const BARRIER_QUALITY_WEIGHT: float = 0.70
|
||||
const BARRIER_RARITY_WEIGHT: float = 0.20
|
||||
const BARRIER_WEIGHT_WEIGHT: float = 0.10
|
||||
# Inclusive per-barrier health bands authored for each quality tier. Encounter
|
||||
# generation rolls uniformly across the selected tier's complete band.
|
||||
const BARRIER_HEALTH_MINIMUMS: Array[int] = [1, 10, 50, 100, 200]
|
||||
const BARRIER_HEALTH_MAXIMUMS: Array[int] = [9, 50, 100, 200, 400]
|
||||
const DISPLAY_NAMES: PackedStringArray = [
|
||||
|
|
@ -89,32 +84,26 @@ static func apply_barrier_health(base_health: int, quality: int) -> int:
|
|||
)
|
||||
|
||||
|
||||
static func barrier_health_for_catch(
|
||||
quality: int,
|
||||
rarity: int,
|
||||
weight_percentile: float,
|
||||
variance: float = 1.0,
|
||||
) -> int:
|
||||
static func barrier_health_range(quality: int) -> Vector2i:
|
||||
var safe_quality: int = (
|
||||
quality if is_valid(quality) else Tier.BORING
|
||||
)
|
||||
var safe_rarity: float = clampf(float(rarity) / 4.0, 0.0, 1.0)
|
||||
var safe_weight: float = clampf(weight_percentile, 0.0, 1.0)
|
||||
var weighted_health: float = float(BARRIER_HEALTH_MAXIMUMS[safe_quality]) * (
|
||||
BARRIER_QUALITY_WEIGHT
|
||||
+ BARRIER_RARITY_WEIGHT * safe_rarity
|
||||
+ BARRIER_WEIGHT_WEIGHT * safe_weight
|
||||
)
|
||||
var varied_health: int = roundi(
|
||||
weighted_health * clampf(variance, 0.8, 1.2)
|
||||
)
|
||||
return clampi(
|
||||
varied_health,
|
||||
return Vector2i(
|
||||
BARRIER_HEALTH_MINIMUMS[safe_quality],
|
||||
BARRIER_HEALTH_MAXIMUMS[safe_quality],
|
||||
)
|
||||
|
||||
|
||||
static func roll_barrier_health(
|
||||
rng: RandomNumberGenerator,
|
||||
quality: int,
|
||||
) -> int:
|
||||
var health_range: Vector2i = barrier_health_range(quality)
|
||||
if rng == null:
|
||||
return health_range.x
|
||||
return rng.randi_range(health_range.x, health_range.y)
|
||||
|
||||
|
||||
static func roll(
|
||||
rng: RandomNumberGenerator,
|
||||
weight_multipliers: Array[float] = [],
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
script = ExtResource("1_profile")
|
||||
barrier_count_min = 3
|
||||
barrier_count_max = 4
|
||||
barrier_health_min = 4
|
||||
barrier_health_max = 7
|
||||
first_barrier_margin = 0.14
|
||||
final_barrier_margin = 0.1
|
||||
minimum_barrier_spacing = 0.14
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
script = ExtResource("1_profile")
|
||||
barrier_count_min = 3
|
||||
barrier_count_max = 4
|
||||
barrier_health_min = 5
|
||||
barrier_health_max = 8
|
||||
first_barrier_margin = 0.13
|
||||
final_barrier_margin = 0.09
|
||||
minimum_barrier_spacing = 0.14
|
||||
|
|
|
|||
|
|
@ -69,8 +69,6 @@ var _reel_input_held: bool = false
|
|||
var _reel_speed: float = 0.0
|
||||
var _click_power: int = 1
|
||||
var _fish_quality: int = FishQualityType.Tier.BORING
|
||||
var _fish_rarity: int = 0
|
||||
var _fish_weight_percentile: float = 0.0
|
||||
var _chase_delay_remaining: float = 0.0
|
||||
var _failure_epsilon: float = 0.0001
|
||||
var _auto_click_accumulator: float = 0.0
|
||||
|
|
@ -113,8 +111,6 @@ func start_encounter(
|
|||
reel_speed: float,
|
||||
click_power: int,
|
||||
fish_quality: int = FishQualityType.Tier.BORING,
|
||||
fish_rarity: int = 0,
|
||||
fish_weight_percentile: float = 0.0,
|
||||
) -> void:
|
||||
reset()
|
||||
if profile == null:
|
||||
|
|
@ -128,8 +124,6 @@ func start_encounter(
|
|||
if FishQualityType.is_valid(fish_quality)
|
||||
else FishQualityType.Tier.BORING
|
||||
)
|
||||
_fish_rarity = clampi(fish_rarity, 0, 4)
|
||||
_fish_weight_percentile = clampf(fish_weight_percentile, 0.0, 1.0)
|
||||
_chase_delay_remaining = CHASE_START_DELAY
|
||||
chase_progress = -CHASE_START_OFFSET
|
||||
_seed_encounter_rng()
|
||||
|
|
@ -145,8 +139,6 @@ func start_authoritative_encounter(
|
|||
click_power: int,
|
||||
encounter_seed_value: int,
|
||||
fish_quality: int = FishQualityType.Tier.BORING,
|
||||
fish_rarity: int = 0,
|
||||
fish_weight_percentile: float = 0.0,
|
||||
) -> void:
|
||||
var previous_test_mode: bool = use_deterministic_test_seed
|
||||
var previous_seed: int = deterministic_test_seed
|
||||
|
|
@ -157,8 +149,6 @@ func start_authoritative_encounter(
|
|||
reel_speed,
|
||||
click_power,
|
||||
fish_quality,
|
||||
fish_rarity,
|
||||
fish_weight_percentile,
|
||||
)
|
||||
use_deterministic_test_seed = previous_test_mode
|
||||
deterministic_test_seed = previous_seed
|
||||
|
|
@ -327,11 +317,8 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
|
|||
+ spacing * float(barrier_index)
|
||||
+ random_offsets[barrier_index] * random_slack
|
||||
)
|
||||
var health: int = FishQualityType.barrier_health_for_catch(
|
||||
_fish_quality,
|
||||
_fish_rarity,
|
||||
_fish_weight_percentile,
|
||||
_rng.randf_range(0.9, 1.1),
|
||||
var health: int = FishQualityType.roll_barrier_health(
|
||||
_rng, _fish_quality
|
||||
)
|
||||
_barriers.append(Barrier.new(position, health))
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ extends Resource
|
|||
|
||||
@export_range(0, 12, 1) var barrier_count_min: int = 2
|
||||
@export_range(0, 12, 1) var barrier_count_max: int = 4
|
||||
@export_range(1, 100, 1) var barrier_health_min: int = 3
|
||||
@export_range(1, 100, 1) var barrier_health_max: int = 7
|
||||
@export_range(0.0, 0.45, 0.01) var first_barrier_margin: float = 0.15
|
||||
@export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10
|
||||
@export_range(0.01, 1.0, 0.01) var minimum_barrier_spacing: float = 0.15
|
||||
|
|
@ -15,11 +13,6 @@ func get_barrier_count_range() -> Vector2i:
|
|||
return Vector2i(minimum, maxi(minimum, barrier_count_max))
|
||||
|
||||
|
||||
func get_barrier_health_range() -> Vector2i:
|
||||
var minimum: int = maxi(1, barrier_health_min)
|
||||
return Vector2i(minimum, maxi(minimum, barrier_health_max))
|
||||
|
||||
|
||||
func get_generation_interval() -> Vector2:
|
||||
var start: float = clampf(first_barrier_margin, 0.0, 0.95)
|
||||
var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@
|
|||
script = ExtResource("1_profile")
|
||||
barrier_count_min = 2
|
||||
barrier_count_max = 4
|
||||
barrier_health_min = 3
|
||||
barrier_health_max = 7
|
||||
first_barrier_margin = 0.15
|
||||
final_barrier_margin = 0.1
|
||||
minimum_barrier_spacing = 0.15
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void:
|
|||
return
|
||||
|
||||
var half_thickness: float = line_thickness * 0.5
|
||||
_line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
var ribbon_vertices := PackedVector3Array()
|
||||
for point_index: int in range(1, points.size()):
|
||||
var start: Vector3 = points[point_index - 1]
|
||||
var end: Vector3 = points[point_index]
|
||||
|
|
@ -452,12 +452,17 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void:
|
|||
var start_right: Vector3 = to_local(start + side)
|
||||
var end_right: Vector3 = to_local(end + side)
|
||||
var end_left: Vector3 = to_local(end - side)
|
||||
_line_mesh.surface_add_vertex(start_left)
|
||||
_line_mesh.surface_add_vertex(start_right)
|
||||
_line_mesh.surface_add_vertex(end_right)
|
||||
_line_mesh.surface_add_vertex(start_left)
|
||||
_line_mesh.surface_add_vertex(end_right)
|
||||
_line_mesh.surface_add_vertex(end_left)
|
||||
ribbon_vertices.append(start_left)
|
||||
ribbon_vertices.append(start_right)
|
||||
ribbon_vertices.append(end_right)
|
||||
ribbon_vertices.append(start_left)
|
||||
ribbon_vertices.append(end_right)
|
||||
ribbon_vertices.append(end_left)
|
||||
if ribbon_vertices.is_empty():
|
||||
return
|
||||
_line_mesh.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||
for vertex: Vector3 in ribbon_vertices:
|
||||
_line_mesh.surface_add_vertex(vertex)
|
||||
_line_mesh.surface_end()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1045,8 +1045,6 @@ func _activate_bite(confirmation_override: bool = false) -> void:
|
|||
_get_effective_reel_speed(),
|
||||
_get_effective_barrier_damage(),
|
||||
_pending_catch.quality,
|
||||
int(_pending_catch.fish.rarity),
|
||||
_pending_catch.fish.get_weight_percentile(_pending_catch.weight_lb),
|
||||
)
|
||||
_catch_controller.set_reel_input(
|
||||
Input.is_action_pressed("fish_primary")
|
||||
|
|
|
|||
|
|
@ -308,7 +308,15 @@ func _start_dedicated_server() -> void:
|
|||
if not _discovery.set_base_url_override(config.discovery_url):
|
||||
_fail_dedicated_server("The discovery URL is invalid.")
|
||||
return
|
||||
if not _network_session.configure_dedicated_operators(
|
||||
config.operator_fingerprints
|
||||
):
|
||||
_fail_dedicated_server("The server operator list is invalid.")
|
||||
return
|
||||
_configure_portable_stores()
|
||||
if not _discovery.configure_dedicated_runtime(config.server_name):
|
||||
_fail_dedicated_server("The server room name is invalid.")
|
||||
return
|
||||
_initialize_application(true)
|
||||
_player.set_local_control(false)
|
||||
_player.visible = false
|
||||
|
|
@ -323,7 +331,6 @@ func _start_dedicated_server() -> void:
|
|||
_fail_dedicated_server("Could not start the dedicated server.")
|
||||
return
|
||||
_network_session.set_session_display_name(config.server_name)
|
||||
_discovery.set_room_name(config.server_name)
|
||||
if not _network_session.set_host_open(true):
|
||||
_fail_dedicated_server("Could not open the dedicated server.")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ var _session: NetworkSession
|
|||
var _base_url: String = ""
|
||||
var _room_name: String = DEFAULT_ROOM_NAME
|
||||
var _room_name_uses_default: bool = true
|
||||
var _host_settings_persistence_enabled: bool = true
|
||||
var _discoverable: bool = false
|
||||
var _host_status_message: String = ""
|
||||
var _host_status_is_error: bool = false
|
||||
|
|
@ -218,6 +219,11 @@ func get_public_join_state() -> PublicJoinState:
|
|||
return _public_join_state
|
||||
|
||||
|
||||
func configure_dedicated_runtime(room_name: String) -> bool:
|
||||
_host_settings_persistence_enabled = false
|
||||
return set_room_name(room_name)
|
||||
|
||||
|
||||
func set_room_name(value: String) -> bool:
|
||||
var cleaned: String = _sanitize_room_name(value)
|
||||
if cleaned.is_empty():
|
||||
|
|
@ -865,12 +871,46 @@ func _parse_response_dictionary(body: PackedByteArray) -> Dictionary:
|
|||
func _request_failure(response: Dictionary) -> String:
|
||||
var error: Variant = response.get("error", {})
|
||||
if typeof(error) == TYPE_DICTIONARY:
|
||||
var message: String = str((error as Dictionary).get("message", "")).strip_edges()
|
||||
var details := error as Dictionary
|
||||
if str(details.get("code", "")) == "game_version_mismatch":
|
||||
var required_version: String = str(
|
||||
details.get("required_game_version", "")
|
||||
).strip_edges()
|
||||
if not required_version.is_empty():
|
||||
return _discovery_version_mismatch_message(required_version)
|
||||
var message: String = str(details.get("message", "")).strip_edges()
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "Room discovery is temporarily unavailable."
|
||||
|
||||
|
||||
func _discovery_version_mismatch_message(required_version: String) -> String:
|
||||
var local_version: String = NetworkProtocol.game_version()
|
||||
var rejection: NetworkProtocol.RejectionCode = (
|
||||
NetworkProtocol.game_version_rejection(
|
||||
local_version,
|
||||
required_version,
|
||||
)
|
||||
)
|
||||
match rejection:
|
||||
NetworkProtocol.RejectionCode.CLIENT_OUTDATED:
|
||||
return (
|
||||
"Your NETfishing version is out of date. Update to %s to "
|
||||
+ "enable public discovery. This room will not be listed "
|
||||
+ "until you update."
|
||||
) % required_version
|
||||
NetworkProtocol.RejectionCode.SERVER_OUTDATED:
|
||||
return (
|
||||
"Public discovery is still on NETfishing %s. This room "
|
||||
+ "will not be listed until discovery is updated for %s."
|
||||
) % [required_version, local_version]
|
||||
_:
|
||||
return (
|
||||
"Public discovery requires NETfishing %s. This room will "
|
||||
+ "not be listed until both versions match."
|
||||
) % required_version
|
||||
|
||||
|
||||
func _on_session_state_changed(state: NetworkSession.State) -> void:
|
||||
if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty():
|
||||
_set_public_join_state(PublicJoinState.CONNECTING)
|
||||
|
|
@ -1013,6 +1053,8 @@ func _load_settings() -> void:
|
|||
|
||||
|
||||
func _save_settings() -> void:
|
||||
if not _host_settings_persistence_enabled:
|
||||
return
|
||||
var config := ConfigFile.new()
|
||||
config.set_value("host", "room_name", _room_name)
|
||||
config.set_value(
|
||||
|
|
|
|||
|
|
@ -548,8 +548,6 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void:
|
|||
attempt.barrier_damage,
|
||||
attempt.encounter_seed,
|
||||
fish_catch.quality,
|
||||
int(fish.rarity),
|
||||
fish.get_weight_percentile(fish_catch.weight_lb),
|
||||
)
|
||||
var data: Dictionary = {
|
||||
"attempt_id": attempt.attempt_id,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ var _surface_drawing: NetworkSurfaceDrawingService
|
|||
var _revision := 0
|
||||
var _host_block_pairs: Dictionary[String, bool] = {}
|
||||
var _peer_fingerprints: Dictionary[int, String] = {}
|
||||
var _remote_bans: Array[Dictionary] = []
|
||||
var _ban_snapshot_requested: bool = false
|
||||
var _ban_snapshot_loaded: bool = false
|
||||
|
||||
|
||||
func setup(
|
||||
|
|
@ -40,6 +43,7 @@ func setup(
|
|||
func(_count: int, _maximum: int) -> void: _sync_authenticated_peers()
|
||||
)
|
||||
_session.state_changed.connect(_on_session_state_changed)
|
||||
_session.operator_status_changed.connect(_on_operator_status_changed)
|
||||
_relationships.relationship_changed.connect(_on_relationship_changed)
|
||||
_bans.bans_changed.connect(_changed)
|
||||
_chat.set_relationship_store(_relationships)
|
||||
|
|
@ -64,13 +68,24 @@ func get_entries() -> Array[PlayerListEntry]:
|
|||
entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint)
|
||||
entry.display_name = record.display_name
|
||||
entry.is_host = peer_id == 1
|
||||
entry.is_operator = _session.is_peer_operator(peer_id)
|
||||
entry.is_local_player = peer_id == local_id
|
||||
entry.continuity_state = _known.identity_status(record.identity_fingerprint, record.display_name)
|
||||
entry.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
|
||||
entry.muted = _relationships.is_muted(record.identity_fingerprint)
|
||||
entry.blocked = blocked
|
||||
entry.can_kick = _session.is_host() and peer_id != local_id and peer_id != 1
|
||||
entry.can_kick = (
|
||||
_session.can_local_moderate()
|
||||
and peer_id != local_id
|
||||
and peer_id != 1
|
||||
and (_session.is_host() or not entry.is_operator)
|
||||
)
|
||||
entry.can_ban = entry.can_kick
|
||||
entry.can_manage_operator = (
|
||||
_session.can_manage_operators()
|
||||
and peer_id != local_id
|
||||
and peer_id != 1
|
||||
)
|
||||
entry.revision = _revision
|
||||
result.append(entry)
|
||||
result.sort_custom(_entry_before)
|
||||
|
|
@ -89,6 +104,14 @@ func is_local_host() -> bool:
|
|||
return _session.is_host()
|
||||
|
||||
|
||||
func is_local_moderator() -> bool:
|
||||
return _session.can_local_moderate()
|
||||
|
||||
|
||||
func can_manage_operators() -> bool:
|
||||
return _session.can_manage_operators()
|
||||
|
||||
|
||||
func is_open_host() -> bool:
|
||||
return _session.is_open_host()
|
||||
|
||||
|
|
@ -102,7 +125,12 @@ func get_relationships() -> Array[Dictionary]:
|
|||
|
||||
|
||||
func get_bans() -> Array[Dictionary]:
|
||||
return _bans.get_bans(_session.get_host_identity_fingerprint()) if _session.is_host() else []
|
||||
if _session.is_host():
|
||||
return _bans.get_bans(_session.get_host_identity_fingerprint())
|
||||
if not _session.is_local_operator():
|
||||
return []
|
||||
_request_ban_snapshot()
|
||||
return _remote_bans.duplicate(true)
|
||||
|
||||
|
||||
func set_surface_drawing_service(
|
||||
|
|
@ -131,14 +159,12 @@ func get_session_artwork_counts() -> Vector2i:
|
|||
|
||||
|
||||
func reset_session_artwork() -> bool:
|
||||
if not _session.is_host() or _surface_drawing == null:
|
||||
if _session.is_host():
|
||||
return _reset_session_artwork_on_host()
|
||||
if not _session.is_local_operator():
|
||||
return false
|
||||
var ok: bool = _surface_drawing.clear_session_artwork()
|
||||
moderation_finished.emit(
|
||||
ok,
|
||||
"Session artwork cleared." if ok else "Session artwork could not be cleared.",
|
||||
)
|
||||
return ok
|
||||
request_reset_session_artwork.rpc_id(1)
|
||||
return true
|
||||
|
||||
|
||||
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool:
|
||||
|
|
@ -154,12 +180,13 @@ func set_blocked(fingerprint: String, display_name: String, value: bool) -> bool
|
|||
|
||||
|
||||
func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
|
||||
if not _valid_moderation_target(peer_id, fingerprint, revision):
|
||||
moderation_finished.emit(false, "That player is no longer connected.")
|
||||
if _session.is_host():
|
||||
return _kick_on_host(peer_id, fingerprint, revision, true)
|
||||
if not _session.is_local_operator():
|
||||
moderation_finished.emit(false, "Only the host or an operator can remove players.")
|
||||
return false
|
||||
var ok := _session.kick_authenticated_peer(peer_id, fingerprint)
|
||||
moderation_finished.emit(ok, "Player removed." if ok else "Player could not be removed.")
|
||||
return ok
|
||||
request_kick.rpc_id(1, peer_id, fingerprint)
|
||||
return true
|
||||
|
||||
|
||||
func ban(
|
||||
|
|
@ -168,22 +195,158 @@ func ban(
|
|||
display_name: String,
|
||||
revision: int,
|
||||
) -> bool:
|
||||
if not _valid_moderation_target(peer_id, fingerprint, revision):
|
||||
moderation_finished.emit(false, "That player is no longer connected.")
|
||||
if _session.is_host():
|
||||
return _ban_on_host(
|
||||
peer_id, fingerprint, display_name, revision, true
|
||||
)
|
||||
if not _session.is_local_operator():
|
||||
moderation_finished.emit(false, "Only the host or an operator can ban players.")
|
||||
return false
|
||||
var host_fingerprint := _session.get_host_identity_fingerprint()
|
||||
if not _bans.ban(host_fingerprint, fingerprint, display_name):
|
||||
moderation_finished.emit(false, "Ban could not be saved.")
|
||||
return false
|
||||
var ok := _session.kick_authenticated_peer(peer_id, fingerprint, true)
|
||||
moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.")
|
||||
request_ban.rpc_id(1, peer_id, fingerprint)
|
||||
return true
|
||||
|
||||
|
||||
func unban(fingerprint: String) -> bool:
|
||||
if not _session.is_host():
|
||||
if _session.is_host():
|
||||
var ok: bool = _bans.unban(
|
||||
_session.get_host_identity_fingerprint(), fingerprint
|
||||
)
|
||||
moderation_finished.emit(
|
||||
ok, "Player unbanned." if ok else "Ban could not be removed."
|
||||
)
|
||||
return ok
|
||||
if not _session.is_local_operator():
|
||||
moderation_finished.emit(false, "Only the host or an operator can remove bans.")
|
||||
return false
|
||||
return _bans.unban(_session.get_host_identity_fingerprint(), fingerprint)
|
||||
request_unban.rpc_id(1, fingerprint)
|
||||
return true
|
||||
|
||||
|
||||
func set_operator(
|
||||
peer_id: int,
|
||||
fingerprint: String,
|
||||
enabled: bool,
|
||||
revision: int,
|
||||
) -> bool:
|
||||
if (
|
||||
not _session.can_manage_operators()
|
||||
or not _valid_moderation_target(
|
||||
peer_id, fingerprint, revision, true, true
|
||||
)
|
||||
):
|
||||
moderation_finished.emit(false, "That player is no longer connected.")
|
||||
return false
|
||||
var ok: bool = _session.set_peer_operator(peer_id, fingerprint, enabled)
|
||||
moderation_finished.emit(
|
||||
ok,
|
||||
("Player is now an operator." if enabled else "Operator access removed.")
|
||||
if ok
|
||||
else "Operator access could not be changed.",
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func request_kick(peer_id: int, fingerprint: String) -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if not _valid_operator_sender(sender_id):
|
||||
return
|
||||
var ok: bool = _kick_on_host(peer_id, fingerprint, -1, false)
|
||||
_send_moderation_result(
|
||||
sender_id, ok, "Player removed." if ok else "Player could not be removed."
|
||||
)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func request_ban(peer_id: int, fingerprint: String) -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if not _valid_operator_sender(sender_id):
|
||||
return
|
||||
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
|
||||
var display_name: String = record.display_name if record != null else "Player"
|
||||
var ok: bool = _ban_on_host(
|
||||
peer_id, fingerprint, display_name, -1, false
|
||||
)
|
||||
_send_moderation_result(
|
||||
sender_id, ok, "Player banned." if ok else "Player could not be banned."
|
||||
)
|
||||
_send_ban_snapshot(sender_id)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func request_unban(fingerprint: String) -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if (
|
||||
not _valid_operator_sender(sender_id)
|
||||
or not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
|
||||
):
|
||||
return
|
||||
var ok: bool = _bans.unban(
|
||||
_session.get_host_identity_fingerprint(), fingerprint
|
||||
)
|
||||
_send_moderation_result(
|
||||
sender_id, ok, "Player unbanned." if ok else "Ban could not be removed."
|
||||
)
|
||||
_send_ban_snapshot(sender_id)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func request_reset_session_artwork() -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if not _valid_operator_sender(sender_id):
|
||||
return
|
||||
var ok: bool = _reset_session_artwork_on_host(false)
|
||||
_send_moderation_result(
|
||||
sender_id,
|
||||
ok,
|
||||
"Session artwork cleared."
|
||||
if ok
|
||||
else "Session artwork could not be cleared.",
|
||||
)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func request_ban_snapshot() -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if _valid_operator_sender(sender_id):
|
||||
_send_ban_snapshot(sender_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func receive_moderation_result(success: bool, message: String) -> void:
|
||||
if not _session.is_joined_client():
|
||||
return
|
||||
moderation_finished.emit(success, message.left(120))
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func receive_ban_snapshot(records: Array) -> void:
|
||||
if not _session.is_local_operator() or records.size() > HostBanStore.MAX_BANS:
|
||||
return
|
||||
var sanitized: Array[Dictionary] = []
|
||||
for value: Variant in records:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return
|
||||
var record: Dictionary = value
|
||||
var fingerprint: String = str(record.get("target_fingerprint", ""))
|
||||
var display_name: String = str(
|
||||
record.get("last_known_display_name", "Player")
|
||||
).strip_edges().left(NetworkProtocol.MAX_DISPLAY_NAME_LENGTH)
|
||||
if (
|
||||
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
|
||||
or display_name.is_empty()
|
||||
or typeof(record.get("banned_unix")) != TYPE_INT
|
||||
):
|
||||
return
|
||||
sanitized.append({
|
||||
"target_fingerprint": fingerprint,
|
||||
"last_known_display_name": display_name,
|
||||
"banned_unix": int(record["banned_unix"]),
|
||||
})
|
||||
_remote_bans = sanitized
|
||||
_ban_snapshot_requested = false
|
||||
_ban_snapshot_loaded = true
|
||||
_changed()
|
||||
|
||||
|
||||
func is_locally_blocked(fingerprint: String) -> bool:
|
||||
|
|
@ -271,6 +434,22 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
|
|||
]:
|
||||
_host_block_pairs.clear()
|
||||
_peer_fingerprints.clear()
|
||||
_remote_bans.clear()
|
||||
_ban_snapshot_requested = false
|
||||
_ban_snapshot_loaded = false
|
||||
elif state == NetworkSession.State.JOINED_CLIENT:
|
||||
_request_ban_snapshot()
|
||||
_changed()
|
||||
|
||||
|
||||
func _on_operator_status_changed(peer_id: int, enabled: bool) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
if enabled:
|
||||
_request_ban_snapshot()
|
||||
else:
|
||||
_remote_bans.clear()
|
||||
_ban_snapshot_requested = false
|
||||
_ban_snapshot_loaded = false
|
||||
_changed()
|
||||
|
||||
|
||||
|
|
@ -295,16 +474,137 @@ func _peer_for_fingerprint(fingerprint: String) -> int:
|
|||
return 0
|
||||
|
||||
|
||||
func _valid_moderation_target(peer_id: int, fingerprint: String, revision: int) -> bool:
|
||||
if not _session.is_host() or revision != _revision or peer_id == 1:
|
||||
func _valid_moderation_target(
|
||||
peer_id: int,
|
||||
fingerprint: String,
|
||||
revision: int,
|
||||
require_revision: bool,
|
||||
can_target_operator: bool,
|
||||
) -> bool:
|
||||
if (
|
||||
not _session.is_host()
|
||||
or (require_revision and revision != _revision)
|
||||
or peer_id == 1
|
||||
or (not can_target_operator and _session.is_peer_operator(peer_id))
|
||||
):
|
||||
return false
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
return record != null and record.identity_fingerprint == fingerprint
|
||||
|
||||
|
||||
func _valid_operator_sender(peer_id: int) -> bool:
|
||||
return (
|
||||
_session.is_host()
|
||||
and peer_id > 1
|
||||
and _session.is_authenticated_peer(peer_id)
|
||||
and _session.is_peer_operator(peer_id)
|
||||
)
|
||||
|
||||
|
||||
func _kick_on_host(
|
||||
peer_id: int,
|
||||
fingerprint: String,
|
||||
revision: int,
|
||||
local_host_request: bool,
|
||||
) -> bool:
|
||||
if not _valid_moderation_target(
|
||||
peer_id,
|
||||
fingerprint,
|
||||
revision,
|
||||
local_host_request,
|
||||
local_host_request,
|
||||
):
|
||||
if local_host_request:
|
||||
moderation_finished.emit(false, "That player is no longer connected.")
|
||||
return false
|
||||
var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint)
|
||||
if local_host_request:
|
||||
moderation_finished.emit(
|
||||
ok, "Player removed." if ok else "Player could not be removed."
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
func _ban_on_host(
|
||||
peer_id: int,
|
||||
fingerprint: String,
|
||||
display_name: String,
|
||||
revision: int,
|
||||
local_host_request: bool,
|
||||
) -> bool:
|
||||
if not _valid_moderation_target(
|
||||
peer_id,
|
||||
fingerprint,
|
||||
revision,
|
||||
local_host_request,
|
||||
local_host_request,
|
||||
):
|
||||
if local_host_request:
|
||||
moderation_finished.emit(false, "That player is no longer connected.")
|
||||
return false
|
||||
var record: PeerRegistry.PeerRecord = _session.get_peer_record(peer_id)
|
||||
var trusted_display_name: String = (
|
||||
record.display_name if record != null else display_name
|
||||
)
|
||||
var host_fingerprint: String = _session.get_host_identity_fingerprint()
|
||||
if not _bans.ban(host_fingerprint, fingerprint, trusted_display_name):
|
||||
if local_host_request:
|
||||
moderation_finished.emit(false, "Ban could not be saved.")
|
||||
return false
|
||||
var ok: bool = _session.kick_authenticated_peer(peer_id, fingerprint, true)
|
||||
if local_host_request:
|
||||
moderation_finished.emit(ok, "Player banned." if ok else "Ban saved.")
|
||||
return true
|
||||
|
||||
|
||||
func _reset_session_artwork_on_host(
|
||||
emit_local_result: bool = true,
|
||||
) -> bool:
|
||||
if not _session.is_host() or _surface_drawing == null:
|
||||
return false
|
||||
var ok: bool = _surface_drawing.clear_session_artwork()
|
||||
if emit_local_result:
|
||||
moderation_finished.emit(
|
||||
ok,
|
||||
"Session artwork cleared."
|
||||
if ok
|
||||
else "Session artwork could not be cleared.",
|
||||
)
|
||||
return ok
|
||||
|
||||
|
||||
func _send_moderation_result(
|
||||
peer_id: int,
|
||||
success: bool,
|
||||
message: String,
|
||||
) -> void:
|
||||
receive_moderation_result.rpc_id(peer_id, success, message.left(120))
|
||||
|
||||
|
||||
func _send_ban_snapshot(peer_id: int) -> void:
|
||||
if not _valid_operator_sender(peer_id):
|
||||
return
|
||||
receive_ban_snapshot.rpc_id(
|
||||
peer_id,
|
||||
_bans.get_bans(_session.get_host_identity_fingerprint()),
|
||||
)
|
||||
|
||||
|
||||
func _request_ban_snapshot() -> void:
|
||||
if (
|
||||
_session.is_local_operator()
|
||||
and not _ban_snapshot_requested
|
||||
and not _ban_snapshot_loaded
|
||||
):
|
||||
_ban_snapshot_requested = true
|
||||
request_ban_snapshot.rpc_id(1)
|
||||
|
||||
|
||||
func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool:
|
||||
if a.is_host != b.is_host:
|
||||
return a.is_host
|
||||
if a.is_operator != b.is_operator:
|
||||
return a.is_operator
|
||||
if a.is_local_player != b.is_local_player:
|
||||
return a.is_local_player
|
||||
var compared := a.display_name.naturalnocasecmp_to(b.display_name)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ signal server_trust_required(
|
|||
is_changed: bool,
|
||||
)
|
||||
signal peer_identity_observed(peer_id: int, status: String)
|
||||
signal operator_status_changed(peer_id: int, is_operator: bool)
|
||||
signal server_lost
|
||||
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
|
||||
signal remote_recovery_presentation_changed(
|
||||
|
|
@ -102,6 +103,10 @@ var _moderation_disconnect_message := ""
|
|||
var _host_port: int = 0
|
||||
var _session_display_name: String = "NETfishing Room"
|
||||
var _dedicated_host: bool = false
|
||||
var _configured_operator_fingerprints: Dictionary[String, bool] = {}
|
||||
var _session_operator_fingerprints: Dictionary[String, bool] = {}
|
||||
var _operator_peer_ids: Dictionary[int, bool] = {}
|
||||
var _local_operator: bool = false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
|
|
@ -137,6 +142,20 @@ func setup(
|
|||
_profile_ready = _profile_ready and _player_identity.load_or_create()
|
||||
|
||||
|
||||
func configure_dedicated_operators(
|
||||
fingerprints: PackedStringArray,
|
||||
) -> bool:
|
||||
if state != State.INACTIVE:
|
||||
return false
|
||||
var configured: Dictionary[String, bool] = {}
|
||||
for fingerprint: String in fingerprints:
|
||||
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
|
||||
return false
|
||||
configured[fingerprint] = true
|
||||
_configured_operator_fingerprints = configured
|
||||
return true
|
||||
|
||||
|
||||
func start_private_host(
|
||||
port: int = DEFAULT_PORT,
|
||||
port_attempts: int = 1,
|
||||
|
|
@ -215,6 +234,9 @@ func _start_host(
|
|||
multiplayer.multiplayer_peer = peer
|
||||
_session_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
||||
_registry.clear()
|
||||
_session_operator_fingerprints.clear()
|
||||
_operator_peer_ids.clear()
|
||||
_local_operator = false
|
||||
_spawn_service.clear_remote_players()
|
||||
if not dedicated:
|
||||
_register_player_host()
|
||||
|
|
@ -301,6 +323,44 @@ func is_dedicated_host() -> bool:
|
|||
return is_host() and _dedicated_host
|
||||
|
||||
|
||||
func is_local_operator() -> bool:
|
||||
return state == State.JOINED_CLIENT and _local_operator
|
||||
|
||||
|
||||
func can_local_moderate() -> bool:
|
||||
return is_host() or is_local_operator()
|
||||
|
||||
|
||||
func can_manage_operators() -> bool:
|
||||
return is_host() and not _dedicated_host
|
||||
|
||||
|
||||
func is_peer_operator(peer_id: int) -> bool:
|
||||
return bool(_operator_peer_ids.get(peer_id, false))
|
||||
|
||||
|
||||
func set_peer_operator(
|
||||
peer_id: int,
|
||||
fingerprint: String,
|
||||
enabled: bool,
|
||||
) -> bool:
|
||||
if not can_manage_operators() or peer_id <= 1:
|
||||
return false
|
||||
var record: PeerRegistry.PeerRecord = _registry.get_peer(peer_id)
|
||||
if (
|
||||
record == null
|
||||
or not record.identity_authenticated
|
||||
or record.identity_fingerprint != fingerprint
|
||||
):
|
||||
return false
|
||||
if enabled:
|
||||
_session_operator_fingerprints[fingerprint] = true
|
||||
else:
|
||||
_session_operator_fingerprints.erase(fingerprint)
|
||||
_set_operator_status(peer_id, enabled)
|
||||
return true
|
||||
|
||||
|
||||
func set_session_display_name(value: String) -> void:
|
||||
var cleaned: String = value.strip_edges().left(48)
|
||||
if cleaned.is_empty():
|
||||
|
|
@ -822,6 +882,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
|
|||
_pending_authentication.erase(peer_id)
|
||||
_pending_identity_challenges.erase(peer_id)
|
||||
_authenticated_identity_cache.erase(peer_id)
|
||||
_operator_peer_ids.erase(peer_id)
|
||||
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
|
||||
if not recovery_attempt.is_empty():
|
||||
_recovery_attempts.erase(peer_id)
|
||||
|
|
@ -1136,6 +1197,11 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
|
||||
)
|
||||
return
|
||||
var operator_enabled: bool = _operator_for_fingerprint(
|
||||
str(identity["fingerprint"])
|
||||
)
|
||||
if operator_enabled:
|
||||
_operator_peer_ids[sender_id] = true
|
||||
var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
|
||||
data["cosmetic_snapshot"]
|
||||
)
|
||||
|
|
@ -1180,9 +1246,12 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
)
|
||||
)
|
||||
receive_spawn_list.rpc_id(sender_id, _build_spawn_list())
|
||||
receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot())
|
||||
receive_peer_spawn.rpc(
|
||||
_make_spawn_entry(sender_id, display_name, spawn_transform)
|
||||
)
|
||||
if operator_enabled:
|
||||
receive_operator_status.rpc(sender_id, true)
|
||||
peer_authenticated.emit(sender_id, display_name)
|
||||
_emit_peer_count()
|
||||
|
||||
|
|
@ -1334,12 +1403,41 @@ func receive_peer_spawn(entry: Dictionary) -> void:
|
|||
func receive_peer_despawn(peer_id: int) -> void:
|
||||
if state != State.JOINED_CLIENT:
|
||||
return
|
||||
_operator_peer_ids.erase(peer_id)
|
||||
_registry.remove_peer(peer_id)
|
||||
_spawn_service.remove_peer(peer_id)
|
||||
peer_removed.emit(peer_id)
|
||||
_emit_peer_count()
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func receive_operator_snapshot(peer_ids: PackedInt32Array) -> void:
|
||||
if (
|
||||
state != State.JOINED_CLIENT
|
||||
or peer_ids.size() > get_session_max_players()
|
||||
):
|
||||
return
|
||||
_operator_peer_ids.clear()
|
||||
for peer_id: int in peer_ids:
|
||||
if peer_id > 1:
|
||||
_operator_peer_ids[peer_id] = true
|
||||
_update_local_operator()
|
||||
for peer_id: int in _operator_peer_ids:
|
||||
operator_status_changed.emit(peer_id, true)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func receive_operator_status(peer_id: int, enabled: bool) -> void:
|
||||
if state != State.JOINED_CLIENT or peer_id <= 1:
|
||||
return
|
||||
if enabled:
|
||||
_operator_peer_ids[peer_id] = true
|
||||
else:
|
||||
_operator_peer_ids.erase(peer_id)
|
||||
_update_local_operator()
|
||||
operator_status_changed.emit(peer_id, enabled)
|
||||
|
||||
|
||||
func _apply_spawn_entry(entry: Dictionary) -> void:
|
||||
if (
|
||||
typeof(entry.get("peer_id")) != TYPE_INT
|
||||
|
|
@ -1470,6 +1568,36 @@ func _sanitized_capabilities(value: Variant) -> PackedStringArray:
|
|||
return result
|
||||
|
||||
|
||||
func _operator_for_fingerprint(fingerprint: String) -> bool:
|
||||
return bool((
|
||||
_configured_operator_fingerprints
|
||||
if _dedicated_host
|
||||
else _session_operator_fingerprints
|
||||
).get(fingerprint, false))
|
||||
|
||||
|
||||
func _operator_peer_id_snapshot() -> PackedInt32Array:
|
||||
var result: PackedInt32Array = PackedInt32Array()
|
||||
for peer_id: int in _operator_peer_ids:
|
||||
result.append(peer_id)
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
|
||||
func _set_operator_status(peer_id: int, enabled: bool) -> void:
|
||||
if enabled:
|
||||
_operator_peer_ids[peer_id] = true
|
||||
else:
|
||||
_operator_peer_ids.erase(peer_id)
|
||||
operator_status_changed.emit(peer_id, enabled)
|
||||
receive_operator_status.rpc(peer_id, enabled)
|
||||
|
||||
|
||||
func _update_local_operator() -> void:
|
||||
var local_peer_id: int = multiplayer.get_unique_id()
|
||||
_local_operator = bool(_operator_peer_ids.get(local_peer_id, false))
|
||||
|
||||
|
||||
func _verify_spawn_identity(entry: Dictionary) -> bool:
|
||||
var fingerprint := str(entry.get("identity_fingerprint", ""))
|
||||
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
|
||||
|
|
@ -1929,5 +2057,8 @@ func _teardown_peer() -> void:
|
|||
_server_identity_fingerprint = ""
|
||||
_server_identity_public_key = ""
|
||||
_session_identity_keys.clear()
|
||||
_session_operator_fingerprints.clear()
|
||||
_operator_peer_ids.clear()
|
||||
_local_operator = false
|
||||
_host_port = 0
|
||||
_dedicated_host = false
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ var full_fingerprint := ""
|
|||
var compact_fingerprint := ""
|
||||
var display_name := ""
|
||||
var is_host := false
|
||||
var is_operator := false
|
||||
var is_local_player := false
|
||||
var continuity_state := ""
|
||||
var ping_to_host_ms := -1
|
||||
|
|
@ -13,4 +14,5 @@ var muted := false
|
|||
var blocked := false
|
||||
var can_kick := false
|
||||
var can_ban := false
|
||||
var can_manage_operator := false
|
||||
var revision := 0
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
NETfishing
|
||||
v0.6.5-alpha
|
||||
Alpha 0.6.5
|
||||
v0.6.7-alpha
|
||||
Alpha 0.6.7
|
||||
|
||||
Thank you for trying this early private playtest.
|
||||
|
||||
NETfishing is currently a local gameplay prototype for a future
|
||||
multiplayer-first game. Networking and multiplayer are NOT implemented in
|
||||
this build.
|
||||
NETfishing is an early multiplayer fishing game. This alpha includes local
|
||||
progression, player-hosted and dedicated multiplayer sessions, public room
|
||||
discovery, and direct UDP connections. Expect rough edges and please keep a
|
||||
backup of any save or identity data you care about.
|
||||
|
||||
INSTALLATION
|
||||
|
||||
|
|
@ -60,6 +61,9 @@ FISHING
|
|||
|
||||
FEATURES TO TRY
|
||||
|
||||
- Hosting a private or public room and joining compatible online sessions
|
||||
- Direct-IP, saved-server, recent-server, and public-room connection flows
|
||||
- Text chat, player calls, player lists, relationships, and moderation tools
|
||||
- Movement, jumping, camera rotation, and zoom
|
||||
- Short, medium, and maximum-distance casts
|
||||
- Valid water and invalid land casts
|
||||
|
|
@ -101,11 +105,11 @@ Please include:
|
|||
- How movement, casting, chase pressure, barrier clicking, menus, and camera
|
||||
controls felt
|
||||
|
||||
KNOWN PRE-ALPHA LIMITATIONS
|
||||
KNOWN ALPHA LIMITATIONS
|
||||
|
||||
- Multiplayer/networking is not implemented.
|
||||
- Fish and character art are temporary.
|
||||
- UI and balance values are placeholders.
|
||||
- There is no audio.
|
||||
- Public sessions remain direct connections; restrictive NAT or carrier-grade
|
||||
networks may prevent some players from joining.
|
||||
- Multiplayer, dedicated-server deployment, UI, and balance are still alpha.
|
||||
- Some fish and character artwork remains temporary.
|
||||
- There is no installer or automatic updater.
|
||||
- Windows builds are unsigned.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ config_version=5
|
|||
[application]
|
||||
|
||||
config/name="NETFISHING"
|
||||
config/version="0.6.5-alpha"
|
||||
config/version="0.6.7-alpha"
|
||||
run/main_scene="res://main/main.tscn"
|
||||
config/features=PackedStringArray("4.7", "GL Compatibility")
|
||||
config/icon="res://art/exported/system_icons/netfishing_256.png"
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ set -euo pipefail
|
|||
|
||||
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.5-alpha"
|
||||
readonly BUILD_ROOT="${PROJECT_ROOT}/builds/v0.6.7-alpha"
|
||||
readonly WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
|
||||
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
|
||||
readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt"
|
||||
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
|
||||
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
|
||||
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-windows-x86_64.zip"
|
||||
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-linux-x86_64.zip"
|
||||
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.7-alpha-windows-x86_64.zip"
|
||||
readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.7-alpha-linux-x86_64.zip"
|
||||
readonly GODOT_BIN="${GODOT_BIN:-godot}"
|
||||
|
||||
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
|
|||
|
||||
readonly -a QUICK_TESTS=(
|
||||
"tests/android_readiness_validation.gd"
|
||||
"tests/dedicated_server_config_validation.gd"
|
||||
"tests/fish_catalog_content_validation.gd"
|
||||
"tests/fish_quality_validation.gd"
|
||||
"tests/fishing_audio_validation.gd"
|
||||
|
|
@ -32,6 +33,7 @@ readonly -a RUNTIME_TESTS=(
|
|||
|
||||
readonly -a HOST_TESTS=(
|
||||
"tests/art_tools_validation.gd"
|
||||
"tests/dedicated_host_session_validation.gd"
|
||||
"tests/economy_regression_validation.gd"
|
||||
"tests/fish_hotbar_showcase_validation.gd"
|
||||
"tests/fishing_authority_validation.gd"
|
||||
|
|
@ -44,6 +46,7 @@ readonly -a NETWORK_TESTS=(
|
|||
"tests/fish_showcase_multiplayer_validation.gd"
|
||||
"tests/fishing_multiplayer_validation.gd"
|
||||
"tests/job_multiplayer_validation.gd"
|
||||
"tests/operator_multiplayer_validation.gd"
|
||||
"tests/surface_drawing_multiplayer_validation.gd"
|
||||
"tests/world_time_multiplayer_validation.gd"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const DEFAULT_NAME: String = "NETfishing Dedicated Server"
|
|||
const DEFAULT_BIND_ADDRESS: String = "*"
|
||||
const DEFAULT_PORT: int = 7777
|
||||
const DEFAULT_MAX_PLAYERS: int = 8
|
||||
const MAX_OPERATORS: int = 64
|
||||
|
||||
var server_name: String = DEFAULT_NAME
|
||||
var bind_address: String = DEFAULT_BIND_ADDRESS
|
||||
|
|
@ -13,6 +14,7 @@ var max_players: int = DEFAULT_MAX_PLAYERS
|
|||
var public_listing: bool = false
|
||||
var discovery_url: String = ""
|
||||
var data_directory: String = ""
|
||||
var operator_fingerprints: PackedStringArray = PackedStringArray()
|
||||
var error_message: String = ""
|
||||
|
||||
|
||||
|
|
@ -65,6 +67,9 @@ func _load_file(path: String) -> bool:
|
|||
discovery_url = str(file.get_value(
|
||||
"discovery", "url", discovery_url
|
||||
))
|
||||
operator_fingerprints = _parse_fingerprint_list(file.get_value(
|
||||
"moderation", "operators", operator_fingerprints
|
||||
))
|
||||
return true
|
||||
|
||||
|
||||
|
|
@ -88,6 +93,10 @@ func _apply_environment() -> void:
|
|||
data_directory = _environment_string(
|
||||
"NETFISHING_DATA_DIR", data_directory
|
||||
)
|
||||
if OS.has_environment("NETFISHING_SERVER_OPERATORS"):
|
||||
operator_fingerprints = _parse_fingerprint_list(
|
||||
OS.get_environment("NETFISHING_SERVER_OPERATORS")
|
||||
)
|
||||
|
||||
|
||||
func _apply_arguments(arguments: PackedStringArray) -> void:
|
||||
|
|
@ -106,6 +115,10 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
|
|||
data_directory = argument.trim_prefix("--data-dir=")
|
||||
elif argument.begins_with("--discovery-url="):
|
||||
discovery_url = argument.trim_prefix("--discovery-url=")
|
||||
elif argument.begins_with("--operators="):
|
||||
operator_fingerprints = _parse_fingerprint_list(
|
||||
argument.trim_prefix("--operators=")
|
||||
)
|
||||
elif argument == "--public":
|
||||
public_listing = true
|
||||
elif argument == "--private":
|
||||
|
|
@ -117,6 +130,7 @@ func _validate() -> void:
|
|||
bind_address = bind_address.strip_edges()
|
||||
discovery_url = discovery_url.strip_edges().trim_suffix("/")
|
||||
data_directory = data_directory.strip_edges()
|
||||
operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
|
||||
if server_name.is_empty():
|
||||
error_message = "Server name cannot be empty."
|
||||
elif not _safe_text(server_name):
|
||||
|
|
@ -134,6 +148,17 @@ func _validate() -> void:
|
|||
or discovery_url.begins_with("http://")
|
||||
):
|
||||
error_message = "A public server requires a discovery URL."
|
||||
elif operator_fingerprints.size() > MAX_OPERATORS:
|
||||
error_message = "A server may configure at most %d operators." % (
|
||||
MAX_OPERATORS
|
||||
)
|
||||
else:
|
||||
for fingerprint: String in operator_fingerprints:
|
||||
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
|
||||
error_message = (
|
||||
"Server operator fingerprints must be 64 lowercase hex characters."
|
||||
)
|
||||
break
|
||||
|
||||
|
||||
static func _safe_text(value: String) -> bool:
|
||||
|
|
@ -164,3 +189,29 @@ static func _environment_bool(name: String, fallback: bool) -> bool:
|
|||
|
||||
static func _parse_int(value: String, fallback: int) -> int:
|
||||
return int(value) if value.strip_edges().is_valid_int() else fallback
|
||||
|
||||
|
||||
static func _parse_fingerprint_list(value: Variant) -> PackedStringArray:
|
||||
var values: PackedStringArray = PackedStringArray()
|
||||
if typeof(value) == TYPE_STRING:
|
||||
var text: String = str(value)
|
||||
for separator: String in [";", "\n", "\r", "\t", " "]:
|
||||
text = text.replace(separator, ",")
|
||||
values = text.split(",", false)
|
||||
elif typeof(value) in [TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY]:
|
||||
for entry: Variant in value:
|
||||
if typeof(entry) in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
values.append(str(entry))
|
||||
return _normalized_fingerprints(values)
|
||||
|
||||
|
||||
static func _normalized_fingerprints(
|
||||
values: PackedStringArray,
|
||||
) -> PackedStringArray:
|
||||
var result: PackedStringArray = PackedStringArray()
|
||||
for value: String in values:
|
||||
var fingerprint: String = value.strip_edges().to_lower()
|
||||
if not fingerprint.is_empty() and fingerprint not in result:
|
||||
result.append(fingerprint)
|
||||
result.sort()
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ func _run() -> void:
|
|||
select_button.button_index = JOY_BUTTON_BACK
|
||||
select_button.pressed = true
|
||||
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
|
||||
await process_frame
|
||||
assert(chat_ui.is_open())
|
||||
assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP)
|
||||
var typed_chat_entry := chat_ui.find_child(
|
||||
|
|
@ -121,6 +122,8 @@ func _run() -> void:
|
|||
accept_button.pressed = true
|
||||
assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button)))
|
||||
assert(typed_chat_entry.virtual_keyboard_enabled)
|
||||
await process_frame
|
||||
assert(typed_chat_entry.has_focus())
|
||||
var left_bumper := InputEventJoypadButton.new()
|
||||
left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER
|
||||
left_bumper.pressed = true
|
||||
|
|
@ -129,10 +132,8 @@ func _run() -> void:
|
|||
assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE)
|
||||
assert(not typed_chat_entry.virtual_keyboard_enabled)
|
||||
assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
|
||||
assert(chat_ui.is_open())
|
||||
assert(not typed_chat_entry.virtual_keyboard_enabled)
|
||||
assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper)))
|
||||
assert(not chat_ui.is_open())
|
||||
assert(not typed_chat_entry.virtual_keyboard_enabled)
|
||||
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
|
||||
assert(not chat_ui.is_open())
|
||||
assert(chat_ui.is_collapsed())
|
||||
|
|
@ -315,5 +316,7 @@ func _run() -> void:
|
|||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ func _run() -> void:
|
|||
standard_button.material != null,
|
||||
"ordinary controller focus receives inversion",
|
||||
)
|
||||
var pointer_motion := InputEventMouseMotion.new()
|
||||
pointer_motion.position = Vector2(4.0, 4.0)
|
||||
presentation._input(pointer_motion)
|
||||
await process_frame
|
||||
_expect(
|
||||
standard_button.material == null,
|
||||
"mouse motion clears controller focus presentation",
|
||||
)
|
||||
presentation._input(controller_event)
|
||||
|
||||
authored_selector.grab_focus()
|
||||
await process_frame
|
||||
|
|
|
|||
|
|
@ -23,14 +23,45 @@ func _run() -> void:
|
|||
var original := _make_button("cute", "cute (show variants)")
|
||||
option_list.add_child(original)
|
||||
await process_frame
|
||||
original.grab_focus()
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == null,
|
||||
"programmatic focus stays pending outside navigation mode",
|
||||
)
|
||||
var keyboard_navigation := InputEventKey.new()
|
||||
keyboard_navigation.keycode = KEY_DOWN
|
||||
keyboard_navigation.pressed = true
|
||||
recovery._input(keyboard_navigation)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == original,
|
||||
"keyboard navigation restores pending initial focus",
|
||||
)
|
||||
var menu_shortcut := InputEventKey.new()
|
||||
menu_shortcut.physical_keycode = KEY_TAB
|
||||
menu_shortcut.pressed = true
|
||||
recovery._input(menu_shortcut)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == null,
|
||||
"the menu shortcut does not leave a pseudo-focused option",
|
||||
)
|
||||
|
||||
var controller_event := InputEventJoypadButton.new()
|
||||
controller_event.button_index = JOY_BUTTON_A
|
||||
controller_event.pressed = true
|
||||
recovery._input(controller_event)
|
||||
original.grab_focus()
|
||||
await process_frame
|
||||
_expect(root.gui_get_focus_owner() == original, "original option receives focus")
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == original,
|
||||
"controller navigation restores pending initial focus",
|
||||
)
|
||||
|
||||
option_list.remove_child(original)
|
||||
original.queue_free()
|
||||
|
|
@ -53,6 +84,63 @@ func _run() -> void:
|
|||
root.gui_get_focus_owner() == explicit_target,
|
||||
"explicit focus changes take precedence over recovery",
|
||||
)
|
||||
var pointer_motion := InputEventMouseMotion.new()
|
||||
pointer_motion.position = Vector2(4.0, 4.0)
|
||||
recovery._input(pointer_motion)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == null,
|
||||
"mouse motion releases controller focus",
|
||||
)
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == null,
|
||||
"mouse motion prevents automatic focus recovery",
|
||||
)
|
||||
recovery._input(controller_event)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == explicit_target,
|
||||
"controller navigation can resume from pointer mode",
|
||||
)
|
||||
var pointer_press := InputEventMouseButton.new()
|
||||
pointer_press.button_index = MOUSE_BUTTON_LEFT
|
||||
pointer_press.pressed = true
|
||||
recovery._input(pointer_press)
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == explicit_target,
|
||||
"mouse-down preserves focus through button activation",
|
||||
)
|
||||
var pointer_release := InputEventMouseButton.new()
|
||||
pointer_release.button_index = MOUSE_BUTTON_LEFT
|
||||
pointer_release.pressed = false
|
||||
recovery._input(pointer_release)
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == null,
|
||||
"mouse-up clears the completed button focus",
|
||||
)
|
||||
recovery._input(controller_event)
|
||||
await process_frame
|
||||
await process_frame
|
||||
|
||||
var outside_button := _make_button("outside", "outside")
|
||||
stage.add_child(outside_button)
|
||||
option_list.hide()
|
||||
root.gui_release_focus()
|
||||
await process_frame
|
||||
await process_frame
|
||||
_expect(
|
||||
root.gui_get_focus_owner() == null,
|
||||
"focus recovery never escapes a hidden menu scope",
|
||||
)
|
||||
option_list.show()
|
||||
explicit_target.grab_focus()
|
||||
await process_frame
|
||||
var leave_world_ui_event := InputEventJoypadButton.new()
|
||||
leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER
|
||||
leave_world_ui_event.pressed = true
|
||||
|
|
@ -64,6 +152,14 @@ func _run() -> void:
|
|||
root.gui_get_focus_owner() == null,
|
||||
"LB intentionally leaving world UI is never recovered",
|
||||
)
|
||||
var accessibility_only := Control.new()
|
||||
accessibility_only.focus_mode = Control.FOCUS_ACCESSIBILITY
|
||||
stage.add_child(accessibility_only)
|
||||
await process_frame
|
||||
_expect(
|
||||
not bool(recovery.call("_is_focusable", accessibility_only)),
|
||||
"accessibility-only controls are not recovery targets",
|
||||
)
|
||||
|
||||
stage.queue_free()
|
||||
if _failures.is_empty():
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
extends SceneTree
|
||||
|
||||
const TEST_PORT: int = 35777
|
||||
const OPERATOR_FINGERPRINT: String = (
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
|
|
@ -35,12 +38,18 @@ func _run() -> void:
|
|||
host_bans,
|
||||
true,
|
||||
)
|
||||
assert(session.configure_dedicated_operators(
|
||||
PackedStringArray([OPERATOR_FINGERPRINT])
|
||||
))
|
||||
assert(session.start_dedicated_host(TEST_PORT, 5, "127.0.0.1"))
|
||||
assert(session.is_dedicated_host())
|
||||
assert(session.get_local_peer_id() == 0)
|
||||
assert(session.get_player_count() == 0)
|
||||
assert(session.get_session_max_players() == 5)
|
||||
assert(session.get_host_port() == TEST_PORT)
|
||||
assert(bool(session.call(
|
||||
"_operator_for_fingerprint", OPERATOR_FINGERPRINT
|
||||
)))
|
||||
assert(session.set_host_open(true))
|
||||
assert(session.is_open_host())
|
||||
session.disconnect_session("Dedicated host validation complete.")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
extends SceneTree
|
||||
|
||||
const ConfigType = preload("res://server/dedicated_server_config.gd")
|
||||
const OPERATOR_A: String = (
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
const OPERATOR_B: String = (
|
||||
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
)
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -25,6 +31,9 @@ func _run() -> void:
|
|||
file.set_value("server", "public", true)
|
||||
file.set_value("server", "data_directory", "/tmp/configured-server")
|
||||
file.set_value("discovery", "url", "https://discovery.netfishing.org/")
|
||||
file.set_value(
|
||||
"moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
|
||||
)
|
||||
assert(file.save(path) == OK)
|
||||
|
||||
var configured := ConfigType.new()
|
||||
|
|
@ -37,8 +46,35 @@ func _run() -> void:
|
|||
assert(int(configured.get("max_players")) == 12)
|
||||
assert(bool(configured.get("public_listing")))
|
||||
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org")
|
||||
assert(
|
||||
configured.get("operator_fingerprints")
|
||||
== PackedStringArray([OPERATOR_A, OPERATOR_B])
|
||||
)
|
||||
assert(DirAccess.remove_absolute(path) == OK)
|
||||
|
||||
var parsed := ConfigType.new()
|
||||
parsed.set(
|
||||
"operator_fingerprints",
|
||||
ConfigType._parse_fingerprint_list(
|
||||
"%s, %s;%s" % [OPERATOR_B.to_upper(), OPERATOR_A, OPERATOR_A]
|
||||
),
|
||||
)
|
||||
parsed.set("data_directory", "/tmp/parsed-server")
|
||||
parsed.call("_validate")
|
||||
assert(parsed.is_valid())
|
||||
assert(
|
||||
parsed.get("operator_fingerprints")
|
||||
== PackedStringArray([OPERATOR_A, OPERATOR_B])
|
||||
)
|
||||
|
||||
var invalid_operator := ConfigType.new()
|
||||
invalid_operator.set(
|
||||
"operator_fingerprints", PackedStringArray(["not-a-fingerprint"])
|
||||
)
|
||||
invalid_operator.set("data_directory", "/tmp/invalid-operator-server")
|
||||
invalid_operator.call("_validate")
|
||||
assert(not invalid_operator.is_valid())
|
||||
|
||||
var invalid := ConfigType.new()
|
||||
invalid.set("public_listing", true)
|
||||
invalid.set("data_directory", "/tmp/invalid-server")
|
||||
|
|
@ -58,6 +94,27 @@ func _run() -> void:
|
|||
DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS
|
||||
< DiscoveryClient.UPNP_RENEW_INTERVAL_SECONDS
|
||||
)
|
||||
var discovery_settings_path: String = ProjectSettings.globalize_path(
|
||||
DiscoveryClient.SETTINGS_PATH
|
||||
)
|
||||
assert(not FileAccess.file_exists(discovery_settings_path))
|
||||
assert(discovery.set_room_name("Client Room"))
|
||||
var client_settings: PackedByteArray = FileAccess.get_file_as_bytes(
|
||||
discovery_settings_path
|
||||
)
|
||||
assert(not client_settings.is_empty())
|
||||
var dedicated_discovery := DiscoveryClient.new()
|
||||
dedicated_discovery.call("_load_settings")
|
||||
assert(
|
||||
dedicated_discovery.configure_dedicated_runtime("Headless Room")
|
||||
)
|
||||
assert(dedicated_discovery.get_room_name() == "Headless Room")
|
||||
assert(
|
||||
FileAccess.get_file_as_bytes(discovery_settings_path)
|
||||
== client_settings
|
||||
)
|
||||
dedicated_discovery.free()
|
||||
assert(DirAccess.remove_absolute(discovery_settings_path) == OK)
|
||||
assert(
|
||||
discovery.get_host_state()
|
||||
== DiscoveryClient.HostState.CLOSED
|
||||
|
|
@ -107,6 +164,46 @@ func _run() -> void:
|
|||
"0.6.5-beta", "0.6.5-alpha"
|
||||
) == NetworkProtocol.RejectionCode.SERVER_OUTDATED
|
||||
)
|
||||
var outdated_discovery_error := {
|
||||
"error": {
|
||||
"code": "game_version_mismatch",
|
||||
"message": "generic mismatch",
|
||||
"required_game_version": "0.6.8-alpha",
|
||||
},
|
||||
}
|
||||
var outdated_message: String = str(
|
||||
discovery.call("_request_failure", outdated_discovery_error)
|
||||
)
|
||||
assert("out of date" in outdated_message)
|
||||
assert("0.6.8-alpha" in outdated_message)
|
||||
assert("will not be listed" in outdated_message)
|
||||
discovery.set(
|
||||
"_host_request_kind",
|
||||
DiscoveryClient.HostRequestKind.CREATE,
|
||||
)
|
||||
discovery.set("_host_request_in_flight", true)
|
||||
discovery.call(
|
||||
"_on_host_request_completed",
|
||||
HTTPRequest.RESULT_SUCCESS,
|
||||
HTTPClient.RESPONSE_CONFLICT,
|
||||
PackedStringArray(),
|
||||
JSON.stringify(outdated_discovery_error).to_utf8_buffer(),
|
||||
)
|
||||
assert(discovery.get_host_state() == DiscoveryClient.HostState.ERROR)
|
||||
assert(discovery.host_status_is_error())
|
||||
assert(discovery.get_host_status_message() == outdated_message)
|
||||
var newer_discovery_error := {
|
||||
"error": {
|
||||
"code": "game_version_mismatch",
|
||||
"message": "generic mismatch",
|
||||
"required_game_version": "0.6.6-alpha",
|
||||
},
|
||||
}
|
||||
var newer_message: String = str(
|
||||
discovery.call("_request_failure", newer_discovery_error)
|
||||
)
|
||||
assert("discovery is updated" in newer_message)
|
||||
assert("will not be listed" in newer_message)
|
||||
discovery.free()
|
||||
print("DEDICATED_SERVER_CONFIG_VALIDATION_OK")
|
||||
quit()
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ func _run() -> void:
|
|||
print("Economy regression validation: PASS")
|
||||
session.disconnect_session("Economy validation complete.")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -126,7 +127,9 @@ func _run_multiplayer_host() -> void:
|
|||
print("Economy multiplayer host validation: PASS")
|
||||
session.disconnect_session("Economy host validation complete.")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -228,7 +231,9 @@ func _run_multiplayer_client() -> void:
|
|||
print("Economy multiplayer client validation: PASS")
|
||||
session.disconnect_session("Economy client validation complete.")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -407,7 +412,7 @@ func _test_player_menu_sale(
|
|||
assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP)
|
||||
assert(sell_action.focus_mode == Control.FOCUS_ALL)
|
||||
assert(ui_viewport != null)
|
||||
await _activate_focused_button(sell_action, ui_viewport)
|
||||
await _activate_pointer_control(sell_action, ui_viewport)
|
||||
await process_frame
|
||||
assert(confirmation.visible)
|
||||
assert(confirm_button.visible and not confirm_button.disabled)
|
||||
|
|
@ -416,7 +421,7 @@ func _test_player_menu_sale(
|
|||
> (player_menu.get_node("%CoolerOuterWall") as Control).z_index
|
||||
)
|
||||
_sale_result.clear()
|
||||
await _activate_focused_button(confirm_button, ui_viewport)
|
||||
await _activate_pointer_control(confirm_button, ui_viewport)
|
||||
await process_frame
|
||||
assert(not _sale_result.is_empty() and bool(_sale_result[1]))
|
||||
assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
|
||||
|
|
@ -435,18 +440,29 @@ func _test_player_menu_sale(
|
|||
await create_timer(2.2).timeout
|
||||
|
||||
|
||||
func _activate_focused_button(
|
||||
button: Button,
|
||||
func _activate_pointer_control(
|
||||
control: Control,
|
||||
ui_viewport: SubViewport,
|
||||
) -> void:
|
||||
button.grab_focus()
|
||||
var presenter := ui_viewport.get_parent() as SubViewportContainer
|
||||
assert(presenter != null)
|
||||
var local_center: Vector2 = control.get_global_transform_with_canvas() * (
|
||||
control.size * 0.5
|
||||
)
|
||||
var center: Vector2 = presenter.position + local_center * presenter.scale
|
||||
var motion := InputEventMouseMotion.new()
|
||||
motion.position = center
|
||||
motion.global_position = center
|
||||
root.push_input(motion, true)
|
||||
await process_frame
|
||||
assert(button.has_focus())
|
||||
assert(ui_viewport.gui_get_hovered_control() == control)
|
||||
for is_pressed: bool in [true, false]:
|
||||
var accept := InputEventAction.new()
|
||||
accept.action = &"ui_accept"
|
||||
accept.pressed = is_pressed
|
||||
ui_viewport.push_input(accept, false)
|
||||
var click := InputEventMouseButton.new()
|
||||
click.button_index = MOUSE_BUTTON_LEFT
|
||||
click.position = center
|
||||
click.global_position = center
|
||||
click.pressed = is_pressed
|
||||
root.push_input(click, true)
|
||||
await process_frame
|
||||
|
||||
|
||||
|
|
@ -555,19 +571,25 @@ func _test_fishing_shop_sale_ui(
|
|||
assert(not shop.has_node("ShopPanel/Margin/Layout/ModeTabs"))
|
||||
var shop_tabs: Array = shop.get("_shop_tabs") as Array
|
||||
assert(shop_tabs.size() == 6)
|
||||
var art_supplies_tab := shop_tabs[4] as Button
|
||||
assert(art_supplies_tab != null and art_supplies_tab.text == "Art Supplies")
|
||||
var sell_mode := shop_tabs[5] as Button
|
||||
assert(sell_mode != null and sell_mode.text == "Sell Fish")
|
||||
await _activate_pointer_control(art_supplies_tab, ui_viewport)
|
||||
await process_frame
|
||||
var stock_sections: Array[String] = []
|
||||
for child: Node in shop.get_node("%SuppliesList").get_children():
|
||||
if child is Label:
|
||||
stock_sections.append((child as Label).text)
|
||||
assert(stock_sections == ["supplies", "art kit", "markers", "brushes", "grids"])
|
||||
await _activate_focused_button(sell_mode, ui_viewport)
|
||||
assert(stock_sections == ["art kit", "markers", "brushes", "grids"])
|
||||
await _activate_pointer_control(sell_mode, ui_viewport)
|
||||
await process_frame
|
||||
assert(shop.visible and not player_menu.visible)
|
||||
assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales"))
|
||||
assert((shop.get_node("%ShopCoolerPage") as Control).visible)
|
||||
assert(not (shop.get_node("%ShopPanel") as Control).visible)
|
||||
assert((shop.get_node("%ShopPanel") as Control).visible)
|
||||
assert(not (shop.get_node("ShopPanel/Margin/Layout/Body") as Control).visible)
|
||||
assert(not (shop.get_node("%Feedback") as Control).visible)
|
||||
var mounted_cooler := player_menu.get("_cooler_page") as Control
|
||||
assert(mounted_cooler != null and mounted_cooler.visible)
|
||||
assert(
|
||||
|
|
@ -577,6 +599,11 @@ func _test_fishing_shop_sale_ui(
|
|||
var water_surface := player_menu.get("_cooler_water_surface") as ColorRect
|
||||
assert(cooler_outer_wall != null and cooler_outer_wall.visible)
|
||||
assert(water_surface.visible and water_surface.material is ShaderMaterial)
|
||||
var cooler_sort_option := player_menu.get("_cooler_sort_option") as Control
|
||||
await _activate_pointer_control(cooler_sort_option, ui_viewport)
|
||||
var cooler_choice_panel := cooler_sort_option.get("_choice_panel") as Control
|
||||
assert(cooler_choice_panel.visible)
|
||||
cooler_sort_option.call("close_choices")
|
||||
assert(
|
||||
StringName(
|
||||
(player_menu.get("_sale_buyer_override") as FishBuyerProfile).id
|
||||
|
|
@ -587,10 +614,10 @@ func _test_fishing_shop_sale_ui(
|
|||
var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button
|
||||
assert(fish_button != null and fish_button.visible)
|
||||
assert(reserved_button != null and reserved_button.visible)
|
||||
await _activate_focused_button(fish_button, ui_viewport)
|
||||
await _activate_pointer_control(fish_button, ui_viewport)
|
||||
var sell_button := player_menu.get("_sell_bubble") as Button
|
||||
assert(sell_button.visible and not sell_button.disabled)
|
||||
await _activate_focused_button(sell_button, ui_viewport)
|
||||
await _activate_pointer_control(sell_button, ui_viewport)
|
||||
await process_frame
|
||||
var confirmation := player_menu.get("_sale_confirmation") as Control
|
||||
var confirm_button := player_menu.get("_confirm_sale_button") as Button
|
||||
|
|
@ -600,20 +627,21 @@ func _test_fishing_shop_sale_ui(
|
|||
> cooler_outer_wall.z_index
|
||||
)
|
||||
_sale_result.clear()
|
||||
await _activate_focused_button(confirm_button, ui_viewport)
|
||||
await _activate_pointer_control(confirm_button, ui_viewport)
|
||||
await process_frame
|
||||
assert(not _sale_result.is_empty() and bool(_sale_result[1]))
|
||||
assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
|
||||
assert(player.wallet.get_balance() == balance_before + fish_catch.sale_value)
|
||||
assert(not sale_service.is_local_sale_pending())
|
||||
assert(reservations.release(reservation_id))
|
||||
await _activate_focused_button(shop_tabs[0] as Button, ui_viewport)
|
||||
await _activate_pointer_control(shop_tabs[0] as Button, ui_viewport)
|
||||
await process_frame
|
||||
assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible)
|
||||
assert(not (shop.get_node("%ShopCoolerPage") as Control).visible)
|
||||
assert(not player_menu.is_shop_cooler_mounted())
|
||||
shop.close_shop()
|
||||
await process_frame
|
||||
await shop.menu_visibility_changed
|
||||
assert(not shop.visible)
|
||||
|
||||
|
||||
func _assert_sale(
|
||||
|
|
@ -634,7 +662,9 @@ func _assert_sale(
|
|||
assert(bool(_sale_result[1]) == expected_success)
|
||||
assert(not sale_service.is_local_sale_pending())
|
||||
if expected_success:
|
||||
assert(player.wallet.get_balance() > balance_before)
|
||||
var payout: int = int(_sale_result[4])
|
||||
assert(payout >= 0)
|
||||
assert(player.wallet.get_balance() == balance_before + payout)
|
||||
for catch_id: StringName in catch_ids:
|
||||
assert(not player.inventory.contains_catch_id(catch_id))
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ extends SceneTree
|
|||
const MainScene = preload("res://main/main.tscn")
|
||||
const FishCatchType = preload("res://fish/fish_catch.gd")
|
||||
const FishDataType = preload("res://fish/fish_data.gd")
|
||||
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
|
|
@ -40,7 +39,6 @@ func _run() -> void:
|
|||
var service := main.get_node(
|
||||
"%NetworkFishShowcaseService"
|
||||
) as NetworkFishShowcaseService
|
||||
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
|
||||
assert(player != null and fish_catalog != null and service != null)
|
||||
var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill")
|
||||
assert(fish != null)
|
||||
|
|
@ -83,22 +81,18 @@ func _run() -> void:
|
|||
assert(player.hotbar.get_selected_fish_catch_id() == fish_catch.catch_id)
|
||||
assert(player.hotbar.get_selected_item_id().is_empty())
|
||||
|
||||
var press := InputEventMouseButton.new()
|
||||
press.button_index = MOUSE_BUTTON_LEFT
|
||||
press.pressed = true
|
||||
fishing_spot.call("_unhandled_input", press)
|
||||
await process_frame
|
||||
assert(service.is_local_showcase_visible())
|
||||
assert(service.get_local_showcase_catch_id() == fish_catch.catch_id)
|
||||
assert((player.get_node("%HeldFishDisplay") as Node3D).visible)
|
||||
fishing_spot.call("_unhandled_input", press)
|
||||
await _wait_for_held_fish_visibility(player, true)
|
||||
assert(service.toggle_selected_fish())
|
||||
await process_frame
|
||||
assert(not service.is_local_showcase_visible())
|
||||
assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible)
|
||||
await _wait_for_held_fish_visibility(player, false)
|
||||
var saved_weather: WorldWeatherService.Weather = (
|
||||
world_weather.get_weather()
|
||||
)
|
||||
var saved_weather_seconds: float = world_weather.get_seconds_remaining()
|
||||
var saved_time_hours: float = world_time.get_time_hours()
|
||||
|
||||
assert(save_manager.save_now())
|
||||
var save_path: String = str(save_manager.get("_save_path"))
|
||||
|
|
@ -116,7 +110,7 @@ func _run() -> void:
|
|||
== 125
|
||||
)
|
||||
assert(absf(
|
||||
float((parsed as Dictionary)["world"]["time_hours"]) - 19.75
|
||||
float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours
|
||||
) < 0.01)
|
||||
assert(
|
||||
int((parsed as Dictionary)["world"]["weather"])
|
||||
|
|
@ -149,7 +143,7 @@ func _run() -> void:
|
|||
)
|
||||
assert(save_manager.load_player_data())
|
||||
assert(player.experience.get_total_experience() == 125)
|
||||
assert(absf(world_time.get_time_hours() - 19.75) < 0.01)
|
||||
assert(absf(world_time.get_time_hours() - saved_time_hours) < 0.01)
|
||||
assert(world_weather.get_weather() == saved_weather)
|
||||
assert(absf(
|
||||
world_weather.get_seconds_remaining() - saved_weather_seconds
|
||||
|
|
@ -163,14 +157,13 @@ func _run() -> void:
|
|||
FishQuality.Tier.EXCEPTIONAL,
|
||||
)
|
||||
)
|
||||
assert(not service.is_local_showcase_visible())
|
||||
assert(service.toggle_selected_fish())
|
||||
assert(service.is_local_showcase_visible())
|
||||
await _wait_for_held_fish_visibility(player, true)
|
||||
assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null)
|
||||
await process_frame
|
||||
assert(player.hotbar.get_fish_catch_id(1).is_empty())
|
||||
assert(not service.is_local_showcase_visible())
|
||||
assert(not (player.get_node("%HeldFishDisplay") as Node3D).visible)
|
||||
await _wait_for_held_fish_visibility(player, false)
|
||||
|
||||
var valid_state: Dictionary = {
|
||||
"session_id": "session",
|
||||
|
|
@ -202,5 +195,26 @@ func _run() -> void:
|
|||
print("Fish hotbar showcase validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
func _wait_for_held_fish_visibility(
|
||||
player: Player,
|
||||
expected_visible: bool,
|
||||
) -> void:
|
||||
var held_fish_display := player.get("_held_fish_display") as Node3D
|
||||
assert(held_fish_display != null)
|
||||
var deadline_msec: int = Time.get_ticks_msec() + 2000
|
||||
while (
|
||||
held_fish_display.visible != expected_visible
|
||||
and Time.get_ticks_msec() < deadline_msec
|
||||
):
|
||||
await process_frame
|
||||
assert(
|
||||
held_fish_display.visible == expected_visible,
|
||||
"Held fish display did not become %s within the timeout."
|
||||
% expected_visible,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,40 +83,68 @@ func _validate_barrier_challenge_curve() -> void:
|
|||
var expected_maximums: Array[int] = [9, 50, 100, 200, 400]
|
||||
var previous_health: int = 0
|
||||
for quality: int in FishQualityType.TIER_COUNT:
|
||||
var expected_range := Vector2i(
|
||||
expected_minimums[quality], expected_maximums[quality]
|
||||
)
|
||||
assert(
|
||||
FishQualityType.barrier_health_range(quality)
|
||||
== expected_range
|
||||
)
|
||||
assert(
|
||||
FishQualityType.roll_barrier_health(null, quality)
|
||||
== expected_range.x
|
||||
)
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.seed = 73013 + quality
|
||||
var observed_counts: Dictionary[int, int] = {}
|
||||
var observed_minimum: int = expected_range.y
|
||||
var observed_maximum: int = expected_range.x
|
||||
var observed_total: int = 0
|
||||
var band_size: int = expected_range.y - expected_range.x + 1
|
||||
var sample_count: int = maxi(10000, band_size * 250)
|
||||
for _sample: int in sample_count:
|
||||
var rolled_health: int = FishQualityType.roll_barrier_health(
|
||||
rng, quality
|
||||
)
|
||||
assert(
|
||||
rolled_health >= expected_range.x
|
||||
and rolled_health <= expected_range.y
|
||||
)
|
||||
observed_counts[rolled_health] = (
|
||||
int(observed_counts.get(rolled_health, 0)) + 1
|
||||
)
|
||||
observed_minimum = mini(observed_minimum, rolled_health)
|
||||
observed_maximum = maxi(observed_maximum, rolled_health)
|
||||
observed_total += rolled_health
|
||||
assert(observed_minimum == expected_range.x)
|
||||
assert(observed_maximum == expected_range.y)
|
||||
for authored_health: int in range(
|
||||
expected_range.x, expected_range.y + 1
|
||||
):
|
||||
assert(observed_counts.has(authored_health))
|
||||
var observed_average: float = (
|
||||
float(observed_total) / float(sample_count)
|
||||
)
|
||||
var authored_midpoint: float = (
|
||||
float(expected_range.x + expected_range.y) / 2.0
|
||||
)
|
||||
assert(
|
||||
absf(observed_average - authored_midpoint)
|
||||
< maxf(0.15, float(band_size) * 0.01)
|
||||
)
|
||||
var health: int = FishQualityType.apply_barrier_health(8, quality)
|
||||
assert(health > previous_health)
|
||||
previous_health = health
|
||||
assert(FishQualityType.apply_barrier_health(8, -1) == 8)
|
||||
assert(FishQualityType.apply_barrier_health(0, FishQualityType.Tier.SHINY) == 4)
|
||||
var impressive_low_input: int = FishQualityType.barrier_health_for_catch(
|
||||
FishQualityType.Tier.IMPRESSIVE,
|
||||
0,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
var impressive_high_input: int = FishQualityType.barrier_health_for_catch(
|
||||
FishQualityType.Tier.IMPRESSIVE,
|
||||
4,
|
||||
1.0,
|
||||
1.0,
|
||||
)
|
||||
assert(impressive_low_input >= 50 and impressive_low_input <= 100)
|
||||
assert(impressive_high_input > impressive_low_input)
|
||||
assert(impressive_high_input <= 100)
|
||||
assert(
|
||||
FishQualityType.barrier_health_for_catch(
|
||||
FishQualityType.Tier.BORING,
|
||||
4,
|
||||
1.0,
|
||||
1.0,
|
||||
) <= 9
|
||||
FishQualityType.barrier_health_range(-1)
|
||||
== Vector2i(1, 9)
|
||||
)
|
||||
|
||||
var profile := CatchDifficultyProfile.new()
|
||||
profile.barrier_count_min = 1
|
||||
profile.barrier_count_max = 1
|
||||
profile.barrier_health_min = 8
|
||||
profile.barrier_health_max = 8
|
||||
profile.first_barrier_margin = 0.2
|
||||
profile.final_barrier_margin = 0.2
|
||||
profile.minimum_barrier_spacing = 0.1
|
||||
|
|
@ -141,12 +169,9 @@ func _validate_barrier_challenge_curve() -> void:
|
|||
assert(health <= expected_maximums[quality])
|
||||
controller.queue_free()
|
||||
|
||||
var shiny_health: int = FishQualityType.barrier_health_for_catch(
|
||||
FishQualityType.Tier.SHINY,
|
||||
4,
|
||||
1.0,
|
||||
1.0,
|
||||
)
|
||||
var shiny_health: int = FishQualityType.barrier_health_range(
|
||||
FishQualityType.Tier.SHINY
|
||||
).y
|
||||
var base_power_clicks: int = ceili(float(shiny_health) / 1.0)
|
||||
var max_power_clicks: int = ceili(
|
||||
float(shiny_health)
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ func _run_host() -> void:
|
|||
var fish_catch: FishCatch = _add_bluegill(main, player)
|
||||
assert(player.hotbar.assign_fish(1, fish_catch.catch_id))
|
||||
assert(player.hotbar.select_slot(1))
|
||||
assert(service.toggle_selected_fish())
|
||||
assert(service.is_local_showcase_visible())
|
||||
await _wait_for_held_fish_visibility(player, true)
|
||||
assert(session.set_host_open(true))
|
||||
|
||||
var remote_peer_id: int = 0
|
||||
|
|
@ -66,7 +66,9 @@ func _run_host() -> void:
|
|||
print("Fish showcase multiplayer host validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -118,8 +120,8 @@ func _run_client() -> void:
|
|||
var fish_catch: FishCatch = _add_bluegill(main, player)
|
||||
assert(player.hotbar.assign_fish(1, fish_catch.catch_id))
|
||||
assert(player.hotbar.select_slot(1))
|
||||
assert(service.toggle_selected_fish())
|
||||
assert((player.get("_held_fish_display") as Node3D).visible)
|
||||
assert(service.is_local_showcase_visible())
|
||||
await _wait_for_held_fish_visibility(player, true)
|
||||
await create_timer(2.0).timeout
|
||||
assert(service.toggle_selected_fish())
|
||||
var held_display := player.get("_held_fish_display") as Node3D
|
||||
|
|
@ -132,7 +134,9 @@ func _run_client() -> void:
|
|||
print("Fish showcase multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -166,3 +170,18 @@ func _create_initialized_main() -> Node:
|
|||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
return main
|
||||
|
||||
|
||||
func _wait_for_held_fish_visibility(
|
||||
player: Player,
|
||||
expected_visible: bool,
|
||||
) -> void:
|
||||
var held_fish_display := player.get("_held_fish_display") as Node3D
|
||||
assert(held_fish_display != null)
|
||||
var deadline_msec: int = Time.get_ticks_msec() + 2000
|
||||
while (
|
||||
held_fish_display.visible != expected_visible
|
||||
and Time.get_ticks_msec() < deadline_msec
|
||||
):
|
||||
await process_frame
|
||||
assert(held_fish_display.visible == expected_visible)
|
||||
|
|
|
|||
|
|
@ -36,8 +36,14 @@ func _run() -> void:
|
|||
assert(not session.is_open_host())
|
||||
assert(service != null and fishing_spot != null and player != null)
|
||||
assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod")
|
||||
var pond := main.get_node(
|
||||
"TestWorld/Regions/StarterIslandRegion/WaterBodies/Pond"
|
||||
) as Node3D
|
||||
var pond_region := pond.get_node("FishingRegion") as FishableWaterRegion
|
||||
assert(pond != null and pond_region != null)
|
||||
var pond_surface_y: float = pond_region.get_surface_height()
|
||||
|
||||
player.global_position = Vector3(-0.5, 3.95, 2.1)
|
||||
player.global_position = pond.global_position + Vector3(8.9, 1.44, 0.0)
|
||||
var visuals := player.get_node("Visuals") as Node3D
|
||||
visuals.rotation.y = PI * 0.5
|
||||
for _frame: int in 4:
|
||||
|
|
@ -50,8 +56,8 @@ func _run() -> void:
|
|||
fishing_spot.set("_cast_charge", 0.32)
|
||||
fishing_spot.call("_update_cast_charge", 0.0)
|
||||
var aimed_target: Vector3 = fishing_spot.get("_cast_target")
|
||||
assert(aimed_target.x < -1.35)
|
||||
assert(is_equal_approx(aimed_target.y, 2.51))
|
||||
assert(aimed_target.x < player.global_position.x - 0.85)
|
||||
assert(is_equal_approx(aimed_target.y, pond_surface_y))
|
||||
assert(fishing_spot.is_target_fishable(aimed_target))
|
||||
fishing_spot.call("_confirm_cast")
|
||||
assert(fishing_spot.state == FishingSpotType.FishingState.CASTING)
|
||||
|
|
@ -68,7 +74,7 @@ func _run() -> void:
|
|||
var attempts: Dictionary = service.get("_attempts")
|
||||
var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id())
|
||||
assert(attempt != null)
|
||||
assert(is_equal_approx(attempt.target.y, 2.51))
|
||||
assert(is_equal_approx(attempt.target.y, pond_surface_y))
|
||||
assert(attempt.bobber_position.is_equal_approx(attempt.target))
|
||||
|
||||
fishing_spot.set("_withdrawal_input_held", true)
|
||||
|
|
@ -98,7 +104,7 @@ func _run() -> void:
|
|||
assert(not bobber.visible)
|
||||
|
||||
# A private host rolls and retains the authoritative catch before the fight
|
||||
# so its quality can scale the same barriers clients receive in snapshots.
|
||||
# so its quality selects the barrier band clients receive in snapshots.
|
||||
fishing_spot.call("_begin_aiming", player)
|
||||
fishing_spot.set("_cast_charge", 0.32)
|
||||
fishing_spot.call("_update_cast_charge", 0.0)
|
||||
|
|
@ -124,29 +130,40 @@ func _run() -> void:
|
|||
fish,
|
||||
)
|
||||
assert(fish_catch != null and fish_catch.is_valid())
|
||||
var baseline_controller := CatchController.new()
|
||||
root.add_child(baseline_controller)
|
||||
baseline_controller.start_authoritative_encounter(
|
||||
var reference_controller := CatchController.new()
|
||||
root.add_child(reference_controller)
|
||||
reference_controller.start_authoritative_encounter(
|
||||
fish.catch_profile,
|
||||
attempt.reel_speed,
|
||||
attempt.barrier_damage,
|
||||
attempt.encounter_seed,
|
||||
FishQuality.Tier.BORING,
|
||||
fish_catch.quality,
|
||||
)
|
||||
var quality_barriers: Array = attempt.controller.get("_barriers")
|
||||
var baseline_barriers: Array = baseline_controller.get("_barriers")
|
||||
assert(quality_barriers.size() == baseline_barriers.size())
|
||||
var reference_barriers: Array = reference_controller.get("_barriers")
|
||||
assert(quality_barriers.size() == reference_barriers.size())
|
||||
for barrier_index: int in quality_barriers.size():
|
||||
var quality_barrier: RefCounted = quality_barriers[barrier_index]
|
||||
var baseline_barrier: RefCounted = baseline_barriers[barrier_index]
|
||||
var reference_barrier: RefCounted = reference_barriers[barrier_index]
|
||||
assert(
|
||||
int(quality_barrier.get("maximum_health"))
|
||||
== FishQuality.apply_barrier_health(
|
||||
int(baseline_barrier.get("maximum_health")),
|
||||
fish_catch.quality,
|
||||
is_equal_approx(
|
||||
float(quality_barrier.get("position")),
|
||||
float(reference_barrier.get("position")),
|
||||
)
|
||||
)
|
||||
baseline_controller.queue_free()
|
||||
assert(
|
||||
int(quality_barrier.get("maximum_health"))
|
||||
== int(reference_barrier.get("maximum_health"))
|
||||
)
|
||||
assert(
|
||||
int(quality_barrier.get("maximum_health"))
|
||||
>= FishQuality.BARRIER_HEALTH_MINIMUMS[fish_catch.quality]
|
||||
)
|
||||
assert(
|
||||
int(quality_barrier.get("maximum_health"))
|
||||
<= FishQuality.BARRIER_HEALTH_MAXIMUMS[fish_catch.quality]
|
||||
)
|
||||
reference_controller.queue_free()
|
||||
service.call("_cancel_attempt", session.get_local_peer_id(), "")
|
||||
await process_frame
|
||||
assert(not service.has_local_attempt())
|
||||
|
|
@ -154,5 +171,7 @@ func _run() -> void:
|
|||
print("Fishing authority validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
|
|
|||
|
|
@ -150,7 +150,9 @@ func _run_host() -> void:
|
|||
print("Fishing multiplayer host validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -325,7 +327,9 @@ func _run_client() -> void:
|
|||
print("Fishing multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -266,11 +266,47 @@ func _validate_bite_wait_distribution() -> void:
|
|||
if wait_seconds >= FishingSpotType.BITE_LONG_MAX_SECONDS:
|
||||
very_long_count += 1
|
||||
var average_wait_seconds: float = total_wait_seconds / 10000.0
|
||||
assert(quick_count >= 2300 and quick_count <= 2700)
|
||||
var quick_ratio: float = float(quick_count) / 10000.0
|
||||
var very_long_ratio: float = float(very_long_count) / 10000.0
|
||||
var very_long_probability: float = 1.0 - (
|
||||
FishingSpotType.BITE_QUICK_PROBABILITY
|
||||
+ FishingSpotType.BITE_TYPICAL_PROBABILITY
|
||||
+ FishingSpotType.BITE_LONG_PROBABILITY
|
||||
)
|
||||
var expected_average_wait_seconds: float = (
|
||||
FishingSpotType.BITE_QUICK_PROBABILITY
|
||||
* (
|
||||
FishingSpotType.BITE_QUICK_MIN_SECONDS
|
||||
+ FishingSpotType.BITE_QUICK_MAX_SECONDS
|
||||
)
|
||||
* 0.5
|
||||
+ FishingSpotType.BITE_TYPICAL_PROBABILITY
|
||||
* (
|
||||
FishingSpotType.BITE_QUICK_MAX_SECONDS
|
||||
+ FishingSpotType.BITE_TYPICAL_MAX_SECONDS
|
||||
)
|
||||
* 0.5
|
||||
+ FishingSpotType.BITE_LONG_PROBABILITY
|
||||
* (
|
||||
FishingSpotType.BITE_TYPICAL_MAX_SECONDS
|
||||
+ FishingSpotType.BITE_LONG_MAX_SECONDS
|
||||
)
|
||||
* 0.5
|
||||
+ very_long_probability
|
||||
* (
|
||||
FishingSpotType.BITE_LONG_MAX_SECONDS
|
||||
+ FishingSpotType.BITE_MAX_SECONDS
|
||||
)
|
||||
* 0.5
|
||||
)
|
||||
assert(absf(
|
||||
quick_ratio - FishingSpotType.BITE_QUICK_PROBABILITY
|
||||
) <= 0.02)
|
||||
assert(typical_or_long_count > quick_count)
|
||||
assert(very_long_count >= 100 and very_long_count <= 300)
|
||||
assert(average_wait_seconds >= 45.0)
|
||||
assert(average_wait_seconds <= 49.0)
|
||||
assert(absf(very_long_ratio - very_long_probability) <= 0.005)
|
||||
assert(absf(
|
||||
average_wait_seconds - expected_average_wait_seconds
|
||||
) <= 1.0)
|
||||
fishing_spot.queue_free()
|
||||
await process_frame
|
||||
|
||||
|
|
@ -305,11 +341,16 @@ func _validate_remote_presentation() -> void:
|
|||
fish_catch.sale_value = 1
|
||||
assert(fish_catch.is_valid())
|
||||
presentation.play_return(fish_catch)
|
||||
await create_timer(0.6).timeout
|
||||
var catch_display := player.find_child(
|
||||
"CatchDisplay", true, false
|
||||
) as Node3D
|
||||
assert(catch_display != null)
|
||||
var showcase_deadline_msec: int = Time.get_ticks_msec() + 2000
|
||||
while (
|
||||
not catch_display.visible
|
||||
and Time.get_ticks_msec() < showcase_deadline_msec
|
||||
):
|
||||
await process_frame
|
||||
assert(catch_display.visible)
|
||||
await presentation.return_completed
|
||||
assert(not catch_display.visible)
|
||||
|
|
|
|||
|
|
@ -62,7 +62,9 @@ func _run() -> void:
|
|||
await _capture_inventory_pages(player_menu)
|
||||
print("Inventory notepad artwork validation: PASS")
|
||||
presentation_stage.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 10:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ func _run_host() -> void:
|
|||
print("Job multiplayer host validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -93,7 +95,9 @@ func _run_client() -> void:
|
|||
print("Job multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -187,8 +187,10 @@ func _run() -> void:
|
|||
assert(int(save_data.get("save_version", -1)) == 7)
|
||||
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
|
||||
|
||||
session.disconnect_session("Job system validation complete.")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
print("Job system validation: PASS")
|
||||
quit()
|
||||
|
||||
|
|
|
|||
|
|
@ -79,10 +79,15 @@ func _run() -> void:
|
|||
"_show_section_immediate", PlayerMenu.Section.PROFILE
|
||||
)
|
||||
await process_frame
|
||||
assert(not bool(
|
||||
assert(bool(
|
||||
player_menu.call("_handle_direct_page_shortcut", shortcut)
|
||||
))
|
||||
await create_timer(0.5).timeout
|
||||
assert(player_menu.visible)
|
||||
assert(logbook.visible)
|
||||
assert(
|
||||
player_menu.get("_current_section") == PlayerMenu.Section.LOGBOOK
|
||||
)
|
||||
player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false)
|
||||
|
||||
print(
|
||||
|
|
@ -90,7 +95,8 @@ func _run() -> void:
|
|||
% [root.size.x, root.size.y]
|
||||
)
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ func _validate_page() -> void:
|
|||
page.activate()
|
||||
await process_frame
|
||||
assert(page.get("_category") == WaterType.Type.FRESH_WATER)
|
||||
assert((page.get("_catalog_grid") as GridContainer).columns == 5)
|
||||
assert((page.get("_catalog_grid") as GridContainer).columns == 4)
|
||||
var initial_detail_body := page.get("_detail_body") as VBoxContainer
|
||||
assert(not initial_detail_body.get_parent() is ScrollContainer)
|
||||
assert(
|
||||
|
|
@ -209,7 +209,7 @@ func _validate_page() -> void:
|
|||
assert(portrait.source_texture == fish.display_texture)
|
||||
assert(
|
||||
portrait.custom_minimum_size
|
||||
== LogbookPortraitType.ENTRY_FRAME_SIZE
|
||||
== LogbookPage.CATALOG_PORTRAIT_SIZE
|
||||
)
|
||||
assert(
|
||||
portrait.expand_mode
|
||||
|
|
|
|||
232
tests/operator_multiplayer_validation.gd
Normal file
232
tests/operator_multiplayer_validation.gd
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
extends SceneTree
|
||||
|
||||
const MainScene = preload("res://main/main.tscn")
|
||||
const TEST_PORT: int = 18140
|
||||
const FIRST_BANNED_FINGERPRINT: String = (
|
||||
"1111111111111111111111111111111111111111111111111111111111111111"
|
||||
)
|
||||
const SECOND_BANNED_FINGERPRINT: String = (
|
||||
"2222222222222222222222222222222222222222222222222222222222222222"
|
||||
)
|
||||
|
||||
var _moderation_results: Array[Dictionary] = []
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
var arguments: PackedStringArray = OS.get_cmdline_user_args()
|
||||
if arguments.has("host"):
|
||||
await _run_host()
|
||||
return
|
||||
if arguments.has("client"):
|
||||
await _run_client()
|
||||
return
|
||||
push_error("Operator multiplayer validation needs host or client mode.")
|
||||
quit(1)
|
||||
|
||||
|
||||
func _run_host() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var service := (
|
||||
main.get_node("%NetworkPlayerListService")
|
||||
as NetworkPlayerListService
|
||||
)
|
||||
var bans := main.get_node("%HostBanStore") as HostBanStore
|
||||
assert(session.start_private_host(TEST_PORT))
|
||||
assert(session.set_host_open(true))
|
||||
|
||||
var remote_peer_id: int = await _wait_for_remote_peer(session)
|
||||
assert(remote_peer_id > 1)
|
||||
var remote_record: PeerRegistry.PeerRecord = session.get_peer_record(
|
||||
remote_peer_id
|
||||
)
|
||||
assert(remote_record != null and remote_record.identity_authenticated)
|
||||
var host_fingerprint: String = session.get_host_identity_fingerprint()
|
||||
assert(bans.ban(
|
||||
host_fingerprint, FIRST_BANNED_FINGERPRINT, "First Banned Player"
|
||||
))
|
||||
|
||||
var entry: PlayerListEntry = _entry_for_peer(service, remote_peer_id)
|
||||
assert(entry != null and entry.can_manage_operator)
|
||||
assert(service.set_operator(
|
||||
remote_peer_id,
|
||||
remote_record.identity_fingerprint,
|
||||
true,
|
||||
entry.revision,
|
||||
))
|
||||
assert(session.is_peer_operator(remote_peer_id))
|
||||
var players_page := main.find_child(
|
||||
"PlayersPage", true, false
|
||||
) as PlayersPage
|
||||
assert(players_page != null)
|
||||
players_page.call("_refresh")
|
||||
assert(_has_button_text(players_page, "deop"))
|
||||
|
||||
var unban_deadline: int = Time.get_ticks_msec() + 12000
|
||||
while (
|
||||
Time.get_ticks_msec() < unban_deadline
|
||||
and bans.is_banned(host_fingerprint, FIRST_BANNED_FINGERPRINT)
|
||||
):
|
||||
await process_frame
|
||||
assert(not bans.is_banned(
|
||||
host_fingerprint, FIRST_BANNED_FINGERPRINT
|
||||
))
|
||||
assert(bans.ban(
|
||||
host_fingerprint, SECOND_BANNED_FINGERPRINT, "Second Banned Player"
|
||||
))
|
||||
|
||||
entry = _entry_for_peer(service, remote_peer_id)
|
||||
assert(entry != null and entry.is_operator)
|
||||
assert(service.set_operator(
|
||||
remote_peer_id,
|
||||
remote_record.identity_fingerprint,
|
||||
false,
|
||||
entry.revision,
|
||||
))
|
||||
assert(not session.is_peer_operator(remote_peer_id))
|
||||
|
||||
var disconnect_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while (
|
||||
Time.get_ticks_msec() < disconnect_deadline
|
||||
and session.is_authenticated_peer(remote_peer_id)
|
||||
):
|
||||
await process_frame
|
||||
assert(not session.is_authenticated_peer(remote_peer_id))
|
||||
assert(bans.is_banned(host_fingerprint, SECOND_BANNED_FINGERPRINT))
|
||||
print("Operator multiplayer host validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
func _run_client() -> void:
|
||||
var main: Node = await _create_initialized_main()
|
||||
main.call(
|
||||
"_on_title_join_game_requested", "127.0.0.1:%d" % TEST_PORT
|
||||
)
|
||||
var session := main.get_node("%NetworkSession") as NetworkSession
|
||||
var service := (
|
||||
main.get_node("%NetworkPlayerListService")
|
||||
as NetworkPlayerListService
|
||||
)
|
||||
service.moderation_finished.connect(
|
||||
func(success: bool, message: String) -> void:
|
||||
_moderation_results.append({
|
||||
"success": success,
|
||||
"message": message,
|
||||
})
|
||||
)
|
||||
var join_deadline: int = Time.get_ticks_msec() + 20000
|
||||
while Time.get_ticks_msec() < join_deadline:
|
||||
await process_frame
|
||||
if session.state == NetworkSession.State.VERIFYING_SERVER_IDENTITY:
|
||||
main.call("_confirm_server_trust")
|
||||
if session.is_joined_client():
|
||||
break
|
||||
assert(session.is_joined_client())
|
||||
|
||||
var operator_deadline: int = Time.get_ticks_msec() + 10000
|
||||
while Time.get_ticks_msec() < operator_deadline and not session.is_local_operator():
|
||||
await process_frame
|
||||
assert(session.is_local_operator())
|
||||
assert(service.is_local_moderator())
|
||||
var players_page := main.find_child(
|
||||
"PlayersPage", true, false
|
||||
) as PlayersPage
|
||||
assert(players_page != null)
|
||||
players_page.call("_refresh")
|
||||
var tabs := players_page.get("_tabs") as HBoxContainer
|
||||
assert((tabs.get_child(2) as Button).visible)
|
||||
players_page.call("_select_tab", 2)
|
||||
assert(int(players_page.get("_current_tab")) == 2)
|
||||
var local_entry: PlayerListEntry = _entry_for_peer(
|
||||
service, session.get_local_peer_id()
|
||||
)
|
||||
assert(local_entry != null and local_entry.is_operator)
|
||||
|
||||
var ban_deadline: int = Time.get_ticks_msec() + 10000
|
||||
while (
|
||||
Time.get_ticks_msec() < ban_deadline
|
||||
and not _has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT)
|
||||
):
|
||||
await process_frame
|
||||
assert(_has_ban(service.get_bans(), FIRST_BANNED_FINGERPRINT))
|
||||
assert(service.unban(FIRST_BANNED_FINGERPRINT))
|
||||
var result_deadline: int = Time.get_ticks_msec() + 8000
|
||||
while Time.get_ticks_msec() < result_deadline and _moderation_results.is_empty():
|
||||
await process_frame
|
||||
assert(not _moderation_results.is_empty())
|
||||
assert(bool(_moderation_results.back().get("success", false)))
|
||||
|
||||
var deop_deadline: int = Time.get_ticks_msec() + 10000
|
||||
while Time.get_ticks_msec() < deop_deadline and session.is_local_operator():
|
||||
await process_frame
|
||||
assert(not session.is_local_operator())
|
||||
assert(not service.is_local_moderator())
|
||||
assert(not (tabs.get_child(2) as Button).visible)
|
||||
assert(int(players_page.get("_current_tab")) == 0)
|
||||
service.request_unban.rpc_id(1, SECOND_BANNED_FINGERPRINT)
|
||||
await create_timer(1.0).timeout
|
||||
print("Operator multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
func _create_initialized_main() -> Node:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var main: Node = MainScene.instantiate()
|
||||
root.add_child(main)
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
if not bool(main.get("_application_initialized")):
|
||||
main.call("_activate_selected_data_path", "", true)
|
||||
for _frame: int in 8:
|
||||
await process_frame
|
||||
assert(bool(main.get("_application_initialized")))
|
||||
return main
|
||||
|
||||
|
||||
func _wait_for_remote_peer(session: NetworkSession) -> int:
|
||||
var deadline: int = Time.get_ticks_msec() + 20000
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
await process_frame
|
||||
for peer_id: int in session.get_authenticated_peer_ids():
|
||||
if peer_id != session.get_local_peer_id():
|
||||
return peer_id
|
||||
return 0
|
||||
|
||||
|
||||
func _entry_for_peer(
|
||||
service: NetworkPlayerListService,
|
||||
peer_id: int,
|
||||
) -> PlayerListEntry:
|
||||
for entry: PlayerListEntry in service.get_entries():
|
||||
if entry.peer_id == peer_id:
|
||||
return entry
|
||||
return null
|
||||
|
||||
|
||||
func _has_ban(records: Array[Dictionary], fingerprint: String) -> bool:
|
||||
for record: Dictionary in records:
|
||||
if str(record.get("target_fingerprint", "")) == fingerprint:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _has_button_text(root_node: Node, text: String) -> bool:
|
||||
for node: Node in root_node.find_children("*", "Button", true, false):
|
||||
var button := node as Button
|
||||
if button != null and button.text == text:
|
||||
return true
|
||||
return false
|
||||
1
tests/operator_multiplayer_validation.gd.uid
Normal file
1
tests/operator_multiplayer_validation.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://cv17xsf20ue5t
|
||||
|
|
@ -12,6 +12,14 @@ func _run() -> void:
|
|||
root.add_child(game_ui)
|
||||
await process_frame
|
||||
game_ui.set("_gameplay_ui_enabled", true)
|
||||
var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer
|
||||
var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer
|
||||
var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label
|
||||
var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label
|
||||
assert(panel != null and bubble != null)
|
||||
assert(not panel.visible and not bubble.visible)
|
||||
game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true)
|
||||
await process_frame
|
||||
game_ui.call(
|
||||
"_on_experience_awarded",
|
||||
50,
|
||||
|
|
@ -20,13 +28,6 @@ func _run() -> void:
|
|||
1,
|
||||
1,
|
||||
)
|
||||
var panel := game_ui.get_node("%ExperienceProgressPanel") as PanelContainer
|
||||
var bubble := game_ui.get_node("%ExperienceBubble") as PanelContainer
|
||||
var bubble_label := game_ui.get_node("%ExperienceBubbleLabel") as Label
|
||||
var award_label := game_ui.get_node("%ExperienceAwardLabel") as Label
|
||||
assert(panel != null and bubble != null)
|
||||
assert(not panel.visible and not bubble.visible)
|
||||
game_ui.call("_on_showcase_changed", "bluegill", "common", 1.0, 0, true)
|
||||
await process_frame
|
||||
assert(not panel.visible and not bubble.visible)
|
||||
game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false)
|
||||
|
|
@ -43,5 +44,8 @@ func _run() -> void:
|
|||
await create_timer(1.2).timeout
|
||||
assert(not panel.visible and not bubble.visible)
|
||||
game_ui.queue_free()
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
print("Player experience UI validation: PASS")
|
||||
quit()
|
||||
|
|
|
|||
|
|
@ -114,7 +114,9 @@ func _run_host() -> void:
|
|||
print("Surface drawing multiplayer host validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -207,7 +209,9 @@ func _run_client() -> void:
|
|||
print("Surface drawing multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,9 @@ func _run() -> void:
|
|||
print("Surface drawing runtime validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ func _run() -> void:
|
|||
await _validate_blocked_author_visibility()
|
||||
_validate_peer_capability_tracking()
|
||||
print("Surface drawing validation: PASS")
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -118,6 +120,7 @@ func _validate_art_unlocks() -> void:
|
|||
assert(unlocks.is_brush_size_unlocked(4))
|
||||
assert(unlocks.is_grid_size_unlocked(128))
|
||||
assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1))
|
||||
unlocks.free()
|
||||
|
||||
|
||||
func _validate_grid_snapping() -> void:
|
||||
|
|
@ -265,6 +268,7 @@ func _validate_blocked_author_visibility() -> void:
|
|||
})
|
||||
var world := Node3D.new()
|
||||
root.add_child(world)
|
||||
world.add_child(relationships)
|
||||
var canvas := SurfaceDrawingCanvas.new()
|
||||
world.add_child(canvas)
|
||||
var state: Dictionary = {
|
||||
|
|
|
|||
|
|
@ -205,6 +205,8 @@ func _run() -> void:
|
|||
|
||||
presenter.queue_free()
|
||||
root.size = original_size
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
print("UI scaling runtime validation: PASS")
|
||||
quit()
|
||||
|
|
|
|||
|
|
@ -83,7 +83,9 @@ func _run_host() -> void:
|
|||
print("World time multiplayer host validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
@ -195,7 +197,9 @@ func _run_client() -> void:
|
|||
print("World time multiplayer client validation: PASS")
|
||||
session.disconnect_session("")
|
||||
main.queue_free()
|
||||
await process_frame
|
||||
for _frame: int in 4:
|
||||
await process_frame
|
||||
await create_timer(0.1).timeout
|
||||
quit()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ func _input(event: InputEvent) -> void:
|
|||
CONTROLLER_MOTION_THRESHOLD
|
||||
):
|
||||
_set_controller_active(true)
|
||||
elif event is InputEventMouseMotion:
|
||||
_set_controller_active(false)
|
||||
elif event is InputEventMouseButton:
|
||||
if (event as InputEventMouseButton).pressed:
|
||||
_set_controller_active(false)
|
||||
|
|
|
|||
|
|
@ -3,11 +3,28 @@ extends Node
|
|||
|
||||
const CONTROLLER_AXIS_THRESHOLD: float = 0.35
|
||||
const SEMANTIC_MATCH_BONUS: float = 1000000.0
|
||||
const KEYBOARD_NAVIGATION_ACTIONS: Array[StringName] = [
|
||||
&"ui_accept",
|
||||
&"ui_cancel",
|
||||
&"ui_up",
|
||||
&"ui_down",
|
||||
&"ui_left",
|
||||
&"ui_right",
|
||||
&"ui_focus_next",
|
||||
&"ui_focus_prev",
|
||||
&"ui_page_up",
|
||||
&"ui_page_down",
|
||||
&"ui_home",
|
||||
&"ui_end",
|
||||
]
|
||||
|
||||
var _controller_active: bool = false
|
||||
var _focus_navigation_active: bool = false
|
||||
var _last_focus_center: Vector2 = Vector2.ZERO
|
||||
var _last_focus_key: String = ""
|
||||
var _scope_chain: Array[WeakRef] = []
|
||||
var _pending_focus: WeakRef
|
||||
var _pointer_button_down: bool = false
|
||||
var _recovery_generation: int = 0
|
||||
|
||||
|
||||
|
|
@ -27,23 +44,51 @@ func _input(event: InputEvent) -> void:
|
|||
if event is InputEventJoypadButton:
|
||||
var button_event := event as InputEventJoypadButton
|
||||
if button_event.pressed:
|
||||
_pointer_button_down = false
|
||||
_controller_active = true
|
||||
_focus_navigation_active = true
|
||||
if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER:
|
||||
_recovery_generation += 1
|
||||
_scope_chain.clear()
|
||||
_pending_focus = null
|
||||
return
|
||||
_request_pending_focus()
|
||||
return
|
||||
if event is InputEventJoypadMotion:
|
||||
if absf((event as InputEventJoypadMotion).axis_value) >= (
|
||||
CONTROLLER_AXIS_THRESHOLD
|
||||
):
|
||||
_pointer_button_down = false
|
||||
_controller_active = true
|
||||
_focus_navigation_active = true
|
||||
_request_pending_focus()
|
||||
return
|
||||
if event is InputEventMouseMotion:
|
||||
_pointer_button_down = (
|
||||
(event as InputEventMouseMotion).button_mask != 0
|
||||
)
|
||||
_leave_focus_navigation(not _pointer_button_down)
|
||||
return
|
||||
if event is InputEventMouseButton:
|
||||
if (event as InputEventMouseButton).pressed:
|
||||
_controller_active = false
|
||||
var mouse_button := event as InputEventMouseButton
|
||||
if mouse_button.button_index in [
|
||||
MOUSE_BUTTON_LEFT,
|
||||
MOUSE_BUTTON_RIGHT,
|
||||
MOUSE_BUTTON_MIDDLE,
|
||||
]:
|
||||
_pointer_button_down = mouse_button.pressed
|
||||
_leave_focus_navigation(false)
|
||||
if not mouse_button.pressed:
|
||||
_release_current_pointer_focus.call_deferred()
|
||||
return
|
||||
if event is InputEventKey and (event as InputEventKey).pressed:
|
||||
_pointer_button_down = false
|
||||
_controller_active = false
|
||||
if _is_keyboard_navigation_event(event):
|
||||
_focus_navigation_active = true
|
||||
_request_pending_focus()
|
||||
else:
|
||||
_leave_focus_navigation()
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
|
|
@ -58,8 +103,16 @@ func _process(_delta: float) -> void:
|
|||
func _on_gui_focus_changed(control: Control) -> void:
|
||||
_recovery_generation += 1
|
||||
if control != null:
|
||||
if _controller_active and control is BaseButton:
|
||||
_remember_focus(control)
|
||||
if _focus_navigation_active:
|
||||
_pending_focus = null
|
||||
if _controller_active and control is BaseButton:
|
||||
_remember_focus(control)
|
||||
return
|
||||
if _keeps_pointer_focus(control):
|
||||
return
|
||||
if _is_focusable(control):
|
||||
_pending_focus = weakref(control)
|
||||
_release_focus_if_inactive.call_deferred(control)
|
||||
return
|
||||
if not _controller_active or _scope_chain.is_empty():
|
||||
return
|
||||
|
|
@ -92,13 +145,18 @@ func _recover_focus(generation: int) -> void:
|
|||
scope == null
|
||||
or not is_instance_valid(scope)
|
||||
or not scope.is_inside_tree()
|
||||
or not scope.is_visible_in_tree()
|
||||
):
|
||||
continue
|
||||
if not scope.is_visible_in_tree():
|
||||
_scope_chain.clear()
|
||||
return
|
||||
var replacement := _best_replacement_in(scope)
|
||||
if replacement != null:
|
||||
replacement.grab_focus()
|
||||
return
|
||||
else:
|
||||
_scope_chain.clear()
|
||||
return
|
||||
_scope_chain.clear()
|
||||
|
||||
|
||||
func _best_replacement_in(scope: Control) -> Control:
|
||||
|
|
@ -124,7 +182,7 @@ func _is_focusable(control: Control) -> bool:
|
|||
control == null
|
||||
or not control.is_inside_tree()
|
||||
or not control.is_visible_in_tree()
|
||||
or control.focus_mode == Control.FOCUS_NONE
|
||||
or control.focus_mode not in [Control.FOCUS_CLICK, Control.FOCUS_ALL]
|
||||
):
|
||||
return false
|
||||
var button := control as BaseButton
|
||||
|
|
@ -140,3 +198,80 @@ func _semantic_key(control: Control) -> String:
|
|||
tooltip = tooltip.trim_suffix(" (show variants)")
|
||||
tooltip = tooltip.trim_suffix(" (hide variants)")
|
||||
return "%s|%s|%s" % [control.get_class(), label, tooltip]
|
||||
|
||||
|
||||
func _leave_focus_navigation(release_focus: bool = true) -> void:
|
||||
_controller_active = false
|
||||
_focus_navigation_active = false
|
||||
_recovery_generation += 1
|
||||
_scope_chain.clear()
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner == null or _keeps_pointer_focus(focus_owner):
|
||||
return
|
||||
if _is_focusable(focus_owner):
|
||||
_pending_focus = weakref(focus_owner)
|
||||
if release_focus:
|
||||
_release_focus_if_inactive.call_deferred(focus_owner)
|
||||
|
||||
|
||||
func _release_focus_if_inactive(control: Control) -> void:
|
||||
if (
|
||||
_focus_navigation_active
|
||||
or _pointer_button_down
|
||||
or control == null
|
||||
or not is_instance_valid(control)
|
||||
or get_viewport().gui_get_focus_owner() != control
|
||||
or _keeps_pointer_focus(control)
|
||||
):
|
||||
return
|
||||
get_viewport().gui_release_focus()
|
||||
|
||||
|
||||
func _release_current_pointer_focus() -> void:
|
||||
var focus_owner: Control = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner != null:
|
||||
_release_focus_if_inactive(focus_owner)
|
||||
|
||||
|
||||
func _request_pending_focus() -> void:
|
||||
if (
|
||||
not _focus_navigation_active
|
||||
or _pending_focus == null
|
||||
or get_viewport().gui_get_focus_owner() != null
|
||||
):
|
||||
return
|
||||
_restore_pending_focus.call_deferred(_recovery_generation)
|
||||
|
||||
|
||||
func _restore_pending_focus(generation: int) -> void:
|
||||
if (
|
||||
generation != _recovery_generation
|
||||
or not _focus_navigation_active
|
||||
or _pending_focus == null
|
||||
or get_viewport().gui_get_focus_owner() != null
|
||||
):
|
||||
return
|
||||
var target := _pending_focus.get_ref() as Control
|
||||
if not _is_focusable(target):
|
||||
_pending_focus = null
|
||||
return
|
||||
_pending_focus = null
|
||||
target.grab_focus()
|
||||
|
||||
|
||||
func _is_keyboard_navigation_event(event: InputEvent) -> bool:
|
||||
if event.is_action_pressed(&"open_backpack"):
|
||||
return false
|
||||
if (
|
||||
event.is_action_pressed(&"ui_cancel")
|
||||
and get_viewport().gui_get_focus_owner() == null
|
||||
):
|
||||
return false
|
||||
for action: StringName in KEYBOARD_NAVIGATION_ACTIONS:
|
||||
if event.is_action_pressed(action):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _keeps_pointer_focus(control: Control) -> bool:
|
||||
return control is LineEdit or control is TextEdit
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ const SUPPLY_PRICE_HORIZONTAL_PADDING: float = 12.0
|
|||
|
||||
@onready var _wallet_label: Label = %WalletLabel
|
||||
@onready var _shop_panel: PanelContainer = %ShopPanel
|
||||
@onready var _shop_panel_margin: MarginContainer = $ShopPanel/Margin
|
||||
@onready var _shop_panel_layout: VBoxContainer = $ShopPanel/Margin/Layout
|
||||
@onready var _shop_cooler_page: Control = %ShopCoolerPage
|
||||
@onready var _shop_cooler_mount: Control = %ShopCoolerMount
|
||||
@onready var _shop_body: HBoxContainer = $ShopPanel/Margin/Layout/Body
|
||||
|
|
@ -367,6 +369,7 @@ func get_shop_cooler_mount() -> Control:
|
|||
func activate_shop_cooler_page() -> void:
|
||||
_cooler_page_active = true
|
||||
_shop_panel.show()
|
||||
_set_shop_panel_background_pointer_blocking(false)
|
||||
_shop_body.hide()
|
||||
_feedback.hide()
|
||||
_shop_cooler_page.show()
|
||||
|
|
@ -377,6 +380,7 @@ func deactivate_shop_cooler_page() -> void:
|
|||
_shop_tab_bar.show()
|
||||
_cooler_page_active = false
|
||||
_cooler_modal_open = false
|
||||
_set_shop_panel_background_pointer_blocking(true)
|
||||
for tab: OrganizerTab in _shop_tabs:
|
||||
tab.disabled = false
|
||||
_shop_cooler_page.hide()
|
||||
|
|
@ -385,6 +389,17 @@ func deactivate_shop_cooler_page() -> void:
|
|||
_feedback.show()
|
||||
|
||||
|
||||
func _set_shop_panel_background_pointer_blocking(blocking: bool) -> void:
|
||||
var mouse_filter := (
|
||||
Control.MOUSE_FILTER_STOP
|
||||
if blocking
|
||||
else Control.MOUSE_FILTER_IGNORE
|
||||
)
|
||||
_shop_panel.mouse_filter = mouse_filter
|
||||
_shop_panel_margin.mouse_filter = mouse_filter
|
||||
_shop_panel_layout.mouse_filter = mouse_filter
|
||||
|
||||
|
||||
func set_shop_cooler_modal_open(is_open: bool) -> void:
|
||||
_cooler_modal_open = is_open
|
||||
for tab: OrganizerTab in _shop_tabs:
|
||||
|
|
|
|||
|
|
@ -351,7 +351,10 @@ func _open_confirmation(
|
|||
else BubbleConfirmationPageType.InitialFocus.CONFIRM
|
||||
)
|
||||
)
|
||||
_begin_root_exit(_show_confirmation)
|
||||
# Confirmation actions reject input while the root page is still leaving.
|
||||
# Finish that exit before exposing an interactive confirmation page so a
|
||||
# pointer click can never land in the overlap window and be discarded.
|
||||
_begin_root_exit(_show_confirmation, false)
|
||||
|
||||
|
||||
func _show_confirmation() -> void:
|
||||
|
|
@ -450,7 +453,10 @@ func _finish_root_entry(generation: int) -> void:
|
|||
_root_transition_active = false
|
||||
|
||||
|
||||
func _begin_root_exit(completed: Callable) -> void:
|
||||
func _begin_root_exit(
|
||||
completed: Callable,
|
||||
overlap_next_page: bool = true,
|
||||
) -> void:
|
||||
if _root_transition_active or not _root_page.visible:
|
||||
return
|
||||
_root_transition_generation += 1
|
||||
|
|
@ -458,9 +464,14 @@ func _begin_root_exit(completed: Callable) -> void:
|
|||
_emit_transition_flurry()
|
||||
var generation: int = _root_transition_generation
|
||||
_root_page.transition_out(
|
||||
_finish_root_exit.bind(generation),
|
||||
_finish_root_exit.bind(
|
||||
generation,
|
||||
Callable() if overlap_next_page else completed,
|
||||
),
|
||||
0.0
|
||||
)
|
||||
if not overlap_next_page:
|
||||
return
|
||||
await get_tree().create_timer(
|
||||
UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
|
||||
).timeout
|
||||
|
|
@ -471,10 +482,13 @@ func _begin_root_exit(completed: Callable) -> void:
|
|||
|
||||
func _finish_root_exit(
|
||||
generation: int,
|
||||
completed: Callable,
|
||||
) -> void:
|
||||
if generation != _root_transition_generation or not visible:
|
||||
return
|
||||
_root_transition_active = false
|
||||
if completed.is_valid():
|
||||
completed.call()
|
||||
|
||||
|
||||
func _finish_user_close() -> void:
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ func _set_host_toggle_state(
|
|||
|
||||
|
||||
func _select_tab(index: int) -> void:
|
||||
if index == 2 and (_service == null or not _service.is_local_host()):
|
||||
if index == 2 and (_service == null or not _service.is_local_moderator()):
|
||||
return
|
||||
_current_tab = index
|
||||
_refresh()
|
||||
|
|
@ -207,10 +207,12 @@ func _refresh() -> void:
|
|||
_count_label.text = "%d / %d connected" % [
|
||||
_service.get_connected_count(), _service.get_max_players(),
|
||||
]
|
||||
if _current_tab == 2 and not _service.is_local_moderator():
|
||||
_current_tab = 0
|
||||
for index: int in _tabs.get_child_count():
|
||||
var button := _tabs.get_child(index) as Button
|
||||
button.button_pressed = index == _current_tab
|
||||
button.visible = index != 2 or _service.is_local_host()
|
||||
button.visible = index != 2 or _service.is_local_moderator()
|
||||
_refresh_host_settings()
|
||||
for child: Node in _list.get_children():
|
||||
child.queue_free()
|
||||
|
|
@ -330,7 +332,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void:
|
|||
|
||||
|
||||
func _build_active_rows() -> void:
|
||||
if _service.is_local_host():
|
||||
if _service.is_local_moderator():
|
||||
_build_session_artwork_controls()
|
||||
var entries := _service.get_entries()
|
||||
if entries.is_empty():
|
||||
|
|
@ -339,10 +341,12 @@ func _build_active_rows() -> void:
|
|||
for entry: PlayerListEntry in entries:
|
||||
var row := _make_row()
|
||||
var identity := Label.new()
|
||||
identity.custom_minimum_size.x = 515
|
||||
identity.custom_minimum_size.x = 430
|
||||
var markers: Array[String] = []
|
||||
if entry.is_host:
|
||||
markers.append("host")
|
||||
if entry.is_operator:
|
||||
markers.append("operator")
|
||||
if entry.is_local_player:
|
||||
markers.append("You")
|
||||
identity.text = "%s%s · %s\n%s" % [
|
||||
|
|
@ -351,7 +355,7 @@ func _build_active_rows() -> void:
|
|||
entry.compact_fingerprint,
|
||||
entry.continuity_state,
|
||||
]
|
||||
identity.tooltip_text = NetworkIdentityCrypto.format_fingerprint(
|
||||
identity.tooltip_text = "Full identity fingerprint:\n%s" % (
|
||||
entry.full_fingerprint
|
||||
)
|
||||
identity.add_theme_color_override(
|
||||
|
|
@ -381,6 +385,12 @@ func _build_active_rows() -> void:
|
|||
block.pressed.connect(_confirm_block.bind(entry))
|
||||
UtilityPageStyle.apply_ocean_button(block)
|
||||
row.add_child(block)
|
||||
if entry.can_manage_operator:
|
||||
var operator := Button.new()
|
||||
operator.text = "deop" if entry.is_operator else "op"
|
||||
operator.pressed.connect(_confirm_operator.bind(entry))
|
||||
UtilityPageStyle.apply_ocean_button(operator)
|
||||
row.add_child(operator)
|
||||
var kick := Button.new()
|
||||
kick.text = "kick"
|
||||
kick.disabled = not entry.can_kick
|
||||
|
|
@ -539,6 +549,24 @@ func _confirm_ban(entry: PlayerListEntry) -> void:
|
|||
)
|
||||
|
||||
|
||||
func _confirm_operator(entry: PlayerListEntry) -> void:
|
||||
var enabled: bool = not entry.is_operator
|
||||
_confirm(
|
||||
(
|
||||
"Grant operator access to %s for this room session?"
|
||||
if enabled
|
||||
else "Remove operator access from %s?"
|
||||
) % entry.display_name,
|
||||
func() -> void:
|
||||
_service.set_operator(
|
||||
entry.peer_id,
|
||||
entry.full_fingerprint,
|
||||
enabled,
|
||||
entry.revision,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _confirm_unban(fingerprint: String) -> void:
|
||||
_confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void:
|
||||
_service.unban(fingerprint)
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ func _ready() -> void:
|
|||
%ChangeDataFolder.pressed.connect(_choose_data_folder)
|
||||
%ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player"))
|
||||
%ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player"))
|
||||
%CopyPlayerFingerprint.pressed.connect(_copy_player_fingerprint)
|
||||
%ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host"))
|
||||
%ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host"))
|
||||
for index: int in _world_options.size():
|
||||
|
|
@ -485,6 +486,16 @@ func _refresh_data_page() -> void:
|
|||
_data_root.override_active
|
||||
or (_network_session != null and _network_session.is_session_active())
|
||||
)
|
||||
var fingerprint: String = (
|
||||
_player_identity.fingerprint if _player_identity != null else ""
|
||||
)
|
||||
%CopyPlayerFingerprint.disabled = not NetworkIdentityCrypto.valid_fingerprint(
|
||||
fingerprint
|
||||
)
|
||||
%CopyPlayerFingerprint.text = (
|
||||
"Copy Player Fingerprint · %s"
|
||||
% NetworkIdentityCrypto.compact_suffix(fingerprint)
|
||||
)
|
||||
|
||||
|
||||
func _open_data_folder() -> void:
|
||||
|
|
@ -492,6 +503,17 @@ func _open_data_folder() -> void:
|
|||
_feedback.text = "Could not open the data folder."
|
||||
|
||||
|
||||
func _copy_player_fingerprint() -> void:
|
||||
var fingerprint: String = (
|
||||
_player_identity.fingerprint if _player_identity != null else ""
|
||||
)
|
||||
if not NetworkIdentityCrypto.valid_fingerprint(fingerprint):
|
||||
_feedback.text = "Player identity is unavailable."
|
||||
return
|
||||
DisplayServer.clipboard_set(fingerprint)
|
||||
_feedback.text = "Full player fingerprint copied for server operator setup."
|
||||
|
||||
|
||||
func _choose_data_folder() -> void:
|
||||
if _data_root == null or _data_root.override_active:
|
||||
_feedback.text = "The data folder is externally managed."
|
||||
|
|
|
|||
|
|
@ -913,6 +913,11 @@ layout_mode = 2
|
|||
text = "Active identity keys stay on this device.\nEncrypted backups can be stored in your synced data folder.\nDo not play the same profile on two devices at the same time."
|
||||
autowrap_mode = 2
|
||||
|
||||
[node name="CopyPlayerFingerprint" type="Button" parent="DataPage/Paper/Content"]
|
||||
unique_name_in_owner = true
|
||||
layout_mode = 2
|
||||
text = "Copy Player Fingerprint"
|
||||
|
||||
[node name="IdentityGrid" type="GridContainer" parent="DataPage/Paper/Content"]
|
||||
layout_mode = 2
|
||||
columns = 2
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ unique_name_in_owner = true
|
|||
layout_mode = 2
|
||||
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
|
||||
theme_override_font_sizes/font_size = 22
|
||||
text = "v0.6.5-alpha"
|
||||
text = "v0.6.7-alpha"
|
||||
horizontal_alignment = 1
|
||||
|
||||
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue