Merge remote-tracking branch 'origin/main'

# Conflicts:
#	export_presets.cfg
#	scripts/build_playtest.sh
This commit is contained in:
Alexander Sellite 2026-08-12 11:22:50 -04:00
commit 9729c278dc
54 changed files with 1627 additions and 261 deletions

View file

@ -46,6 +46,14 @@ derives trusted context from registered peers, authoritative regions, and
server-owned state before mutating inventory, wallet, progression, or shared server-owned state before mutating inventory, wallet, progression, or shared
world state. See [`decisions/0001-host-authority.md`](decisions/0001-host-authority.md). 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 Protocol compatibility is defined in `network/network_protocol.gd`. A visible
release version is not a reason to change the protocol number. release version is not a reason to change the protocol number.

View file

@ -9,7 +9,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,playtest/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -35,11 +35,11 @@ application/modify_resources=true
application/icon="res://art/exported/system_icons/netfishing.ico" application/icon="res://art/exported/system_icons/netfishing.ico"
application/console_wrapper_icon="" application/console_wrapper_icon=""
application/icon_interpolation=4 application/icon_interpolation=4
application/file_version="0.6.5.0" application/file_version="0.6.7.0"
application/product_version="0.6.5.0" application/product_version="0.6.7.0"
application/company_name="Woofmeow" application/company_name="Woofmeow"
application/product_name="NETfishing" 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/copyright="Copyright © 2026 Alexander Sellite and Rheannon Eisworth"
application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md" application/trademarks="NETfishing and Woofmeow branding is reserved; see TRADEMARKS.md"
application/export_angle=0 application/export_angle=0
@ -62,7 +62,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,playtest/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -92,7 +92,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,playtest/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -108,8 +108,8 @@ custom_template/release=""
application/bundle_identifier="io.woofmeow.netfishing" application/bundle_identifier="io.woofmeow.netfishing"
application/icon="res://art/exported/system_icons/netfishing_1024.png" application/icon="res://art/exported/system_icons/netfishing_1024.png"
application/icon_interpolation=0 application/icon_interpolation=0
application/short_version="0.6.4" application/short_version="0.6.7"
application/version="0.6.4" application/version="0.6.7"
application/architecture="universal" application/architecture="universal"
codesign/enable=false codesign/enable=false
notarization/enable=false notarization/enable=false
@ -125,7 +125,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,playtest/*,scripts/*,tests/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -155,7 +155,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,playtest/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""
@ -176,8 +176,8 @@ architectures/armeabi-v7a=false
architectures/arm64-v8a=true architectures/arm64-v8a=true
architectures/x86=false architectures/x86=false
architectures/x86_64=false architectures/x86_64=false
version/code=60500 version/code=60700
version/name="v0.6.5-alpha" version/name="v0.6.7-alpha"
package/unique_name="io.woofmeow.netfishing" package/unique_name="io.woofmeow.netfishing"
package/name="NETfishing" package/name="NETfishing"
package/signed=true package/signed=true
@ -214,7 +214,7 @@ custom_features=""
export_filter="all_resources" export_filter="all_resources"
include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt" include_filter="LICENSE,ASSET-LICENSE.md,TRADEMARKS.md,THIRD-PARTY-NOTICES.md,CREDITS.md,ui/fonts/*LICENSE.txt"
exclude_filter="builds/*,playtest/*,scripts/*" 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() patches=PackedStringArray()
encryption_include_filters="" encryption_include_filters=""
encryption_exclude_filters="" encryption_exclude_filters=""

View file

@ -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 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 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] const SALE_MULTIPLIERS: Array[float] = [1.0, 1.1, 1.25, 1.5, 2.0]
# Legacy profile multiplier retained for serialized/profile compatibility. # Legacy multiplier helpers remain available for compatibility. New encounters
# New encounters use the weighted quality/rarity/weight bands below. # roll directly within the authored quality bands below.
const BARRIER_HEALTH_MULTIPLIERS: Array[float] = [1.0, 1.25, 1.6, 2.2, 3.25] 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 # Inclusive per-barrier health bands authored for each quality tier. Encounter
# catch's position in its authored weight range. The upper bounds define the # generation rolls uniformly across the selected tier's complete band.
# 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
const BARRIER_HEALTH_MINIMUMS: Array[int] = [1, 10, 50, 100, 200] const BARRIER_HEALTH_MINIMUMS: Array[int] = [1, 10, 50, 100, 200]
const BARRIER_HEALTH_MAXIMUMS: Array[int] = [9, 50, 100, 200, 400] const BARRIER_HEALTH_MAXIMUMS: Array[int] = [9, 50, 100, 200, 400]
const DISPLAY_NAMES: PackedStringArray = [ 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( static func barrier_health_range(quality: int) -> Vector2i:
quality: int,
rarity: int,
weight_percentile: float,
variance: float = 1.0,
) -> int:
var safe_quality: int = ( var safe_quality: int = (
quality if is_valid(quality) else Tier.BORING quality if is_valid(quality) else Tier.BORING
) )
var safe_rarity: float = clampf(float(rarity) / 4.0, 0.0, 1.0) return Vector2i(
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,
BARRIER_HEALTH_MINIMUMS[safe_quality], BARRIER_HEALTH_MINIMUMS[safe_quality],
BARRIER_HEALTH_MAXIMUMS[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( static func roll(
rng: RandomNumberGenerator, rng: RandomNumberGenerator,
weight_multipliers: Array[float] = [], weight_multipliers: Array[float] = [],

View file

@ -6,8 +6,6 @@
script = ExtResource("1_profile") script = ExtResource("1_profile")
barrier_count_min = 3 barrier_count_min = 3
barrier_count_max = 4 barrier_count_max = 4
barrier_health_min = 4
barrier_health_max = 7
first_barrier_margin = 0.14 first_barrier_margin = 0.14
final_barrier_margin = 0.1 final_barrier_margin = 0.1
minimum_barrier_spacing = 0.14 minimum_barrier_spacing = 0.14

View file

@ -6,8 +6,6 @@
script = ExtResource("1_profile") script = ExtResource("1_profile")
barrier_count_min = 3 barrier_count_min = 3
barrier_count_max = 4 barrier_count_max = 4
barrier_health_min = 5
barrier_health_max = 8
first_barrier_margin = 0.13 first_barrier_margin = 0.13
final_barrier_margin = 0.09 final_barrier_margin = 0.09
minimum_barrier_spacing = 0.14 minimum_barrier_spacing = 0.14

View file

@ -69,8 +69,6 @@ var _reel_input_held: bool = false
var _reel_speed: float = 0.0 var _reel_speed: float = 0.0
var _click_power: int = 1 var _click_power: int = 1
var _fish_quality: int = FishQualityType.Tier.BORING 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 _chase_delay_remaining: float = 0.0
var _failure_epsilon: float = 0.0001 var _failure_epsilon: float = 0.0001
var _auto_click_accumulator: float = 0.0 var _auto_click_accumulator: float = 0.0
@ -113,8 +111,6 @@ func start_encounter(
reel_speed: float, reel_speed: float,
click_power: int, click_power: int,
fish_quality: int = FishQualityType.Tier.BORING, fish_quality: int = FishQualityType.Tier.BORING,
fish_rarity: int = 0,
fish_weight_percentile: float = 0.0,
) -> void: ) -> void:
reset() reset()
if profile == null: if profile == null:
@ -128,8 +124,6 @@ func start_encounter(
if FishQualityType.is_valid(fish_quality) if FishQualityType.is_valid(fish_quality)
else FishQualityType.Tier.BORING 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_delay_remaining = CHASE_START_DELAY
chase_progress = -CHASE_START_OFFSET chase_progress = -CHASE_START_OFFSET
_seed_encounter_rng() _seed_encounter_rng()
@ -145,8 +139,6 @@ func start_authoritative_encounter(
click_power: int, click_power: int,
encounter_seed_value: int, encounter_seed_value: int,
fish_quality: int = FishQualityType.Tier.BORING, fish_quality: int = FishQualityType.Tier.BORING,
fish_rarity: int = 0,
fish_weight_percentile: float = 0.0,
) -> void: ) -> void:
var previous_test_mode: bool = use_deterministic_test_seed var previous_test_mode: bool = use_deterministic_test_seed
var previous_seed: int = deterministic_test_seed var previous_seed: int = deterministic_test_seed
@ -157,8 +149,6 @@ func start_authoritative_encounter(
reel_speed, reel_speed,
click_power, click_power,
fish_quality, fish_quality,
fish_rarity,
fish_weight_percentile,
) )
use_deterministic_test_seed = previous_test_mode use_deterministic_test_seed = previous_test_mode
deterministic_test_seed = previous_seed deterministic_test_seed = previous_seed
@ -327,11 +317,8 @@ func _generate_barriers(profile: CatchDifficultyProfileType) -> void:
+ spacing * float(barrier_index) + spacing * float(barrier_index)
+ random_offsets[barrier_index] * random_slack + random_offsets[barrier_index] * random_slack
) )
var health: int = FishQualityType.barrier_health_for_catch( var health: int = FishQualityType.roll_barrier_health(
_fish_quality, _rng, _fish_quality
_fish_rarity,
_fish_weight_percentile,
_rng.randf_range(0.9, 1.1),
) )
_barriers.append(Barrier.new(position, health)) _barriers.append(Barrier.new(position, health))

View file

@ -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_min: int = 2
@export_range(0, 12, 1) var barrier_count_max: int = 4 @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 first_barrier_margin: float = 0.15
@export_range(0.0, 0.45, 0.01) var final_barrier_margin: float = 0.10 @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 @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)) 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: func get_generation_interval() -> Vector2:
var start: float = clampf(first_barrier_margin, 0.0, 0.95) var start: float = clampf(first_barrier_margin, 0.0, 0.95)
var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0) var finish: float = clampf(1.0 - final_barrier_margin, start, 1.0)

View file

@ -6,8 +6,6 @@
script = ExtResource("1_profile") script = ExtResource("1_profile")
barrier_count_min = 2 barrier_count_min = 2
barrier_count_max = 4 barrier_count_max = 4
barrier_health_min = 3
barrier_health_max = 7
first_barrier_margin = 0.15 first_barrier_margin = 0.15
final_barrier_margin = 0.1 final_barrier_margin = 0.1
minimum_barrier_spacing = 0.15 minimum_barrier_spacing = 0.15

View file

@ -431,7 +431,7 @@ func _draw_line_ribbon(points: PackedVector3Array) -> void:
return return
var half_thickness: float = line_thickness * 0.5 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()): for point_index: int in range(1, points.size()):
var start: Vector3 = points[point_index - 1] var start: Vector3 = points[point_index - 1]
var end: Vector3 = points[point_index] 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 start_right: Vector3 = to_local(start + side)
var end_right: Vector3 = to_local(end + side) var end_right: Vector3 = to_local(end + side)
var end_left: Vector3 = to_local(end - side) var end_left: Vector3 = to_local(end - side)
_line_mesh.surface_add_vertex(start_left) ribbon_vertices.append(start_left)
_line_mesh.surface_add_vertex(start_right) ribbon_vertices.append(start_right)
_line_mesh.surface_add_vertex(end_right) ribbon_vertices.append(end_right)
_line_mesh.surface_add_vertex(start_left) ribbon_vertices.append(start_left)
_line_mesh.surface_add_vertex(end_right) ribbon_vertices.append(end_right)
_line_mesh.surface_add_vertex(end_left) 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() _line_mesh.surface_end()

View file

@ -1045,8 +1045,6 @@ func _activate_bite(confirmation_override: bool = false) -> void:
_get_effective_reel_speed(), _get_effective_reel_speed(),
_get_effective_barrier_damage(), _get_effective_barrier_damage(),
_pending_catch.quality, _pending_catch.quality,
int(_pending_catch.fish.rarity),
_pending_catch.fish.get_weight_percentile(_pending_catch.weight_lb),
) )
_catch_controller.set_reel_input( _catch_controller.set_reel_input(
Input.is_action_pressed("fish_primary") Input.is_action_pressed("fish_primary")

View file

@ -308,7 +308,15 @@ func _start_dedicated_server() -> void:
if not _discovery.set_base_url_override(config.discovery_url): if not _discovery.set_base_url_override(config.discovery_url):
_fail_dedicated_server("The discovery URL is invalid.") _fail_dedicated_server("The discovery URL is invalid.")
return return
if not _network_session.configure_dedicated_operators(
config.operator_fingerprints
):
_fail_dedicated_server("The server operator list is invalid.")
return
_configure_portable_stores() _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) _initialize_application(true)
_player.set_local_control(false) _player.set_local_control(false)
_player.visible = false _player.visible = false
@ -323,7 +331,6 @@ func _start_dedicated_server() -> void:
_fail_dedicated_server("Could not start the dedicated server.") _fail_dedicated_server("Could not start the dedicated server.")
return return
_network_session.set_session_display_name(config.server_name) _network_session.set_session_display_name(config.server_name)
_discovery.set_room_name(config.server_name)
if not _network_session.set_host_open(true): if not _network_session.set_host_open(true):
_fail_dedicated_server("Could not open the dedicated server.") _fail_dedicated_server("Could not open the dedicated server.")
return return

View file

@ -58,6 +58,7 @@ var _session: NetworkSession
var _base_url: String = "" var _base_url: String = ""
var _room_name: String = DEFAULT_ROOM_NAME var _room_name: String = DEFAULT_ROOM_NAME
var _room_name_uses_default: bool = true var _room_name_uses_default: bool = true
var _host_settings_persistence_enabled: bool = true
var _discoverable: bool = false var _discoverable: bool = false
var _host_status_message: String = "" var _host_status_message: String = ""
var _host_status_is_error: bool = false var _host_status_is_error: bool = false
@ -218,6 +219,11 @@ func get_public_join_state() -> PublicJoinState:
return _public_join_state 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: func set_room_name(value: String) -> bool:
var cleaned: String = _sanitize_room_name(value) var cleaned: String = _sanitize_room_name(value)
if cleaned.is_empty(): if cleaned.is_empty():
@ -865,12 +871,46 @@ func _parse_response_dictionary(body: PackedByteArray) -> Dictionary:
func _request_failure(response: Dictionary) -> String: func _request_failure(response: Dictionary) -> String:
var error: Variant = response.get("error", {}) var error: Variant = response.get("error", {})
if typeof(error) == TYPE_DICTIONARY: 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(): if not message.is_empty():
return message return message
return "Room discovery is temporarily unavailable." 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: func _on_session_state_changed(state: NetworkSession.State) -> void:
if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty(): if state == NetworkSession.State.CONNECTING and not _pending_join_token.is_empty():
_set_public_join_state(PublicJoinState.CONNECTING) _set_public_join_state(PublicJoinState.CONNECTING)
@ -1013,6 +1053,8 @@ func _load_settings() -> void:
func _save_settings() -> void: func _save_settings() -> void:
if not _host_settings_persistence_enabled:
return
var config := ConfigFile.new() var config := ConfigFile.new()
config.set_value("host", "room_name", _room_name) config.set_value("host", "room_name", _room_name)
config.set_value( config.set_value(

View file

@ -548,8 +548,6 @@ func _start_bite(attempt: NetworkFishingAttempt) -> void:
attempt.barrier_damage, attempt.barrier_damage,
attempt.encounter_seed, attempt.encounter_seed,
fish_catch.quality, fish_catch.quality,
int(fish.rarity),
fish.get_weight_percentile(fish_catch.weight_lb),
) )
var data: Dictionary = { var data: Dictionary = {
"attempt_id": attempt.attempt_id, "attempt_id": attempt.attempt_id,

View file

@ -15,6 +15,9 @@ var _surface_drawing: NetworkSurfaceDrawingService
var _revision := 0 var _revision := 0
var _host_block_pairs: Dictionary[String, bool] = {} var _host_block_pairs: Dictionary[String, bool] = {}
var _peer_fingerprints: Dictionary[int, String] = {} 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( func setup(
@ -40,6 +43,7 @@ func setup(
func(_count: int, _maximum: int) -> void: _sync_authenticated_peers() func(_count: int, _maximum: int) -> void: _sync_authenticated_peers()
) )
_session.state_changed.connect(_on_session_state_changed) _session.state_changed.connect(_on_session_state_changed)
_session.operator_status_changed.connect(_on_operator_status_changed)
_relationships.relationship_changed.connect(_on_relationship_changed) _relationships.relationship_changed.connect(_on_relationship_changed)
_bans.bans_changed.connect(_changed) _bans.bans_changed.connect(_changed)
_chat.set_relationship_store(_relationships) _chat.set_relationship_store(_relationships)
@ -64,13 +68,24 @@ func get_entries() -> Array[PlayerListEntry]:
entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint) entry.compact_fingerprint = NetworkIdentityCrypto.compact_suffix(record.identity_fingerprint)
entry.display_name = record.display_name entry.display_name = record.display_name
entry.is_host = peer_id == 1 entry.is_host = peer_id == 1
entry.is_operator = _session.is_peer_operator(peer_id)
entry.is_local_player = peer_id == local_id entry.is_local_player = peer_id == local_id
entry.continuity_state = _known.identity_status(record.identity_fingerprint, record.display_name) 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.ping_to_host_ms = _session.get_peer_rtt_ms(peer_id)
entry.muted = _relationships.is_muted(record.identity_fingerprint) entry.muted = _relationships.is_muted(record.identity_fingerprint)
entry.blocked = blocked 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_ban = entry.can_kick
entry.can_manage_operator = (
_session.can_manage_operators()
and peer_id != local_id
and peer_id != 1
)
entry.revision = _revision entry.revision = _revision
result.append(entry) result.append(entry)
result.sort_custom(_entry_before) result.sort_custom(_entry_before)
@ -89,6 +104,14 @@ func is_local_host() -> bool:
return _session.is_host() 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: func is_open_host() -> bool:
return _session.is_open_host() return _session.is_open_host()
@ -102,7 +125,12 @@ func get_relationships() -> Array[Dictionary]:
func get_bans() -> 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( func set_surface_drawing_service(
@ -131,14 +159,12 @@ func get_session_artwork_counts() -> Vector2i:
func reset_session_artwork() -> bool: 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 return false
var ok: bool = _surface_drawing.clear_session_artwork() request_reset_session_artwork.rpc_id(1)
moderation_finished.emit( return true
ok,
"Session artwork cleared." if ok else "Session artwork could not be cleared.",
)
return ok
func set_muted(fingerprint: String, display_name: String, value: bool) -> bool: 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: func kick(peer_id: int, fingerprint: String, revision: int) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision): if _session.is_host():
moderation_finished.emit(false, "That player is no longer connected.") 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 return false
var ok := _session.kick_authenticated_peer(peer_id, fingerprint) request_kick.rpc_id(1, peer_id, fingerprint)
moderation_finished.emit(ok, "Player removed." if ok else "Player could not be removed.") return true
return ok
func ban( func ban(
@ -168,22 +195,158 @@ func ban(
display_name: String, display_name: String,
revision: int, revision: int,
) -> bool: ) -> bool:
if not _valid_moderation_target(peer_id, fingerprint, revision): if _session.is_host():
moderation_finished.emit(false, "That player is no longer connected.") 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 return false
var host_fingerprint := _session.get_host_identity_fingerprint() request_ban.rpc_id(1, peer_id, 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.")
return true return true
func unban(fingerprint: String) -> bool: 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 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: func is_locally_blocked(fingerprint: String) -> bool:
@ -271,6 +434,22 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
]: ]:
_host_block_pairs.clear() _host_block_pairs.clear()
_peer_fingerprints.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() _changed()
@ -295,16 +474,137 @@ func _peer_for_fingerprint(fingerprint: String) -> int:
return 0 return 0
func _valid_moderation_target(peer_id: int, fingerprint: String, revision: int) -> bool: func _valid_moderation_target(
if not _session.is_host() or revision != _revision or peer_id == 1: 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 return false
var record := _session.get_peer_record(peer_id) var record := _session.get_peer_record(peer_id)
return record != null and record.identity_fingerprint == fingerprint 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: func _entry_before(a: PlayerListEntry, b: PlayerListEntry) -> bool:
if a.is_host != b.is_host: if a.is_host != b.is_host:
return a.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: if a.is_local_player != b.is_local_player:
return a.is_local_player return a.is_local_player
var compared := a.display_name.naturalnocasecmp_to(b.display_name) var compared := a.display_name.naturalnocasecmp_to(b.display_name)

View file

@ -32,6 +32,7 @@ signal server_trust_required(
is_changed: bool, is_changed: bool,
) )
signal peer_identity_observed(peer_id: int, status: String) signal peer_identity_observed(peer_id: int, status: String)
signal operator_status_changed(peer_id: int, is_operator: bool)
signal server_lost signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3) signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
signal remote_recovery_presentation_changed( signal remote_recovery_presentation_changed(
@ -102,6 +103,10 @@ var _moderation_disconnect_message := ""
var _host_port: int = 0 var _host_port: int = 0
var _session_display_name: String = "NETfishing Room" var _session_display_name: String = "NETfishing Room"
var _dedicated_host: bool = false 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: func _ready() -> void:
@ -137,6 +142,20 @@ func setup(
_profile_ready = _profile_ready and _player_identity.load_or_create() _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( func start_private_host(
port: int = DEFAULT_PORT, port: int = DEFAULT_PORT,
port_attempts: int = 1, port_attempts: int = 1,
@ -215,6 +234,9 @@ func _start_host(
multiplayer.multiplayer_peer = peer multiplayer.multiplayer_peer = peer
_session_id = Crypto.new().generate_random_bytes(16).hex_encode() _session_id = Crypto.new().generate_random_bytes(16).hex_encode()
_registry.clear() _registry.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_spawn_service.clear_remote_players() _spawn_service.clear_remote_players()
if not dedicated: if not dedicated:
_register_player_host() _register_player_host()
@ -301,6 +323,44 @@ func is_dedicated_host() -> bool:
return is_host() and _dedicated_host 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: func set_session_display_name(value: String) -> void:
var cleaned: String = value.strip_edges().left(48) var cleaned: String = value.strip_edges().left(48)
if cleaned.is_empty(): if cleaned.is_empty():
@ -822,6 +882,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_authentication.erase(peer_id) _pending_authentication.erase(peer_id)
_pending_identity_challenges.erase(peer_id) _pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id) _authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "") var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
if not recovery_attempt.is_empty(): if not recovery_attempt.is_empty():
_recovery_attempts.erase(peer_id) _recovery_attempts.erase(peer_id)
@ -1136,6 +1197,11 @@ func submit_client_hello(data: Dictionary) -> void:
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
) )
return 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( var submitted_appearance := CharacterCustomizationCatalog.sanitized_snapshot(
data["cosmetic_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_spawn_list.rpc_id(sender_id, _build_spawn_list())
receive_operator_snapshot.rpc_id(sender_id, _operator_peer_id_snapshot())
receive_peer_spawn.rpc( receive_peer_spawn.rpc(
_make_spawn_entry(sender_id, display_name, spawn_transform) _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) peer_authenticated.emit(sender_id, display_name)
_emit_peer_count() _emit_peer_count()
@ -1334,12 +1403,41 @@ func receive_peer_spawn(entry: Dictionary) -> void:
func receive_peer_despawn(peer_id: int) -> void: func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT: if state != State.JOINED_CLIENT:
return return
_operator_peer_ids.erase(peer_id)
_registry.remove_peer(peer_id) _registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id) _spawn_service.remove_peer(peer_id)
peer_removed.emit(peer_id) peer_removed.emit(peer_id)
_emit_peer_count() _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: func _apply_spawn_entry(entry: Dictionary) -> void:
if ( if (
typeof(entry.get("peer_id")) != TYPE_INT typeof(entry.get("peer_id")) != TYPE_INT
@ -1470,6 +1568,36 @@ func _sanitized_capabilities(value: Variant) -> PackedStringArray:
return result 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: func _verify_spawn_identity(entry: Dictionary) -> bool:
var fingerprint := str(entry.get("identity_fingerprint", "")) var fingerprint := str(entry.get("identity_fingerprint", ""))
var public_pem := NetworkIdentityCrypto.normalize_public_pem( var public_pem := NetworkIdentityCrypto.normalize_public_pem(
@ -1929,5 +2057,8 @@ func _teardown_peer() -> void:
_server_identity_fingerprint = "" _server_identity_fingerprint = ""
_server_identity_public_key = "" _server_identity_public_key = ""
_session_identity_keys.clear() _session_identity_keys.clear()
_session_operator_fingerprints.clear()
_operator_peer_ids.clear()
_local_operator = false
_host_port = 0 _host_port = 0
_dedicated_host = false _dedicated_host = false

View file

@ -6,6 +6,7 @@ var full_fingerprint := ""
var compact_fingerprint := "" var compact_fingerprint := ""
var display_name := "" var display_name := ""
var is_host := false var is_host := false
var is_operator := false
var is_local_player := false var is_local_player := false
var continuity_state := "" var continuity_state := ""
var ping_to_host_ms := -1 var ping_to_host_ms := -1
@ -13,4 +14,5 @@ var muted := false
var blocked := false var blocked := false
var can_kick := false var can_kick := false
var can_ban := false var can_ban := false
var can_manage_operator := false
var revision := 0 var revision := 0

View file

@ -1,12 +1,13 @@
NETfishing NETfishing
v0.6.5-alpha v0.6.7-alpha
Alpha 0.6.5 Alpha 0.6.7
Thank you for trying this early private playtest. Thank you for trying this early private playtest.
NETfishing is currently a local gameplay prototype for a future NETfishing is an early multiplayer fishing game. This alpha includes local
multiplayer-first game. Networking and multiplayer are NOT implemented in progression, player-hosted and dedicated multiplayer sessions, public room
this build. discovery, and direct UDP connections. Expect rough edges and please keep a
backup of any save or identity data you care about.
INSTALLATION INSTALLATION
@ -60,6 +61,9 @@ FISHING
FEATURES TO TRY 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 - Movement, jumping, camera rotation, and zoom
- Short, medium, and maximum-distance casts - Short, medium, and maximum-distance casts
- Valid water and invalid land casts - Valid water and invalid land casts
@ -101,11 +105,11 @@ Please include:
- How movement, casting, chase pressure, barrier clicking, menus, and camera - How movement, casting, chase pressure, barrier clicking, menus, and camera
controls felt controls felt
KNOWN PRE-ALPHA LIMITATIONS KNOWN ALPHA LIMITATIONS
- Multiplayer/networking is not implemented. - Public sessions remain direct connections; restrictive NAT or carrier-grade
- Fish and character art are temporary. networks may prevent some players from joining.
- UI and balance values are placeholders. - Multiplayer, dedicated-server deployment, UI, and balance are still alpha.
- There is no audio. - Some fish and character artwork remains temporary.
- There is no installer or automatic updater. - There is no installer or automatic updater.
- Windows builds are unsigned. - Windows builds are unsigned.

View file

@ -11,7 +11,7 @@ config_version=5
[application] [application]
config/name="NETFISHING" config/name="NETFISHING"
config/version="0.6.5-alpha" config/version="0.6.7-alpha"
run/main_scene="res://main/main.tscn" run/main_scene="res://main/main.tscn"
config/features=PackedStringArray("4.7", "GL Compatibility") config/features=PackedStringArray("4.7", "GL Compatibility")
config/icon="res://art/exported/system_icons/netfishing_256.png" config/icon="res://art/exported/system_icons/netfishing_256.png"

View file

@ -4,14 +4,14 @@ set -euo pipefail
readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly PROJECT_ROOT="$(cd -- "${SCRIPT_DIR}/.." && 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 WINDOWS_DIR="${BUILD_ROOT}/windows-x86_64"
readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64" readonly LINUX_DIR="${BUILD_ROOT}/linux-x86_64"
readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt" readonly README_SOURCE="${PROJECT_ROOT}/playtest/README-PLAYTEST.txt"
readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)" readonly SOURCE_COMMIT="$(git -C "${PROJECT_ROOT}" rev-parse HEAD)"
readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing" readonly SOURCE_URL="https://forge.makearmy.io/woofmeow/netfishing"
readonly WINDOWS_ZIP="${BUILD_ROOT}/NETfishing-v0.6.5-alpha-windows-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.5-alpha-linux-x86_64.zip" readonly LINUX_ZIP="${BUILD_ROOT}/NETfishing-v0.6.7-alpha-linux-x86_64.zip"
readonly GODOT_BIN="${GODOT_BIN:-godot}" readonly GODOT_BIN="${GODOT_BIN:-godot}"
if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then if [[ ! -f "${PROJECT_ROOT}/project.godot" ]]; then

View file

@ -10,6 +10,7 @@ readonly RUN_ROOT="$(mktemp -d -t netfishing-validations.XXXXXX)"
readonly -a QUICK_TESTS=( readonly -a QUICK_TESTS=(
"tests/android_readiness_validation.gd" "tests/android_readiness_validation.gd"
"tests/dedicated_server_config_validation.gd"
"tests/fish_catalog_content_validation.gd" "tests/fish_catalog_content_validation.gd"
"tests/fish_quality_validation.gd" "tests/fish_quality_validation.gd"
"tests/fishing_audio_validation.gd" "tests/fishing_audio_validation.gd"
@ -32,6 +33,7 @@ readonly -a RUNTIME_TESTS=(
readonly -a HOST_TESTS=( readonly -a HOST_TESTS=(
"tests/art_tools_validation.gd" "tests/art_tools_validation.gd"
"tests/dedicated_host_session_validation.gd"
"tests/economy_regression_validation.gd" "tests/economy_regression_validation.gd"
"tests/fish_hotbar_showcase_validation.gd" "tests/fish_hotbar_showcase_validation.gd"
"tests/fishing_authority_validation.gd" "tests/fishing_authority_validation.gd"
@ -44,6 +46,7 @@ readonly -a NETWORK_TESTS=(
"tests/fish_showcase_multiplayer_validation.gd" "tests/fish_showcase_multiplayer_validation.gd"
"tests/fishing_multiplayer_validation.gd" "tests/fishing_multiplayer_validation.gd"
"tests/job_multiplayer_validation.gd" "tests/job_multiplayer_validation.gd"
"tests/operator_multiplayer_validation.gd"
"tests/surface_drawing_multiplayer_validation.gd" "tests/surface_drawing_multiplayer_validation.gd"
"tests/world_time_multiplayer_validation.gd" "tests/world_time_multiplayer_validation.gd"
) )

View file

@ -5,6 +5,7 @@ const DEFAULT_NAME: String = "NETfishing Dedicated Server"
const DEFAULT_BIND_ADDRESS: String = "*" const DEFAULT_BIND_ADDRESS: String = "*"
const DEFAULT_PORT: int = 7777 const DEFAULT_PORT: int = 7777
const DEFAULT_MAX_PLAYERS: int = 8 const DEFAULT_MAX_PLAYERS: int = 8
const MAX_OPERATORS: int = 64
var server_name: String = DEFAULT_NAME var server_name: String = DEFAULT_NAME
var bind_address: String = DEFAULT_BIND_ADDRESS var bind_address: String = DEFAULT_BIND_ADDRESS
@ -13,6 +14,7 @@ var max_players: int = DEFAULT_MAX_PLAYERS
var public_listing: bool = false var public_listing: bool = false
var discovery_url: String = "" var discovery_url: String = ""
var data_directory: String = "" var data_directory: String = ""
var operator_fingerprints: PackedStringArray = PackedStringArray()
var error_message: String = "" var error_message: String = ""
@ -65,6 +67,9 @@ func _load_file(path: String) -> bool:
discovery_url = str(file.get_value( discovery_url = str(file.get_value(
"discovery", "url", discovery_url "discovery", "url", discovery_url
)) ))
operator_fingerprints = _parse_fingerprint_list(file.get_value(
"moderation", "operators", operator_fingerprints
))
return true return true
@ -88,6 +93,10 @@ func _apply_environment() -> void:
data_directory = _environment_string( data_directory = _environment_string(
"NETFISHING_DATA_DIR", data_directory "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: func _apply_arguments(arguments: PackedStringArray) -> void:
@ -106,6 +115,10 @@ func _apply_arguments(arguments: PackedStringArray) -> void:
data_directory = argument.trim_prefix("--data-dir=") data_directory = argument.trim_prefix("--data-dir=")
elif argument.begins_with("--discovery-url="): elif argument.begins_with("--discovery-url="):
discovery_url = argument.trim_prefix("--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": elif argument == "--public":
public_listing = true public_listing = true
elif argument == "--private": elif argument == "--private":
@ -117,6 +130,7 @@ func _validate() -> void:
bind_address = bind_address.strip_edges() bind_address = bind_address.strip_edges()
discovery_url = discovery_url.strip_edges().trim_suffix("/") discovery_url = discovery_url.strip_edges().trim_suffix("/")
data_directory = data_directory.strip_edges() data_directory = data_directory.strip_edges()
operator_fingerprints = _normalized_fingerprints(operator_fingerprints)
if server_name.is_empty(): if server_name.is_empty():
error_message = "Server name cannot be empty." error_message = "Server name cannot be empty."
elif not _safe_text(server_name): elif not _safe_text(server_name):
@ -134,6 +148,17 @@ func _validate() -> void:
or discovery_url.begins_with("http://") or discovery_url.begins_with("http://")
): ):
error_message = "A public server requires a discovery URL." 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: 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: static func _parse_int(value: String, fallback: int) -> int:
return int(value) if value.strip_edges().is_valid_int() else fallback 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

View file

@ -109,6 +109,7 @@ func _run() -> void:
select_button.button_index = JOY_BUTTON_BACK select_button.button_index = JOY_BUTTON_BACK
select_button.pressed = true select_button.pressed = true
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
await process_frame
assert(chat_ui.is_open()) assert(chat_ui.is_open())
assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP) assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_STOP)
var typed_chat_entry := chat_ui.find_child( var typed_chat_entry := chat_ui.find_child(
@ -121,6 +122,8 @@ func _run() -> void:
accept_button.pressed = true accept_button.pressed = true
assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button))) assert(bool(game_ui.call("_handle_controller_chat_controls", accept_button)))
assert(typed_chat_entry.virtual_keyboard_enabled) assert(typed_chat_entry.virtual_keyboard_enabled)
await process_frame
assert(typed_chat_entry.has_focus())
var left_bumper := InputEventJoypadButton.new() var left_bumper := InputEventJoypadButton.new()
left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER left_bumper.button_index = JOY_BUTTON_LEFT_SHOULDER
left_bumper.pressed = true left_bumper.pressed = true
@ -129,10 +132,8 @@ func _run() -> void:
assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE) assert(chat_panel.mouse_filter == Control.MOUSE_FILTER_IGNORE)
assert(not typed_chat_entry.virtual_keyboard_enabled) assert(not typed_chat_entry.virtual_keyboard_enabled)
assert(bool(game_ui.call("_handle_controller_chat_controls", left_bumper))) 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 chat_ui.is_open())
assert(not typed_chat_entry.virtual_keyboard_enabled)
assert(bool(game_ui.call("_handle_controller_chat_controls", select_button))) assert(bool(game_ui.call("_handle_controller_chat_controls", select_button)))
assert(not chat_ui.is_open()) assert(not chat_ui.is_open())
assert(chat_ui.is_collapsed()) assert(chat_ui.is_collapsed())
@ -315,5 +316,7 @@ func _run() -> void:
var session := main.get_node("%NetworkSession") as NetworkSession var session := main.get_node("%NetworkSession") as NetworkSession
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -39,6 +39,15 @@ func _run() -> void:
standard_button.material != null, standard_button.material != null,
"ordinary controller focus receives inversion", "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() authored_selector.grab_focus()
await process_frame await process_frame

View file

@ -23,14 +23,45 @@ func _run() -> void:
var original := _make_button("cute", "cute (show variants)") var original := _make_button("cute", "cute (show variants)")
option_list.add_child(original) option_list.add_child(original)
await process_frame 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() var controller_event := InputEventJoypadButton.new()
controller_event.button_index = JOY_BUTTON_A controller_event.button_index = JOY_BUTTON_A
controller_event.pressed = true controller_event.pressed = true
recovery._input(controller_event) recovery._input(controller_event)
original.grab_focus()
await process_frame 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) option_list.remove_child(original)
original.queue_free() original.queue_free()
@ -53,6 +84,63 @@ func _run() -> void:
root.gui_get_focus_owner() == explicit_target, root.gui_get_focus_owner() == explicit_target,
"explicit focus changes take precedence over recovery", "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() var leave_world_ui_event := InputEventJoypadButton.new()
leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER leave_world_ui_event.button_index = JOY_BUTTON_LEFT_SHOULDER
leave_world_ui_event.pressed = true leave_world_ui_event.pressed = true
@ -64,6 +152,14 @@ func _run() -> void:
root.gui_get_focus_owner() == null, root.gui_get_focus_owner() == null,
"LB intentionally leaving world UI is never recovered", "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() stage.queue_free()
if _failures.is_empty(): if _failures.is_empty():

View file

@ -1,6 +1,9 @@
extends SceneTree extends SceneTree
const TEST_PORT: int = 35777 const TEST_PORT: int = 35777
const OPERATOR_FINGERPRINT: String = (
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
func _init() -> void: func _init() -> void:
@ -35,12 +38,18 @@ func _run() -> void:
host_bans, host_bans,
true, true,
) )
assert(session.configure_dedicated_operators(
PackedStringArray([OPERATOR_FINGERPRINT])
))
assert(session.start_dedicated_host(TEST_PORT, 5, "127.0.0.1")) assert(session.start_dedicated_host(TEST_PORT, 5, "127.0.0.1"))
assert(session.is_dedicated_host()) assert(session.is_dedicated_host())
assert(session.get_local_peer_id() == 0) assert(session.get_local_peer_id() == 0)
assert(session.get_player_count() == 0) assert(session.get_player_count() == 0)
assert(session.get_session_max_players() == 5) assert(session.get_session_max_players() == 5)
assert(session.get_host_port() == TEST_PORT) assert(session.get_host_port() == TEST_PORT)
assert(bool(session.call(
"_operator_for_fingerprint", OPERATOR_FINGERPRINT
)))
assert(session.set_host_open(true)) assert(session.set_host_open(true))
assert(session.is_open_host()) assert(session.is_open_host())
session.disconnect_session("Dedicated host validation complete.") session.disconnect_session("Dedicated host validation complete.")

View file

@ -1,6 +1,12 @@
extends SceneTree extends SceneTree
const ConfigType = preload("res://server/dedicated_server_config.gd") const ConfigType = preload("res://server/dedicated_server_config.gd")
const OPERATOR_A: String = (
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
)
const OPERATOR_B: String = (
"fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
)
func _initialize() -> void: func _initialize() -> void:
@ -25,6 +31,9 @@ func _run() -> void:
file.set_value("server", "public", true) file.set_value("server", "public", true)
file.set_value("server", "data_directory", "/tmp/configured-server") file.set_value("server", "data_directory", "/tmp/configured-server")
file.set_value("discovery", "url", "https://discovery.netfishing.org/") file.set_value("discovery", "url", "https://discovery.netfishing.org/")
file.set_value(
"moderation", "operators", PackedStringArray([OPERATOR_B, OPERATOR_A])
)
assert(file.save(path) == OK) assert(file.save(path) == OK)
var configured := ConfigType.new() var configured := ConfigType.new()
@ -37,8 +46,35 @@ func _run() -> void:
assert(int(configured.get("max_players")) == 12) assert(int(configured.get("max_players")) == 12)
assert(bool(configured.get("public_listing"))) assert(bool(configured.get("public_listing")))
assert(str(configured.get("discovery_url")) == "https://discovery.netfishing.org") 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) 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() var invalid := ConfigType.new()
invalid.set("public_listing", true) invalid.set("public_listing", true)
invalid.set("data_directory", "/tmp/invalid-server") invalid.set("data_directory", "/tmp/invalid-server")
@ -58,6 +94,27 @@ func _run() -> void:
DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS DiscoveryClient.UPNP_RETRY_INTERVAL_SECONDS
< DiscoveryClient.UPNP_RENEW_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( assert(
discovery.get_host_state() discovery.get_host_state()
== DiscoveryClient.HostState.CLOSED == DiscoveryClient.HostState.CLOSED
@ -107,6 +164,46 @@ func _run() -> void:
"0.6.5-beta", "0.6.5-alpha" "0.6.5-beta", "0.6.5-alpha"
) == NetworkProtocol.RejectionCode.SERVER_OUTDATED ) == 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() discovery.free()
print("DEDICATED_SERVER_CONFIG_VALIDATION_OK") print("DEDICATED_SERVER_CONFIG_VALIDATION_OK")
quit() quit()

View file

@ -82,7 +82,8 @@ func _run() -> void:
print("Economy regression validation: PASS") print("Economy regression validation: PASS")
session.disconnect_session("Economy validation complete.") session.disconnect_session("Economy validation complete.")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
quit() quit()
@ -126,7 +127,9 @@ func _run_multiplayer_host() -> void:
print("Economy multiplayer host validation: PASS") print("Economy multiplayer host validation: PASS")
session.disconnect_session("Economy host validation complete.") session.disconnect_session("Economy host validation complete.")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -228,7 +231,9 @@ func _run_multiplayer_client() -> void:
print("Economy multiplayer client validation: PASS") print("Economy multiplayer client validation: PASS")
session.disconnect_session("Economy client validation complete.") session.disconnect_session("Economy client validation complete.")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -407,7 +412,7 @@ func _test_player_menu_sale(
assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP) assert(sell_action.mouse_filter == Control.MOUSE_FILTER_STOP)
assert(sell_action.focus_mode == Control.FOCUS_ALL) assert(sell_action.focus_mode == Control.FOCUS_ALL)
assert(ui_viewport != null) assert(ui_viewport != null)
await _activate_focused_button(sell_action, ui_viewport) await _activate_pointer_control(sell_action, ui_viewport)
await process_frame await process_frame
assert(confirmation.visible) assert(confirmation.visible)
assert(confirm_button.visible and not confirm_button.disabled) 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 > (player_menu.get_node("%CoolerOuterWall") as Control).z_index
) )
_sale_result.clear() _sale_result.clear()
await _activate_focused_button(confirm_button, ui_viewport) await _activate_pointer_control(confirm_button, ui_viewport)
await process_frame await process_frame
assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not _sale_result.is_empty() and bool(_sale_result[1]))
assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) 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 await create_timer(2.2).timeout
func _activate_focused_button( func _activate_pointer_control(
button: Button, control: Control,
ui_viewport: SubViewport, ui_viewport: SubViewport,
) -> void: ) -> 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 await process_frame
assert(button.has_focus()) assert(ui_viewport.gui_get_hovered_control() == control)
for is_pressed: bool in [true, false]: for is_pressed: bool in [true, false]:
var accept := InputEventAction.new() var click := InputEventMouseButton.new()
accept.action = &"ui_accept" click.button_index = MOUSE_BUTTON_LEFT
accept.pressed = is_pressed click.position = center
ui_viewport.push_input(accept, false) click.global_position = center
click.pressed = is_pressed
root.push_input(click, true)
await process_frame await process_frame
@ -555,19 +571,25 @@ func _test_fishing_shop_sale_ui(
assert(not shop.has_node("ShopPanel/Margin/Layout/ModeTabs")) assert(not shop.has_node("ShopPanel/Margin/Layout/ModeTabs"))
var shop_tabs: Array = shop.get("_shop_tabs") as Array var shop_tabs: Array = shop.get("_shop_tabs") as Array
assert(shop_tabs.size() == 6) 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 var sell_mode := shop_tabs[5] as Button
assert(sell_mode != null and sell_mode.text == "Sell Fish") 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] = [] var stock_sections: Array[String] = []
for child: Node in shop.get_node("%SuppliesList").get_children(): for child: Node in shop.get_node("%SuppliesList").get_children():
if child is Label: if child is Label:
stock_sections.append((child as Label).text) stock_sections.append((child as Label).text)
assert(stock_sections == ["supplies", "art kit", "markers", "brushes", "grids"]) assert(stock_sections == ["art kit", "markers", "brushes", "grids"])
await _activate_focused_button(sell_mode, ui_viewport) await _activate_pointer_control(sell_mode, ui_viewport)
await process_frame await process_frame
assert(shop.visible and not player_menu.visible) assert(shop.visible and not player_menu.visible)
assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales")) assert(not shop.has_node("ShopPanel/Margin/Layout/Body/FishSales"))
assert((shop.get_node("%ShopCoolerPage") as Control).visible) 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 var mounted_cooler := player_menu.get("_cooler_page") as Control
assert(mounted_cooler != null and mounted_cooler.visible) assert(mounted_cooler != null and mounted_cooler.visible)
assert( assert(
@ -577,6 +599,11 @@ func _test_fishing_shop_sale_ui(
var water_surface := player_menu.get("_cooler_water_surface") as ColorRect var water_surface := player_menu.get("_cooler_water_surface") as ColorRect
assert(cooler_outer_wall != null and cooler_outer_wall.visible) assert(cooler_outer_wall != null and cooler_outer_wall.visible)
assert(water_surface.visible and water_surface.material is ShaderMaterial) 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( assert(
StringName( StringName(
(player_menu.get("_sale_buyer_override") as FishBuyerProfile).id (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 var reserved_button := fish_nodes.get(reserved_catch.catch_id) as Button
assert(fish_button != null and fish_button.visible) assert(fish_button != null and fish_button.visible)
assert(reserved_button != null and reserved_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 var sell_button := player_menu.get("_sell_bubble") as Button
assert(sell_button.visible and not sell_button.disabled) 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 await process_frame
var confirmation := player_menu.get("_sale_confirmation") as Control var confirmation := player_menu.get("_sale_confirmation") as Control
var confirm_button := player_menu.get("_confirm_sale_button") as Button 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 > cooler_outer_wall.z_index
) )
_sale_result.clear() _sale_result.clear()
await _activate_focused_button(confirm_button, ui_viewport) await _activate_pointer_control(confirm_button, ui_viewport)
await process_frame await process_frame
assert(not _sale_result.is_empty() and bool(_sale_result[1])) assert(not _sale_result.is_empty() and bool(_sale_result[1]))
assert(not player.inventory.contains_catch_id(fish_catch.catch_id)) assert(not player.inventory.contains_catch_id(fish_catch.catch_id))
assert(player.wallet.get_balance() == balance_before + fish_catch.sale_value) assert(player.wallet.get_balance() == balance_before + fish_catch.sale_value)
assert(not sale_service.is_local_sale_pending()) assert(not sale_service.is_local_sale_pending())
assert(reservations.release(reservation_id)) 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 await process_frame
assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible) assert(shop.visible and (shop.get_node("%ShopPanel") as Control).visible)
assert(not (shop.get_node("%ShopCoolerPage") as Control).visible) assert(not (shop.get_node("%ShopCoolerPage") as Control).visible)
assert(not player_menu.is_shop_cooler_mounted()) assert(not player_menu.is_shop_cooler_mounted())
shop.close_shop() shop.close_shop()
await process_frame await shop.menu_visibility_changed
assert(not shop.visible)
func _assert_sale( func _assert_sale(
@ -634,7 +662,9 @@ func _assert_sale(
assert(bool(_sale_result[1]) == expected_success) assert(bool(_sale_result[1]) == expected_success)
assert(not sale_service.is_local_sale_pending()) assert(not sale_service.is_local_sale_pending())
if expected_success: 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: for catch_id: StringName in catch_ids:
assert(not player.inventory.contains_catch_id(catch_id)) assert(not player.inventory.contains_catch_id(catch_id))
else: else:

View file

@ -3,7 +3,6 @@ extends SceneTree
const MainScene = preload("res://main/main.tscn") const MainScene = preload("res://main/main.tscn")
const FishCatchType = preload("res://fish/fish_catch.gd") const FishCatchType = preload("res://fish/fish_catch.gd")
const FishDataType = preload("res://fish/fish_data.gd") const FishDataType = preload("res://fish/fish_data.gd")
const FishingSpotType = preload("res://fishing/fishing_spot.gd")
func _initialize() -> void: func _initialize() -> void:
@ -40,7 +39,6 @@ func _run() -> void:
var service := main.get_node( var service := main.get_node(
"%NetworkFishShowcaseService" "%NetworkFishShowcaseService"
) as NetworkFishShowcaseService ) as NetworkFishShowcaseService
var fishing_spot := main.get_node("%FishingSpot") as FishingSpotType
assert(player != null and fish_catalog != null and service != null) assert(player != null and fish_catalog != null and service != null)
var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill") var fish: FishDataType = fish_catalog.get_fish_by_id(&"bluegill")
assert(fish != null) 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_fish_catch_id() == fish_catch.catch_id)
assert(player.hotbar.get_selected_item_id().is_empty()) 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.is_local_showcase_visible())
assert(service.get_local_showcase_catch_id() == fish_catch.catch_id) assert(service.get_local_showcase_catch_id() == fish_catch.catch_id)
assert((player.get_node("%HeldFishDisplay") as Node3D).visible) await _wait_for_held_fish_visibility(player, true)
fishing_spot.call("_unhandled_input", press) assert(service.toggle_selected_fish())
await process_frame await process_frame
assert(not service.is_local_showcase_visible()) 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 = ( var saved_weather: WorldWeatherService.Weather = (
world_weather.get_weather() world_weather.get_weather()
) )
var saved_weather_seconds: float = world_weather.get_seconds_remaining() 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()) assert(save_manager.save_now())
var save_path: String = str(save_manager.get("_save_path")) var save_path: String = str(save_manager.get("_save_path"))
@ -116,7 +110,7 @@ func _run() -> void:
== 125 == 125
) )
assert(absf( assert(absf(
float((parsed as Dictionary)["world"]["time_hours"]) - 19.75 float((parsed as Dictionary)["world"]["time_hours"]) - saved_time_hours
) < 0.01) ) < 0.01)
assert( assert(
int((parsed as Dictionary)["world"]["weather"]) int((parsed as Dictionary)["world"]["weather"])
@ -149,7 +143,7 @@ func _run() -> void:
) )
assert(save_manager.load_player_data()) assert(save_manager.load_player_data())
assert(player.experience.get_total_experience() == 125) 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(world_weather.get_weather() == saved_weather)
assert(absf( assert(absf(
world_weather.get_seconds_remaining() - saved_weather_seconds world_weather.get_seconds_remaining() - saved_weather_seconds
@ -163,14 +157,13 @@ func _run() -> void:
FishQuality.Tier.EXCEPTIONAL, FishQuality.Tier.EXCEPTIONAL,
) )
) )
assert(not service.is_local_showcase_visible())
assert(service.toggle_selected_fish())
assert(service.is_local_showcase_visible()) 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) assert(player.inventory.remove_catch_by_id(fish_catch.catch_id) != null)
await process_frame await process_frame
assert(player.hotbar.get_fish_catch_id(1).is_empty()) assert(player.hotbar.get_fish_catch_id(1).is_empty())
assert(not service.is_local_showcase_visible()) 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 = { var valid_state: Dictionary = {
"session_id": "session", "session_id": "session",
@ -202,5 +195,26 @@ func _run() -> void:
print("Fish hotbar showcase validation: PASS") print("Fish hotbar showcase validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() 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,
)

View file

@ -83,40 +83,68 @@ func _validate_barrier_challenge_curve() -> void:
var expected_maximums: Array[int] = [9, 50, 100, 200, 400] var expected_maximums: Array[int] = [9, 50, 100, 200, 400]
var previous_health: int = 0 var previous_health: int = 0
for quality: int in FishQualityType.TIER_COUNT: 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) var health: int = FishQualityType.apply_barrier_health(8, quality)
assert(health > previous_health) assert(health > previous_health)
previous_health = health previous_health = health
assert(FishQualityType.apply_barrier_health(8, -1) == 8) assert(FishQualityType.apply_barrier_health(8, -1) == 8)
assert(FishQualityType.apply_barrier_health(0, FishQualityType.Tier.SHINY) == 4) 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( assert(
FishQualityType.barrier_health_for_catch( FishQualityType.barrier_health_range(-1)
FishQualityType.Tier.BORING, == Vector2i(1, 9)
4,
1.0,
1.0,
) <= 9
) )
var profile := CatchDifficultyProfile.new() var profile := CatchDifficultyProfile.new()
profile.barrier_count_min = 1 profile.barrier_count_min = 1
profile.barrier_count_max = 1 profile.barrier_count_max = 1
profile.barrier_health_min = 8
profile.barrier_health_max = 8
profile.first_barrier_margin = 0.2 profile.first_barrier_margin = 0.2
profile.final_barrier_margin = 0.2 profile.final_barrier_margin = 0.2
profile.minimum_barrier_spacing = 0.1 profile.minimum_barrier_spacing = 0.1
@ -141,12 +169,9 @@ func _validate_barrier_challenge_curve() -> void:
assert(health <= expected_maximums[quality]) assert(health <= expected_maximums[quality])
controller.queue_free() controller.queue_free()
var shiny_health: int = FishQualityType.barrier_health_for_catch( var shiny_health: int = FishQualityType.barrier_health_range(
FishQualityType.Tier.SHINY, FishQualityType.Tier.SHINY
4, ).y
1.0,
1.0,
)
var base_power_clicks: int = ceili(float(shiny_health) / 1.0) var base_power_clicks: int = ceili(float(shiny_health) / 1.0)
var max_power_clicks: int = ceili( var max_power_clicks: int = ceili(
float(shiny_health) float(shiny_health)

View file

@ -35,8 +35,8 @@ func _run_host() -> void:
var fish_catch: FishCatch = _add_bluegill(main, player) var fish_catch: FishCatch = _add_bluegill(main, player)
assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.assign_fish(1, fish_catch.catch_id))
assert(player.hotbar.select_slot(1)) assert(player.hotbar.select_slot(1))
assert(service.toggle_selected_fish())
assert(service.is_local_showcase_visible()) assert(service.is_local_showcase_visible())
await _wait_for_held_fish_visibility(player, true)
assert(session.set_host_open(true)) assert(session.set_host_open(true))
var remote_peer_id: int = 0 var remote_peer_id: int = 0
@ -66,7 +66,9 @@ func _run_host() -> void:
print("Fish showcase multiplayer host validation: PASS") print("Fish showcase multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -118,8 +120,8 @@ func _run_client() -> void:
var fish_catch: FishCatch = _add_bluegill(main, player) var fish_catch: FishCatch = _add_bluegill(main, player)
assert(player.hotbar.assign_fish(1, fish_catch.catch_id)) assert(player.hotbar.assign_fish(1, fish_catch.catch_id))
assert(player.hotbar.select_slot(1)) assert(player.hotbar.select_slot(1))
assert(service.toggle_selected_fish()) assert(service.is_local_showcase_visible())
assert((player.get("_held_fish_display") as Node3D).visible) await _wait_for_held_fish_visibility(player, true)
await create_timer(2.0).timeout await create_timer(2.0).timeout
assert(service.toggle_selected_fish()) assert(service.toggle_selected_fish())
var held_display := player.get("_held_fish_display") as Node3D 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") print("Fish showcase multiplayer client validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -166,3 +170,18 @@ func _create_initialized_main() -> Node:
await process_frame await process_frame
assert(bool(main.get("_application_initialized"))) assert(bool(main.get("_application_initialized")))
return main 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)

View file

@ -36,8 +36,14 @@ func _run() -> void:
assert(not session.is_open_host()) assert(not session.is_open_host())
assert(service != null and fishing_spot != null and player != null) assert(service != null and fishing_spot != null and player != null)
assert(player.hotbar.get_selected_item_id() == &"basic_fishing_rod") 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 var visuals := player.get_node("Visuals") as Node3D
visuals.rotation.y = PI * 0.5 visuals.rotation.y = PI * 0.5
for _frame: int in 4: for _frame: int in 4:
@ -50,8 +56,8 @@ func _run() -> void:
fishing_spot.set("_cast_charge", 0.32) fishing_spot.set("_cast_charge", 0.32)
fishing_spot.call("_update_cast_charge", 0.0) fishing_spot.call("_update_cast_charge", 0.0)
var aimed_target: Vector3 = fishing_spot.get("_cast_target") var aimed_target: Vector3 = fishing_spot.get("_cast_target")
assert(aimed_target.x < -1.35) assert(aimed_target.x < player.global_position.x - 0.85)
assert(is_equal_approx(aimed_target.y, 2.51)) assert(is_equal_approx(aimed_target.y, pond_surface_y))
assert(fishing_spot.is_target_fishable(aimed_target)) assert(fishing_spot.is_target_fishable(aimed_target))
fishing_spot.call("_confirm_cast") fishing_spot.call("_confirm_cast")
assert(fishing_spot.state == FishingSpotType.FishingState.CASTING) assert(fishing_spot.state == FishingSpotType.FishingState.CASTING)
@ -68,7 +74,7 @@ func _run() -> void:
var attempts: Dictionary = service.get("_attempts") var attempts: Dictionary = service.get("_attempts")
var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id()) var attempt: NetworkFishingAttempt = attempts.get(session.get_local_peer_id())
assert(attempt != null) 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)) assert(attempt.bobber_position.is_equal_approx(attempt.target))
fishing_spot.set("_withdrawal_input_held", true) fishing_spot.set("_withdrawal_input_held", true)
@ -98,7 +104,7 @@ func _run() -> void:
assert(not bobber.visible) assert(not bobber.visible)
# A private host rolls and retains the authoritative catch before the fight # 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.call("_begin_aiming", player)
fishing_spot.set("_cast_charge", 0.32) fishing_spot.set("_cast_charge", 0.32)
fishing_spot.call("_update_cast_charge", 0.0) fishing_spot.call("_update_cast_charge", 0.0)
@ -124,29 +130,40 @@ func _run() -> void:
fish, fish,
) )
assert(fish_catch != null and fish_catch.is_valid()) assert(fish_catch != null and fish_catch.is_valid())
var baseline_controller := CatchController.new() var reference_controller := CatchController.new()
root.add_child(baseline_controller) root.add_child(reference_controller)
baseline_controller.start_authoritative_encounter( reference_controller.start_authoritative_encounter(
fish.catch_profile, fish.catch_profile,
attempt.reel_speed, attempt.reel_speed,
attempt.barrier_damage, attempt.barrier_damage,
attempt.encounter_seed, attempt.encounter_seed,
FishQuality.Tier.BORING, fish_catch.quality,
) )
var quality_barriers: Array = attempt.controller.get("_barriers") var quality_barriers: Array = attempt.controller.get("_barriers")
var baseline_barriers: Array = baseline_controller.get("_barriers") var reference_barriers: Array = reference_controller.get("_barriers")
assert(quality_barriers.size() == baseline_barriers.size()) assert(quality_barriers.size() == reference_barriers.size())
for barrier_index: int in quality_barriers.size(): for barrier_index: int in quality_barriers.size():
var quality_barrier: RefCounted = quality_barriers[barrier_index] 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( assert(
int(quality_barrier.get("maximum_health")) is_equal_approx(
== FishQuality.apply_barrier_health( float(quality_barrier.get("position")),
int(baseline_barrier.get("maximum_health")), float(reference_barrier.get("position")),
fish_catch.quality,
) )
) )
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(), "") service.call("_cancel_attempt", session.get_local_peer_id(), "")
await process_frame await process_frame
assert(not service.has_local_attempt()) assert(not service.has_local_attempt())
@ -154,5 +171,7 @@ func _run() -> void:
print("Fishing authority validation: PASS") print("Fishing authority validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -150,7 +150,9 @@ func _run_host() -> void:
print("Fishing multiplayer host validation: PASS") print("Fishing multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -325,7 +327,9 @@ func _run_client() -> void:
print("Fishing multiplayer client validation: PASS") print("Fishing multiplayer client validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -266,11 +266,47 @@ func _validate_bite_wait_distribution() -> void:
if wait_seconds >= FishingSpotType.BITE_LONG_MAX_SECONDS: if wait_seconds >= FishingSpotType.BITE_LONG_MAX_SECONDS:
very_long_count += 1 very_long_count += 1
var average_wait_seconds: float = total_wait_seconds / 10000.0 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(typical_or_long_count > quick_count)
assert(very_long_count >= 100 and very_long_count <= 300) assert(absf(very_long_ratio - very_long_probability) <= 0.005)
assert(average_wait_seconds >= 45.0) assert(absf(
assert(average_wait_seconds <= 49.0) average_wait_seconds - expected_average_wait_seconds
) <= 1.0)
fishing_spot.queue_free() fishing_spot.queue_free()
await process_frame await process_frame
@ -305,11 +341,16 @@ func _validate_remote_presentation() -> void:
fish_catch.sale_value = 1 fish_catch.sale_value = 1
assert(fish_catch.is_valid()) assert(fish_catch.is_valid())
presentation.play_return(fish_catch) presentation.play_return(fish_catch)
await create_timer(0.6).timeout
var catch_display := player.find_child( var catch_display := player.find_child(
"CatchDisplay", true, false "CatchDisplay", true, false
) as Node3D ) as Node3D
assert(catch_display != null) 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) assert(catch_display.visible)
await presentation.return_completed await presentation.return_completed
assert(not catch_display.visible) assert(not catch_display.visible)

View file

@ -62,7 +62,9 @@ func _run() -> void:
await _capture_inventory_pages(player_menu) await _capture_inventory_pages(player_menu)
print("Inventory notepad artwork validation: PASS") print("Inventory notepad artwork validation: PASS")
presentation_stage.queue_free() presentation_stage.queue_free()
await process_frame for _frame: int in 10:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -56,7 +56,9 @@ func _run_host() -> void:
print("Job multiplayer host validation: PASS") print("Job multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -93,7 +95,9 @@ func _run_client() -> void:
print("Job multiplayer client validation: PASS") print("Job multiplayer client validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -187,8 +187,10 @@ func _run() -> void:
assert(int(save_data.get("save_version", -1)) == 7) assert(int(save_data.get("save_version", -1)) == 7)
assert(PlayerJobService.validate_save_data(save_data.get("jobs", {}))) assert(PlayerJobService.validate_save_data(save_data.get("jobs", {})))
session.disconnect_session("Job system validation complete.")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
print("Job system validation: PASS") print("Job system validation: PASS")
quit() quit()

View file

@ -79,10 +79,15 @@ func _run() -> void:
"_show_section_immediate", PlayerMenu.Section.PROFILE "_show_section_immediate", PlayerMenu.Section.PROFILE
) )
await process_frame await process_frame
assert(not bool( assert(bool(
player_menu.call("_handle_direct_page_shortcut", shortcut) player_menu.call("_handle_direct_page_shortcut", shortcut)
)) ))
await create_timer(0.5).timeout
assert(player_menu.visible) assert(player_menu.visible)
assert(logbook.visible)
assert(
player_menu.get("_current_section") == PlayerMenu.Section.LOGBOOK
)
player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false) player_menu.close_menu(PlayerMenu.CloseReason.TEARDOWN, false)
print( print(
@ -90,7 +95,8 @@ func _run() -> void:
% [root.size.x, root.size.y] % [root.size.x, root.size.y]
) )
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
quit() quit()

View file

@ -171,7 +171,7 @@ func _validate_page() -> void:
page.activate() page.activate()
await process_frame await process_frame
assert(page.get("_category") == WaterType.Type.FRESH_WATER) 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 var initial_detail_body := page.get("_detail_body") as VBoxContainer
assert(not initial_detail_body.get_parent() is ScrollContainer) assert(not initial_detail_body.get_parent() is ScrollContainer)
assert( assert(
@ -209,7 +209,7 @@ func _validate_page() -> void:
assert(portrait.source_texture == fish.display_texture) assert(portrait.source_texture == fish.display_texture)
assert( assert(
portrait.custom_minimum_size portrait.custom_minimum_size
== LogbookPortraitType.ENTRY_FRAME_SIZE == LogbookPage.CATALOG_PORTRAIT_SIZE
) )
assert( assert(
portrait.expand_mode portrait.expand_mode

View 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

View file

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

View file

@ -12,6 +12,14 @@ func _run() -> void:
root.add_child(game_ui) root.add_child(game_ui)
await process_frame await process_frame
game_ui.set("_gameplay_ui_enabled", true) 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( game_ui.call(
"_on_experience_awarded", "_on_experience_awarded",
50, 50,
@ -20,13 +28,6 @@ func _run() -> void:
1, 1,
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 await process_frame
assert(not panel.visible and not bubble.visible) assert(not panel.visible and not bubble.visible)
game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false) game_ui.call("_on_showcase_changed", "", "", 0.0, 0, false)
@ -43,5 +44,8 @@ func _run() -> void:
await create_timer(1.2).timeout await create_timer(1.2).timeout
assert(not panel.visible and not bubble.visible) assert(not panel.visible and not bubble.visible)
game_ui.queue_free() game_ui.queue_free()
for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
print("Player experience UI validation: PASS") print("Player experience UI validation: PASS")
quit() quit()

View file

@ -114,7 +114,9 @@ func _run_host() -> void:
print("Surface drawing multiplayer host validation: PASS") print("Surface drawing multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -207,7 +209,9 @@ func _run_client() -> void:
print("Surface drawing multiplayer client validation: PASS") print("Surface drawing multiplayer client validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -110,7 +110,9 @@ func _run() -> void:
print("Surface drawing runtime validation: PASS") print("Surface drawing runtime validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -21,6 +21,8 @@ func _run() -> void:
await _validate_blocked_author_visibility() await _validate_blocked_author_visibility()
_validate_peer_capability_tracking() _validate_peer_capability_tracking()
print("Surface drawing validation: PASS") print("Surface drawing validation: PASS")
for _frame: int in 4:
await process_frame
quit() quit()
@ -118,6 +120,7 @@ func _validate_art_unlocks() -> void:
assert(unlocks.is_brush_size_unlocked(4)) assert(unlocks.is_brush_size_unlocked(4))
assert(unlocks.is_grid_size_unlocked(128)) assert(unlocks.is_grid_size_unlocked(128))
assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1)) assert(not unlocks.restore_mask(PlayerArtUnlocks.ALL_UNLOCK_MASK + 1))
unlocks.free()
func _validate_grid_snapping() -> void: func _validate_grid_snapping() -> void:
@ -265,6 +268,7 @@ func _validate_blocked_author_visibility() -> void:
}) })
var world := Node3D.new() var world := Node3D.new()
root.add_child(world) root.add_child(world)
world.add_child(relationships)
var canvas := SurfaceDrawingCanvas.new() var canvas := SurfaceDrawingCanvas.new()
world.add_child(canvas) world.add_child(canvas)
var state: Dictionary = { var state: Dictionary = {

View file

@ -205,6 +205,8 @@ func _run() -> void:
presenter.queue_free() presenter.queue_free()
root.size = original_size 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") print("UI scaling runtime validation: PASS")
quit() quit()

View file

@ -83,7 +83,9 @@ func _run_host() -> void:
print("World time multiplayer host validation: PASS") print("World time multiplayer host validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()
@ -195,7 +197,9 @@ func _run_client() -> void:
print("World time multiplayer client validation: PASS") print("World time multiplayer client validation: PASS")
session.disconnect_session("") session.disconnect_session("")
main.queue_free() main.queue_free()
await process_frame for _frame: int in 4:
await process_frame
await create_timer(0.1).timeout
quit() quit()

View file

@ -41,6 +41,8 @@ func _input(event: InputEvent) -> void:
CONTROLLER_MOTION_THRESHOLD CONTROLLER_MOTION_THRESHOLD
): ):
_set_controller_active(true) _set_controller_active(true)
elif event is InputEventMouseMotion:
_set_controller_active(false)
elif event is InputEventMouseButton: elif event is InputEventMouseButton:
if (event as InputEventMouseButton).pressed: if (event as InputEventMouseButton).pressed:
_set_controller_active(false) _set_controller_active(false)

View file

@ -3,11 +3,28 @@ extends Node
const CONTROLLER_AXIS_THRESHOLD: float = 0.35 const CONTROLLER_AXIS_THRESHOLD: float = 0.35
const SEMANTIC_MATCH_BONUS: float = 1000000.0 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 _controller_active: bool = false
var _focus_navigation_active: bool = false
var _last_focus_center: Vector2 = Vector2.ZERO var _last_focus_center: Vector2 = Vector2.ZERO
var _last_focus_key: String = "" var _last_focus_key: String = ""
var _scope_chain: Array[WeakRef] = [] var _scope_chain: Array[WeakRef] = []
var _pending_focus: WeakRef
var _pointer_button_down: bool = false
var _recovery_generation: int = 0 var _recovery_generation: int = 0
@ -27,23 +44,51 @@ func _input(event: InputEvent) -> void:
if event is InputEventJoypadButton: if event is InputEventJoypadButton:
var button_event := event as InputEventJoypadButton var button_event := event as InputEventJoypadButton
if button_event.pressed: if button_event.pressed:
_pointer_button_down = false
_controller_active = true _controller_active = true
_focus_navigation_active = true
if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER: if button_event.button_index == JOY_BUTTON_LEFT_SHOULDER:
_recovery_generation += 1 _recovery_generation += 1
_scope_chain.clear() _scope_chain.clear()
_pending_focus = null
return
_request_pending_focus()
return return
if event is InputEventJoypadMotion: if event is InputEventJoypadMotion:
if absf((event as InputEventJoypadMotion).axis_value) >= ( if absf((event as InputEventJoypadMotion).axis_value) >= (
CONTROLLER_AXIS_THRESHOLD CONTROLLER_AXIS_THRESHOLD
): ):
_pointer_button_down = false
_controller_active = true _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 return
if event is InputEventMouseButton: if event is InputEventMouseButton:
if (event as InputEventMouseButton).pressed: var mouse_button := event as InputEventMouseButton
_controller_active = false 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 return
if event is InputEventKey and (event as InputEventKey).pressed: if event is InputEventKey and (event as InputEventKey).pressed:
_pointer_button_down = false
_controller_active = 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: func _process(_delta: float) -> void:
@ -58,8 +103,16 @@ func _process(_delta: float) -> void:
func _on_gui_focus_changed(control: Control) -> void: func _on_gui_focus_changed(control: Control) -> void:
_recovery_generation += 1 _recovery_generation += 1
if control != null: if control != null:
if _controller_active and control is BaseButton: if _focus_navigation_active:
_remember_focus(control) _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 return
if not _controller_active or _scope_chain.is_empty(): if not _controller_active or _scope_chain.is_empty():
return return
@ -92,13 +145,18 @@ func _recover_focus(generation: int) -> void:
scope == null scope == null
or not is_instance_valid(scope) or not is_instance_valid(scope)
or not scope.is_inside_tree() or not scope.is_inside_tree()
or not scope.is_visible_in_tree()
): ):
continue continue
if not scope.is_visible_in_tree():
_scope_chain.clear()
return
var replacement := _best_replacement_in(scope) var replacement := _best_replacement_in(scope)
if replacement != null: if replacement != null:
replacement.grab_focus() replacement.grab_focus()
return else:
_scope_chain.clear()
return
_scope_chain.clear()
func _best_replacement_in(scope: Control) -> Control: func _best_replacement_in(scope: Control) -> Control:
@ -124,7 +182,7 @@ func _is_focusable(control: Control) -> bool:
control == null control == null
or not control.is_inside_tree() or not control.is_inside_tree()
or not control.is_visible_in_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 return false
var button := control as BaseButton 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(" (show variants)")
tooltip = tooltip.trim_suffix(" (hide variants)") tooltip = tooltip.trim_suffix(" (hide variants)")
return "%s|%s|%s" % [control.get_class(), label, tooltip] 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

View file

@ -74,6 +74,8 @@ const SUPPLY_PRICE_HORIZONTAL_PADDING: float = 12.0
@onready var _wallet_label: Label = %WalletLabel @onready var _wallet_label: Label = %WalletLabel
@onready var _shop_panel: PanelContainer = %ShopPanel @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_page: Control = %ShopCoolerPage
@onready var _shop_cooler_mount: Control = %ShopCoolerMount @onready var _shop_cooler_mount: Control = %ShopCoolerMount
@onready var _shop_body: HBoxContainer = $ShopPanel/Margin/Layout/Body @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: func activate_shop_cooler_page() -> void:
_cooler_page_active = true _cooler_page_active = true
_shop_panel.show() _shop_panel.show()
_set_shop_panel_background_pointer_blocking(false)
_shop_body.hide() _shop_body.hide()
_feedback.hide() _feedback.hide()
_shop_cooler_page.show() _shop_cooler_page.show()
@ -377,6 +380,7 @@ func deactivate_shop_cooler_page() -> void:
_shop_tab_bar.show() _shop_tab_bar.show()
_cooler_page_active = false _cooler_page_active = false
_cooler_modal_open = false _cooler_modal_open = false
_set_shop_panel_background_pointer_blocking(true)
for tab: OrganizerTab in _shop_tabs: for tab: OrganizerTab in _shop_tabs:
tab.disabled = false tab.disabled = false
_shop_cooler_page.hide() _shop_cooler_page.hide()
@ -385,6 +389,17 @@ func deactivate_shop_cooler_page() -> void:
_feedback.show() _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: func set_shop_cooler_modal_open(is_open: bool) -> void:
_cooler_modal_open = is_open _cooler_modal_open = is_open
for tab: OrganizerTab in _shop_tabs: for tab: OrganizerTab in _shop_tabs:

View file

@ -351,7 +351,10 @@ func _open_confirmation(
else BubbleConfirmationPageType.InitialFocus.CONFIRM 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: func _show_confirmation() -> void:
@ -450,7 +453,10 @@ func _finish_root_entry(generation: int) -> void:
_root_transition_active = false _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: if _root_transition_active or not _root_page.visible:
return return
_root_transition_generation += 1 _root_transition_generation += 1
@ -458,9 +464,14 @@ func _begin_root_exit(completed: Callable) -> void:
_emit_transition_flurry() _emit_transition_flurry()
var generation: int = _root_transition_generation var generation: int = _root_transition_generation
_root_page.transition_out( _root_page.transition_out(
_finish_root_exit.bind(generation), _finish_root_exit.bind(
generation,
Callable() if overlap_next_page else completed,
),
0.0 0.0
) )
if not overlap_next_page:
return
await get_tree().create_timer( await get_tree().create_timer(
UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY UIMotion.BUBBLE_TRANSITION_OVERLAP_DELAY
).timeout ).timeout
@ -471,10 +482,13 @@ func _begin_root_exit(completed: Callable) -> void:
func _finish_root_exit( func _finish_root_exit(
generation: int, generation: int,
completed: Callable,
) -> void: ) -> void:
if generation != _root_transition_generation or not visible: if generation != _root_transition_generation or not visible:
return return
_root_transition_active = false _root_transition_active = false
if completed.is_valid():
completed.call()
func _finish_user_close() -> void: func _finish_user_close() -> void:

View file

@ -194,7 +194,7 @@ func _set_host_toggle_state(
func _select_tab(index: int) -> void: 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 return
_current_tab = index _current_tab = index
_refresh() _refresh()
@ -207,10 +207,12 @@ func _refresh() -> void:
_count_label.text = "%d / %d connected" % [ _count_label.text = "%d / %d connected" % [
_service.get_connected_count(), _service.get_max_players(), _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(): for index: int in _tabs.get_child_count():
var button := _tabs.get_child(index) as Button var button := _tabs.get_child(index) as Button
button.button_pressed = index == _current_tab 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() _refresh_host_settings()
for child: Node in _list.get_children(): for child: Node in _list.get_children():
child.queue_free() child.queue_free()
@ -330,7 +332,7 @@ func _on_host_status_changed(message: String, is_error: bool) -> void:
func _build_active_rows() -> void: func _build_active_rows() -> void:
if _service.is_local_host(): if _service.is_local_moderator():
_build_session_artwork_controls() _build_session_artwork_controls()
var entries := _service.get_entries() var entries := _service.get_entries()
if entries.is_empty(): if entries.is_empty():
@ -339,10 +341,12 @@ func _build_active_rows() -> void:
for entry: PlayerListEntry in entries: for entry: PlayerListEntry in entries:
var row := _make_row() var row := _make_row()
var identity := Label.new() var identity := Label.new()
identity.custom_minimum_size.x = 515 identity.custom_minimum_size.x = 430
var markers: Array[String] = [] var markers: Array[String] = []
if entry.is_host: if entry.is_host:
markers.append("host") markers.append("host")
if entry.is_operator:
markers.append("operator")
if entry.is_local_player: if entry.is_local_player:
markers.append("You") markers.append("You")
identity.text = "%s%s · %s\n%s" % [ identity.text = "%s%s · %s\n%s" % [
@ -351,7 +355,7 @@ func _build_active_rows() -> void:
entry.compact_fingerprint, entry.compact_fingerprint,
entry.continuity_state, entry.continuity_state,
] ]
identity.tooltip_text = NetworkIdentityCrypto.format_fingerprint( identity.tooltip_text = "Full identity fingerprint:\n%s" % (
entry.full_fingerprint entry.full_fingerprint
) )
identity.add_theme_color_override( identity.add_theme_color_override(
@ -381,6 +385,12 @@ func _build_active_rows() -> void:
block.pressed.connect(_confirm_block.bind(entry)) block.pressed.connect(_confirm_block.bind(entry))
UtilityPageStyle.apply_ocean_button(block) UtilityPageStyle.apply_ocean_button(block)
row.add_child(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() var kick := Button.new()
kick.text = "kick" kick.text = "kick"
kick.disabled = not entry.can_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: func _confirm_unban(fingerprint: String) -> void:
_confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void: _confirm("Unban %s?" % NetworkIdentityCrypto.compact_suffix(fingerprint), func() -> void:
_service.unban(fingerprint) _service.unban(fingerprint)

View file

@ -156,6 +156,7 @@ func _ready() -> void:
%ChangeDataFolder.pressed.connect(_choose_data_folder) %ChangeDataFolder.pressed.connect(_choose_data_folder)
%ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player")) %ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player"))
%ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player")) %ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player"))
%CopyPlayerFingerprint.pressed.connect(_copy_player_fingerprint)
%ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host")) %ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host"))
%ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host")) %ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host"))
for index: int in _world_options.size(): for index: int in _world_options.size():
@ -485,6 +486,16 @@ func _refresh_data_page() -> void:
_data_root.override_active _data_root.override_active
or (_network_session != null and _network_session.is_session_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: func _open_data_folder() -> void:
@ -492,6 +503,17 @@ func _open_data_folder() -> void:
_feedback.text = "Could not open the data folder." _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: func _choose_data_folder() -> void:
if _data_root == null or _data_root.override_active: if _data_root == null or _data_root.override_active:
_feedback.text = "The data folder is externally managed." _feedback.text = "The data folder is externally managed."

View file

@ -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." 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 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"] [node name="IdentityGrid" type="GridContainer" parent="DataPage/Paper/Content"]
layout_mode = 2 layout_mode = 2
columns = 2 columns = 2

View file

@ -209,7 +209,7 @@ unique_name_in_owner = true
layout_mode = 2 layout_mode = 2
theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1) theme_override_colors/font_color = Color(0.682, 0.733, 0.761, 1)
theme_override_font_sizes/font_size = 22 theme_override_font_sizes/font_size = 22
text = "v0.6.5-alpha" text = "v0.6.7-alpha"
horizontal_alignment = 1 horizontal_alignment = 1
[node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"] [node name="Spacer" type="Control" parent="ResponsiveTitleStage/TitlePresentationScaleRoot/Center/MainContent"]