Add gathering, unified inventory, and real-time world

This commit is contained in:
Alexander Sellite 2026-08-18 18:19:36 -04:00
parent 173371e6fc
commit fa57edca83
113 changed files with 6700 additions and 1307 deletions

View file

@ -5,7 +5,6 @@ const BURST_COUNT: int = 3
const WINDOW_COUNT: int = 5
const WINDOW_SECONDS: float = 10.0
const CALL_COOLDOWN_MILLISECONDS: int = 180
const WORLD_COMMAND_COOLDOWN_MILLISECONDS: int = 500
const CALL_PITCH_VARIANTS: Array[float] = [
0.96,
1.03,
@ -23,7 +22,6 @@ signal message_received(message: Dictionary)
signal local_message_confirmed(message: Dictionary)
signal send_rejected(message: String)
signal history_replaced(messages: Array[Dictionary])
signal world_command_finished(success: bool, message: String)
signal character_call_received(
peer_id: int,
call_id: String,
@ -37,22 +35,13 @@ var _request_ledgers: Dictionary[int, Dictionary] = {}
var _rate_times: Dictionary[int, Array] = {}
var _last_call_msec: Dictionary[int, int] = {}
var _call_variant_indices: Dictionary[int, int] = {}
var _last_world_command_msec: Dictionary[int, int] = {}
var _sequence: int = 0
var _peer_names: Dictionary[int, String] = {}
var _relationships: PlayerRelationshipStore
var _world_time: WorldTimeService
var _world_weather: WorldWeatherService
func setup(
session: NetworkSession,
world_time: WorldTimeService,
world_weather: WorldWeatherService,
) -> void:
func setup(session: NetworkSession) -> void:
_session = session
_world_time = world_time
_world_weather = world_weather
_session.peer_authenticated.connect(_on_peer_authenticated)
_session.peer_removed.connect(_on_peer_removed)
_session.state_changed.connect(_on_session_state_changed)
@ -261,188 +250,6 @@ func broadcast_system_message(body: String) -> bool:
return true
func request_world_time_change(phase_name: String) -> bool:
var normalized: String = phase_name.strip_edges().to_lower()
if not _valid_time_phase(normalized) or _session == null:
return false
if _session.is_host():
return _apply_world_time_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_TIME_CAPABILITY
)
):
return false
submit_world_time_command.rpc_id(1, normalized)
return true
func request_world_weather_change(weather_name: String) -> bool:
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty() or _session == null:
return false
if _session.is_host():
return _apply_world_weather_change(normalized)
if (
not _session.is_joined_client()
or not _session.is_local_operator()
or not _session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
)
):
return false
submit_world_weather_command.rpc_id(1, normalized)
return true
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_time_command(phase_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id, false, "Only the host or an operator can change world time."
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_time_change(
phase_name.strip_edges().to_lower()
)
_send_world_command_result(
sender_id,
success,
"" if success else "World time could not be changed.",
)
@rpc("any_peer", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func submit_world_weather_command(weather_name: String) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not _valid_world_command_sender(sender_id):
_send_world_command_result(
sender_id,
false,
"Only the host or an operator can change world weather.",
)
return
if not _consume_world_command_rate(sender_id):
_send_world_command_result(sender_id, false, "Slow down.")
return
var success: bool = _apply_world_weather_change(
_normalized_weather_name(weather_name)
)
_send_world_command_result(
sender_id,
success,
"" if success else "World weather could not be changed.",
)
@rpc("authority", "call_remote", "reliable", NetworkChatProtocol.RELIABLE_CHANNEL)
func receive_world_command_result(success: bool, message: String) -> void:
if _session == null or not _session.is_joined_client():
return
world_command_finished.emit(success, message.left(120))
func _valid_world_command_sender(peer_id: int) -> bool:
return (
_session != null
and _session.is_host()
and peer_id > 1
and _session.is_authenticated_peer(peer_id)
and _session.is_peer_operator(peer_id)
)
func _apply_world_time_change(phase_name: String) -> bool:
if _world_time == null or not _valid_time_phase(phase_name):
return false
var target_hour: float = _time_phase_hour(phase_name)
if not _world_time.set_authoritative_time(target_hour):
return false
broadcast_system_message(
"World time set to %s (%s)."
% [phase_name, _world_time.get_clock_text()]
)
return true
func _apply_world_weather_change(weather_name: String) -> bool:
if _world_weather == null:
return false
var normalized: String = _normalized_weather_name(weather_name)
if normalized.is_empty():
return false
var target_weather: WorldWeatherService.Weather = (
_weather_for_name(normalized)
)
if not _world_weather.set_authoritative_weather(target_weather):
return false
broadcast_system_message("World weather set to %s." % normalized)
return true
func _send_world_command_result(
peer_id: int,
success: bool,
message: String,
) -> void:
if peer_id > 1 and _session.is_authenticated_peer(peer_id):
receive_world_command_result.rpc_id(peer_id, success, message.left(120))
func _consume_world_command_rate(peer_id: int) -> bool:
var now_msec: int = Time.get_ticks_msec()
var last_msec: int = _last_world_command_msec.get(
peer_id, now_msec - WORLD_COMMAND_COOLDOWN_MILLISECONDS
)
if now_msec - last_msec < WORLD_COMMAND_COOLDOWN_MILLISECONDS:
return false
_last_world_command_msec[peer_id] = now_msec
return true
static func _valid_time_phase(phase_name: String) -> bool:
return phase_name in ["dawn", "day", "dusk", "night"]
static func _time_phase_hour(phase_name: String) -> float:
match phase_name:
"dawn":
return WorldTimeService.DAWN_START_HOUR
"day":
return WorldTimeService.DAWN_END_HOUR
"dusk":
return WorldTimeService.DUSK_START_HOUR
"night":
return WorldTimeService.DUSK_END_HOUR
return -1.0
static func _normalized_weather_name(weather_name: String) -> String:
var normalized: String = weather_name.strip_edges().to_lower()
return "clear" if normalized == "sunny" else normalized if normalized in [
"clear", "cloudy", "rainy", "foggy"
] else ""
static func _weather_for_name(
weather_name: String,
) -> WorldWeatherService.Weather:
match weather_name:
"cloudy":
return WorldWeatherService.Weather.CLOUDY
"rainy":
return WorldWeatherService.Weather.RAINY
"foggy":
return WorldWeatherService.Weather.FOGGY
return WorldWeatherService.Weather.SUNNY
func get_history() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for message: Dictionary in _history:
@ -638,7 +445,6 @@ func _on_peer_removed(peer_id: int) -> void:
_rate_times.erase(peer_id)
_last_call_msec.erase(peer_id)
_call_variant_indices.erase(peer_id)
_last_world_command_msec.erase(peer_id)
if not _session.is_host():
return
var display_name: String = _peer_names.get(peer_id, "Player")
@ -701,7 +507,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_rate_times.clear()
_last_call_msec.clear()
_call_variant_indices.clear()
_last_world_command_msec.clear()
_peer_names.clear()
_sequence = 0
history_replaced.emit([])

View file

@ -253,7 +253,7 @@ func _handle_cast_request(peer_id: int, data: Dictionary) -> void:
_record_and_reject(peer_id, request_id, "Already fishing.")
return
if not bool(data["capacity_available"]):
_record_and_reject(peer_id, request_id, "Cooler is full.")
_record_and_reject(peer_id, request_id, "Inventory is full.")
return
var rod := _item_catalog.get_item_by_id(
StringName(str(data["rod_id"]))
@ -812,11 +812,8 @@ func _handle_local_capacity_probe(data: Dictionary) -> void:
return
var can_accept: bool = (
_local_inventory != null
and _local_capacity != null
and (
_local_inventory.contains_catch_id(StringName(data["catch_id"]))
or _local_inventory.get_all_catches().size()
< _local_capacity.get_capacity()
and _local_inventory.can_accept_catch(
StringName(data["catch_id"])
)
)
if _session.is_host():
@ -864,7 +861,7 @@ func _handle_capacity_response(
):
return
if not can_accept:
_cancel_attempt(peer_id, "Cooler is full.")
_cancel_attempt(peer_id, "Inventory is full.")
return
_finalize_catch(attempt)
@ -915,11 +912,7 @@ func _apply_target_outcome(data: Dictionary) -> void:
if fish_catch == null:
return
var already_owned: bool = _local_inventory.contains_catch_id(catch_id)
if (
not already_owned
and _local_inventory.get_all_catches().size()
>= _local_capacity.get_capacity()
):
if not already_owned and not _local_inventory.can_accept_catch(catch_id):
return
if not already_owned:
var experience_award: int = (

View file

@ -389,6 +389,10 @@ func _apply_equipped(data: Dictionary) -> void:
item_id == FishingShopStockType.CRAB_NET_ID
and bool(data["owns_item"]),
)
avatar.set_active_shovel(
item_id == FishingShopStockType.STANDARD_SHOVEL_ID
and bool(data["owns_item"]),
)
equipped_state_changed.emit(
peer_id, StringName(str(data["item_id"])), int(data["category"])
)

View file

@ -928,7 +928,9 @@ func _can_receive(attachment: Dictionary) -> bool:
return _wallet.can_credit(int(attachment["amount"]))
PlayerAssetReservationService.AttachmentType.FISH:
return (
_inventory.get_all_catches().size() < _cooler_capacity.get_capacity()
_inventory.can_accept_catch(
StringName(str(attachment["catch_id"]))
)
and not _inventory.contains_catch_id(
StringName(str(attachment["catch_id"]))
)

View file

@ -21,6 +21,7 @@ const MAIL_RELIABLE_CHANNEL: int = 9
const ENET_CHANNEL_COUNT: int = 10
const SURFACE_DRAWING_CAPABILITY: String = "surface_drawing_v2"
const ART_SHOP_CAPABILITY: String = "art_shop_v1"
const BACKPACK_SHOP_CAPABILITY: String = "backpack_shop_v1"
const WORLD_TIME_CAPABILITY: String = "world_time_v1"
const WORLD_WEATHER_CAPABILITY: String = "world_weather_v1"
const FISH_QUALITY_CAPABILITY: String = "fish_quality_v1"
@ -176,6 +177,7 @@ static func make_client_hello(
JOBS_CAPABILITY,
WORLD_SPAWN_CAPABILITY,
APPEARANCE_PREVIEW_CAPABILITY,
BACKPACK_SHOP_CAPABILITY,
]),
"cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
@ -296,6 +298,7 @@ static func make_server_hello(
"sale_v1",
"shop_v1",
ART_SHOP_CAPABILITY,
BACKPACK_SHOP_CAPABILITY,
"item_use_v1",
"equipment_v1",
"fish_showcase_v1",

View file

@ -6,6 +6,8 @@ const RELIABLE_CHANNEL: int = NetworkProtocol.SALE_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_CATCH_ID_LENGTH: int = 160
const MAX_CATCHES_PER_REQUEST: int = 64
const MAX_ITEM_STACKS_PER_REQUEST: int = 20
const MAX_ITEM_QUANTITY: int = 999
const MAX_MESSAGE_LENGTH: int = 160
enum Rejection {
@ -40,6 +42,7 @@ static func validate_request(data: Variant) -> String:
var session_id: String = payload["session_id"]
var buyer_id: String = str(payload["buyer_id"])
var catches: Array = payload["catches"]
var items: Array = payload.get("items", [])
if (
request_id.is_empty()
or request_id.length() > MAX_ID_LENGTH
@ -47,8 +50,10 @@ static func validate_request(data: Variant) -> String:
or session_id.length() > MAX_ID_LENGTH
or buyer_id.is_empty()
or buyer_id.length() > MAX_ID_LENGTH
or catches.is_empty()
or typeof(items) != TYPE_ARRAY
or (catches.is_empty() and items.is_empty())
or catches.size() > MAX_CATCHES_PER_REQUEST
or items.size() > MAX_ITEM_STACKS_PER_REQUEST
):
return "Sale could not be completed."
var seen: Dictionary[String, bool] = {}
@ -74,6 +79,23 @@ static func validate_request(data: Variant) -> String:
):
return "Sale could not be completed."
seen[catch_id] = true
var seen_items: Dictionary[String, bool] = {}
for value: Variant in items:
if typeof(value) != TYPE_DICTIONARY:
return "Sale could not be completed."
var item: Dictionary = value
var item_id := str(item.get("item_id", ""))
var quantity := int(item.get("quantity", 0))
if (
item_id.is_empty()
or item_id.length() > MAX_ID_LENGTH
or seen_items.has(item_id)
or typeof(item.get("quantity")) != TYPE_INT
or quantity < 1
or quantity > MAX_ITEM_QUANTITY
):
return "Sale could not be completed."
seen_items[item_id] = true
return ""
@ -93,6 +115,8 @@ static func validate_result(data: Variant) -> bool:
and typeof(payload.get("accepted")) == TYPE_BOOL
and typeof(payload.get("catch_ids")) == TYPE_ARRAY
and payload["catch_ids"].size() <= MAX_CATCHES_PER_REQUEST
and typeof(payload.get("items", [])) == TYPE_ARRAY
and payload.get("items", []).size() <= MAX_ITEM_STACKS_PER_REQUEST
and typeof(payload.get("payout")) == TYPE_INT
and int(payload["payout"]) >= 0
and typeof(payload.get("base_value")) == TYPE_INT
@ -119,4 +143,20 @@ static func validate_result(data: Variant) -> bool:
):
return false
seen[catch_id] = true
var seen_items: Dictionary[String, bool] = {}
for value: Variant in payload.get("items", []):
if typeof(value) != TYPE_DICTIONARY:
return false
var item := value as Dictionary
var item_id := str(item.get("item_id", ""))
if (
item_id.is_empty()
or item_id.length() > MAX_ID_LENGTH
or seen_items.has(item_id)
or typeof(item.get("quantity")) != TYPE_INT
or int(item["quantity"]) < 1
or int(item["quantity"]) > MAX_ITEM_QUANTITY
):
return false
seen_items[item_id] = true
return true

View file

@ -8,6 +8,10 @@ const FishPoolType = preload("res://fish/fish_pool.gd")
const FishBuyerProfileType = preload("res://economy/fish_buyer_profile.gd")
const FishSaleServiceType = preload("res://economy/fish_sale_service.gd")
const FishSaleResultType = preload("res://economy/fish_sale_result.gd")
const ItemDataType = preload("res://items/item_data.gd")
const ItemCatalogType = preload("res://items/item_catalog.gd")
const PlayerBagType = preload("res://inventory/player_bag.gd")
const ItemResalePolicyType = preload("res://economy/item_resale_policy.gd")
const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
const PELICAN_BUYER_ID: StringName = &"pelicans"
@ -27,6 +31,8 @@ var _spawn_service: PlayerSpawnService
var _network_fishing: NetworkFishingService
var _shop_interaction: FishingShopInteraction
var _inventory: FishInventory
var _bag: PlayerBagType
var _item_catalog: ItemCatalogType
var _wallet: PlayerWallet
var _sale_service: FishSaleServiceType
var _save_manager: PlayerSaveManager
@ -40,8 +46,10 @@ var _applied_results: Dictionary[String, bool] = {}
var _received_results: Dictionary[String, bool] = {}
var _pending_local_request_id: String = ""
var _pending_local_catch_ids: Array[StringName] = []
var _pending_local_items: Array[Dictionary] = []
var _pending_local_buyer_id: StringName
var _reservations: PlayerAssetReservationService
var _inventory_layout: PlayerInventoryLayout
func setup(
@ -50,18 +58,23 @@ func setup(
network_fishing: NetworkFishingService,
shop_interaction: FishingShopInteraction,
inventory: FishInventory,
bag: PlayerBagType,
item_catalog: ItemCatalogType,
wallet: PlayerWallet,
sale_service: FishSaleServiceType,
save_manager: PlayerSaveManager,
fish_catalog: FishPoolType,
buyers: Array[FishBuyerProfileType],
reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null,
) -> void:
_session = session
_spawn_service = spawn_service
_network_fishing = network_fishing
_shop_interaction = shop_interaction
_inventory = inventory
_bag = bag
_item_catalog = item_catalog
_wallet = wallet
_sale_service = sale_service
_save_manager = save_manager
@ -71,6 +84,7 @@ func setup(
if buyer != null and buyer.is_valid():
_buyers[buyer.id] = buyer
_reservations = reservations
_inventory_layout = inventory_layout
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):
@ -102,6 +116,15 @@ func can_request_sale(
func request_local_sale(
catch_ids: Array[StringName],
buyer_id: StringName = PELICAN_BUYER_ID,
) -> String:
var no_items: Dictionary[StringName, int] = {}
return request_local_mixed_sale(catch_ids, no_items, buyer_id)
func request_local_mixed_sale(
catch_ids: Array[StringName],
item_quantities: Dictionary[StringName, int],
buyer_id: StringName = MAIN_SHOP_BUYER_ID,
) -> String:
for catch_id: StringName in catch_ids:
if _reservations != null and _reservations.is_fish_reserved(catch_id):
@ -123,7 +146,12 @@ func request_local_sale(
0
)
return ""
if catch_ids.is_empty() or _inventory == null:
if (
(catch_ids.is_empty() and item_quantities.is_empty())
or _inventory == null
or _bag == null
or _item_catalog == null
):
local_sale_finished.emit(
"", false, "Sale could not be completed.", _empty_catch_ids(), 0
)
@ -143,15 +171,41 @@ func request_local_sale(
)
return ""
evidence.append(fish_catch.to_network_dict())
var item_evidence: Array[Dictionary] = []
var item_ids: Array[StringName] = []
item_ids.assign(item_quantities.keys())
item_ids.sort_custom(
func(left: StringName, right: StringName) -> bool:
return str(left) < str(right)
)
for item_id: StringName in item_ids:
var quantity := int(item_quantities.get(item_id, 0))
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
if (
quantity < 1
or not ItemResalePolicyType.is_sellable(item)
or quantity > _bag.get_quantity(item_id)
or (
_reservations != null
and quantity > _reservations.get_available_item_quantity(item_id)
)
):
local_sale_finished.emit(
"", false, "That item is no longer available.", _empty_catch_ids(), 0
)
return ""
item_evidence.append({"item_id": str(item_id), "quantity": quantity})
var request_id: String = _new_id("sale")
var request: Dictionary = {
"request_id": request_id,
"session_id": _session.get_session_id(),
"buyer_id": str(buyer.id),
"catches": evidence,
"items": item_evidence,
}
_pending_local_request_id = request_id
_pending_local_catch_ids = catch_ids.duplicate()
_pending_local_items = item_evidence.duplicate(true)
_pending_local_buyer_id = buyer.id
local_sale_pending.emit(request_id)
if _session.is_host():
@ -219,7 +273,11 @@ func _handle_sale_request(peer_id: int, data: Dictionary) -> void:
))
return
var result: Dictionary = _build_authoritative_result(
peer_id, request_id, data["catches"], buyer
peer_id,
request_id,
data["catches"],
data.get("items", []),
buyer,
)
_pending_by_peer[peer_id] = request_id
_record_and_send(peer_id, result)
@ -229,9 +287,14 @@ func _build_authoritative_result(
peer_id: int,
request_id: String,
evidence_values: Array,
item_values: Array,
buyer: FishBuyerProfileType,
) -> Dictionary:
if _fish_catalog == null or buyer == null:
if (
buyer == null
or (not evidence_values.is_empty() and _fish_catalog == null)
or (not item_values.is_empty() and _item_catalog == null)
):
return _rejected_result(
request_id, "The buyer is unavailable."
)
@ -283,6 +346,27 @@ func _build_authoritative_result(
base_value += decoded.sale_value
payout += offer
catch_ids.append(str(decoded.catch_id))
var items: Array[Dictionary] = []
if not item_values.is_empty() and buyer.id != MAIN_SHOP_BUYER_ID:
return _rejected_result(request_id, "This buyer only accepts catches.")
for value: Variant in item_values:
var evidence := value as Dictionary
var item_id := StringName(str(evidence.get("item_id", "")))
var quantity := int(evidence.get("quantity", 0))
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
var unit_value := ItemResalePolicyType.get_unit_value(item)
if quantity < 1 or unit_value < 0:
return _rejected_result(request_id, "That item cannot be sold.")
var item_value := unit_value * quantity
if (
item_value < 0
or base_value > 9223372036854775807 - item_value
or payout > 9223372036854775807 - item_value
):
return _rejected_result(request_id, "Sale could not be completed.")
base_value += item_value
payout += item_value
items.append({"item_id": str(item_id), "quantity": quantity})
return {
"result_id": _new_id("sale_result"),
"request_id": request_id,
@ -291,6 +375,7 @@ func _build_authoritative_result(
"buyer_id": str(buyer.id),
"accepted": true,
"catch_ids": catch_ids,
"items": items,
"payout": payout,
"base_value": base_value,
"message": "Sale complete.",
@ -313,6 +398,7 @@ func _rejected_result(request_id: String, message: String) -> Dictionary:
"target_peer_id": 0,
"accepted": false,
"catch_ids": [],
"items": [],
"payout": 0,
"base_value": 0,
"message": message.left(NetworkSaleProtocol.MAX_MESSAGE_LENGTH),
@ -388,6 +474,12 @@ func _apply_sale_result(data: Dictionary) -> void:
if catch_ids != _pending_local_catch_ids:
_fail_local_apply(data, "Sale could not be completed.")
return
var items: Array[Dictionary] = []
for value: Variant in data.get("items", []):
items.append((value as Dictionary).duplicate(true))
if items != _pending_local_items:
_fail_local_apply(data, "Sale could not be completed.")
return
for catch_id: StringName in catch_ids:
if _reservations != null and _reservations.is_fish_reserved(catch_id):
_fail_local_apply(data, "Reserved in a letter.")
@ -396,10 +488,12 @@ func _apply_sale_result(data: Dictionary) -> void:
if buyer == null or not buyer.is_valid():
_fail_local_apply(data, "The buyer is unavailable.")
return
var preview: FishSaleResultType = _sale_service.preview_batch(
catch_ids, buyer
)
if not preview.is_success():
var catch_payout: int = 0
var catch_base_value: int = 0
var preview: FishSaleResultType
if not catch_ids.is_empty():
preview = _sale_service.preview_batch(catch_ids, buyer)
if preview != null and not preview.is_success():
var message: String = (
"Favorite catches cannot be sold."
if preview.status == FishSaleResultType.Status.FAVORITED
@ -407,22 +501,63 @@ func _apply_sale_result(data: Dictionary) -> void:
)
_fail_local_apply(data, message)
return
if preview != null:
catch_payout = preview.payout
catch_base_value = preview.base_value
var item_payout: int = 0
for record: Dictionary in items:
var item_id := StringName(str(record.get("item_id", "")))
var quantity := int(record.get("quantity", 0))
var item: ItemDataType = _item_catalog.get_item_by_id(item_id)
var unit_value := ItemResalePolicyType.get_unit_value(item)
if (
quantity < 1
or unit_value < 0
or quantity > _bag.get_quantity(item_id)
or (
_reservations != null
and quantity > _reservations.get_available_item_quantity(item_id)
)
):
_fail_local_apply(data, "That item is no longer available.")
return
item_payout += unit_value * quantity
if (
preview.payout != int(data["payout"])
or preview.base_value != int(data["base_value"])
catch_payout + item_payout != int(data["payout"])
or catch_base_value + item_payout != int(data["base_value"])
):
_fail_local_apply(data, "Sale could not be completed.")
return
var inventory_snapshot: Array[FishCatch] = _inventory.get_all_catches()
var sequence_snapshot: int = _inventory.get_next_catch_sequence()
var wallet_snapshot: int = _wallet.get_balance()
var local_result: FishSaleResultType = _sale_service.sell_batch(
catch_ids, buyer
var bag_snapshot := _bag.get_all_items()
var layout_snapshot: Dictionary = (
_inventory_layout.to_save_data()
if _inventory_layout != null else {}
)
if not local_result.is_success() or not _save_manager.save_if_dirty():
var wallet_snapshot: int = _wallet.get_balance()
var applied: bool = true
if not catch_ids.is_empty():
applied = (
_inventory.remove_catches_by_ids(catch_ids).size()
== catch_ids.size()
)
if applied:
for record: Dictionary in items:
if not _bag.remove_item(
StringName(str(record["item_id"])), int(record["quantity"])
):
applied = false
break
if applied:
applied = _wallet.credit(int(data["payout"]))
if not applied or not _save_manager.save_if_dirty():
_inventory.replace_all_catches(
inventory_snapshot, sequence_snapshot
)
_bag.replace_all_items(bag_snapshot)
if _inventory_layout != null:
_inventory_layout.restore_from_save_data(layout_snapshot)
_wallet.restore_balance(wallet_snapshot)
_save_manager.save_if_dirty()
_fail_local_apply(data, "Sale could not be completed.")
@ -457,6 +592,7 @@ func _finish_local_sale(
) -> void:
_pending_local_request_id = ""
_pending_local_catch_ids.clear()
_pending_local_items.clear()
_pending_local_buyer_id = StringName()
local_sale_finished.emit(
request_id, accepted, message, catch_ids, payout
@ -591,6 +727,7 @@ func _clear_session_state() -> void:
_received_results.clear()
_pending_local_request_id = ""
_pending_local_catch_ids.clear()
_pending_local_items.clear()
_pending_local_buyer_id = StringName()

View file

@ -271,6 +271,7 @@ func _register_player_host() -> void:
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.JOBS_CAPABILITY,
NetworkProtocol.WORLD_SPAWN_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
]),
)
_registry.update_appearance(1, _local_appearance_snapshot)
@ -554,6 +555,7 @@ func supports_server_capability(capability: StringName) -> bool:
return str(capability) in PackedStringArray([
"movement_v1", "fishing_v1", "sale_v1", "shop_v1",
NetworkProtocol.ART_SHOP_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
"item_use_v1", "equipment_v1", "fish_showcase_v1",
NetworkProtocol.FISH_QUALITY_CAPABILITY,
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
@ -1365,6 +1367,7 @@ func receive_server_hello(data: Dictionary) -> void:
NetworkProtocol.SURFACE_DRAWING_CAPABILITY,
NetworkProtocol.WORLD_TIME_CAPABILITY,
NetworkProtocol.WORLD_WEATHER_CAPABILITY,
NetworkProtocol.BACKPACK_SHOP_CAPABILITY,
]),
)
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)

View file

@ -3,6 +3,7 @@ extends RefCounted
const CAPABILITY: StringName = &"shop_v1"
const ART_CAPABILITY: StringName = &"art_shop_v1"
const BACKPACK_CAPABILITY: StringName = &"backpack_shop_v1"
const RELIABLE_CHANNEL: int = NetworkProtocol.SHOP_RELIABLE_CHANNEL
const MAX_ID_LENGTH: int = 96
const MAX_MESSAGE_LENGTH: int = 160
@ -16,6 +17,7 @@ enum ProductCategory {
COOLER_CAPACITY_UPGRADE,
ART_KIT,
ART_UPGRADE,
BACKPACK_CAPACITY_UPGRADE,
}

View file

@ -13,6 +13,7 @@ const SHOP_ID: StringName = &"main_fishing_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"
const BACKPACK_PRODUCT_ID: StringName = &"backpack_capacity_upgrade"
const MAX_LEDGER_ENTRIES_PER_PEER: int = 64
signal local_purchase_pending(request_id: String)
@ -35,6 +36,7 @@ var _bag: PlayerBag
var _item_catalog: ItemCatalog
var _upgrades: PlayerFishingUpgrades
var _cooler_capacity: PlayerCoolerCapacity
var _inventory_layout: PlayerInventoryLayout
var _art_unlocks: PlayerArtUnlocks
var _save_manager: PlayerSaveManager
var _request_ledgers: Dictionary[int, Dictionary] = {}
@ -60,6 +62,7 @@ func setup(
art_unlocks: PlayerArtUnlocks,
save_manager: PlayerSaveManager,
reservations: PlayerAssetReservationService,
inventory_layout: PlayerInventoryLayout = null,
) -> void:
_session = session
_spawn_service = spawn_service
@ -73,6 +76,7 @@ func setup(
_art_unlocks = art_unlocks
_save_manager = save_manager
_reservations = reservations
_inventory_layout = inventory_layout
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):
@ -104,6 +108,18 @@ func can_request_art_purchase() -> bool:
)
func can_request_backpack_purchase() -> bool:
return (
can_request_purchase()
and (
_session.is_host()
or _session.supports_server_capability(
NetworkShopProtocol.BACKPACK_CAPABILITY
)
)
)
func is_local_purchase_pending() -> bool:
return not _pending_local_request.is_empty()
@ -163,6 +179,16 @@ func request_cooler_capacity_upgrade() -> String:
)
func request_backpack_capacity_upgrade() -> String:
return _request_purchase(
BACKPACK_PRODUCT_ID,
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE,
1,
_inventory_layout.get_backpack_level()
if _inventory_layout != null else 0,
)
func request_art_kit() -> String:
return _request_purchase(
ArtShopStockType.ART_KIT_ITEM_ID,
@ -230,6 +256,15 @@ func _request_purchase(
product_id, category, 0, 0,
)
return ""
if (
category == NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE
and not can_request_backpack_purchase()
):
local_purchase_finished.emit(
"", false, "Backpack upgrades require a newer server.",
product_id, category, 0, 0,
)
return ""
var request_id: String = _new_id("shop")
var request: Dictionary = {
"request_id": request_id,
@ -372,7 +407,7 @@ func _build_authoritative_result(
or current_state + quantity > item.max_stack
):
rejection = (
"Your Bag is full."
"Your inventory is full."
if item != null and current_state >= item.max_stack
else "Purchase could not be completed."
)
@ -458,6 +493,17 @@ func _build_authoritative_result(
]
)
resulting_state = current_state + 1
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
if (
product_id != BACKPACK_PRODUCT_ID
or current_state >= PlayerInventoryLayout.MAX_BACKPACK_LEVEL
):
rejection = "Upgrade is already at maximum."
else:
cost = PlayerInventoryLayout.BACKPACK_EXPANSION_COSTS[
current_state
]
resulting_state = current_state + 1
NetworkShopProtocol.ProductCategory.ART_KIT:
var art_item: ItemDataType = _item_catalog.get_item_by_id(
product_id
@ -612,6 +658,10 @@ func _apply_purchase_result(data: Dictionary) -> void:
var reel_snapshot: int = _upgrades.get_reel_speed_level()
var barrier_snapshot: int = _upgrades.get_barrier_power_level()
var cooler_snapshot: int = _cooler_capacity.get_level()
var backpack_snapshot: int = (
_inventory_layout.get_backpack_level()
if _inventory_layout != null else 0
)
var art_snapshot: int = _art_unlocks.get_unlock_mask()
var applied: bool = _apply_local_product(data)
if not applied or not _save_manager.save_if_dirty():
@ -619,6 +669,8 @@ func _apply_purchase_result(data: Dictionary) -> void:
_bag.replace_unlocked_bait_ids(bait_unlock_snapshot)
_upgrades.restore_levels(reel_snapshot, barrier_snapshot)
_cooler_capacity.restore_level(cooler_snapshot)
if _inventory_layout != null:
_inventory_layout.restore_backpack_level(backpack_snapshot)
_art_unlocks.restore_mask(art_snapshot)
_wallet.restore_balance(wallet_snapshot)
_save_manager.save_if_dirty()
@ -687,7 +739,7 @@ func _validate_local_result(data: Dictionary) -> String:
):
return "Purchase could not be completed."
if not _bag.can_add_item(product_id, data["quantity"]):
return "Your Bag is full."
return "Your inventory is full."
NetworkShopProtocol.ProductCategory.ROD:
var rod := (
_item_catalog.get_item_by_id(product_id)
@ -719,6 +771,13 @@ func _validate_local_result(data: Dictionary) -> String:
return "Purchase could not be completed."
if _cooler_capacity.get_next_cost() != cost:
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
if (
_inventory_layout == null
or _inventory_layout.get_backpack_level() != expected_state
or _inventory_layout.get_next_backpack_cost() != cost
):
return "Purchase could not be completed."
NetworkShopProtocol.ProductCategory.ART_KIT:
var art_item: ItemDataType = (
_item_catalog.get_item_by_id(product_id)
@ -768,6 +827,11 @@ func _apply_local_product(data: Dictionary) -> bool:
return _upgrades.purchase_barrier_power(_wallet)
NetworkShopProtocol.ProductCategory.COOLER_CAPACITY_UPGRADE:
return _cooler_capacity.purchase(_wallet)
NetworkShopProtocol.ProductCategory.BACKPACK_CAPACITY_UPGRADE:
return (
_inventory_layout != null
and _inventory_layout.purchase_backpack(_wallet)
)
NetworkShopProtocol.ProductCategory.ART_KIT:
return (
_wallet.debit(int(data["total_cost"]))

View file

@ -39,6 +39,7 @@ var _entity_revisions: Dictionary = {}
var _surface_triangles: Dictionary = {}
var _surface_areas: Dictionary = {}
var _surface_total_areas: Dictionary = {}
var _spawn_anchor_positions: Dictionary = {}
var _respawns: Array[Dictionary] = []
var _next_respawn_by_type: Dictionary[StringName, float] = {}
var _charge_requests: Dictionary = {}
@ -151,10 +152,9 @@ func find_entity_near(
if bool(state.get("locked", false)):
continue
var entity_position: Vector3 = state.get("position", Vector3.ZERO)
var distance_squared: float = Vector2(
entity_position.x - position.x,
entity_position.z - position.z,
).length_squared()
var distance_squared: float = entity_position.distance_squared_to(
position
)
if distance_squared <= best_distance_squared:
best_distance_squared = distance_squared
best_id = entity_id
@ -177,10 +177,9 @@ func find_capture_target(
if entry == null or entry.required_tool_id != tool_id:
continue
var entity_position: Vector3 = state.get("position", Vector3.ZERO)
var distance_squared: float = Vector2(
entity_position.x - position.x,
entity_position.z - position.z,
).length_squared()
var distance_squared: float = entity_position.distance_squared_to(
position
)
if (
distance_squared <= entry.capture_radius * entry.capture_radius
and distance_squared <= best_distance_squared
@ -248,14 +247,30 @@ func _begin_population_if_ready() -> void:
func _cache_spawn_surface(entry: GatherableDataType) -> void:
if entry == null or _surface_triangles.has(entry.type_id):
if (
entry == null
or _surface_triangles.has(entry.type_id)
or _spawn_anchor_positions.has(entry.type_id)
):
return
var triangles: Array[PackedVector3Array] = (
_world.get_spawn_surface_triangles(
if not entry.spawn_anchor_set_id.is_empty():
var anchor_positions: PackedVector3Array = (
_world.get_gatherable_spawn_positions(entry.spawn_anchor_set_id)
)
_spawn_anchor_positions[entry.type_id] = anchor_positions
if anchor_positions.is_empty():
push_warning(
"No gatherable spawn anchors were found for %s." % entry.type_id
)
return
var triangles: Array[PackedVector3Array]
if not entry.diggable_area_id.is_empty():
triangles = _world.get_diggable_area_triangles(entry.diggable_area_id)
else:
triangles = _world.get_spawn_surface_triangles(
entry.surface_materials,
entry.minimum_surface_y,
)
)
var areas := PackedFloat32Array()
var total_area: float = 0.0
for triangle: PackedVector3Array in triangles:
@ -279,6 +294,11 @@ func _spawn_entity(entry: GatherableDataType) -> void:
var position: Vector3 = _sample_surface_position(entry)
if not position.is_finite():
return
var target: Vector3 = (
position
if entry.is_stationary_spawn()
else _sample_surface_position(entry, position, entry.roam_radius)
)
var quality: int = FishQualityType.roll(_rng)
var entity_id: String = _new_id("world")
var state: Dictionary = {
@ -286,11 +306,16 @@ func _spawn_entity(entry: GatherableDataType) -> void:
"type_id": entry.type_id,
"data": entry,
"position": position,
"target": _sample_surface_position(entry, position, entry.roam_radius),
"target": target,
"yaw": _rng.randf_range(-PI, PI),
"quality": quality,
"revision": 1,
"locked": false,
"expires_at": (
_now() + entry.active_lifetime_seconds
if entry.active_lifetime_seconds > 0.0
else INF
),
}
if not (state["target"] as Vector3).is_finite():
state["target"] = position
@ -311,6 +336,11 @@ func _update_host_entities(delta: float) -> void:
var entry := state.get("data") as GatherableDataType
if entry == null:
continue
if _now() >= float(state.get("expires_at", INF)):
_despawn_entity(entity_id, &"expired", false, true)
continue
if entry.is_stationary_spawn():
continue
var quality: int = _get_state_quality(state)
var position: Vector3 = state["position"]
var target: Vector3 = state["target"]
@ -349,7 +379,7 @@ func _update_host_entities(delta: float) -> void:
state["yaw"] = atan2(-direction.x, -direction.z)
state["position"] = position
_entities[entity_id] = state
if _should_scare(entry, position, quality):
if entry.can_be_scared() and _should_scare(entry, position, quality):
_despawn_entity(entity_id, &"scared", true, true)
@ -383,6 +413,8 @@ func _sample_surface_position(
maximum_distance: float = INF,
) -> Vector3:
_cache_spawn_surface(entry)
if not entry.spawn_anchor_set_id.is_empty():
return _sample_anchor_position(entry, origin, maximum_distance)
var triangles: Array = _surface_triangles.get(entry.type_id, [])
var cumulative_areas: PackedFloat32Array = _surface_areas.get(
entry.type_id,
@ -420,6 +452,44 @@ func _sample_surface_position(
return fallback
func _sample_anchor_position(
entry: GatherableDataType,
origin: Vector3,
maximum_distance: float,
) -> Vector3:
var anchors: PackedVector3Array = _spawn_anchor_positions.get(
entry.type_id,
PackedVector3Array(),
)
var candidates := PackedVector3Array()
for anchor: Vector3 in anchors:
if not anchor.is_finite() or _anchor_is_occupied(entry.type_id, anchor):
continue
if (
origin.is_finite()
and is_finite(maximum_distance)
and anchor.distance_to(origin) > maximum_distance
):
continue
candidates.append(anchor)
if candidates.is_empty():
return Vector3(INF, INF, INF)
return candidates[_rng.randi_range(0, candidates.size() - 1)]
func _anchor_is_occupied(type_id: StringName, anchor: Vector3) -> bool:
for state: Dictionary in _entities.values():
if StringName(state.get("type_id", StringName())) != type_id:
continue
var position: Variant = state.get("position")
if (
typeof(position) == TYPE_VECTOR3
and (position as Vector3).distance_squared_to(anchor) <= 0.0001
):
return true
return false
func _broadcast_entity_snapshots() -> void:
if _entities.is_empty():
return
@ -515,7 +585,9 @@ func _handle_interaction_begin(peer_id: int, data: Dictionary) -> void:
or request_id.length() > MAX_REQUEST_ID_LENGTH
or _pending_captures.has(peer_id)
):
_send_interaction_result(peer_id, request_id, false, "Cannot use the net now.")
_send_interaction_result(
peer_id, request_id, false, "Cannot use that gathering tool now."
)
return
_charge_requests[peer_id] = {
"request_id": request_id,
@ -556,27 +628,26 @@ func _handle_interaction_finish(peer_id: int, data: Dictionary) -> void:
):
error = "The catch attempt was invalid."
elif state.is_empty() or entry == null or bool(state.get("locked", false)):
error = "That animal is no longer there."
error = "That gathering spot is no longer there."
elif _now() - float(charge.get("started", _now())) + 0.05 < entry.charge_duration:
error = "Pull the net all the way back first."
error = "Finish readying the tool first."
elif _item_use.get_equipped_item_id(peer_id) != entry.required_tool_id:
error = "Equip the correct gathering tool."
elif avatar == null or not avatar.is_sneaking():
error = "Sneak closer before swinging the net."
elif avatar == null:
error = "The player is unavailable."
elif entry.requires_sneaking and not avatar.is_sneaking():
error = "Sneak closer before using the tool."
else:
var entity_position: Vector3 = state["position"]
var target_distance: float = Vector2(
entity_position.x - target_position.x,
entity_position.z - target_position.z,
).length()
var target_distance: float = entity_position.distance_to(target_position)
var player_distance: float = Vector2(
avatar.global_position.x - entity_position.x,
avatar.global_position.z - entity_position.z,
).length()
if target_distance > entry.capture_radius:
error = "The net missed."
error = "The gathering tool missed."
elif player_distance > entry.interaction_range:
error = "Move closer before swinging the net."
error = "Move closer before using the tool."
if not error.is_empty():
_send_interaction_result(peer_id, request_id, false, error)
return
@ -670,12 +741,7 @@ func _handle_local_capacity_probe(data: Dictionary) -> void:
var catch_id := StringName(str(data.get("catch_id", "")))
var can_accept: bool = (
_local_inventory != null
and _local_capacity != null
and (
_local_inventory.contains_catch_id(catch_id)
or _local_inventory.get_all_catches().size()
< _local_capacity.get_capacity()
)
and _local_inventory.can_accept_catch(catch_id)
)
if _session.is_host():
_handle_capacity_response(
@ -728,7 +794,7 @@ func _handle_capacity_response(
):
return
if not can_accept:
_reject_pending_capture(peer_id, "Cooler is full.")
_reject_pending_capture(peer_id, "Inventory is full.")
return
_pending_captures.erase(peer_id)
var entity_id: String = str(pending["entity_id"])
@ -813,8 +879,7 @@ func _apply_capture_result(data: Dictionary) -> void:
)
if (
not already_owned
and _local_inventory.get_all_catches().size()
>= _local_capacity.get_capacity()
and not _local_inventory.can_accept_catch(fish_catch.catch_id)
):
return
if not already_owned:
@ -1138,6 +1203,7 @@ func _clear_world() -> void:
_surface_triangles.clear()
_surface_areas.clear()
_surface_total_areas.clear()
_spawn_anchor_positions.clear()
_respawns.clear()
_next_respawn_by_type.clear()
_charge_requests.clear()

View file

@ -45,20 +45,16 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_time.set_persistence_tracking_enabled(true)
_world_time.begin_session(
_world_time.get_persistent_time_hours()
)
_world_time.begin_authoritative_session()
return
if state == NetworkSession.State.JOINED_CLIENT:
_active_session_id = _session.get_session_id()
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_time.set_persistence_tracking_enabled(false)
if _session.supports_server_capability(
NetworkProtocol.WORLD_TIME_CAPABILITY
):
_world_time.begin_session()
_world_time.begin_remote_session()
else:
_world_time.end_session()
return
@ -72,7 +68,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_time.set_persistence_tracking_enabled(false)
_world_time.end_session()

View file

@ -3,7 +3,7 @@ extends Node
const SYNC_INTERVAL_SECONDS: float = 15.0
const MAX_SESSION_ID_LENGTH: int = 96
const MAX_REMAINING_SECONDS: float = 1800.0
const MAX_REMAINING_SECONDS: float = WorldWeatherService.WEATHER_PERIOD_SECONDS
var _session: NetworkSession
var _world_weather: WorldWeatherService
@ -44,7 +44,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_weather.set_persistence_tracking_enabled(true)
_world_weather.begin_authoritative_session(
_active_session_id.hash()
)
@ -53,7 +52,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_active_session_id = _session.get_session_id()
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_weather.set_persistence_tracking_enabled(false)
if _session.supports_server_capability(
NetworkProtocol.WORLD_WEATHER_CAPABILITY
):
@ -71,7 +69,6 @@ func _on_session_state_changed(state: NetworkSession.State) -> void:
_sequence = 0
_last_received_sequence = -1
_sync_elapsed = 0.0
_world_weather.set_persistence_tracking_enabled(false)
_world_weather.end_session()