Contract App
Full documentation for the tablet Contracts app — a contract board where players buy and start job/heist contracts with global locking, stock management, and a queue system.
Overview
The Contracts app lets players browse, purchase, and start activity contracts (heists, robberies, jobs). Each contract type has a global stock (shared across all players) and a per-player purchase cap. Only ONE active instance per contract type can exist server-wide — if Group A starts a gruppe6_heist contract, no other group can start it until Group A completes or fails. Other groups join a start queue and are notified when the contract becomes available.
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Contract App (React) │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ Browse │ │ My Contracts │ │ Queue │ │
│ │ (cards) │ │ (owned list) │ │ (start queue positions) │ │
│ └────┬─────┘ └──────┬───────┘ └───────────┬────────────┘ │
│ └───────────────┼──────────────────────┘ │
│ │ nuiFetch │
├───────────────────────┼──────────────────────────────────────┤
│ client/contracts.lua │
│ RegisterNUICallback('contracts:*') │
│ → TriggerServerEvent('wtf_group:server:contracts:*') │
├───────────────────────┼──────────────────────────────────────┤
│ server/contracts.lua │
│ ContractMgr: │
│ ├── GetTypes() → stock, playerOwned, isActive, queueLen │
│ ├── Buy() → money check, stock check, player cap │
│ ├── Start() → global lock, group check, consume, trigger │
│ ├── Complete() → unlock, cooldown, process queue │
│ ├── JoinStartQueue() / LeaveStartQueue() │
│ ├── RestockLoop() → periodic stock restoration │
│ └── 7 Exports for external resources │
├───────────────────────┼──────────────────────────────────────┤
│ MySQL (oxmysql) │
│ wtf_contract_stock — global stock per type │
│ wtf_contract_active — global lock (1 per type) │
│ wtf_contract_inventory — player-owned contracts │
│ wtf_contract_start_queue — FIFO start queue │
│ wtf_contract_history — purchase/start log │
└──────────────────────────────────────────────────────────────┘Files
| File | Purpose |
|---|---|
server/contracts.lua | Server logic: buy, start, complete, queue, stock, exports (~350 lines) |
client/contracts.lua | NUI callback bridge + event listeners (~90 lines) |
ui/src/apps/Contracts/index.jsx | React UI: browse, inventory, queue views (~500 lines) |
config/config.lua | Config.Contracts section |
server/database.lua | 5 new tables in ensureTables() |
ui/src/lib/appRegistry.js | contracts app entry |
ui/src/lib/icons.jsx | ContractsIcon SVG component |
Config
Config.Contracts = {
enabled = true,
maxPerPlayer = 3, -- max contracts of each type per player
restockInterval = 3600, -- seconds between stock restocks
restockAmount = 5, -- how many to add per restock
requireGroup = true, -- must be in a group to buy/start
requireLeaderToStart = true, -- only leader can start
cooldownActivity = 'contract', -- cooldown key for wtf_group
showStock = true,
showPlayerCount = true,
globalLock = true, -- ONE active instance per type server-wide
categories = {
{ id = 'all', label = 'All', icon = '📋' },
{ id = 'heist', label = 'Heists', icon = '💀' },
{ id = 'robbery', label = 'Robberies', icon = '🔫' },
{ id = 'job', label = 'Jobs', icon = '💼' },
},
contracts = {
gruppe6_heist = {
name = 'Gruppe6 Armored Truck',
description = 'Rob a Gruppe6 Stockade armored truck.',
category = 'heist',
price = 50000,
initialStock = 10,
cooldown = 3600, -- 1 hour
failCooldown = 600, -- 10 minutes
minPlayers = 1,
minLevel = 1,
enabled = true,
integration = {
resource = 'gruppe6-heist',
startEvent = 'gruppe6_heist:client:startFromContract',
startTarget = 'client',
},
},
},
}Database Tables
wtf_contract_stock
Global stock per contract type.
| Column | Type | Description |
|---|---|---|
contract_id | VARCHAR(50) PK | Contract type ID |
current_stock | INT | Current available stock |
max_stock | INT | Maximum stock (initial stock) |
last_restock_at | BIGINT | Last restock timestamp |
wtf_contract_active
Global lock — only one row per contract type when in use.
| Column | Type | Description |
|---|---|---|
contract_id | VARCHAR(50) PK | Contract type ID (locked) |
citizenid | VARCHAR(50) | Player who started it |
group_id | VARCHAR(50) | Group doing the contract |
started_at | BIGINT | Start timestamp |
mission_id | VARCHAR(50) | External mission ID |
wtf_contract_inventory
Player-owned contracts.
| Column | Type | Description |
|---|---|---|
id | INT PK AUTO | Auto-increment |
citizenid | VARCHAR(50) | Owner |
contract_id | VARCHAR(50) | Contract type |
purchased_at | BIGINT | Purchase timestamp |
status | ENUM | active, started, completed, failed |
started_at | BIGINT | When started |
mission_id | VARCHAR(50) | External mission reference |
wtf_contract_start_queue
FIFO queue for locked contract types.
| Column | Type | Description |
|---|---|---|
id | INT PK AUTO | Auto-increment |
citizenid | VARCHAR(50) | Player in queue |
contract_id | VARCHAR(50) | Contract type |
queued_at | BIGINT | Join timestamp |
wtf_contract_history
Purchase/start/complete log.
| Column | Type | Description |
|---|---|---|
id | INT PK AUTO | Auto-increment |
citizenid | VARCHAR(50) | Player |
contract_id | VARCHAR(50) | Contract type |
action | ENUM | purchased, started, completed, failed |
price_paid | INT | Money spent |
group_id | VARCHAR(50) | Group during action |
mission_id | VARCHAR(50) | External mission ID |
timestamp | BIGINT | Action timestamp |
Global Contract Lock
When Config.Contracts.globalLock = true:
- Only ONE active instance per contract type at any time server-wide
wtf_contract_activehas PRIMARY KEY oncontract_id— MySQL enforces uniqueness- If Player A starts
gruppe6_heist→ type is locked - Player B sees "In Use by Player A" → can join start queue
- When Player A completes/fails → type unlocks → first in queue notified
- If player disconnects while contract is active → forced complete, type unlocks
Start Queue
When a contract type is locked:
- Player clicks "Join Queue" → added to
wtf_contract_start_queue - Queue is FIFO by
queued_attimestamp - When active contract completes/fails:
- First player in queue is removed and notified
- ox_lib notification: "Gruppe6 Armored Truck is now available!"
- NUI message:
contracts:startReadytriggers UI refresh
- Player can now start the contract normally
Data Flow
Buy Contract
React: nuiFetch('contracts:buy', { contractId })
→ client: TriggerServerEvent('wtf_group:server:contracts:buy', contractId)
→ server: ContractMgr.Buy(src, contractId)
├── Check player cap (dbCountOwned)
├── Check stock (StockCache)
├── Check money (Bridge.GetMoney)
├── Bridge.RemoveMoney
├── Decrement stock (dbUpdateStock)
├── Add to inventory (dbAddInventory)
└── Log history (dbLogHistory)
→ TriggerClientEvent('wtf_group:client:contracts:buyResult', src, ok, msg, contractId)
→ React: toast + refresh types/inventoryStart Contract
React: nuiFetch('contracts:start', { contractId })
→ server: ContractMgr.Start(src, contractId)
├── Check player owns active contract
├── Check global lock (ActiveContracts[contractId])
├── Check group, leader, min players, min level
├── Check cooldown (Bridge.IsOnCooldown)
├── Lock group (Bridge.SetLocked)
├── Mark inventory as 'started'
├── Set ActiveContracts + dbSetActive
├── Log history
└── Trigger integration (if configured)
→ React: toast + refreshComplete Contract (from external resource)
External resource: exports.wtf_group:CompleteContract(src, contractId, missionId, success)
→ server: ContractMgr.Complete(src, contractId, missionId, success)
├── Update inventory status ('completed'/'failed')
├── Set cooldown (Bridge.SetCooldown)
├── Unlock group (Bridge.SetLocked)
├── Remove from ActiveContracts + dbRemoveActive
├── Log history
├── Notify player (contracts:completed)
└── Process start queue (ContractMgr.ProcessStartQueue)Player Disconnect
playerDropped event
→ ContractMgr.ForceComplete(contractId, false, 'Player disconnected')
├── Same as Complete but forced
├── Unlocks contract type
└── Processes start queueExports (for Other Resources)
-- Check if a contract type is available (not in use, has stock)
exports.wtf_group:IsContractAvailable(contractId) → boolean
-- Get active contract info for a type
exports.wtf_group:GetActiveContract(contractId) → { citizenid, groupId, startedAt } | nil
-- Get start queue length for a contract type
exports.wtf_group:GetContractQueueLength(contractId) → number
-- Report contract completion/failure
exports.wtf_group:CompleteContract(source, contractId, missionId, success) → boolean, string
-- Get all active contracts across the server
exports.wtf_group:GetActiveContracts() → { [contractId] = { ... } }
-- Get a player's contract inventory
exports.wtf_group:GetPlayerContracts(source) → { { id, contract_id, purchased_at, status } }
-- Check if a player owns an active contract
exports.wtf_group:HasActiveContract(source, contractId) → booleanUsage Examples
-- Another heist resource: report completion
RegisterNetEvent('my_heist:completed', function(success)
local src = source
exports.wtf_group:CompleteContract(src, 'my_heist', missionId, success)
end)
-- Check availability before allowing an action
if exports.wtf_group:IsContractAvailable('gruppe6_heist') then
-- Contract is free and in stock
end
-- Get active contract info
local active = exports.wtf_group:GetActiveContract('gruppe6_heist')
if active then
print('In use by: ' .. active.citizenid)
endNUI Callbacks
| Callback | Data | Description |
|---|---|---|
contracts:getTypes | — | Get all contract types with stock + player info |
contracts:getInventory | — | Get player's owned contracts |
contracts:buy | { contractId } | Purchase a contract |
contracts:start | { contractId } | Start a contract |
contracts:joinStartQueue | { contractId } | Join start queue |
contracts:leaveStartQueue | { contractId } | Leave start queue |
contracts:getStartQueue | { contractId } | Get start queue data |
contracts:getHistory | — | Get purchase history |
NUI Messages (Received by React)
| Action | Data | Description |
|---|---|---|
contractsResponse + contracts:getTypes | { [id]: { ... } } | Contract types data |
contractsResponse + contracts:getInventory | [{ id, contract_id, ... }] | Player inventory |
contractsResponse + contracts:buy | { success, message, contractId } | Buy result |
contractsResponse + contracts:start | { success, message, contractId } | Start result |
contractsResponse + contracts:startQueue | { success, message, contractId } | Queue join result |
contractsResponse + contracts:startReady | { contractId } | Contract unlocked, you can start |
contractsResponse + contracts:stockUpdate | { contractId, stock } | Stock restocked |
contractsResponse + contracts:completed | { contractId, success, missionId } | Heist result |
contractsResponse + contracts:getHistory | [{ ... }] | History data |
UI Views
Browse View
- Category tabs: All, Heists, Robberies, Jobs
- Search bar for filtering
- Contract cards: name, description, price, stock bar, owned count, status badge
- Detail overlay: full info, buy/queue button
My Contracts View
- List of owned contracts with status badges
- Start button (leader only)
- Confirmation modal before starting
Queue View
- Lists contract types currently in use by other players
- Join Queue button for each
- Shows queue positions and player names
Integration with gruppe6_heist
The contract system integrates with gruppe6-heist via event triggering:
- Config:
integration.startEvent = 'gruppe6_heist:client:startFromContract' - Start: When a contract starts, the server fires this event on the player's client
- Heist runs: The gruppe6-heist resource handles the full heist lifecycle
- Completion: The external resource calls
exports.wtf_group:CompleteContract()to report success/failure - Contract cleanup: Status updated, cooldown set, group unlocked, start queue processed
Optional Integration
The integration config is optional. If not set, the contract system works independently:
- Stock, inventory, and locking still work
- The "Start" button triggers a generic event
- Other resources can listen for
wtf_group:server:contractStartedand handle it
Notes
- Stock restocks periodically based on
restockInterval - Global lock uses PRIMARY KEY constraint — race-condition safe
- Player disconnect during active contract triggers forced completion
- Cooldown uses wtf_group's existing cooldown system
- Contracts are one-time use: consumed on start, even if heist fails
- The contract system is framework-agnostic (uses Bridge for all framework calls)
- History is retained for admin auditing