From e5a1c2bf40565f09293f003e4348320ea20019ad Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Mon, 17 Aug 2026 15:57:56 +0200 Subject: [PATCH] ADD - open unique phones by item and hotkey Register a configurable F1 phone toggle through a server-authoritative open request and retain the selected unique handset by IMEI. Verify usable-item support for every inventory adapter, fail visibly on registration errors, and document the behavior. --- README.md | 6 + frontend/src/phoneInventory.contract.test.ts | 62 +++++++++++ frontend/src/serverStartup.contract.test.ts | 17 ++- sky_phone/config/config.lua | 1 + sky_phone/config/locales/de.lua | 3 + sky_phone/config/locales/en.lua | 3 + sky_phone/source/client/main.lua | 19 +++- sky_phone/source/server/phone.lua | 109 ++++++++++++++----- 8 files changed, 186 insertions(+), 34 deletions(-) create mode 100644 frontend/src/phoneInventory.contract.test.ts diff --git a/README.md b/README.md index e091a00..5b4a828 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,8 @@ Default entries for unique phones with physical SIM cards: Do not configure an LB Phone client event or client export. Sky Phone registers the usable items through its server-side inventory adapter. +The server registers `Config.Phone.Item` as usable for every supported inventory adapter: `ox`, `qb`, `lj`, `qs`, `codem`, `core`, `mf`, and `smx`. Resource startup fails visibly if the selected adapter cannot complete that registration. + ### QBCore-style item tables - Set the phone's `unique` value to match `Config.Phone.Unique`. @@ -235,6 +237,8 @@ Config.Sim.Enabled = true | `Unique = true` | Every phone item receives its own IMEI. Settings, apps, local data, linked account, and SIM move with the item. The item must not stack. | | `Unique = false` | Every framework character receives one persistent virtual device. Any configured phone item opens that device. The item may stack. | +With unique phones, using an inventory item selects that exact handset whenever the inventory reports its slot. The F1 hotkey reopens the last selected IMEI; if no handset has been selected yet, the server chooses the first concrete phone slot. The client never supplies a slot or IMEI. + | SIM mode | Behavior | | --- | --- | | `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. | @@ -363,6 +367,8 @@ The model must also be listed in `Config.Payphones.Props`. ## Commands +`Config.Phone.Keybind` defaults to `F1` and can be rebound in FiveM's key bindings. Set it to `false` to disable the phone hotkey. + | Command | Where | Purpose | | --- | --- | --- | | `/phone` | In game | Opens the development phone command when `Config.Phone.DevelopmentCommand` is enabled | diff --git a/frontend/src/phoneInventory.contract.test.ts b/frontend/src/phoneInventory.contract.test.ts new file mode 100644 index 0000000..c84e079 --- /dev/null +++ b/frontend/src/phoneInventory.contract.test.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const readResourceFile = (path: string) => + readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8') + +const inventoryAdapters = [ + ['ox', 'source/bridge/server/inventory/ox.lua'], + ['qb', 'source/bridge/server/inventory/qb.lua'], + ['lj', 'source/bridge/server/inventory/qb.lua'], + ['qs', 'source/bridge/server/inventory/qs.lua'], + ['codem', 'source/bridge/server/inventory/codem.lua'], + ['core', 'source/bridge/server/inventory/core.lua'], + ['mf', 'source/bridge/server/inventory/mf.lua'], + ['smx', 'source/bridge/server/inventory/smx.lua'], +] as const + +describe('phone inventory contracts', () => { + it.each(inventoryAdapters)( + 'registers the phone as a usable item through the %s adapter', + (_inventory, path) => { + expect(readResourceFile(path)).toContain( + 'function Bridge.Inventory.RegisterUsableItem', + ) + }, + ) + + it('fails startup when the selected inventory cannot register the phone item', () => { + const phoneServer = readResourceFile('source/server/phone.lua') + + expect(phoneServer).toContain( + 'Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone)', + ) + expect(phoneServer).toContain('if not usable_registered then') + }) + + it('opens from a configurable F1 mapping without client-provided device identity', () => { + const config = readResourceFile('config/config.lua') + const phoneClient = readResourceFile('source/client/main.lua') + const phoneServer = readResourceFile('source/server/phone.lua') + + expect(config).toContain('Keybind = "F1"') + expect(phoneClient).toContain( + 'RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind)', + ) + expect(phoneClient).toContain( + 'Bridge.Callbacks.Trigger("sky_phone:device:open-request", {})', + ) + expect(phoneServer).toContain( + 'Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)', + ) + }) + + it('keeps a server-selected unique handset as the preferred hotkey device', () => { + const phoneServer = readResourceFile('source/server/phone.lua') + + expect(phoneServer).toContain('local preferred_device_imeis = {}') + expect(phoneServer).toContain('local preferred_imei = preferred_device_imeis[source]') + expect(phoneServer).toContain('preferred_device_imeis[source] = imei') + }) +}) diff --git a/frontend/src/serverStartup.contract.test.ts b/frontend/src/serverStartup.contract.test.ts index 925f58c..0f6ddfa 100644 --- a/frontend/src/serverStartup.contract.test.ts +++ b/frontend/src/serverStartup.contract.test.ts @@ -12,6 +12,16 @@ const phoneServer = readFileSync( ) describe('server startup contracts', () => { + it('registers the production phone-open callback before the database migration runs', () => { + expect( + phoneServer.indexOf( + 'Bridge.Callbacks.Register("sky_phone:device:open-request"', + ), + ).toBeLessThan( + phoneServer.indexOf('Bridge.Database.AfterMigration("sky_phone"'), + ) + }) + it('registers the development-open callback before the database migration runs', () => { expect(manifest.indexOf("'source/server/phone.lua'")).toBeLessThan( manifest.indexOf("'source/server/db_migrate.lua'"), @@ -25,11 +35,12 @@ describe('server startup contracts', () => { ) }) - it('queues early development opens until the resource has fully started', () => { + it('queues early opens until the resource has fully started', () => { expect(phoneServer).toContain( 'AddEventHandler("onServerResourceStart", function(resource_name)', ) - expect(phoneServer).toContain('pending_development_opens[source] = true') - expect(phoneServer).toContain('development_open_handler = open_phone') + expect(phoneServer).toContain('pending_phone_opens[source] = true') + expect(phoneServer).toContain('phone_open_handler = open_phone') + expect(phoneServer).toContain('flush_pending_phone_opens()') }) }) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index bc6f75a..f44f0bb 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -27,6 +27,7 @@ Config.Command = "phone" Config.Phone = { Item = "phone", Unique = true, -- true: data follows each phone item; false: one persistent phone per character + Keybind = "F1", -- false disables the configurable phone key mapping AllowMovement = true, -- true: game input stays active while the mobile phone is open DevelopmentCommand = true, DeviceName = "iFruit Phone", diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index b4a5990..3e4e1dd 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -252,6 +252,9 @@ local german = translate_shared(clone(Locales["en"] or {})) merge(german, { CommandDescription = "Öffne dein Handy.", + Controls = { + OpenPhone = "Handy öffnen", + }, TestData = { CommandDescription = "Testinhalte für alle datenbasierten Handy-Apps erstellen oder aktualisieren.", Success = "Die Testinhalte sind bereit. Deine Sky-Cloud-Anmeldung lautet {email}. Öffne das Handy erneut, um alle Apps zu aktualisieren.", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 29a4163..6ee7aa9 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -1,5 +1,8 @@ Locales["en"] = { CommandDescription = "Open your phone.", + Controls = { + OpenPhone = "Open phone", + }, TestData = { CommandDescription = "Create or refresh test content in every data-driven phone app.", Success = "Test content is ready. Your Sky Cloud login is {email}. Reopen the phone to refresh every app.", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index ae26fdd..b5c478d 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -435,7 +435,7 @@ end if Config.Phone.DevelopmentCommand then RegisterCommand(Config.Command, function() - if is_open then + if is_open or open_requested then close_phone() return end @@ -443,6 +443,23 @@ if Config.Phone.DevelopmentCommand then end, false) end +RegisterCommand("sky_phone_toggle", function() + if is_open or open_requested then + close_phone() + return + end + + Bridge.Callbacks.Trigger("sky_phone:device:open-request", {}) +end, false) + +if Config.Phone.Keybind then + if type(Config.Phone.Keybind) ~= "string" or Config.Phone.Keybind == "" then + error("[sky_phone] Config.Phone.Keybind must be a non-empty keyboard key name or false.") + end + + RegisterKeyMapping("sky_phone_toggle", locale.Controls.OpenPhone, "keyboard", Config.Phone.Keybind) +end + RegisterNetEvent("sky_phone:testdata:feedback", function(success, detail) local test_data_locale = locale.TestData local message = success and test_data_locale.Success or test_data_locale.Failed diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 5a25bea..99df74a 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -1,20 +1,41 @@ -local development_open_handler -local pending_development_opens = {} +local phone_open_handler +local pending_phone_opens = {} local server_started = false +local function flush_pending_phone_opens() + if not server_started or not phone_open_handler then + return + end + + for player_source in pairs(pending_phone_opens) do + pending_phone_opens[player_source] = nil + phone_open_handler(player_source, nil) + end +end + +Bridge.Callbacks.Register("sky_phone:device:open-request", function(source) + if not server_started or not phone_open_handler then + pending_phone_opens[source] = true + return { success = true, data = { queued = true } } + end + + return { success = phone_open_handler(source, nil) } +end) + Bridge.Callbacks.Register("sky_phone:device:development-open", function(source) if not Config.Phone.DevelopmentCommand then return { success = false, error = "disabled" } end - if not server_started or not development_open_handler then - pending_development_opens[source] = true + if not server_started or not phone_open_handler then + pending_phone_opens[source] = true return { success = true, data = { queued = true } } end - return { success = development_open_handler(source, nil) } + + return { success = phone_open_handler(source, nil) } end) AddEventHandler("playerDropped", function() - pending_development_opens[source] = nil + pending_phone_opens[source] = nil end) AddEventHandler("onServerResourceStart", function(resource_name) @@ -23,23 +44,7 @@ AddEventHandler("onServerResourceStart", function(resource_name) end server_started = true - if not development_open_handler then - Bridge.Debug("error", "[sky_phone] Server initialization did not register the development phone handler.") - return - end - - for player_source in pairs(pending_development_opens) do - pending_development_opens[player_source] = nil - local success, opened = pcall(development_open_handler, player_source, nil) - if not success then - Bridge.Debug( - "error", - "[sky_phone] Queued development phone open failed for source %s: %s", - tostring(player_source), - tostring(opened) - ) - end - end + flush_pending_phone_opens() end) Bridge.Database.AfterMigration("sky_phone", function() @@ -50,6 +55,7 @@ SkyPhone = {} local unique_phones = Config.Phone.Unique ~= false local sim_cards_enabled = Config.Sim.Enabled ~= false local sessions = {} +local preferred_device_imeis = {} local auth_attempts = {} local operation_attempts = {} local character_device_cache = {} @@ -311,6 +317,14 @@ local function resolve_used_slot(source, used_item) if slot and slot.name == Config.Phone.Item then return slot end + + Bridge.Debug( + "warn", + "[sky_phone] Usable item callback reported invalid phone slot %s for source %s.", + tostring(slot_id), + tostring(source) + ) + return nil, "phone_slot_missing" end local slots = Bridge.Inventory.GetSlotsWithItem(source, Config.Phone.Item) @@ -332,17 +346,40 @@ local function resolve_used_slot(source, used_item) { always = true } ) end - if #slots == 1 or (not unique_phones and #slots > 0) then + + if not unique_phones and #slots > 0 then + return slots[1] + end + + local preferred_imei = preferred_device_imeis[source] + if preferred_imei then + for _, candidate in ipairs(slots) do + if candidate.metadata and candidate.metadata.imei == preferred_imei then + return candidate + end + end + end + + table.sort(slots, function(left, right) + local left_slot = tonumber(left.slot) + local right_slot = tonumber(right.slot) + if left_slot and right_slot and left_slot ~= right_slot then + return left_slot < right_slot + end + return tostring(left.slot) < tostring(right.slot) + end) + + if slots[1] then return slots[1] end Bridge.Debug( "warn", - "[sky_phone] Usable item callback did not identify an exact phone slot for source %s (%s candidates).", + "[sky_phone] No phone item slot was available for source %s (%s candidates).", tostring(source), tostring(#slots) ) - return nil + return nil, "phone_required" end local function ensure_device(source, slot) @@ -947,15 +984,16 @@ local function open_phone(source, used_item) tostring(source), { always = true } ) - local slot = resolve_used_slot(source, used_item) + local slot, slot_error = resolve_used_slot(source, used_item) if not slot then Bridge.Debug( "debug", - "[sky_phone] Phone open rejected for source %s: no exact inventory slot.", + "[sky_phone] Phone open rejected for source %s: %s.", tostring(source), + tostring(slot_error), { always = true } ) - TriggerClientEvent("sky_phone:device:error", source, "phone_slot_missing") + TriggerClientEvent("sky_phone:device:error", source, slot_error) return false end @@ -995,6 +1033,7 @@ local function open_phone(source, used_item) token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), unlocked = security == nil, } + preferred_device_imeis[source] = imei local payload = bootstrap(source, security, true) Bridge.Debug( "debug", @@ -1010,7 +1049,8 @@ local function open_phone(source, used_item) return true end -development_open_handler = open_phone +phone_open_handler = open_phone +flush_pending_phone_opens() function SkyPhone.OpenDeviceForCall(source, imei) local matches = find_device_slots(source, imei) @@ -1033,6 +1073,7 @@ function SkyPhone.OpenDeviceForCall(source, imei) unlocked = security == nil, } end + preferred_device_imeis[source] = imei TriggerClientEvent("sky_phone:device:open", source, bootstrap(source)) return true end @@ -1045,6 +1086,12 @@ Bridge.Debug( { always = true } ) local usable_registered = Bridge.Inventory.RegisterUsableItem(Config.Phone.Item, open_phone) +if not usable_registered then + error(("[sky_phone] Inventory '%s' did not register phone item '%s' as usable."):format( + tostring(Bridge.Inventory.GetResourceName()), + tostring(Config.Phone.Item) + )) +end Bridge.Debug( "debug", "[sky_phone] Usable item registration returned: %s.", @@ -1427,6 +1474,7 @@ AddEventHandler("playerDropped", function() auth_attempts[source] = nil operation_attempts[source] = nil character_device_cache[source] = nil + preferred_device_imeis[source] = nil end) AddEventHandler("onResourceStop", function(resource_name) @@ -1435,6 +1483,7 @@ AddEventHandler("onResourceStop", function(resource_name) auth_attempts = {} operation_attempts = {} character_device_cache = {} + preferred_device_imeis = {} end end) end)