Add portable RV homes and decor prototype

This commit is contained in:
Alexander Sellite 2026-08-30 21:37:26 -04:00
parent 81decf2b71
commit fe099f47dd
56 changed files with 5459 additions and 112 deletions

View file

@ -0,0 +1,176 @@
class_name NetworkHomeProtocol
extends RefCounted
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
const CAPABILITY: StringName = &"portable_home_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.HOME_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
const WORLD_SPACE: StringName = &"world"
enum TransitionAction {
ENTER,
EXIT,
}
static func make_space_id(owner_fingerprint: String) -> StringName:
return StringName("rv:%s" % owner_fingerprint)
static func manifest_digest(home_data: Dictionary) -> String:
var sanitized: Dictionary = PlayerHomeStateType.sanitize_save_data(home_data)
if sanitized.is_empty():
return ""
var canonical_owned: Array[Dictionary] = []
var owned: Dictionary = sanitized["owned_decor"]
var owned_ids: Array[String] = []
for value: Variant in owned.keys():
owned_ids.append(str(value))
owned_ids.sort()
for decor_id: String in owned_ids:
canonical_owned.append({
"id": decor_id,
"quantity": int(owned[decor_id]),
})
var placements: Array[Dictionary] = []
for value: Variant in sanitized["placements"]:
placements.append((value as Dictionary).duplicate(true))
placements.sort_custom(func(first: Dictionary, second: Dictionary) -> bool:
return str(first["instance_id"]) < str(second["instance_id"])
)
var canonical: Dictionary = {
"schema_version": int(sanitized["schema_version"]),
"tier": int(sanitized["tier"]),
"exterior_id": str(sanitized["exterior_id"]),
"privacy": int(sanitized["privacy"]),
"owned": canonical_owned,
"placements": placements,
"revision": int(sanitized["revision"]),
"next_instance_sequence": int(sanitized["next_instance_sequence"]),
}
return JSON.stringify(canonical).sha256_text()
static func manifest_signature_fields(data: Dictionary) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("owner_fingerprint", "")),
int((data.get("home", {}) as Dictionary).get("revision", -1)),
str(data.get("digest", "")),
]
static func validate_manifest_request(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
if (
not _valid_id(data.get("request_id"))
or not _valid_id(data.get("session_id"))
or not NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
or typeof(data.get("home")) != TYPE_DICTIONARY
or typeof(data.get("digest")) != TYPE_STRING
or str(data["digest"]).length() != 64
or typeof(data.get("sender_signature")) != TYPE_PACKED_BYTE_ARRAY
):
return false
var sanitized: Dictionary = PlayerHomeStateType.sanitize_save_data(
data["home"]
)
return (
not sanitized.is_empty()
and manifest_digest(sanitized) == str(data["digest"])
)
static func validate_manifest_broadcast(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
return (
typeof(data.get("owner_peer_id")) == TYPE_INT
and int(data["owner_peer_id"]) > 0
and NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
and typeof(data.get("home")) == TYPE_DICTIONARY
and not PlayerHomeStateType.sanitize_save_data(data["home"]).is_empty()
and _valid_transform(data.get("exterior_transform"))
and typeof(data.get("interior_index")) == TYPE_INT
and int(data["interior_index"]) >= 0
and int(data["interior_index"]) < 128
)
static func transition_signature_fields(data: Dictionary) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
int(data.get("action", -1)),
str(data.get("owner_fingerprint", "")),
]
static func validate_transition_request(value: Variant) -> bool:
if typeof(value) != TYPE_DICTIONARY:
return false
var data: Dictionary = value
return (
_valid_id(data.get("request_id"))
and _valid_id(data.get("session_id"))
and typeof(data.get("action")) == TYPE_INT
and int(data["action"]) >= TransitionAction.ENTER
and int(data["action"]) <= TransitionAction.EXIT
and NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
)
static func transform_to_array(value: Transform3D) -> Array[float]:
return [
value.basis.x.x, value.basis.x.y, value.basis.x.z,
value.basis.y.x, value.basis.y.y, value.basis.y.z,
value.basis.z.x, value.basis.z.y, value.basis.z.z,
value.origin.x, value.origin.y, value.origin.z,
]
static func array_to_transform(value: Variant) -> Transform3D:
if not _valid_transform(value):
return Transform3D()
var fields: Array = value
return Transform3D(
Basis(
Vector3(float(fields[0]), float(fields[1]), float(fields[2])),
Vector3(float(fields[3]), float(fields[4]), float(fields[5])),
Vector3(float(fields[6]), float(fields[7]), float(fields[8])),
),
Vector3(float(fields[9]), float(fields[10]), float(fields[11])),
)
static func _valid_transform(value: Variant) -> bool:
if typeof(value) != TYPE_ARRAY or (value as Array).size() != 12:
return false
for component: Variant in value:
if (
typeof(component) not in [TYPE_FLOAT, TYPE_INT]
or not is_finite(float(component))
):
return false
return true
static func _valid_id(value: Variant) -> bool:
return (
typeof(value) == TYPE_STRING
and not str(value).is_empty()
and str(value).length() <= MAX_ID_LENGTH
)

View file

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

View file

@ -0,0 +1,956 @@
class_name NetworkHomeService
extends Node
const NetworkHomeProtocolType = preload(
"res://network/network_home_protocol.gd"
)
const PlayerHomeStateType = preload("res://homes/player_home_state.gd")
signal local_transition_finished(accepted: bool, message: String)
signal local_space_changed(space_id: StringName, owner_fingerprint: String)
signal local_manifest_rejected(message: String)
const MAX_MANIFESTS: int = 128
var _session: NetworkSession
var _spawn_service: PlayerSpawnService
var _local_home: PlayerHomeStateType
var _save_manager: PlayerSaveManager
var _world_service: HomeWorldService
var _bag: PlayerBag
var _inventory_layout: PlayerInventoryLayout
var _hotbar: PlayerHotbar
var _manifests_by_fingerprint: Dictionary[String, Dictionary] = {}
var _fingerprint_by_peer: Dictionary[int, String] = {}
var _authorized_owned_counts: Dictionary[int, Dictionary] = {}
var _authorization_result_owner: Dictionary[String, int] = {}
var _next_interior_index: int = 0
var _suppress_local_submission: bool = false
var _publish_queued: bool = false
func setup(
session: NetworkSession,
spawn_service: PlayerSpawnService,
local_home: PlayerHomeStateType,
save_manager: PlayerSaveManager,
world_service: HomeWorldService,
bag: PlayerBag,
inventory_layout: PlayerInventoryLayout,
hotbar: PlayerHotbar,
) -> void:
_session = session
_spawn_service = spawn_service
_local_home = local_home
_save_manager = save_manager
_world_service = world_service
_bag = bag
_inventory_layout = inventory_layout
_hotbar = hotbar
if not _session.state_changed.is_connected(_on_session_state_changed):
_session.state_changed.connect(_on_session_state_changed)
if not _session.join_authenticated.is_connected(_on_join_authenticated):
_session.join_authenticated.connect(_on_join_authenticated)
if not _session.peer_authenticated.is_connected(_on_peer_authenticated):
_session.peer_authenticated.connect(_on_peer_authenticated)
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _local_home.changed.is_connected(_on_local_home_changed):
_local_home.changed.connect(_on_local_home_changed)
func get_local_space() -> StringName:
if _session == null or not _session.is_gameplay_session_active():
return NetworkHomeProtocolType.WORLD_SPACE
return _session.get_peer_space(_session.get_local_peer_id())
func get_local_home_owner_fingerprint() -> String:
var space_id: String = String(get_local_space())
return space_id.trim_prefix("rv:") if space_id.begins_with("rv:") else ""
func can_local_decorate() -> bool:
return (
get_local_home_owner_fingerprint()
== _session.get_local_identity_fingerprint()
)
func get_authoritative_owned_count(
peer_id: int,
decor_id: StringName,
) -> int:
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
if authorized.has(String(decor_id)):
return int(authorized[String(decor_id)])
var fingerprint: String = _fingerprint_for_peer(peer_id)
var manifest: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
if manifest.is_empty():
return -1
var home: Dictionary = manifest.get("home", {})
var owned: Dictionary = home.get("owned_decor", {})
return int(owned.get(String(decor_id), 0))
func place_local_decor(
decor_id: StringName,
position: Vector3,
yaw_radians: float,
mode: PlayerHomeStateType.PlacementMode,
) -> String:
if (
not can_local_decorate()
or _bag == null
or _bag.get_quantity(decor_id) <= 0
or _world_service == null
):
return ""
var owner_fingerprint: String = (
_session.get_local_identity_fingerprint()
)
var normalized: Dictionary = (
_local_home.get_nearest_valid_placement_transform(
decor_id,
position,
yaw_radians,
mode,
"",
Callable(
self, "_candidate_clear_of_players"
).bind(owner_fingerprint, decor_id),
)
)
if normalized.is_empty():
return ""
var instance_id: String = _local_home.place_decor(
decor_id,
normalized["position"],
float(normalized["yaw"]),
mode,
)
if instance_id.is_empty():
return ""
if not _bag.remove_item(decor_id, 1):
_local_home.remove_placement(instance_id)
return ""
_save_manager.save_if_dirty()
return instance_id
func remove_local_decor(instance_id: String) -> bool:
if (
not can_local_decorate()
or _bag == null
or _inventory_layout == null
or _hotbar == null
):
return false
var placement: Dictionary = _local_home.get_placement(instance_id)
if placement.is_empty():
return false
var decor_id := StringName(str(placement["decor_id"]))
var target_hotbar_slot: int = _hotbar.find_item_slot(decor_id)
if target_hotbar_slot < 0:
target_hotbar_slot = _hotbar.find_first_empty_slot()
var prepared_hotbar_placement: bool = false
if (
target_hotbar_slot >= 0
and _inventory_layout.get_container(
PlayerInventoryLayout.EntryKind.ITEM,
decor_id,
) < 0
):
prepared_hotbar_placement = (
_inventory_layout.prepare_new_item_placement(
decor_id,
PlayerInventoryLayout.InventoryContainer.HOTBAR,
target_hotbar_slot,
)
)
var returned_to_inventory: bool = _bag.add_item(decor_id, 1)
if not returned_to_inventory:
if prepared_hotbar_placement:
_inventory_layout.cancel_prepared_item_placement(decor_id)
returned_to_inventory = _bag.add_item_to_storage(decor_id, 1)
if not returned_to_inventory:
return false
if target_hotbar_slot >= 0:
_hotbar.assign_item(target_hotbar_slot, decor_id)
_hotbar.select_slot(target_hotbar_slot)
if not _local_home.remove_placement(instance_id):
_bag.remove_item(decor_id, 1)
return false
_save_manager.save_if_dirty()
return true
func update_local_decor(
instance_id: String,
position: Vector3,
yaw_radians: float,
mode: PlayerHomeStateType.PlacementMode,
) -> bool:
var normalized: Dictionary = get_valid_local_decor_transform(
instance_id, position, yaw_radians, mode
)
if normalized.is_empty():
return false
if not _local_home.update_placement(
instance_id,
normalized["position"],
float(normalized["yaw"]),
mode,
):
return false
_save_manager.save_if_dirty()
return true
func get_valid_local_decor_transform(
instance_id: String,
position: Vector3,
yaw_radians: float,
mode: PlayerHomeStateType.PlacementMode,
) -> Dictionary:
if not can_local_decorate() or _world_service == null:
return {}
var placement: Dictionary = _local_home.get_placement(instance_id)
if placement.is_empty():
return {}
var decor_id := StringName(str(placement.get("decor_id", "")))
return _local_home.get_valid_update_transform(
instance_id,
position,
yaw_radians,
mode,
Callable(
self, "_candidate_clear_of_players"
).bind(
_session.get_local_identity_fingerprint(), decor_id
),
)
func _candidate_clear_of_players(
candidate: Dictionary,
owner_fingerprint: String,
decor_id: StringName,
) -> bool:
return (
_world_service != null
and _world_service.is_decor_placement_clear_of_players(
owner_fingerprint,
decor_id,
candidate["position"],
float(candidate["yaw"]),
)
)
func request_enter(owner_fingerprint: String) -> bool:
if (
_session == null
or not _session.is_gameplay_session_active()
or not _manifests_by_fingerprint.has(owner_fingerprint)
or get_local_space() != NetworkHomeProtocolType.WORLD_SPACE
):
return false
return _submit_transition(
NetworkHomeProtocolType.TransitionAction.ENTER,
owner_fingerprint,
)
func request_exit() -> bool:
var owner_fingerprint: String = get_local_home_owner_fingerprint()
if owner_fingerprint.is_empty():
return false
return _submit_transition(
NetworkHomeProtocolType.TransitionAction.EXIT,
owner_fingerprint,
)
func authorize_decor_purchase(
peer_id: int,
product_id: StringName,
resulting_count: int,
result_id: String,
) -> void:
if not _session.is_host() or peer_id <= 0 or result_id.is_empty():
return
var counts: Dictionary = _authorized_owned_counts.get(peer_id, {})
counts[String(product_id)] = resulting_count
_authorized_owned_counts[peer_id] = counts
_authorization_result_owner[result_id] = peer_id
func finalize_decor_purchase(
peer_id: int,
result_id: String,
applied: bool,
) -> void:
if _authorization_result_owner.get(result_id, 0) != peer_id:
return
_authorization_result_owner.erase(result_id)
if applied:
return
_authorized_owned_counts.erase(peer_id)
func refresh_world_presentations() -> void:
if _world_service == null:
return
_world_service.clear_presentations()
if not _world_service.is_world_ready():
return
if not _session.is_host():
for manifest: Dictionary in _manifests_by_fingerprint.values():
_world_service.apply_manifest(manifest)
return
var local_fingerprint: String = _session.get_local_identity_fingerprint()
if (
not _session.is_dedicated_host()
and not _manifests_by_fingerprint.has(local_fingerprint)
):
publish_local_home()
var fingerprints: Array[String] = []
fingerprints.assign(_manifests_by_fingerprint.keys())
fingerprints.sort()
var reserved: Array[Transform3D] = []
for fingerprint: String in fingerprints:
var manifest: Dictionary = _manifests_by_fingerprint[fingerprint]
var transform: Transform3D = _world_service.choose_exterior_transform(
int(manifest["owner_peer_id"]), reserved
)
reserved.append(transform)
manifest["exterior_transform"] = (
NetworkHomeProtocolType.transform_to_array(transform)
)
_manifests_by_fingerprint[fingerprint] = manifest
_broadcast_manifest(manifest)
for fingerprint: String in fingerprints:
var manifest: Dictionary = _manifests_by_fingerprint[fingerprint]
_spawn_peer_at_exterior(
int(manifest["owner_peer_id"]), fingerprint
)
func publish_local_home() -> bool:
if (
_session == null
or not _session.is_gameplay_session_active()
or _session.is_dedicated_host()
or _local_home == null
):
return false
if _session.is_host() and (
_world_service == null or not _world_service.is_world_ready()
):
return false
var home_data: Dictionary = _local_home.to_save_data()
var request: Dictionary = {
"request_id": _new_id("home"),
"session_id": _session.get_session_id(),
"owner_fingerprint": _session.get_local_identity_fingerprint(),
"home": home_data,
"digest": NetworkHomeProtocolType.manifest_digest(home_data),
}
request["sender_signature"] = _session.sign_local_action(
"portable_home_manifest",
NetworkHomeProtocolType.manifest_signature_fields(request),
)
if _session.is_host():
_process_manifest_request(_session.get_local_peer_id(), request)
else:
submit_home_manifest.rpc_id(1, request)
return true
@rpc(
"any_peer", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func submit_home_manifest(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
_session == null
or not _session.is_host()
or not _session.is_authenticated_peer(sender_id)
):
return
_process_manifest_request(sender_id, data)
func _process_manifest_request(peer_id: int, data: Dictionary) -> void:
if (
not NetworkHomeProtocolType.validate_manifest_request(data)
or str(data["session_id"]) != _session.get_session_id()
):
_reject_manifest(peer_id, "The RV update was invalid.")
return
var record := _session.get_peer_record(peer_id)
var fingerprint: String = str(data["owner_fingerprint"])
if (
record == null
or record.identity_fingerprint != fingerprint
or not _session.verify_peer_action(
peer_id,
"portable_home_manifest",
NetworkHomeProtocolType.manifest_signature_fields(data),
data["sender_signature"],
)
):
_reject_manifest(peer_id, "The RV owner could not be verified.")
return
var home_data: Dictionary = PlayerHomeStateType.sanitize_save_data(
data["home"]
)
var prior: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
var is_first_manifest: bool = prior.is_empty()
if not _valid_manifest_revision(peer_id, prior, home_data):
_reject_manifest(peer_id, "The RV update was rejected by the host.")
return
if not _moved_decor_clear_of_players(fingerprint, prior, home_data):
_reject_manifest(peer_id, "A player is in the way of that decor.")
return
var manifest: Dictionary
if prior.is_empty():
if _manifests_by_fingerprint.size() >= MAX_MANIFESTS:
_reject_manifest(peer_id, "This room cannot place another RV.")
return
var reserved: Array[Transform3D] = []
for existing: Dictionary in _manifests_by_fingerprint.values():
reserved.append(NetworkHomeProtocolType.array_to_transform(
existing["exterior_transform"]
))
var exterior_transform: Transform3D = (
_world_service.choose_exterior_transform(peer_id, reserved)
)
manifest = {
"owner_peer_id": peer_id,
"owner_fingerprint": fingerprint,
"home": home_data,
"exterior_transform": (
NetworkHomeProtocolType.transform_to_array(exterior_transform)
),
"interior_index": _next_interior_index,
}
_next_interior_index += 1
else:
manifest = prior.duplicate(true)
manifest["home"] = home_data
manifest["owner_peer_id"] = peer_id
_manifests_by_fingerprint[fingerprint] = manifest
_fingerprint_by_peer[peer_id] = fingerprint
_consume_owned_authorizations(peer_id, home_data)
_broadcast_manifest(manifest)
if is_first_manifest:
_spawn_peer_at_exterior(peer_id, fingerprint)
func _valid_manifest_revision(
peer_id: int,
prior_manifest: Dictionary,
home_data: Dictionary,
) -> bool:
if prior_manifest.is_empty():
# A portable RV is owned by the player's signed local save. A new host has
# no prior room ledger with which to compare its first manifest.
return true
var prior_home: Dictionary = prior_manifest["home"]
if (
int(home_data["revision"]) <= int(prior_home["revision"])
or int(home_data["tier"]) != int(prior_home["tier"])
or str(home_data["exterior_id"]) != str(prior_home["exterior_id"])
):
return false
var prior_owned: Dictionary = prior_home["owned_decor"]
var next_owned: Dictionary = home_data["owned_decor"]
var all_ids: Dictionary[String, bool] = {}
for value: Variant in prior_owned.keys():
all_ids[str(value)] = true
for value: Variant in next_owned.keys():
all_ids[str(value)] = true
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
for product_id: String in all_ids:
var prior_count: int = int(prior_owned.get(product_id, 0))
var next_count: int = int(next_owned.get(product_id, 0))
if prior_count == next_count:
continue
if next_count < prior_count or int(authorized.get(product_id, -1)) != next_count:
return false
return true
func _moved_decor_clear_of_players(
owner_fingerprint: String,
prior_manifest: Dictionary,
home_data: Dictionary,
) -> bool:
if _world_service == null:
return false
var prior_by_instance: Dictionary[String, Dictionary] = {}
if not prior_manifest.is_empty():
var prior_home: Dictionary = prior_manifest.get("home", {})
for value: Variant in prior_home.get("placements", []):
if typeof(value) != TYPE_DICTIONARY:
continue
var prior_placement: Dictionary = value
prior_by_instance[str(
prior_placement.get("instance_id", "")
)] = prior_placement
for value: Variant in home_data.get("placements", []):
if typeof(value) != TYPE_DICTIONARY:
return false
var placement: Dictionary = value
var instance_id: String = str(placement.get("instance_id", ""))
var prior_placement: Dictionary = prior_by_instance.get(
instance_id, {}
)
if (
not prior_placement.is_empty()
and _same_decor_transform(prior_placement, placement)
):
continue
var decor_id := StringName(str(placement.get("decor_id", "")))
var position: Vector3 = _placement_position(placement)
if (
not position.is_finite()
or not _world_service.is_decor_placement_clear_of_players(
owner_fingerprint,
decor_id,
position,
float(placement.get("yaw", 0.0)),
)
):
return false
return true
static func _same_decor_transform(
first: Dictionary,
second: Dictionary,
) -> bool:
if str(first.get("decor_id", "")) != str(second.get("decor_id", "")):
return false
var first_position: Vector3 = _placement_position(first)
var second_position: Vector3 = _placement_position(second)
return (
first_position.is_equal_approx(second_position)
and is_equal_approx(
float(first.get("yaw", 0.0)),
float(second.get("yaw", 0.0)),
)
)
static func _placement_position(placement: Dictionary) -> Vector3:
var values: Array = placement.get("position", [])
if values.size() != 3:
return Vector3.INF
return Vector3(
float(values[0]), float(values[1]), float(values[2])
)
func _consume_owned_authorizations(peer_id: int, home_data: Dictionary) -> void:
var authorized: Dictionary = _authorized_owned_counts.get(peer_id, {})
if authorized.is_empty():
return
var owned: Dictionary = home_data["owned_decor"]
for product_id: String in authorized.keys():
if int(owned.get(product_id, -1)) == int(authorized[product_id]):
authorized.erase(product_id)
if authorized.is_empty():
_authorized_owned_counts.erase(peer_id)
else:
_authorized_owned_counts[peer_id] = authorized
func _broadcast_manifest(manifest: Dictionary) -> void:
if _world_service != null and _world_service.is_world_ready():
_world_service.apply_manifest(manifest)
for peer_id: int in _session.get_authenticated_peer_ids():
if peer_id == 1:
continue
receive_home_manifest.rpc_id(peer_id, manifest)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_manifest(data: Dictionary) -> void:
if not NetworkHomeProtocolType.validate_manifest_broadcast(data):
return
var fingerprint: String = str(data["owner_fingerprint"])
_manifests_by_fingerprint[fingerprint] = data.duplicate(true)
_fingerprint_by_peer[int(data["owner_peer_id"])] = fingerprint
if _world_service != null and _world_service.is_world_ready():
_world_service.apply_manifest(data)
func _reject_manifest(peer_id: int, message: String) -> void:
var fingerprint: String = _fingerprint_for_peer(peer_id)
var canonical: Dictionary = _manifests_by_fingerprint.get(fingerprint, {})
if peer_id == _session.get_local_peer_id():
_apply_manifest_rejection(message, canonical)
else:
receive_home_manifest_rejection.rpc_id(peer_id, message, canonical)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_manifest_rejection(
message: String,
canonical: Dictionary,
) -> void:
_apply_manifest_rejection(message, canonical)
func _apply_manifest_rejection(message: String, canonical: Dictionary) -> void:
if (
canonical.is_empty()
or not NetworkHomeProtocolType.validate_manifest_broadcast(canonical)
):
local_manifest_rejected.emit(message)
return
_suppress_local_submission = true
_local_home.restore_from_save_data(canonical["home"])
_suppress_local_submission = false
_save_manager.save_if_dirty()
receive_home_manifest(canonical)
local_manifest_rejected.emit(message)
func _submit_transition(action: int, owner_fingerprint: String) -> bool:
var request: Dictionary = {
"request_id": _new_id("home_transition"),
"session_id": _session.get_session_id(),
"action": action,
"owner_fingerprint": owner_fingerprint,
}
request["sender_signature"] = _session.sign_local_action(
"portable_home_transition",
NetworkHomeProtocolType.transition_signature_fields(request),
)
if _session.is_host():
_process_transition_request(_session.get_local_peer_id(), request)
else:
submit_home_transition.rpc_id(1, request)
return true
@rpc(
"any_peer", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func submit_home_transition(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if (
not _session.is_host()
or not _session.is_authenticated_peer(sender_id)
):
return
_process_transition_request(sender_id, data)
func _process_transition_request(peer_id: int, data: Dictionary) -> void:
if (
not NetworkHomeProtocolType.validate_transition_request(data)
or str(data["session_id"]) != _session.get_session_id()
or not _session.verify_peer_action(
peer_id,
"portable_home_transition",
NetworkHomeProtocolType.transition_signature_fields(data),
data["sender_signature"],
)
):
_send_transition_rejection(peer_id, "The RV transition was invalid.")
return
var owner_fingerprint: String = str(data["owner_fingerprint"])
var action: int = int(data["action"])
var avatar: Player = _spawn_service.get_avatar(peer_id)
var manifest: Dictionary = _manifests_by_fingerprint.get(
owner_fingerprint, {}
)
if avatar == null or manifest.is_empty():
_send_transition_rejection(peer_id, "That RV is unavailable.")
return
if action == NetworkHomeProtocolType.TransitionAction.ENTER:
var record := _session.get_peer_record(peer_id)
var owner_is_local: bool = (
record != null and record.identity_fingerprint == owner_fingerprint
)
var privacy: int = int((manifest["home"] as Dictionary)["privacy"])
if (
(not owner_is_local and privacy != PlayerHomeStateType.Privacy.OPEN)
or _session.get_peer_space(peer_id)
!= NetworkHomeProtocolType.WORLD_SPACE
or not _world_service.is_avatar_near_exterior(
avatar, owner_fingerprint
)
):
_send_transition_rejection(peer_id, "You cannot enter that RV.")
return
_apply_host_transition(
peer_id,
NetworkHomeProtocolType.make_space_id(owner_fingerprint),
owner_fingerprint,
_world_service.get_interior_spawn_transform(
owner_fingerprint, peer_id
),
)
return
if (
_session.get_peer_space(peer_id)
!= NetworkHomeProtocolType.make_space_id(owner_fingerprint)
or not _world_service.is_avatar_near_interior_exit(
avatar, owner_fingerprint
)
):
_send_transition_rejection(peer_id, "Move closer to the RV door.")
return
_apply_host_transition(
peer_id,
NetworkHomeProtocolType.WORLD_SPACE,
owner_fingerprint,
_world_service.get_exterior_spawn_transform(owner_fingerprint),
)
func _spawn_peer_at_exterior(
peer_id: int,
owner_fingerprint: String,
) -> void:
if (
not _session.is_host()
or _session.get_peer_space(peer_id)
!= NetworkHomeProtocolType.WORLD_SPACE
or _spawn_service.get_avatar(peer_id) == null
):
return
_apply_host_transition(
peer_id,
NetworkHomeProtocolType.WORLD_SPACE,
owner_fingerprint,
_world_service.get_exterior_spawn_transform(owner_fingerprint),
)
func _apply_host_transition(
peer_id: int,
space_id: StringName,
owner_fingerprint: String,
transform: Transform3D,
) -> void:
var result: Dictionary = {
"peer_id": peer_id,
"space_id": String(space_id),
"owner_fingerprint": owner_fingerprint,
"transform": NetworkHomeProtocolType.transform_to_array(transform),
}
_apply_transition_result(result)
for recipient_id: int in _session.get_authenticated_peer_ids():
if recipient_id == 1:
continue
receive_home_transition.rpc_id(recipient_id, result)
_session.publish_authoritative_teleport(peer_id)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_transition(data: Dictionary) -> void:
if (
typeof(data.get("peer_id")) != TYPE_INT
or typeof(data.get("space_id")) not in [TYPE_STRING, TYPE_STRING_NAME]
or not NetworkIdentityCrypto.valid_fingerprint(
data.get("owner_fingerprint")
)
):
return
var transform: Transform3D = NetworkHomeProtocolType.array_to_transform(
data.get("transform")
)
if transform == Transform3D():
return
_apply_transition_result(data)
func _apply_transition_result(data: Dictionary) -> void:
var peer_id: int = int(data["peer_id"])
var space_id := StringName(str(data["space_id"]))
if not _session.set_peer_space(peer_id, space_id):
return
var avatar: Player = _spawn_service.get_avatar(peer_id)
if avatar != null:
avatar.global_transform = NetworkHomeProtocolType.array_to_transform(
data["transform"]
)
avatar.velocity = Vector3.ZERO
if peer_id == _session.get_local_peer_id():
_world_service.set_local_space(space_id)
local_space_changed.emit(
space_id, str(data["owner_fingerprint"])
)
local_transition_finished.emit(true, "")
_refresh_peer_visibility()
func _send_transition_rejection(peer_id: int, message: String) -> void:
if peer_id == _session.get_local_peer_id():
local_transition_finished.emit(false, message)
else:
receive_home_transition_rejection.rpc_id(peer_id, message)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_transition_rejection(message: String) -> void:
local_transition_finished.emit(
false, message.left(NetworkHomeProtocolType.MAX_MESSAGE_LENGTH)
)
func _refresh_peer_visibility() -> void:
var local_peer_id: int = _session.get_local_peer_id()
if local_peer_id <= 0:
return
for peer_id: int in _spawn_service.get_peer_ids():
if peer_id == local_peer_id:
continue
_spawn_service.set_peer_presentation_visible(
peer_id, _session.peers_share_space(local_peer_id, peer_id)
)
func _on_local_home_changed() -> void:
if _suppress_local_submission or _publish_queued:
return
_publish_queued = true
call_deferred("_publish_queued_home")
func _publish_queued_home() -> void:
_publish_queued = false
if _save_manager != null:
_save_manager.save_if_dirty()
publish_local_home()
func _on_join_authenticated() -> void:
publish_local_home()
func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
if not _session.is_host():
return
for manifest: Dictionary in _manifests_by_fingerprint.values():
receive_home_manifest.rpc_id(peer_id, manifest)
for existing_peer_id: int in _session.get_authenticated_peer_ids():
if existing_peer_id == peer_id:
continue
var avatar: Player = _spawn_service.get_avatar(existing_peer_id)
if avatar == null:
continue
var space_id: StringName = _session.get_peer_space(existing_peer_id)
var owner_fingerprint: String = (
String(space_id).trim_prefix("rv:")
if String(space_id).begins_with("rv:")
else _fingerprint_for_peer(existing_peer_id)
)
if owner_fingerprint.is_empty():
continue
receive_home_transition.rpc_id(peer_id, {
"peer_id": existing_peer_id,
"space_id": String(space_id),
"owner_fingerprint": owner_fingerprint,
"transform": NetworkHomeProtocolType.transform_to_array(
avatar.global_transform
),
})
func _on_peer_removed(peer_id: int) -> void:
var fingerprint: String = _fingerprint_by_peer.get(peer_id, "")
_fingerprint_by_peer.erase(peer_id)
_authorized_owned_counts.erase(peer_id)
if fingerprint.is_empty():
return
if _session.is_host():
for occupant_id: int in _session.get_authenticated_peer_ids():
if (
occupant_id == peer_id
or _session.get_peer_space(occupant_id)
!= NetworkHomeProtocolType.make_space_id(fingerprint)
):
continue
_apply_host_transition(
occupant_id,
NetworkHomeProtocolType.WORLD_SPACE,
fingerprint,
_world_service.get_exterior_spawn_transform(fingerprint),
)
_manifests_by_fingerprint.erase(fingerprint)
_world_service.remove_presentation(fingerprint)
if _session.is_host():
for recipient_id: int in _session.get_authenticated_peer_ids():
if recipient_id != 1:
receive_home_removed.rpc_id(recipient_id, fingerprint)
@rpc(
"authority", "call_remote", "reliable",
NetworkHomeProtocolType.RELIABLE_CHANNEL,
)
func receive_home_removed(owner_fingerprint: String) -> void:
if not NetworkIdentityCrypto.valid_fingerprint(owner_fingerprint):
return
_manifests_by_fingerprint.erase(owner_fingerprint)
_world_service.remove_presentation(owner_fingerprint)
func _on_session_state_changed(state: NetworkSession.State) -> void:
if state in [NetworkSession.State.PRIVATE_HOST, NetworkSession.State.OPEN_HOST]:
if (
not _session.is_dedicated_host()
and _world_service != null
and _world_service.is_world_ready()
):
publish_local_home()
return
if state in [
NetworkSession.State.INACTIVE,
NetworkSession.State.DISCONNECTING,
NetworkSession.State.CONNECTION_FAILED,
NetworkSession.State.SERVER_LOST,
]:
_manifests_by_fingerprint.clear()
_fingerprint_by_peer.clear()
_authorized_owned_counts.clear()
_authorization_result_owner.clear()
_next_interior_index = 0
_world_service.clear_presentations()
_world_service.set_local_space(NetworkHomeProtocolType.WORLD_SPACE)
local_space_changed.emit(NetworkHomeProtocolType.WORLD_SPACE, "")
func _fingerprint_for_peer(peer_id: int) -> String:
if _fingerprint_by_peer.has(peer_id):
return _fingerprint_by_peer[peer_id]
var record := _session.get_peer_record(peer_id)
return record.identity_fingerprint if record != null else ""
func _new_id(prefix: String) -> String:
return "%s:%s" % [
prefix, Crypto.new().generate_random_bytes(16).hex_encode(),
]

View file

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

View file

@ -5,6 +5,7 @@ const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const FishingShopStockType = preload("res://economy/fishing_shop_stock.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const MAX_LEDGER_ENTRIES: int = 64
@ -394,6 +395,10 @@ func _apply_equipped(data: Dictionary) -> void:
item_id == FishingShopStockType.STANDARD_SHOVEL_ID
and bool(data["owns_item"]),
)
avatar.set_active_decor_package(
DecorCatalogType.has_product(item_id)
and bool(data["owns_item"]),
)
equipped_state_changed.emit(
peer_id, StringName(str(data["item_id"])), int(data["category"])
)

View file

@ -3,7 +3,7 @@ extends RefCounted
const WorldLayoutType = preload("res://world/world_layout.gd")
const PROTOCOL_VERSION: int = 11
const PROTOCOL_VERSION: int = 12
const GAME_BUILD: String = "prealpha"
const MAX_GAME_VERSION_LENGTH: int = 64
const MAX_DISPLAY_NAME_LENGTH: int = 24
@ -17,9 +17,9 @@ const MAX_SIGNATURE_LENGTH: int = 2048
# 8 reliable ordered session chat, 9 reliable private session mail,
# 10 observer-only fishing summaries, 11 reliable movement animation state,
# 12 reliable fish showcase state, 13 reliable world-spawn events,
# 14 reliable artwork state, 15 world-spawn snapshots, and 16 reliable
# fishing lifecycle. Bulk world/art payloads must not head-of-line block held
# items, fishing, or animation presentation.
# 14 reliable artwork state, 15 world-spawn snapshots, 16 reliable fishing
# lifecycle, and 17 reliable portable-home state. Bulk world/art payloads must
# not head-of-line block held items, fishing, animation, or home transitions.
const SALE_RELIABLE_CHANNEL: int = 5
const SHOP_RELIABLE_CHANNEL: int = 6
const ITEM_RELIABLE_CHANNEL: int = 7
@ -30,7 +30,8 @@ const SHOWCASE_RELIABLE_CHANNEL: int = 12
const WORLD_SPAWN_RELIABLE_CHANNEL: int = 13
const DRAWING_RELIABLE_CHANNEL: int = 14
const FISHING_RELIABLE_CHANNEL: int = 16
const ENET_CHANNEL_COUNT: int = 17
const HOME_RELIABLE_CHANNEL: int = 17
const ENET_CHANNEL_COUNT: int = 18
const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1"
@ -45,6 +46,7 @@ const WORLD_LAYOUT_CAPABILITY: String = "world_layout_v1"
const FRIENDS_CAPABILITY: String = "friends_v1"
const MOVEMENT_RECONCILIATION_CAPABILITY: String = "movement_reconciliation_v2"
const FISHING_REPLICATION_CAPABILITY: String = "fishing_replication_v2"
const HOME_CAPABILITY: String = "portable_home_v1"
const DEFAULT_WORLD_SEED: int = 13001
const MAX_WORLD_SEED: int = 2147483646
@ -202,6 +204,7 @@ static func make_client_hello(
FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -342,6 +345,7 @@ static func make_server_hello(
FRIENDS_CAPABILITY,
MOVEMENT_RECONCILIATION_CAPABILITY,
FISHING_REPLICATION_CAPABILITY,
HOME_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",

View file

@ -141,6 +141,7 @@ var _last_server_protocol_version: int = 0
var _last_server_world_seed: int = NetworkProtocol.DEFAULT_WORLD_SEED
var _last_server_world_layout: StringName = WorldLayout.GENERATED
var _server_capabilities: PackedStringArray = PackedStringArray()
var _peer_space_ids: Dictionary[int, StringName] = {}
var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
@ -334,6 +335,7 @@ func _register_player_host() -> void:
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -661,6 +663,7 @@ func supports_server_capability(capability: StringName) -> bool:
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
"chat_v1",
"mail_v1",
"profile_v1",
@ -685,6 +688,21 @@ func get_authenticated_peer_ids() -> Array[int]:
return _registry.get_peer_ids()
func set_peer_space(peer_id: int, space_id: StringName) -> bool:
if not _registry.has_peer(peer_id) or space_id.is_empty():
return false
_peer_space_ids[peer_id] = space_id
return true
func get_peer_space(peer_id: int) -> StringName:
return _peer_space_ids.get(peer_id, &"world")
func peers_share_space(first_peer_id: int, second_peer_id: int) -> bool:
return get_peer_space(first_peer_id) == get_peer_space(second_peer_id)
func get_peer_rtt_ms(peer_id: int) -> int:
if peer_id == 1:
return 0
@ -1030,6 +1048,7 @@ func _on_peer_disconnected(peer_id: int) -> void:
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
_operator_peer_ids.erase(peer_id)
_peer_space_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
var recovery_attempt: String = _recovery_attempts.get(peer_id, "")
@ -1313,6 +1332,7 @@ func submit_client_hello(data: Dictionary) -> void:
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]:
if required_capability not in client_capabilities:
_reject_peer(
@ -1546,6 +1566,7 @@ func receive_server_hello(data: Dictionary) -> void:
for required_capability: String in [
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]:
if required_capability not in _server_capabilities:
_teardown_peer()
@ -1578,6 +1599,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.FRIENDS_CAPABILITY,
NetworkProtocol.MOVEMENT_RECONCILIATION_CAPABILITY,
NetworkProtocol.FISHING_REPLICATION_CAPABILITY,
NetworkProtocol.HOME_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
@ -1623,6 +1645,7 @@ func receive_peer_despawn(peer_id: int) -> void:
if state != State.JOINED_CLIENT:
return
_operator_peer_ids.erase(peer_id)
_peer_space_ids.erase(peer_id)
_last_animation_state_by_peer.erase(peer_id)
_pending_animation_state_by_peer.erase(peer_id)
_registry.remove_peer(peer_id)
@ -2247,6 +2270,8 @@ func _broadcast_movement_snapshots() -> void:
continue
var snapshots: Array = []
for subject_id: int in peer_ids:
if not peers_share_space(recipient_id, subject_id):
continue
var subject_avatar: Player = _spawn_service.get_avatar(subject_id)
if subject_avatar == null:
continue
@ -2315,7 +2340,7 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
_animation_refresh_accumulator,
ANIMATION_REFRESH_INTERVAL,
)
var updates: Array = []
var updates_by_subject: Dictionary[int, Array] = {}
for subject_id: int in peer_ids:
var avatar: Player = _spawn_service.get_avatar(subject_id)
if avatar == null:
@ -2333,12 +2358,18 @@ func _broadcast_movement_animation_updates(peer_ids: Array[int]) -> void:
_last_animation_state_by_peer[subject_id] = state.duplicate(true)
var encoded: Array = _encode_movement_animation(subject_id, state)
if not encoded.is_empty():
updates.append(encoded)
if updates.is_empty():
updates_by_subject[subject_id] = encoded
if updates_by_subject.is_empty():
return
for recipient_id: int in peer_ids:
if recipient_id == 1:
continue
var updates: Array = []
for subject_id: int in updates_by_subject:
if peers_share_space(recipient_id, subject_id):
updates.append(updates_by_subject[subject_id])
if updates.is_empty():
continue
receive_movement_animations.rpc_id(recipient_id, updates)
_movement_animation_packets_sent += 1
_movement_animation_states_sent += updates.size()
@ -2888,6 +2919,7 @@ func _teardown_peer() -> void:
_pending_animation_state_by_peer.clear()
_last_local_animation_action_signature.clear()
_server_capabilities = PackedStringArray()
_peer_space_ids.clear()
_server_identity_fingerprint = ""
_server_identity_public_key = ""
_session_identity_keys.clear()

View file

@ -4,6 +4,7 @@ extends RefCounted
const CAPABILITY: StringName = &"shop_v1"
const ART_CAPABILITY: StringName = &"art_shop_v1"
const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1"
const DECOR_CAPABILITY: StringName = &"portable_home_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
@ -18,6 +19,7 @@ enum ProductCategory {
ART_KIT,
ART_UPGRADE,
BACKPACK_CAPACITY_UPGRADE,
DECOR,
}

View file

@ -8,8 +8,10 @@ const ItemDataType = preload("res://items/item_data.gd")
const FishingRodDataType = preload("res://items/fishing_rod_data.gd")
const OwnedItemType = preload("res://items/owned_item.gd")
const ArtShopStockType = preload("res://economy/art_shop_stock.gd")
const DecorCatalogType = preload("res://homes/decor_catalog.gd")
const SHOP_ID: StringName = &"main_fishing_shop"
const FISHING_SHOP_ID: StringName = &"main_fishing_shop"
const DECOR_SHOP_ID: StringName = &"rv_decor_shop"
const REEL_PRODUCT_ID: StringName = &"reel_speed_upgrade"
const BARRIER_PRODUCT_ID: StringName = &"barrier_power_upgrade"
const COOLER_PRODUCT_ID: StringName = &"cooler_capacity_upgrade"
@ -31,6 +33,7 @@ var _session: NetworkSession
var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService
var _interaction: FishingShopInteraction
var _decor_interaction: DecorShopInteraction
var _wallet: PlayerWallet
var _bag: PlayerBag
var _item_catalog: ItemCatalog
@ -47,6 +50,8 @@ var _received_results: Dictionary[String, bool] = {}
var _applied_results: Dictionary[String, bool] = {}
var _pending_local_request: Dictionary = {}
var _reservations: PlayerAssetReservationService
var _home_state: PlayerHomeState
var _network_home: NetworkHomeService
func setup(
@ -63,6 +68,9 @@ func setup(
save_manager: PlayerSaveManager,
reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null,
home_state: PlayerHomeState = null,
network_home: NetworkHomeService = null,
decor_interaction: DecorShopInteraction = null,
) -> void:
_session = session
_spawn_service = spawn_service
@ -77,6 +85,9 @@ func setup(
_save_manager = save_manager
_reservations = reservations
_inventory_layout = inventory_layout
_home_state = home_state
_network_home = network_home
_decor_interaction = decor_interaction
if not _session.peer_removed.is_connected(_on_peer_removed):
_session.peer_removed.connect(_on_peer_removed)
if not _session.state_changed.is_connected(_on_session_state_changed):
@ -87,6 +98,12 @@ func set_shop_interaction(interaction: FishingShopInteraction) -> void:
_interaction = interaction
func set_decor_shop_interaction(
interaction: DecorShopInteraction,
) -> void:
_decor_interaction = interaction
func can_request_purchase() -> bool:
return (
_session != null
@ -124,6 +141,19 @@ func can_request_backpack_purchase() -> bool:
)
func can_request_decor_purchase() -> bool:
return (
can_request_purchase()
and _home_state != null
and (
_session.is_host()
or _session.supports_server_capability(
NetworkShopProtocol.DECOR_CAPABILITY
)
)
)
func is_local_purchase_pending() -> bool:
return not _pending_local_request.is_empty()
@ -193,6 +223,15 @@ func request_backpack_capacity_upgrade() -> String:
)
func request_decor(product_id: StringName) -> String:
return _request_purchase(
product_id,
NetworkShopProtocol.ProductCategory.DECOR,
1,
_home_state.get_owned_count(product_id) if _home_state != null else 0,
)
func request_art_kit() -> String:
return _request_purchase(
ArtShopStockType.ART_KIT_ITEM_ID,
@ -269,11 +308,25 @@ func _request_purchase(
product_id, category, 0, 0,
)
return ""
if (
category == NetworkShopProtocol.ProductCategory.DECOR
and not can_request_decor_purchase()
):
local_purchase_finished.emit(
"", false, "Decor require a newer server.",
product_id, category, 0, 0,
)
return ""
var request_id: String = _new_id("shop")
var shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
var request: Dictionary = {
"request_id": request_id,
"session_id": _session.get_session_id(),
"shop_id": str(SHOP_ID),
"shop_id": str(shop_id),
"product_id": str(product_id),
"category": category,
"quantity": quantity,
@ -345,16 +398,34 @@ func _handle_purchase_request(peer_id: int, data: Dictionary) -> void:
_rejected_result(data, "A purchase is already pending.")
)
return
if not _is_shop_available_for_peer(peer_id):
var requested_shop_id := StringName(str(data["shop_id"]))
if not _is_shop_available_for_peer(peer_id, requested_shop_id):
var avatar: Player = _spawn_service.get_avatar(peer_id)
var requested_interaction: FishingShopInteraction = (
_decor_interaction
if requested_shop_id == DECOR_SHOP_ID
else _interaction
)
var message: String = (
"The shop is unavailable."
if avatar == null or _interaction == null
if avatar == null or requested_interaction == null
else "Move closer to the shop."
)
_record_and_send(peer_id, _rejected_result(data, message))
return
var result: Dictionary = _build_authoritative_result(peer_id, data)
if (
bool(result.get("accepted", false))
and int(result.get("category", -1))
== NetworkShopProtocol.ProductCategory.DECOR
and _network_home != null
):
_network_home.authorize_decor_purchase(
peer_id,
StringName(str(result["product_id"])),
int(result["resulting_state"]),
str(result["result_id"]),
)
_pending_by_peer[peer_id] = request_id
_record_and_send(peer_id, result)
@ -368,10 +439,24 @@ func _build_authoritative_result(
var quantity: int = request["quantity"]
var wallet_balance: int = request["wallet_balance"]
var current_state: int = request["current_state"]
if (
category == NetworkShopProtocol.ProductCategory.DECOR
and _network_home != null
):
var authoritative_count: int = (
_network_home.get_authoritative_owned_count(peer_id, product_id)
)
if authoritative_count >= 0:
current_state = authoritative_count
var cost: int = -1
var resulting_state: int = current_state
var rejection: String = ""
if StringName(str(request["shop_id"])) != SHOP_ID:
var expected_shop_id: StringName = (
DECOR_SHOP_ID
if category == NetworkShopProtocol.ProductCategory.DECOR
else FISHING_SHOP_ID
)
if StringName(str(request["shop_id"])) != expected_shop_id:
rejection = "The shop is unavailable."
elif quantity < 1:
rejection = "Purchase could not be completed."
@ -545,6 +630,19 @@ func _build_authoritative_result(
resulting_state = PlayerArtUnlocks.resulting_mask(
current_state, product_id
)
NetworkShopProtocol.ProductCategory.DECOR:
cost = DecorCatalogType.get_price(product_id)
if (
not DecorCatalogType.has_product(product_id)
or quantity != 1
or current_state < 0
or current_state
>= DecorCatalogType.MAX_OWNED_PER_PRODUCT
or cost < 0
):
rejection = "Decor is unavailable."
else:
resulting_state = current_state + 1
if rejection.is_empty() and (cost < 0 or wallet_balance < cost):
rejection = "Insufficient funds."
if not rejection.is_empty():
@ -667,6 +765,9 @@ func _apply_purchase_result(data: Dictionary) -> void:
if _inventory_layout != null else 0
)
var art_snapshot: int = _art_unlocks.get_unlock_mask()
var home_snapshot: Dictionary = (
_home_state.to_save_data() if _home_state != null else {}
)
var applied: bool = _apply_local_product(data)
if not applied or not _save_manager.save_if_dirty():
_bag.replace_all_items(bag_snapshot)
@ -676,6 +777,8 @@ func _apply_purchase_result(data: Dictionary) -> void:
if _inventory_layout != null:
_inventory_layout.restore_backpack_level(backpack_snapshot)
_art_unlocks.restore_mask(art_snapshot)
if _home_state != null and not home_snapshot.is_empty():
_home_state.restore_from_save_data(home_snapshot)
_wallet.restore_balance(wallet_snapshot)
_save_manager.save_if_dirty()
_fail_local_apply(data, "Purchase could not be completed.")
@ -696,6 +799,7 @@ func _validate_local_result(data: Dictionary) -> String:
or _cooler_capacity == null
or _art_unlocks == null
or _save_manager == null
or _home_state == null
or _wallet.get_balance() != int(data["expected_wallet"])
or str(data["product_id"])
!= str(_pending_local_request.get("product_id", ""))
@ -806,6 +910,20 @@ func _validate_local_result(data: Dictionary) -> String:
or cost != ArtShopStockType.UPGRADE_PRICE
):
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.DECOR:
if (
not DecorCatalogType.has_product(product_id)
or _home_state.get_owned_count(product_id) != expected_state
or resulting_state != expected_state + 1
or cost != DecorCatalogType.get_price(product_id)
or expected_state >= DecorCatalogType.MAX_OWNED_PER_PRODUCT
or not _bag.can_add_item(product_id, 1)
):
return (
"Your inventory is full."
if not _bag.can_add_item(product_id, 1)
else "Purchase could not be completed."
)
_:
return "Purchase could not be completed."
return ""
@ -845,6 +963,12 @@ func _apply_local_product(data: Dictionary) -> bool:
return _art_unlocks.purchase_product(
product_id, _wallet, int(data["total_cost"])
)
NetworkShopProtocol.ProductCategory.DECOR:
return (
_wallet.debit(int(data["total_cost"]))
and _bag.add_item(product_id, 1)
and _home_state.add_owned_decor(product_id, 1)
)
return false
@ -921,7 +1045,7 @@ func _handle_acknowledgement(
peer_id: int,
result_id: String,
request_id: String,
_applied: bool,
applied: bool,
message: String,
) -> void:
if (
@ -933,20 +1057,32 @@ func _handle_acknowledgement(
or _result_owners.get(result_id, 0) != peer_id
):
return
if _network_home != null:
_network_home.finalize_decor_purchase(
peer_id, result_id, applied
)
_acknowledged_results[result_id] = true
_bound_dictionary(_acknowledged_results)
if _pending_by_peer.get(peer_id, "") == request_id:
_pending_by_peer.erase(peer_id)
func _is_shop_available_for_peer(peer_id: int) -> bool:
func _is_shop_available_for_peer(
peer_id: int,
shop_id: StringName,
) -> bool:
var interaction: FishingShopInteraction = (
_decor_interaction
if shop_id == DECOR_SHOP_ID
else _interaction
)
if (
_session == null
or not _session.is_host()
or not _session.is_gameplay_session_active()
or _spawn_service == null
or _interaction == null
or not is_instance_valid(_interaction)
or interaction == null
or not is_instance_valid(interaction)
):
return false
var avatar: Player = _spawn_service.get_avatar(peer_id)
@ -957,7 +1093,7 @@ func _is_shop_available_for_peer(peer_id: int) -> bool:
_network_fishing == null
or not _network_fishing.has_peer_attempt(peer_id)
)
and _interaction.is_avatar_in_range(avatar)
and interaction.is_avatar_in_range(avatar)
)