From 8a4207b2fd246dd0baf64cca7fbf3d19f635719f Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 10 Aug 2026 20:22:02 +0200 Subject: [PATCH] FIX - stabilize payphone call lifecycle --- sky_phone/config/config.lua | 1 + sky_phone/source/bridge/client/framework.lua | 2 +- sky_phone/source/client/payphones.lua | 106 +++++++++++++++++-- sky_phone/source/server/calls.lua | 102 ++++++++++++------ 4 files changed, 170 insertions(+), 41 deletions(-) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 0d06e19..1ee89b9 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -65,6 +65,7 @@ Config.Payphones = { Dictionary = "anim@scripted@payphone_hits@male@", PedClip = "FXFR_PAV_1_INTRO_MALE", PropClip = "FXFR_PAV_1_INTRO_PHONE", + HangupDurationMs = 1200, }, } diff --git a/sky_phone/source/bridge/client/framework.lua b/sky_phone/source/bridge/client/framework.lua index 77f498b..1bb8de6 100644 --- a/sky_phone/source/bridge/client/framework.lua +++ b/sky_phone/source/bridge/client/framework.lua @@ -55,5 +55,5 @@ function Bridge.Framework.ShowHelpNotification(message, key) local control = key == "E" and "~INPUT_CONTEXT~" or ("[%s]"):format(tostring(key or "E")) BeginTextCommandDisplayHelp("STRING") AddTextComponentSubstringPlayerName(("%s %s"):format(control, tostring(message or ""))) - EndTextCommandDisplayHelp(0, false, true, -1) + EndTextCommandDisplayHelp(0, false, false, -1) end diff --git a/sky_phone/source/client/payphones.lua b/sky_phone/source/client/payphones.lua index 5c7173c..b484034 100644 --- a/sky_phone/source/client/payphones.lua +++ b/sky_phone/source/client/payphones.lua @@ -10,7 +10,10 @@ local call_channel = 0 local replacement_prop = nil local hidden_prop = nil local animation_scene = nil +local animation_ped = nil +local animation_floor_z = nil local visuals_starting = false +local visuals_ending = false local hangup_requested = false local configured_models = {} @@ -68,9 +71,10 @@ end local function stop_call_visuals() local animation = Config.Payphones.Animation - local ped = PlayerPedId() + local ped = animation_ped or PlayerPedId() local visuals_active = animation_scene ~= nil or replacement_prop ~= nil visuals_starting = false + visuals_ending = false if visuals_active and DoesEntityExist(ped) then StopAnimTask(ped, animation.Dictionary, animation.PedClip, 0.0) @@ -83,6 +87,9 @@ local function stop_call_visuals() animation_scene = nil end + animation_ped = nil + animation_floor_z = nil + if replacement_prop and DoesEntityExist(replacement_prop) then StopEntityAnim(replacement_prop, animation.PropClip, animation.Dictionary, 0.0) SetEntityVisible(replacement_prop, false, false) @@ -98,6 +105,69 @@ local function stop_call_visuals() RemoveAnimDict(animation.Dictionary) end +local function keep_animation_ped_grounded() + if not animation_ped or not animation_floor_z or not DoesEntityExist(animation_ped) then + return + end + local coords = GetEntityCoords(animation_ped) + if coords.z >= animation_floor_z - 0.15 then + return + end + SetEntityCoordsNoOffset(animation_ped, coords.x, coords.y, animation_floor_z, false, false, false) + SetEntityVelocity(animation_ped, 0.0, 0.0, 0.0) +end + +local function play_hangup_visuals() + if visuals_ending then + return + end + if not animation_scene or not animation_ped or not DoesEntityExist(animation_ped) then + stop_call_visuals() + active_booth = nil + return + end + + visuals_ending = true + local scene = animation_scene + local starting_phase = math.max(0.0, math.min(1.0, GetSynchronizedScenePhase(scene))) + local duration_ms = math.max(250, math.floor(tonumber(Config.Payphones.Animation.HangupDurationMs) or 1200)) + local started_at = GetGameTimer() + SetSynchronizedSceneRate(scene, 0.0) + if replacement_prop and DoesEntityExist(replacement_prop) then + SetEntityAnimSpeed( + replacement_prop, + Config.Payphones.Animation.Dictionary, + Config.Payphones.Animation.PropClip, + 0.0 + ) + end + + CreateThread(function() + while animation_scene == scene do + local progress = math.min(1.0, (GetGameTimer() - started_at) / duration_ms) + local phase = starting_phase * (1.0 - progress) + SetSynchronizedScenePhase(scene, phase) + if replacement_prop and DoesEntityExist(replacement_prop) then + SetEntityAnimCurrentTime( + replacement_prop, + Config.Payphones.Animation.Dictionary, + Config.Payphones.Animation.PropClip, + phase + ) + end + keep_animation_ped_grounded() + if progress >= 1.0 then + break + end + Wait(0) + end + if animation_scene == scene then + stop_call_visuals() + active_booth = nil + end + end) +end + local function start_call_visuals() if visuals_starting or replacement_prop or not active_call_id or not active_booth or not DoesEntityExist(active_booth.entity) @@ -168,6 +238,15 @@ local function start_call_visuals() return end SetSynchronizedSceneHoldLastFrame(animation_scene, true) + animation_ped = ped + local ped_coords = GetEntityCoords(animation_ped) + local found_ground, ground_z = GetGroundZFor_3dCoord( + ped_coords.x, + ped_coords.y, + ped_coords.z + 1.0, + false + ) + animation_floor_z = found_ground and ground_z or ped_coords.z TaskSynchronizedScene( ped, animation_scene, @@ -334,15 +413,14 @@ RegisterNetEvent("sky_phone:payphone:state", function(data) else clear_active_call_state() leave_call_voice() - stop_call_visuals() - active_booth = nil + play_hangup_visuals() end SendNUIMessage({ type = "payphone:state", data = data }) end) CreateThread(function() while true do - if not Config.Payphones.Enabled or payphone_open or active_call_id then + if not Config.Payphones.Enabled or payphone_open or active_call_id or visuals_ending then nearest_payphone = nil Wait(Config.Payphones.ScanIntervalMs) else @@ -391,19 +469,27 @@ end) CreateThread(function() while true do - if active_call_id and active_booth then + local call_id = active_call_id + local booth = active_booth + if call_id and booth then + keep_animation_ped_grounded() Bridge.Framework.ShowHelpNotification(call_help_message(), "E") if IsControlJustReleased(0, 38) and not hangup_requested then hangup_requested = true - Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id }) + Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id }) end - local distance = #(GetEntityCoords(PlayerPedId()) - active_booth.coords) - if distance > Config.Payphones.MaximumCallDistance and not hangup_requested then - hangup_requested = true - Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = active_call_id }) + if active_call_id == call_id and active_booth == booth then + local distance = #(GetEntityCoords(PlayerPedId()) - booth.coords) + if distance > Config.Payphones.MaximumCallDistance and not hangup_requested then + hangup_requested = true + Bridge.Callbacks.Trigger("sky_phone:payphone:hangup", { id = call_id }) + end end Wait(0) + elseif visuals_ending then + keep_animation_ped_grounded() + Wait(0) else Wait(250) end diff --git a/sky_phone/source/server/calls.lua b/sky_phone/source/server/calls.lua index 5e40489..ee059f9 100644 --- a/sky_phone/source/server/calls.lua +++ b/sky_phone/source/server/calls.lua @@ -100,8 +100,8 @@ local function send_state(call, source, state, channel) channel = channel, } if call.payphone and outgoing then - payload.elapsedSeconds = call.payphone.billed_seconds or 0 - payload.totalCost = (call.payphone.billed_seconds or 0) * call.payphone.price_per_second + payload.elapsedSeconds = call.payphone.elapsed_seconds or 0 + payload.totalCost = call.payphone.total_cost or 0 TriggerClientEvent("sky_phone:payphone:state", source, payload) return end @@ -116,6 +116,59 @@ local function notify_recents(device, source) end end +local function settle_payphone_call(call, duration) + local payphone = call.payphone + local elapsed_seconds = math.max(0, math.floor(tonumber(duration) or 0)) + local price_per_second = math.max(0, math.floor(tonumber(payphone.price_per_second) or 0)) + local billable_seconds = elapsed_seconds + local total_cost = billable_seconds * price_per_second + + if total_cost > 0 then + local available_money = tonumber(Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount)) + if not available_money then + Bridge.Debug( + "error", + "[sky_phone] Payphone settlement could not read the balance for source %s.", + tostring(call.caller_source) + ) + billable_seconds = 0 + total_cost = 0 + elseif available_money < total_cost then + billable_seconds = math.min(elapsed_seconds, math.floor(math.max(0, available_money) / price_per_second)) + total_cost = billable_seconds * price_per_second + end + end + + local charged = total_cost == 0 + if total_cost > 0 then + local success, result = pcall( + Bridge.Framework.RemoveMoney, + call.caller_source, + Config.Payphones.PaymentAccount, + total_cost + ) + charged = success and result and true or false + if not success then + Bridge.Debug( + "error", + "[sky_phone] Payphone settlement failed for source %s: %s", + tostring(call.caller_source), + tostring(result) + ) + elseif not result then + Bridge.Debug( + "warn", + "[sky_phone] Payphone settlement was rejected for source %s.", + tostring(call.caller_source) + ) + end + end + + payphone.elapsed_seconds = elapsed_seconds + payphone.total_cost = charged and total_cost or 0 + return charged and billable_seconds == elapsed_seconds +end + local function finish_call(call, status) if not call or call.ended then return @@ -123,6 +176,11 @@ local function finish_call(call, status) call.ended = true local ended_at = os.time() local duration = call.answered_at and math.max(0, ended_at - call.answered_at) or 0 + if call.payphone and call.answered_at and not settle_payphone_call(call, duration) + and status ~= "disconnected" + then + status = "insufficient_funds" + end local callee_status = status if status == "no_answer" or status == "cancelled" then callee_status = "missed" @@ -552,7 +610,8 @@ Bridge.Callbacks.Register("sky_phone:payphone:dial", function(source, data) callee_device = target, started_at = os.time(), payphone = { - billed_seconds = 0, + elapsed_seconds = 0, + total_cost = 0, coords = booth_coords, price_per_second = price_per_second, }, @@ -675,33 +734,16 @@ CreateThread(function() calls_to_finish[call_id] = "disconnected" elseif call.payphone and call.answered_at and not call.ended then local elapsed_seconds = math.max(0, os.time() - call.answered_at) - local seconds_due = elapsed_seconds - call.payphone.billed_seconds - if seconds_due > 0 then - local amount_due = seconds_due * call.payphone.price_per_second - local charged = amount_due == 0 - if amount_due > 0 then - local success, result = pcall( - Bridge.Framework.RemoveMoney, - call.caller_source, - Config.Payphones.PaymentAccount, - amount_due - ) - charged = success and result and true or false - if not success then - Bridge.Debug( - "error", - "[sky_phone] Payphone billing failed for source %s: %s", - tostring(call.caller_source), - tostring(result) - ) - end - end - if charged then - call.payphone.billed_seconds = elapsed_seconds - send_state(call, call.caller_source, "connected", call.channel) - else - calls_to_finish[call_id] = "insufficient_funds" - end + local total_cost = elapsed_seconds * call.payphone.price_per_second + local available_money = tonumber( + Bridge.Framework.GetMoney(call.caller_source, Config.Payphones.PaymentAccount) + ) + if total_cost > 0 and (not available_money or available_money < total_cost) then + calls_to_finish[call_id] = "insufficient_funds" + elseif call.payphone.elapsed_seconds ~= elapsed_seconds then + call.payphone.elapsed_seconds = elapsed_seconds + call.payphone.total_cost = total_cost + send_state(call, call.caller_source, "connected", call.channel) end end end