v0.0.4
Summary
This release improves EMS tablet dispatch, localization, and framework compatibility. lb-tablet can now receive dispatch calls as notifications with Set GPS and Respond actions, Portuguese translations are included, and bridges were expanded for vehicle keys, ESX society funds, modern ESX duty, and Wasabi Ambulance v2 exports.
Review the configuration diffs below before updating — command names, client hooks, state_bags, and tablet settings have changed. The shipped default locale is pt; set Config.locales.lang back to en if you want English.
What's New
Features
- Portuguese locale (
locales/pt.json) and localized complaint, target, dispatch, and laboratory prompts - lb-tablet dispatch notifications with Set GPS and Respond buttons, plus
Config.external_tabletsto load the app and optionally hide the built-in dispatch UI - Garage vehicle-key bridge for qbx, okok, qb, cd_garage, gflp10, wasabi_carlock, and MrNewb
- ESX society / addon-account support for hospital funds
- okokBossMenu duty toggle and modern ESX
setJob(..., onDuty)handling - Wasabi Ambulance v2 compatibility exports (
RevivePlayer,ApplyHeal) - Client hooks are now a returned module:
changeState,toggleDeathscreen, andsendDistressSignal - AI unit concurrency limit and spawn blacklist zones
- Per-command
enabledflags; HUD editor moved fromdispatchsettingstoems:settings - Medical Codex pagination and clearer item / vehicle images in the UI
File Changes
⚙️ Configuration & Bridge Files
These files are not encrypted. Review the changes below — you may need to manually apply updates to your server's config files.
bridge/client/tablets.lua
local ui = uiPage .. (uiPage:find("?", 1, true) and "&" or "?") .. "tablet=true"
local icon = ("https://cfx-nui-%s/web/images/luxu_medicine_app_logo.png"):format(resourceName)
+local TABLET_SET_GPS_EVENT = Luxu.prefixClient .. "tablet:setDispatchGPS"
+local TABLET_ANSWER_EVENT = Luxu.prefixClient .. "tablet:answerDispatch"
+
--- Forwards NUI actions into the lb-tablet custom-app iframe while it is open.
ExternalTabletApp = {
isOpen = false,
send = function(action, data)
if not ExternalTabletApp.isOpen then return end
- if GetResourceState("lb-tablet") ~= "started" then return end
+ if not IsResourceRunning("lb-tablet") then return end
exports["lb-tablet"]:SendCustomAppMessage(APP_IDENTIFIER, action, data)
end,
}
+---@return boolean
+function ExternalTabletApp.usesNotificationsInsteadOfDispatchUi()
+ return Config.external_tablets.enabled
+ and Config.external_tablets.disable_dispatch
+ and IsResourceRunning("lb-tablet")
+end
+
+---@param call table
+---@param kind? "new"|"air"
+function ExternalTabletApp.notifyDispatchCall(call, kind)
+ if not call or not IsResourceRunning("lb-tablet") then return end
+
+ local title
+ local content
+ if kind == "air" then
+ title = Locales.notify_dispatch_air_support_requested:format(
+ call.caller or Locales.dispatch_air_support_unknown_caller
+ )
+ content = call.location or ""
+ else
+ title = Locales.notify_tablet_new_dispatch
+ local details = call.title or ""
+ if call.code and call.code ~= "" then
+ details = (details ~= "") and (call.code .. " " .. details) or call.code
+ end
+ if details ~= "" and call.location and call.location ~= "" then
+ content = Locales.notify_tablet_dispatch_content:format(details, call.location)
+ else
+ content = details ~= "" and details or (call.location or "")
+ end
+ end
+
+ exports["lb-tablet"]:SendNotification({
+ app = APP_IDENTIFIER,
+ title = title,
+ content = content,
+ dontSaveToDatabase = true,
+ customData = {
+ buttons = {
+ {
+ title = Locales.dispatch_set_gps_label,
+ event = TABLET_SET_GPS_EVENT,
+ data = { coords = call.coords },
+ },
+ {
+ title = Locales.dispatch_respond,
+ event = TABLET_ANSWER_EVENT,
+ data = { callId = call.id },
+ },
+ },
+ },
+ })
+end
+
+RegisterNetEvent(TABLET_SET_GPS_EVENT, function(data)
+ local coords = data and data.coords
+ if not coords then return end
+
+ SetNewWaypoint(coords.x, coords.y)
+ Bridge.client.notify(Locales.notify_title, Locales.notify_dispatch_set_on_gps, 3000, "success")
+end)
+
+RegisterNetEvent(TABLET_ANSWER_EVENT, function(data)
+ if not data or not data.callId then return end
+ Luxu.triggerServerEvent("dispatch:answer", data.callId)
+end)
+
local function AddApp()
Wait(500) -- Wait for the AddCustomApp export to be registered
end
AddEventHandler("onResourceStart", function(resource)
- if resource == resourceName and GetResourceState("lb-tablet") == "started" then
+ if not Config.external_tablets.enabled then return end
+
+ if resource == resourceName and IsResourceRunning("lb-tablet") then
AddApp()
elseif resource == "lb-tablet" then
AddApp()bridge/server/garage.lua
+---@class GarageUtils
+local garage = {}
+
+function garage.giveVehicleKeys(source, netId, plate, modelHash)
+ if GetResourceState("qbx_vehiclekeys") == "started" then
+ return exports.qbx_vehiclekeys:GiveKeys(source, netId)
+ elseif GetResourceState("okokGarage") == "started" then
+ return TriggerEvent("okokGarage:GiveKeys", source, plate)
+ elseif GetResourceState("qb-vehiclekeys") == "started" then
+ return exports.qb_vehiclekeys:GiveKeys(source, plate)
+ elseif GetResourceState("cd_garage") == "started" then
+ return TriggerClientEvent("cd_garage:AddKeys", source, plate)
+ elseif GetResourceState("gflp10-carkeys") == "started" then
+ return exports.gflp10_carkeys:AddCarkey(source, modelHash, plate)
+ elseif GetResourceState("wasabi_carlock") == "started" then
+ return exports.wasabi_carlock:GiveKey(source, plate, false)
+ elseif GetResourceState("MrNewbVehicleKeys") == "started" then
+ return exports.MrNewbVehicleKeys:GiveKeysByPlate(source, plate)
+ end
+
+ --- Fallback
+ TriggerClientEvent("vehiclekeys:client:SetOwner", source, plate)
+end
+
+return garagebridge/server/player.lua
---@param charId string
function player.getPlayerByCharId(charId)
- if Framework.name == 'esx' then
+ if Framework.name == "esx" then
return Framework.object.GetPlayerFromIdentifier(charId)
else
return Framework.object.Functions.GetPlayerByCitizenId(charId)
---@param charId string
function player.getOfflinePlayerByCharId(charId)
- if Framework.name == 'qb' or Framework.name == "qbx" then
+ if Framework.name == "qb" or Framework.name == "qbx" then
return Framework.object.Functions.GetOfflinePlayerByCitizenId(charId)
else
local result = MySQL.single.await("SELECT * FROM `users` WHERE `identifier` = ?", { charId })
local levelNum = tonumber(level)
if levelNum and data then
normalized[levelNum] = {
- name = data.name,
- label = data.label or data.name,
+ name = data.name,
+ label = data.label or data.name,
payment = data.payment or data.salary or 0,
- isBoss = data.isboss == true or data.isBoss == true or
+ isBoss = data.isboss == true or data.isBoss == true or
(Framework.name == "esx" and lib.table.contains(Config.job.boss_grades, data.name))
}
end
end
return {
- name = job.name,
- label = job.label,
- grade = job.grade,
- gradeName = job.grade_name,
+ name = job.name,
+ label = job.label,
+ grade = job.grade,
+ gradeName = job.grade_name,
gradeLabel = job.grade_label,
- onDuty = onDuty,
- salary = job.payment,
- isBoss = isBoss
+ onDuty = onDuty,
+ salary = job.payment,
+ isBoss = isBoss
}
else
local job = playerObject.PlayerData.job
return {
- name = job.name,
- label = job.label,
- grade = job.grade.level,
- gradeName = job.grade.name,
+ name = job.name,
+ label = job.label,
+ grade = job.grade.level,
+ gradeName = job.grade.name,
gradeLabel = job.grade.name,
- onDuty = job.onduty,
- salary = job.payment,
- isBoss = job.isboss == true
+ onDuty = job.onduty,
+ salary = job.payment,
+ isBoss = job.isboss == true
}
end
end
local playerObject = player.getPlayerObject(source)
if not playerObject then return false end
+ if GetResourceState("okokBossMenu") == "started" then
+ return exports["okokBossMenu"]:toggleJobDuty(source, onDuty)
+ end
+
if Framework.name == "qbx" then
exports.qbx_core:SetJobDuty(source, onDuty)
return true
end
if Framework.name == "esx" then
- if playerObject.setDuty then
- playerObject.setDuty(onDuty)
- return true
+ local job = playerObject.getJob()
+ if not job then return false end
+
+
+ local jobName = job.name or ""
+ if jobName:sub(1, 4) == "off_" then
+ jobName = jobName:sub(5)
end
- if playerObject.setJobDuty then
- playerObject.setJobDuty(onDuty)
+ -- Modern ESX: duty is job.onDuty, set via setJob(name, grade, onDuty).
+ if type(job.onDuty) == "boolean" then
+ playerObject.setJob(jobName, job.grade, onDuty)
return true
end
- local job = playerObject.getJob and playerObject.getJob() or nil
- if not job then return false end
-
- local jobName = job.name or ""
- local targetJob = jobName
- if onDuty and jobName:sub(1, 4) == "off_" then
- targetJob = jobName:sub(5)
- elseif not onDuty and jobName:sub(1, 4) ~= "off_" then
- targetJob = "off_" .. jobName
+ -- Legacy: off_<job> rename. Invalid on current ESX (job does not exist).
+ local currentName = job.name or ""
+ local targetJob = currentName
+ if onDuty and currentName:sub(1, 4) == "off_" then
+ targetJob = currentName:sub(5)
+ elseif not onDuty and currentName:sub(1, 4) ~= "off_" then
+ targetJob = "off_" .. currentName
end
- if targetJob == jobName then return true end
+ if targetJob == currentName then return true end
if playerObject.setJob then
playerObject.setJob(targetJob, job.grade)
return true
end
-
- if Framework.object.SetPlayerJob then
- Framework.object.SetPlayerJob(source, targetJob, job.grade)
- return true
- end
end
return false
---@param reason string
function player.addMoney(playerObject, account, amount, reason)
-- Cash as Item ? Missing checks?
- if account == 'cash' and GetResourceState('ox_inventory') == 'started' then
- local current = exports.ox_inventory:GetItem(source, 'cash', nil, true) or 0 --[[ @as number ]]
- exports.ox_inventory:RemoveItem(source, 'cash', current)
- exports.ox_inventory:AddItem(source, 'cash', current + amount)
+ if account == "cash" and GetResourceState("ox_inventory") == "started" then
+ local current = exports.ox_inventory:GetItem(source, "cash", nil, true) or 0 --[[ @as number ]]
+ exports.ox_inventory:RemoveItem(source, "cash", current)
+ exports.ox_inventory:AddItem(source, "cash", current + amount)
return
end
reason = reason or "luxu_ambulancejob:unknown"
-- Cash as Item ? Missing checks?
- if account == 'cash' and GetResourceState('ox_inventory') == 'started' then
- local current = exports.ox_inventory:GetItem(source, 'cash', nil, true) or 0 --[[ @as number ]]
- exports.ox_inventory:RemoveItem(source, 'cash', current)
- exports.ox_inventory:AddItem(source, 'cash', current - amount)
+ if account == "cash" and GetResourceState("ox_inventory") == "started" then
+ local current = exports.ox_inventory:GetItem(source, "cash", nil, true) or 0 --[[ @as number ]]
+ exports.ox_inventory:RemoveItem(source, "cash", current)
+ exports.ox_inventory:AddItem(source, "cash", current - amount)
return
end
---@return BloodType
function player.getBloodType(playerObject)
if Framework.name == "esx" then
- local bloodType = playerObject.getMeta('bloodtype')
+ local bloodType = playerObject.getMeta("bloodtype")
if not bloodType then
bloodType = BLOODTYPES[math.random(1, #BLOODTYPES)]
- playerObject.setMeta('bloodtype', bloodType)
+ playerObject.setMeta("bloodtype", bloodType)
end
return bloodType
else
---@return BloodType
function player.getBloodType(playerObject)
if Framework.name == "esx" then
- local bloodType = playerObject.getMeta('bloodtype')
+ local bloodType = playerObject.getMeta("bloodtype")
if not bloodType then
bloodType = BLOODTYPES[math.random(1, #BLOODTYPES)]
- playerObject.setMeta('bloodtype', bloodType)
+ playerObject.setMeta("bloodtype", bloodType)
end
return bloodType
elsebridge/server/society.lua
---@class SocietyFunctions
local society = {}
+--- Funds live in esx_addonaccount. Resolve the registered account name first.
+---@param job string
+---@return table|nil
+local function getEsxSocietyAccount(job)
+ local society = exports["esx_society"]:GetSociety(job)
+ local accountName = society and society.account or ("society_" .. job)
+ if not IsResourceRunning("esx_addonaccount") then
+ return
+ end
+ return exports["esx_addonaccount"]:GetSharedAccount(accountName)
+end
+
function society.getMoney(job)
if IsResourceRunning("Renewed-Banking") then
return exports["Renewed-Banking"]:getAccountMoney(job)
if IsResourceRunning("qb-banking") then
return exports["qb-banking"]:GetAccountBalance(job)
end
+ if IsResourceRunning("esx_society") then
+ local account = getEsxSocietyAccount(job)
+ return account and account.money or 0
+ end
return 0
end
exports["Renewed-Banking"]:addAccountMoney(job, amount, reason)
elseif IsResourceRunning("qb-banking") then
exports["qb-banking"]:AddMoney(job, amount, reason)
+ elseif IsResourceRunning("esx_society") then
+ local account = getEsxSocietyAccount(job)
+ if account then
+ account.addMoney(amount)
+ end
end
end
exports["Renewed-Banking"]:removeAccountMoney(job, amount, reason)
elseif IsResourceRunning("qb-banking") then
exports["qb-banking"]:RemoveMoney(job, amount, reason)
+ elseif IsResourceRunning("esx_society") then
+ local account = getEsxSocietyAccount(job)
+ if account then
+ account.removeMoney(amount)
+ end
end
endconfig/compatibility/server.lua
StatusServer.revivePlayer(target, true)
end
end)
+
+function ExportProvider(resource, exportName, func)
+ AddEventHandler(("__cfx_export_%s_%s"):format(resource, exportName), func)
+end
+
+ExportProvider("wasabi_ambulance_v2", "RevivePlayer", function(serverId)
+ StatusServer.revivePlayer(serverId, true)
+end)
+ExportProvider("wasabi_ambulance_v2", "ApplyHeal", function(serverId, options)
+ StatusServer.revivePlayer(serverId, false)
+end)config/config.lua
+local state_bags = {
+ --[[ Common State Bags from other frameworks]]
+ dead = "dead",
+ last_standing = "laststanding",
+ thirst = "thirst",
+ hunger = "hunger",
+}
+
return {
debug = true,
dev_mode = true, -- This is used for our development mode, might have issues, don't use it.
},
locales = {
- lang = "en", -- locales/{lang}.json
+ lang = "pt", -- locales/{lang}.json
code = "en-US",
symbol_fallback = "$",
unit_system = "metric", -- "metric" | "imperial"
debug = false
},
- state_bags = {
- --[[ Luxu Medicine ]]
- blood_level = "luxu_medicine:blood_level",
- cold = "luxu_medicine:cold",
- heart_rate = "luxu_medicine:heart_rate",
- spo2 = "luxu_medicine:spo2",
- temperature = "luxu_medicine:temperature",
- },
+ state_bags = state_bags,
- unsafe_client_events = {
- enabled = false,
- --- Adds backwards compatibility client events for reviving player.
- --- This can be exploited by cheaters. We can't protect against this, if enabled.
+ --[[ External Tablets like lb-tablet ]]
+ external_tablets = {
+ enabled = true, --If true, lb-tablet app will be loaded immediately.
+ disable_dispatch = true -- Disables the small dispatch menu, uses tablet notifications instead.
},
dependencies = {
enabled = false,
health_penalty = 1,
check_interval = 60, -- seconds
- state_bags = { "hunger", "thirst" }
+ state_bags = { state_bags.hunger, state_bags.thirst }
},
features = {
diseases = true,
},
+
+
--[[ Job ]]
job = {
job_name = "ambulance",
spawn_altitude = 120.0,
landing_search_radius = 60.0,
},
+ limits = {
+ max_concurrent_units = 10,
+ },
+
+ --[[ Blacklist zones for AI to not spawn in ]]
+ blacklist_zones = {
+ --[[ { label = "PVP Zone", coords = vector3(185.8, -1040.4, 29.3), radius = 100.0 }, ]]
+ }
},
commands = {
- ["dispatch"] = {
+ ["ems"] = {
enabled = true,
- name = "dispatch",
- description = "Open the dispatch menu",
+ name = "ems",
+ description = "Open the EMS Tablet",
jobs = { "ambulance" },
keybind = {
enabled = true,
- default_key = "F10"
+ default_key = "F11"
}
},
- ["dispatchsettings"] = {
- name = "dispatchsettings",
- description = "Open the HUD layout editor",
- },
- ["mobility"] = {
- name = "mobility",
- description = "Toggle prescribed crutch or wheelchair",
- },
- ["ems"] = {
- name = "ems",
- description = "Open the EMS menu",
+ ["dispatch"] = {
+ enabled = true,
+ name = "dispatch",
+ description = "Open the dispatch menu",
jobs = { "ambulance" },
keybind = {
enabled = true,
- default_key = "F11"
+ default_key = "F10"
}
},
- ["patientactions"] = {
- name = "ems:patient",
- description = "Open the patient action hotbar",
- jobs = { "ambulance" },
- },
["guidebook"] = {
+ enabled = true,
name = "ems:guidebook",
description = "Open the guidebook",
jobs = { "ambulance" },
},
+ ["settings"] = {
+ enabled = true,
+ name = "ems:settings",
+ description = "Open the HUD layout editor",
+ },
["config"] = {
+ enabled = true,
name = "ems:config",
- description = "Open Luxu Creator (hospital or pharmacy tools)",
+ description = "Open Hospital/Pharmacy/Laboratory creator",
+ },
+ ["mobility"] = {
+ enabled = true,
+ name = "mobility",
+ description = "Toggle prescribed crutch or wheelchair",
},
+
--[[ ⭐ Admin Commands ]]
["revive"] = {
+ enabled = true,
name = "revive",
description = "Revive Player",
params = {config/hooks/client.lua
--[[ Actual Hooks that can prevent actions ]]
-
+--[[
---@enum PlayerState
STATES = {
ALIVE = "alive",
DEAD = "dead",
}
+ ]]
+---@class ClientHooks
+local hooks = {}
+--- Called before the local player's life state changes (alive / last_standing / dead).
---@param currentState PlayerState
---@param newState PlayerState
-function Hooks.changeState(currentState, newState)
+---@return boolean
+function hooks.changeState(currentState, newState)
+ return true
+end
+--- Called when the deathscreen should open (`true`) or close (`false`).
+--- Return false to hide the built-in overlay and screen effects.
+---@param visible boolean
+---@return boolean
+function hooks.toggleDeathscreen(visible)
+ return true
end
---- Called when the player dies
+--- Called before an automatic distress signal is sent (last standing or dead).
---@return boolean
-function Hooks.sendDistressSignal()
+function hooks.sendDistressSignal()
return true
end
+
+return hooksconfig/hooks/server.lua
--AddEventHandler("luxu_medicine:server:player_revived", function(targetSource) end)
--AddEventHandler("luxu_medicine:server:player_healed", function(targetSource) end)
--AddEventHandler("luxu_medicine:server:player_injured", function(targetSource) end)
+
+local hooks = {}
+--[[ Todo ]]
+
+return hooksfxmanifest.lua
author "luxu-gg <[email protected]>"
description "Luxu Medicine - Ambulance Job"
-version "0.0.3"
+version "0.0.4"
dependencies {
"oxmysql",[addons]/crutches/fxmanifest.lua
[addons]/iak_wheelchair/fxmanifest.lua
[addons]/medical_props/fxmanifest.lua
Full list of files changed
| Change | File |
|---|---|
| Modified | bridge/client/tablets.lua |
| Added | bridge/server/garage.lua |
| Modified | bridge/server/player.lua |
| Modified | bridge/server/society.lua |
| Modified | config/compatibility/server.lua |
| Modified | config/config.lua |
| Modified | config/hooks/client.lua |
| Modified | config/hooks/server.lua |
| Modified | data/hospitals.json |
| Modified | data/shops.json |
| Modified | fxmanifest.lua |
| Modified | [addons]/crutches/fxmanifest.lua |
| Modified | [addons]/iak_wheelchair/fxmanifest.lua |
| Modified | [addons]/medical_props/fxmanifest.lua |
| Modified | locales/en.json |
| Added | locales/pt.json |
| Modified | client/cl_main.lua |
| Modified | client/handlers/bootstrap.lua |
| Modified | client/handlers/complaints.lua |
| Modified | client/handlers/placement.lua |
| Modified | client/handlers/targets.lua |
| Modified | client/lib/animations.lua |
| Modified | client/modules/ai.lua |
| Modified | client/modules/conditions/blackout_effects.lua |
| Modified | client/modules/conditions/blood_splash_loop.lua |
| Modified | client/modules/conditions/movement_effects.lua |
| Modified | client/modules/garage.lua |
| Modified | client/modules/laboratory/laboratory.lua |
| Modified | client/modules/mobility.lua |
| Modified | client/modules/status.lua |
| Modified | client/modules/treatment/cpr.lua |
| Modified | client/modules/treatment/defib.lua |
| Modified | client/modules/treatment/patient_hotbar.lua |
| Modified | client/modules/treatment/stretcher.lua |
| Modified | client/modules/treatment/triage.lua |
| Modified | client/modules/vitals.lua |
| Modified | server/features/dispatch.lua |
| Modified | server/features/shops.lua |
| Modified | server/features/treatment.lua |
| Modified | server/modules/conditions.lua |
| Modified | server/modules/garage.lua |
| Modified | server/modules/medical_bag.lua |
| Modified | server/modules/minigames.lua |
| Modified | server/modules/mobility.lua |
| Modified | server/modules/player2player.lua |
| Modified | server/modules/shop.lua |
| Modified | server/modules/viruses.lua |
| Modified | server/modules/vitals.lua |
| Modified | server/sv_main.lua |
| Modified | server/sv_status.lua |
| Modified | shared/init.lua |
| Modified | shared/types.lua |
| Modified | shared/utils.lua |
| Modified | web/ |