RedM documentation

Murphy's Job Board

Advanced

Create a job - Advanced Guide


text
┌─────────────────────────────────────────────────────────────┐
│ Job File (config/jobs/xxx.lua) │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ JobData (shared) │ │ IsDuplicityVersion() split │ │
│ │ name, label, steps │───>│ Server: JobsConfig[name] │ │
│ │ board, image, reward│ │ Client: actions + Job:new() │ │
│ └─────────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌──────────────┐
│ JobManager │ │ Callbacks │ │ Adapters │
│ (client) │ │ (shared) │ │ (server-side)│
│ │ │ │ │ │
│ Job:new() │ │ triggerSvr │ │ AddCurrency │
│ Job:start()│ │ triggerCli │ │ GiveItem │
│ Job:stop() │ │ register() │ │ RemoveItem │
│ nextStep() │ │ │ │ GetCharJob │
└────────────┘ └────────────┘ └──────────────┘

Key insight: Job files run on both server and client. The IsDuplicityVersion() check splits the logic:

  • Server side only stores the job's metadata in JobsConfig (for poster generation)
  • Client side contains all gameplay logic: actions, entity spawning, interactions, cleanup

Job Lifecycle

text
Player accepts poster
│
▼
Job:start(step?)
│
├── Saves progression to DB (charid, job name, step 1)
│
▼
executeCurrentStep() ──── Calls actions[step.action](job)
│
│ (player completes the step's objective)
│
▼
job:nextStep()
│
├── Increments currentStep
├── Updates DB progression
├── Updates active job UI info
│
▼
executeCurrentStep() ──── Next action...
│
│ ... repeats until final step ...
│
▼
Final step sets job.completed = true
│
├── Saves nil to DB (clears progression)
└── Cleanup (Utils.StopJob)
── OR ──
Player abandons job
│
▼
Job:stop()
│
├── Sets job.abandon = true
├── Saves nil to DB
└── Abandon watcher threads detect flag → cleanup

Resuming After Disconnect

When a player connects, the GetProgression server callback checks the DB for an existing record. If found, the client receives (jobName, stepNumber) and calls Job:start(step) to resume from where they left off.


Writing Custom Actions

Action Function Signature

Every action receives the job object as its only parameter:

lua
actions["MyAction"] = function(job)
local data = job.steps[job.currentStep] -- Access step-specific data
local stepIndex = job.currentStep -- Current step number (1-based)
local jobName = job.name -- Unique job identifier
-- ... do work ...
job:nextStep() -- Advance to next step when done
end

The job object exposes:

Property/Method Type Description
job.name string Unique job identifier (from filename())
job.label string Display name
job.steps table[] All step definitions
job.currentStep number Index of the currently executing step (1-based)
job.completed boolean True when all steps are done
job.abandon boolean True when the player abandoned the job
job.activejobinfo table Data sent to the active job UI overlay
job:start(step?) function Start the job (optionally at a specific step)
job:stop() function Abandon the job
job:nextStep() function Advance to the next step
job:executeCurrentStep() function Run the current step's action

The Abandon Watcher Pattern

Every action that spawns entities or creates interactions must include an abandon watcher thread. This is the standard cleanup pattern used across all job types:

lua
actions["MyAction"] = function(job)
-- ... setup logic ...
-- REQUIRED: Abandon watcher
Citizen.CreateThread(function()
local step = job.currentStep -- Capture the step at creation time
while true do
Wait(1000)
-- Triggers when step advances OR job is abandoned
if step ~= job.currentStep or job.abandon == true then
-- Clean up step-specific resources
RemoveBlip(blip)
if job.abandon == true then
-- Full cleanup on abandon
exports.murphy_interact:RemoveInteraction(job.name .. "_step" .. step)
ClearGpsMultiRoute()
Utils.StopJob()
end
break
end
end
end)
end

Why this works: The watcher captures job.currentStep at the time the action starts. When job:nextStep() increments it, step ~= job.currentStep becomes true, triggering cleanup. If the player abandons, job.abandon is set to true by Job:stop().

Complete Example: Custom Action

Here's a fishing-spot action where the player must stand near water and play a fishing animation:

lua
actions["FishAtSpot"] = function(job)
local data = job.steps[job.currentStep]
local coords = data.coords
local duration = data.duration or 10000
TriggerEvent("murphy_jobpanel:ShowObjective", data.description, 4000)
local blip = CreateBlip(coords, data.label, 'blip_objective', `BLIP_STYLE_OBJECTIVE`)
table.insert(blips, blip)
-- GPS route to the location
StartGpsMultiRoute(GetHashKey("COLOR_OBJECTIVE"), true, true)
Citizen.InvokeNative(0x64C59DD6834FA942, coords)
Citizen.InvokeNative(0x4426D65E029A4DC0, true)
exports.murphy_interact:AddInteraction({
coords = vector3(coords.x, coords.y, coords.z + 1.5),
distance = 8.0,
interactDst = 3.0,
id = job.name .. "_step" .. job.currentStep,
options = {
{
label = "Start fishing",
action = function(entity, crds, args)
local ped = PlayerPedId()
TaskStartScenarioInPlace(ped, "WORLD_HUMAN_RIVER_FISHING", -1, true)
Citizen.Wait(duration)
ClearPedTasks(ped)
ClearGpsMultiRoute()
exports.murphy_interact:RemoveInteraction(
job.name .. "_step" .. job.currentStep
)
job:nextStep()
end,
},
}
})
-- Abandon watcher
Citizen.CreateThread(function()
local step = job.currentStep
while true do
Wait(1000)
if step ~= job.currentStep or job.abandon == true then
RemoveBlip(blip)
if job.abandon == true then
exports.murphy_interact:RemoveInteraction(job.name .. "_step" .. step)
ClearGpsMultiRoute()
Utils.StopJob()
end
break
end
end
end)
end

The Callback System

Murphy JobPanel uses a custom lightweight RPC system (not framework callbacks) defined in config/Callbacks.lua.

Client -> Server

lua
Callback.triggerServer('CallbackName', function(result)
-- Handle the result
print("Server returned:", result)
end, arg1, arg2, ...)

Server -> Client

lua
Callback.triggerClient('CallbackName', source, function(result)
print("Client returned:", result)
end, arg1, arg2, ...)

Available Server Callbacks

These are pre-registered and ready to use in your actions:

Callback Name Arguments Returns Description
AddCurrency amount - Add money to the player
RemoveCurrency amount - Remove money from the player
GetCharMoney - number Get the player's cash balance
GetItemAmount item number Count of an item in inventory
GiveItem item, amount, meta boolean Add item to inventory
RemoveItem item, amount, meta boolean Remove item from inventory
GetCharJob - job, grade Get player's current RP job
GetCharIdentifier - string Get character ID
GetProgression - job, step Load saved job progression
RequestJobPosters boardName table[] Get available jobs for a board
TakeJobPoster jobName boolean Claim a job from the board

Usage example (paying the player):

lua
-- In a CompleteJob action:
Callback.triggerServer('AddCurrency', function()
-- Currency added, continue
end, job.steps[job.currentStep].reward)

Usage example (checking inventory):

lua
Callback.triggerServer('GetItemAmount', function(amount)
if amount >= 5 then
Callback.triggerServer('RemoveItem', function(success)
if success then
job:nextStep()
end
end, "water", 5, {})
else
TriggerEvent("murphy_jobpanel:ShowObjective", "You need 5 water!", 4000)
end
end, "water")

Working with Entities

Spawning Vehicles

lua
local vehicleHash = GetHashKey("chuckwagon002x")
RequestModel(vehicleHash)
while not HasModelLoaded(vehicleHash) do Wait(1) end
local vehicle = CreateVehicle(
vehicleHash,
coords.x, coords.y, coords.z,
heading, -- float: spawn rotation
true, true, -- isNetwork, thisScriptCheck
false, true -- p6, p7
)
SetEntityAsMissionEntity(vehicle, true, true)

Important: Always call SetEntityAsMissionEntity to prevent the game from despawning your vehicle.

Spawning Peds/Animals

lua
local model = GetHashKey("a_c_wolf")
RequestModel(model)
while not HasModelLoaded(model) do Wait(1) end
local ped = CreatePed(model, x, y, z, heading, true, true)
SetEntityAsMissionEntity(ped, true, true)
SetRandomOutfitVariation(ped, true) -- Randomize appearance
SetModelAsNoLongerNeeded(model) -- Free the model from memory

For NPCs that shouldn't die:

lua
SetEntityCanBeDamaged(npc, false)
SetEntityInvincible(npc, true)

For animals that wander:

lua
TaskWanderInArea(animal, x, y, z, radius, 0, 0)

Attaching Objects

Objects can be attached to vehicles or peds using the native 0x6B9BBD38AB0796DF (AttachEntityToEntity):

Attach to vehicle (packages on a carriage):

lua
Citizen.InvokeNative(0x6B9BBD38AB0796DF,
packageEntity, -- entity to attach
vehicle, -- target entity
0, -- bone index
offsetX, offsetY, offsetZ, -- position offset
rotX, rotY, rotZ, -- rotation offset
0, 0, 0, 0, -- additional params
2, -- attachment type
true -- collision
)

Attach to ped (carrying a box):

lua
LoadAnim("mech_carry_box")
Citizen.InvokeNative(0xEA47FE3719165B94, ped, "mech_carry_box", "idle", 1.0, 8.0, -1, 31, 0, 0, 0, 0)
Citizen.InvokeNative(0x6B9BBD38AB0796DF,
packageEntity,
ped,
GetEntityBoneIndexByName(ped, "SKEL_R_Finger12"),
0.20, -0.028, 0.001, -- position
15.0, 175.0, 0.0, -- rotation
true, true, false, true,
1, true
)

Interaction System (murphy_interact)

The job system uses murphy_interact for player-to-world interactions. Three types are available:

Static World Interaction

lua
exports.murphy_interact:AddInteraction({
coords = vector3(x, y, z),
distance = 8.0, -- Draw distance
interactDst = 2.5, -- Interaction distance
id = "unique_id", -- Used for removal
options = {
{
label = "Do something",
action = function(entity, coords, args)
-- Triggered when player interacts
end,
},
}
})
-- Remove when done:
exports.murphy_interact:RemoveInteraction("unique_id")

Entity Interaction (networked)

lua
exports.murphy_interact:AddEntityInteraction({
netId = NetworkGetNetworkIdFromEntity(entity),
id = "unique_id",
distance = 8.0,
interactDst = 4.0,
options = { ... }
})
-- Remove:
exports.murphy_interact:RemoveEntityInteraction(netId, "unique_id")

Local Entity Interaction (client-only)

lua
exports.murphy_interact:AddLocalEntityInteraction({
entity = entity,
name = "interactionName",
id = entity, -- Can use entity handle as ID
distance = 5.0,
interactDst = 3.0,
offset = vec3(0.0, 0.0, 0.5),
ignoreLos = true,
options = { ... }
})
-- Remove:
exports.murphy_interact:RemoveLocalEntityInteraction(entity, entity)

Best practice for IDs: Use job.name .. "_step" .. job.currentStep to ensure unique IDs that can be cleaned up per-step.


Blips and GPS Routes

Creating Blips

lua
-- Standard blip at coordinates
local blip = CreateBlip(coords, "Label", 'blip_objective', `BLIP_STYLE_OBJECTIVE`)
-- Radius blip (circular area on map)
local blip = CreateBlipRadius(coords, radius, "Label", 'blip_objective', `BLIP_STYLE_OBJECTIVE`)
-- Entity blip (follows a ped/vehicle)
local blip = CreateBlipEntity(entity, "Label", `BLIP_STYLE_OBJECTIVE`, nil, `blip_ambient_ped_small`)

GPS Route

Draw a GPS route line on the minimap:

lua
-- Start route
StartGpsMultiRoute(GetHashKey("COLOR_OBJECTIVE"), true, true)
Citizen.InvokeNative(0x64C59DD6834FA942, destination) -- Add waypoint
Citizen.InvokeNative(0x4426D65E029A4DC0, true) -- Render route
-- Clear route
ClearGpsMultiRoute()

Blip Styles

Common blip styles used in the resource:

Hash Usage
`BLIP_STYLE_OBJECTIVE` Standard objective
`BLIP_STYLE_NEUTRAL` Neutral/waiting
`BLIP_STYLE_MP_MISSION_GIVER` Warning/attention
`BLIP_STYLE_HERDING_ANIMAL` Herding

Always Clean Up Blips

Track all blips in a local table and remove them in Utils.StopJob():

lua
local blips = {}
-- When creating:
table.insert(blips, blip)
-- In StopJob:
for _, blip in ipairs(blips) do
if blip then RemoveBlip(blip) end
end
blips = {}

Ordered vs Random Patterns

Ordered Pattern

Locations are assigned to steps sequentially in Utils.createJob():

lua
function Utils.createJob(jobData)
local deliveryIndex = 1
for i, step in ipairs(jobData.steps) do
if step.action == "DeliverPackage" then
local location = deliveries[deliveryIndex]
step.coords = location.Pos
step.description = location.Description
deliveryIndex = deliveryIndex + 1
end
step.action = actions[step.action]
end
return Job:new(jobData)
end

The player always visits locations in the same order.

Random Pattern

Locations are randomly assigned, with deduplication to avoid repeats:

lua
function Utils.createJob(jobData)
local usedLocations = {}
for _, step in ipairs(jobData.steps) do
if step.action == "DeliverPackage" then
local location
repeat
location = deliveries[math.random(#deliveries)]
until not usedLocations[location]
usedLocations[location] = true
step.coords = location.Pos
step.description = location.Description
end
step.action = actions[step.action]
end
return Job:new(jobData)
end

Note for random delivery jobs: The NewLocation() function also randomizes at runtime (not just at creation), so each job run gives a different route.

Rule of thumb: Make sure you have more locations in the deliveries table than DeliverPackage steps, otherwise the random selection loop will hang.


Inventory Integration

For jobs that interact with the player's inventory, use the server callbacks:

Check if player has items

lua
Callback.triggerServer('GetItemAmount', function(amount)
if amount >= requiredAmount then
-- Player has enough
end
end, "item_name")

Remove items from player

lua
Callback.triggerServer('RemoveItem', function(success)
if success then
-- Item removed successfully
end
end, "item_name", amount, {}) -- Third arg is metadata (empty table if none)

Give items to player

lua
Callback.triggerServer('GiveItem', function(success)
if success then
-- Item given
end
end, "item_name", amount, {})

Relationship Groups (Combat/Herding)

Aggressive Animals (Hunting)

Make animals hostile toward the player:

lua
AddRelationshipGroup("animalstokill")
SetRelationshipBetweenGroups(5, GetHashKey("animalstokill"), GetHashKey("PLAYER")) -- 5 = Hate
SetRelationshipBetweenGroups(5, GetHashKey("PLAYER"), GetHashKey("animalstokill"))
-- Assign to spawned animal:
SetPedRelationshipGroupHash(animal, GetHashKey("animalstokill"))
SetPedCombatAttributes(animal, 58, true) -- CA_DISABLE_FLEE_FROM_COMBAT
-- Prevent fleeing behavior:
Citizen.InvokeNative(0x9FF1E042FA597187, animal, 22, 0) -- ATB_FleeMountedPeds = false
Citizen.InvokeNative(0x9FF1E042FA597187, animal, 88, 1) -- ATB_WaitInsteadOfFleeForUnreachableVolumes = true

Passive Animals (Herding)

Make animals neutral but responsive to player proximity:

lua
AddRelationshipGroup("herdanimals")
SetRelationshipBetweenGroups(1, GetHashKey("herdanimals"), GetHashKey("PLAYER")) -- 1 = Respect
SetRelationshipBetweenGroups(1, GetHashKey("PLAYER"), GetHashKey("herdanimals"))
-- Configure cow behavior:
SetAnimalTuningBoolParam(cow, 25, false) -- Disable specific flee behavior
SetAnimalTuningBoolParam(cow, 24, false)
TaskAnimalUnalerted(cow, -1, false, 0, 0)
SetPedConfigFlag(cow, 297, true)
SetPedConfigFlag(cow, 310, true)
SetPedConfigFlag(cow, 40, false)
-- Disable mounting and lassoing:
Citizen.InvokeNative(0xAE6004120C18DF97, cow, 5, false)
Citizen.InvokeNative(0xAE6004120C18DF97, cow, 6, false)
-- Move cow toward destination via road navmesh:
TaskMoveFollowRoadUsingNavmesh(cow, 2.0, dest.x, dest.y, dest.z, 0)

The Utils Pattern

Every job file follows the same organizational pattern with a Utils table:

lua
local Utils = {}
local actions = {}
-- Action definitions
actions["StepName"] = function(job) ... end
actions["CompleteJob"] = function(job) ... end
-- Cleanup function (REQUIRED)
function Utils.StopJob()
-- Delete all spawned entities
-- Remove all blips
-- Clear GPS routes
-- Reset local state variables
end
-- Job factory (REQUIRED)
function Utils.createJob(jobData)
for i, step in ipairs(jobData.steps) do
-- Map data from location tables to steps
-- Convert string action names to function references
step.action = actions[step.action]
end
return Job:new(jobData)
end
-- Create and return the job instance
local job = Utils.createJob(JobData)
-- Resource cleanup handler (REQUIRED)
AddEventHandler("onResourceStop", function(resourceName)
if GetCurrentResourceName() ~= resourceName then return end
Utils.StopJob()
end)
return job

The three required functions:

  1. Utils.StopJob() - cleans up all entities and state
  2. Utils.createJob(jobData) - maps location data to steps and creates the Job instance
  3. onResourceStop handler - cleanup when resource is restarted

Building a Complete Custom Job

Here's a full example of a Mining Job where the player goes to mine locations, plays mining animations, and collects ore:

lua
local JobData = {
name = filename(),
label = "Mining",
description = "Extract ore from the Annesburg mines",
steps = {
{ action = "GoToMine" },
{ action = "MineOre" },
{ action = "MineOre" },
{ action = "MineOre" },
{ action = "DeliverOre", reward = 75, label = "Deliver", description = "Deliver the ore" },
},
board = { "vanhorn" },
image = "./img/Poster_art/camp.png",
reward = "$75",
}
if IsDuplicityVersion() then
JobsConfig[filename()] = JobData
else
local lang = {
["Go to mine"] = "Go to the mine entrance",
["Mine ore"] = "Mine ore",
["Deliver"] = "Deliver the ore",
["Complete"] = "Job complete!",
}
local mineEntrance = vector3(2933.0, 1280.0, 44.0)
local oreSpots = {
{ coords = vector3(2940.0, 1285.0, 44.0), description = "Mine the first vein" },
{ coords = vector3(2945.0, 1290.0, 44.0), description = "Mine the second vein" },
{ coords = vector3(2950.0, 1295.0, 44.0), description = "Mine the third vein" },
}
local deliveryPoint = vector3(2984.0, 561.65, 43.45) -- Van Horn
local oreCount = 0
local blips = {}
local Utils = {}
local actions = {}
actions["GoToMine"] = function(job)
local blip = CreateBlip(mineEntrance, lang["Go to mine"], 'blip_objective', `BLIP_STYLE_OBJECTIVE`)
table.insert(blips, blip)
TriggerEvent("murphy_jobpanel:ShowObjective", lang["Go to mine"], 4000)
StartGpsMultiRoute(GetHashKey("COLOR_OBJECTIVE"), true, true)
Citizen.InvokeNative(0x64C59DD6834FA942, mineEntrance)
Citizen.InvokeNative(0x4426D65E029A4DC0, true)
Citizen.CreateThread(function()
while true do
Wait(500)
if #(GetEntityCoords(PlayerPedId()) - mineEntrance) < 5.0 then
ClearGpsMultiRoute()
RemoveBlip(blip)
job:nextStep()
break
end
end
end)
Citizen.CreateThread(function()
local step = job.currentStep
while true do
Wait(1000)
if step ~= job.currentStep or job.abandon == true then
RemoveBlip(blip)
if job.abandon == true then
ClearGpsMultiRoute()
Utils.StopJob()
end
break
end
end
end)
end
actions["MineOre"] = function(job)
local data = job.steps[job.currentStep]
local coords = data.coords
TriggerEvent("murphy_jobpanel:ShowObjective", data.description, 4000)
local blip = CreateBlip(coords, lang["Mine ore"], 'blip_objective', `BLIP_STYLE_OBJECTIVE`)
table.insert(blips, blip)
exports.murphy_interact:AddInteraction({
coords = vector3(coords.x, coords.y, coords.z + 1.0),
distance = 8.0,
interactDst = 2.0,
id = job.name .. "_step" .. job.currentStep,
options = {
{
label = lang["Mine ore"],
action = function(entity, crds, args)
local ped = PlayerPedId()
local startPos = GetEntityCoords(ped)
-- Play pickaxe animation
LoadAnim("amb_work@world_human_pickaxe@male_a@idle_a")
TaskPlayAnim(ped,
"amb_work@world_human_pickaxe@male_a@idle_a",
"idle_a", 8.0, 8.0, -1, 25, 0, 0, 0, 0
)
Wait(8000)
if #(GetEntityCoords(ped) - startPos) > 5.0 then
ClearPedTasks(ped)
TriggerEvent("murphy_jobpanel:ShowObjective",
"You moved too far!", 4000)
else
ClearPedTasks(ped)
oreCount = oreCount + 1
exports.murphy_interact:RemoveInteraction(
job.name .. "_step" .. job.currentStep
)
RemoveBlip(blip)
job:nextStep()
end
end,
},
}
})
Citizen.CreateThread(function()
local step = job.currentStep
while true do
Wait(1000)
if step ~= job.currentStep or job.abandon == true then
RemoveBlip(blip)
if job.abandon == true then
exports.murphy_interact:RemoveInteraction(
job.name .. "_step" .. step
)
Utils.StopJob()
end
break
end
end
end)
end
actions["DeliverOre"] = function(job)
TriggerEvent("murphy_jobpanel:ShowObjective", lang["Deliver"], 4000)
local blip = CreateBlip(deliveryPoint, lang["Deliver"], 'blip_objective', `BLIP_STYLE_OBJECTIVE`)
table.insert(blips, blip)
StartGpsMultiRoute(GetHashKey("COLOR_OBJECTIVE"), true, true)
Citizen.InvokeNative(0x64C59DD6834FA942, deliveryPoint)
Citizen.InvokeNative(0x4426D65E029A4DC0, true)
exports.murphy_interact:AddInteraction({
coords = vector3(deliveryPoint.x, deliveryPoint.y, deliveryPoint.z + 1.0),
distance = 10.0,
interactDst = 3.0,
id = job.name .. "_step" .. job.currentStep,
options = {
{
label = lang["Complete"],
action = function()
Callback.triggerServer('AddCurrency', function()
exports.murphy_interact:RemoveInteraction(
job.name .. "_step" .. job.currentStep
)
job:nextStep()
Utils.StopJob()
end, job.steps[job.currentStep].reward)
end,
},
}
})
Citizen.CreateThread(function()
local step = job.currentStep
while true do
Wait(1000)
if step ~= job.currentStep or job.abandon == true then
RemoveBlip(blip)
if job.abandon == true then
exports.murphy_interact:RemoveInteraction(
job.name .. "_step" .. step
)
ClearGpsMultiRoute()
Utils.StopJob()
end
break
end
end
end)
end
function Utils.StopJob()
ClearGpsMultiRoute()
for _, blip in ipairs(blips) do
if blip then RemoveBlip(blip) end
end
blips = {}
oreCount = 0
end
function Utils.createJob(jobData)
local oreIndex = 1
for i, step in ipairs(jobData.steps) do
if step.action == "MineOre" then
local spot = oreSpots[oreIndex]
step.coords = spot.coords
step.description = spot.description
oreIndex = oreIndex + 1
end
step.action = actions[step.action]
end
return Job:new(jobData)
end
local job = Utils.createJob(JobData)
AddEventHandler("onResourceStop", function(resourceName)
if GetCurrentResourceName() ~= resourceName then return end
Utils.StopJob()
end)
return job
end

Framework Adapters

The adapter layer abstracts framework-specific APIs so job logic stays framework-agnostic. All adapters are in server/adapters/ and are guarded by:

lua
if Config.framework == "vorp" then
-- VORP-specific implementation
end

Adapter Interface

Every adapter implements these functions (called via server callbacks):

Function Purpose
GetCharIdentifier(id) Get character's unique identifier
GetCharFirstname(id) Get character's first name
GetCharLastname(id) Get character's last name
GetCharMoney(id) Get cash balance
AddCurrency(id, amt) Add cash
RemoveCurrency(id, amt) Remove cash
GetItemAmount(src, item) Get item count in inventory
GiveItem(src, item, amt, meta) Add item to inventory
RemoveItem(src, item, amt, meta) Remove item from inventory
GetCharJob(id) Get current job name
GetCharJobGrade(id) Get current job grade
GetCharGroup(id) Get permission group (admin, etc.)

Adding Support for a New Framework

  1. Create server/adapters/MyFrameworkAdapter.lua
  2. Wrap all code in if Config.framework == "myframework" then
  3. Implement all functions listed above using your framework's APIs
  4. Add the framework name to Config.framework options in config.lua

Database and Persistence

Schema

sql
CREATE TABLE `murphy_jobpanel` (
`id` INT(11) PRIMARY KEY AUTO_INCREMENT,
`charid` VARCHAR(200) DEFAULT 0,
`job` VARCHAR(2000),
`step` VARCHAR(200)
);

How Persistence Works

  • On job start: Inserts/updates (charid, jobName, 1)
  • On step advance: Updates step column
  • On complete/abandon: Sets job and step to NULL
  • On player connect: Queries by charid to check for in-progress job

This is handled automatically by the SaveProgression event and GetProgression callback - you don't need to interact with the DB directly in job files.


Performance Considerations

  1. Wait intervals: Use Wait(1000) for monitoring threads (abandon watchers, arrival checks). Only use Wait(0) when absolutely necessary (animation loops). Wait(200) is fine for proximity checks.

  2. Model loading: Always pair RequestModel with SetModelAsNoLongerNeeded after spawning to free memory.

  3. Entity cleanup: Every entity you create must be deleted in Utils.StopJob() and in the onResourceStop handler. Leaked entities persist in the game world.

  4. Blip cleanup: Same as entities - track all blips in a table and remove them on cleanup.

  5. Interaction cleanup: Always call RemoveInteraction/RemoveEntityInteraction/RemoveLocalEntityInteraction when a step ends or the job is abandoned.

  6. Board refresh: Config.Refreshpanels = 60 regenerates posters every 60 minutes. Lower values increase server load slightly. Set to false if you don't need auto-refresh.


Debugging Tips

Lua print() outputs to the server or client console depending on context. Use it freely for development:

lua
print("Step", job.currentStep, "coords:", data.coords)
print("Entity spawned:", entity, "at", GetEntityCoords(entity))

Common Issues

Problem Likely Cause Fix
Job doesn't appear on board board field doesn't match any Config.Panel key Check spelling matches exactly
Actions don't execute String name in step.action doesn't match actions["Name"] Verify exact string match
Entities don't spawn Model not loaded or coords underground Add HasModelLoaded wait loop; check Z coordinate
Job gets stuck job:nextStep() never called Ensure all code paths call nextStep()
Entities persist after abandon Missing abandon watcher or StopJob() doesn't delete them Add watcher thread; check cleanup function
"Already have a job" Player has a stale DB record Check murphy_jobpanel table, clear the row
GPS route doesn't clear ClearGpsMultiRoute() missing from cleanup Add to both step cleanup and StopJob()

Checking DB State

sql
-- See all active jobs:
SELECT * FROM murphy_jobpanel WHERE job IS NOT NULL;
-- Clear a stuck player:
UPDATE murphy_jobpanel SET job = NULL, step = NULL WHERE charid = 'PLAYER_CHARID';

Last updated: September 9, 2026