mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:01:31 +00:00
FIX - harden phone stability and app controls (#28)
This commit is contained in:
@@ -51,6 +51,7 @@ Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -60,6 +61,52 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
@@ -116,7 +163,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -301,6 +348,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["de"] = {
|
||||
invalid_sim = "Diese SIM-Karte besitzt ungültige Metadaten.",
|
||||
phone_required = "Du benötigst ein Handy in deinem Inventar.",
|
||||
operation_in_progress = "Eine andere Handy-Aktion wird bereits ausgeführt.",
|
||||
rate_limited = "Das Handy wurde zu oft geöffnet. Versuch es gleich erneut.",
|
||||
voice_unavailable = "Der konfigurierte Sprachdienst ist nicht verfügbar.",
|
||||
default = "Das Handy konnte nicht geöffnet werden.",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["en"] = {
|
||||
invalid_sim = "This SIM card has invalid metadata.",
|
||||
phone_required = "You need a phone in your inventory.",
|
||||
operation_in_progress = "Another phone operation is already in progress.",
|
||||
rate_limited = "The phone was opened too often. Try again in a moment.",
|
||||
voice_unavailable = "The configured phone voice service is unavailable.",
|
||||
default = "The phone could not be opened.",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@ Locales["es"] = {
|
||||
invalid_sim = "Esta tarjeta SIM tiene metadatos inválidos.",
|
||||
phone_required = "Necesitas un teléfono en tu inventario.",
|
||||
operation_in_progress = "Otra operación de teléfono ya está en progreso.",
|
||||
rate_limited = "El teléfono se abrió demasiadas veces. Inténtalo de nuevo en un momento.",
|
||||
voice_unavailable = "El servicio de voz configurado para el teléfono no está disponible.",
|
||||
default = "No se pudo abrir el teléfono.",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,15 @@ local provider_aliases = {
|
||||
|
||||
local function resolve_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = provider_aliases[configured] or configured
|
||||
local resource_name = provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -23,8 +23,8 @@ end
|
||||
|
||||
function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
local matches = {}
|
||||
for index, item in pairs(inventory:SearchInventory(source, item_name, metadata) or {}) do
|
||||
local normalized = normalize(item, tonumber(index) or index)
|
||||
for _, slot_id in ipairs(inventory:GetSlotIdsWithItem(source, item_name, metadata) or {}) do
|
||||
local normalized = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if normalized and normalized.name == item_name
|
||||
and Bridge.Inventory.MetadataMatches(normalized.metadata, metadata) then
|
||||
matches[#matches + 1] = normalized
|
||||
@@ -40,7 +40,7 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) ~= true then
|
||||
if inventory:SetItemMetadata(source, slot.slot, requested_metadata) == false then
|
||||
return false
|
||||
end
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
|
||||
@@ -41,6 +41,15 @@ end
|
||||
|
||||
local function resolve_call_provider()
|
||||
local configured = tostring(Config.Calls.VoiceProvider or "")
|
||||
if configured == "auto" then
|
||||
for _, candidate in ipairs({ "yaca", "pma", "saltychat" }) do
|
||||
if GetResourceState(call_provider_resources[candidate]) == "started" then
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local selected = call_provider_aliases[configured] or configured
|
||||
local resource_name = call_provider_resources[selected]
|
||||
if resource_name and GetResourceState(resource_name) == "started" then
|
||||
|
||||
@@ -75,6 +75,21 @@ Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true
|
||||
|
||||
local locale, locale_name = SkyPhoneLocales.Resolve(Config.Bridge.Locale)
|
||||
|
||||
local function apply_disabled_apps(payload)
|
||||
if type(payload) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
payload.disabledApps = disabled
|
||||
end
|
||||
|
||||
local function send_admin_panel_open()
|
||||
SendNUIMessage({
|
||||
type = "admin:open",
|
||||
@@ -136,6 +151,7 @@ AddEventHandler("sky_phone:configurator:updated", function()
|
||||
refresh_admin_command_suggestion()
|
||||
SkyPhoneApps.SendCatalog()
|
||||
if is_open and device_payload then
|
||||
apply_disabled_apps(device_payload)
|
||||
device_payload.lang = locale_name
|
||||
device_payload.locales = locale.Nui
|
||||
device_payload.fallbackLocales = Locales.en.Nui
|
||||
@@ -175,6 +191,22 @@ local function close_phone(close_device_session)
|
||||
end
|
||||
end
|
||||
|
||||
local function request_phone_open(callback_name)
|
||||
if is_open or open_requested then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = true
|
||||
local result = Bridge.Callbacks.Trigger(callback_name, {})
|
||||
if type(result) == "table" and result.success == true then
|
||||
return true
|
||||
end
|
||||
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
|
||||
local function toggle_phone(open, no_focus)
|
||||
if open ~= nil and type(open) ~= "boolean" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid phone toggle state.")
|
||||
@@ -190,6 +222,9 @@ local function toggle_phone(open, no_focus)
|
||||
should_open = not (is_open or open_requested)
|
||||
end
|
||||
if not should_open then
|
||||
if open == nil and open_requested and not is_open then
|
||||
return true
|
||||
end
|
||||
close_phone()
|
||||
return true
|
||||
end
|
||||
@@ -202,12 +237,7 @@ local function toggle_phone(open, no_focus)
|
||||
return true
|
||||
end
|
||||
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if type(result) ~= "table" or result.success ~= true then
|
||||
open_without_focus = false
|
||||
return false
|
||||
end
|
||||
return true
|
||||
return request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
SkyPhoneClient.Toggle = toggle_phone
|
||||
@@ -217,13 +247,16 @@ AddEventHandler("sky_phone:client:forceClose", function()
|
||||
end)
|
||||
|
||||
local function run_development_command()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:development-open", {})
|
||||
request_phone_open("sky_phone:device:development-open")
|
||||
end
|
||||
|
||||
refresh_development_command = function()
|
||||
@@ -256,13 +289,16 @@ end
|
||||
refresh_development_command()
|
||||
|
||||
local function run_phone_toggle()
|
||||
if is_open or open_requested then
|
||||
if is_open then
|
||||
close_phone()
|
||||
return
|
||||
end
|
||||
if open_requested then
|
||||
return
|
||||
end
|
||||
|
||||
open_without_focus = false
|
||||
Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
request_phone_open("sky_phone:device:open-request")
|
||||
end
|
||||
|
||||
RegisterCommand("sky_phone_live_activity_open", function()
|
||||
@@ -271,8 +307,7 @@ RegisterCommand("sky_phone_live_activity_open", function()
|
||||
end
|
||||
|
||||
open_home_requested = true
|
||||
local result = Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})
|
||||
if not result or not result.success then
|
||||
if not request_phone_open("sky_phone:device:open-request") then
|
||||
open_home_requested = false
|
||||
end
|
||||
end, false)
|
||||
@@ -459,6 +494,10 @@ end)
|
||||
RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
if type(data) ~= "table" or type(data.device) ~= "table" or type(data.device.imei) ~= "string" then
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device open data.")
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
end
|
||||
return
|
||||
end
|
||||
Bridge.Debug(
|
||||
@@ -468,6 +507,7 @@ RegisterNetEvent("sky_phone:device:open", function(data)
|
||||
tostring(data.account ~= nil),
|
||||
{ always = true }
|
||||
)
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
open_requested = true
|
||||
@@ -484,6 +524,7 @@ RegisterNetEvent("sky_phone:device:updated", function(data)
|
||||
Bridge.Debug("error", "[sky_phone] Rejected invalid device update data.")
|
||||
return
|
||||
end
|
||||
apply_disabled_apps(data)
|
||||
device_payload = data
|
||||
update_equipped_phone_number(data)
|
||||
SendNUIMessage({ type = "device:updated", data = data })
|
||||
@@ -499,8 +540,10 @@ RegisterNetEvent("sky_phone:device:invalidated", function()
|
||||
end)
|
||||
|
||||
RegisterNetEvent("sky_phone:device:error", function(error_code)
|
||||
if not is_open and not open_requested then
|
||||
if not is_open then
|
||||
open_requested = false
|
||||
open_without_focus = false
|
||||
open_home_requested = false
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
|
||||
@@ -373,6 +373,9 @@ end
|
||||
|
||||
local function app_metadata(app_id)
|
||||
if BUILTIN_APPS[app_id] then
|
||||
if not SkyPhone.IsAppEnabled(app_id) then
|
||||
return nil
|
||||
end
|
||||
return {
|
||||
defaultInstalled = DEFAULT_INSTALLED_APPS[app_id] == true,
|
||||
removable = PROTECTED_APPS[app_id] ~= true,
|
||||
@@ -481,6 +484,7 @@ Bridge.Callbacks.Register("sky_phone:admin:bootstrap", function(source)
|
||||
success = true,
|
||||
data = {
|
||||
players = players,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
stats = {
|
||||
online = #players,
|
||||
devices = tonumber(stats.devices) or 0,
|
||||
|
||||
+144
-122
@@ -1368,8 +1368,6 @@ ensure_schema()
|
||||
migrate_crypto_keys()
|
||||
initialize_markets()
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", initialize_markets)
|
||||
|
||||
local function reconcile_settlements(include_recent)
|
||||
local age_clause = include_recent and "" or " AND settlement.`updated_at` < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 5 MINUTE)"
|
||||
local rows = Bridge.Database.Query([[
|
||||
@@ -1416,8 +1414,6 @@ local function reconcile_settlements(include_recent)
|
||||
end
|
||||
end
|
||||
|
||||
reconcile_settlements(true)
|
||||
|
||||
local function crypto_random_int(minimum, maximum, failure_message)
|
||||
local value = exports[GetCurrentResourceName()]:CryptoRandomInt(minimum, maximum)
|
||||
if type(value) ~= "number" then
|
||||
@@ -1500,134 +1496,160 @@ local function advance_market_cycle(config, dynamics)
|
||||
return dynamics.cycle_bias
|
||||
end
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(5 * 60 * 1000)
|
||||
reconcile_settlements(false)
|
||||
local scheduler_generation = 0
|
||||
|
||||
local function start_crypto_schedulers()
|
||||
scheduler_generation = scheduler_generation + 1
|
||||
if Config.Crypto.Enabled ~= true then
|
||||
return
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
Wait(tick_seconds * 1000)
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
local generation = scheduler_generation
|
||||
reconcile_settlements(true)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
Wait(5 * 60 * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
reconcile_settlements(false)
|
||||
end
|
||||
end)
|
||||
|
||||
CreateThread(function()
|
||||
while scheduler_generation == generation and Config.Crypto.Enabled == true do
|
||||
local tick_seconds = crypto_random_int(
|
||||
Config.Crypto.PriceTickMinimumSeconds,
|
||||
Config.Crypto.PriceTickMaximumSeconds + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market tick interval."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
Wait(tick_seconds * 1000)
|
||||
if scheduler_generation ~= generation or Config.Crypto.Enabled ~= true then
|
||||
break
|
||||
end
|
||||
with_exchange_lock(function()
|
||||
local market_count = crypto_random_int(
|
||||
Config.Crypto.MarketsPerTickMinimum,
|
||||
Config.Crypto.MarketsPerTickMaximum + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market count."
|
||||
)
|
||||
advance_global_market_cycle()
|
||||
local changed_markets = {}
|
||||
market_count = math.min(market_count, #market_order)
|
||||
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
for offset = 0, market_count - 1 do
|
||||
local order_index = ((market_cursor + offset - 1) % #market_order) + 1
|
||||
local market_id = market_order[order_index]
|
||||
local config = markets[market_id]
|
||||
local row = Bridge.Database.Query(
|
||||
"SELECT `price`,`version`,`status` FROM `sky_phone_crypto_markets` WHERE `id` = ? LIMIT 1",
|
||||
{ market_id }
|
||||
)[1]
|
||||
if row and row.status == "active" then
|
||||
local price = tonumber(row.price) or config.InitialPrice
|
||||
local impulse = crypto_random_int(
|
||||
-config.VolatilityBasisPoints,
|
||||
config.VolatilityBasisPoints + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market movement."
|
||||
)
|
||||
local direction_roll = crypto_random_int(
|
||||
if impulse > 0 then
|
||||
impulse = math.floor(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
elseif impulse < 0 then
|
||||
impulse = math.ceil(impulse / Config.Crypto.RandomImpulseDivisor)
|
||||
end
|
||||
local shock_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
10000,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock roll."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
local dynamics = market_dynamics[market_id] or {
|
||||
momentum = 0,
|
||||
cycle_bias = 0,
|
||||
cycle_direction = 0,
|
||||
cycle_remaining_ticks = 0,
|
||||
cycle_target = 0,
|
||||
}
|
||||
dynamics.momentum = truncate_integer((
|
||||
dynamics.momentum * Config.Crypto.MomentumDecayBasisPoints
|
||||
+ impulse * Config.Crypto.MomentumImpulseBasisPoints
|
||||
) / 10000)
|
||||
local cycle_bias = advance_market_cycle(config, dynamics)
|
||||
market_dynamics[market_id] = dynamics
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
local deviation = math.floor(
|
||||
(config.InitialPrice - price) * 10000 / config.InitialPrice
|
||||
)
|
||||
local reversion = truncate_integer(
|
||||
deviation * Config.Crypto.MeanReversionBasisPoints / 10000
|
||||
)
|
||||
local shock = 0
|
||||
if shock_roll < Config.Crypto.MarketShockChanceBasisPoints then
|
||||
local multiplier = crypto_random_int(
|
||||
Config.Crypto.MarketShockMinimumMultiplier,
|
||||
Config.Crypto.MarketShockMaximumMultiplier + 1,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock multiplier."
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
local direction_roll = crypto_random_int(
|
||||
0,
|
||||
2,
|
||||
"[sky_phone] Crypto entropy provider did not return a market shock direction."
|
||||
)
|
||||
local direction = direction_roll == 0 and -1 or 1
|
||||
shock = direction * config.VolatilityBasisPoints * multiplier
|
||||
end
|
||||
|
||||
local maximum_movement = config.VolatilityBasisPoints
|
||||
* Config.Crypto.MaximumMovementMultiplier
|
||||
local movement = impulse + dynamics.momentum + cycle_bias
|
||||
+ global_market_trend + reversion + shock
|
||||
movement = math.max(-maximum_movement, math.min(maximum_movement, movement))
|
||||
if movement > 0 then
|
||||
movement = math.floor(movement / Config.Crypto.TickMovementDivisor)
|
||||
elseif movement < 0 then
|
||||
movement = math.ceil(movement / Config.Crypto.TickMovementDivisor)
|
||||
end
|
||||
local next_price = math.floor(price * (10000 + movement) / 10000)
|
||||
if next_price == price and movement ~= 0 then
|
||||
next_price = price + (movement > 0 and 1 or -1)
|
||||
end
|
||||
next_price = math.max(config.MinimumPrice, math.min(config.MaximumPrice, next_price))
|
||||
local next_version = (tonumber(row.version) or 0) + 1
|
||||
if Bridge.Database.Transaction({
|
||||
{ query = [[UPDATE `sky_phone_crypto_markets` SET `price` = ?, `version` = ? WHERE `id` = ? AND `version` = ?]], params = { next_price, next_version, market_id, row.version } },
|
||||
{ query = [[INSERT INTO `sky_phone_crypto_market_ticks` (`market_id`,`version`,`price`) VALUES (?, ?, ?)]], params = { market_id, next_version, next_price } },
|
||||
}) then
|
||||
changed_markets[#changed_markets + 1] = market_id
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? AND `id` NOT IN (
|
||||
SELECT `id` FROM (
|
||||
SELECT `id` FROM `sky_phone_crypto_market_ticks`
|
||||
WHERE `market_id` = ? ORDER BY `id` DESC LIMIT ?
|
||||
) retained
|
||||
)
|
||||
]], { market_id, market_id, Config.Crypto.HistoryRetentionTicks })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
market_cursor = ((market_cursor + market_count - 1) % #market_order) + 1
|
||||
if #changed_markets > 0 then
|
||||
TriggerClientEvent("sky_phone:crypto:changed", -1, {
|
||||
markets = market_dtos(changed_markets),
|
||||
updatedAt = os.time() * 1000,
|
||||
})
|
||||
end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function refresh_crypto_runtime()
|
||||
initialize_markets()
|
||||
start_crypto_schedulers()
|
||||
end
|
||||
|
||||
AddEventHandler("sky_phone:configurator:serverUpdated", refresh_crypto_runtime)
|
||||
start_crypto_schedulers()
|
||||
|
||||
end)
|
||||
|
||||
@@ -931,10 +931,16 @@ end)
|
||||
CreateThread(function()
|
||||
while true do
|
||||
Wait(Config.DarkChat.CleanupIntervalSeconds * 1000)
|
||||
local batch_size = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.DarkChat.CleanupBatchSize) or 250)
|
||||
)
|
||||
Bridge.Database.Query([[
|
||||
DELETE FROM `sky_phone_darkchat_messages`
|
||||
WHERE `expires_at` IS NOT NULL AND `expires_at` <= CURRENT_TIMESTAMP
|
||||
]], {})
|
||||
ORDER BY `expires_at`, `id`
|
||||
LIMIT ?
|
||||
]], { batch_size })
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
@@ -54,12 +54,28 @@ Bridge.Debug("debug", "[sky_phone] Server initialization started after database
|
||||
|
||||
SkyPhone = {}
|
||||
|
||||
function SkyPhone.IsAppEnabled(app_id)
|
||||
return type(Config.Apps) ~= "table" or Config.Apps[app_id] ~= false
|
||||
end
|
||||
|
||||
function SkyPhone.GetDisabledApps()
|
||||
local disabled = {}
|
||||
for app_id, enabled in pairs(Config.Apps or {}) do
|
||||
if type(app_id) == "string" and enabled == false then
|
||||
disabled[#disabled + 1] = app_id
|
||||
end
|
||||
end
|
||||
table.sort(disabled)
|
||||
return disabled
|
||||
end
|
||||
|
||||
local sessions = {}
|
||||
local preferred_device_imeis = {}
|
||||
local equipped_phone_numbers = {}
|
||||
local equipped_phone_identifiers = {}
|
||||
local equipped_phone_sources = {}
|
||||
local operation_attempts = {}
|
||||
local phone_open_in_progress = {}
|
||||
local character_device_cache = {}
|
||||
|
||||
local function trim(value)
|
||||
@@ -548,6 +564,7 @@ local function bootstrap(source, security, security_loaded)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
disabledApps = SkyPhone.GetDisabledApps(),
|
||||
phoneNumberFormat = {
|
||||
length = Config.Sim.NumberLength,
|
||||
groups = Config.Sim.NumberGroups,
|
||||
@@ -839,7 +856,7 @@ function SkyPhone.RefreshDevice(imei)
|
||||
end
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
local function perform_phone_open(source, used_item)
|
||||
local opened_at = GetGameTimer()
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
@@ -926,6 +943,38 @@ local function open_phone(source, used_item)
|
||||
return true
|
||||
end
|
||||
|
||||
local function open_phone(source, used_item)
|
||||
if phone_open_in_progress[source] then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "operation_in_progress")
|
||||
return false, "operation_in_progress"
|
||||
end
|
||||
|
||||
local request_limit = math.max(
|
||||
1,
|
||||
math.floor(tonumber(Config.Phone.OpenRequestsPerMinute) or 20)
|
||||
)
|
||||
if not SkyPhone.AllowOperation(source, "phone_open", request_limit, 60) then
|
||||
TriggerClientEvent("sky_phone:device:error", source, "rate_limited")
|
||||
return false, "rate_limited"
|
||||
end
|
||||
|
||||
phone_open_in_progress[source] = true
|
||||
local completed, success, error_code = pcall(perform_phone_open, source, used_item)
|
||||
phone_open_in_progress[source] = nil
|
||||
if not completed then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Phone open failed unexpectedly for source %s: %s",
|
||||
tostring(source),
|
||||
tostring(success),
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, "request_failed")
|
||||
return false, "request_failed"
|
||||
end
|
||||
return success, error_code
|
||||
end
|
||||
|
||||
phone_open_handler = open_phone
|
||||
flush_pending_phone_opens()
|
||||
|
||||
@@ -1028,6 +1077,7 @@ AddEventHandler("playerDropped", function()
|
||||
sessions[source] = nil
|
||||
discard_equipped_phone_number(source)
|
||||
operation_attempts[source] = nil
|
||||
phone_open_in_progress[source] = nil
|
||||
character_device_cache[source] = nil
|
||||
preferred_device_imeis[source] = nil
|
||||
end)
|
||||
@@ -1036,6 +1086,7 @@ AddEventHandler("onResourceStop", function(resource_name)
|
||||
if resource_name == GetCurrentResourceName() then
|
||||
sessions = {}
|
||||
operation_attempts = {}
|
||||
phone_open_in_progress = {}
|
||||
character_device_cache = {}
|
||||
preferred_device_imeis = {}
|
||||
equipped_phone_numbers = {}
|
||||
|
||||
@@ -35,6 +35,7 @@ end
|
||||
local CLIENT_CONFIG_KEYS = {
|
||||
AdminPanel = true,
|
||||
Animations = true,
|
||||
Apps = true,
|
||||
Banking = true,
|
||||
Billing = true,
|
||||
Bridge = true,
|
||||
|
||||
@@ -36,6 +36,7 @@ Config.Phone = {
|
||||
Item = "phone",
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; forced false for metadata-free inventories
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
OpenRequestsPerMinute = 20,
|
||||
AllowMovement = true, -- true: game input stays active while the mobile phone is open
|
||||
HoldToLook = {
|
||||
Enabled = true, -- hold the configured control to hide the cursor and look around; independent of AllowMovement
|
||||
@@ -45,6 +46,52 @@ Config.Phone = {
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
|
||||
-- Server-wide availability for bundled apps. Set an entry to false to hide it
|
||||
-- from every phone, the App Store and per-device app management.
|
||||
Config.Apps = {
|
||||
["app-store"] = true,
|
||||
banking = true,
|
||||
billing = true,
|
||||
calculator = true,
|
||||
calendar = true,
|
||||
camera = true,
|
||||
citymarkt = true,
|
||||
citywarn = true,
|
||||
clock = true,
|
||||
companies = true,
|
||||
crewlink = true,
|
||||
crypto = true,
|
||||
darkchat = true,
|
||||
feather = true,
|
||||
flare = true,
|
||||
fliptok = true,
|
||||
garage = true,
|
||||
health = true,
|
||||
house = true,
|
||||
["local-pages"] = true,
|
||||
mail = true,
|
||||
map = true,
|
||||
memory = true,
|
||||
memos = true,
|
||||
messages = true,
|
||||
minesweeper = true,
|
||||
music = true,
|
||||
["neon-drop"] = true,
|
||||
notes = true,
|
||||
["number-merge"] = true,
|
||||
phone = true,
|
||||
photos = true,
|
||||
picstagram = true,
|
||||
radio = true,
|
||||
settings = true,
|
||||
["sky-flappy"] = true,
|
||||
skyride = true,
|
||||
snake = true,
|
||||
["tower-stack"] = true,
|
||||
weather = true,
|
||||
["weazel-news"] = true,
|
||||
}
|
||||
|
||||
Config.TestData = {
|
||||
Enabled = false, -- development/test servers only; keep disabled in production
|
||||
Command = "phonetestdata",
|
||||
@@ -101,7 +148,7 @@ Config.Speaker = {
|
||||
}
|
||||
|
||||
Config.Calls = {
|
||||
VoiceProvider = "pma", -- yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
VoiceProvider = "pma", -- auto, yaca (alias: yaca-voice), pma (alias: pma-voice), saltychat (alias: salty)
|
||||
RingSeconds = 30,
|
||||
ContactNameMaxLength = 80,
|
||||
ContactNotesMaxLength = 500,
|
||||
@@ -286,6 +333,7 @@ Config.DarkChat = {
|
||||
VoiceMaxBase64Length = 360000,
|
||||
VoiceWaveformSamples = 48,
|
||||
CleanupIntervalSeconds = 30,
|
||||
CleanupBatchSize = 250,
|
||||
AllowedDisappearTimers = {
|
||||
[0] = true,
|
||||
[-1] = true, -- after reading
|
||||
|
||||
Reference in New Issue
Block a user