Skip to content

Commit 207302d

Browse files
TMHSDigitalclaude
andcommitted
feat: add four example resources and harden drug-sell
New complete, runnable reference resources under examples/: - doorlock: replicated GlobalState lock state + change handlers (state-bags) - playtime-tracker: oxmysql parameterised queries + migration (database) - speedometer-hud: rate-limited vanilla NUI HUD (nui + performance) - secure-shop: server-authoritative purchase flow (patterns + security) Harden drug-sell: - enforce the sale cooldown server-side (client cooldown is bypassable) - server picks the drug the player actually holds (was a random pick that failed most sales) and rolls the outcome authoritatively - replace the per-frame key poll with RegisterKeyMapping (0.00ms idle) - dispatch the police alert from the server with server-read coords - clean per-player cooldown tables on playerDropped Add examples/README.md index and register examples/ in the architecture tree. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 28401a1 commit 207302d

27 files changed

Lines changed: 1329 additions & 71 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ CFX-Developer-Tools/
2828
rules/
2929
<rule-name>.mdc # Cursor rule files
3030
snippets/ # Lua, JS, C# code snippets
31-
templates/ # Resource starter templates
31+
templates/ # Resource starter templates (blank per-framework skeletons)
32+
examples/ # Complete, runnable reference resources
3233
mcp-server/
3334
server.py # MCP server entry point (Python, FastMCP)
3435
tools/ # One module per tool

examples/README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Examples
2+
3+
Complete, ready-to-use CFX resources that demonstrate the skills and rules in this repo. Unlike `templates/` (blank per-framework skeletons), each folder here is a finished resource you can drop into a server and run.
4+
5+
Every example follows the repo conventions: `fxmanifest.lua` declares `fx_version 'cerulean'`, `games { 'gta5' }`, and `lua54 true`; logic is server-authoritative; and no credentials are hardcoded.
6+
7+
| Example | Demonstrates | Notes |
8+
|---------|--------------|-------|
9+
| [`drug-sell`](drug-sell/) | client-server patterns, server-side anti-cheat | Sell drugs to nearby peds. Server owns the cooldown, drug selection, payout, and cop-alert. ESX / QBCore / standalone bridge. |
10+
| [`doorlock`](doorlock/) | `state-bags` | Lock/unlock doors via replicated `GlobalState` and a `AddStateBagChangeHandler`. Server validates range. Pure Lua, no dependencies. |
11+
| [`playtime-tracker`](playtime-tracker/) | `database-integration` | Tracks lifetime playtime in MySQL via oxmysql with parameterised queries and a migration. Requires `oxmysql`. |
12+
| [`speedometer-hud`](speedometer-hud/) | `nui-development`, `performance-optimization` | Vanilla NUI HUD fed by rate-limited, change-gated `SendNUIMessage`, in-vehicle only. No build tooling. |
13+
| [`secure-shop`](secure-shop/) | `client-server-patterns`, `security-best-practices` | Server-authoritative purchase flow. The client sends only an item id and quantity; the server owns prices and validates everything. |
14+
15+
Each example has its own `README.md` with install steps and the specific pattern it teaches.

examples/doorlock/README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# doorlock
2+
3+
A minimal FiveM example resource that demonstrates **replicated StateBags** (the
4+
repo's `state-bags` skill) for server-authoritative door locking.
5+
6+
## What it demonstrates
7+
8+
- Storing shared state in `GlobalState`, a replicated StateBag that the server
9+
owns and every client reads.
10+
- Seeding `GlobalState` from config on resource start so the value exists before
11+
any client reads it and late joiners get it automatically.
12+
- Reacting to state changes with `AddStateBagChangeHandler(...)` instead of
13+
polling, so the resource idles at 0.00ms.
14+
- A server-authoritative flow: clients only request a toggle, and the server
15+
validates the id, validates range with `GetEntityCoords(GetPlayerPed(src))`,
16+
and applies a per-player anti-spam cooldown before flipping the state.
17+
- A `RegisterCommand` + `RegisterKeyMapping` keybind (rebindable, no per-frame
18+
poll).
19+
- An ESX / QBCore notification bridge with a standalone fallback.
20+
21+
## Install
22+
23+
1. Copy the `doorlock` folder into your server's `resources` directory.
24+
2. Add `ensure doorlock` to your `server.cfg`.
25+
3. Edit `config.lua` to set your door `coords` (and optional `doorHash`).
26+
4. Restart the server (or `ensure doorlock`) and walk up to a door, then press
27+
the toggle key (default `E`).
28+
29+
No database or credentials required.
30+
31+
## State-bag keys used
32+
33+
Each configured door mirrors its lock state into one replicated GlobalState key:
34+
35+
| Key | Type | Owner | Meaning |
36+
|-----|------|-------|---------|
37+
| `doorlock:<id>` | boolean | server | `true` = locked, `false` = unlocked |
38+
39+
For the default config that is `doorlock:ld_front` and `doorlock:ld_back`.
40+
41+
Read a value anywhere on the client or server with:
42+
43+
```lua
44+
local locked = GlobalState['doorlock:ld_front']
45+
```
46+
47+
Only the server writes these keys (in `server/main.lua`). Clients never write
48+
them; they call the `doorlock:server:toggle` net event instead.
49+
50+
## How to add doors
51+
52+
Append an entry to `Config.Doors` in `config.lua`:
53+
54+
```lua
55+
{
56+
id = 'ld_garage', -- unique; becomes 'doorlock:ld_garage'
57+
label = 'Garage Door',
58+
doorHash = nil, -- or a hash registered via AddDoorToSystem
59+
coords = vec3(-805.0, 172.0, 76.74),
60+
distance = 2.0,
61+
locked = true, -- initial state seeded into GlobalState
62+
},
63+
```
64+
65+
Restart the resource. The new door's GlobalState key is seeded on start and a
66+
change handler is registered for it automatically. If you set `doorHash` to a
67+
door registered with the GTA door system (via `AddDoorToSystem`), the example
68+
will also drive the physical door with `DoorSystemSetDoorState`.

examples/doorlock/client/main.lua

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
local Framework, FW = nil, nil
2+
3+
local function resolveFramework()
4+
local choice = Config.Framework
5+
if choice == 'esx' or (choice == 'auto' and GetResourceState('es_extended') == 'started') then
6+
Framework = 'esx'
7+
FW = exports['es_extended']:getSharedObject()
8+
elseif choice == 'qb' or (choice == 'auto' and GetResourceState('qb-core') == 'started') then
9+
Framework = 'qb'
10+
FW = exports['qb-core']:GetCoreObject()
11+
else
12+
Framework = 'standalone'
13+
end
14+
end
15+
16+
local function notify(msg, kind)
17+
if Framework == 'esx' then
18+
FW.ShowNotification(msg)
19+
elseif Framework == 'qb' then
20+
FW.Functions.Notify(msg, kind or 'primary')
21+
else
22+
BeginTextCommandThefeedPost('STRING')
23+
AddTextComponentSubstringPlayerName(msg)
24+
EndTextCommandThefeedPostTicker(false, true)
25+
end
26+
end
27+
28+
-- Returns the nearest configured door within its own distance, or nil. The
29+
-- server re-checks range too; this is just so we send the right id.
30+
local function getNearestDoor()
31+
local coords = GetEntityCoords(PlayerPedId())
32+
local nearest, nearestDist = nil, nil
33+
for _, door in ipairs(Config.Doors) do
34+
local dist = #(coords - door.coords)
35+
if dist <= door.distance and (not nearestDist or dist < nearestDist) then
36+
nearest = door
37+
nearestDist = dist
38+
end
39+
end
40+
return nearest
41+
end
42+
43+
-- A command + key mapping replaces a per-frame poll, so this resource idles at
44+
-- 0.00ms and players can rebind the key in the FiveM key bindings menu.
45+
RegisterCommand('toggledoor', function()
46+
local door = getNearestDoor()
47+
if not door then
48+
notify('No door within reach.', 'error')
49+
return
50+
end
51+
-- The server is authoritative: it validates range and flips GlobalState.
52+
TriggerServerEvent('doorlock:server:toggle', door.id)
53+
end, false)
54+
RegisterKeyMapping('toggledoor', 'Toggle the nearest door lock', 'keyboard', Config.ToggleKey)
55+
56+
-- React to the replicated lock state. AddStateBagChangeHandler fires on EVERY
57+
-- client whenever the server writes GlobalState['doorlock:'..id], including the
58+
-- initial seed and for late joiners, so the physical door and notification stay
59+
-- in sync without any polling.
60+
for _, door in ipairs(Config.Doors) do
61+
AddStateBagChangeHandler('doorlock:' .. door.id, 'global', function(_, _, value)
62+
local locked = value == true
63+
64+
-- Drive a physical door only if one is registered with the door system.
65+
if door.doorHash then
66+
DoorSystemSetDoorState(door.doorHash, locked and 1 or 0, false, false)
67+
end
68+
69+
notify(('%s is now %s.'):format(door.label, locked and 'locked' or 'unlocked'),
70+
locked and 'error' or 'success')
71+
end)
72+
end
73+
74+
CreateThread(function()
75+
resolveFramework()
76+
end)

examples/doorlock/config.lua

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
Config = {}
2+
3+
-- 'auto' resolves ESX or QBCore at runtime for notifications only. The lock
4+
-- logic itself is framework-agnostic. Force with 'esx' / 'qb' / 'standalone'.
5+
Config.Framework = 'auto'
6+
7+
-- Key the player presses to toggle the nearest door. Players can rebind it in
8+
-- Settings > Key Bindings > FiveM (RegisterKeyMapping in client/main.lua).
9+
Config.ToggleKey = 'E'
10+
11+
-- Define your doors here. Each door's lock state is mirrored into GlobalState
12+
-- under the key 'doorlock:'..id, so every client sees the same value.
13+
-- id - unique string, also forms the StateBag key 'doorlock:'..id
14+
-- label - shown in notifications
15+
-- doorHash - a GTA door hash registered with the door system (AddDoorToSystem),
16+
-- or nil if you only want logical locking (no physical door).
17+
-- coords - world position used for the proximity check (client + server)
18+
-- distance - max meters from the door to toggle it
19+
-- locked - the initial lock state seeded into GlobalState on resource start
20+
Config.Doors = {
21+
{
22+
id = 'ld_front',
23+
label = 'Front Door',
24+
doorHash = nil, -- set to a registered door hash to drive a physical door
25+
coords = vec3(-809.83, 175.05, 76.74),
26+
distance = 2.0,
27+
locked = true,
28+
},
29+
{
30+
id = 'ld_back',
31+
label = 'Back Door',
32+
doorHash = nil,
33+
coords = vec3(-802.41, 170.12, 76.74),
34+
distance = 2.0,
35+
locked = true,
36+
},
37+
}

examples/doorlock/fxmanifest.lua

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
fx_version 'cerulean'
2+
games { 'gta5' }
3+
4+
name 'doorlock'
5+
author 'TMHSDigital'
6+
description 'Server-authoritative door locking via replicated StateBags (ESX/QBCore notify bridge, standalone fallback)'
7+
version '1.0.0'
8+
9+
lua54 true
10+
11+
shared_script 'config.lua'
12+
13+
client_script 'client/main.lua'
14+
server_script 'server/main.lua'

examples/doorlock/server/main.lua

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
-- doorlock server: the authoritative owner of every door's lock state.
2+
--
3+
-- The lock state lives in GlobalState['doorlock:'..id]. GlobalState is a
4+
-- replicated StateBag, so writing it here pushes the value to every client and
5+
-- every late joiner automatically. Clients never write it; they only ask the
6+
-- server to flip it via the 'doorlock:server:toggle' net event.
7+
8+
-- Build an id -> door lookup once so validation is O(1) and we never trust a
9+
-- client-supplied id that is not in the config.
10+
local doorById = {}
11+
for _, door in ipairs(Config.Doors) do
12+
doorById[door.id] = door
13+
end
14+
15+
-- Seed each door's GlobalState from config on resource start. Doing this from
16+
-- the server guarantees the replicated value exists before any client reads it.
17+
AddEventHandler('onResourceStart', function(resource)
18+
if resource ~= GetCurrentResourceName() then return end
19+
for _, door in ipairs(Config.Doors) do
20+
GlobalState['doorlock:' .. door.id] = door.locked and true or false
21+
end
22+
end)
23+
24+
-- Server-enforced per-player cooldown. The client keybind can be spammed (or a
25+
-- modded client can fire the event directly), so the rate limit lives here.
26+
local lastToggle = {}
27+
local TOGGLE_COOLDOWN_MS = 500
28+
29+
RegisterNetEvent('doorlock:server:toggle', function(id)
30+
local src = source
31+
if not src or src <= 0 then return end
32+
33+
-- Input validation: the id must be a string and a configured door.
34+
if type(id) ~= 'string' then return end
35+
local door = doorById[id]
36+
if not door then return end
37+
38+
-- Anti-spam: ignore toggles that arrive faster than the cooldown.
39+
local now = GetGameTimer()
40+
if now - (lastToggle[src] or 0) < TOGGLE_COOLDOWN_MS then return end
41+
42+
-- Range check is done SERVER-side using the player's ped coords (OneSync),
43+
-- so a modded client cannot toggle a door from across the map.
44+
local ped = GetPlayerPed(src)
45+
if ped == 0 then return end
46+
local coords = GetEntityCoords(ped)
47+
if #(coords - door.coords) > door.distance then return end
48+
49+
lastToggle[src] = now
50+
51+
-- Flip the replicated boolean. Every client's StateBag change handler fires.
52+
local key = 'doorlock:' .. door.id
53+
local current = GlobalState[key]
54+
GlobalState[key] = not current
55+
end)
56+
57+
-- Stop the cooldown table from growing without bound as players leave.
58+
AddEventHandler('playerDropped', function()
59+
lastToggle[source] = nil
60+
end)

examples/drug-sell/client/main.lua

Lines changed: 41 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -50,20 +50,19 @@ local function getNearestPed()
5050
return nearest
5151
end
5252

53-
-- Pick a random drug the player can attempt to sell. The server re-validates inventory.
54-
local function pickDrug()
55-
return Config.Drugs[math.random(1, #Config.Drugs)]
56-
end
57-
58-
local lastSale = 0
53+
-- The ped we last offered to, so we can play the reaction on the server's verdict.
54+
local pendingPed = nil
55+
-- Local cooldown for snappy feedback only. The server enforces the real one.
56+
local lastAttempt = 0
5957

6058
local function attemptSale()
6159
if Config.RequireOnFoot and IsPedInAnyVehicle(PlayerPedId(), false) then
60+
notify('Get out of the vehicle first.', 'error')
6261
return
6362
end
6463

6564
local now = GetGameTimer()
66-
if now - lastSale < Config.Cooldown * 1000 then
65+
if now - lastAttempt < Config.Cooldown * 1000 then
6766
notify('You need to lay low for a moment.', 'error')
6867
return
6968
end
@@ -74,44 +73,47 @@ local function attemptSale()
7473
return
7574
end
7675

77-
lastSale = now
78-
79-
local drug = pickDrug()
80-
local roll = math.random()
81-
local pedNet = PedToNet(ped)
82-
83-
if roll <= Config.Chances.accept then
84-
notify(('Selling %s...'):format(drug.label), 'primary')
85-
TaskTurnPedToFaceEntity(ped, PlayerPedId(), 1500)
86-
Wait(1200)
87-
TriggerServerEvent('drug-sell:server:sell', drug.item)
88-
elseif roll <= Config.Chances.accept + Config.Chances.decline then
89-
notify('They waved you off.', 'error')
90-
TaskSmartFleePed(ped, PlayerPedId(), 60.0, -1, false, false)
91-
SetPedKeepTask(ped, true)
92-
else
93-
notify('Wrong customer. They are calling the cops!', 'error')
94-
TaskSmartFleePed(ped, PlayerPedId(), 100.0, -1, false, false)
95-
SetPedKeepTask(ped, true)
96-
local coords = GetEntityCoords(PlayerPedId())
97-
TriggerServerEvent(Config.PoliceAlertEvent, { x = coords.x, y = coords.y, z = coords.z })
98-
end
76+
lastAttempt = now
77+
pendingPed = ped
78+
TaskTurnPedToFaceEntity(ped, PlayerPedId(), 1500)
79+
-- The server picks the drug, rolls the outcome, and moves the money.
80+
TriggerServerEvent('drug-sell:server:sell')
9981
end
10082

83+
-- A command + key mapping replaces a per-frame IsControlJustReleased poll,
84+
-- so this resource idles at 0.00ms and players can rebind the key.
85+
RegisterCommand('drugsell', function()
86+
attemptSale()
87+
end, false)
88+
RegisterKeyMapping('drugsell', 'Offer drugs to the nearest ped', 'keyboard', Config.DefaultKey)
89+
10190
CreateThread(function()
10291
resolveFramework()
103-
while true do
104-
Wait(0)
105-
if IsControlJustReleased(0, Config.SellKey) then
106-
attemptSale()
107-
end
108-
end
10992
end)
11093

111-
RegisterNetEvent('drug-sell:client:saleResult', function(ok, label, amount)
112-
if ok then
113-
notify(('Sold for $%d.'):format(amount), 'success')
114-
else
94+
-- The server is authoritative: it decides sold / declined / cops and we only
95+
-- play the matching reaction on the ped and notify the player.
96+
RegisterNetEvent('drug-sell:client:saleResult', function(outcome, label, amount)
97+
local ped = pendingPed
98+
pendingPed = nil
99+
100+
if outcome == 'sold' then
101+
notify(('Sold %s for $%d.'):format(label or 'drugs', amount or 0), 'success')
102+
elseif outcome == 'declined' then
103+
notify('They waved you off.', 'error')
104+
if ped and DoesEntityExist(ped) then
105+
TaskSmartFleePed(ped, PlayerPedId(), 60.0, -1, false, false)
106+
SetPedKeepTask(ped, true)
107+
end
108+
elseif outcome == 'cops' then
109+
notify('Wrong customer - they are calling the cops!', 'error')
110+
if ped and DoesEntityExist(ped) then
111+
TaskSmartFleePed(ped, PlayerPedId(), 100.0, -1, false, false)
112+
SetPedKeepTask(ped, true)
113+
end
114+
elseif outcome == 'cooldown' then
115+
notify('You need to lay low for a moment.', 'error')
116+
elseif outcome == 'empty' then
115117
notify(('You have no %s to sell.'):format(label or 'drugs'), 'error')
116118
end
117119
end)

0 commit comments

Comments
 (0)