From fd4e49facf75336742dde78acad17dc126fb9444 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:16:16 +0100 Subject: [PATCH 01/58] add project overview --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a4a6f1 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Soul Project ESX Legacy + +Base modular do **Soul Project** para servidores FiveM assentes em **ESX Legacy**. O repositório não duplica o código de terceiros: a recipe do txAdmin instala as dependências oficiais e mantém o código `sp_*` isolado para atualizações e manutenção sem colisões. + +## Incluído + +- ESX Legacy Core e ESX Legacy Addons oficiais; +- `oxmysql`, `pma-voice`, `bob74_ipl` e recursos base Cfx.re; +- configuração PT-PT, OneSync e política Steam-only; +- grupos próprios para core, UI, trabalhos, crime, mapas e veículos; +- `sp_bootstrap` com gate Steam, health check e state bag de prontidão; +- SQL de controlo de migrações Soul Project; +- validação automática no GitHub Actions, incluindo deteção básica de segredos. + +## Instalação pelo txAdmin + +Usa esta recipe personalizada no Server Deployer: + +```text +https://raw.githubusercontent.com/SoulBrotherDev/ESX-LEGACY/main/recipe.yaml +``` + +Depois da instalação, abre `config/secrets.cfg` e coloca a Steam Web API Key. Sem essa chave o servidor não conseguirá fornecer identificadores `steam:` e o gate Steam-only recusará ligações. + +## Arranque + +A ordem de recursos está em `config/resources.cfg`. Após arrancar, executa na consola: + +```text +sp_health +``` + +O comando apresenta o estado de `oxmysql`, `es_extended` e dos módulos Soul Project essenciais. + +## Estrutura + +```text +config/ Configuração dividida por domínio +database/ Migrações próprias do Soul Project +docs/ Instalação e arquitetura +resources/[sp_core]/ Núcleo transversal Soul Project +resources/[sp_ui]/ Interfaces e HUD +resources/[sp_jobs]/ Trabalhos legais +resources/[sp_illegal]/ Sistemas criminais +resources/[sp_maps]/ IPLs, MLOs e mapas próprios +resources/[sp_vehicles]/ Veículos, handling e ecossistema automóvel +resources/[sp_custom]/ Integrações transversais +``` + +## Regras da base + +1. Não versionar chaves, tokens, palavras-passe ou connection strings reais. +2. Recursos de terceiros permanecem fora do Git e são instalados pela recipe. +3. Recursos próprios usam prefixo `sp_` e manifest `fxmanifest.lua`. +4. Alterações SQL próprias são cumulativas e registadas em `sp_schema_migrations`. +5. Atualizações do ESX são testadas em staging antes de chegar a produção. From 3eca2be8ae4328314c2bf0902ebf6ac0de6fa2bd Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:16:36 +0100 Subject: [PATCH 02/58] add txAdmin deployment recipe --- recipe.yaml | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 recipe.yaml diff --git a/recipe.yaml b/recipe.yaml new file mode 100644 index 0000000..56c873c --- /dev/null +++ b/recipe.yaml @@ -0,0 +1,120 @@ +$engine: 3 +$onesync: on +name: Soul Project ESX Legacy +version: 0.1.0 +author: Soul Project +description: Base modular PT-PT para FiveM com ESX Legacy, Steam-only e estrutura sp_*. + +tasks: + - action: download_github + src: https://github.com/citizenfx/cfx-server-data + ref: master + subpath: resources + dest: ./resources/[cfx-default] + + - action: remove_path + path: ./resources/[cfx-default]/[gameplay]/chat + + - action: download_github + src: https://github.com/SoulBrotherDev/ESX-LEGACY + ref: main + dest: ./tmp/sp-base + + - action: copy_path + src: ./tmp/sp-base/server.cfg + dest: ./server.cfg + + - action: copy_path + src: ./tmp/sp-base/config + dest: ./config + + - action: copy_path + src: ./tmp/sp-base/docs + dest: ./docs + + - action: copy_path + src: ./tmp/sp-base/README.md + dest: ./README.md + + - action: copy_path + src: ./tmp/sp-base/config/secrets.example.cfg + dest: ./config/secrets.cfg + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_core] + dest: ./resources/[sp_core] + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_custom] + dest: ./resources/[sp_custom] + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_jobs] + dest: ./resources/[sp_jobs] + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_illegal] + dest: ./resources/[sp_illegal] + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_maps] + dest: ./resources/[sp_maps] + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_ui] + dest: ./resources/[sp_ui] + + - action: copy_path + src: ./tmp/sp-base/resources/[sp_vehicles] + dest: ./resources/[sp_vehicles] + + - action: replace_string + mode: all_vars + file: ./server.cfg + + - action: connect_database + + - action: download_github + src: https://github.com/esx-framework/esx-core + ref: main + dest: ./tmp/esx-core + + - action: query_database + file: ./tmp/esx-core/[SQL]/legacy.sql + + - action: query_database + file: ./tmp/sp-base/database/000_sp_base.sql + + - action: download_github + src: https://github.com/esx-framework/ESX-Legacy-Addons + ref: main + dest: ./tmp/esx-addons + + - action: move_path + src: ./tmp/esx-core/[core] + dest: ./resources/[core] + + - action: move_path + src: ./tmp/esx-addons/[esx_addons] + dest: ./resources/[esx_addons] + + - action: download_github + src: https://github.com/Bob74/bob74_ipl + ref: master + dest: ./resources/[standalone]/bob74_ipl + + - action: download_github + src: https://github.com/AvarianKnight/pma-voice + ref: main + dest: ./resources/[standalone]/pma-voice + + - action: download_file + path: ./tmp/oxmysql.zip + url: https://github.com/overextended/oxmysql/releases/latest/download/oxmysql.zip + + - action: unzip + src: ./tmp/oxmysql.zip + dest: ./resources/[standalone] + + - action: remove_path + path: ./tmp From a302852187d40a775cf227b0a30444dfef05b357 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:16:43 +0100 Subject: [PATCH 03/58] add modular server configuration --- server.cfg | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 server.cfg diff --git a/server.cfg b/server.cfg new file mode 100644 index 0000000..9470467 --- /dev/null +++ b/server.cfg @@ -0,0 +1,21 @@ +# Soul Project ESX Legacy +# Gerado pela recipe txAdmin. Os valores {{...}} são substituídos durante o deploy. + +{{serverEndpoints}} + +sv_licenseKey "{{svLicense}}" +sv_hostname "{{serverName}} | Soul Project ESX" +sets sv_projectName "{{serverName}} | Soul Project ESX" +sets sv_projectDesc "{{recipeDescription}}" +sv_maxclients {{maxClients}} +set mysql_connection_string "{{dbConnectionString}}" + +{{addPrincipalsMaster}} + +exec config/core.cfg +exec config/security.cfg +exec config/esx.cfg +exec config/voice.cfg +exec config/permissions.cfg +exec config/secrets.cfg +exec config/resources.cfg From edd37548a86450bea1f5bfbe324041090307adc9 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:16:53 +0100 Subject: [PATCH 04/58] add core convars --- config/core.cfg | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 config/core.cfg diff --git a/config/core.cfg b/config/core.cfg new file mode 100644 index 0000000..37eac85 --- /dev/null +++ b/config/core.cfg @@ -0,0 +1,17 @@ +# Identidade pública +sets locale "pt-PT" +sets tags "pt-PT, roleplay, esx, esx-legacy, soul-project, steam-only" + +# Cfx.re +set resources_useSystemChat true +sv_scriptHookAllowed 0 +sv_endpointPrivacy true + +# Soul Project +setr sp:server_name "Soul Project" +setr sp:locale "pt-PT" +setr sp:environment "development" +setr sp:steam_only 1 + +# Ativar apenas depois de validar mapas, veículos e artefactos DLC. +# sv_enforceGameBuild 3258 From 5ce18f3bfc6458534670c44825f7d6c01cc80dc8 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:16:58 +0100 Subject: [PATCH 05/58] configure ESX for PT and Steam identifiers --- config/esx.cfg | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 config/esx.cfg diff --git a/config/esx.cfg b/config/esx.cfg new file mode 100644 index 0000000..d774f44 --- /dev/null +++ b/config/esx.cfg @@ -0,0 +1,5 @@ +# ESX Legacy +setr esx:locale "pt" +setr esx:identifier "steam" + +# A identificação Steam exige uma Steam Web API Key válida em config/secrets.cfg. From fb873303e65f3b7d89d1e465e9289b028dfd39fb Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:04 +0100 Subject: [PATCH 06/58] add network security convars --- config/security.cfg | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 config/security.cfg diff --git a/config/security.cfg b/config/security.cfg new file mode 100644 index 0000000..b022ac5 --- /dev/null +++ b/config/security.cfg @@ -0,0 +1,6 @@ +# Convars de segurança para reduzir abuso de eventos de rede. +sv_enableNetworkedSounds false +sv_enableNetworkedScriptEntityStates false +sv_enableNetworkedPhoneExplosions false +sv_filterRequestControl 2 +sv_endpointPrivacy true From 23cfe55a790931a92de0d0dc29d49f02d472266f Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:11 +0100 Subject: [PATCH 07/58] add voice configuration --- config/voice.cfg | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 config/voice.cfg diff --git a/config/voice.cfg b/config/voice.cfg new file mode 100644 index 0000000..13c229c --- /dev/null +++ b/config/voice.cfg @@ -0,0 +1,4 @@ +# pma-voice +setr voice_enableRadioAnim 1 +setr voice_useNativeAudio true +setr voice_useSendingRangeOnly true From 3c14d10f0387a50bcabff73019b47e5d827f08f3 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:25 +0100 Subject: [PATCH 08/58] add Soul Project ACE hierarchy --- config/permissions.cfg | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 config/permissions.cfg diff --git a/config/permissions.cfg b/config/permissions.cfg new file mode 100644 index 0000000..a475851 --- /dev/null +++ b/config/permissions.cfg @@ -0,0 +1,28 @@ +# Hierarquia Soul Project +add_principal group.owner group.admin +add_principal group.admin group.mod +add_principal group.mod group.support +add_principal group.admin group.user +add_principal group.dev group.user + +# Administração geral +add_ace group.owner command allow +add_ace group.admin command allow +add_ace group.admin command.quit deny + +# Desenvolvimento controlado +add_ace group.dev command.refresh allow +add_ace group.dev command.start allow +add_ace group.dev command.stop allow +add_ace group.dev command.restart allow +add_ace group.dev command.ensure allow +add_ace group.dev command.sp_health allow + +# Suporte e moderação +add_ace group.support command.sp_health allow + +# Permissões necessárias ao es_extended. +add_ace resource.es_extended command.add_ace allow +add_ace resource.es_extended command.add_principal allow +add_ace resource.es_extended command.remove_principal allow +add_ace resource.es_extended command.stop allow From 1bcd6813df11770eda08b11c09b0e845225d2343 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:31 +0100 Subject: [PATCH 09/58] add deterministic resource order --- config/resources.cfg | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 config/resources.cfg diff --git a/config/resources.cfg b/config/resources.cfg new file mode 100644 index 0000000..f1fca74 --- /dev/null +++ b/config/resources.cfg @@ -0,0 +1,23 @@ +# Cfx.re / sistema +ensure chat +ensure hardcap + +# Persistência e voz +ensure oxmysql +ensure pma-voice +ensure bob74_ipl + +# ESX Legacy +ensure esx_lib +ensure es_extended +ensure [core] +ensure [esx_addons] + +# Soul Project +ensure [sp_core] +ensure [sp_ui] +ensure [sp_jobs] +ensure [sp_illegal] +ensure [sp_maps] +ensure [sp_vehicles] +ensure [sp_custom] From bc89eb2279af167ca7cb8459ba483c824440dd03 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:37 +0100 Subject: [PATCH 10/58] add safe secrets template --- config/secrets.example.cfg | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 config/secrets.example.cfg diff --git a/config/secrets.example.cfg b/config/secrets.example.cfg new file mode 100644 index 0000000..235a510 --- /dev/null +++ b/config/secrets.example.cfg @@ -0,0 +1,8 @@ +# Copiar para config/secrets.cfg e nunca versionar o ficheiro real. + +# Obrigatório para a política Steam-only. +set steam_webApiKey "COLOCAR_STEAM_WEB_API_KEY" + +# Exemplos de segredos futuros, manter comentados até serem usados. +# setr sp:discord_bot_token "COLOCAR_TOKEN" +# setr sp:discord_application_id "COLOCAR_APPLICATION_ID" From 459172fe87b6c6c28e4fa6117e0ee5e9ae3d2adf Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:47 +0100 Subject: [PATCH 11/58] add migration registry --- database/000_sp_base.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 database/000_sp_base.sql diff --git a/database/000_sp_base.sql b/database/000_sp_base.sql new file mode 100644 index 0000000..d6d2e76 --- /dev/null +++ b/database/000_sp_base.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS `sp_schema_migrations` ( + `id` varchar(100) NOT NULL, + `description` varchar(255) NOT NULL, + `checksum` char(64) DEFAULT NULL, + `applied_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `sp_schema_migrations` (`id`, `description`) +VALUES ('000_sp_base', 'Inicialização da base Soul Project ESX Legacy'); From cde8509783c4990f25df560ed2e0f543d9d25a74 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:17:59 +0100 Subject: [PATCH 12/58] add installation guide --- docs/INSTALL.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/INSTALL.md diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..9a0110b --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,37 @@ +# Instalação + +## Requisitos + +- FXServer atualizado com txAdmin; +- MariaDB ou MySQL acessível pelo servidor; +- chave de licença Cfx.re; +- Steam Web API Key para a política Steam-only. + +## Deploy recomendado + +1. No txAdmin, cria um novo servidor com **Remote URL Template**. +2. Introduz `https://raw.githubusercontent.com/SoulBrotherDev/ESX-LEGACY/main/recipe.yaml`. +3. Preenche o nome, licença Cfx.re, número máximo de jogadores e base de dados. +4. Conclui a recipe e abre `config/secrets.cfg`. +5. Substitui `COLOCAR_STEAM_WEB_API_KEY` pela chave Steam real. +6. Arranca o servidor e executa `sp_health` na consola. + +## Primeiro teste + +Confirma no arranque: + +- `oxmysql` em estado `started`; +- `es_extended` em estado `started`; +- `sp_bootstrap` em estado `started`; +- entrada recusada quando o cliente não apresenta identificador `steam:`; +- criação da tabela `sp_schema_migrations`. + +## Produção + +Altera em `config/core.cfg`: + +```cfg +setr sp:environment "production" +``` + +Mantém `sv_enforceGameBuild` comentado até o conjunto completo de mapas e veículos ser validado nesse build. From cb541a7c85f5f98ff33bb25161d49ec8ae8d45e1 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:10 +0100 Subject: [PATCH 13/58] document resource architecture --- docs/ARCHITECTURE.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5ea04da --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,33 @@ +# Arquitetura + +## Princípio + +O repositório guarda configuração e código Soul Project. Dependências externas são instaladas pela recipe, evitando forks silenciosos e simplificando atualizações. + +## Camadas + +1. **Cfx.re**: recursos de sistema e hardcap. +2. **Persistência**: `oxmysql`. +3. **Framework**: `esx_lib`, `es_extended` e `[core]`. +4. **Addons ESX**: pacote oficial `[esx_addons]`. +5. **Soul Project Core**: serviços transversais, permissões e integrações. +6. **Domínios Soul Project**: UI, trabalhos, crime, mapas, veículos e custom. + +## Convenções + +- todos os recursos próprios começam por `sp_`; +- eventos seguem `sp_recurso:contexto:acao`; +- callbacks e exports públicos são documentados no README do recurso; +- o servidor valida sempre permissões, propriedade e montantes; +- a NUI nunca é fonte de verdade; +- SQL novo entra numa migração numerada e cumulativa. + +## Grupos + +- `[sp_core]`: identidade técnica, permissões, bridge e serviços partilhados; +- `[sp_ui]`: HUD, menus, identidade e componentes NUI; +- `[sp_jobs]`: trabalhos legais e entidades públicas; +- `[sp_illegal]`: gangs, territórios, laboratórios e contratos; +- `[sp_maps]`: IPLs, MLOs, mapas e loaders; +- `[sp_vehicles]`: veículos, danos, persistência, lojas e handling; +- `[sp_custom]`: integrações temporárias ou transversais. From c5d9d1962fca719f195f8aa0084c2dd65c087c5a Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:26 +0100 Subject: [PATCH 14/58] add repository validator --- tools/validate.py | 86 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 tools/validate.py diff --git a/tools/validate.py b/tools/validate.py new file mode 100644 index 0000000..f71a2fb --- /dev/null +++ b/tools/validate.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +REQUIRED = [ + "README.md", + "recipe.yaml", + "server.cfg", + "config/resources.cfg", + "config/security.cfg", + "config/secrets.example.cfg", + "database/000_sp_base.sql", + "resources/[sp_core]/sp_bootstrap/fxmanifest.lua", +] + +SECRET_PATTERNS = { + "Cfx.re license key": re.compile(r"cfxk_[A-Za-z0-9_-]{20,}"), + "MySQL password in URI": re.compile(r"mysql://[^\s:]+:[^\s@{]+@", re.IGNORECASE), + "Discord bot token": re.compile(r"[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,}"), +} + +TEXT_SUFFIXES = {".cfg", ".json", ".lua", ".md", ".py", ".sql", ".yaml", ".yml"} + + +def fail(message: str) -> None: + print(f"[FAIL] {message}") + + +def main() -> int: + errors = 0 + + for relative in REQUIRED: + if not (ROOT / relative).exists(): + fail(f"ficheiro obrigatório em falta: {relative}") + errors += 1 + + resources_path = ROOT / "config/resources.cfg" + if resources_path.exists(): + resources_cfg = resources_path.read_text(encoding="utf-8") + ordered = ["ensure oxmysql", "ensure es_extended", "ensure [core]", "ensure [sp_core]"] + positions = [resources_cfg.find(item) for item in ordered] + if any(position < 0 for position in positions) or positions != sorted(positions): + fail("ordem de arranque inválida em config/resources.cfg") + errors += 1 + + recipe_path = ROOT / "recipe.yaml" + if recipe_path.exists(): + recipe = recipe_path.read_text(encoding="utf-8") + for source in ( + "esx-framework/esx-core", + "esx-framework/ESX-Legacy-Addons", + "overextended/oxmysql", + ): + if source not in recipe: + fail(f"dependência ausente da recipe: {source}") + errors += 1 + + for path in ROOT.rglob("*"): + if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES: + continue + if path.name == "secrets.example.cfg": + continue + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for label, pattern in SECRET_PATTERNS.items(): + if pattern.search(content): + fail(f"possível {label} em {path.relative_to(ROOT)}") + errors += 1 + + if errors: + print(f"\nValidação terminou com {errors} erro(s).") + return 1 + + print("[OK] Estrutura, ordem de arranque e segredos validados.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9c244ff9552f922e6d118944ee62a9efff2724c8 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:35 +0100 Subject: [PATCH 15/58] add GitHub validation workflow --- .github/workflows/validate.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/workflows/validate.yml diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..d4da1e3 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,26 @@ +name: Validate ESX base + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Validate repository + run: python tools/validate.py + + - name: Check whitespace + run: git diff --check From 67aad3d27f147ba03ed7706507eaaa4fa2227765 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:44 +0100 Subject: [PATCH 16/58] add sp_bootstrap manifest --- .../[sp_core]/sp_bootstrap/fxmanifest.lua | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 resources/[sp_core]/sp_bootstrap/fxmanifest.lua diff --git a/resources/[sp_core]/sp_bootstrap/fxmanifest.lua b/resources/[sp_core]/sp_bootstrap/fxmanifest.lua new file mode 100644 index 0000000..6f2f72c --- /dev/null +++ b/resources/[sp_core]/sp_bootstrap/fxmanifest.lua @@ -0,0 +1,20 @@ +fx_version 'cerulean' +game 'gta5' + +name 'sp_bootstrap' +author 'Soul Project' +description 'Bootstrap, gate Steam-only e diagnóstico da base Soul Project ESX Legacy' +version '0.1.0' + +shared_scripts { + 'config.lua', + 'shared/logger.lua' +} + +client_script 'client/main.lua' +server_script 'server/main.lua' + +dependencies { + 'oxmysql', + 'es_extended' +} From 4f5f59b6be52b6e0f11118903ac7be768a279b11 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:18:54 +0100 Subject: [PATCH 17/58] configure sp_bootstrap --- resources/[sp_core]/sp_bootstrap/config.lua | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 resources/[sp_core]/sp_bootstrap/config.lua diff --git a/resources/[sp_core]/sp_bootstrap/config.lua b/resources/[sp_core]/sp_bootstrap/config.lua new file mode 100644 index 0000000..3ff13a5 --- /dev/null +++ b/resources/[sp_core]/sp_bootstrap/config.lua @@ -0,0 +1,18 @@ +Config = {} + +Config.Locale = GetConvar('sp:locale', 'pt-PT') +Config.Environment = GetConvar('sp:environment', 'development') +Config.SteamOnly = GetConvarInt('sp:steam_only', 1) == 1 +Config.HealthCommand = 'sp_health' + +Config.RequiredResources = { + 'oxmysql', + 'es_extended' +} + +Config.SteamRequiredMessage = table.concat({ + 'SOUL PROJECT', + '', + 'É obrigatório ter a Steam aberta e associada ao FiveM.', + 'Fecha o FiveM, inicia sessão na Steam e tenta novamente.' +}, '\n') From 5a1f18910dd87a3f62dd85995fc76157e0863f8c Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:19:05 +0100 Subject: [PATCH 18/58] add shared bootstrap logger --- .../[sp_core]/sp_bootstrap/shared/logger.lua | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 resources/[sp_core]/sp_bootstrap/shared/logger.lua diff --git a/resources/[sp_core]/sp_bootstrap/shared/logger.lua b/resources/[sp_core]/sp_bootstrap/shared/logger.lua new file mode 100644 index 0000000..c6aa172 --- /dev/null +++ b/resources/[sp_core]/sp_bootstrap/shared/logger.lua @@ -0,0 +1,27 @@ +SPLogger = SPLogger or {} + +local validLevels = { + debug = true, + info = true, + warn = true, + error = true +} + +local function encodeContext(context) + if context == nil then + return '' + end + + local ok, encoded = pcall(json.encode, context) + if not ok then + return ' context=' + end + + return (' context=%s'):format(encoded) +end + +function SPLogger.Log(level, message, context) + local normalizedLevel = validLevels[level] and level or 'info' + local prefix = ('[sp_bootstrap][%s][%s]'):format(Config.Environment, normalizedLevel:upper()) + print(('%s %s%s'):format(prefix, tostring(message), encodeContext(context))) +end From add6c25d773db2170f6f7cb2d119f92778924a3f Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:19:21 +0100 Subject: [PATCH 19/58] implement Steam gate and health checks --- .../[sp_core]/sp_bootstrap/server/main.lua | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 resources/[sp_core]/sp_bootstrap/server/main.lua diff --git a/resources/[sp_core]/sp_bootstrap/server/main.lua b/resources/[sp_core]/sp_bootstrap/server/main.lua new file mode 100644 index 0000000..702c0c5 --- /dev/null +++ b/resources/[sp_core]/sp_bootstrap/server/main.lua @@ -0,0 +1,91 @@ +local RESOURCE_NAME = GetCurrentResourceName() + +local function getIdentifierByType(source, identifierType) + local prefix = identifierType .. ':' + + for _, identifier in ipairs(GetPlayerIdentifiers(source)) do + if identifier:sub(1, #prefix) == prefix then + return identifier + end + end + + return nil +end + +local function collectHealth() + local health = { + resource = RESOURCE_NAME, + environment = Config.Environment, + steamOnly = Config.SteamOnly, + ok = true, + dependencies = {} + } + + for _, resourceName in ipairs(Config.RequiredResources) do + local state = GetResourceState(resourceName) + health.dependencies[resourceName] = state + + if state ~= 'started' then + health.ok = false + end + end + + return health +end + +AddEventHandler('playerConnecting', function(_, _, deferrals) + if not Config.SteamOnly then + return + end + + local playerSource = source + deferrals.defer() + Wait(0) + + local steamIdentifier = getIdentifierByType(playerSource, 'steam') + if not steamIdentifier then + SPLogger.Log('warn', 'Ligação recusada sem identificador Steam', { source = playerSource }) + deferrals.done(Config.SteamRequiredMessage) + return + end + + deferrals.done() +end) + +RegisterNetEvent('sp_bootstrap:clientReady', function() + local playerSource = source + local player = Player(playerSource) + + if player and player.state then + player.state:set('spReady', true, true) + end +end) + +RegisterCommand(Config.HealthCommand, function(source) + local health = collectHealth() + local level = health.ok and 'info' or 'error' + SPLogger.Log(level, 'Health check executado', health) + + if source > 0 then + TriggerClientEvent('chat:addMessage', source, { + args = { + 'Soul Project', + health.ok and 'Base operacional.' or 'Foram detetadas dependências em falta.' + } + }) + end +end, true) + +exports('GetHealth', collectHealth) + +AddEventHandler('onResourceStart', function(resourceName) + if resourceName ~= RESOURCE_NAME then + return + end + + CreateThread(function() + Wait(1000) + local health = collectHealth() + SPLogger.Log(health.ok and 'info' or 'error', 'Bootstrap inicializado', health) + end) +end) From fefca2c1a99c71222319d7ea3ef7a6dd4170a472 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:19:28 +0100 Subject: [PATCH 20/58] signal client readiness --- resources/[sp_core]/sp_bootstrap/client/main.lua | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 resources/[sp_core]/sp_bootstrap/client/main.lua diff --git a/resources/[sp_core]/sp_bootstrap/client/main.lua b/resources/[sp_core]/sp_bootstrap/client/main.lua new file mode 100644 index 0000000..f6e3824 --- /dev/null +++ b/resources/[sp_core]/sp_bootstrap/client/main.lua @@ -0,0 +1,7 @@ +CreateThread(function() + while not NetworkIsSessionStarted() do + Wait(250) + end + + TriggerServerEvent('sp_bootstrap:clientReady') +end) From 13b3c203750821df70941a550f77e93ae10d1c15 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:19:43 +0100 Subject: [PATCH 21/58] document core resource group --- resources/[sp_core]/README.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 resources/[sp_core]/README.md diff --git a/resources/[sp_core]/README.md b/resources/[sp_core]/README.md new file mode 100644 index 0000000..8c6309c --- /dev/null +++ b/resources/[sp_core]/README.md @@ -0,0 +1,7 @@ +# [sp_core] + +Serviços transversais do Soul Project. Um recurso entra aqui quando é necessário por vários domínios e não representa apenas uma interface ou funcionalidade isolada. + +Incluído inicialmente: + +- `sp_bootstrap`: gate Steam-only, diagnóstico e sinalização de cliente pronto. From 3c995a957c38360e9028a89e77f7eea0d1aba00e Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:19:50 +0100 Subject: [PATCH 22/58] prepare UI resource group --- resources/[sp_ui]/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 resources/[sp_ui]/README.md diff --git a/resources/[sp_ui]/README.md b/resources/[sp_ui]/README.md new file mode 100644 index 0000000..1e05e51 --- /dev/null +++ b/resources/[sp_ui]/README.md @@ -0,0 +1,3 @@ +# [sp_ui] + +Interfaces Soul Project, incluindo HUD, identidade, menus e componentes NUI. A camada visual segue o Style-SP-Cyber-01, mas nunca decide dinheiro, permissões ou propriedade. From f03d5bb976909b44fd5c3b18ea491d03faa8e5e9 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:00 +0100 Subject: [PATCH 23/58] prepare jobs resource group --- resources/[sp_jobs]/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 resources/[sp_jobs]/README.md diff --git a/resources/[sp_jobs]/README.md b/resources/[sp_jobs]/README.md new file mode 100644 index 0000000..546ec8c --- /dev/null +++ b/resources/[sp_jobs]/README.md @@ -0,0 +1,3 @@ +# [sp_jobs] + +Trabalhos legais, entidades públicas e respetivas integrações económicas. Exemplos: polícia, emergência médica, mecânica, agricultura e serviços municipais. From 096237b4ba35288ec890060d2b5d4caf912a3019 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:09 +0100 Subject: [PATCH 24/58] prepare illegal resource group --- resources/[sp_illegal]/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 resources/[sp_illegal]/README.md diff --git a/resources/[sp_illegal]/README.md b/resources/[sp_illegal]/README.md new file mode 100644 index 0000000..bc5c0cd --- /dev/null +++ b/resources/[sp_illegal]/README.md @@ -0,0 +1,3 @@ +# [sp_illegal] + +Sistemas criminais Soul Project: gangs, territórios, laboratórios, contratos, roubos e progressão ilegal. Toda a autoridade financeira e de inventário permanece no servidor. From 1b55469e324f41573636ac14184267170e714cb1 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:16 +0100 Subject: [PATCH 25/58] prepare maps resource group --- resources/[sp_maps]/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 resources/[sp_maps]/README.md diff --git a/resources/[sp_maps]/README.md b/resources/[sp_maps]/README.md new file mode 100644 index 0000000..52ed188 --- /dev/null +++ b/resources/[sp_maps]/README.md @@ -0,0 +1,3 @@ +# [sp_maps] + +IPLs, loaders, MLOs e mapas próprios. Cada pacote deve documentar coordenadas, dependências, colisões conhecidas e impacto de performance. From 510fd69344190b60f8195aca5a7ad951fa4e1310 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:23 +0100 Subject: [PATCH 26/58] prepare vehicles resource group --- resources/[sp_vehicles]/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 resources/[sp_vehicles]/README.md diff --git a/resources/[sp_vehicles]/README.md b/resources/[sp_vehicles]/README.md new file mode 100644 index 0000000..9e509a0 --- /dev/null +++ b/resources/[sp_vehicles]/README.md @@ -0,0 +1,3 @@ +# [sp_vehicles] + +Veículos addon, handling, persistência, danos, lojas, garagens e sistemas de reboque. Cada veículo deve ter spawn único e metadados técnicos documentados. From a37cff7847dfeee647dcb58a7099f2ec0404c539 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:20:31 +0100 Subject: [PATCH 27/58] prepare custom resource group --- resources/[sp_custom]/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 resources/[sp_custom]/README.md diff --git a/resources/[sp_custom]/README.md b/resources/[sp_custom]/README.md new file mode 100644 index 0000000..8962c53 --- /dev/null +++ b/resources/[sp_custom]/README.md @@ -0,0 +1,3 @@ +# [sp_custom] + +Integrações Soul Project sem domínio definitivo. Recursos permanentes devem migrar para uma família específica assim que a responsabilidade estiver clara. From c0a8675ad368b24397e0e32e89f2d390cdf9cd6c Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 18:22:08 +0100 Subject: [PATCH 28/58] fix validator self-match --- tools/validate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/validate.py b/tools/validate.py index f71a2fb..1c3a23c 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -6,6 +6,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +VALIDATOR_PATH = Path(__file__).resolve() REQUIRED = [ "README.md", @@ -63,7 +64,7 @@ def main() -> int: for path in ROOT.rglob("*"): if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES: continue - if path.name == "secrets.example.cfg": + if path.resolve() == VALIDATOR_PATH or path.name == "secrets.example.cfg": continue try: content = path.read_text(encoding="utf-8") From 88072cff99217a71bed16127c238feed876448ba Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:33:39 +0100 Subject: [PATCH 29/58] Configure voice radio and calls --- config/voice.cfg | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/config/voice.cfg b/config/voice.cfg index 13c229c..ce70dd3 100644 --- a/config/voice.cfg +++ b/config/voice.cfg @@ -1,4 +1,18 @@ -# pma-voice -setr voice_enableRadioAnim 1 +# pma-voice: proximidade, rádio e chamadas +setr voice_enableUi 1 +setr voice_enableProximityCycle 1 +setr voice_defaultCycle "F11" +setr voice_defaultVoiceMode 2 setr voice_useNativeAudio true setr voice_useSendingRangeOnly true +setr voice_enableRadios 1 +setr voice_enableCalls 1 +setr voice_enableSubmix 1 +setr voice_enableRadioAnim 1 +setr voice_defaultRadio "LMENU" +setr voice_defaultRadioVolume 35 +setr voice_defaultCallVolume 60 +setr voice_onClickVolume 10 +setr voice_offClickVolume 3 +setr voice_hideEndpoints 1 +setr voice_debugMode 0 From 80c6558454bbb575a24e7f66421e6ca1c043c2ff Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:33:48 +0100 Subject: [PATCH 30/58] Add essential item migration --- database/005_sp_essential_items.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 database/005_sp_essential_items.sql diff --git a/database/005_sp_essential_items.sql b/database/005_sp_essential_items.sql new file mode 100644 index 0000000..13a12a5 --- /dev/null +++ b/database/005_sp_essential_items.sql @@ -0,0 +1,12 @@ +-- Soul Project ESX: itens essenciais reutilizáveis. +INSERT INTO `items` (`name`, `label`, `weight`, `rare`, `can_remove`) VALUES + ('phone', 'Telemóvel', 1, 0, 1), + ('radio', 'Rádio', 1, 0, 1) +ON DUPLICATE KEY UPDATE + `label` = VALUES(`label`), + `weight` = VALUES(`weight`), + `rare` = VALUES(`rare`), + `can_remove` = VALUES(`can_remove`); + +INSERT IGNORE INTO `sp_schema_migrations` (`version`, `description`) +VALUES ('005', 'Itens essenciais: telemóvel e rádio'); From 1a11244827400fdf0cd0913059f369d6b7234e8c Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:34:04 +0100 Subject: [PATCH 31/58] Add phone database migration --- database/010_sp_phone.sql | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 database/010_sp_phone.sql diff --git a/database/010_sp_phone.sql b/database/010_sp_phone.sql new file mode 100644 index 0000000..929e7fb --- /dev/null +++ b/database/010_sp_phone.sql @@ -0,0 +1,60 @@ +-- Soul Project ESX: telefone, contactos, mensagens, chamadas e pedidos de serviço. +CREATE TABLE IF NOT EXISTS `sp_phone_numbers` ( + `identifier` VARCHAR(96) NOT NULL, + `phone_number` VARCHAR(20) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`identifier`), + UNIQUE KEY `uq_sp_phone_number` (`phone_number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sp_phone_contacts` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `owner_identifier` VARCHAR(96) NOT NULL, + `display_name` VARCHAR(64) NOT NULL, + `phone_number` VARCHAR(20) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_sp_phone_contact` (`owner_identifier`, `phone_number`), + KEY `idx_sp_phone_contacts_owner` (`owner_identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sp_phone_messages` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `sender_number` VARCHAR(20) NOT NULL, + `receiver_number` VARCHAR(20) NOT NULL, + `body` VARCHAR(500) NOT NULL, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `read_at` TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_sp_phone_messages_sender` (`sender_number`, `created_at`), + KEY `idx_sp_phone_messages_receiver` (`receiver_number`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sp_phone_calls` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `caller_number` VARCHAR(20) NOT NULL, + `receiver_number` VARCHAR(20) NOT NULL, + `status` ENUM('missed', 'declined', 'completed', 'failed') NOT NULL, + `duration_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sp_phone_calls_caller` (`caller_number`, `created_at`), + KEY `idx_sp_phone_calls_receiver` (`receiver_number`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sp_phone_service_messages` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `service` VARCHAR(32) NOT NULL, + `sender_identifier` VARCHAR(96) NOT NULL, + `sender_number` VARCHAR(20) NOT NULL, + `message` VARCHAR(500) NOT NULL, + `coords` LONGTEXT NULL, + `status` ENUM('open', 'accepted', 'closed') NOT NULL DEFAULT 'open', + `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sp_phone_service_status` (`service`, `status`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `sp_schema_migrations` (`version`, `description`) +VALUES ('010', 'Telefone Soul Project: números, contactos, SMS, chamadas e serviços'); From 9f08a844be342cff3310cb6471fb54768bc79f57 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:34:12 +0100 Subject: [PATCH 32/58] Add starter pack migration --- database/015_sp_starterpack.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 database/015_sp_starterpack.sql diff --git a/database/015_sp_starterpack.sql b/database/015_sp_starterpack.sql new file mode 100644 index 0000000..a9248bf --- /dev/null +++ b/database/015_sp_starterpack.sql @@ -0,0 +1,10 @@ +-- Soul Project ESX: controlo idempotente do kit inicial. +CREATE TABLE IF NOT EXISTS `sp_starter_claims` ( + `identifier` VARCHAR(96) NOT NULL, + `claimed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `payload` LONGTEXT NULL, + PRIMARY KEY (`identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO `sp_schema_migrations` (`version`, `description`) +VALUES ('015', 'Registo idempotente do kit inicial'); From 4e977e43367fd482b08e0603729c45f172064f7e Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:34:28 +0100 Subject: [PATCH 33/58] Add Portuguese job labels migration --- database/020_sp_jobs_pt.sql | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 database/020_sp_jobs_pt.sql diff --git a/database/020_sp_jobs_pt.sql b/database/020_sp_jobs_pt.sql new file mode 100644 index 0000000..02b9acb --- /dev/null +++ b/database/020_sp_jobs_pt.sql @@ -0,0 +1,22 @@ +-- Soul Project ESX: nomenclatura PT-PT para empregos essenciais. +UPDATE `jobs` SET `label` = 'Desempregado' WHERE `name` = 'unemployed'; +UPDATE `jobs` SET `label` = 'Polícia' WHERE `name` = 'police'; +UPDATE `jobs` SET `label` = 'Emergência Médica' WHERE `name` = 'ambulance'; +UPDATE `jobs` SET `label` = 'Mecânico' WHERE `name` = 'mechanic'; +UPDATE `jobs` SET `label` = 'Táxi' WHERE `name` = 'taxi'; +UPDATE `jobs` SET `label` = 'Concessionário' WHERE `name` = 'cardealer'; +UPDATE `jobs` SET `label` = 'Imobiliária' WHERE `name` = 'realestateagent'; + +UPDATE `job_grades` SET `label` = 'Recruta' WHERE `job_name` = 'police' AND `grade` = 0; +UPDATE `job_grades` SET `label` = 'Agente' WHERE `job_name` = 'police' AND `grade` = 1; +UPDATE `job_grades` SET `label` = 'Sargento' WHERE `job_name` = 'police' AND `grade` = 2; +UPDATE `job_grades` SET `label` = 'Tenente' WHERE `job_name` = 'police' AND `grade` = 3; +UPDATE `job_grades` SET `label` = 'Comandante' WHERE `job_name` = 'police' AND `grade` >= 4; + +UPDATE `job_grades` SET `label` = 'Estagiário' WHERE `job_name` = 'ambulance' AND `grade` = 0; +UPDATE `job_grades` SET `label` = 'Paramédico' WHERE `job_name` = 'ambulance' AND `grade` = 1; +UPDATE `job_grades` SET `label` = 'Médico' WHERE `job_name` = 'ambulance' AND `grade` = 2; +UPDATE `job_grades` SET `label` = 'Diretor Clínico' WHERE `job_name` = 'ambulance' AND `grade` >= 3; + +INSERT IGNORE INTO `sp_schema_migrations` (`version`, `description`) +VALUES ('020', 'Nomenclatura PT-PT dos empregos ESX essenciais'); From 3954813387622fbf4cd0e86d9179a683827626a5 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:34:44 +0100 Subject: [PATCH 34/58] Add radio manifest --- resources/[sp_core]/sp_radio/fxmanifest.lua | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 resources/[sp_core]/sp_radio/fxmanifest.lua diff --git a/resources/[sp_core]/sp_radio/fxmanifest.lua b/resources/[sp_core]/sp_radio/fxmanifest.lua new file mode 100644 index 0000000..0a00c48 --- /dev/null +++ b/resources/[sp_core]/sp_radio/fxmanifest.lua @@ -0,0 +1,20 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +author 'Soul Project' +description 'Rádio ESX seguro sobre pma-voice' +version '1.0.0' + +shared_scripts { + '@es_extended/imports.lua', + 'config.lua' +} + +client_script 'client/main.lua' +server_script 'server/main.lua' + +dependencies { + 'es_extended', + 'pma-voice' +} From d62f21df9bed74c57925fa69cdba9ceed708b912 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:34:57 +0100 Subject: [PATCH 35/58] Configure radio channels --- resources/[sp_core]/sp_radio/config.lua | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 resources/[sp_core]/sp_radio/config.lua diff --git a/resources/[sp_core]/sp_radio/config.lua b/resources/[sp_core]/sp_radio/config.lua new file mode 100644 index 0000000..eb98668 --- /dev/null +++ b/resources/[sp_core]/sp_radio/config.lua @@ -0,0 +1,28 @@ +Config = {} + +Config.ItemRequired = true +Config.ItemName = 'radio' +Config.Command = 'radio' +Config.LeaveCommand = 'radiooff' +Config.VolumeCommand = 'radiovol' +Config.DefaultKey = 'F6' +Config.MinimumChannel = 1 +Config.MaximumChannel = 999 +Config.RevalidateSeconds = 10 + +Config.RestrictedRanges = { + { min = 1, max = 49, jobs = { police = true }, label = 'Polícia' }, + { min = 50, max = 99, jobs = { ambulance = true }, label = 'Emergência Médica' }, + { min = 100, max = 149, jobs = { police = true, ambulance = true }, label = 'Emergência conjunta' }, + { min = 150, max = 199, jobs = { mechanic = true }, label = 'Mecânicos' }, + { min = 200, max = 249, jobs = { taxi = true }, label = 'Táxis' } +} + +Config.Messages = { + noItem = 'Precisas de um rádio para usar frequências.', + invalid = 'A frequência indicada não é válida.', + denied = 'Não tens autorização para essa frequência.', + joined = 'Ligado à frequência %s.', + left = 'Rádio desligado.', + volume = 'Volume do rádio definido para %s%%.' +} From fa0519d75545ff552c7c9f5103f24f89a3234d59 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:35:19 +0100 Subject: [PATCH 36/58] Implement secure radio server --- resources/[sp_core]/sp_radio/server/main.lua | 99 ++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 resources/[sp_core]/sp_radio/server/main.lua diff --git a/resources/[sp_core]/sp_radio/server/main.lua b/resources/[sp_core]/sp_radio/server/main.lua new file mode 100644 index 0000000..dd94f42 --- /dev/null +++ b/resources/[sp_core]/sp_radio/server/main.lua @@ -0,0 +1,99 @@ +local ESX = exports['es_extended']:getSharedObject() +local registeredChannels = {} + +local function getRestrictedRange(channel) + for i = 1, #Config.RestrictedRanges do + local range = Config.RestrictedRanges[i] + if channel >= range.min and channel <= range.max then + return range + end + end +end + +local function hasRadio(xPlayer) + if not Config.ItemRequired then + return true + end + + local item = xPlayer.getInventoryItem(Config.ItemName) + return item and (item.count or 0) > 0 +end + +local function canAccess(source, channel) + channel = tonumber(channel) + if not channel or channel < Config.MinimumChannel or channel > Config.MaximumChannel then + return false, Config.Messages.invalid + end + + local xPlayer = ESX.GetPlayerFromId(source) + if not xPlayer then + return false, Config.Messages.denied + end + + if not hasRadio(xPlayer) then + return false, Config.Messages.noItem + end + + local range = getRestrictedRange(channel) + local jobName = xPlayer.job and xPlayer.job.name or 'unemployed' + if range and not range.jobs[jobName] then + return false, Config.Messages.denied + end + + return true +end + +ESX.RegisterServerCallback('sp_radio:server:canAccess', function(source, cb, channel) + local allowed, reason = canAccess(source, channel) + cb(allowed, reason) +end) + +ESX.RegisterUsableItem(Config.ItemName, function(source) + TriggerClientEvent('sp_radio:client:open', source) +end) + +RegisterNetEvent('sp_radio:server:revalidate', function(channel) + local source = source + local allowed, reason = canAccess(source, channel) + if not allowed then + TriggerClientEvent('sp_radio:client:forceLeave', source, reason) + end +end) + +local function registerPmaChecks() + while GetResourceState('pma-voice') ~= 'started' do + Wait(500) + end + + for channel = Config.MinimumChannel, Config.MaximumChannel do + if getRestrictedRange(channel) then + local currentChannel = channel + local ok = pcall(function() + exports['pma-voice']:addChannelCheck(currentChannel, function(source) + local allowed = canAccess(source, currentChannel) + return allowed == true + end) + end) + + if ok then + registeredChannels[#registeredChannels + 1] = currentChannel + end + end + end +end + +CreateThread(registerPmaChecks) + +AddEventHandler('onResourceStop', function(resourceName) + if resourceName ~= GetCurrentResourceName() or GetResourceState('pma-voice') ~= 'started' then + return + end + + for i = 1, #registeredChannels do + pcall(function() + exports['pma-voice']:removeChannelCheck(registeredChannels[i]) + end) + end +end) + +exports('CanAccessChannel', canAccess) From f7313f2fd9607e26542ac69d63f5e44feca78734 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:35:40 +0100 Subject: [PATCH 37/58] Implement radio client --- resources/[sp_core]/sp_radio/client/main.lua | 114 +++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 resources/[sp_core]/sp_radio/client/main.lua diff --git a/resources/[sp_core]/sp_radio/client/main.lua b/resources/[sp_core]/sp_radio/client/main.lua new file mode 100644 index 0000000..3c6d612 --- /dev/null +++ b/resources/[sp_core]/sp_radio/client/main.lua @@ -0,0 +1,114 @@ +local ESX = exports['es_extended']:getSharedObject() +local currentChannel = 0 + +local function notify(message) + ESX.ShowNotification(message) +end + +local function keyboardInput(title, defaultText, maxLength) + AddTextEntry('SP_RADIO_INPUT', title) + DisplayOnscreenKeyboard(1, 'SP_RADIO_INPUT', '', defaultText or '', '', '', '', maxLength or 6) + + while UpdateOnscreenKeyboard() == 0 do + DisableAllControlActions(0) + Wait(0) + end + + if UpdateOnscreenKeyboard() == 1 then + return GetOnscreenKeyboardResult() + end +end + +local function leaveRadio(message) + exports['pma-voice']:setRadioChannel(0) + currentChannel = 0 + LocalPlayer.state:set('spRadioChannel', 0, true) + notify(message or Config.Messages.left) +end + +local function joinRadio(channel) + channel = math.floor(tonumber(channel) or 0) + if channel < Config.MinimumChannel or channel > Config.MaximumChannel then + notify(Config.Messages.invalid) + return + end + + ESX.TriggerServerCallback('sp_radio:server:canAccess', function(allowed, reason) + if not allowed then + notify(reason or Config.Messages.denied) + return + end + + exports['pma-voice']:setRadioChannel(channel) + currentChannel = channel + LocalPlayer.state:set('spRadioChannel', channel, true) + notify(Config.Messages.joined:format(channel)) + end, channel) +end + +local function openRadioInput() + local value = keyboardInput('Frequência do rádio', currentChannel > 0 and tostring(currentChannel) or '', 6) + if value and value ~= '' then + joinRadio(value) + end +end + +RegisterCommand(Config.Command, function(_, args) + if args[1] then + joinRadio(args[1]) + else + openRadioInput() + end +end, false) + +RegisterKeyMapping(Config.Command, 'Abrir rádio', 'keyboard', Config.DefaultKey) + +RegisterCommand(Config.LeaveCommand, function() + leaveRadio() +end, false) + +RegisterCommand(Config.VolumeCommand, function(_, args) + local volume = math.floor(tonumber(args[1]) or -1) + if volume < 1 or volume > 100 then + notify('Usa /' .. Config.VolumeCommand .. ' 1-100.') + return + end + + exports['pma-voice']:setRadioVolume(volume) + notify(Config.Messages.volume:format(volume)) +end, false) + +RegisterNetEvent('sp_radio:client:open', openRadioInput) + +RegisterNetEvent('sp_radio:client:forceLeave', function(reason) + if currentChannel > 0 then + leaveRadio(reason) + end +end) + +RegisterNetEvent('esx:setJob', function() + if currentChannel > 0 then + TriggerServerEvent('sp_radio:server:revalidate', currentChannel) + end +end) + +CreateThread(function() + while true do + Wait(Config.RevalidateSeconds * 1000) + if currentChannel > 0 then + TriggerServerEvent('sp_radio:server:revalidate', currentChannel) + end + end +end) + +AddEventHandler('onResourceStop', function(resourceName) + if resourceName == GetCurrentResourceName() and currentChannel > 0 then + exports['pma-voice']:setRadioChannel(0) + end +end) + +exports('JoinChannel', joinRadio) +exports('LeaveChannel', leaveRadio) +exports('GetChannel', function() + return currentChannel +end) From b82d6d9912f1b8ff064a4d91d1957ac0668a6da3 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:35:54 +0100 Subject: [PATCH 38/58] Add starter pack manifest --- .../[sp_core]/sp_starterpack/fxmanifest.lua | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 resources/[sp_core]/sp_starterpack/fxmanifest.lua diff --git a/resources/[sp_core]/sp_starterpack/fxmanifest.lua b/resources/[sp_core]/sp_starterpack/fxmanifest.lua new file mode 100644 index 0000000..a9baa24 --- /dev/null +++ b/resources/[sp_core]/sp_starterpack/fxmanifest.lua @@ -0,0 +1,22 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +author 'Soul Project' +description 'Kit inicial idempotente para ESX Legacy' +version '1.0.0' + +shared_scripts { + '@es_extended/imports.lua', + 'config.lua' +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua' +} + +dependencies { + 'es_extended', + 'oxmysql' +} From 267814644f438f49b74249c2ff64c69826b97ad9 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:36:01 +0100 Subject: [PATCH 39/58] Configure starter pack --- resources/[sp_core]/sp_starterpack/config.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 resources/[sp_core]/sp_starterpack/config.lua diff --git a/resources/[sp_core]/sp_starterpack/config.lua b/resources/[sp_core]/sp_starterpack/config.lua new file mode 100644 index 0000000..fed7093 --- /dev/null +++ b/resources/[sp_core]/sp_starterpack/config.lua @@ -0,0 +1,11 @@ +Config = {} + +Config.Enabled = true +Config.Items = { + { name = 'phone', count = 1 }, + { name = 'radio', count = 1 }, + { name = 'bread', count = 2 }, + { name = 'water', count = 2 } +} + +Config.Notification = 'Recebeste o kit inicial da cidade.' From 169940b21ce32c8d31fb4a8ba090de6d3ad24d4e Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:36:18 +0100 Subject: [PATCH 40/58] Implement starter pack grant --- .../[sp_core]/sp_starterpack/server/main.lua | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 resources/[sp_core]/sp_starterpack/server/main.lua diff --git a/resources/[sp_core]/sp_starterpack/server/main.lua b/resources/[sp_core]/sp_starterpack/server/main.lua new file mode 100644 index 0000000..5816659 --- /dev/null +++ b/resources/[sp_core]/sp_starterpack/server/main.lua @@ -0,0 +1,63 @@ +local ESX = exports['es_extended']:getSharedObject() +local processing = {} + +local function getIdentifier(xPlayer) + if xPlayer.getIdentifier then + return xPlayer.getIdentifier() + end + return xPlayer.identifier +end + +local function grantStarterPack(source, xPlayer) + if not Config.Enabled or processing[source] then + return + end + + xPlayer = xPlayer or ESX.GetPlayerFromId(source) + if not xPlayer then + return + end + + processing[source] = true + local identifier = getIdentifier(xPlayer) + local payload = json.encode(Config.Items) + + local affected = MySQL.update.await( + 'INSERT IGNORE INTO sp_starter_claims (identifier, payload) VALUES (?, ?)', + { identifier, payload } + ) + + if affected and affected > 0 then + for i = 1, #Config.Items do + local entry = Config.Items[i] + local ok, err = pcall(function() + xPlayer.addInventoryItem(entry.name, entry.count) + end) + + if not ok then + print(('[sp_starterpack] Não foi possível entregar %s: %s'):format(entry.name, err)) + end + end + + TriggerClientEvent('esx:showNotification', source, Config.Notification) + end + + processing[source] = nil +end + +AddEventHandler('esx:playerLoaded', function(playerId, xPlayer) + CreateThread(function() + Wait(1500) + grantStarterPack(playerId, xPlayer) + end) +end) + +CreateThread(function() + Wait(2000) + local players = ESX.GetExtendedPlayers() + for i = 1, #players do + grantStarterPack(players[i].source, players[i]) + end +end) + +exports('GrantStarterPack', grantStarterPack) From 7b79367b360c65be5daedc3c14e0b3cb21be3b65 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:36:28 +0100 Subject: [PATCH 41/58] Add phone manifest --- resources/[sp_ui]/sp_phone/fxmanifest.lua | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/fxmanifest.lua diff --git a/resources/[sp_ui]/sp_phone/fxmanifest.lua b/resources/[sp_ui]/sp_phone/fxmanifest.lua new file mode 100644 index 0000000..d10acd7 --- /dev/null +++ b/resources/[sp_ui]/sp_phone/fxmanifest.lua @@ -0,0 +1,33 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +author 'Soul Project' +description 'Telefone ESX com SMS, contactos, chamadas e serviços' +version '1.0.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', + 'html/style.css', + 'html/app.js' +} + +shared_scripts { + '@es_extended/imports.lua', + 'config.lua' +} + +client_script 'client/main.lua' + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua' +} + +dependencies { + 'es_extended', + 'oxmysql', + 'pma-voice' +} From 758f5d6522e57c027a6b09f0a42ebe97c44fb377 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:36:37 +0100 Subject: [PATCH 42/58] Configure phone system --- resources/[sp_ui]/sp_phone/config.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/config.lua diff --git a/resources/[sp_ui]/sp_phone/config.lua b/resources/[sp_ui]/sp_phone/config.lua new file mode 100644 index 0000000..da90d48 --- /dev/null +++ b/resources/[sp_ui]/sp_phone/config.lua @@ -0,0 +1,21 @@ +Config = {} + +Config.Command = 'phone' +Config.DefaultKey = 'F1' +Config.ItemRequired = true +Config.ItemName = 'phone' +Config.NumberPrefix = '555' +Config.NumberDigits = 4 +Config.MaximumMessageLength = 500 +Config.MaximumContacts = 100 +Config.MessageCooldownMs = 750 +Config.CallChannelBase = 70000 +Config.CallTimeoutSeconds = 30 +Config.HistoryLimit = 100 + +Config.Services = { + police = { label = 'Polícia', number = '112', job = 'police' }, + ambulance = { label = 'Emergência Médica', number = '115', job = 'ambulance' }, + mechanic = { label = 'Mecânico', number = '116', job = 'mechanic' }, + taxi = { label = 'Táxi', number = '117', job = 'taxi' } +} From 6ab459466f135b46b54945eb29c4f8fdba2aad39 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:37:53 +0100 Subject: [PATCH 43/58] Implement phone server --- resources/[sp_ui]/sp_phone/server/main.lua | 456 +++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/server/main.lua diff --git a/resources/[sp_ui]/sp_phone/server/main.lua b/resources/[sp_ui]/sp_phone/server/main.lua new file mode 100644 index 0000000..f7a09c6 --- /dev/null +++ b/resources/[sp_ui]/sp_phone/server/main.lua @@ -0,0 +1,456 @@ +local ESX = exports['es_extended']:getSharedObject() +local sourceByNumber = {} +local numberBySource = {} +local callsBySource = {} +local messageRate = {} +local nextCallChannel = Config.CallChannelBase + +math.randomseed(os.time()) + +local function getIdentifier(xPlayer) + if xPlayer.getIdentifier then + return xPlayer.getIdentifier() + end + return xPlayer.identifier +end + +local function hasPhone(xPlayer) + if not Config.ItemRequired then + return true + end + local item = xPlayer.getInventoryItem(Config.ItemName) + return item and (item.count or 0) > 0 +end + +local function cleanNumber(value) + return tostring(value or ''):gsub('[^%d%-]', ''):sub(1, 20) +end + +local function cleanText(value) + local text = tostring(value or ''):gsub('%c', ' ') + text = text:match('^%s*(.-)%s*$') or '' + return text:sub(1, Config.MaximumMessageLength) +end + +local function generatePhoneNumber() + local maximum = (10 ^ Config.NumberDigits) - 1 + local suffix = math.random(0, maximum) + return ('%s%0' .. Config.NumberDigits .. 'd'):format(Config.NumberPrefix, suffix) +end + +local function ensurePhoneNumber(source, xPlayer) + xPlayer = xPlayer or ESX.GetPlayerFromId(source) + if not xPlayer then + return nil + end + + local identifier = getIdentifier(xPlayer) + local number = MySQL.scalar.await('SELECT phone_number FROM sp_phone_numbers WHERE identifier = ?', { identifier }) + + if not number then + for _ = 1, 40 do + local candidate = generatePhoneNumber() + local exists = MySQL.scalar.await('SELECT 1 FROM sp_phone_numbers WHERE phone_number = ?', { candidate }) + if not exists then + local inserted = MySQL.update.await( + 'INSERT IGNORE INTO sp_phone_numbers (identifier, phone_number) VALUES (?, ?)', + { identifier, candidate } + ) + if inserted and inserted > 0 then + number = candidate + break + end + end + end + end + + if number then + numberBySource[source] = number + sourceByNumber[number] = source + end + + return number +end + +local function serviceSnapshot() + local counts = {} + for key in pairs(Config.Services) do + counts[key] = 0 + end + + local players = ESX.GetExtendedPlayers() + for i = 1, #players do + local jobName = players[i].job and players[i].job.name + for key, service in pairs(Config.Services) do + if jobName == service.job then + counts[key] = counts[key] + 1 + end + end + end + + local services = {} + for key, service in pairs(Config.Services) do + services[#services + 1] = { + key = key, + label = service.label, + number = service.number, + online = counts[key] + } + end + table.sort(services, function(a, b) return a.label < b.label end) + return services +end + +local function setPlayerCall(source, channel) + if GetResourceState('pma-voice') ~= 'started' then + return false + end + + return pcall(function() + exports['pma-voice']:setPlayerCall(source, channel) + end) +end + +local function finishCall(call, status) + if not call or call.finished then + return + end + + call.finished = true + local duration = 0 + if call.connectedAt then + duration = math.max(0, os.time() - call.connectedAt) + end + + setPlayerCall(call.caller, 0) + setPlayerCall(call.receiver, 0) + + TriggerClientEvent('sp_phone:client:callEnded', call.caller, status) + TriggerClientEvent('sp_phone:client:callEnded', call.receiver, status) + + callsBySource[call.caller] = nil + callsBySource[call.receiver] = nil + + MySQL.insert.await([[ + INSERT INTO sp_phone_calls (caller_number, receiver_number, status, duration_seconds) + VALUES (?, ?, ?, ?) + ]], { call.callerNumber, call.receiverNumber, status, duration }) +end + +local function callbackError(cb, message) + cb({ ok = false, error = message }) +end + +ESX.RegisterServerCallback('sp_phone:server:canOpen', function(source, cb) + local xPlayer = ESX.GetPlayerFromId(source) + cb(xPlayer ~= nil and hasPhone(xPlayer)) +end) + +ESX.RegisterServerCallback('sp_phone:server:getData', function(source, cb) + local xPlayer = ESX.GetPlayerFromId(source) + if not xPlayer or not hasPhone(xPlayer) then + return callbackError(cb, 'Não tens um telemóvel.') + end + + local identifier = getIdentifier(xPlayer) + local number = ensurePhoneNumber(source, xPlayer) + if not number then + return callbackError(cb, 'Não foi possível atribuir um número.') + end + + local contacts = MySQL.query.await([[ + SELECT id, display_name, phone_number + FROM sp_phone_contacts + WHERE owner_identifier = ? + ORDER BY display_name ASC + ]], { identifier }) + + local messages = MySQL.query.await([[ + SELECT id, sender_number, receiver_number, body, created_at, read_at + FROM sp_phone_messages + WHERE sender_number = ? OR receiver_number = ? + ORDER BY id DESC + LIMIT ? + ]], { number, number, Config.HistoryLimit }) + + local calls = MySQL.query.await([[ + SELECT id, caller_number, receiver_number, status, duration_seconds, created_at + FROM sp_phone_calls + WHERE caller_number = ? OR receiver_number = ? + ORDER BY id DESC + LIMIT 30 + ]], { number, number }) + + MySQL.update.await( + 'UPDATE sp_phone_messages SET read_at = CURRENT_TIMESTAMP WHERE receiver_number = ? AND read_at IS NULL', + { number } + ) + + cb({ + ok = true, + number = number, + contacts = contacts, + messages = messages, + calls = calls, + services = serviceSnapshot() + }) +end) + +ESX.RegisterServerCallback('sp_phone:server:sendMessage', function(source, cb, targetNumber, body) + local xPlayer = ESX.GetPlayerFromId(source) + if not xPlayer or not hasPhone(xPlayer) then + return callbackError(cb, 'Não tens um telemóvel.') + end + + local now = GetGameTimer() + if messageRate[source] and now - messageRate[source] < Config.MessageCooldownMs then + return callbackError(cb, 'Estás a enviar mensagens demasiado depressa.') + end + messageRate[source] = now + + local senderNumber = ensurePhoneNumber(source, xPlayer) + targetNumber = cleanNumber(targetNumber) + body = cleanText(body) + + if targetNumber == '' or body == '' then + return callbackError(cb, 'Número ou mensagem inválida.') + end + + if targetNumber == senderNumber then + return callbackError(cb, 'Não podes enviar mensagens para ti próprio.') + end + + local targetExists = MySQL.scalar.await('SELECT 1 FROM sp_phone_numbers WHERE phone_number = ?', { targetNumber }) + if not targetExists then + return callbackError(cb, 'Esse número não existe.') + end + + local messageId = MySQL.insert.await([[ + INSERT INTO sp_phone_messages (sender_number, receiver_number, body) + VALUES (?, ?, ?) + ]], { senderNumber, targetNumber, body }) + + local targetSource = sourceByNumber[targetNumber] + if targetSource then + TriggerClientEvent('sp_phone:client:message', targetSource, { + id = messageId, + sender_number = senderNumber, + receiver_number = targetNumber, + body = body + }) + end + + cb({ ok = true, id = messageId }) +end) + +ESX.RegisterServerCallback('sp_phone:server:addContact', function(source, cb, displayName, phoneNumber) + local xPlayer = ESX.GetPlayerFromId(source) + if not xPlayer then + return callbackError(cb, 'Jogador inválido.') + end + + displayName = cleanText(displayName):sub(1, 64) + phoneNumber = cleanNumber(phoneNumber) + if displayName == '' or phoneNumber == '' then + return callbackError(cb, 'Contacto inválido.') + end + + local identifier = getIdentifier(xPlayer) + local count = MySQL.scalar.await('SELECT COUNT(*) FROM sp_phone_contacts WHERE owner_identifier = ?', { identifier }) or 0 + if tonumber(count) >= Config.MaximumContacts then + return callbackError(cb, 'A lista de contactos está cheia.') + end + + MySQL.update.await([[ + INSERT INTO sp_phone_contacts (owner_identifier, display_name, phone_number) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE display_name = VALUES(display_name) + ]], { identifier, displayName, phoneNumber }) + + cb({ ok = true }) +end) + +ESX.RegisterServerCallback('sp_phone:server:deleteContact', function(source, cb, contactId) + local xPlayer = ESX.GetPlayerFromId(source) + if not xPlayer then + return callbackError(cb, 'Jogador inválido.') + end + + MySQL.update.await( + 'DELETE FROM sp_phone_contacts WHERE id = ? AND owner_identifier = ?', + { tonumber(contactId) or 0, getIdentifier(xPlayer) } + ) + cb({ ok = true }) +end) + +ESX.RegisterServerCallback('sp_phone:server:sendService', function(source, cb, serviceKey, body) + local xPlayer = ESX.GetPlayerFromId(source) + local service = Config.Services[tostring(serviceKey or '')] + if not xPlayer or not service then + return callbackError(cb, 'Serviço inválido.') + end + + body = cleanText(body) + if body == '' then + return callbackError(cb, 'Escreve uma descrição do pedido.') + end + + local number = ensurePhoneNumber(source, xPlayer) + local ped = GetPlayerPed(source) + local coords = ped > 0 and GetEntityCoords(ped) or vector3(0.0, 0.0, 0.0) + local encodedCoords = json.encode({ x = coords.x, y = coords.y, z = coords.z }) + + local requestId = MySQL.insert.await([[ + INSERT INTO sp_phone_service_messages + (service, sender_identifier, sender_number, message, coords) + VALUES (?, ?, ?, ?, ?) + ]], { serviceKey, getIdentifier(xPlayer), number, body, encodedCoords }) + + local players = ESX.GetExtendedPlayers() + for i = 1, #players do + if players[i].job and players[i].job.name == service.job then + TriggerClientEvent('sp_phone:client:serviceDispatch', players[i].source, { + id = requestId, + service = serviceKey, + label = service.label, + sender = number, + message = body, + coords = { x = coords.x, y = coords.y, z = coords.z } + }) + end + end + + cb({ ok = true, id = requestId }) +end) + +ESX.RegisterServerCallback('sp_phone:server:startCall', function(source, cb, targetNumber) + local xPlayer = ESX.GetPlayerFromId(source) + if not xPlayer or not hasPhone(xPlayer) then + return callbackError(cb, 'Não tens um telemóvel.') + end + + local callerNumber = ensurePhoneNumber(source, xPlayer) + targetNumber = cleanNumber(targetNumber) + local targetSource = sourceByNumber[targetNumber] + + if not targetSource or GetPlayerPing(targetSource) <= 0 then + MySQL.insert.await([[ + INSERT INTO sp_phone_calls (caller_number, receiver_number, status) + VALUES (?, ?, 'missed') + ]], { callerNumber, targetNumber }) + return callbackError(cb, 'O destinatário está indisponível.') + end + + if targetSource == source or callsBySource[source] or callsBySource[targetSource] then + return callbackError(cb, 'A linha está ocupada.') + end + + nextCallChannel = nextCallChannel + 1 + local call = { + caller = source, + receiver = targetSource, + callerNumber = callerNumber, + receiverNumber = targetNumber, + channel = nextCallChannel, + state = 'ringing', + createdAt = os.time() + } + + callsBySource[source] = call + callsBySource[targetSource] = call + + TriggerClientEvent('sp_phone:client:outgoingCall', source, targetNumber) + TriggerClientEvent('sp_phone:client:incomingCall', targetSource, callerNumber) + + CreateThread(function() + Wait(Config.CallTimeoutSeconds * 1000) + if not call.finished and call.state == 'ringing' then + finishCall(call, 'missed') + end + end) + + cb({ ok = true }) +end) + +ESX.RegisterServerCallback('sp_phone:server:answerCall', function(source, cb) + local call = callsBySource[source] + if not call or call.receiver ~= source or call.state ~= 'ringing' then + return callbackError(cb, 'Não existe uma chamada para atender.') + end + + call.state = 'connected' + call.connectedAt = os.time() + local callerOk = setPlayerCall(call.caller, call.channel) + local receiverOk = setPlayerCall(call.receiver, call.channel) + + if not callerOk or not receiverOk then + finishCall(call, 'failed') + return callbackError(cb, 'Falha ao ligar o canal de voz.') + end + + TriggerClientEvent('sp_phone:client:callConnected', call.caller, call.receiverNumber) + TriggerClientEvent('sp_phone:client:callConnected', call.receiver, call.callerNumber) + cb({ ok = true }) +end) + +ESX.RegisterServerCallback('sp_phone:server:declineCall', function(source, cb) + local call = callsBySource[source] + if not call or call.receiver ~= source or call.state ~= 'ringing' then + return callbackError(cb, 'Não existe uma chamada para rejeitar.') + end + + finishCall(call, 'declined') + cb({ ok = true }) +end) + +ESX.RegisterServerCallback('sp_phone:server:hangupCall', function(source, cb) + local call = callsBySource[source] + if not call then + return callbackError(cb, 'Não existe uma chamada ativa.') + end + + finishCall(call, call.state == 'connected' and 'completed' or 'declined') + cb({ ok = true }) +end) + +ESX.RegisterUsableItem(Config.ItemName, function(source) + TriggerClientEvent('sp_phone:client:open', source) +end) + +AddEventHandler('esx:playerLoaded', function(playerId, xPlayer) + CreateThread(function() + Wait(1000) + ensurePhoneNumber(playerId, xPlayer) + end) +end) + +AddEventHandler('playerDropped', function() + local source = source + local number = numberBySource[source] + if number then + sourceByNumber[number] = nil + numberBySource[source] = nil + end + + local call = callsBySource[source] + if call then + finishCall(call, call.state == 'connected' and 'completed' or 'missed') + end + messageRate[source] = nil +end) + +CreateThread(function() + Wait(2000) + local players = ESX.GetExtendedPlayers() + for i = 1, #players do + ensurePhoneNumber(players[i].source, players[i]) + end +end) + +exports('GetPhoneNumber', function(source) + return numberBySource[source] or ensurePhoneNumber(source) +end) + +exports('GetSourceFromNumber', function(number) + return sourceByNumber[cleanNumber(number)] +end) From badf83b82aa118fe4d97588f89cc95dd72a05d6e Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:38:24 +0100 Subject: [PATCH 44/58] Implement phone client bridge --- resources/[sp_ui]/sp_phone/client/main.lua | 153 +++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/client/main.lua diff --git a/resources/[sp_ui]/sp_phone/client/main.lua b/resources/[sp_ui]/sp_phone/client/main.lua new file mode 100644 index 0000000..a6f625a --- /dev/null +++ b/resources/[sp_ui]/sp_phone/client/main.lua @@ -0,0 +1,153 @@ +local ESX = exports['es_extended']:getSharedObject() +local isOpen = false + +local function notify(message) + ESX.ShowNotification(message) +end + +local function closePhone() + if not isOpen then + return + end + + isOpen = false + SetNuiFocus(false, false) + SendNUIMessage({ action = 'close' }) +end + +local function fetchPhoneData(callback) + ESX.TriggerServerCallback('sp_phone:server:getData', function(result) + if not result or not result.ok then + notify(result and result.error or 'Não foi possível carregar o telemóvel.') + if callback then callback(nil) end + return + end + if callback then callback(result) end + end) +end + +local function openPhone(extraMessage) + ESX.TriggerServerCallback('sp_phone:server:canOpen', function(allowed) + if not allowed then + notify('Não tens um telemóvel.') + return + end + + fetchPhoneData(function(data) + if not data then return end + isOpen = true + SetNuiFocus(true, true) + SendNUIMessage({ action = 'open', data = data }) + if extraMessage then + SendNUIMessage(extraMessage) + end + end) + end) +end + +local function serverCallback(name, nuiCallback, ...) + ESX.TriggerServerCallback(name, function(result) + nuiCallback(result or { ok = false, error = 'Sem resposta do servidor.' }) + end, ...) +end + +RegisterCommand(Config.Command, function() + if isOpen then + closePhone() + else + openPhone() + end +end, false) + +RegisterKeyMapping(Config.Command, 'Abrir telemóvel', 'keyboard', Config.DefaultKey) + +RegisterNetEvent('sp_phone:client:open', function() + openPhone() +end) + +RegisterNUICallback('close', function(_, cb) + closePhone() + cb({ ok = true }) +end) + +RegisterNUICallback('refresh', function(_, cb) + fetchPhoneData(function(data) + cb(data or { ok = false }) + end) +end) + +RegisterNUICallback('sendMessage', function(data, cb) + serverCallback('sp_phone:server:sendMessage', cb, data.number, data.body) +end) + +RegisterNUICallback('addContact', function(data, cb) + serverCallback('sp_phone:server:addContact', cb, data.name, data.number) +end) + +RegisterNUICallback('deleteContact', function(data, cb) + serverCallback('sp_phone:server:deleteContact', cb, data.id) +end) + +RegisterNUICallback('startCall', function(data, cb) + serverCallback('sp_phone:server:startCall', cb, data.number) +end) + +RegisterNUICallback('answerCall', function(_, cb) + serverCallback('sp_phone:server:answerCall', cb) +end) + +RegisterNUICallback('declineCall', function(_, cb) + serverCallback('sp_phone:server:declineCall', cb) +end) + +RegisterNUICallback('hangupCall', function(_, cb) + serverCallback('sp_phone:server:hangupCall', cb) +end) + +RegisterNUICallback('sendService', function(data, cb) + serverCallback('sp_phone:server:sendService', cb, data.service, data.body) +end) + +RegisterNetEvent('sp_phone:client:message', function(message) + if isOpen then + SendNUIMessage({ action = 'message', data = message }) + else + notify(('Nova mensagem de %s'):format(message.sender_number)) + end +end) + +RegisterNetEvent('sp_phone:client:incomingCall', function(number) + openPhone({ action = 'incomingCall', number = number }) +end) + +RegisterNetEvent('sp_phone:client:outgoingCall', function(number) + if isOpen then + SendNUIMessage({ action = 'outgoingCall', number = number }) + end +end) + +RegisterNetEvent('sp_phone:client:callConnected', function(number) + if isOpen then + SendNUIMessage({ action = 'callConnected', number = number }) + end + notify(('Chamada ligada com %s.'):format(number)) +end) + +RegisterNetEvent('sp_phone:client:callEnded', function(status) + if isOpen then + SendNUIMessage({ action = 'callEnded', status = status }) + end +end) + +RegisterNetEvent('sp_phone:client:serviceDispatch', function(dispatch) + notify(('[%s] %s: %s'):format(dispatch.label, dispatch.sender, dispatch.message)) + if isOpen then + SendNUIMessage({ action = 'serviceDispatch', data = dispatch }) + end +end) + +AddEventHandler('onResourceStop', function(resourceName) + if resourceName == GetCurrentResourceName() then + SetNuiFocus(false, false) + end +end) From 6d330bd68519c9679bdc6c7b0028fccdec452fec Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:38:47 +0100 Subject: [PATCH 45/58] Add phone interface markup --- resources/[sp_ui]/sp_phone/html/index.html | 84 ++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/html/index.html diff --git a/resources/[sp_ui]/sp_phone/html/index.html b/resources/[sp_ui]/sp_phone/html/index.html new file mode 100644 index 0000000..b68244f --- /dev/null +++ b/resources/[sp_ui]/sp_phone/html/index.html @@ -0,0 +1,84 @@ + + + + + + Soul Project Phone + + + +
+
+
+ SOUL NETWORK + SEM REDE +
+ +
+ + + + + +
+
+ +
+ +
Seleciona uma conversa.
+
+ + +
+
+
+
+ +
+
+

Novo contacto

+ + + +
+
+
+ +
+
+ +

A localização atual é anexada automaticamente ao pedido.

+
+ +
+
+
+ +
+ ESX · PMA VOICE + 00:00 +
+ + +
+ + + From 3fbbf6b47c57debf2de19a074d2442ce3de77b6a Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:39:27 +0100 Subject: [PATCH 46/58] Style phone interface --- resources/[sp_ui]/sp_phone/html/style.css | 197 ++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/html/style.css diff --git a/resources/[sp_ui]/sp_phone/html/style.css b/resources/[sp_ui]/sp_phone/html/style.css new file mode 100644 index 0000000..2815fc6 --- /dev/null +++ b/resources/[sp_ui]/sp_phone/html/style.css @@ -0,0 +1,197 @@ +:root { + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #f4f4f4; + background: transparent; + color-scheme: dark; +} + +* { box-sizing: border-box; } +html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; } +button, input, textarea { font: inherit; } +button { cursor: pointer; } +.hidden { display: none !important; } + +.phone { + position: absolute; + right: 3.2vw; + bottom: 4.2vh; + width: min(430px, 34vw); + height: min(760px, 86vh); + display: grid; + grid-template-rows: auto auto 1fr auto; + overflow: hidden; + border: 1px solid rgba(240, 213, 138, .62); + border-radius: 26px; + background: + linear-gradient(135deg, rgba(202, 162, 77, .08), transparent 38%), + radial-gradient(circle at 84% 5%, rgba(0, 209, 255, .12), transparent 28%), + rgba(4, 4, 4, .97); + box-shadow: 0 28px 80px rgba(0, 0, 0, .7), inset 0 0 0 3px rgba(151, 154, 159, .08); +} + +.phone::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + opacity: .22; + background-image: + linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px); + background-size: 24px 24px; +} + +.phone-header, .phone-footer, .tabs, .view { position: relative; z-index: 1; } +.phone-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 22px 14px; + border-bottom: 1px solid rgba(151,154,159,.18); +} +.phone-header strong { display: block; font-size: 1.12rem; letter-spacing: .12em; color: #f0d58a; } +.eyebrow { display: block; margin-bottom: 4px; font-size: .64rem; letter-spacing: .24em; color: #979a9f; } +.icon-button { + width: 36px; + height: 36px; + border: 1px solid rgba(202,162,77,.35); + border-radius: 12px; + color: #f0d58a; + background: rgba(202,162,77,.08); +} +.icon-button:hover { background: rgba(202,162,77,.2); } + +.tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; + padding: 10px 12px; + border-bottom: 1px solid rgba(151,154,159,.14); +} +.tab { + min-width: 0; + padding: 9px 5px; + border: 0; + border-radius: 9px; + color: #979a9f; + background: transparent; + font-size: .72rem; + text-transform: uppercase; + letter-spacing: .06em; +} +.tab:hover, .tab.active { color: #040404; background: linear-gradient(135deg, #caa24d, #f0d58a); } + +.view { display: none; min-height: 0; padding: 12px; overflow: hidden; } +.view.active { display: block; } +#view-messages.active { display: grid; } +.message-layout { display: grid; grid-template-columns: 38% 1fr; gap: 10px; min-height: 0; } +aside, .conversation { min-height: 0; } +aside { display: flex; flex-direction: column; gap: 8px; } +.conversation { display: grid; grid-template-rows: auto 1fr auto; gap: 8px; } + +.card, .list > article, .service-card { + border: 1px solid rgba(151,154,159,.18); + border-radius: 12px; + background: rgba(255,255,255,.035); +} +.list { display: flex; flex-direction: column; gap: 7px; overflow-y: auto; scrollbar-width: thin; } +.list > article { padding: 10px; } +.list > article:hover { border-color: rgba(202,162,77,.5); background: rgba(202,162,77,.07); } +.list strong { color: #f0d58a; } +.list small { display: block; margin-top: 4px; color: #979a9f; } + +label { display: grid; gap: 5px; color: #979a9f; font-size: .75rem; } +input, textarea { + width: 100%; + border: 1px solid rgba(151,154,159,.24); + border-radius: 10px; + outline: none; + color: #f4f4f4; + background: rgba(0,0,0,.38); +} +input { height: 38px; padding: 0 11px; } +textarea { min-height: 78px; padding: 10px 11px; resize: none; } +input:focus, textarea:focus { border-color: #caa24d; box-shadow: 0 0 0 2px rgba(202,162,77,.12); } + +button.primary, button.accept, button.danger { + border: 0; + border-radius: 10px; + padding: 10px 13px; + font-weight: 750; +} +.primary { color: #040404; background: linear-gradient(135deg, #caa24d, #f0d58a); } +.accept { color: #00100d; background: #52e2bd; } +.danger { color: white; background: linear-gradient(135deg, #a6263e, #e84a5f); } +.full { width: 100%; } + +.message-thread { + min-height: 0; + overflow-y: auto; + padding: 9px; + border: 1px solid rgba(151,154,159,.14); + border-radius: 12px; + background: rgba(0,0,0,.24); +} +.message-thread.empty { display: grid; place-items: center; color: #6f7378; font-size: .8rem; } +.bubble { max-width: 84%; margin: 6px 0; padding: 9px 10px; border-radius: 12px; font-size: .82rem; overflow-wrap: anywhere; } +.bubble.incoming { margin-right: auto; background: rgba(151,154,159,.16); } +.bubble.outgoing { margin-left: auto; color: #090805; background: linear-gradient(135deg, #caa24d, #f0d58a); } +.bubble time { display: block; margin-top: 5px; opacity: .62; font-size: .62rem; } +.composer { display: grid; grid-template-columns: 1fr auto; gap: 7px; } +.composer textarea { min-height: 54px; } + +.stack { display: grid; gap: 9px; padding: 12px; margin-bottom: 10px; } +.stack h2 { margin: 0 0 4px; font-size: 1rem; color: #f0d58a; } +.contact-list { max-height: calc(100% - 190px); } +.contact-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; } +.row-actions { display: flex; gap: 5px; } +.row-actions button { padding: 6px 8px; border-radius: 8px; border: 1px solid rgba(202,162,77,.25); color: #f0d58a; background: transparent; } + +.service-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 9px; margin-bottom: 10px; } +.service-card { padding: 12px; } +.service-card strong { display: block; color: #f0d58a; } +.service-card span { display: block; margin: 5px 0 10px; color: #979a9f; font-size: .74rem; } +.service-card button { width: 100%; } +.hint { color: #6f7378; font-size: .7rem; } + +.phone-footer { + display: flex; + justify-content: space-between; + padding: 10px 18px 13px; + border-top: 1px solid rgba(151,154,159,.15); + color: #6f7378; + font-size: .64rem; + letter-spacing: .12em; +} + +.toast { + position: absolute; + z-index: 8; + top: 78px; + left: 16px; + right: 16px; + padding: 10px 12px; + border: 1px solid rgba(202,162,77,.5); + border-radius: 10px; + color: #f0d58a; + background: rgba(4,4,4,.96); + box-shadow: 0 12px 30px rgba(0,0,0,.45); +} + +.call-overlay { + position: absolute; + z-index: 12; + inset: 0; + display: grid; + place-content: center; + justify-items: center; + gap: 18px; + background: radial-gradient(circle at center, rgba(202,162,77,.18), rgba(4,4,4,.98) 58%); +} +.call-overlay span { color: #979a9f; letter-spacing: .15em; text-transform: uppercase; } +.call-overlay strong { font-size: 2rem; color: #f0d58a; letter-spacing: .1em; } +.call-actions { display: flex; gap: 10px; } + +@media (max-width: 900px) { + .phone { width: 420px; right: 20px; } +} From 4e270cbbaccdc26b250ed8bc7f7cab4507ac2205 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:40:20 +0100 Subject: [PATCH 47/58] Implement phone interface logic --- resources/[sp_ui]/sp_phone/html/app.js | 329 +++++++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 resources/[sp_ui]/sp_phone/html/app.js diff --git a/resources/[sp_ui]/sp_phone/html/app.js b/resources/[sp_ui]/sp_phone/html/app.js new file mode 100644 index 0000000..33a83a3 --- /dev/null +++ b/resources/[sp_ui]/sp_phone/html/app.js @@ -0,0 +1,329 @@ +const resourceName = typeof GetParentResourceName === 'function' ? GetParentResourceName() : 'sp_phone'; +const phone = document.getElementById('phone'); +const toast = document.getElementById('toast'); +const ownNumber = document.getElementById('own-number'); +const conversationList = document.getElementById('conversation-list'); +const messageThread = document.getElementById('message-thread'); +const messageNumber = document.getElementById('message-number'); +const messageBody = document.getElementById('message-body'); +const contactList = document.getElementById('contact-list'); +const serviceList = document.getElementById('service-list'); +const callHistory = document.getElementById('call-history'); +const callOverlay = document.getElementById('call-overlay'); +const callState = document.getElementById('call-state'); +const callNumber = document.getElementById('call-number'); +const answerCall = document.getElementById('answer-call'); +const declineCall = document.getElementById('decline-call'); +const hangupCall = document.getElementById('hangup-call'); + +let state = { + number: '', + contacts: [], + messages: [], + calls: [], + services: [], + selectedNumber: '' +}; +let toastTimer; + +async function post(endpoint, payload = {}) { + const response = await fetch(`https://${resourceName}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=UTF-8' }, + body: JSON.stringify(payload) + }); + return response.json(); +} + +function showToast(message) { + toast.textContent = message; + toast.classList.remove('hidden'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toast.classList.add('hidden'), 3200); +} + +function displayName(number) { + const contact = state.contacts.find(entry => entry.phone_number === number); + return contact ? contact.display_name : number; +} + +function formatDate(value) { + if (!value) return ''; + const date = new Date(String(value).replace(' ', 'T')); + return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('pt-PT', { + day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' + }); +} + +function makeButton(label, className, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + if (className) button.className = className; + button.addEventListener('click', onClick); + return button; +} + +function otherNumber(message) { + return message.sender_number === state.number ? message.receiver_number : message.sender_number; +} + +function setView(viewName) { + document.querySelectorAll('.tab').forEach(tab => tab.classList.toggle('active', tab.dataset.view === viewName)); + document.querySelectorAll('.view').forEach(view => view.classList.toggle('active', view.id === `view-${viewName}`)); +} + +function renderConversations() { + conversationList.replaceChildren(); + const grouped = new Map(); + + state.messages.forEach(message => { + const number = otherNumber(message); + const existing = grouped.get(number); + if (!existing || Number(message.id) > Number(existing.id)) grouped.set(number, message); + }); + + [...grouped.entries()] + .sort((a, b) => Number(b[1].id) - Number(a[1].id)) + .forEach(([number, message]) => { + const article = document.createElement('article'); + article.tabIndex = 0; + const title = document.createElement('strong'); + title.textContent = displayName(number); + const preview = document.createElement('small'); + preview.textContent = message.body; + article.append(title, preview); + article.addEventListener('click', () => selectConversation(number)); + conversationList.append(article); + }); +} + +function selectConversation(number) { + state.selectedNumber = number; + messageNumber.value = number; + renderThread(); +} + +function renderThread() { + const number = state.selectedNumber || messageNumber.value.trim(); + messageThread.replaceChildren(); + + if (!number) { + messageThread.className = 'message-thread empty'; + messageThread.textContent = 'Seleciona uma conversa.'; + return; + } + + const messages = state.messages + .filter(message => otherNumber(message) === number) + .sort((a, b) => Number(a.id) - Number(b.id)); + + if (!messages.length) { + messageThread.className = 'message-thread empty'; + messageThread.textContent = 'Ainda não existem mensagens.'; + return; + } + + messageThread.className = 'message-thread'; + messages.forEach(message => { + const bubble = document.createElement('div'); + const outgoing = message.sender_number === state.number; + bubble.className = `bubble ${outgoing ? 'outgoing' : 'incoming'}`; + const body = document.createElement('span'); + body.textContent = message.body; + const time = document.createElement('time'); + time.textContent = formatDate(message.created_at); + bubble.append(body, time); + messageThread.append(bubble); + }); + messageThread.scrollTop = messageThread.scrollHeight; +} + +function renderContacts() { + contactList.replaceChildren(); + state.contacts.forEach(contact => { + const article = document.createElement('article'); + article.className = 'contact-row'; + const info = document.createElement('div'); + const name = document.createElement('strong'); + name.textContent = contact.display_name; + const number = document.createElement('small'); + number.textContent = contact.phone_number; + info.append(name, number); + + const actions = document.createElement('div'); + actions.className = 'row-actions'; + actions.append( + makeButton('SMS', '', () => { + selectConversation(contact.phone_number); + setView('messages'); + }), + makeButton('Ligar', '', () => startCall(contact.phone_number)), + makeButton('×', '', async () => { + const result = await post('deleteContact', { id: contact.id }); + if (!result.ok) return showToast(result.error || 'Não foi possível apagar o contacto.'); + await refresh(); + }) + ); + article.append(info, actions); + contactList.append(article); + }); +} + +function renderServices() { + serviceList.replaceChildren(); + state.services.forEach(service => { + const card = document.createElement('article'); + card.className = 'service-card'; + const name = document.createElement('strong'); + name.textContent = `${service.label} · ${service.number}`; + const online = document.createElement('span'); + online.textContent = `${service.online} profissional${service.online === 1 ? '' : 'is'} online`; + const button = makeButton('Enviar pedido', 'primary', async () => { + const body = document.getElementById('service-body').value.trim(); + const result = await post('sendService', { service: service.key, body }); + if (!result.ok) return showToast(result.error || 'Falha ao enviar o pedido.'); + document.getElementById('service-body').value = ''; + showToast(`Pedido enviado para ${service.label}.`); + }); + card.append(name, online, button); + serviceList.append(card); + }); +} + +function renderCalls() { + callHistory.replaceChildren(); + const labels = { missed: 'Não atendida', declined: 'Rejeitada', completed: 'Concluída', failed: 'Falhou' }; + state.calls.forEach(call => { + const incoming = call.receiver_number === state.number; + const number = incoming ? call.caller_number : call.receiver_number; + const article = document.createElement('article'); + const title = document.createElement('strong'); + title.textContent = `${incoming ? 'Recebida' : 'Efetuada'} · ${displayName(number)}`; + const detail = document.createElement('small'); + detail.textContent = `${labels[call.status] || call.status} · ${call.duration_seconds || 0}s · ${formatDate(call.created_at)}`; + article.append(title, detail); + article.addEventListener('click', () => startCall(number)); + callHistory.append(article); + }); +} + +function renderAll() { + ownNumber.textContent = state.number || 'SEM NÚMERO'; + renderConversations(); + renderThread(); + renderContacts(); + renderServices(); + renderCalls(); +} + +async function refresh() { + const result = await post('refresh'); + if (!result.ok) return showToast(result.error || 'Falha ao atualizar.'); + const selected = state.selectedNumber; + state = { ...state, ...result, selectedNumber: selected }; + renderAll(); +} + +async function startCall(number) { + const target = String(number || '').trim(); + if (!target) return showToast('Indica um número para ligar.'); + const result = await post('startCall', { number: target }); + if (!result.ok) showToast(result.error || 'Não foi possível iniciar a chamada.'); +} + +function showCall(mode, number) { + callOverlay.classList.remove('hidden'); + callNumber.textContent = number; + answerCall.classList.toggle('hidden', mode !== 'incoming'); + declineCall.classList.toggle('hidden', mode !== 'incoming'); + hangupCall.classList.toggle('hidden', mode === 'incoming'); + callState.textContent = mode === 'incoming' ? 'Chamada recebida' : mode === 'connected' ? 'Em chamada' : 'A chamar'; +} + +function hideCall() { + callOverlay.classList.add('hidden'); +} + +document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => setView(tab.dataset.view))); +document.getElementById('close').addEventListener('click', () => post('close')); +document.getElementById('new-message').addEventListener('click', () => { + state.selectedNumber = ''; + messageNumber.value = ''; + renderThread(); +}); +messageNumber.addEventListener('change', () => selectConversation(messageNumber.value.trim())); + +document.getElementById('message-form').addEventListener('submit', async event => { + event.preventDefault(); + const number = messageNumber.value.trim(); + const body = messageBody.value.trim(); + const result = await post('sendMessage', { number, body }); + if (!result.ok) return showToast(result.error || 'Falha ao enviar a mensagem.'); + messageBody.value = ''; + state.selectedNumber = number; + await refresh(); +}); + +document.getElementById('contact-form').addEventListener('submit', async event => { + event.preventDefault(); + const nameInput = document.getElementById('contact-name'); + const numberInput = document.getElementById('contact-number'); + const result = await post('addContact', { name: nameInput.value.trim(), number: numberInput.value.trim() }); + if (!result.ok) return showToast(result.error || 'Falha ao guardar o contacto.'); + nameInput.value = ''; + numberInput.value = ''; + await refresh(); +}); + +answerCall.addEventListener('click', async () => { + const result = await post('answerCall'); + if (!result.ok) showToast(result.error || 'Falha ao atender.'); +}); +declineCall.addEventListener('click', async () => { + const result = await post('declineCall'); + if (!result.ok) showToast(result.error || 'Falha ao rejeitar.'); +}); +hangupCall.addEventListener('click', async () => { + const result = await post('hangupCall'); + if (!result.ok) showToast(result.error || 'Falha ao desligar.'); +}); + +document.addEventListener('keydown', event => { + if (event.key === 'Escape' && !phone.classList.contains('hidden')) post('close'); +}); + +setInterval(() => { + document.getElementById('clock').textContent = new Date().toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' }); +}, 1000); + +window.addEventListener('message', event => { + const payload = event.data || {}; + if (payload.action === 'open') { + state = { ...state, ...payload.data, selectedNumber: state.selectedNumber }; + phone.classList.remove('hidden'); + phone.setAttribute('aria-hidden', 'false'); + renderAll(); + } else if (payload.action === 'close') { + phone.classList.add('hidden'); + phone.setAttribute('aria-hidden', 'true'); + hideCall(); + } else if (payload.action === 'message') { + state.messages.push(payload.data); + renderConversations(); + renderThread(); + showToast(`Nova mensagem de ${displayName(payload.data.sender_number)}.`); + } else if (payload.action === 'incomingCall') { + showCall('incoming', displayName(payload.number)); + } else if (payload.action === 'outgoingCall') { + showCall('outgoing', displayName(payload.number)); + } else if (payload.action === 'callConnected') { + showCall('connected', displayName(payload.number)); + } else if (payload.action === 'callEnded') { + hideCall(); + showToast('Chamada terminada.'); + refresh(); + } else if (payload.action === 'serviceDispatch') { + showToast(`[${payload.data.label}] ${payload.data.sender}: ${payload.data.message}`); + } +}); From 3c5af6315019ee0382f57c230372d6dd7fc8985f Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:41:08 +0100 Subject: [PATCH 48/58] Expand txAdmin recipe with essential systems --- recipe.yaml | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/recipe.yaml b/recipe.yaml index 56c873c..6e744db 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -1,9 +1,9 @@ $engine: 3 $onesync: on name: Soul Project ESX Legacy -version: 0.1.0 +version: 0.2.0 author: Soul Project -description: Base modular PT-PT para FiveM com ESX Legacy, Steam-only e estrutura sp_*. +description: Base modular PT-PT para FiveM com ESX Legacy, Steam-only, rádio, telefone e empregos essenciais. tasks: - action: download_github @@ -75,7 +75,7 @@ tasks: - action: connect_database - action: download_github - src: https://github.com/esx-framework/esx-core + src: https://github.com/esx-framework/esx_core ref: main dest: ./tmp/esx-core @@ -85,11 +85,63 @@ tasks: - action: query_database file: ./tmp/sp-base/database/000_sp_base.sql + - action: query_database + file: ./tmp/sp-base/database/005_sp_essential_items.sql + + - action: query_database + file: ./tmp/sp-base/database/010_sp_phone.sql + + - action: query_database + file: ./tmp/sp-base/database/015_sp_starterpack.sql + - action: download_github src: https://github.com/esx-framework/ESX-Legacy-Addons ref: main dest: ./tmp/esx-addons + # SQL estrutural dos addons oficiais ESX. + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_addonaccount/esx_addonaccount.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_addoninventory/esx_addoninventory.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_datastore/esx_datastore.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_society/esx_society.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_billing/esx_billing.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_license/esx_license.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_banking/banking.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_jobs/esx_jobs.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_policejob/esx_policejob.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_ambulancejob/esx_ambulancejob.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_mechanicjob/esx_mechanicjob.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_taxijob/esx_taxijob.sql + + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_vehicleshop/esx_vehicleshop.sql + + - action: query_database + file: ./tmp/sp-base/database/020_sp_jobs_pt.sql + - action: move_path src: ./tmp/esx-core/[core] dest: ./resources/[core] From cb5ef8179ebcc8f4a710007e3516c0b23a0edc67 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:43:27 +0100 Subject: [PATCH 49/58] Install garage database schema --- recipe.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/recipe.yaml b/recipe.yaml index 6e744db..3cfe44f 100644 --- a/recipe.yaml +++ b/recipe.yaml @@ -139,6 +139,9 @@ tasks: - action: query_database file: ./tmp/esx-addons/[esx_addons]/esx_vehicleshop/esx_vehicleshop.sql + - action: query_database + file: ./tmp/esx-addons/[esx_addons]/esx_garage/esx_garage.sql + - action: query_database file: ./tmp/sp-base/database/020_sp_jobs_pt.sql From bd99f31bfd15fb051aa859c2f0b0bed3d433b73c Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:44:49 +0100 Subject: [PATCH 50/58] Validate essential ESX systems --- tools/validate.py | 165 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 143 insertions(+), 22 deletions(-) diff --git a/tools/validate.py b/tools/validate.py index 1c3a23c..acaa38c 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -14,53 +14,164 @@ "server.cfg", "config/resources.cfg", "config/security.cfg", + "config/voice.cfg", "config/secrets.example.cfg", "database/000_sp_base.sql", + "database/005_sp_essential_items.sql", + "database/010_sp_phone.sql", + "database/015_sp_starterpack.sql", + "database/020_sp_jobs_pt.sql", "resources/[sp_core]/sp_bootstrap/fxmanifest.lua", + "resources/[sp_core]/sp_radio/fxmanifest.lua", + "resources/[sp_core]/sp_radio/config.lua", + "resources/[sp_core]/sp_radio/client/main.lua", + "resources/[sp_core]/sp_radio/server/main.lua", + "resources/[sp_core]/sp_starterpack/fxmanifest.lua", + "resources/[sp_core]/sp_starterpack/config.lua", + "resources/[sp_core]/sp_starterpack/server/main.lua", + "resources/[sp_ui]/sp_phone/fxmanifest.lua", + "resources/[sp_ui]/sp_phone/config.lua", + "resources/[sp_ui]/sp_phone/client/main.lua", + "resources/[sp_ui]/sp_phone/server/main.lua", + "resources/[sp_ui]/sp_phone/html/index.html", + "resources/[sp_ui]/sp_phone/html/style.css", + "resources/[sp_ui]/sp_phone/html/app.js", ] +RECIPE_SOURCES = ( + "esx-framework/esx_core", + "esx-framework/ESX-Legacy-Addons", + "AvarianKnight/pma-voice", + "overextended/oxmysql", +) + +RECIPE_EXTERNAL_SQL = ( + "esx_addonaccount/esx_addonaccount.sql", + "esx_addoninventory/esx_addoninventory.sql", + "esx_datastore/esx_datastore.sql", + "esx_society/esx_society.sql", + "esx_billing/esx_billing.sql", + "esx_license/esx_license.sql", + "esx_banking/banking.sql", + "esx_jobs/esx_jobs.sql", + "esx_policejob/esx_policejob.sql", + "esx_ambulancejob/esx_ambulancejob.sql", + "esx_mechanicjob/esx_mechanicjob.sql", + "esx_taxijob/esx_taxijob.sql", + "esx_vehicleshop/esx_vehicleshop.sql", + "esx_garage/esx_garage.sql", +) + +START_ORDER = ( + "ensure oxmysql", + "ensure pma-voice", + "ensure es_extended", + "ensure [core]", + "ensure [esx_addons]", + "ensure [sp_core]", + "ensure [sp_ui]", +) + SECRET_PATTERNS = { "Cfx.re license key": re.compile(r"cfxk_[A-Za-z0-9_-]{20,}"), "MySQL password in URI": re.compile(r"mysql://[^\s:]+:[^\s@{]+@", re.IGNORECASE), "Discord bot token": re.compile(r"[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,}"), } -TEXT_SUFFIXES = {".cfg", ".json", ".lua", ".md", ".py", ".sql", ".yaml", ".yml"} +TEXT_SUFFIXES = { + ".cfg", + ".css", + ".html", + ".js", + ".json", + ".lua", + ".md", + ".py", + ".sql", + ".yaml", + ".yml", +} def fail(message: str) -> None: print(f"[FAIL] {message}") -def main() -> int: +def validate_required_files() -> int: errors = 0 - for relative in REQUIRED: - if not (ROOT / relative).exists(): + if not (ROOT / relative).is_file(): fail(f"ficheiro obrigatório em falta: {relative}") errors += 1 + return errors + + +def validate_resource_order() -> int: + path = ROOT / "config/resources.cfg" + if not path.exists(): + return 0 + + content = path.read_text(encoding="utf-8") + positions = [content.find(item) for item in START_ORDER] + if any(position < 0 for position in positions) or positions != sorted(positions): + fail("ordem de arranque inválida em config/resources.cfg") + return 1 + return 0 + + +def validate_recipe() -> int: + path = ROOT / "recipe.yaml" + if not path.exists(): + return 0 + + content = path.read_text(encoding="utf-8") + errors = 0 + + for source in RECIPE_SOURCES: + if source not in content: + fail(f"dependência ausente da recipe: {source}") + errors += 1 - resources_path = ROOT / "config/resources.cfg" - if resources_path.exists(): - resources_cfg = resources_path.read_text(encoding="utf-8") - ordered = ["ensure oxmysql", "ensure es_extended", "ensure [core]", "ensure [sp_core]"] - positions = [resources_cfg.find(item) for item in ordered] - if any(position < 0 for position in positions) or positions != sorted(positions): - fail("ordem de arranque inválida em config/resources.cfg") + for sql_path in RECIPE_EXTERNAL_SQL: + if sql_path not in content: + fail(f"SQL oficial não importado pela recipe: {sql_path}") errors += 1 - recipe_path = ROOT / "recipe.yaml" - if recipe_path.exists(): - recipe = recipe_path.read_text(encoding="utf-8") - for source in ( - "esx-framework/esx-core", - "esx-framework/ESX-Legacy-Addons", - "overextended/oxmysql", - ): - if source not in recipe: - fail(f"dependência ausente da recipe: {source}") + for migration in sorted((ROOT / "database").glob("*.sql")): + expected = f"./tmp/sp-base/database/{migration.name}" + if expected not in content: + fail(f"migração própria não importada pela recipe: {migration.name}") + errors += 1 + + return errors + + +def validate_manifests() -> int: + errors = 0 + for manifest in ROOT.glob("resources/**/fxmanifest.lua"): + if not manifest.parent.name.startswith("sp_"): + continue + + content = manifest.read_text(encoding="utf-8") + if "fx_version" not in content or "game" not in content: + fail(f"manifest incompleto: {manifest.relative_to(ROOT)}") + errors += 1 + + for relative in re.findall(r"['\"]([^'\"]+\.(?:lua|js|html|css))['\"]", content): + if relative.startswith("@") or "*" in relative: + continue + target = manifest.parent / relative + if not target.is_file(): + fail( + "ficheiro referenciado pelo manifest não existe: " + f"{target.relative_to(ROOT)}" + ) errors += 1 + return errors + +def validate_secrets() -> int: + errors = 0 for path in ROOT.rglob("*"): if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES: continue @@ -74,12 +185,22 @@ def main() -> int: if pattern.search(content): fail(f"possível {label} em {path.relative_to(ROOT)}") errors += 1 + return errors + + +def main() -> int: + errors = 0 + errors += validate_required_files() + errors += validate_resource_order() + errors += validate_recipe() + errors += validate_manifests() + errors += validate_secrets() if errors: print(f"\nValidação terminou com {errors} erro(s).") return 1 - print("[OK] Estrutura, ordem de arranque e segredos validados.") + print("[OK] Estrutura, recipe, manifests, ordem de arranque e segredos validados.") return 0 From 1e65a5ada6e980499ea555ede720227c042c816b Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:45:05 +0100 Subject: [PATCH 51/58] Add Lua and JavaScript validation --- .github/workflows/validate.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d4da1e3..ddbde74 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,8 +19,24 @@ jobs: with: python-version: '3.12' + - name: Install Lua parser + run: | + sudo apt-get update + sudo apt-get install -y lua5.4 + - name: Validate repository run: python tools/validate.py + - name: Validate Lua syntax + shell: bash + run: | + while IFS= read -r -d '' file; do + echo "Checking $file" + luac5.4 -p "$file" + done < <(find resources -type f -name '*.lua' -print0) + + - name: Validate JavaScript syntax + run: node --check 'resources/[sp_ui]/sp_phone/html/app.js' + - name: Check whitespace run: git diff --check From e39da21f3074eb636bb9d3f5c5627ca8280e2e89 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:45:31 +0100 Subject: [PATCH 52/58] Document essential ESX systems --- README.md | 93 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 4a4a6f1..7560ab3 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,62 @@ # Soul Project ESX Legacy -Base modular do **Soul Project** para servidores FiveM assentes em **ESX Legacy**. O repositório não duplica o código de terceiros: a recipe do txAdmin instala as dependências oficiais e mantém o código `sp_*` isolado para atualizações e manutenção sem colisões. +Base modular do **Soul Project** para servidores FiveM assentes em **ESX Legacy**. A recipe txAdmin instala as dependências externas e mantém todo o código próprio `sp_*` separado, para permitir atualizações sem transformar o servidor num puzzle de recursos copiados à mão. -## Incluído +## Sistemas incluídos + +### Fundação - ESX Legacy Core e ESX Legacy Addons oficiais; - `oxmysql`, `pma-voice`, `bob74_ipl` e recursos base Cfx.re; - configuração PT-PT, OneSync e política Steam-only; -- grupos próprios para core, UI, trabalhos, crime, mapas e veículos; -- `sp_bootstrap` com gate Steam, health check e state bag de prontidão; -- SQL de controlo de migrações Soul Project; -- validação automática no GitHub Actions, incluindo deteção básica de segredos. +- permissões ACE para `owner`, `admin`, `dev`, `mod` e `support`; +- `sp_bootstrap` com gate Steam, health check e state bag `spReady`; +- migrações SQL cumulativas registadas em `sp_schema_migrations`. + +### Comunicação + +- `sp_radio` sobre `pma-voice`; +- frequências públicas e frequências reservadas por profissão; +- item `radio`, comandos `/radio`, `/radiooff` e `/radiovol`; +- revalidação server-side após mudança de emprego ou perda do item; +- `sp_phone` com número persistente por personagem; +- contactos, SMS persistentes, histórico de chamadas e chamadas de voz; +- pedidos com localização para Polícia, Emergência Médica, Mecânico e Táxi; +- item `phone`, comando `/phone` e tecla `F1`. + +### Economia e roleplay + +A instalação importa o SQL necessário para: + +- contas partilhadas, inventários partilhados e datastores; +- sociedades e gestão de empresas; +- faturação, licenças e banking; +- lojas e necessidades incluídas nos addons ESX; +- concessionário, veículos próprios e garagem; +- propriedades, personalização, HUD e restantes módulos do pack oficial. + +### Empregos essenciais + +- Polícia; +- Emergência Médica; +- Mecânico; +- Táxi; +- Concessionário; +- Imobiliária; +- empregos civis do `esx_jobs`. + +Os nomes principais são ajustados para PT-PT pela migração `020_sp_jobs_pt.sql`. + +### Primeiro personagem + +O `sp_starterpack` entrega apenas uma vez: + +- 1 telemóvel; +- 1 rádio; +- 2 águas; +- 2 pães. + +A entrega é idempotente e fica registada em `sp_starter_claims`. ## Instalação pelo txAdmin @@ -20,37 +66,52 @@ Usa esta recipe personalizada no Server Deployer: https://raw.githubusercontent.com/SoulBrotherDev/ESX-LEGACY/main/recipe.yaml ``` -Depois da instalação, abre `config/secrets.cfg` e coloca a Steam Web API Key. Sem essa chave o servidor não conseguirá fornecer identificadores `steam:` e o gate Steam-only recusará ligações. +Depois da instalação, abre `config/secrets.cfg` e coloca a Steam Web API Key. Chaves, tokens e palavras-passe reais nunca devem ser enviados para o Git. -## Arranque +Consulta o guia completo em [`docs/INSTALL.md`](docs/INSTALL.md) e o catálogo funcional em [`docs/ESSENTIALS.md`](docs/ESSENTIALS.md). -A ordem de recursos está em `config/resources.cfg`. Após arrancar, executa na consola: +## Arranque e diagnóstico + +A ordem dos recursos está em `config/resources.cfg`. Após arrancar, executa na consola: ```text sp_health ``` -O comando apresenta o estado de `oxmysql`, `es_extended` e dos módulos Soul Project essenciais. +O comando apresenta o estado do núcleo ESX, base de dados e módulos Soul Project essenciais. ## Estrutura ```text config/ Configuração dividida por domínio database/ Migrações próprias do Soul Project -docs/ Instalação e arquitetura -resources/[sp_core]/ Núcleo transversal Soul Project -resources/[sp_ui]/ Interfaces e HUD -resources/[sp_jobs]/ Trabalhos legais +docs/ Instalação, arquitetura e catálogo funcional +resources/[sp_core]/ Bootstrap, rádio e kit inicial +resources/[sp_ui]/ Telefone, interfaces e HUD +resources/[sp_jobs]/ Trabalhos legais próprios resources/[sp_illegal]/ Sistemas criminais resources/[sp_maps]/ IPLs, MLOs e mapas próprios resources/[sp_vehicles]/ Veículos, handling e ecossistema automóvel resources/[sp_custom]/ Integrações transversais ``` +## Qualidade + +O GitHub Actions verifica automaticamente: + +- ficheiros e dependências obrigatórias; +- SQL próprio incluído na recipe; +- ordem de arranque; +- referências dos manifests; +- sintaxe Lua; +- sintaxe JavaScript; +- whitespace e deteção básica de segredos. + ## Regras da base 1. Não versionar chaves, tokens, palavras-passe ou connection strings reais. 2. Recursos de terceiros permanecem fora do Git e são instalados pela recipe. -3. Recursos próprios usam prefixo `sp_` e manifest `fxmanifest.lua`. +3. Recursos próprios usam prefixo `sp_` e `fxmanifest.lua`. 4. Alterações SQL próprias são cumulativas e registadas em `sp_schema_migrations`. -5. Atualizações do ESX são testadas em staging antes de chegar a produção. +5. Autoridade financeira, inventário, rádio, telefone e empregos permanece no servidor. +6. Atualizações do ESX são testadas em staging antes de chegar a produção. From d2fb0ca72afa3fddb529fe55e24f4a84c98442df Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:45:51 +0100 Subject: [PATCH 53/58] Grant moderation voice permissions --- config/permissions.cfg | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/permissions.cfg b/config/permissions.cfg index a475851..e86cb6b 100644 --- a/config/permissions.cfg +++ b/config/permissions.cfg @@ -18,7 +18,9 @@ add_ace group.dev command.restart allow add_ace group.dev command.ensure allow add_ace group.dev command.sp_health allow -# Suporte e moderação +# Moderação, voz e suporte +add_ace group.mod command.muteply allow +add_ace group.mod command.sp_health allow add_ace group.support command.sp_health allow # Permissões necessárias ao es_extended. From 7c1872abc3e275102815fb68bda1ae138b12b174 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:46:26 +0100 Subject: [PATCH 54/58] Add essential systems guide --- docs/ESSENTIALS.md | 195 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/ESSENTIALS.md diff --git a/docs/ESSENTIALS.md b/docs/ESSENTIALS.md new file mode 100644 index 0000000..ee1957c --- /dev/null +++ b/docs/ESSENTIALS.md @@ -0,0 +1,195 @@ +# Sistemas essenciais + +Este documento descreve os módulos incluídos na base `0.2.0` e os testes mínimos antes de promover o servidor para produção. + +## Dependências instaladas pela recipe + +| Componente | Função | +|---|---| +| ESX Legacy Core | framework, jogadores, empregos, inventário e contas | +| ESX Legacy Addons | jobs, sociedades, banking, billing, lojas, propriedades e veículos | +| oxmysql | persistência MySQL/MariaDB | +| pma-voice | proximidade, rádio e chamadas | +| bob74_ipl | carregamento de IPLs base | +| cfx-server-data | recursos base Cfx.re | + +As dependências externas são descarregadas no deploy e não são copiadas para este repositório. + +## `sp_bootstrap` + +Responsabilidades: + +- impedir entrada sem identificador `steam:`; +- verificar recursos essenciais; +- disponibilizar o comando ACE `sp_health`; +- definir `LocalPlayer.state.spReady` quando o jogador termina o carregamento; +- fornecer logging normalizado para os recursos Soul Project. + +Teste: + +```text +sp_health +``` + +O resultado deve apresentar `oxmysql`, `es_extended`, `sp_bootstrap`, `sp_radio`, `sp_starterpack` e `sp_phone` como `started`. + +## `sp_radio` + +### Utilização + +| Ação | Comando/tecla | +|---|---| +| abrir ou escolher frequência | `F6` ou `/radio [frequência]` | +| desligar rádio | `/radiooff` | +| alterar volume | `/radiovol 1-100` | + +O rádio exige o item `radio` por omissão. + +### Frequências + +| Intervalo | Acesso | +|---|---| +| 1-49 | Polícia | +| 50-99 | Emergência Médica | +| 100-149 | Polícia e Emergência Médica | +| 150-199 | Mecânicos | +| 200-249 | Táxis | +| 250-999 | Público | + +As restrições são validadas no servidor e novamente no `pma-voice`. A frequência é removida quando o jogador perde acesso. + +Configuração: `resources/[sp_core]/sp_radio/config.lua`. + +## `sp_phone` + +### Utilização + +- `F1` ou `/phone` abre o telemóvel; +- o item `phone` é obrigatório; +- cada personagem recebe um número persistente `555XXXX`; +- o número e todos os remetentes são resolvidos no servidor. + +### Funções + +- contactos; +- SMS persistentes; +- conversas e mensagens lidas; +- histórico de chamadas; +- chamadas de voz pelo `pma-voice`; +- pedidos de serviço com coordenadas anexadas. + +### Serviços + +| Número | Serviço | Emprego destinatário | +|---|---|---| +| 112 | Polícia | `police` | +| 115 | Emergência Médica | `ambulance` | +| 116 | Mecânico | `mechanic` | +| 117 | Táxi | `taxi` | + +Configuração: `resources/[sp_ui]/sp_phone/config.lua`. + +Tabelas: + +- `sp_phone_numbers`; +- `sp_phone_contacts`; +- `sp_phone_messages`; +- `sp_phone_calls`; +- `sp_phone_service_messages`. + +## `sp_starterpack` + +É entregue uma única vez por identificador de personagem: + +- `phone` ×1; +- `radio` ×1; +- `bread` ×2; +- `water` ×2. + +A operação usa `INSERT IGNORE` em `sp_starter_claims`, impedindo duplicação após reconnect ou restart. + +Configuração: `resources/[sp_core]/sp_starterpack/config.lua`. + +## Empregos e economia ESX + +A recipe instala o pack completo de addons e importa os esquemas necessários para: + +- `esx_addonaccount`; +- `esx_addoninventory`; +- `esx_datastore`; +- `esx_society`; +- `esx_billing`; +- `esx_license`; +- `esx_banking`; +- `esx_jobs`; +- `esx_policejob`; +- `esx_ambulancejob`; +- `esx_mechanicjob`; +- `esx_taxijob`; +- `esx_vehicleshop`; +- `esx_garage`. + +O grupo `[esx_addons]` inclui ainda os restantes módulos oficiais compatíveis presentes no pack, como necessidades, HUD, propriedades, lojas e personalização de veículos. + +### Empregos principais + +| Código ESX | Designação PT-PT | +|---|---| +| `unemployed` | Desempregado | +| `police` | Polícia | +| `ambulance` | Emergência Médica | +| `mechanic` | Mecânico | +| `taxi` | Táxi | +| `cardealer` | Concessionário | +| `realestateagent` | Imobiliária | + +A tradução inicial é aplicada por `database/020_sp_jobs_pt.sql` depois dos SQL oficiais. + +## Ordem de arranque + +A sequência crítica é: + +```text +oxmysql +pma-voice +es_extended +[core] +[esx_addons] +[sp_core] +[sp_ui] +``` + +Não colocar recursos que usam ESX ou MySQL antes das respetivas dependências. + +## Checklist de staging + +1. Executar a recipe numa base de dados vazia. +2. Confirmar ausência de erros `query_database`. +3. Colocar a Steam Web API Key em `config/secrets.cfg`. +4. Arrancar e executar `sp_health`. +5. Entrar com Steam aberta e confirmar o kit inicial. +6. Reconectar e confirmar que o kit não é repetido. +7. Testar rádio público com dois jogadores. +8. Testar acesso negado a uma frequência profissional. +9. Atribuir `police`, entrar na frequência 1 e retirar novamente o emprego. +10. Enviar SMS, reconnectar e confirmar persistência. +11. Efetuar, atender e terminar uma chamada. +12. Enviar pedidos 112, 115, 116 e 117 com profissionais online. +13. Testar Polícia, Emergência Médica, Mecânico, Táxi, concessionário e garagem. +14. Confirmar billing, society e banking. +15. Rever salários, preços, veículos, posições e permissões antes de produção. + +## Limites da base + +Esta base fornece um ponto de partida funcional e reproduzível. Não substitui a configuração própria da cidade para: + +- coordenadas e MLOs; +- fardas e veículos de serviço; +- equilíbrio económico; +- inventário final; +- dispatch avançado; +- MDT; +- multicharacter e aparência próprios; +- sistemas Soul Project já desenvolvidos separadamente. + +Esses módulos devem entrar nas famílias `sp_*` sem editar diretamente o código externo sempre que possível. From 388e48588f080f26e2ff1c4e29daec0d9f7c132c Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:46:52 +0100 Subject: [PATCH 55/58] Expand staging installation guide --- docs/INSTALL.md | 76 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9a0110b..466c56f 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -5,26 +5,69 @@ - FXServer atualizado com txAdmin; - MariaDB ou MySQL acessível pelo servidor; - chave de licença Cfx.re; -- Steam Web API Key para a política Steam-only. +- Steam Web API Key para a política Steam-only; +- portas FiveM e txAdmin configuradas no firewall. ## Deploy recomendado -1. No txAdmin, cria um novo servidor com **Remote URL Template**. +1. No txAdmin, cria um servidor novo com **Remote URL Template**. 2. Introduz `https://raw.githubusercontent.com/SoulBrotherDev/ESX-LEGACY/main/recipe.yaml`. -3. Preenche o nome, licença Cfx.re, número máximo de jogadores e base de dados. -4. Conclui a recipe e abre `config/secrets.cfg`. -5. Substitui `COLOCAR_STEAM_WEB_API_KEY` pela chave Steam real. -6. Arranca o servidor e executa `sp_health` na consola. +3. Preenche nome, licença Cfx.re, lotação e ligação à base de dados. +4. Usa uma base de dados vazia para o primeiro deploy. +5. Conclui a recipe e abre `config/secrets.cfg`. +6. Substitui `COLOCAR_STEAM_WEB_API_KEY` pela chave Steam real. +7. Configura os identificadores ACE dos administradores em `config/permissions.cfg` ou pelo txAdmin. +8. Arranca o servidor e executa `sp_health` na consola. + +## O que a recipe instala + +- recursos base Cfx.re; +- ESX Legacy Core; +- ESX Legacy Addons; +- oxmysql; +- pma-voice; +- bob74_ipl; +- recursos próprios em `[sp_core]` e `[sp_ui]`; +- SQL oficial de sociedades, billing, banking, jobs, veículos e garagem; +- migrações próprias do telefone, rádio, kit inicial e traduções PT-PT. ## Primeiro teste Confirma no arranque: -- `oxmysql` em estado `started`; -- `es_extended` em estado `started`; -- `sp_bootstrap` em estado `started`; -- entrada recusada quando o cliente não apresenta identificador `steam:`; -- criação da tabela `sp_schema_migrations`. +- `oxmysql` em `started`; +- `pma-voice` em `started`; +- `es_extended` em `started`; +- `[core]` e `[esx_addons]` sem erros; +- `sp_bootstrap`, `sp_radio`, `sp_starterpack` e `sp_phone` em `started`; +- entrada recusada sem identificador `steam:`; +- tabelas `sp_schema_migrations`, `sp_phone_numbers` e `sp_starter_claims` criadas. + +Executa: + +```text +sp_health +``` + +Depois entra com uma personagem nova e confirma: + +- kit inicial entregue apenas uma vez; +- `/radio 250` liga uma frequência pública; +- `F1` abre o telefone; +- SMS e chamadas funcionam entre dois jogadores; +- pedidos 112/115/116/117 chegam aos empregos corretos. + +O checklist completo está em [`ESSENTIALS.md`](ESSENTIALS.md). + +## Segredos + +O ficheiro `config/secrets.cfg` é ignorado pelo Git. Mantém nele apenas valores locais: + +```cfg +set steam_webApiKey "CHAVE_REAL_AQUI" +``` + +A ligação MySQL é preenchida pelo txAdmin no `server.cfg`. Não coloques tokens Discord, licenças Cfx.re ou palavras-passe em documentação, commits ou scripts Lua. ## Produção @@ -34,4 +77,13 @@ Altera em `config/core.cfg`: setr sp:environment "production" ``` -Mantém `sv_enforceGameBuild` comentado até o conjunto completo de mapas e veículos ser validado nesse build. +Antes de abrir o servidor: + +1. ajusta salários e economia; +2. configura garagens, concessionário, lojas e propriedades; +3. revê fardas, veículos e armamento dos empregos; +4. testa todos os recursos com OneSync; +5. cria backup da base de dados; +6. ativa build enforcement apenas depois de validar mapas e veículos nesse build. + +Mantém `sv_enforceGameBuild` comentado até o conjunto completo de mapas e veículos ser validado. From 1591034c747d9d8c88c19a7e71b4ce8868fc6a32 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:47:17 +0100 Subject: [PATCH 56/58] Monitor essential Soul resources --- resources/[sp_core]/sp_bootstrap/config.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/resources/[sp_core]/sp_bootstrap/config.lua b/resources/[sp_core]/sp_bootstrap/config.lua index 3ff13a5..da5ecfe 100644 --- a/resources/[sp_core]/sp_bootstrap/config.lua +++ b/resources/[sp_core]/sp_bootstrap/config.lua @@ -7,7 +7,12 @@ Config.HealthCommand = 'sp_health' Config.RequiredResources = { 'oxmysql', - 'es_extended' + 'pma-voice', + 'es_extended', + 'sp_bootstrap', + 'sp_radio', + 'sp_starterpack', + 'sp_phone' } Config.SteamRequiredMessage = table.concat({ From 6c617997c79305d3ffb8c1e9f889aac6eb728355 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:47:35 +0100 Subject: [PATCH 57/58] Delay complete base health check --- resources/[sp_core]/sp_bootstrap/server/main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/[sp_core]/sp_bootstrap/server/main.lua b/resources/[sp_core]/sp_bootstrap/server/main.lua index 702c0c5..4b29926 100644 --- a/resources/[sp_core]/sp_bootstrap/server/main.lua +++ b/resources/[sp_core]/sp_bootstrap/server/main.lua @@ -84,7 +84,7 @@ AddEventHandler('onResourceStart', function(resourceName) end CreateThread(function() - Wait(1000) + Wait(5000) local health = collectHealth() SPLogger.Log(health.ok and 'info' or 'error', 'Bootstrap inicializado', health) end) From 467218b1e0d46b0dc2fc4b823d89cb42fb199336 Mon Sep 17 00:00:00 2001 From: Soul Project <99090529+SoulBrotherDev@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:49:41 +0100 Subject: [PATCH 58/58] Harden phone item validation --- resources/[sp_ui]/sp_phone/server/main.lua | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/resources/[sp_ui]/sp_phone/server/main.lua b/resources/[sp_ui]/sp_phone/server/main.lua index f7a09c6..8255f01 100644 --- a/resources/[sp_ui]/sp_phone/server/main.lua +++ b/resources/[sp_ui]/sp_phone/server/main.lua @@ -245,8 +245,8 @@ end) ESX.RegisterServerCallback('sp_phone:server:addContact', function(source, cb, displayName, phoneNumber) local xPlayer = ESX.GetPlayerFromId(source) - if not xPlayer then - return callbackError(cb, 'Jogador inválido.') + if not xPlayer or not hasPhone(xPlayer) then + return callbackError(cb, 'Não tens um telemóvel.') end displayName = cleanText(displayName):sub(1, 64) @@ -272,8 +272,8 @@ end) ESX.RegisterServerCallback('sp_phone:server:deleteContact', function(source, cb, contactId) local xPlayer = ESX.GetPlayerFromId(source) - if not xPlayer then - return callbackError(cb, 'Jogador inválido.') + if not xPlayer or not hasPhone(xPlayer) then + return callbackError(cb, 'Não tens um telemóvel.') end MySQL.update.await( @@ -286,9 +286,12 @@ end) ESX.RegisterServerCallback('sp_phone:server:sendService', function(source, cb, serviceKey, body) local xPlayer = ESX.GetPlayerFromId(source) local service = Config.Services[tostring(serviceKey or '')] - if not xPlayer or not service then + if not service then return callbackError(cb, 'Serviço inválido.') end + if not xPlayer or not hasPhone(xPlayer) then + return callbackError(cb, 'Não tens um telemóvel.') + end body = cleanText(body) if body == '' then @@ -332,8 +335,9 @@ ESX.RegisterServerCallback('sp_phone:server:startCall', function(source, cb, tar local callerNumber = ensurePhoneNumber(source, xPlayer) targetNumber = cleanNumber(targetNumber) local targetSource = sourceByNumber[targetNumber] + local targetPlayer = targetSource and ESX.GetPlayerFromId(targetSource) or nil - if not targetSource or GetPlayerPing(targetSource) <= 0 then + if not targetSource or not targetPlayer or not hasPhone(targetPlayer) or GetPlayerPing(targetSource) <= 0 then MySQL.insert.await([[ INSERT INTO sp_phone_calls (caller_number, receiver_number, status) VALUES (?, ?, 'missed')