Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Key utilities in [addons/talo/utils/](addons/talo/utils/):
- **CryptoManager** - Encryption key generation/storage for offline data
- **SessionManager** - Session token persistence
- **DebounceTimer** - Debounces health checks, player updates, save updates (1s default, configurable via `debounce_timer_seconds`)
- **TaloDebouncedAPI** ([apis/debounced_api.gd](addons/talo/apis/debounced_api.gd)) - Base class for debounced APIs. Provides `flush_updates() -> FlushResult` and per-update awaiters. Player and save updates debounce by default but can be awaited for entity-specific results.

## GDScript Standards

Expand Down
94 changes: 94 additions & 0 deletions addons/talo/apis/debounced_api.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
class_name TaloDebouncedAPI extends TaloAPI

enum FlushResult {
NOTHING_PENDING,
SUCCESS,
FAILURE,
}

signal _update_settled(success: bool, operation_data: Variant)

var _update_timer: TaloDebounceTimer
var _is_executing: bool
var _is_queued: bool
var _pending_waiters: Array[UpdateWaiter] = []

func _init(base_path: String, leading: bool = true) -> void:
super(base_path)
_update_timer = TaloDebounceTimer.new(_on_debounce_fired, leading)
add_child(_update_timer)

func _debounce() -> void:
_update_timer.debounce()

func _queue_update() -> UpdateWaiter:
var waiter := UpdateWaiter.new()
_pending_waiters.append(waiter)
_debounce()
return waiter

func _run_debounced_update() -> Variant:
return null

func _build_update_result(success: bool, operation_data: Variant) -> Variant:
return operation_data

func _on_debounce_fired() -> void:
# if an update is executing, queue a new update
if _is_executing:
_is_queued = true
return
# else, just execute it
_execute_update()

func _execute_update() -> void:
while true:
var waiters := _pending_waiters
_pending_waiters = []
_is_executing = true
var result := await _run_debounced_update()
_is_executing = false

var success := result != null
if not success:
push_error("%s debounced update failed" % name)

# check if another update was queued during execution
if _is_queued:
_is_queued = false
_is_executing = true

var update_result := _build_update_result(success, result)
for waiter in waiters:
waiter.settle(update_result)
_update_settled.emit(success, result)

if not _is_executing:
return

func flush_updates() -> FlushResult:
var result := FlushResult.NOTHING_PENDING
while _is_executing or not _update_timer.is_stopped():
if _is_executing:
var settled: Array = await _update_settled
var success: bool = settled[0]
if success:
# don't override the failure result
if result == FlushResult.NOTHING_PENDING:
result = FlushResult.SUCCESS
else:
result = FlushResult.FAILURE
else:
_update_timer.stop()
_execute_update()

return result

class UpdateWaiter:
signal settled(result: Variant)

var result: Variant

func settle(update_result: Variant) -> void:
result = update_result
settled.emit(update_result)
1 change: 1 addition & 0 deletions addons/talo/apis/debounced_api.gd.uid
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
uid://cyhwup66cl8nu
54 changes: 40 additions & 14 deletions addons/talo/apis/players_api.gd
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class_name PlayersAPI extends TaloAPI
class_name PlayersAPI extends TaloDebouncedAPI
## An interface for communicating with the Talo Players API.
##
## This API is used to identify players and update player data.
Expand All @@ -20,14 +20,15 @@ signal identity_cleared()
## Emitted when one or more props are rejected during a player update.
signal props_rejected(rejected_props: Array[TaloRejectedProp])

var _update_timer := TaloDebounceTimer.new(_handle_update_timer_timeout, false)
## Emitted when a debounced player update settles.
signal player_updated(success: bool)

func _init(base_path: String) -> void:
super(base_path)
_update_settled.connect(_on_update_settled)

func _ready() -> void:
Talo.connection_restored.connect(_on_connection_restored)
add_child(_update_timer)

func _handle_update_timer_timeout() -> void:
await Talo.players.update()

func _handle_identify_success(alias: TaloPlayerAlias, socket_token: String = "") -> TaloPlayerAlias:
if not await Talo.is_offline() and Talo.socket.is_identified():
Expand Down Expand Up @@ -89,16 +90,14 @@ func identify_game_center(
})
return await identify("game_center", identifier.uri_encode())

## Queue a debounced update to the current player. The timer will reset every time this method is called.
func debounce_update() -> void:
_update_timer.debounce()

## Flush and sync the player's current data with Talo.
func update() -> TaloPlayer:
func _run_debounced_update() -> Variant:
if Talo.identity_check() != OK:
return null

var res := await client.make_request(HTTPClient.METHOD_PATCH, "/%s" % Talo.current_player.id, { props = Talo.current_player.get_serialized_props() })
var res := await client.make_request(HTTPClient.METHOD_PATCH, "/%s" % Talo.current_player.id, {
props = Talo.current_player.get_serialized_props()
})

match res.status:
200:
if is_instance_valid(Talo.current_alias.player):
Expand All @@ -112,10 +111,29 @@ func update() -> TaloPlayer:
if rejected_props.size() > 0:
props_rejected.emit(rejected_props)

return Talo.current_player
return rejected_props
_:
return null

func _on_update_settled(success: bool, _operation_data: Variant) -> void:
player_updated.emit(success)

func _build_update_result(success: bool, operation_data: Variant) -> Variant:
if not success:
return PlayerUpdateResult.new(false)
return PlayerUpdateResult.new(true, operation_data)

## Flush and sync the player's current data with Talo.
func update() -> TaloPlayer:
var data := await _run_debounced_update()
if data == null:
return null
return Talo.current_player

## Queue a debounced update. The returned signal resolves with a PlayerUpdateResult.
func debounce_update() -> Signal:
return _queue_update().settled

## Merge all of the data from player_id2 into player_id1 and delete player_id2.
func merge(player_id1: String, player_id2: String, options := MergeOptions.new()) -> TaloPlayer:
var res := await client.make_request(HTTPClient.METHOD_POST, "/merge", {
Expand Down Expand Up @@ -205,3 +223,11 @@ class SearchPage:

class MergeOptions:
var post_merge_identity_service: String = ""

class PlayerUpdateResult:
var success: bool
var rejected_props: Array[TaloRejectedProp]

func _init(success: bool, rejected_props: Array[TaloRejectedProp] = []) -> void:
self.success = success
self.rejected_props = rejected_props
47 changes: 34 additions & 13 deletions addons/talo/apis/saves_api.gd
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
class_name SavesAPI extends TaloAPI
class_name SavesAPI extends TaloDebouncedAPI
## An interface for communicating with the Talo Saves API.
##
## This API allows you to save and load game data for your players. You can create, update, and delete saves, as well as load and unload them.
Expand All @@ -13,6 +13,8 @@ signal save_chosen(save: TaloGameSave)
signal save_loading_completed
## Emitted when the current save is unloaded.
signal save_unloaded(save: TaloGameSave)
## Emitted when the current save is updated. The save is null on failure.
signal save_updated(success: bool, save: TaloGameSave)

var _saves_manager := TaloSavesManager.new()

Expand All @@ -28,14 +30,9 @@ var latest: TaloGameSave:
var current: TaloGameSave:
get: return _saves_manager.current_save

var _update_timer := TaloDebounceTimer.new(_handle_update_timer_timeout)

func _ready() -> void:
add_child(_update_timer)

func _handle_update_timer_timeout() -> void:
if _saves_manager.current_save:
await update_save(_saves_manager.current_save)
func _init(base_path: String) -> void:
super(base_path)
_update_settled.connect(_on_update_settled)

## Sync an offline save with an online save using the offline save data.
func replace_save_with_offline_save(offline_save: TaloGameSave) -> TaloGameSave:
Expand Down Expand Up @@ -119,19 +116,33 @@ func create_save(save_name: String, content: Dictionary = {}) -> TaloGameSave:
func register(loadable: TaloLoadable) -> void:
_saves_manager.register(loadable)

func _run_debounced_update() -> Variant:
if _saves_manager.current_save:
return await update_save(_saves_manager.current_save)
return null

func _on_update_settled(success: bool, operation_data: Variant) -> void:
save_updated.emit(success, operation_data if success else null)

func _build_update_result(success: bool, operation_data: Variant) -> Variant:
var save: TaloGameSave = operation_data if success else null
return SaveUpdateResult.new(success, save)

## Update the currently loaded save using the current state of the game and with the given name.
func update_current_save(new_name: String = "") -> TaloGameSave:
func update_current_save(new_name: String = "") -> Variant:
if not _saves_manager.current_save:
return null

# if the save is being renamed, sync it immediately
if not new_name.is_empty():
return await update_save(_saves_manager.current_save, new_name)
var save := await update_save(_saves_manager.current_save, new_name)
var success := save != null
save_updated.emit(success, save if success else null)
return SaveUpdateResult.new(success, save)
# else, update the save locally and queue it for syncing
else:
_update_timer.debounce()
_saves_manager.current_save.content = _saves_manager.get_save_content()
return _saves_manager.current_save
return await _queue_update().settled

## Update the given save using the current state of the game and with the given name.
func update_save(save: TaloGameSave, new_name: String = "") -> TaloGameSave:
Expand All @@ -157,6 +168,8 @@ func update_save(save: TaloGameSave, new_name: String = "") -> TaloGameSave:
match res.status:
200:
save = TaloGameSave.new(res.body.save)
_:
return null

_saves_manager.replace_save(save)
return save
Expand All @@ -181,3 +194,11 @@ func delete_save(save: TaloGameSave, unload_if_current_save: bool = false) -> vo
## Get the format version for the current save.
func get_format_version() -> String:
return _saves_manager.get_format_version()

class SaveUpdateResult extends RefCounted:
var success: bool
var save: TaloGameSave

func _init(result_success: bool, result_save: TaloGameSave) -> void:
success = result_success
save = result_save
26 changes: 16 additions & 10 deletions addons/talo/entities/entity_with_props.gd
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,23 @@ func get_prop(key: String, fallback: String = "") -> String:
return fallback if filtered.is_empty() else filtered.front().value

## Set a property by key and value.
func set_prop(key: String, value: String) -> void:
func set_prop(key: String, value: String) -> Variant:
var filtered := props.filter(func (prop: TaloProp): return prop.key == key)
if filtered.is_empty():
props.push_back(TaloProp.new(key, value))
else:
filtered.front().value = value
return null

## Delete a property by key.
func delete_prop(key: String) -> void:
func delete_prop(key: String) -> Variant:
props.assign(props.map(
func (prop: TaloProp):
if prop.key == key:
prop.value = null
return prop
))
return null

func get_serialized_props() -> Array:
return props.map(func (prop: TaloProp): return prop.to_dictionary())
Expand All @@ -43,7 +45,7 @@ func get_prop_array(key: String) -> Array[String]:
return result

## Set all values for a prop array by key, replacing any existing values.
func set_prop_array(key: String, values: Array[String]) -> void:
func set_prop_array(key: String, values: Array[String]) -> Variant:
var unique_values: Array[String] = []

for v in values:
Expand All @@ -52,40 +54,43 @@ func set_prop_array(key: String, values: Array[String]) -> void:

if unique_values.is_empty():
push_error("set_prop_array: values must not be empty")
return
return null

var array_key := TaloProp.to_array_key(key)

props.assign(props.filter(func (prop: TaloProp): return prop.key != array_key))
for v in unique_values:
props.push_back(TaloProp.new(array_key, v))
return null

## Delete a prop array by key, leaving a sentinel null entry.
func delete_prop_array(key: String) -> void:
func delete_prop_array(key: String) -> Variant:
var array_key := TaloProp.to_array_key(key)

var matches := props.filter(func (prop: TaloProp): return prop.key == array_key)
if matches.is_empty():
push_error("delete_prop_array: array key not found")
return
return null

props.assign(props.filter(func (prop: TaloProp): return prop.key != array_key))
props.push_back(TaloProp.new(array_key, null))
return null

## Insert a value into a prop array by key.
func insert_into_prop_array(key: String, value: String) -> void:
func insert_into_prop_array(key: String, value: String) -> Variant:
if value == "":
push_error("insert_into_prop_array: value must not be empty")
return
return null

var array_key := TaloProp.to_array_key(key)
var already_exists := props.any(func (prop: TaloProp): return prop.key == array_key && prop.value == value)
if !already_exists:
props.assign(props.filter(func (prop: TaloProp): return !(prop.key == array_key && prop.value == null)))
props.push_back(TaloProp.new(array_key, value))
return null

## Remove a value from a prop array by key.
func remove_from_prop_array(key: String, value: String) -> void:
func remove_from_prop_array(key: String, value: String) -> Variant:
var array_key := TaloProp.to_array_key(key)
var had_sentinel := props.any(func (prop: TaloProp): return prop.key == array_key && prop.value == null)
props.assign(props.filter(func (prop: TaloProp): return !(prop.key == array_key && prop.value == null)))
Expand All @@ -95,8 +100,9 @@ func remove_from_prop_array(key: String, value: String) -> void:
if had_sentinel:
props.push_back(TaloProp.new(array_key, null))
push_error("remove_from_prop_array: value not found in array")
return
return null

props.assign(props.filter(func (prop: TaloProp): return !(prop.key == array_key && prop.value == value)))
if !props.any(func (prop: TaloProp): return prop.key == array_key):
props.push_back(TaloProp.new(array_key, null))
return null
Loading