mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
FIX - harden inventory contracts and resolve phone interaction issues (#20)
* FIX - repair inventory metadata and ESX contracts Use the stable core_inventory metadata setter and validate the complete inventory adapter contract after provider bridges load. Preserve the real ESX configuration error instead of cascading into a missing RegisterUsableItem failure. * FIX - clarify phone opening without SIM * FIX - harden inventory and device contracts Validate SIM number configuration before item handling, preserve QB metadata mutation contracts, and propagate phone-open failures to callers. Extend regression coverage for inventory bridges and device bootstrap errors. * FIX - resolve reported phone interaction issues Stop game-input passthrough while NUI text fields are focused and add an independent hold-to-look option. Propagate SIM number formatting to the frontend, migrate configured currency symbols to utf8mb4, and clarify SkyRide, VaultX, and DarkChat behavior with focused contract coverage. * FIX - detect missing packaged phone UI Validate the generated NUI entrypoint, its referenced assets, and required static media when the resource starts. Print an actionable server-console warning for incomplete source archives, package phone sounds through the manifest, and cover the detection contract with Lua regressions.
This commit is contained in:
@@ -17,6 +17,8 @@ for (const requiredFragment of [
|
||||
"fx_version 'cerulean'",
|
||||
"node_version '22'",
|
||||
"use_experimental_fxv2_oal 'yes'",
|
||||
"'source/server/nui_build_check.lua'",
|
||||
"'source/html/sounds/**'",
|
||||
"ui_page 'source/html/index.html'",
|
||||
]) {
|
||||
if (!manifest.includes(requiredFragment)) {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<p align="center">
|
||||
<a href="https://www.sky-systems.net/shop/phone#live-demo"><strong>Live demo</strong></a>
|
||||
•
|
||||
<a href="https://github.com/sky-systems/sky_phone"><strong>Download for free</strong></a>
|
||||
<a href="https://github.com/sky-systems/sky_phone/releases/latest"><strong>Download for free</strong></a>
|
||||
•
|
||||
<a href="https://discord.gg/sky-systems"><strong>Discord support</strong></a>
|
||||
</p>
|
||||
@@ -44,7 +44,7 @@ Sky Phone is a **free and open-source FiveM phone script** built to give serious
|
||||
|
||||
This is not a cut-down free alternative. Sky Phone includes the core experience server owners and players expect from a leading paid FiveM phone, plus full source access, no purchase price, no feature paywalls, and no forced ecosystem lock-in.
|
||||
|
||||
The production frontend is included, so a normal server installation does not require Node.js or pnpm.
|
||||
The production frontend is included in the published release package, so a normal server installation does not require Node.js or pnpm. GitHub's automatically generated source archives do not contain that build.
|
||||
|
||||
## Why Sky Phone stands out
|
||||
|
||||
@@ -160,8 +160,8 @@ Start the selected voice resource before Sky Phone.
|
||||
|
||||
## Quick installation
|
||||
|
||||
1. Copy the resource into your FiveM resources directory.
|
||||
2. Keep the resource folder name `sky_phone`.
|
||||
1. Download and extract the latest published [Sky Phone release](https://github.com/sky-systems/sky_phone/releases/latest). Do not use GitHub's automatically generated "Source code" archives for a server installation because they do not contain the built frontend.
|
||||
2. Copy the included resource into your FiveM resources directory and keep its folder name `sky_phone`.
|
||||
3. Start `oxmysql`, your framework, inventory, and voice resource before Sky Phone.
|
||||
4. Review `sky_phone/config/config.lua` and `sky_phone/config/media.lua`.
|
||||
5. Add the required inventory items.
|
||||
@@ -182,6 +182,13 @@ Replace the example framework, inventory, and voice resources with the providers
|
||||
|
||||
Sky Phone creates and upgrades its database tables automatically. A manual SQL import is normally not required.
|
||||
|
||||
### How players open the phone
|
||||
|
||||
- Give the player the item configured in `Config.Phone.Item` (default: `phone`).
|
||||
- Players can use that inventory item or press the configured keybind (default: `F1`).
|
||||
- The keybind still verifies server-side that the player owns a configured phone item; it does not bypass inventory ownership.
|
||||
- A SIM card is **not required to open or use the phone itself**. With `Config.Sim.Enabled = true`, only cellular features such as calls and messages require an inserted SIM.
|
||||
|
||||
## Configuration
|
||||
|
||||
Customer settings are organized in:
|
||||
@@ -344,7 +351,7 @@ With unique phones, using an inventory item selects that exact handset whenever
|
||||
|
||||
| SIM mode | Behavior |
|
||||
| --- | --- |
|
||||
| `Enabled = true` | A registered or anonymous physical SIM item is required for cellular service. |
|
||||
| `Enabled = true` | The phone opens with or without a SIM. A registered or anonymous physical SIM item is required only for cellular service such as calls and messages. |
|
||||
| `Enabled = false` | Sky Phone creates a persistent automatic number for devices without a SIM. Physical SIM items are not required. |
|
||||
|
||||
When changing these modes on an existing production server, restart the resource and test with a copy of the database first. The first phone used after switching to non-unique mode may adopt an existing valid IMEI so its local data is preserved.
|
||||
@@ -602,12 +609,20 @@ pnpm build
|
||||
|
||||
### The phone item does nothing
|
||||
|
||||
- A warning that the inventory returned no configured phone item means an item definition, `Config.Phone.Item`, inventory selection, or player ownership problem. It is not caused by a missing SIM card.
|
||||
- Confirm the framework and inventory are supported and started first.
|
||||
- Confirm the item name matches `Config.Phone.Item`.
|
||||
- Confirm the item is usable.
|
||||
- In unique mode, confirm the phone is non-stackable.
|
||||
- Check the server console for inventory adapter warnings.
|
||||
|
||||
### The resource starts but the phone UI is missing
|
||||
|
||||
- On startup, the server console prints `SKY PHONE UI BUILD IS MISSING OR INCOMPLETE` and lists the missing or invalid packaged files.
|
||||
- Install the latest published release package rather than GitHub's automatically generated source archive.
|
||||
- Confirm `sky_phone/source/html/index.html`, `assets`, `img`, and `sounds` exist.
|
||||
- Developers working from source must run the frontend production build before starting the resource.
|
||||
|
||||
### Calls connect without audio
|
||||
|
||||
- Confirm the configured voice resource is running.
|
||||
|
||||
@@ -70,7 +70,7 @@ import type {
|
||||
CompanyChangedPayload,
|
||||
CompanyUnreadCounts,
|
||||
} from '@/types/companies'
|
||||
import type { PhoneCall } from '@/types/phone'
|
||||
import type { PhoneCall, PhoneNumberFormat } from '@/types/phone'
|
||||
import type { DynamicIslandActivity } from '@/types/dynamicIsland'
|
||||
import type { EasyShareEvent } from '@/types/easyshare'
|
||||
import type { CryptoMarketChangedData } from '@/types/crypto'
|
||||
@@ -84,6 +84,7 @@ import { formatTimer } from '@/utils/clock'
|
||||
import { parsePhonePreferences } from '@/utils/preferences'
|
||||
import { getHairlinePixelStyle } from '@/utils/rendering'
|
||||
import { isTextInputElement } from '@/utils/textInputFocus'
|
||||
import { configurePhoneNumberFormat } from '@/utils/phone'
|
||||
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
||||
import SpringboardView from '@/views/SpringboardView.vue'
|
||||
|
||||
@@ -133,6 +134,7 @@ type NavigationEventData = {
|
||||
type SimPickerPayload = {
|
||||
choices: SimPhoneChoice[]
|
||||
number: string
|
||||
phoneNumberFormat?: PhoneNumberFormat
|
||||
}
|
||||
|
||||
type NotificationEventData = Omit<PhoneNotificationInput, 'device'> & {
|
||||
@@ -491,6 +493,7 @@ function getViewportScale(): number {
|
||||
}
|
||||
|
||||
function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
if (payload.device?.imei) {
|
||||
companies.bindDeviceScope(
|
||||
payload.device.imei,
|
||||
@@ -1222,7 +1225,9 @@ function onMessage(event: MessageEvent<AppMessage>): void {
|
||||
loadUnlockedPhoneData()
|
||||
}
|
||||
} else if (event.data?.type === 'sim:picker' && event.data.data) {
|
||||
simPicker.value = event.data.data as unknown as SimPickerPayload
|
||||
const payload = event.data.data as unknown as SimPickerPayload
|
||||
configurePhoneNumberFormat(payload.phoneNumberFormat)
|
||||
simPicker.value = payload
|
||||
} else if (event.data?.type === 'sim:picker-close') {
|
||||
simPicker.value = null
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export type PhoneOpenPayload = {
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
phoneNumberFormat?: DeviceBootstrap['phoneNumberFormat']
|
||||
player?: DeviceBootstrap['player']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
@@ -638,7 +639,8 @@ const cryptoFallbackLocales = {
|
||||
invalid_handle: 'Use 3–20 letters, numbers, dots or underscores.',
|
||||
handle_taken: 'That VaultX handle is already taken.',
|
||||
profile_exists: 'This character already owns a VaultX profile.',
|
||||
invalid_password: 'Password must be 8–72 characters.',
|
||||
invalid_password:
|
||||
'Use 8–72 characters with uppercase, lowercase, a number, and a special character.',
|
||||
password_mismatch: 'The passwords do not match.',
|
||||
accept_terms: 'Confirm that this is a fictional in-game wallet.',
|
||||
invalid_credentials: 'The password is incorrect.',
|
||||
@@ -2086,7 +2088,7 @@ const defaultLocales: LocaleTree = {
|
||||
to: 'To:',
|
||||
connectPrivately: 'Connect privately',
|
||||
newChatBody:
|
||||
'Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.',
|
||||
"Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.",
|
||||
darkIdOrInvite: 'Dark-ID or invitation code',
|
||||
continue: 'Continue',
|
||||
contacts: 'DarkChat Contacts',
|
||||
@@ -3036,6 +3038,8 @@ const defaultLocales: LocaleTree = {
|
||||
viewRides: 'View Ride Options',
|
||||
change: 'Change',
|
||||
requestRide: 'Request SkyRide',
|
||||
playerDriverNotice:
|
||||
'SkyRide matches you with real player drivers. A driver must be online and accept your request.',
|
||||
serviceMeta: '{eta} min away · {seats} seats',
|
||||
distanceMeters: '{distance} m',
|
||||
distanceKilometers: '{distance} km',
|
||||
@@ -3106,7 +3110,8 @@ const defaultLocales: LocaleTree = {
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
statusBody: {
|
||||
searching: 'We are matching you with a nearby driver.',
|
||||
searching:
|
||||
'Your request is waiting for an available player driver to accept it.',
|
||||
accepted: 'Your driver is preparing to pick you up.',
|
||||
driver_arriving: 'Your driver is on the way to your pickup.',
|
||||
arrived: 'Your driver is waiting at the pickup point.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Note } from '@/utils/notes'
|
||||
import type { MemoDto } from '@/types/memos'
|
||||
import type { PhoneSim } from '@/types/phone'
|
||||
import type { PhoneNumberFormat, PhoneSim } from '@/types/phone'
|
||||
|
||||
export type DeviceDataEntry<T = unknown> = {
|
||||
payload: T
|
||||
@@ -50,6 +50,7 @@ export type DeviceBootstrap = {
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
phoneNumberFormat: PhoneNumberFormat
|
||||
player: PhonePlayerIdentity
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type SimType = 'registered' | 'anonymous'
|
||||
|
||||
export type PhoneNumberFormat = {
|
||||
groups: number[]
|
||||
length: number
|
||||
}
|
||||
|
||||
export type PhoneSim = {
|
||||
id: string
|
||||
number: string
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatPhoneNumber, normalizePhoneNumber } from './phone'
|
||||
import {
|
||||
configurePhoneNumberFormat,
|
||||
formatPhoneNumber,
|
||||
normalizePhoneNumber,
|
||||
} from './phone'
|
||||
|
||||
describe('phone numbers', () => {
|
||||
it('normalizes formatted ten digit values', () => {
|
||||
@@ -13,4 +17,13 @@ describe('phone numbers', () => {
|
||||
expect(formatPhoneNumber('5551')).toBe('555 1')
|
||||
expect(formatPhoneNumber(5551234567)).toBe('555 123 4567')
|
||||
})
|
||||
|
||||
it('uses the server-provided number length and display groups', () => {
|
||||
configurePhoneNumberFormat({ groups: [4, 3, 3], length: 10 })
|
||||
|
||||
expect(formatPhoneNumber('0171234567')).toBe('0171 234 567')
|
||||
expect(normalizePhoneNumber('0171 234 567')).toBe('0171234567')
|
||||
|
||||
configurePhoneNumberFormat()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,53 @@
|
||||
import type { PhoneNumberFormat } from '@/types/phone'
|
||||
|
||||
export const PHONE_NUMBER_LENGTH = 10
|
||||
const DEFAULT_PHONE_NUMBER_FORMAT: PhoneNumberFormat = {
|
||||
groups: [3, 3, 4],
|
||||
length: PHONE_NUMBER_LENGTH,
|
||||
}
|
||||
let phoneNumberFormat: PhoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||
|
||||
export function configurePhoneNumberFormat(value?: PhoneNumberFormat): void {
|
||||
const length = value?.length
|
||||
const groups = value?.groups
|
||||
if (
|
||||
typeof length !== 'number' ||
|
||||
!Number.isInteger(length) ||
|
||||
length < 1 ||
|
||||
length > 24 ||
|
||||
!Array.isArray(groups) ||
|
||||
groups.length === 0 ||
|
||||
groups.some((group) => !Number.isInteger(group) || group < 1)
|
||||
) {
|
||||
phoneNumberFormat = DEFAULT_PHONE_NUMBER_FORMAT
|
||||
return
|
||||
}
|
||||
|
||||
phoneNumberFormat = {
|
||||
groups: [...groups],
|
||||
length,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(value: string): string | null {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
return digits.length === PHONE_NUMBER_LENGTH ? digits : null
|
||||
return digits.length === phoneNumberFormat.length ? digits : null
|
||||
}
|
||||
|
||||
export function formatPhoneNumber(value: string | number): string {
|
||||
const digits = String(value).replace(/\D/g, '').slice(0, PHONE_NUMBER_LENGTH)
|
||||
const groups = [digits.slice(0, 3), digits.slice(3, 6), digits.slice(6, 10)]
|
||||
return groups.filter(Boolean).join(' ')
|
||||
const digits = String(value)
|
||||
.replace(/\D/g, '')
|
||||
.slice(0, phoneNumberFormat.length)
|
||||
const formatted: string[] = []
|
||||
let offset = 0
|
||||
|
||||
for (const size of phoneNumberFormat.groups) {
|
||||
const group = digits.slice(offset, offset + size)
|
||||
if (!group) break
|
||||
formatted.push(group)
|
||||
offset += size
|
||||
}
|
||||
if (offset < digits.length) formatted.push(digits.slice(offset))
|
||||
|
||||
return formatted.join(' ')
|
||||
}
|
||||
|
||||
@@ -119,6 +119,8 @@ describe('VaultX crypto app contracts', () => {
|
||||
expect(source).not.toContain('class="auth-panel__heading"')
|
||||
expect(source).not.toContain('class="auth-action-dock"')
|
||||
expect(source).not.toContain('class="password-rules"')
|
||||
expect(source).toContain('class="auth-password-hint"')
|
||||
expect(source).toContain("t('auth.ruleSpecial')")
|
||||
expect(source).toMatch(
|
||||
/\.auth-shell\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*338px;/s,
|
||||
)
|
||||
|
||||
@@ -824,6 +824,10 @@ onUnmounted(() => {
|
||||
:size="18"
|
||||
/></button></template
|
||||
></SkyField>
|
||||
<p v-if="authMode === 'register'" class="auth-password-hint">
|
||||
{{ t('auth.ruleLength') }} · {{ t('auth.ruleMixed') }} ·
|
||||
{{ t('auth.ruleNumber') }} · {{ t('auth.ruleSpecial') }}
|
||||
</p>
|
||||
<p v-if="formError" class="error">{{ formError }}</p>
|
||||
<SkyButton block large class="auth-submit" type="submit">{{
|
||||
t(
|
||||
@@ -2060,6 +2064,12 @@ onUnmounted(() => {
|
||||
.auth-form {
|
||||
gap: 12px;
|
||||
}
|
||||
.auth-password-hint {
|
||||
margin: -3px 4px 0;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
.auth-account {
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr) 18px;
|
||||
|
||||
@@ -37,10 +37,17 @@ describe('DarkChatApp Sky UI contract', () => {
|
||||
expect(newChat).toContain('class="dc-recipient-field"')
|
||||
expect(newChat).toContain('layout="inline"')
|
||||
expect(newChat).toContain('class="dc-new-chat-contacts"')
|
||||
expect(newChat).toContain("{{ t('newChatBody') }}")
|
||||
expect(newChat).toContain("{{ phone.t('Common.cancel') }}")
|
||||
expect(newChat).not.toContain('class="dc-hero"')
|
||||
})
|
||||
|
||||
it('rejects the current profile identifiers before opening confirmation', () => {
|
||||
expect(source).toContain('darkchat.profile?.darkId')
|
||||
expect(source).toContain('darkchat.profile?.inviteCode')
|
||||
expect(source).toContain("showToast(errorText('self_chat'))")
|
||||
})
|
||||
|
||||
it('keeps the search visually compact without shrinking its wrapper', () => {
|
||||
expect(source).toMatch(
|
||||
/\.dc-search :deep\(\.sky-searchbar__control\),[\s\S]*?height:\s*38px;[\s\S]*?min-height:\s*38px;/,
|
||||
|
||||
@@ -427,6 +427,20 @@ function closeReport(): void {
|
||||
function requestStart(value = identifier.value): void {
|
||||
const clean = value.trim()
|
||||
if (!clean) return
|
||||
const ownIdentifiers = [
|
||||
darkchat.profile?.darkId,
|
||||
darkchat.profile?.inviteCode,
|
||||
]
|
||||
if (
|
||||
ownIdentifiers.some(
|
||||
(ownIdentifier) =>
|
||||
ownIdentifier?.toLocaleLowerCase(phone.lang) ===
|
||||
clean.toLocaleLowerCase(phone.lang),
|
||||
)
|
||||
) {
|
||||
showToast(errorText('self_chat'))
|
||||
return
|
||||
}
|
||||
pendingIdentifier.value = clean
|
||||
safetyOpen.value = true
|
||||
}
|
||||
@@ -1262,6 +1276,13 @@ onBeforeUnmount(() => {
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.dc-new-chat-intro {
|
||||
margin: 2px 2px 12px;
|
||||
color: var(--dc-muted);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.dc-recipient-field,
|
||||
.dc-new-chat-contacts {
|
||||
margin: 0 calc(var(--sky-page-gutter) * -1) 12px;
|
||||
@@ -2071,6 +2092,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</SkyNavbar>
|
||||
<SkyScrollArea padded class="dc-new-chat-scroll">
|
||||
<p class="dc-new-chat-intro">{{ t('newChatBody') }}</p>
|
||||
<SkyList class="dc-recipient-field" density="compact" flush>
|
||||
<SkyField
|
||||
:label="t('to')"
|
||||
|
||||
@@ -18,6 +18,11 @@ describe('SkyRide app layout', () => {
|
||||
expect(source).toContain('skyride-history-card__distance')
|
||||
})
|
||||
|
||||
it('explains that requests require an online player driver', () => {
|
||||
expect(source).toContain("phone.t('Apps.skyride.playerDriverNotice')")
|
||||
expect(source).toContain('class="skyride-player-driver-notice"')
|
||||
})
|
||||
|
||||
it('aligns rider and activity surfaces to one content width', () => {
|
||||
expect(source).toMatch(
|
||||
/\.skyride-location-list,\s*\.skyride-activity-list\s*\{[^}]*margin-inline:\s*2px !important;/s,
|
||||
|
||||
@@ -848,6 +848,9 @@ onBeforeUnmount(() => {
|
||||
</k-button>
|
||||
</div>
|
||||
</k-card>
|
||||
<p class="skyride-player-driver-notice">
|
||||
{{ phone.t('Apps.skyride.playerDriverNotice') }}
|
||||
</p>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
@@ -2109,6 +2112,13 @@ onBeforeUnmount(() => {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.skyride-player-driver-notice {
|
||||
margin: 2px 7px 8px;
|
||||
color: var(--ride-muted);
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.skyride-primary :deep(svg) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ Config.Phone = {
|
||||
Unique = true, -- true: data follows each phone item; false: one persistent phone per character; hex/esx require false
|
||||
Keybind = "F1", -- false disables the configurable phone key mapping
|
||||
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
|
||||
Control = 19, -- INPUT_CHARACTER_WHEEL (Left Alt by default)
|
||||
},
|
||||
DevelopmentCommand = true,
|
||||
DeviceName = "iFruit Phone",
|
||||
}
|
||||
@@ -65,9 +69,9 @@ Config.Sim = {
|
||||
Enabled = true, -- false: devices receive a persistent random number automatically; hex/esx require false
|
||||
RegisteredItem = "sky_phone_sim_registered",
|
||||
AnonymousItem = "sky_phone_sim_anonymous",
|
||||
NumberLength = 10,
|
||||
NumberPrefix = "",
|
||||
NumberGroups = { 3, 3, 4 },
|
||||
NumberLength = 10, -- total number of digits, including NumberPrefix
|
||||
NumberPrefix = "", -- digits only; use "555", not "555-"
|
||||
NumberGroups = { 3, 3, 4 }, -- display groups separated by spaces
|
||||
}
|
||||
|
||||
-- =============================================================================
|
||||
|
||||
@@ -606,7 +606,7 @@ Locales["de"] = {
|
||||
createIdentity = "Dunkle Identität erstellen", createIdentityBody = "Erstell eine private Identität, bevor du ein DarkChat-Gespräch anfängst.",
|
||||
security = "Sicherheit", privateNetwork = "Privates Einladungs-Nur-Netzwerk", noResults = "Keine Ergebnisse", noResultsBody = "Versucht es mit einem anderen Alias oder Dark-ID.",
|
||||
noChats = "Keine privaten Chats", noChatsBody = "Verbinde dich über eine Dark-ID oder einen Einladungscode. Es gibt keine öffentliche Suche.", newChat = "Neuer Chat", to = "An:",
|
||||
connectPrivately = "Privat verbinden", newChatBody = "Gib einen genauen Dark-ID oder Einladungscode ein. Unbekannte Identitäten erfordern eine Bestätigung.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
|
||||
connectPrivately = "Privat verbinden", newChatBody = "Gib die Dark-ID oder den Einladungscode einer anderen Person ein. Deine eigene ID kannst du im Profil teilen.", darkIdOrInvite = "Dark-ID oder Einladungscode", continue = "Weiter", contacts = "DarkChat Kontakte", contactsHint = "Gespeicherte private Identitäten erscheinen nur auf deinem DarkChat-Konto.", noContacts = "Keine gespeicherten DarkChat Kontakte", private = "Privat", shareIdentity = "Tippe auf deine private Identität",
|
||||
message = "Dunkle Botschaft", activeNow = "Anteil der Tätigkeiten", encryptedSession = "Privatsitzung", serverPrivate = "Private server-gespeicherte Konversation",
|
||||
emoji = "Emoji", gif = "GIF", gifs = "GIF", photo = "Foto", video = "Video", attachPhoto = "Foto anhängen", takePhoto = "Foto machen", attachGif = "GIF-Anlage", attachVideo = "Video anhängen", searchGifs = "GIF durchsuchen", loadMore = "Mehr laden",
|
||||
voiceMessage = "Sprachnachricht", sending = "Senden", failed = "Nicht geliefert", delivered = "Lieferung", read = "Lesen", replying = "Antwort auf",
|
||||
@@ -622,7 +622,7 @@ Locales["de"] = {
|
||||
microphoneUnavailable = "Das Mikrofon ist nicht verfügbar.", recordingTooLarge = "Die Sprachbotschaft ist zu groß.",
|
||||
errors = {
|
||||
sign_out_failed = "Sky Cloud konnte sich nicht ausmelden.",
|
||||
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib einen gültigen Dark-ID oder Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deine Identität nicht selbst sagen.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Diese GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
|
||||
not_authenticated = "Melde dich zuerst bei deinem Sky Cloud-Konto an.", invalid_dark_id = "Gib eine gültige Dark-ID oder einen Einladungscode ein.", profile_not_found = "Diese private Identität wurde nicht gefunden.", self_chat = "Du kannst deiner eigenen Identität keine Nachricht senden.", conversation_not_found = "Diese Unterhaltung ist nicht verfügbar.", blocked = "Nachrichten werden in diesem Gespräch blockiert.", invalid_message = "Gib eine gültige Nachricht ein.", invalid_gif = "Dieses GIF ist ungültig.", invalid_voice = "Diese Sprachnachricht ist ungültig.", invalid_attachment = "Dieses Foto oder Video ist nicht verfügbar.", invalid_profile = "Prüfe deinen Alias und deine Privatsphäre.", rate_limited = "Zu viele Anfragen. Versuch es gleich erneut.", gif_provider_unconfigured = "GIF-Suche ist nicht konfiguriert.", gif_provider_unauthorized = "Der GIF-Anbieterschlüssel ist ungültig.", gif_provider_rate_limited = "Die GIF-Suche ist ausgelastet. Versuch es gleich erneut.", gif_provider_failed = "GIFs sind vorübergehend nicht verfügbar.", default = "DarkChat konnte die Anfrage nicht abschließen.",
|
||||
},
|
||||
},
|
||||
messages = {
|
||||
@@ -1069,7 +1069,7 @@ Locales["de"] = {
|
||||
profile = { verified = "Charakterbesitz verifiziert", walletKey = "Öffentlicher Crypto-Key", walletKeyBody = "Teile diesen Key, um Krypto zu empfangen. Er gewährt keinen Kontozugriff.", copyKey = "Kopieren", copied = "Kopiert", shareKey = "Teilen", shareTitle = "Mein VaultX Crypto-Key", shareText = "Sende Krypto an meinen öffentlichen VaultX-Key:", trades = "Trades", volume = "Volumen", memberSince = "Mitglied seit", preferences = "Einstellungen", priceAlerts = "Kurswarnungen", priceAlertsBody = "Bei auffälligen Marktbewegungen benachrichtigen.", confirmations = "Handelsbestätigung", confirmationsBody = "Zusätzliche Bestätigung vor der Ausführung behalten.", hideBalances = "Privatsphäre-Modus", hideBalancesBody = "Beträge in VaultX verbergen.", identity = "Profilidentität", edit = "Bearbeiten", account = "Konto & Sicherheit", editTitle = "Profil bearbeiten", editBody = "Ändere deinen Benutzernamen oder lege ein neues Passwort fest. Dein aktuelles Passwort bestätigt Identitätsänderungen.", currentPassword = "Aktuelles Passwort", currentPasswordPlaceholder = "Für Namens- oder Passwortänderungen erforderlich", newPassword = "Neues Passwort", newPasswordPlaceholder = "Leer lassen, um das aktuelle Passwort zu behalten", passwordSecurity = "Passwörter werden speicherintensiv gehasht und nie wieder angezeigt.", saved = "Profil sicher gespeichert.", save = "Profil speichern", saveChanges = "Änderungen speichern", securityTitle = "Geschütztes Profil", securityBody = "Dein Profil bleibt an diesen Framework-Charakter und die Finanzsitzung gebunden." },
|
||||
activityTypes = { buy = "Asset-Kauf", sell = "Asset-Verkauf", deposit = "Bankeinzahlung", withdrawal = "Bankauszahlung", transfer_in = "Krypto empfangen", transfer_out = "Krypto gesendet" },
|
||||
statuses = { completed = "Abgeschlossen", pending = "Prüfung ausstehend", failed = "Fehlgeschlagen", manual_review = "Manuelle Prüfung" },
|
||||
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Das Passwort muss 8–72 Zeichen lang sein.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
||||
errors = { invalid_profile = "Prüfe deine VaultX-Profileinstellungen.", invalid_handle = "Nutze 3–20 Buchstaben, Zahlen, Punkte oder Unterstriche.", handle_taken = "Dieser VaultX-Benutzername ist bereits vergeben.", profile_exists = "Dieser Charakter besitzt bereits ein VaultX-Profil.", invalid_password = "Nutze 8–72 Zeichen mit Groß- und Kleinbuchstaben, einer Zahl und einem Sonderzeichen.", password_mismatch = "Die Passwörter stimmen nicht überein.", accept_terms = "Bestätige, dass dies ein fiktives Ingame-Wallet ist.", invalid_credentials = "Das Passwort ist falsch.", locked = "Zu viele Versuche. Versuche es später erneut.", not_authenticated = "Logge dich zuerst bei VaultX ein.", invalid_amount = "Gib einen positiven ganzen Betrag ein.", invalid_quantity = "Gib eine Menge mit bis zu sechs Nachkommastellen ein.", invalid_wallet_key = "Gib einen vollständigen VX-Empfangsschlüssel ein.", recipient_not_found = "Zu diesem Key wurde kein aktives VaultX-Konto gefunden.", self_transfer = "Du kannst keine Krypto an deinen eigenen Key senden.", invalid_transfer = "Prüfe Empfänger, Asset und Krypto-Menge.", recipient_limit_exceeded = "Das Empfänger-Wallet kann diese Menge nicht aufnehmen.", insufficient_funds = "Dein verfügbarer Bestand reicht nicht aus.", insufficient_liquidity = "Die Börse kann diese Menge derzeit nicht ausführen.", quote_expired = "Das Angebot ist abgelaufen. Fordere ein neues an.", quote_unavailable = "Das Angebot ist nicht mehr verfügbar.", market_unavailable = "Dieser Markt ist vorübergehend pausiert.", limit_exceeded = "Die Anfrage überschreitet ein Börsenlimit.", duplicate_request = "Diese Anfrage wurde bereits verarbeitet.", rate_limited = "Zu viele Anfragen. Versuche es gleich erneut.", settlement_pending = "Eine Geldbewegung wartet bereits auf Prüfung.", service_unavailable = "VaultX ist vorübergehend nicht verfügbar.", request_failed = "Die sichere Börsenanfrage ist fehlgeschlagen.", default = "VaultX konnte die Anfrage nicht abschließen." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Willkommen zurück", totalBalance = "Gesamtsaldo", recentPeriod = "in jüngster Zeit",
|
||||
@@ -1207,7 +1207,7 @@ Locales["de"] = {
|
||||
zoomIn = "Vergrößern", zoomOut = "Auszoomen", resetMap = "Kartenansicht zurücksetzen",
|
||||
savedPlaces = "Gespeicherte Plätze", savedPlace = "Gespeicherter Ort", quickDestinations = "Schnelle Reiseziele",
|
||||
quickLocations = { ["legion-square"] = "Legionsplatz", ["diamond-casino"] = "Diamant Casino", airport = "Flughafen Los Santos", vinewood = "Weinholz" },
|
||||
viewRides = "Ansicht Einstellungen für die Fahrt", change = "Veränderung", requestRide = "Anfrage SkyRide",
|
||||
viewRides = "Fahrtoptionen anzeigen", change = "Ändern", requestRide = "SkyRide anfragen", playerDriverNotice = "SkyRide vermittelt an echte Spieler-Fahrer. Ein Fahrer muss online sein und deine Anfrage annehmen.",
|
||||
serviceMeta = "{eta} min entfernt · {seats} Sitze", distanceMeters = "{distance} m",
|
||||
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
|
||||
fare = "Fahrpreis", calculatedFare = "Berechnet", customFare = "Eigener Preis",
|
||||
@@ -1241,7 +1241,7 @@ Locales["de"] = {
|
||||
arrived = "Fahrer angekommen", in_progress = "Auf dem Weg", completed = "Abgeschlossen", cancelled = "Abgebrochen",
|
||||
},
|
||||
statusBody = {
|
||||
searching = "Wir passen zu dir mit einem Fahrer in der Nähe.", accepted = "Dein Fahrer will dich abholen.",
|
||||
searching = "Deine Anfrage wartet darauf, dass ein verfügbarer Spieler-Fahrer sie annimmt.", accepted = "Dein Fahrer bereitet die Abholung vor.",
|
||||
driver_arriving = "Dein Fahrer ist auf dem Weg zu deinem Pickup.", arrived = "Dein Fahrer wartet am Abholpunkt.",
|
||||
in_progress = "Du bist auf dem Weg zu deinem Ziel.", completed = "Du bist am Ziel angekommen.",
|
||||
cancelled = "Diese Fahrt ist nicht mehr aktiv.",
|
||||
|
||||
@@ -606,7 +606,7 @@ Locales["en"] = {
|
||||
createIdentity = "Create Dark Identity", createIdentityBody = "Create a private identity before starting a DarkChat conversation.",
|
||||
security = "Security", privateNetwork = "Private invitation-only network", noResults = "No Results", noResultsBody = "Try another alias or Dark-ID.",
|
||||
noChats = "No Private Chats", noChatsBody = "Connect with a Dark-ID or invitation code. There is no public search.", newChat = "New Chat", to = "To:",
|
||||
connectPrivately = "Connect privately", newChatBody = "Enter an exact Dark-ID or invitation code. Unknown identities require confirmation.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
|
||||
connectPrivately = "Connect privately", newChatBody = "Enter another person's Dark-ID or invitation code. You can share your own ID from your profile.", darkIdOrInvite = "Dark-ID or invitation code", continue = "Continue", contacts = "DarkChat Contacts", contactsHint = "Saved private identities appear only on your DarkChat account.", noContacts = "No saved DarkChat contacts", private = "Private", shareIdentity = "Tap to share your private identity",
|
||||
message = "Dark message", activeNow = "Activity shared", encryptedSession = "Private session", serverPrivate = "Private server-stored conversation",
|
||||
emoji = "Emoji", gif = "GIF", gifs = "GIFs", photo = "Photo", video = "Video", attachPhoto = "Attach Photo", takePhoto = "Take Photo", attachGif = "Attach GIF", attachVideo = "Attach Video", searchGifs = "Search GIFs", loadMore = "Load More",
|
||||
voiceMessage = "Voice message", sending = "Sending", failed = "Not delivered", delivered = "Delivered", read = "Read", replying = "Replying to",
|
||||
@@ -1069,7 +1069,7 @@ Locales["en"] = {
|
||||
profile = { verified = "Character ownership verified", walletKey = "Public crypto key", walletKeyBody = "Share this key to receive crypto. It cannot access your account.", copyKey = "Copy", copied = "Copied", shareKey = "Share", shareTitle = "My VaultX crypto key", shareText = "Send crypto to my public VaultX key:", trades = "Trades", volume = "Volume", memberSince = "Member since", preferences = "Preferences", priceAlerts = "Price alerts", priceAlertsBody = "Notify me about notable market moves.", confirmations = "Trade confirmations", confirmationsBody = "Keep an extra confirmation before execution.", hideBalances = "Privacy mode", hideBalancesBody = "Hide balances across VaultX.", identity = "Profile identity", edit = "Edit", account = "Account & security", editTitle = "Edit profile", editBody = "Update your handle or set a new password. Your current password confirms identity changes.", currentPassword = "Current password", currentPasswordPlaceholder = "Required for handle or password changes", newPassword = "New password", newPasswordPlaceholder = "Leave blank to keep your current password", passwordSecurity = "Passwords use memory-hard hashing and are never shown again.", saved = "Profile saved securely.", save = "Save profile", saveChanges = "Save changes", securityTitle = "Protected profile", securityBody = "Your profile stays bound to this framework character and financial session." },
|
||||
activityTypes = { buy = "Asset purchase", sell = "Asset sale", deposit = "Bank deposit", withdrawal = "Bank withdrawal", transfer_in = "Crypto received", transfer_out = "Crypto sent" },
|
||||
statuses = { completed = "Completed", pending = "Pending review", failed = "Failed", manual_review = "Manual review" },
|
||||
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 3–20 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Password must be 8–72 characters.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
|
||||
errors = { invalid_profile = "Check your VaultX profile settings.", invalid_handle = "Use 3–20 letters, numbers, dots or underscores.", handle_taken = "That VaultX handle is already taken.", profile_exists = "This character already owns a VaultX profile.", invalid_password = "Use 8–72 characters with uppercase, lowercase, a number, and a special character.", password_mismatch = "The passwords do not match.", accept_terms = "Confirm that this is a fictional in-game wallet.", invalid_credentials = "The password is incorrect.", locked = "Too many attempts. Try again later.", not_authenticated = "Sign in to VaultX first.", invalid_amount = "Enter a positive whole amount.", invalid_quantity = "Enter a quantity with up to six decimals.", invalid_wallet_key = "Enter a complete VX receiving key.", recipient_not_found = "No active VaultX account uses this key.", self_transfer = "You cannot send crypto to your own key.", invalid_transfer = "Verify a recipient, asset and valid crypto amount.", recipient_limit_exceeded = "The recipient wallet cannot hold that amount.", insufficient_funds = "Your available balance is too low.", insufficient_liquidity = "The exchange cannot fill that amount right now.", quote_expired = "This quote expired. Request a new one.", quote_unavailable = "The quote is no longer available.", market_unavailable = "This market is temporarily paused.", limit_exceeded = "This request exceeds an exchange limit.", duplicate_request = "This request was already processed.", rate_limited = "Too many requests. Try again shortly.", settlement_pending = "A money transfer is already pending review.", service_unavailable = "VaultX is temporarily unavailable.", request_failed = "The secure exchange request failed.", default = "VaultX could not complete the request." },
|
||||
},
|
||||
banking = {
|
||||
name = "Banking", welcome = "Welcome back", totalBalance = "Total Balance", recentPeriod = "in recent activity",
|
||||
@@ -1207,7 +1207,7 @@ Locales["en"] = {
|
||||
zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view",
|
||||
savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations",
|
||||
quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" },
|
||||
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide",
|
||||
viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide", playerDriverNotice = "SkyRide matches you with real player drivers. A driver must be online and accept your request.",
|
||||
serviceMeta = "{eta} min away · {seats} seats", distanceMeters = "{distance} m",
|
||||
distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min",
|
||||
fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price",
|
||||
@@ -1241,7 +1241,7 @@ Locales["en"] = {
|
||||
arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled",
|
||||
},
|
||||
statusBody = {
|
||||
searching = "We are matching you with a nearby driver.", accepted = "Your driver is preparing to pick you up.",
|
||||
searching = "Your request is waiting for an available player driver to accept it.", accepted = "Your driver is preparing to pick you up.",
|
||||
driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.",
|
||||
in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.",
|
||||
cancelled = "This ride is no longer active.",
|
||||
|
||||
@@ -76,6 +76,7 @@ server_scripts {
|
||||
'config/media.lua',
|
||||
'config/locales/en.lua',
|
||||
'config/locales/de.lua',
|
||||
'source/server/nui_build_check.lua',
|
||||
'source/server/update_check.lua',
|
||||
'source/bridge/server/database.lua',
|
||||
'source/bridge/server/migrations.lua',
|
||||
@@ -86,6 +87,7 @@ server_scripts {
|
||||
'source/bridge/server/housing/*.lua',
|
||||
'source/bridge/server/inventory.lua',
|
||||
'source/bridge/server/inventory/*.lua',
|
||||
'source/bridge/server/inventory_contract.lua',
|
||||
'source/bridge/server/voice.lua',
|
||||
'source/server/custom_apps.lua',
|
||||
'source/server/media_metadata.lua',
|
||||
@@ -150,6 +152,7 @@ files {
|
||||
'source/html/index.html',
|
||||
'source/html/assets/**',
|
||||
'source/html/img/**',
|
||||
'source/html/sounds/**',
|
||||
'config/music/**',
|
||||
}
|
||||
|
||||
|
||||
@@ -41,20 +41,18 @@ if not supported_inventories[configured_inventory] then
|
||||
error(("[sky_phone] Unsupported or unavailable inventory '%s'. Configure a supported inventory adapter."):format(tostring(configured_inventory)))
|
||||
end
|
||||
|
||||
Bridge.Inventory.Name = configured_inventory
|
||||
|
||||
if configured_inventory == "hex" or configured_inventory == "esx" then
|
||||
if Bridge.Framework.GetName() ~= "esx" then
|
||||
error(("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory))
|
||||
end
|
||||
if Config.Phone.Unique ~= false then
|
||||
error(("[sky_phone] Inventory '%s' cannot store unique phone metadata. Set Config.Phone.Unique = false or configure a metadata-capable inventory."):format(configured_inventory))
|
||||
end
|
||||
if Config.Sim.Enabled ~= false then
|
||||
error(("[sky_phone] Inventory '%s' cannot store physical SIM metadata. Set Config.Sim.Enabled = false or configure a metadata-capable inventory."):format(configured_inventory))
|
||||
Bridge.Inventory.ConfigurationError =
|
||||
("[sky_phone] Inventory '%s' is only supported with ESX."):format(configured_inventory)
|
||||
elseif Config.Phone.Unique ~= false or Config.Sim.Enabled ~= false then
|
||||
Bridge.Inventory.ConfigurationError = ("[sky_phone] Inventory '%s' cannot store unique phone or physical SIM metadata. Set Config.Phone.Unique = false and Config.Sim.Enabled = false, or configure a metadata-capable inventory.")
|
||||
:format(configured_inventory)
|
||||
end
|
||||
end
|
||||
|
||||
Bridge.Inventory.Name = configured_inventory
|
||||
|
||||
function Bridge.Inventory.NormalizeItem(item, metadata_field)
|
||||
if not item then
|
||||
return nil
|
||||
|
||||
@@ -38,13 +38,12 @@ function Bridge.Inventory.GetSlotsWithItem(source, item_name, metadata)
|
||||
end
|
||||
|
||||
function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
local slot = Bridge.Inventory.GetSlot(source, slot_id)
|
||||
if not slot then
|
||||
local numeric_slot = tonumber(slot_id)
|
||||
if not numeric_slot then
|
||||
return false
|
||||
end
|
||||
inventory:updateMetadata(source, slot.slot, metadata or {})
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated and Bridge.Inventory.MetadataMatches(updated.metadata, metadata or {}) or false
|
||||
inventory:setMetadata(source, numeric_slot, metadata or {})
|
||||
return true
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem()
|
||||
|
||||
@@ -15,12 +15,16 @@ local function normalize(item)
|
||||
return nil
|
||||
end
|
||||
|
||||
local metadata = {}
|
||||
for key, value in pairs(type(item.info) == "table" and item.info or {}) do
|
||||
metadata[key] = value
|
||||
end
|
||||
return {
|
||||
name = item.name,
|
||||
slot = tonumber(item.slot),
|
||||
count = tonumber(item.amount) or 0,
|
||||
amount = tonumber(item.amount) or 0,
|
||||
metadata = type(item.info) == "table" and item.info or {},
|
||||
metadata = metadata,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -77,11 +81,33 @@ function Bridge.Inventory.SetSlotMetadata(source, slot_id, metadata)
|
||||
if not player then
|
||||
return false
|
||||
end
|
||||
player.PlayerData.items[slot.slot].info = metadata or {}
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
player.PlayerData.items[slot.slot].info = requested_metadata
|
||||
player.Functions.SetInventory(player.PlayerData.items, true)
|
||||
return true
|
||||
local updated = player.PlayerData.items[slot.slot]
|
||||
return updated ~= nil and Bridge.Inventory.MetadataMatches(updated.info, requested_metadata)
|
||||
end
|
||||
return inventory:SetItemData(source, slot.name, "info", metadata or {}, slot.slot) == true
|
||||
|
||||
local amount = tonumber(slot.amount or slot.count) or 0
|
||||
if amount <= 0 then
|
||||
return false
|
||||
end
|
||||
|
||||
local requested_metadata = type(metadata) == "table" and metadata or {}
|
||||
if inventory:RemoveItem(source, slot.name, amount, slot.slot, "sky_phone:metadata-update") ~= true then
|
||||
return false
|
||||
end
|
||||
|
||||
if inventory:AddItem(source, slot.name, amount, slot.slot, requested_metadata, "sky_phone:metadata-update") ~= true then
|
||||
return false
|
||||
end
|
||||
|
||||
local updated = Bridge.Inventory.GetSlot(source, slot.slot)
|
||||
return updated
|
||||
and updated.name == slot.name
|
||||
and updated.amount == amount
|
||||
and Bridge.Inventory.MetadataMatches(updated.metadata, requested_metadata)
|
||||
or false
|
||||
end
|
||||
|
||||
function Bridge.Inventory.CanCarryItem(source, item_name, count)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
local required_methods = {
|
||||
"GetResourceName",
|
||||
"GetSlot",
|
||||
"GetSlotsWithItem",
|
||||
"SetSlotMetadata",
|
||||
"CanCarryItem",
|
||||
"AddItem",
|
||||
"RemoveItem",
|
||||
"RegisterUsableItem",
|
||||
}
|
||||
|
||||
local configuration_error = Bridge.Inventory.ConfigurationError
|
||||
|
||||
for _, method_name in ipairs(required_methods) do
|
||||
if configuration_error then
|
||||
Bridge.Inventory[method_name] = function()
|
||||
error(configuration_error, 2)
|
||||
end
|
||||
elseif type(Bridge.Inventory[method_name]) ~= "function" then
|
||||
local contract_error = ("[sky_phone] Inventory adapter '%s' is missing required method '%s'.")
|
||||
:format(tostring(Bridge.Inventory.Name), method_name)
|
||||
Bridge.Inventory[method_name] = function()
|
||||
error(contract_error, 2)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,16 @@ SkyPhoneFocus = {}
|
||||
local blocked_phone_controls = { 24, 140, 141, 142, 257, 263, 264 }
|
||||
local blocked_phone_look_controls = { 1, 2, 3, 4, 5, 6 }
|
||||
local focused_control_groups = { 0, 1, 2 }
|
||||
local hold_to_look_config = Config.Phone.HoldToLook
|
||||
local hold_to_look_enabled = type(hold_to_look_config) == "table" and hold_to_look_config.Enabled == true
|
||||
local hold_to_look_control = hold_to_look_enabled and tonumber(hold_to_look_config.Control) or nil
|
||||
if hold_to_look_enabled and (
|
||||
not hold_to_look_control
|
||||
or hold_to_look_control ~= math.floor(hold_to_look_control)
|
||||
or hold_to_look_control < 0
|
||||
) then
|
||||
error("[sky_phone] Config.Phone.HoldToLook.Control must be a non-negative whole control index.")
|
||||
end
|
||||
local state = {
|
||||
activity_suspended = false,
|
||||
allow_movement = Config.Phone.AllowMovement,
|
||||
@@ -13,6 +23,7 @@ local state = {
|
||||
external_game_input = nil,
|
||||
external_game_input_owner = nil,
|
||||
is_open = false,
|
||||
look_passthrough = false,
|
||||
notification_focus = false,
|
||||
payphone_focus = false,
|
||||
sim_picker_open = false,
|
||||
@@ -22,6 +33,14 @@ local block_game = false
|
||||
local block_look = false
|
||||
local game_input = false
|
||||
|
||||
local function allows_game_input(value)
|
||||
local allowed = value.external_game_input
|
||||
if allowed == nil then
|
||||
allowed = value.allow_movement
|
||||
end
|
||||
return allowed == true
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.ApplyFocusedControls()
|
||||
for _, group in ipairs(focused_control_groups) do
|
||||
DisableAllControlActions(group)
|
||||
@@ -51,20 +70,19 @@ function SkyPhoneFocus.Resolve(state)
|
||||
if state.camera_active and not state.camera_nui_focused then
|
||||
return { block_game = false, block_look = false, cursor = false, focused = true, game_input = true, keep_input = true }
|
||||
end
|
||||
local allow_game_input = state.external_game_input
|
||||
if allow_game_input == nil then
|
||||
allow_game_input = state.allow_movement
|
||||
end
|
||||
local game_input = state.is_open and allow_game_input and not state.camera_active
|
||||
local game_input = state.is_open
|
||||
and allows_game_input(state)
|
||||
and not state.camera_active
|
||||
and not state.text_input_focused
|
||||
local focused = state.is_open
|
||||
or state.notification_focus
|
||||
or state.payphone_focus
|
||||
or state.sim_picker_open
|
||||
or (state.camera_active and state.camera_nui_focused)
|
||||
local cursor = focused and not (state.is_open and state.cursor_disabled)
|
||||
local cursor = focused and not (state.is_open and (state.cursor_disabled or state.look_passthrough))
|
||||
return {
|
||||
block_game = cursor and (not game_input or state.text_input_focused),
|
||||
block_look = game_input and not state.cursor_disabled,
|
||||
block_game = cursor and not game_input,
|
||||
block_look = game_input and cursor,
|
||||
cursor = cursor,
|
||||
focused = focused,
|
||||
game_input = game_input,
|
||||
@@ -73,6 +91,16 @@ function SkyPhoneFocus.Resolve(state)
|
||||
end
|
||||
|
||||
function SkyPhoneFocus.Reapply()
|
||||
if state.look_passthrough and (
|
||||
not hold_to_look_enabled
|
||||
or not state.is_open
|
||||
or state.cursor_disabled
|
||||
or state.text_input_focused
|
||||
or state.camera_active
|
||||
or not allows_game_input(state)
|
||||
) then
|
||||
state.look_passthrough = false
|
||||
end
|
||||
local focus = SkyPhoneFocus.Resolve(state)
|
||||
SetNuiFocus(focus.focused, focus.cursor)
|
||||
SetNuiFocusKeepInput(focus.keep_input)
|
||||
@@ -90,6 +118,7 @@ end
|
||||
function SkyPhoneFocus.BeginNuiHydration()
|
||||
-- Browser-owned focus claims cannot survive a CEF reload.
|
||||
state.notification_focus = false
|
||||
state.look_passthrough = false
|
||||
state.text_input_focused = false
|
||||
end
|
||||
|
||||
@@ -107,6 +136,7 @@ function SkyPhoneFocus.SetPhone(open, cursor_disabled)
|
||||
state.cursor_disabled = false
|
||||
state.external_game_input = nil
|
||||
state.external_game_input_owner = nil
|
||||
state.look_passthrough = false
|
||||
state.text_input_focused = false
|
||||
end
|
||||
SkyPhoneFocus.Reapply()
|
||||
@@ -124,6 +154,9 @@ end
|
||||
|
||||
function SkyPhoneFocus.SetTextInputFocused(active)
|
||||
state.text_input_focused = active == true
|
||||
if state.text_input_focused then
|
||||
state.look_passthrough = false
|
||||
end
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
|
||||
@@ -150,6 +183,7 @@ function SkyPhoneFocus.Reset()
|
||||
state.external_game_input = nil
|
||||
state.external_game_input_owner = nil
|
||||
state.is_open = false
|
||||
state.look_passthrough = false
|
||||
state.notification_focus = false
|
||||
state.payphone_focus = false
|
||||
state.sim_picker_open = false
|
||||
@@ -164,6 +198,14 @@ end
|
||||
CreateThread(function()
|
||||
while true do
|
||||
if game_input or block_game then
|
||||
local look_passthrough = hold_to_look_enabled
|
||||
and game_input
|
||||
and not state.cursor_disabled
|
||||
and IsControlPressed(0, hold_to_look_control)
|
||||
if look_passthrough ~= state.look_passthrough then
|
||||
state.look_passthrough = look_passthrough
|
||||
SkyPhoneFocus.Reapply()
|
||||
end
|
||||
if block_game then
|
||||
SkyPhoneFocus.ApplyFocusedControls()
|
||||
else
|
||||
|
||||
@@ -699,7 +699,7 @@ local schema = {
|
||||
{ name = "title", type = "VARCHAR(160) NOT NULL" },
|
||||
{ name = "description", type = "VARCHAR(1000) NOT NULL DEFAULT ''" },
|
||||
{ name = "amount", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "currency", type = "VARCHAR(8) NOT NULL", characterSet = "utf8mb4", collation = "utf8mb4_unicode_ci" },
|
||||
{ name = "status", type = "ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open'" },
|
||||
{ name = "read_at", type = "DATETIME NULL" },
|
||||
{ name = "issued_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" },
|
||||
@@ -1001,8 +1001,8 @@ local schema = {
|
||||
{
|
||||
name = "handle",
|
||||
type = "VARCHAR(24) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_general_ci",
|
||||
characterSet = "utf8mb4",
|
||||
collation = "utf8mb4_unicode_ci",
|
||||
},
|
||||
{ name = "bio", type = "VARCHAR(160) NOT NULL DEFAULT ''" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
@@ -2156,8 +2156,8 @@ local schema = {
|
||||
{
|
||||
name = "currency",
|
||||
type = "VARCHAR(8) NOT NULL",
|
||||
characterSet = "ascii",
|
||||
collation = "ascii_general_ci",
|
||||
characterSet = "utf8mb4",
|
||||
collation = "utf8mb4_unicode_ci",
|
||||
},
|
||||
{ name = "driver_vehicle_model", type = "VARCHAR(64) NULL" },
|
||||
{ name = "driver_vehicle_color", type = "VARCHAR(64) NULL" },
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
local resource_name = GetCurrentResourceName()
|
||||
local index_path = "source/html/index.html"
|
||||
local required_static_files = {
|
||||
"source/html/img/custom-app.svg",
|
||||
"source/html/sounds/button.mp3",
|
||||
}
|
||||
|
||||
local function load_non_empty_file(path)
|
||||
local content = LoadResourceFile(resource_name, path)
|
||||
if type(content) ~= "string" or content == "" then
|
||||
return nil
|
||||
end
|
||||
return content
|
||||
end
|
||||
|
||||
local function add_missing_path(missing_paths, seen_paths, path)
|
||||
if seen_paths[path] then
|
||||
return
|
||||
end
|
||||
|
||||
seen_paths[path] = true
|
||||
missing_paths[#missing_paths + 1] = path
|
||||
end
|
||||
|
||||
local function normalize_entry_asset_path(url)
|
||||
if type(url) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local path = url:gsub("[?#].*$", "")
|
||||
while path:sub(1, 2) == "./" do
|
||||
path = path:sub(3)
|
||||
end
|
||||
|
||||
if path:sub(1, 7) ~= "assets/" then
|
||||
return nil
|
||||
end
|
||||
|
||||
return "source/html/" .. path
|
||||
end
|
||||
|
||||
local function collect_entry_asset_paths(index_html)
|
||||
local paths = {}
|
||||
local seen_paths = {}
|
||||
|
||||
for url in index_html:gmatch("src%s*=%s*[\"']([^\"']+)[\"']") do
|
||||
local path = normalize_entry_asset_path(url)
|
||||
if path and not seen_paths[path] then
|
||||
seen_paths[path] = true
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
|
||||
for url in index_html:gmatch("href%s*=%s*[\"']([^\"']+)[\"']") do
|
||||
local path = normalize_entry_asset_path(url)
|
||||
if path and not seen_paths[path] then
|
||||
seen_paths[path] = true
|
||||
paths[#paths + 1] = path
|
||||
end
|
||||
end
|
||||
|
||||
return paths
|
||||
end
|
||||
|
||||
local function find_missing_nui_files()
|
||||
local missing_paths = {}
|
||||
local seen_paths = {}
|
||||
local index_html = load_non_empty_file(index_path)
|
||||
|
||||
if not index_html then
|
||||
add_missing_path(missing_paths, seen_paths, index_path)
|
||||
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (generated entry assets)")
|
||||
else
|
||||
local entry_asset_paths = collect_entry_asset_paths(index_html)
|
||||
if #entry_asset_paths == 0 then
|
||||
add_missing_path(missing_paths, seen_paths, "source/html/assets/* (no entry asset referenced by index.html)")
|
||||
else
|
||||
for index = 1, #entry_asset_paths do
|
||||
local path = entry_asset_paths[index]
|
||||
if not load_non_empty_file(path) then
|
||||
add_missing_path(missing_paths, seen_paths, path)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for index = 1, #required_static_files do
|
||||
local path = required_static_files[index]
|
||||
if not load_non_empty_file(path) then
|
||||
add_missing_path(missing_paths, seen_paths, path)
|
||||
end
|
||||
end
|
||||
|
||||
return missing_paths
|
||||
end
|
||||
|
||||
local function print_missing_nui_notice(missing_paths)
|
||||
local border = "======================================================================"
|
||||
local lines = {
|
||||
("^1%s^0"):format(border),
|
||||
"^1 SKY PHONE UI BUILD IS MISSING OR INCOMPLETE ^0",
|
||||
("^1%s^0"):format(border),
|
||||
"^3 The resource started, but the packaged phone UI cannot load.^0",
|
||||
"",
|
||||
"^3 Missing or invalid files:^0",
|
||||
}
|
||||
|
||||
for index = 1, #missing_paths do
|
||||
lines[#lines + 1] = ("^1 - %s^0"):format(missing_paths[index])
|
||||
end
|
||||
|
||||
lines[#lines + 1] = ""
|
||||
lines[#lines + 1] = "^3 Install the latest published release package, not GitHub's automatic source archive.^0"
|
||||
lines[#lines + 1] = "^5 Release: https://github.com/sky-systems/sky_phone/releases/latest^0"
|
||||
lines[#lines + 1] = "^3 Developers working from source must run build_frontend.bat before starting sky_phone.^0"
|
||||
lines[#lines + 1] = ("^1%s^0"):format(border)
|
||||
|
||||
print(table.concat(lines, "\n"))
|
||||
end
|
||||
|
||||
local missing_paths = find_missing_nui_files()
|
||||
if #missing_paths > 0 then
|
||||
print_missing_nui_notice(missing_paths)
|
||||
end
|
||||
@@ -19,7 +19,8 @@ Bridge.Callbacks.Register("sky_phone:device:open-request", function(source)
|
||||
return { success = true, data = { queued = true } }
|
||||
end
|
||||
|
||||
return { success = phone_open_handler(source, nil) }
|
||||
local success, error_code = phone_open_handler(source, nil)
|
||||
return { success = success, error = error_code }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||
@@ -31,7 +32,8 @@ Bridge.Callbacks.Register("sky_phone:device:development-open", function(source)
|
||||
return { success = true, data = { queued = true } }
|
||||
end
|
||||
|
||||
return { success = phone_open_handler(source, nil) }
|
||||
local success, error_code = phone_open_handler(source, nil)
|
||||
return { success = success, error = error_code }
|
||||
end)
|
||||
|
||||
AddEventHandler("playerDropped", function()
|
||||
@@ -394,9 +396,10 @@ local function resolve_used_slot(source, used_item)
|
||||
|
||||
Bridge.Debug(
|
||||
"warn",
|
||||
"[sky_phone] No phone item slot was available for source %s (%s candidates).",
|
||||
tostring(source),
|
||||
tostring(#slots)
|
||||
"[sky_phone] Inventory '%s' returned no '%s' phone item for source %s. This is an item/configuration problem, not a missing SIM card.",
|
||||
tostring(Bridge.Inventory.GetResourceName()),
|
||||
tostring(Config.Phone.Item),
|
||||
tostring(source)
|
||||
)
|
||||
return nil, "phone_required"
|
||||
end
|
||||
@@ -547,6 +550,10 @@ local function bootstrap(source, security, security_loaded)
|
||||
|
||||
return {
|
||||
token = session.token,
|
||||
phoneNumberFormat = {
|
||||
length = Config.Sim.NumberLength,
|
||||
groups = Config.Sim.NumberGroups,
|
||||
},
|
||||
security = SkyPhoneSecurity.Status(device.imei, security, security_loaded),
|
||||
device = {
|
||||
imei = device.imei,
|
||||
@@ -852,7 +859,7 @@ local function open_phone(source, used_item)
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, slot_error)
|
||||
return false
|
||||
return false, slot_error
|
||||
end
|
||||
|
||||
local imei, error_code = ensure_device(source, slot)
|
||||
@@ -866,7 +873,7 @@ local function open_phone(source, used_item)
|
||||
{ always = true }
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||
return false
|
||||
return false, error_code
|
||||
end
|
||||
local prepared, prepare_error = SkyPhoneSim.PrepareDevice(source, slot, imei)
|
||||
if not prepared then
|
||||
@@ -877,8 +884,9 @@ local function open_phone(source, used_item)
|
||||
imei,
|
||||
tostring(prepare_error)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, prepare_error or "request_failed")
|
||||
return false
|
||||
local error_code = prepare_error or "request_failed"
|
||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||
return false, error_code
|
||||
end
|
||||
|
||||
local security = SkyPhoneSecurity.Load(imei)
|
||||
@@ -892,7 +900,20 @@ local function open_phone(source, used_item)
|
||||
unlocked = security == nil,
|
||||
}
|
||||
preferred_device_imeis[source] = imei
|
||||
local payload = bootstrap(source, security, true)
|
||||
local payload, bootstrap_error = bootstrap(source, security, true)
|
||||
if not payload then
|
||||
local error_code = type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed"
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Phone bootstrap failed for source %s slot %s IMEI %s: %s.",
|
||||
tostring(source),
|
||||
tostring(slot.slot),
|
||||
imei,
|
||||
tostring(error_code)
|
||||
)
|
||||
TriggerClientEvent("sky_phone:device:error", source, error_code)
|
||||
return false, error_code
|
||||
end
|
||||
Bridge.Debug(
|
||||
"debug",
|
||||
"[sky_phone] Triggering client open for source %s slot %s IMEI %s account_linked=%s after %sms.",
|
||||
@@ -932,7 +953,18 @@ function SkyPhone.OpenDeviceForCall(source, imei)
|
||||
}
|
||||
end
|
||||
preferred_device_imeis[source] = imei
|
||||
TriggerClientEvent("sky_phone:device:open", source, bootstrap(source))
|
||||
local payload, bootstrap_error = bootstrap(source)
|
||||
if not payload then
|
||||
Bridge.Debug(
|
||||
"error",
|
||||
"[sky_phone] Call notification bootstrap failed for source %s IMEI %s: %s.",
|
||||
tostring(source),
|
||||
tostring(imei),
|
||||
tostring(type(bootstrap_error) == "table" and bootstrap_error.error or "request_failed")
|
||||
)
|
||||
return false
|
||||
end
|
||||
TriggerClientEvent("sky_phone:device:open", source, payload)
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ Bridge.Database.AfterMigration("sky_phone", function()
|
||||
|
||||
SkyPhoneSim = {}
|
||||
|
||||
local valid_number_configuration, number_configuration_error = SkyPhoneSimNumber.ValidateConfiguration(
|
||||
Config.Sim.NumberLength,
|
||||
Config.Sim.NumberPrefix
|
||||
)
|
||||
if not valid_number_configuration then
|
||||
error(("[sky_phone] Invalid SIM number configuration: %s."):format(number_configuration_error))
|
||||
end
|
||||
|
||||
local unique_phones = Config.Phone.Unique ~= false
|
||||
local sim_cards_enabled = Config.Sim.Enabled ~= false
|
||||
local pending_insertions = {}
|
||||
@@ -393,6 +401,10 @@ local function use_sim(source, used_item)
|
||||
TriggerClientEvent("sky_phone:sim:picker", source, {
|
||||
choices = choices,
|
||||
number = sim.phone_number,
|
||||
phoneNumberFormat = {
|
||||
length = Config.Sim.NumberLength,
|
||||
groups = Config.Sim.NumberGroups,
|
||||
},
|
||||
})
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
SkyPhoneSimNumber = {}
|
||||
|
||||
function SkyPhoneSimNumber.ValidateConfiguration(length, prefix)
|
||||
if type(length) ~= "number" or length ~= math.floor(length) or length < 1 or length > 24 then
|
||||
return false, "NumberLength must be a whole number between 1 and 24"
|
||||
end
|
||||
if type(prefix) ~= "string" or prefix:find("%D") then
|
||||
return false, "NumberPrefix must contain digits only"
|
||||
end
|
||||
if #prefix > length then
|
||||
return false, "NumberPrefix cannot be longer than NumberLength"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function SkyPhoneSimNumber.Normalize(value, length, prefix)
|
||||
if type(value) ~= "string" and type(value) ~= "number" then
|
||||
return nil
|
||||
end
|
||||
if not SkyPhoneSimNumber.ValidateConfiguration(length, prefix) then
|
||||
return nil
|
||||
end
|
||||
local number = tostring(value):gsub("%D", "")
|
||||
if #number ~= length or number:sub(1, #prefix) ~= prefix then
|
||||
return nil
|
||||
@@ -26,6 +42,9 @@ function SkyPhoneSimNumber.FromEntropy(entropy, length, prefix)
|
||||
if type(entropy) ~= "string" or entropy == "" then
|
||||
return nil
|
||||
end
|
||||
if not SkyPhoneSimNumber.ValidateConfiguration(length, prefix) then
|
||||
return nil
|
||||
end
|
||||
local source = entropy:gsub("%D", "")
|
||||
if source == "" then
|
||||
return nil
|
||||
|
||||
@@ -360,7 +360,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_billing_invoices` (
|
||||
`title` VARCHAR(160) NOT NULL,
|
||||
`description` VARCHAR(1000) NOT NULL DEFAULT '',
|
||||
`amount` BIGINT UNSIGNED NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`status` ENUM('open', 'processing', 'paid', 'disputed', 'cancelled', 'refunded') NOT NULL DEFAULT 'open',
|
||||
`read_at` DATETIME NULL,
|
||||
`issued_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -944,7 +944,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_skyride_rides` (
|
||||
`duration_seconds` INT UNSIGNED NOT NULL,
|
||||
`price` INT UNSIGNED NOT NULL,
|
||||
`payout_amount` INT UNSIGNED NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`currency` VARCHAR(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`driver_vehicle_model` VARCHAR(64) NULL,
|
||||
`driver_vehicle_color` VARCHAR(64) NULL,
|
||||
`driver_vehicle_plate` VARCHAR(16) NULL,
|
||||
|
||||
+31
-6
@@ -6,7 +6,12 @@ local nui_callbacks = {}
|
||||
local nui_focus = nil
|
||||
local nui_keep_input = nil
|
||||
|
||||
Config = { Phone = { AllowMovement = true } }
|
||||
Config = {
|
||||
Phone = {
|
||||
AllowMovement = true,
|
||||
HoldToLook = { Enabled = true, Control = 19 },
|
||||
},
|
||||
}
|
||||
Bridge = { Debug = function() end }
|
||||
|
||||
function CreateThread(callback)
|
||||
@@ -61,6 +66,7 @@ local function resolve(overrides)
|
||||
cursor_disabled = false,
|
||||
external_game_input = nil,
|
||||
is_open = false,
|
||||
look_passthrough = false,
|
||||
notification_focus = false,
|
||||
payphone_focus = false,
|
||||
sim_picker_open = false,
|
||||
@@ -115,10 +121,10 @@ local typing_phone = resolve({
|
||||
assert(
|
||||
typing_phone.cursor
|
||||
and typing_phone.focused
|
||||
and typing_phone.keep_input
|
||||
and typing_phone.game_input
|
||||
and not typing_phone.keep_input
|
||||
and not typing_phone.game_input
|
||||
and typing_phone.block_game,
|
||||
"a focused phone text input must block GTA controls without hiding the NUI cursor"
|
||||
"a focused phone text input must stop game-input passthrough and retain the NUI cursor"
|
||||
)
|
||||
|
||||
local external_movement_phone = resolve({
|
||||
@@ -142,8 +148,8 @@ local external_movement_typing_phone = resolve({
|
||||
assert(
|
||||
external_movement_typing_phone.cursor
|
||||
and external_movement_typing_phone.focused
|
||||
and external_movement_typing_phone.keep_input
|
||||
and external_movement_typing_phone.game_input
|
||||
and not external_movement_typing_phone.keep_input
|
||||
and not external_movement_typing_phone.game_input
|
||||
and external_movement_typing_phone.block_game,
|
||||
"a focused text input must override an external movement claim"
|
||||
)
|
||||
@@ -177,6 +183,21 @@ assert(
|
||||
"LB noFocus must preserve movement and camera look without retaining the NUI cursor"
|
||||
)
|
||||
|
||||
local movable_look_passthrough_phone = resolve({
|
||||
allow_movement = true,
|
||||
is_open = true,
|
||||
look_passthrough = true,
|
||||
})
|
||||
assert(
|
||||
not movable_look_passthrough_phone.cursor
|
||||
and movable_look_passthrough_phone.focused
|
||||
and movable_look_passthrough_phone.keep_input
|
||||
and movable_look_passthrough_phone.game_input
|
||||
and not movable_look_passthrough_phone.block_game
|
||||
and not movable_look_passthrough_phone.block_look,
|
||||
"HoldToLook must temporarily release the NUI cursor without disabling configured movement"
|
||||
)
|
||||
|
||||
local stationary_cursor_disabled_phone = resolve({
|
||||
cursor_disabled = true,
|
||||
is_open = true,
|
||||
@@ -289,6 +310,10 @@ assert(
|
||||
SkyPhoneFocus.SetPhone(false)
|
||||
SkyPhoneFocus.SetPhone(true)
|
||||
assert(nui_focus.cursor, "closing the phone must clear the previous no-focus claim")
|
||||
SkyPhoneFocus.SetTextInputFocused(true)
|
||||
assert(not nui_keep_input, "runtime text focus must stop game-input passthrough")
|
||||
SkyPhoneFocus.SetTextInputFocused(false)
|
||||
assert(nui_keep_input, "leaving a text input must restore configured phone movement")
|
||||
|
||||
local external_success, external_error = SkyPhoneFocus.SetExternalGameInput("custom_app", true)
|
||||
assert(external_success and external_error == nil and nui_keep_input, "external movement claim must apply")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
local function reset_bridge(inventory_name, unique_phones, sim_cards_enabled)
|
||||
Config = {
|
||||
Bridge = {
|
||||
Inventory = inventory_name,
|
||||
},
|
||||
Phone = {
|
||||
Unique = unique_phones,
|
||||
},
|
||||
Sim = {
|
||||
Enabled = sim_cards_enabled,
|
||||
},
|
||||
}
|
||||
Bridge = {
|
||||
Debug = function()
|
||||
end,
|
||||
Framework = {
|
||||
GetName = function()
|
||||
return inventory_name == "esx" and "esx" or "qb"
|
||||
end,
|
||||
},
|
||||
Inventory = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function load_inventory_contract(adapter_path)
|
||||
dofile("sky_phone/source/bridge/server/inventory.lua")
|
||||
dofile(adapter_path)
|
||||
dofile("sky_phone/source/bridge/server/inventory_contract.lua")
|
||||
end
|
||||
|
||||
local core_items = {
|
||||
{
|
||||
name = "phone",
|
||||
slot = 15,
|
||||
count = 1,
|
||||
metadata = {},
|
||||
},
|
||||
}
|
||||
local metadata_write
|
||||
local core_inventory = {}
|
||||
|
||||
function core_inventory:getInventory(source)
|
||||
assert(source == 7)
|
||||
return core_items
|
||||
end
|
||||
|
||||
function core_inventory:setMetadata(source, slot, metadata)
|
||||
metadata_write = { source = source, slot = slot, metadata = metadata }
|
||||
core_items[1].metadata = metadata
|
||||
end
|
||||
|
||||
function core_inventory:updateMetadata()
|
||||
error("core bridge must use the stable setMetadata contract")
|
||||
end
|
||||
|
||||
reset_bridge("core", true, true)
|
||||
exports = {
|
||||
core_inventory = core_inventory,
|
||||
}
|
||||
GetResourceState = function(resource_name)
|
||||
return resource_name == "core_inventory" and "started" or "missing"
|
||||
end
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/core.lua")
|
||||
|
||||
assert(Bridge.Inventory.SetSlotMetadata(7, "15", { imei = "123456789012345" }))
|
||||
assert(metadata_write.source == 7)
|
||||
assert(metadata_write.slot == 15, "core metadata slot must be numeric")
|
||||
assert(metadata_write.metadata.imei == "123456789012345")
|
||||
assert(Bridge.Inventory.GetSlot(7, 15).metadata.imei == "123456789012345")
|
||||
|
||||
local qb_item = {
|
||||
name = "phone",
|
||||
slot = 2,
|
||||
amount = 1,
|
||||
info = { owner = "kept" },
|
||||
}
|
||||
local qb_write_mode = "persist"
|
||||
local qb_inventory = {}
|
||||
|
||||
function qb_inventory:GetItemBySlot(source, slot)
|
||||
assert(source == 9 and slot == 2)
|
||||
return qb_item
|
||||
end
|
||||
|
||||
function qb_inventory:GetItemsByName(source, item_name)
|
||||
assert(source == 9 and item_name == "phone")
|
||||
return qb_item and { qb_item } or {}
|
||||
end
|
||||
|
||||
function qb_inventory:SetItemData()
|
||||
error("qb bridge must not rely on an unverified direct metadata setter")
|
||||
end
|
||||
|
||||
function qb_inventory:RemoveItem(source, item_name, amount, slot, reason)
|
||||
assert(source == 9 and item_name == "phone" and amount == 1 and slot == 2)
|
||||
assert(reason == "sky_phone:metadata-update")
|
||||
if not qb_item then
|
||||
return false
|
||||
end
|
||||
qb_item = nil
|
||||
return true
|
||||
end
|
||||
|
||||
function qb_inventory:AddItem(source, item_name, amount, slot, info, reason)
|
||||
assert(source == 9 and item_name == "phone" and amount == 1 and slot == 2)
|
||||
assert(reason == "sky_phone:metadata-update")
|
||||
qb_item = {
|
||||
name = item_name,
|
||||
slot = slot,
|
||||
amount = amount,
|
||||
info = qb_write_mode == "drop_metadata" and {} or info,
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
function qb_inventory:CanAddItem()
|
||||
return true
|
||||
end
|
||||
|
||||
reset_bridge("qb", true, true)
|
||||
exports = {
|
||||
["qb-inventory"] = qb_inventory,
|
||||
}
|
||||
GetResourceState = function(resource_name)
|
||||
return resource_name == "qb-inventory" and "started" or "missing"
|
||||
end
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/qb.lua")
|
||||
|
||||
assert(Bridge.Inventory.SetSlotMetadata(9, 2, { owner = "kept", imei = "123456789012345" }))
|
||||
assert(qb_item.info.owner == "kept" and qb_item.info.imei == "123456789012345")
|
||||
|
||||
qb_write_mode = "drop_metadata"
|
||||
assert(not Bridge.Inventory.SetSlotMetadata(9, 2, { owner = "kept", imei = "999999999999999" }))
|
||||
assert(qb_item and qb_item.slot == 2, "QB metadata verification must leave the item in its exact slot")
|
||||
|
||||
local function create_esx()
|
||||
local usable_items = {}
|
||||
local player = {}
|
||||
|
||||
function player.getInventoryItem(item_name)
|
||||
return {
|
||||
name = item_name,
|
||||
count = 1,
|
||||
}
|
||||
end
|
||||
|
||||
local esx = {}
|
||||
|
||||
function esx.GetPlayerFromId(source)
|
||||
assert(source == 11)
|
||||
return player
|
||||
end
|
||||
|
||||
function esx.RegisterUsableItem(item_name, callback)
|
||||
usable_items[item_name] = callback
|
||||
end
|
||||
|
||||
return esx, usable_items
|
||||
end
|
||||
|
||||
local esx, usable_items = create_esx()
|
||||
reset_bridge("esx", false, false)
|
||||
exports = {
|
||||
es_extended = {
|
||||
getSharedObject = function()
|
||||
return esx
|
||||
end,
|
||||
},
|
||||
}
|
||||
GetResourceState = function(resource_name)
|
||||
return resource_name == "es_extended" and "started" or "missing"
|
||||
end
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
|
||||
|
||||
local used_item
|
||||
assert(Bridge.Inventory.RegisterUsableItem("phone", function(source, item)
|
||||
assert(source == 11)
|
||||
used_item = item
|
||||
end))
|
||||
usable_items.phone(11)
|
||||
assert(used_item.name == "phone")
|
||||
assert(used_item.slot == "phone")
|
||||
assert(used_item.count == 1)
|
||||
|
||||
esx = create_esx()
|
||||
reset_bridge("esx", true, true)
|
||||
exports = {
|
||||
es_extended = {
|
||||
getSharedObject = function()
|
||||
return esx
|
||||
end,
|
||||
},
|
||||
}
|
||||
load_inventory_contract("sky_phone/source/bridge/server/inventory/esx.lua")
|
||||
|
||||
local ok, configuration_error = pcall(Bridge.Inventory.RegisterUsableItem, "phone", function()
|
||||
end)
|
||||
assert(not ok)
|
||||
assert(configuration_error:find("Config.Phone.Unique = false", 1, true))
|
||||
assert(configuration_error:find("Config.Sim.Enabled = false", 1, true))
|
||||
|
||||
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
|
||||
local manifest = manifest_file:read("*a")
|
||||
manifest_file:close()
|
||||
local adapters = assert(manifest:find("source/bridge/server/inventory/*.lua", 1, true))
|
||||
local contract = assert(manifest:find("source/bridge/server/inventory_contract.lua", 1, true))
|
||||
assert(adapters < contract, "inventory contract must load after provider adapters")
|
||||
|
||||
print("inventory bridge regression checks passed")
|
||||
@@ -0,0 +1,64 @@
|
||||
local source_path = "sky_phone/source/server/nui_build_check.lua"
|
||||
local original_print = print
|
||||
|
||||
local function run_check(files)
|
||||
local output = {}
|
||||
|
||||
print = function(message)
|
||||
output[#output + 1] = tostring(message)
|
||||
end
|
||||
GetCurrentResourceName = function()
|
||||
return "sky_phone"
|
||||
end
|
||||
LoadResourceFile = function(resource_name, path)
|
||||
assert(resource_name == "sky_phone", "NUI build check must inspect its own resource")
|
||||
return files[path]
|
||||
end
|
||||
|
||||
dofile(source_path)
|
||||
return table.concat(output, "\n")
|
||||
end
|
||||
|
||||
local valid_files = {
|
||||
["source/html/index.html"] = [[
|
||||
<link rel="stylesheet" href="./assets/sky-index.css">
|
||||
<script type="module" src="./assets/sky-index.js"></script>
|
||||
]],
|
||||
["source/html/assets/sky-index.css"] = "body{}",
|
||||
["source/html/assets/sky-index.js"] = "console.log('ready')",
|
||||
["source/html/img/custom-app.svg"] = "<svg></svg>",
|
||||
["source/html/sounds/button.mp3"] = "audio",
|
||||
}
|
||||
|
||||
assert(run_check(valid_files) == "", "a complete NUI build must not print a warning")
|
||||
|
||||
local missing_build_output = run_check({})
|
||||
assert(missing_build_output:find("SKY PHONE UI BUILD IS MISSING OR INCOMPLETE", 1, true))
|
||||
assert(missing_build_output:find("source/html/index.html", 1, true))
|
||||
assert(missing_build_output:find("source/html/assets/*", 1, true))
|
||||
assert(missing_build_output:find("source/html/img/custom-app.svg", 1, true))
|
||||
assert(missing_build_output:find("source/html/sounds/button.mp3", 1, true))
|
||||
assert(missing_build_output:find("not GitHub's automatic source archive", 1, true))
|
||||
assert(missing_build_output:find("build_frontend.bat", 1, true))
|
||||
|
||||
local missing_asset_files = {}
|
||||
for path, content in pairs(valid_files) do
|
||||
missing_asset_files[path] = content
|
||||
end
|
||||
missing_asset_files["source/html/assets/sky-index.js"] = nil
|
||||
|
||||
local missing_asset_output = run_check(missing_asset_files)
|
||||
assert(missing_asset_output:find("source/html/assets/sky-index.js", 1, true))
|
||||
assert(not missing_asset_output:find("source/html/assets/sky-index.css", 1, true))
|
||||
|
||||
local query_asset_files = {}
|
||||
for path, content in pairs(valid_files) do
|
||||
query_asset_files[path] = content
|
||||
end
|
||||
query_asset_files["source/html/index.html"] = [[
|
||||
<script type="module" src="./assets/sky-index.js?v=1#entry"></script>
|
||||
]]
|
||||
assert(run_check(query_asset_files) == "", "asset query strings and fragments must be ignored")
|
||||
|
||||
print = original_print
|
||||
io.write("Sky Phone NUI build check tests passed\n")
|
||||
@@ -1,5 +1,6 @@
|
||||
local registered_callbacks = {}
|
||||
local migration_callbacks = {}
|
||||
local event_handlers = {}
|
||||
|
||||
Bridge = {
|
||||
Callbacks = {
|
||||
@@ -75,8 +76,10 @@ json = {
|
||||
end,
|
||||
}
|
||||
|
||||
function AddEventHandler(_, callback)
|
||||
function AddEventHandler(name, callback)
|
||||
assert(type(callback) == "function")
|
||||
event_handlers[name] = event_handlers[name] or {}
|
||||
event_handlers[name][#event_handlers[name] + 1] = callback
|
||||
end
|
||||
|
||||
function TriggerClientEvent()
|
||||
@@ -177,6 +180,106 @@ for _, callback_name in ipairs({
|
||||
assert(response.success == false and response.error == "device_not_open", "callback not bound to core: " .. callback_name)
|
||||
end
|
||||
|
||||
local phone_item = {
|
||||
name = "phone",
|
||||
slot = 4,
|
||||
amount = 1,
|
||||
metadata = { imei = "123456789012345" },
|
||||
}
|
||||
local opened_event
|
||||
local device_error
|
||||
local hide_phone_during_prepare = false
|
||||
|
||||
Bridge.Framework.GetIdentifier = function(source)
|
||||
assert(source == 1)
|
||||
return "license:test-player"
|
||||
end
|
||||
Bridge.Framework.GetFirstname = function()
|
||||
return "Test"
|
||||
end
|
||||
Bridge.Framework.GetLastname = function()
|
||||
return "Player"
|
||||
end
|
||||
Bridge.Inventory.GetSlot = function(source, slot)
|
||||
assert(source == 1 and slot == phone_item.slot)
|
||||
return phone_item
|
||||
end
|
||||
Bridge.Inventory.GetSlotsWithItem = function(source, item_name)
|
||||
assert(source == 1 and item_name == Config.Phone.Item)
|
||||
if hide_phone_during_prepare == true then
|
||||
return {}
|
||||
end
|
||||
return { phone_item }
|
||||
end
|
||||
Bridge.Inventory.SetSlotMetadata = function()
|
||||
error("existing phone metadata must not be rewritten")
|
||||
end
|
||||
Bridge.Database.Query = function(query)
|
||||
if query:find("FROM `sky_phone_devices` d", 1, true) then
|
||||
return {
|
||||
{
|
||||
imei = phone_item.metadata.imei,
|
||||
device_name = Config.Phone.DeviceName,
|
||||
account_id = nil,
|
||||
sim_id = nil,
|
||||
},
|
||||
}
|
||||
end
|
||||
return {}
|
||||
end
|
||||
SkyPhoneImei.IsValid = function(imei)
|
||||
return imei == phone_item.metadata.imei
|
||||
end
|
||||
SkyPhoneSim = {
|
||||
PrepareDevice = function(source, slot, imei)
|
||||
assert(source == 1 and slot == phone_item and imei == phone_item.metadata.imei)
|
||||
if hide_phone_during_prepare == "next" then
|
||||
hide_phone_during_prepare = true
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
SkyPhoneNotes = {
|
||||
List = function()
|
||||
return {}
|
||||
end,
|
||||
}
|
||||
SkyPhoneMemos = {
|
||||
List = function()
|
||||
return {}
|
||||
end,
|
||||
}
|
||||
SkyPhoneCompanies = {
|
||||
ClearCallAvailability = function()
|
||||
end,
|
||||
}
|
||||
TriggerClientEvent = function(event_name, source, payload)
|
||||
if event_name == "sky_phone:device:open" then
|
||||
opened_event = { source = source, payload = payload }
|
||||
elseif event_name == "sky_phone:device:error" then
|
||||
device_error = { source = source, error = payload }
|
||||
end
|
||||
end
|
||||
|
||||
for _, callback in ipairs(event_handlers.onServerResourceStart or {}) do
|
||||
callback("sky_phone")
|
||||
end
|
||||
|
||||
local no_sim_open = registered_callbacks["sky_phone:device:open-request"](1, {})
|
||||
assert(no_sim_open.success == true, "a phone item without a SIM must still open")
|
||||
assert(opened_event and opened_event.source == 1, "no-SIM open must reach the client")
|
||||
assert(opened_event.payload.device.imei == phone_item.metadata.imei)
|
||||
assert(opened_event.payload.device.sim == nil, "no-SIM bootstrap must keep device.sim nullable")
|
||||
|
||||
opened_event = nil
|
||||
device_error = nil
|
||||
hide_phone_during_prepare = "next"
|
||||
local lost_phone_open = registered_callbacks["sky_phone:device:open-request"](1, {})
|
||||
assert(lost_phone_open.success == false, "bootstrap ownership loss must fail the open request")
|
||||
assert(lost_phone_open.error == "device_not_owned", "bootstrap ownership loss must return its error code")
|
||||
assert(opened_event == nil, "bootstrap ownership loss must not open the NUI")
|
||||
assert(device_error and device_error.error == "device_not_owned", "bootstrap ownership loss must notify the client")
|
||||
|
||||
local manifest_file = assert(io.open("sky_phone/fxmanifest.lua", "rb"))
|
||||
local manifest = manifest_file:read("*a")
|
||||
manifest_file:close()
|
||||
|
||||
@@ -9,6 +9,10 @@ assert(SkyPhoneSimNumber.NormalizeService("9-1-1", 10) == "911", "formatted serv
|
||||
assert(SkyPhoneSimNumber.NormalizeService("0", 10) == "0", "single-digit service numbers must normalize")
|
||||
assert(SkyPhoneSimNumber.NormalizeService("", 10) == nil, "empty service numbers must fail")
|
||||
assert(SkyPhoneSimNumber.NormalizeService("12345678901", 10) == nil, "long service numbers must fail")
|
||||
assert(SkyPhoneSimNumber.ValidateConfiguration(9, "555"), "numeric SIM prefixes must be valid")
|
||||
assert(SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 9, "555") == "555550840")
|
||||
assert(not SkyPhoneSimNumber.ValidateConfiguration(9, "555-"), "formatted SIM prefixes must be rejected")
|
||||
assert(SkyPhoneSimNumber.FromEntropy("550e8400-e29b-41d4-a716-446655440000", 9, "555-") == nil)
|
||||
|
||||
local attempts = 0
|
||||
local reserved = SkyPhoneSimNumber.Reserve(function()
|
||||
|
||||
Reference in New Issue
Block a user