From 890a250270bcb8dff12bff3c806b1e5d464cab86 Mon Sep 17 00:00:00 2001 From: Jesse Howard Date: Sun, 20 Sep 2026 17:12:20 +0000 Subject: [PATCH] Initial how_progression baseline --- .gitignore | 12 + client.lua | 2837 +++++++++++++++++++ docs/ARCHITECTURE.md | 122 + docs/CONTACTS.md | 17 + docs/CREWS.md | 7 + docs/MISSION_DESIGN.md | 31 + docs/PHONE.md | 7 + docs/PROGRESSION.md | 9 + fxmanifest.lua | 30 + server.lua | 3816 ++++++++++++++++++++++++++ server/services/mission_registry.lua | 45 + shared/contacts/cosmic_dave.lua | 14 + shared/contacts/crowbar.lua | 37 + shared/contacts/deke.lua | 18 + shared/contacts/gabe.lua | 13 + shared/contacts/init.lua | 27 + shared/contacts/mack.lua | 67 + shared/contacts/unknown.lua | 7 + shared/contacts/vinny.lua | 46 + shared/debug_contact.lua | 6 + shared/locations.lua | 28 + shared/mission_ids.lua | 62 + 22 files changed, 7258 insertions(+) create mode 100644 .gitignore create mode 100644 client.lua create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTACTS.md create mode 100644 docs/CREWS.md create mode 100644 docs/MISSION_DESIGN.md create mode 100644 docs/PHONE.md create mode 100644 docs/PROGRESSION.md create mode 100644 fxmanifest.lua create mode 100644 server.lua create mode 100644 server/services/mission_registry.lua create mode 100644 shared/contacts/cosmic_dave.lua create mode 100644 shared/contacts/crowbar.lua create mode 100644 shared/contacts/deke.lua create mode 100644 shared/contacts/gabe.lua create mode 100644 shared/contacts/init.lua create mode 100644 shared/contacts/mack.lua create mode 100644 shared/contacts/unknown.lua create mode 100644 shared/contacts/vinny.lua create mode 100644 shared/debug_contact.lua create mode 100644 shared/locations.lua create mode 100644 shared/mission_ids.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35f4bcb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Development backups / snapshots +*.bak* +*.pre-* + +# Editor/temp files +*~ +*.swp +*.tmp + +# OS junk +.DS_Store +Thumbs.db diff --git a/client.lua b/client.lua new file mode 100644 index 0000000..212dfb7 --- /dev/null +++ b/client.lua @@ -0,0 +1,2837 @@ +local crowbarCoords = vec4(1699.67, 3775.30, 34.74, 207.22) + +local crowbarPed +local crowbarBlip +local crowbarBike + +local mackCoords = vec4(2366.60, 3154.91, 48.21, 17.84) +local mackPed +local mackBlip +local gabeBlip + +local dekeCoords = vec4(2359.89, 3136.74, 48.21, 110.90) +local dekePed + +local crowbarMission1Vehicle +local crowbarMission1Blip +local crowbarMission1ReturnActive = false + +local crowbarMission2Vehicle +local crowbarMission2Blip + +-- ============================================================ +-- Mack world setup +-- ============================================================ + +local function createDeke() + if DoesEntityExist(dekePed) then + return + end + + local model = `s_m_m_autoshop_02` + + lib.requestModel(model) + + dekePed = CreatePed( + 0, + model, + dekeCoords.x, + dekeCoords.y, + dekeCoords.z - 1.0, + dekeCoords.w, + false, + false + ) + + SetPedDefaultComponentVariation(dekePed) + SetEntityInvincible(dekePed, true) + FreezeEntityPosition(dekePed, true) + SetBlockingOfNonTemporaryEvents(dekePed, true) + + TaskStartScenarioInPlace( + dekePed, + 'WORLD_HUMAN_STAND_IMPATIENT', + 0, + true + ) + + exports.ox_target:addLocalEntity(dekePed, { + { + name = 'how_progression_deke_shop', + icon = 'fa-solid fa-toolbox', + label = 'Talk to Deke', + distance = 2.0, + + onSelect = function() + exports.ox_inventory:openInventory( + 'shop', + { + type = 'DekeTools', + id = 1 + } + ) + end + } + }) + + SetModelAsNoLongerNeeded(model) +end + +local function createMack() + createDeke() + + if DoesEntityExist(mackPed) then + return + end + + local model = `s_m_y_xmech_01` + + lib.requestModel(model) + + mackPed = CreatePed( + 0, + model, + mackCoords.x, + mackCoords.y, + mackCoords.z - 1.0, + mackCoords.w, + false, + false + ) + + SetPedDefaultComponentVariation(mackPed) + + SetEntityInvincible(mackPed, true) + FreezeEntityPosition(mackPed, true) + SetBlockingOfNonTemporaryEvents(mackPed, true) + + TaskStartScenarioInPlace( + mackPed, + 'WORLD_HUMAN_CLIPBOARD', + 0, + true + ) + + exports.ox_target:addLocalEntity(mackPed, { + { + name = 'how_progression_mack_talk', + icon = 'fa-solid fa-comment', + label = 'Talk to Mack', + distance = 2.0, + + canInteract = function() + return lib.callback.await( + 'how_progression:server:canTalkToMack', + false + ) + end, + + onSelect = function() + local state = lib.callback.await( + 'how_progression:server:getCrowbarMission4State', + false + ) + + if state == 'meet_mack' then + lib.alertDialog({ + header = 'Mack', + + content = + "So you're Crowbar's new errand boy.\n\n" .. + "He put in a good word for you. Well... as good a word " .. + "as Crowbar gives anybody. Says you're about halfway trustworthy.\n\n" .. + "You need that cash cleaned, go see Gabe. He's in the warehouse.\n\n" .. + "And don't ask how it works. Gabe gets cranky when people ask questions.", + + centered = true + }) + + TriggerServerEvent( + 'how_progression:server:crowbarMission4TalkedToMack' + ) + else + local mackState = lib.callback.await( + 'how_progression:server:getMackMission1State', + false + ) + + if mackState + and mackState.objective == 'available' + then + local answer = lib.alertDialog({ + header = 'Mack', + content = + "Got a delivery for you. Guy named Vinny just opened up a little bike shop.\n\n" .. + "Fixed a few Sanchezes for him. They're out back.\n\n" .. + "Take 'em over. Apparently he's got customers waiting, which means he's called me about fourteen fucking times today.\n\n" .. + "Try not to scratch 'em.\n\n" .. + "Take these. It gets dangerous in my line of work.", + centered = true, + cancel = true, + labels = { + confirm = 'Take the job', + cancel = 'Not now' + } + }) + + if answer == 'confirm' then + TriggerServerEvent( + 'how_progression:server:startMackMission1' + ) + end + elseif mackState + and mackState.objective ~= 'complete' + then + lib.notify({ + title = 'Mack', + description = + "Finish what you're doing first.", + type = 'inform' + }) + else + lib.alertDialog({ + header = 'Mack', + content = + "Crowbar says you're all right. That's good enough for me.", + centered = true + }) + end + end + end + } + }) + + SetModelAsNoLongerNeeded(model) +end + +-- ============================================================ +-- Crowbar world setup +-- ============================================================ + +local function createCrowbar() + if DoesEntityExist(crowbarPed) then + return + end + + local model = `ig_clay` + + lib.requestModel(model) + + crowbarPed = CreatePed( + 0, + model, + crowbarCoords.x, + crowbarCoords.y, + crowbarCoords.z - 1.0, + crowbarCoords.w, + false, + false + ) + + SetEntityInvincible(crowbarPed, true) + FreezeEntityPosition(crowbarPed, true) + SetBlockingOfNonTemporaryEvents(crowbarPed, true) + + TaskStartScenarioInPlace( + crowbarPed, + 'WORLD_HUMAN_SMOKING', + 0, + true + ) + + exports.ox_target:addLocalEntity(crowbarPed, { + { + name = 'how_progression_crowbar_talk', + icon = 'fa-solid fa-comment', + label = 'Talk to Crowbar', + distance = 2.0, + + canInteract = function() + local active = lib.callback.await( + 'how_progression:server:isCrowbarMissionActive', + false + ) + + return not active + end, + + onSelect = function() + local stage = lib.callback.await( + 'how_progression:server:getCrowbarMission', + false + ) + + if stage == 1 then + local result = lib.alertDialog({ + header = 'Crowbar', + + content = + "So you're the one looking for work.\n\n" .. + "I don't know you, and I sure as hell don't trust you. " .. + "So we're gonna start simple. You prove you can follow directions, " .. + "maybe we'll talk about real work.\n\n" .. + "Some creep owes me money. He's holed up at an abandoned service " .. + "station outside town. God knows what that creep is doing in there.\n\n" .. + "I don't want the money. I want his car. It's parked outside.\n\n" .. + "I had a copy of the key made. Don't ask how. Take the car and " .. + "bring it back here in one piece.\n\n" .. + "Do that, and maybe you're worth knowing.", + + centered = true, + cancel = true, + + labels = { + confirm = "I'm in", + cancel = "Not right now" + } + }) + + if result == 'confirm' then + TriggerServerEvent( + 'how_progression:server:startCrowbarMission1' + ) + end + + elseif stage == 2 then + local result = lib.alertDialog({ + header = 'Crowbar', + + content = + "I always loved this car. Hope that creep's crying " .. + "in a basement somewhere. Anyway...\n\n" .. + "You did good. But don't get excited—I still don't trust you.\n\n" .. + "So we're gonna try something a little harder. Another car. " .. + "This time I didn't get you a key. You'll have to steal it yourself.\n\n" .. + "Good news is, you get to keep this one.\n\n" .. + "I'll give you the picks to get inside. You're gonna need a pair " .. + "of snips to get it running. You Tool sells 'em.\n\n" .. + "You in?", + + centered = true, + cancel = true, + + labels = { + confirm = "I'm in", + cancel = "Not right now" + } + }) + + if result == 'confirm' then + TriggerServerEvent( + 'how_progression:server:startCrowbarMission2' + ) + end + + elseif stage == 3 then + local result = lib.alertDialog({ + header = 'Crowbar', + + content = + "All right. Let's see how you handle yourself when things get loud.\n\n" .. + "There's a 24/7 down in Harmony. Easy money.\n\n" .. + "Use this. There's no magazine, so make it convincing. " .. + "Hit the register and get out of there.\n\n" .. + "You in?", + + centered = true, + cancel = true, + + labels = { + confirm = "I'm in", + cancel = "Not right now" + } + }) + + if result == 'confirm' then + TriggerServerEvent( + 'how_progression:server:startCrowbarMission3' + ) + end + + elseif stage == 4 then + local result = lib.alertDialog({ + header = 'Crowbar', + + content = + "That cash from the store? Don't spend it. It's marked.\n\n" .. + "I know a guy who can help, but he doesn't deal with strangers. " .. + "Go see Mack over at the scrapyard. I told him you were coming.\n\n" .. + "Don't make me regret that.\n\n" .. + "You in?", + + centered = true, + cancel = true, + + labels = { + confirm = "I'm in", + cancel = "Not right now" + } + }) + + if result == 'confirm' then + TriggerServerEvent( + 'how_progression:server:startCrowbarMission4' + ) + end + + elseif stage == 5 then + local result = lib.alertDialog({ + header = 'Crowbar', + + content = + "Now you got money you can actually spend. Good. " .. + "Means I don't gotta keep supplying you.\n\n" .. + "Next thing I got ain't a stick-up. You're picking up " .. + "a package for me, and there's a chance somebody else wants it.\n\n" .. + "Go buy yourself some ammo at the Ammu-Nation next door. " .. + "Maybe a vest too, if you're the nervous type... or don't. " .. + "I don't care. I just want my package.\n\n" .. + "I'll make it worth your while.", + + centered = true, + cancel = true, + + labels = { + confirm = "I'm in", + cancel = "Not right now" + } + }) + + if result == 'confirm' then + TriggerServerEvent( + 'how_progression:server:startCrowbarMission5' + ) + end + + elseif stage >= 6 then + lib.alertDialog({ + header = 'Crowbar', + + content = + "You've done what I asked. For now, that's enough.\n\n" .. + "I'll let you know when I've got something else.", + + centered = true + }) + + else + lib.alertDialog({ + header = 'Crowbar', + content = "You shouldn't be talking to me yet.", + centered = true + }) + end + end + } + }) + + SetModelAsNoLongerNeeded(model) +end + +local function createCrowbarBlip() + if DoesBlipExist(crowbarBlip) then + return + end + + crowbarBlip = AddBlipForCoord( + crowbarCoords.x, + crowbarCoords.y, + crowbarCoords.z + ) + + SetBlipSprite(crowbarBlip, 280) + SetBlipScale(crowbarBlip, 0.75) + SetBlipAsShortRange(crowbarBlip, false) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Crowbar') + EndTextCommandSetBlipName(crowbarBlip) +end + +local function createCrowbarBike() + if DoesEntityExist(crowbarBike) then + return + end + + local model = `daemon` + + lib.requestModel(model) + + crowbarBike = CreateVehicle( + model, + 1699.70, + 3771.63, + 34.53, + 258.21, + false, + false + ) + + SetEntityAsMissionEntity(crowbarBike, true, true) + SetVehicleOnGroundProperly(crowbarBike) + SetVehicleDoorsLocked(crowbarBike, 2) + SetEntityInvincible(crowbarBike, true) + FreezeEntityPosition(crowbarBike, true) + + SetModelAsNoLongerNeeded(model) +end + +local function unlockCrowbar() + createCrowbar() + createCrowbarBike() + createCrowbarBlip() + + print('[how_progression] Crowbar unlocked') +end + +RegisterNetEvent( + 'how_progression:client:unlockCrowbar', + unlockCrowbar +) + + +-- Forward declarations for progression-driven world state. +local createMackBlip +local unlockVinnyWorld + +-- Restore persistent progression-driven world state after character login. +RegisterNetEvent( + 'how_progression:client:syncProgression', + function(progression) + if not progression then return end + + -- Stage 0 means the contact has not been discovered. + if (progression.crowbar_stage or 0) >= 1 then + unlockCrowbar() + end + + -- Mack becomes a persistent known contact once his progression begins. + if (progression.mack_stage or 0) >= 1 then + createMack() + createMackBlip() + end + + if progression.vinny_fence_unlocked == true + or tonumber(progression.vinny_fence_unlocked) == 1 + then + unlockVinnyWorld() + end + + -- Future persistent contacts are restored here. + -- Tina, Cosmic Dave, etc. will use their appropriate progression + -- state as they are added. + end +) + +-- ============================================================ +-- Mack Mission 1: Easy Rider +-- ============================================================ + +local mackMission1BikeSpawns = { + vec4(2360.90, 3114.91, 48.21, 239.14), + vec4(2360.28, 3113.93, 48.21, 245.54), + vec4(2359.64, 3112.59, 48.21, 265.43), + vec4(2359.45, 3111.15, 48.21, 246.34), + vec4(2361.34, 3117.44, 48.21, 234.06) +} + +local vinnyMissionDrop = + vec3(1373.86, 3615.83, 34.89) + +local vinnyFenceDrop = + vec4(1356.79, 3617.37, 34.88, 105.38) + +local vinnyCoords = + vec4(1362.57, 3602.49, 34.96, 236.75) + +local mackMission1DetourCoords = + vec3(252.87, 2814.15, 43.19) + +local mackMission1TrashCarCoords = + vec4(285.10, 2827.15, 42.89, 116.30) + +local mackMission1Bikes = {} +local mackMission1TrashCar +local mackMission1Blip +local mackMission1Active = false +local mackMission1Objective +local mackMission1Delivered = {} +local mackMission1DetourSent = false +local mackMission1TrashReported = false +local mackMission1Owner = false +local mackMission1BikeCount = 1 +local mackMission1HeadingToVinny = false +local mackMission1DetourExplained = false +local mackMission1DeliveryMarkerActive = false + +local vinnyPed +local vinnyBlip +local vinnyFenceUnlocked = false +local vinnyDisplayBikes = {} +local vinnyDisplayPositions = { + vec4(1368.39, 3594.54, 34.34, 237.54), + vec4(1366.95, 3598.24, 34.37, 244.92), + vec4(1368.76, 3592.05, 34.37, 241.99), + vec4(1357.67, 3601.45, 34.44, 194.85), + vec4(1364.94, 3603.77, 34.33, 195.30) +} + +local function clearMackMission1Blip() + if DoesBlipExist(mackMission1Blip) then + RemoveBlip(mackMission1Blip) + end + + mackMission1Blip = nil +end + +local function setMackMission1Route(coords, name) + clearMackMission1Blip() + + mackMission1Blip = + AddBlipForCoord(coords.x, coords.y, coords.z) + + SetBlipSprite(mackMission1Blip, 1) + SetBlipColour(mackMission1Blip, 5) + SetBlipRoute(mackMission1Blip, true) + SetBlipRouteColour(mackMission1Blip, 5) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString(name) + EndTextCommandSetBlipName(mackMission1Blip) + + SetNewWaypoint(coords.x, coords.y) +end + +local function spawnMackMission1Bikes() + if #mackMission1Bikes > 0 then return end + + local model = `sanchez` + lib.requestModel(model) + + for i = 1, mackMission1BikeCount do + local coords = mackMission1BikeSpawns[i] + + local bike = CreateVehicle( + model, + coords.x, + coords.y, + coords.z, + coords.w, + true, + true + ) + + SetEntityAsMissionEntity(bike, true, true) + SetVehicleOnGroundProperly(bike) + SetVehicleDoorsLocked(bike, 1) + SetVehicleEngineOn(bike, false, true, true) + + Entity(bike).state:set( + 'howMackMission1Bike', + i, + true + ) + + mackMission1Bikes[i] = bike + + local netId = NetworkGetNetworkIdFromEntity(bike) + + TriggerServerEvent( + 'how_progression:server:mackMission1BikeSpawned', + i, + netId + ) + end + + SetModelAsNoLongerNeeded(model) +end + +local function spawnMackMission1TrashCar() + if DoesEntityExist(mackMission1TrashCar) then + return + end + + local model = `superd` + lib.requestModel(model) + + mackMission1TrashCar = CreateVehicle( + model, + mackMission1TrashCarCoords.x, + mackMission1TrashCarCoords.y, + mackMission1TrashCarCoords.z, + mackMission1TrashCarCoords.w, + true, + true + ) + + SetEntityAsMissionEntity( + mackMission1TrashCar, + true, + true + ) + + SetVehicleOnGroundProperly(mackMission1TrashCar) + SetVehicleDoorsLocked(mackMission1TrashCar, 2) + SetVehicleEngineOn( + mackMission1TrashCar, + false, + true, + true + ) + + SetModelAsNoLongerNeeded(model) +end + +local function createVinnyDisplayBikes() + lib.requestModel(`sanchez`) + local price = exports.qbx_core:GetVehiclesByName().sanchez.price + + for i, coords in ipairs(vinnyDisplayPositions) do + if not DoesEntityExist(vinnyDisplayBikes[i]) then + local bike = CreateVehicle( + `sanchez`, coords.x, coords.y, coords.z, coords.w, + false, false + ) + + if DoesEntityExist(bike) then + SetEntityAsMissionEntity(bike, true, true) + SetVehicleOnGroundProperly(bike) + SetVehicleDoorsLocked(bike, 2) + SetEntityInvincible(bike, true) + FreezeEntityPosition(bike, true) + + exports.ox_target:addLocalEntity(bike, { + { + name = 'how_progression_vinny_buy_' .. i, + icon = 'fa-solid fa-motorcycle', + label = ('Buy Sanchez ($%s)'):format( + lib.math.groupdigits(price) + ), + distance = 2.0, + onSelect = function() + TriggerServerEvent( + 'qbx_vehicleshop:server:buyShowroomVehicle', + 'sanchez' + ) + end + } + }) + + vinnyDisplayBikes[i] = bike + end + end + end + + SetModelAsNoLongerNeeded(`sanchez`) +end + +local function createVinny() + if DoesEntityExist(vinnyPed) then + createVinnyDisplayBikes() + return + end + + -- Generic salesman/businessman. Easy to change after we see him. + local model = `a_m_m_business_01` + lib.requestModel(model) + + vinnyPed = CreatePed( + 0, + model, + vinnyCoords.x, + vinnyCoords.y, + vinnyCoords.z - 1.0, + vinnyCoords.w, + false, + false + ) + + SetPedDefaultComponentVariation(vinnyPed) + SetEntityAsMissionEntity(vinnyPed, true, true) + SetEntityInvincible(vinnyPed, true) + FreezeEntityPosition(vinnyPed, true) + SetBlockingOfNonTemporaryEvents(vinnyPed, true) + + TaskStartScenarioInPlace( + vinnyPed, + 'WORLD_HUMAN_STAND_IMPATIENT', + 0, + true + ) + + exports.ox_target:addLocalEntity(vinnyPed, { + { + name = 'how_progression_vinny_talk', + icon = 'fa-solid fa-comment', + label = 'Talk to Vinny', + distance = 2.0, + + canInteract = function() + return mackMission1Objective == 'talk_vinny' + end, + + onSelect = function() + lib.alertDialog({ + header = 'Vinny', + content = + "Yeah, thanks, thanks. Now get outta here. " .. + "I see a sucker ready to buy.\n\n" .. + "Give me your number and I'll text ya.\n\n" .. + "I promise you're gonna like this.", + centered = true + }) + + TriggerServerEvent( + 'how_progression:server:completeMackMission1' + ) + end + } + }) + + SetModelAsNoLongerNeeded(model) + createVinnyDisplayBikes() +end + +local function createVinnyBlip() + if DoesBlipExist(vinnyBlip) then return end + + vinnyBlip = AddBlipForCoord( + vinnyCoords.x, + vinnyCoords.y, + vinnyCoords.z + ) + + SetBlipSprite(vinnyBlip, 226) + SetBlipScale(vinnyBlip, 0.7) + SetBlipAsShortRange(vinnyBlip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Vinny') + EndTextCommandSetBlipName(vinnyBlip) +end + +unlockVinnyWorld = function() + vinnyFenceUnlocked = true + mackMission1DeliveryMarkerActive = false + + createVinny() + createVinnyBlip() +end + +AddEventHandler('QBCore:Client:OnPlayerUnload', function() + vinnyFenceUnlocked = false + mackMission1DeliveryMarkerActive = false + + if DoesEntityExist(vinnyPed) then + exports.ox_target:removeLocalEntity(vinnyPed, 'how_progression_vinny_talk') + DeleteEntity(vinnyPed) + end + vinnyPed = nil + + if DoesBlipExist(vinnyBlip) then RemoveBlip(vinnyBlip) end + vinnyBlip = nil + + for i, bike in ipairs(vinnyDisplayBikes) do + if DoesEntityExist(bike) then + exports.ox_target:removeLocalEntity( + bike, 'how_progression_vinny_buy_' .. i + ) + DeleteVehicle(bike) + end + end + vinnyDisplayBikes = {} +end) + +RegisterNetEvent( + 'how_progression:client:startMackMission1', + function(isOwner, bikeCount) + mackMission1Active = true + mackMission1Objective = 'leave_scrapyard' + mackMission1Delivered = {} + mackMission1DetourSent = false + mackMission1TrashReported = false + mackMission1HeadingToVinny = false + mackMission1DetourExplained = false + mackMission1DeliveryMarkerActive = false + mackMission1Owner = isOwner == true + mackMission1BikeCount = math.min( + math.max(tonumber(bikeCount) or 1, 1), + 5 + ) + + if mackMission1Owner then + spawnMackMission1Bikes() + end + + createVinny() + + setMackMission1Route( + vec3( + mackMission1BikeSpawns[1].x, + mackMission1BikeSpawns[1].y, + mackMission1BikeSpawns[1].z + ), + 'Pick Up the Bikes' + ) + + lib.notify({ + title = 'Easy Rider', + description = ( + 'Pick up the Sanchez%s behind Mack. (%d total)' + ):format( + mackMission1BikeCount == 1 and '' or 'es', + mackMission1BikeCount + ), + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackMission1TrashCar', + function(isOwner) + if not mackMission1Active then return end + + mackMission1Objective = 'trash_car' + + if isOwner == true then + spawnMackMission1TrashCar() + end + + setMackMission1Route( + mackMission1DetourCoords, + "Mack's Detour" + ) + + lib.notify({ + title = 'Mack', + description = "Read Mack's message.", + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackMission1DetourExplained', + function() + if not mackMission1Active then return end + + mackMission1DetourExplained = true + + local targetCoords = mackMission1TrashCarCoords + if DoesEntityExist(mackMission1TrashCar) then + targetCoords = GetEntityCoords(mackMission1TrashCar) + end + + setMackMission1Route(targetCoords, "Mack's Target") + + lib.notify({ + title = 'Mack', + description = "Read Mack's message.", + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackMission1DeliverBikes', + function(deliveredCount, bikeCount) + if not mackMission1Active then return end + + mackMission1Objective = 'deliver_bikes' + mackMission1DeliveryMarkerActive = true + mackMission1BikeCount = + tonumber(bikeCount) or mackMission1BikeCount + + setMackMission1Route( + vinnyMissionDrop, + 'Deliver the Bikes' + ) + + lib.notify({ + title = 'Easy Rider', + description = ( + 'Deliver the Sanchez%s to Vinny. (%d/%d)' + ):format( + mackMission1BikeCount == 1 and '' or 'es', + deliveredCount or 0, + mackMission1BikeCount + ), + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackMission1DeliveryCount', + function(count, bikeCount) + mackMission1BikeCount = + tonumber(bikeCount) or mackMission1BikeCount + + lib.notify({ + title = 'Easy Rider', + description = ( + 'Sanchez%s delivered: %d/%d' + ):format( + mackMission1BikeCount == 1 and '' or 'es', + count, + mackMission1BikeCount + ), + type = 'success' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackMission1TalkVinny', + function() + mackMission1Objective = 'talk_vinny' + mackMission1DeliveryMarkerActive = false + + clearMackMission1Blip() + + setMackMission1Route( + vec3( + vinnyCoords.x, + vinnyCoords.y, + vinnyCoords.z + ), + 'Vinny' + ) + + lib.notify({ + title = 'Easy Rider', + description = 'Talk to Vinny.', + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackMission1Complete', + function() + mackMission1Active = false + mackMission1Objective = nil + mackMission1Owner = false + mackMission1DeliveryMarkerActive = false + + ClearGpsPlayerWaypoint() + clearMackMission1Blip() + + lib.notify({ + title = 'Easy Rider', + description = 'Job complete.', + type = 'success' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:mackReward', + function(repReward, newRep, _, cashReward) + lib.notify({ + title = 'Mack', + description = ( + '$%d | +%d REP | Mack REP: %d' + ):format( + cashReward or 0, + repReward or 0, + newRep or 0 + ), + type = 'success' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:unlockVinnyFence', + function() + unlockVinnyWorld() + + lib.notify({ + title = 'Sanchez Fence Unlocked', + description = + 'Vinny buys stolen Sanchezes around back.', + type = 'success' + }) + end +) + +RegisterNetEvent('how_progression:client:leaveFenceBike', function(netId) + local ped = PlayerPedId() + local vehicle = GetVehiclePedIsIn(ped, false) + if vehicle ~= 0 and NetworkGetNetworkIdFromEntity(vehicle) == netId then + TaskLeaveVehicle(ped, vehicle, 0) + end +end) + +CreateThread(function() + local lastAttempt = 0 + + while true do + Wait(vinnyFenceUnlocked and 150 or 1000) + + if vinnyFenceUnlocked and GetGameTimer() - lastAttempt >= 5000 then + local ped = PlayerPedId() + local vehicle = GetVehiclePedIsIn(ped, false) + + if vehicle ~= 0 + and GetPedInVehicleSeat(vehicle, -1) == ped + and GetEntityModel(vehicle) == `sanchez` + and #(GetEntityCoords(vehicle) - vinnyFenceDrop.xyz) <= 2.5 + then + lastAttempt = GetGameTimer() + + local gameDay = GetClockYear() * 10000 + + GetClockMonth() * 100 + + GetClockDayOfMonth() + + local success, message = lib.callback.await( + 'how_progression:server:fenceSanchez', + false, + NetworkGetNetworkIdFromEntity(vehicle), + gameDay + ) + + lib.notify({ + title = 'Vinny', + description = message or 'Vinny could not take that bike.', + type = success and 'success' or 'error' + }) + end + end + end +end) + +-- Draw Vinny's fence marker and the mission-bike delivery circle every frame. +CreateThread(function() + while true do + Wait(0) + + if vinnyFenceUnlocked then + DrawMarker( + 1, + vinnyFenceDrop.x, + vinnyFenceDrop.y, + vinnyFenceDrop.z - 1.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, vinnyFenceDrop.w, + 3.0, 3.0, 0.5, + 255, 255, 255, 150, + false, + false, + 2, + false, + nil, + nil, + false + ) + end + + if mackMission1DeliveryMarkerActive + and mackMission1Objective == 'deliver_bikes' + then + DrawMarker( + 1, + vinnyMissionDrop.x, + vinnyMissionDrop.y, + vinnyMissionDrop.z - 1.0, + 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, + 4.5, 4.5, 1.0, + 255, 255, 255, 160, + false, + false, + 2, + false, + nil, + nil, + false + ) + end + end +end) + +-- Mission monitoring. +CreateThread(function() + while true do + if not mackMission1Active then + Wait(1000) + else + Wait(100) + + local ped = PlayerPedId() + local playerCoords = GetEntityCoords(ped) + + -- Riding a mission Sanchez makes Vinny the main destination. + if mackMission1Objective == 'leave_scrapyard' + and not mackMission1HeadingToVinny + and IsPedInAnyVehicle(ped, false) + then + local vehicle = GetVehiclePedIsIn(ped, false) + + if GetPedInVehicleSeat(vehicle, -1) == ped then + local missionBike = + Entity(vehicle).state.howMackMission1Bike + + if missionBike then + mackMission1HeadingToVinny = true + + setMackMission1Route( + vinnyMissionDrop, + 'Deliver the Bikes' + ) + + lib.notify({ + title = 'Easy Rider', + description = 'Deliver the bike to Vinny.', + type = 'inform' + }) + end + end + end + + -- Mack interrupts once the crew is underway. + if mackMission1Objective == 'leave_scrapyard' + and not mackMission1DetourSent + then + local distance = + #(playerCoords - vec3( + mackMission1BikeSpawns[1].x, + mackMission1BikeSpawns[1].y, + mackMission1BikeSpawns[1].z + )) + + if distance >= 175.0 then + mackMission1DetourSent = true + + TriggerServerEvent( + 'how_progression:server:mackMission1Detour' + ) + end + end + + -- Reaching Mack's address triggers the actual explanation. + if mackMission1Objective == 'trash_car' + and not mackMission1DetourExplained + then + local distance = + #(playerCoords - mackMission1DetourCoords) + + if distance <= 35.0 then + mackMission1DetourExplained = true + + TriggerServerEvent( + 'how_progression:server:mackMission1AtDetour' + ) + end + end + + -- The car must now be genuinely trashed. + -- + -- Body health must be heavily reduced AND the engine must + -- have taken meaningful damage. A few bumps won't do it. + if mackMission1Owner + and mackMission1Objective == 'trash_car' + and mackMission1DetourExplained + and DoesEntityExist(mackMission1TrashCar) + and not mackMission1TrashReported + then + local bodyHealth = + GetVehicleBodyHealth(mackMission1TrashCar) + + local engineHealth = + GetVehicleEngineHealth(mackMission1TrashCar) + + if bodyHealth <= 250.0 + and engineHealth <= 650.0 + then + mackMission1TrashReported = true + + TriggerServerEvent( + 'how_progression:server:mackMission1CarTrashed' + ) + end + end + + if mackMission1DeliveryMarkerActive + and mackMission1Objective == 'deliver_bikes' + then + -- Owner is authoritative for mission-bike delivery. + if mackMission1Owner + and IsPedInAnyVehicle(ped, false) + then + local vehicle = + GetVehiclePedIsIn(ped, false) + + if GetPedInVehicleSeat(vehicle, -1) == ped then + local bikeIndex = + Entity(vehicle).state.howMackMission1Bike + + if bikeIndex + and not mackMission1Delivered[bikeIndex] + then + local vehicleCoords = + GetEntityCoords(vehicle) + + local distance = + #(vehicleCoords - vinnyMissionDrop) + + if distance <= 3.0 then + mackMission1Delivered[bikeIndex] = true + + TaskLeaveVehicle( + ped, + vehicle, + 0 + ) + + local timeout = + GetGameTimer() + 3000 + + while IsPedInVehicle( + ped, + vehicle, + false + ) and GetGameTimer() < timeout do + Wait(50) + end + + Wait(500) + + if DoesEntityExist(vehicle) then + SetEntityAsMissionEntity( + vehicle, + true, + true + ) + DeleteVehicle(vehicle) + end + + TriggerServerEvent( + 'how_progression:server:mackMission1BikeDelivered', + bikeIndex + ) + end + end + end + end + end + end + end +end) + +-- ============================================================ +-- Crowbar Mission 4: Wash the Cash +-- ============================================================ + +local gabeCoords = vec3(2340.96, 3126.11, 48.21) + +createMackBlip = function() + if DoesBlipExist(mackBlip) then return end + + mackBlip = AddBlipForCoord( + mackCoords.x, + mackCoords.y, + mackCoords.z + ) + + SetBlipSprite(mackBlip, 280) + SetBlipScale(mackBlip, 0.75) + SetBlipAsShortRange(mackBlip, false) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Mack') + EndTextCommandSetBlipName(mackBlip) +end + +local function createGabeBlip() + if DoesBlipExist(gabeBlip) then return end + + gabeBlip = AddBlipForCoord( + gabeCoords.x, + gabeCoords.y, + gabeCoords.z + ) + + SetBlipSprite(gabeBlip, 500) + SetBlipDisplay(gabeBlip, 4) + SetBlipScale(gabeBlip, 0.7) + SetBlipAsShortRange(gabeBlip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Money Laundering') + EndTextCommandSetBlipName(gabeBlip) +end + +RegisterNetEvent( + 'how_progression:client:startCrowbarMission4', + function() + createMackBlip() + SetNewWaypoint(mackCoords.x, mackCoords.y) + + lib.notify({ + title = 'Wash the Cash', + description = 'Go meet Mack at the scrapyard.', + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission4Mack', + function() + ClearGpsPlayerWaypoint() + createMackBlip() + createGabeBlip() + SetNewWaypoint(gabeCoords.x, gabeCoords.y) + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission4Gabe', + function() + ClearGpsPlayerWaypoint() + createGabeBlip() + end +) + +RegisterNetEvent( + 'how_progression:client:unlockMack', + function(setRoute) + createMack() + createMackBlip() + + if setRoute then + SetNewWaypoint(mackCoords.x, mackCoords.y) + end + end +) + +RegisterNetEvent( + 'how_progression:client:unlockGabe', + function(setRoute) + createGabeBlip() + + if setRoute then + SetNewWaypoint(gabeCoords.x, gabeCoords.y) + end + end +) + +RegisterNetEvent( + 'how_progression:client:cleanupCrowbarMission4', + function() + ClearGpsPlayerWaypoint() + createMackBlip() + createGabeBlip() + end +) + +-- ============================================================ +-- Crowbar Mission 5: Buy In +-- ============================================================ + +local crowbarMission5Drop = + vec3(1537.56, 1700.49, 109.68) + +local crowbarMission5DropBlip +local crowbarMission5ReturnBlip +local crowbarMission5Active = false +local crowbarMission5CanReturn = false +local crowbarMission5StashId +local crowbarMission5Crate + +local function clearCrowbarMission5Blips() + if DoesBlipExist(crowbarMission5DropBlip) then + RemoveBlip(crowbarMission5DropBlip) + crowbarMission5DropBlip = nil + end + + if DoesBlipExist(crowbarMission5ReturnBlip) then + RemoveBlip(crowbarMission5ReturnBlip) + crowbarMission5ReturnBlip = nil + end +end + +local function createCrowbarMission5DropBlip() + if DoesBlipExist(crowbarMission5DropBlip) then return end + + crowbarMission5DropBlip = AddBlipForCoord( + crowbarMission5Drop.x, + crowbarMission5Drop.y, + crowbarMission5Drop.z + ) + + SetBlipSprite(crowbarMission5DropBlip, 478) + SetBlipScale(crowbarMission5DropBlip, 0.75) + SetBlipRoute(crowbarMission5DropBlip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Package Pickup') + EndTextCommandSetBlipName(crowbarMission5DropBlip) +end + +local function createCrowbarMission5ReturnBlip() + if DoesBlipExist(crowbarMission5ReturnBlip) then return end + + crowbarMission5ReturnBlip = AddBlipForCoord( + crowbarCoords.x, + crowbarCoords.y, + crowbarCoords.z + ) + + SetBlipSprite(crowbarMission5ReturnBlip, 280) + SetBlipScale(crowbarMission5ReturnBlip, 0.75) + SetBlipRoute(crowbarMission5ReturnBlip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Crowbar') + EndTextCommandSetBlipName(crowbarMission5ReturnBlip) +end + +local function armCrowbarMission5Ambusher(ped) + SetEntityAsMissionEntity(ped, true, true) + + SetPedRelationshipGroupHash( + ped, + `HOW_CROWBAR_AMBUSH` + ) + + GiveWeaponToPed( + ped, + `WEAPON_PISTOL`, + 90, + false, + true + ) + + SetPedAccuracy(ped, 18) + SetPedCombatAbility(ped, 1) + SetPedCombatRange(ped, 1) + SetPedCombatMovement(ped, 2) + SetPedKeepTask(ped, true) +end + +local crowbarMission5Ambushers = {} +local crowbarMission5EscapeActive = false +local crowbarMission5EscapeReported = false + +local function attackCrowbarMission5Crew(ped) + TaskCombatHatedTargetsAroundPed( + ped, + 150.0, + 0 + ) +end + +local function createCrowbarMission5AmbushVehicle(data) + local vehicleModel = joaat(data.vehicle) + + lib.requestModel(vehicleModel) + + local vehicle = CreateVehicle( + vehicleModel, + data.spawn.x, + data.spawn.y, + data.spawn.z, + data.spawn.w, + true, + true + ) + + if not DoesEntityExist(vehicle) then + print( + '[how_progression] Failed to create M5 ambush vehicle: ' + .. data.vehicle + ) + return + end + + SetEntityAsMissionEntity(vehicle, true, true) + SetVehicleOnGroundProperly(vehicle) + SetVehicleEngineOn(vehicle, true, true, false) + + local occupants = {} + + for seat = -1, 1 do + local model = data.peds[seat + 2] + + lib.requestModel(model) + + local ped = CreatePedInsideVehicle( + vehicle, + 4, + model, + seat, + true, + true + ) + + if DoesEntityExist(ped) then + armCrowbarMission5Ambusher(ped) + occupants[#occupants + 1] = ped + crowbarMission5Ambushers[#crowbarMission5Ambushers + 1] = ped + end + + SetModelAsNoLongerNeeded(model) + end + + local driver = occupants[1] + + if not DoesEntityExist(driver) then + DeleteEntity(vehicle) + return + end + + SetDriverAbility(driver, 1.0) + SetDriverAggressiveness(driver, 1.0) + + CreateThread(function() + local target = PlayerPedId() + local lastTask = 0 + local onFootAttack = false + + while crowbarMission5Active + and crowbarMission5EscapeActive + and DoesEntityExist(vehicle) + and DoesEntityExist(driver) + and not IsEntityDead(driver) + and DoesEntityExist(target) + and not IsEntityDead(target) + do + local vehicleCoords = GetEntityCoords(vehicle) + local targetCoords = GetEntityCoords(target) + local distance = #(vehicleCoords - targetCoords) + + -- + -- Stay in the truck and pursue while the carrier is + -- moving away or still some distance from the Lost. + -- + if distance > 30.0 and not onFootAttack then + if GetGameTimer() - lastTask > 2500 then + TaskVehicleChase(driver, target) + + SetDriveTaskDrivingStyle( + driver, + 786603 + ) + + SetDriveTaskCruiseSpeed( + driver, + 35.0 + ) + + lastTask = GetGameTimer() + end + + -- + -- Once they actually catch the carrier, pile out and fight. + -- + elseif distance <= 30.0 and not onFootAttack then + onFootAttack = true + + BringVehicleToHalt( + vehicle, + 4.0, + 1000, + false + ) + + Wait(750) + + for _, ped in ipairs(occupants) do + if DoesEntityExist(ped) + and not IsEntityDead(ped) + then + TaskLeaveVehicle( + ped, + vehicle, + 256 + ) + end + end + + Wait(1200) + + for _, ped in ipairs(occupants) do + if DoesEntityExist(ped) + and not IsEntityDead(ped) + then + TaskCombatPed( + ped, + target, + 0, + 16 + ) + + SetPedKeepTask(ped, true) + end + end + + -- + -- If the carrier runs after the Lost have dismounted, + -- keep the surviving attackers actively hunting them. + -- + elseif onFootAttack then + for _, ped in ipairs(occupants) do + if DoesEntityExist(ped) + and not IsEntityDead(ped) + and not IsPedInCombat(ped, target) + then + TaskCombatPed( + ped, + target, + 0, + 16 + ) + + SetPedKeepTask(ped, true) + end + end + end + + Wait(500) + end + end) + + SetModelAsNoLongerNeeded(vehicleModel) +end + +local function spawnCrowbarMission5Ambush() + AddRelationshipGroup('HOW_CROWBAR_AMBUSH') + + SetRelationshipBetweenGroups( + 5, + `HOW_CROWBAR_AMBUSH`, + `PLAYER` + ) + + SetRelationshipBetweenGroups( + 5, + `PLAYER`, + `HOW_CROWBAR_AMBUSH` + ) + + lib.notify({ + title = 'Buy In', + description = + "Looks like somebody else wanted Crowbar's package.", + type = 'error' + }) + + CreateThread(function() + -- Lead truck + createCrowbarMission5AmbushVehicle({ + vehicle = 'rebel', + + spawn = vec4( + 1464.0, + 1760.0, + 108.0, + 130.0 + ), + + destination = vec3( + 1521.0, + 1715.0, + 109.0 + ), + + peds = { + `g_m_y_lost_01`, + `g_m_y_lost_02`, + `g_m_y_lost_03` + } + }) + + -- Second truck follows the same reliable approach. + createCrowbarMission5AmbushVehicle({ + vehicle = 'bodhi2', + + spawn = vec4( + 1448.0, + 1774.0, + 108.0, + 130.0 + ), + + destination = vec3( + 1513.0, + 1723.0, + 109.0 + ), + + peds = { + `g_m_y_lost_02`, + `g_m_y_lost_03`, + `g_m_y_lost_01` + } + }) + end) +end + +RegisterNetEvent( + 'how_progression:client:startCrowbarMission5', + function(stashId) + crowbarMission5Active = true + crowbarMission5CanReturn = false + crowbarMission5StashId = stashId + + clearCrowbarMission5Blips() + createCrowbarMission5DropBlip() + + SetNewWaypoint( + crowbarMission5Drop.x, + crowbarMission5Drop.y + ) + + lib.notify({ + title = 'Buy In', + description = 'Find the package at the desert shed.', + type = 'inform' + }) + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission5PackageTaken', + function(spawnAmbush) + crowbarMission5CanReturn = false + + if DoesBlipExist(crowbarMission5DropBlip) then + RemoveBlip(crowbarMission5DropBlip) + crowbarMission5DropBlip = nil + end + + ClearGpsPlayerWaypoint() + + if spawnAmbush then + crowbarMission5Ambushers = {} + crowbarMission5EscapeActive = true + crowbarMission5EscapeReported = false + + spawnCrowbarMission5Ambush() + + CreateThread(function() + -- + -- Give both ambush vehicles time to spawn before + -- evaluating whether everybody is dead. + -- + Wait(3000) + + local clearSince = nil + + while crowbarMission5Active + and crowbarMission5EscapeActive + and not crowbarMission5EscapeReported + do + local playerPed = PlayerPedId() + local playerCoords = + GetEntityCoords(playerPed) + + local living = 0 + local allFarEnough = true + + for _, ped in ipairs(crowbarMission5Ambushers) do + if DoesEntityExist(ped) + and not IsEntityDead(ped) + then + living = living + 1 + + local distance = + #(GetEntityCoords(ped) - playerCoords) + + if distance < 250.0 then + allFarEnough = false + end + end + end + + -- + -- Killing every ambusher resolves the escape + -- immediately. + -- + if living == 0 + and #crowbarMission5Ambushers > 0 + then + crowbarMission5EscapeReported = true + + TriggerServerEvent( + 'how_progression:server:crowbarMission5Escaped' + ) + + break + end + + -- + -- Otherwise every surviving pursuer must remain + -- at least 250m away for 20 continuous seconds. + -- + if living > 0 and allFarEnough then + if not clearSince then + clearSince = GetGameTimer() + elseif GetGameTimer() - clearSince >= 20000 then + crowbarMission5EscapeReported = true + + TriggerServerEvent( + 'how_progression:server:crowbarMission5Escaped' + ) + + break + end + else + clearSince = nil + end + + Wait(500) + end + end) + end + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission5Escaped', + function() + crowbarMission5EscapeActive = false + crowbarMission5CanReturn = true + + lib.notify({ + title = 'Buy In', + description = 'You lost the pursuers.', + type = 'success' + }) + + createCrowbarMission5ReturnBlip() + + SetNewWaypoint( + crowbarCoords.x, + crowbarCoords.y + ) + end +) + +RegisterNetEvent( + 'how_progression:client:cleanupCrowbarMission5', + function() + crowbarMission5Active = false + crowbarMission5CanReturn = false + crowbarMission5EscapeActive = false + crowbarMission5EscapeReported = false + crowbarMission5StashId = nil + crowbarMission5Ambushers = {} + + ClearGpsPlayerWaypoint() + clearCrowbarMission5Blips() + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission5Complete', + function() + lib.alertDialog({ + header = 'Crowbar', + content = "You got it here. That's all that matters.", + centered = true + }) + end +) + +local function createCrowbarMission5Crate() + if DoesEntityExist(crowbarMission5Crate) then + return + end + + local model = `prop_box_wood02a_pu` + lib.requestModel(model) + + -- Visually verified placement for the package crate. + local objectZ = 108.6397 + + crowbarMission5Crate = CreateObject( + model, + crowbarMission5Drop.x, + crowbarMission5Drop.y, + objectZ, + false, + false, + false + ) + + SetEntityHeading(crowbarMission5Crate, 56.26) + FreezeEntityPosition(crowbarMission5Crate, true) + SetEntityAsMissionEntity( + crowbarMission5Crate, + true, + true + ) + + exports.ox_target:addLocalEntity( + crowbarMission5Crate, + { + { + name = 'how_progression_crowbar_package', + icon = 'fa-solid fa-box', + label = 'Search Container', + distance = 2.5, + + canInteract = function() + return crowbarMission5Active + and not crowbarMission5CanReturn + and crowbarMission5StashId ~= nil + end, + + onSelect = function() + exports.ox_inventory:openInventory( + 'stash', + crowbarMission5StashId + ) + end + } + } + ) + + SetModelAsNoLongerNeeded(model) +end + +CreateThread(function() + Wait(2000) + createCrowbarMission5Crate() +end) + +exports.ox_target:addSphereZone({ + coords = vec3( + crowbarCoords.x, + crowbarCoords.y, + crowbarCoords.z + ), + radius = 2.0, + debug = false, + + options = { + { + name = 'how_progression_crowbar_package_return', + icon = 'fa-solid fa-box', + label = 'Give Crowbar the Package', + + canInteract = function() + return crowbarMission5Active + and crowbarMission5CanReturn + end, + + onSelect = function() + TriggerServerEvent( + 'how_progression:server:completeCrowbarMission5' + ) + end + } + } +}) + +-- ============================================================ +-- Crowbar Mission 1 +-- ============================================================ + +RegisterNetEvent( + 'how_progression:client:startCrowbarMission1', + function(netId) + crowbarMission1Vehicle = netId + crowbarMission1ReturnActive = false + + if DoesBlipExist(crowbarMission1Blip) then + RemoveBlip(crowbarMission1Blip) + crowbarMission1Blip = nil + end + + crowbarMission1Blip = AddBlipForCoord( + 911.40, + 3589.50, + 33.25 + ) + + SetBlipSprite(crowbarMission1Blip, 225) + SetBlipScale(crowbarMission1Blip, 0.85) + SetBlipRoute(crowbarMission1Blip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString("Crowbar's Target") + EndTextCommandSetBlipName(crowbarMission1Blip) + + lib.notify({ + title = 'Crowbar', + description = "Take the car and bring it back to Crowbar.", + type = 'inform', + duration = 5000 + }) + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission1Return', + function() + if not crowbarMission1Vehicle then + return + end + + crowbarMission1ReturnActive = true + + if DoesBlipExist(crowbarMission1Blip) then + RemoveBlip(crowbarMission1Blip) + crowbarMission1Blip = nil + end + + crowbarMission1Blip = AddBlipForCoord( + 1705.50, + 3771.76, + 34.49 + ) + + SetBlipSprite(crowbarMission1Blip, 1) + SetBlipScale(crowbarMission1Blip, 0.85) + SetBlipRoute(crowbarMission1Blip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString("Crowbar's Drop-Off") + EndTextCommandSetBlipName(crowbarMission1Blip) + + lib.notify({ + title = 'Crowbar', + description = 'Good. Bring it back to me.', + type = 'inform', + duration = 5000 + }) + end +) + +-- Mission 1 state transition: +-- Once the player starts the target vehicle, remove the target +-- blip and route them back to Crowbar's drop-off. +CreateThread(function() + while true do + Wait(500) + + if crowbarMission1Vehicle + and not crowbarMission1ReturnActive + and NetworkDoesEntityExistWithNetworkId( + crowbarMission1Vehicle + ) then + + local vehicle = + NetToVeh(crowbarMission1Vehicle) + + if DoesEntityExist(vehicle) then + local playerPed = PlayerPedId() + + if GetPedInVehicleSeat( + vehicle, + -1 + ) == playerPed + and GetIsVehicleEngineRunning(vehicle) then + + TriggerServerEvent( + 'how_progression:server:crowbarMission1VehicleAcquired' + ) + end + end + end + end +end) + +-- Mission 1 completion / return detection. +CreateThread(function() + while true do + Wait(500) + + if crowbarMission1Vehicle + and crowbarMission1ReturnActive then + + local vehicle = + NetToVeh(crowbarMission1Vehicle) + + if DoesEntityExist(vehicle) then + local playerPed = PlayerPedId() + + local vehicleCoords = + GetEntityCoords(vehicle) + + local returnCoords = + vector3( + 1705.50, + 3771.76, + 34.49 + ) + + if #(vehicleCoords - returnCoords) < 8.0 + and GetPedInVehicleSeat( + vehicle, + -1 + ) == playerPed then + + -- Prevent this from firing repeatedly while + -- the player is getting out. + crowbarMission1ReturnActive = false + + if DoesBlipExist(crowbarMission1Blip) then + RemoveBlip(crowbarMission1Blip) + crowbarMission1Blip = nil + end + + -- Shut the vehicle down and have the player exit. + SetVehicleEngineOn(vehicle, false, true, true) + SetVehicleHandbrake(vehicle, true) + + TaskLeaveVehicle( + playerPed, + vehicle, + 0 + ) + + lib.notify({ + title = 'Crowbar', + description = 'Good. Leave the car here.', + type = 'inform', + duration = 3000 + }) + + -- Wait until the player has actually exited + -- before asking the server to delete the vehicle. + CreateThread(function() + local timeout = GetGameTimer() + 10000 + + while DoesEntityExist(vehicle) + and GetPedInVehicleSeat( + vehicle, + -1 + ) == playerPed + and GetGameTimer() < timeout do + + Wait(250) + end + + if DoesEntityExist(vehicle) then + TriggerServerEvent( + 'how_progression:server:completeCrowbarMission1', + crowbarMission1Vehicle + ) + end + + crowbarMission1Vehicle = nil + end) + end + end + end + end +end) + +-- Visible return marker. +CreateThread(function() + while true do + Wait(0) + + if crowbarMission1ReturnActive then + DrawMarker( + 1, + 1705.50, + 3771.76, + 33.49, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 3.0, + 0.5, + 255, + 255, + 255, + 150, + false, + false, + 2, + false, + nil, + nil, + false + ) + end + end +end) + +-- ============================================================ +-- Crowbar Mission 2 +-- ============================================================ + +RegisterNetEvent( + 'how_progression:client:startCrowbarMission2', + function(netId) + crowbarMission2Vehicle = netId + crowbarMission2Stolen = false + + if DoesBlipExist(crowbarMission2Blip) then + RemoveBlip(crowbarMission2Blip) + crowbarMission2Blip = nil + end + + crowbarMission2Blip = AddBlipForCoord( + 2684.30, + 3292.18, + 54.75 + ) + + SetBlipSprite(crowbarMission2Blip, 225) + SetBlipScale(crowbarMission2Blip, 0.85) + SetBlipRoute(crowbarMission2Blip, true) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString("Crowbar's Target") + EndTextCommandSetBlipName(crowbarMission2Blip) + + lib.notify({ + title = 'Crowbar', + description = 'Find the car Crowbar told you about.', + type = 'inform', + duration = 5000 + }) + end +) + +local crowbarMission2Stolen = false + +CreateThread(function() + while true do + Wait(500) + + if crowbarMission2Vehicle + and not crowbarMission2Stolen then + + local vehicle = + NetToVeh(crowbarMission2Vehicle) + + if DoesEntityExist(vehicle) then + local playerPed = + PlayerPedId() + + local vehicleCoords = + GetEntityCoords(vehicle) + + local targetCoords = + vector3( + 2684.30, + 3292.18, + 54.75 + ) + + -- The player must actually be driving the stolen + -- vehicle away from the target before the mission + -- can complete. + if GetPedInVehicleSeat( + vehicle, + -1 + ) == playerPed + and #(vehicleCoords - targetCoords) >= 100.0 then + + crowbarMission2Stolen = true + + if DoesBlipExist(crowbarMission2Blip) then + RemoveBlip(crowbarMission2Blip) + crowbarMission2Blip = nil + end + + local props = + lib.getVehicleProperties(vehicle) + + TriggerServerEvent( + 'how_progression:server:completeCrowbarMission2', + crowbarMission2Vehicle, + props + ) + + crowbarMission2Vehicle = nil + + end + end + end + end +end) + +-- ============================================================ +-- Crowbar Mission 3: Quick Score +-- ============================================================ + +local crowbarMission3Blip = nil +local crowbarMission3EscapeActive = false +local crowbarMission3WantedSeen = false + +RegisterNetEvent( + 'how_progression:client:startCrowbarMission3', + function() + print('[how_progression] Mission 3 client start event received') + + crowbarMission3EscapeActive = false + crowbarMission3WantedSeen = false + + if DoesBlipExist(crowbarMission3Blip) then + RemoveBlip(crowbarMission3Blip) + crowbarMission3Blip = nil + end + + crowbarMission3Blip = AddBlipForCoord( + 549.33, + 2669.57, + 42.16 + ) + + SetBlipSprite(crowbarMission3Blip, 52) + SetBlipScale(crowbarMission3Blip, 0.85) + SetBlipRoute(crowbarMission3Blip, true) + + print(('[how_progression] Mission 3 Harmony blip created: %s'):format( + tostring(crowbarMission3Blip) + )) + + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Harmony 24/7') + EndTextCommandSetBlipName(crowbarMission3Blip) + + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission3Escape', + function() + if DoesBlipExist(crowbarMission3Blip) then + RemoveBlip(crowbarMission3Blip) + crowbarMission3Blip = nil + end + + crowbarMission3EscapeActive = true + + -- The robbery may already have raised the wanted level + -- before this event reaches the client. + if GetPlayerWantedLevel(PlayerId()) > 0 then + crowbarMission3WantedSeen = true + end + + lib.notify({ + title = 'Crowbar', + description = 'Get out of there. Lose the cops.', + type = 'inform', + duration = 5000 + }) + end +) + +CreateThread(function() + while true do + Wait(500) + + if crowbarMission3EscapeActive then + local wantedLevel = + GetPlayerWantedLevel(PlayerId()) + + if wantedLevel > 0 then + crowbarMission3WantedSeen = true + + elseif crowbarMission3WantedSeen then + crowbarMission3EscapeActive = false + crowbarMission3WantedSeen = false + + TriggerServerEvent( + 'how_progression:server:completeCrowbarMission3' + ) + end + end + end +end) + +-- ============================================================ +-- Rewards +-- ============================================================ + +RegisterNetEvent( + 'how_progression:client:crowbarReward', + function(repReward, totalRep, newStage) + lib.notify({ + title = 'Crowbar', + description = ('+%s Crowbar REP | Total: %s/100'):format( + repReward, + totalRep + ), + type = 'success', + duration = 5000 + }) + end +) + +RegisterNetEvent( + 'how_progression:client:crowbarMission1Reward', + function(cashReward, repReward) + lib.alertDialog({ + header = 'Crowbar', + + content = ( + "Not bad.\n\n" .. + "You followed directions, brought the car back intact, " .. + "and didn't make a complete mess of things.\n\n" .. + "Here's your cut: $%s.\n\n" .. + "Maybe there's something else you can do for me." + ):format(cashReward), + + centered = true + }) + end +) + +-- ============================================================ +-- Mack scrapyard ambience +-- ============================================================ + +local scrapyardFestivalBus +local scrapyardJunkCar +local scrapyardForklift +local scrapyardWorkers = {} + +local function protectScrapyardVehicle(vehicle) + SetEntityAsMissionEntity(vehicle, true, true) + SetVehicleOnGroundProperly(vehicle) + SetVehicleDoorsLocked(vehicle, 2) + SetEntityInvincible(vehicle, true) + FreezeEntityPosition(vehicle, true) +end + +local function createScrapyardVehicle(modelName, coords) + local model = joaat(modelName) + + lib.requestModel(model) + + local vehicle = CreateVehicle( + model, + coords.x, + coords.y, + coords.z, + coords.w, + false, + false + ) + + protectScrapyardVehicle(vehicle) + SetModelAsNoLongerNeeded(model) + + return vehicle +end + +local function createScrapyardWorker(modelName, coords, radius) + local model = joaat(modelName) + + lib.requestModel(model) + + local ped = CreatePed( + 0, + model, + coords.x, + coords.y, + coords.z - 1.0, + coords.w, + false, + false + ) + + SetPedDefaultComponentVariation(ped) + SetEntityAsMissionEntity(ped, true, true) + SetEntityInvincible(ped, true) + SetBlockingOfNonTemporaryEvents(ped, true) + SetPedCanRagdoll(ped, false) + SetPedFleeAttributes(ped, 0, false) + + -- Wander around this part of the scrapyard. + TaskWanderInArea( + ped, + coords.x, + coords.y, + coords.z, + radius, + 2.0, + 10.0 + ) + + SetModelAsNoLongerNeeded(model) + + scrapyardWorkers[#scrapyardWorkers + 1] = ped + + return ped +end + +local function createScrapyardAmbience() + if not DoesEntityExist(scrapyardFestivalBus) then + scrapyardFestivalBus = createScrapyardVehicle( + 'pbus2', + vec4(2349.54, 3133.84, 48.21, 77.73) + ) + end + + if not DoesEntityExist(scrapyardJunkCar) then + scrapyardJunkCar = createScrapyardVehicle( + 'emperor', + vec4(2343.67, 3141.72, 48.21, 211.39) + ) + + -- Make it look like something Mack is stripping for parts. + SetVehicleEngineHealth(scrapyardJunkCar, 100.0) + SetVehicleBodyHealth(scrapyardJunkCar, 300.0) + + SmashVehicleWindow(scrapyardJunkCar, 0) + SmashVehicleWindow(scrapyardJunkCar, 1) + SmashVehicleWindow(scrapyardJunkCar, 2) + SmashVehicleWindow(scrapyardJunkCar, 3) + + SetVehicleDoorBroken(scrapyardJunkCar, 0, true) + SetVehicleTyreBurst(scrapyardJunkCar, 0, true, 1000.0) + SetVehicleTyreBurst(scrapyardJunkCar, 4, true, 1000.0) + end + + if not DoesEntityExist(scrapyardForklift) then + scrapyardForklift = createScrapyardVehicle( + 'forklift', + vec4(2350.42, 3142.49, 48.21, 231.27) + ) + end + + if #scrapyardWorkers == 0 then + -- Main yard + createScrapyardWorker( + 's_m_y_xmech_02', + vec4(2355.0, 3148.0, 48.21, 180.0), + 12.0 + ) + + createScrapyardWorker( + 's_m_m_autoshop_01', + vec4(2345.0, 3150.0, 48.21, 90.0), + 12.0 + ) + + createScrapyardWorker( + 's_m_y_construct_01', + vec4(2360.0, 3143.0, 48.21, 270.0), + 15.0 + ) + + -- Around warehouse/work areas + createScrapyardWorker( + 's_m_y_construct_02', + vec4(2341.0, 3130.0, 48.21, 0.0), + 10.0 + ) + + createScrapyardWorker( + 's_m_m_dockwork_01', + vec4(2334.0, 3125.0, 48.21, 180.0), + 15.0 + ) + + -- Wider-ranging yard hand + createScrapyardWorker( + 's_m_m_gardener_01', + vec4(2368.0, 3130.0, 48.21, 90.0), + 22.0 + ) + end +end + +-- ============================================================ +-- Cosmic Dave +-- ============================================================ + +local cosmicDavePed + +local cosmicDaveCoords = vec4( + 2447.63, + 3775.08, + 41.43, + 23.30 +) + +local function createCosmicDave() + if DoesEntityExist(cosmicDavePed) then + return + end + + local model = `ig_omega` + + lib.requestModel(model) + + cosmicDavePed = CreatePed( + 0, + model, + cosmicDaveCoords.x, + cosmicDaveCoords.y, + cosmicDaveCoords.z - 1.0, + cosmicDaveCoords.w, + false, + false + ) + + SetPedDefaultComponentVariation(cosmicDavePed) + SetEntityAsMissionEntity(cosmicDavePed, true, true) + SetEntityInvincible(cosmicDavePed, true) + FreezeEntityPosition(cosmicDavePed, true) + SetBlockingOfNonTemporaryEvents(cosmicDavePed, true) + + TaskStartScenarioInPlace( + cosmicDavePed, + 'WORLD_HUMAN_PARTYING', + 0, + true + ) + + SetModelAsNoLongerNeeded(model) +end + +-- ============================================================ +-- Initial state +-- ============================================================ + +CreateThread(function() + Wait(3000) + + -- Mack's scrapyard physically exists even before the player + -- discovers him as a contact. + createMack() + createScrapyardAmbience() + createCosmicDave() + + local stage = lib.callback.await( + 'how_progression:server:getCrowbarMission', + false + ) + + if stage and stage >= 1 then + unlockCrowbar() + end + + print(( + '[how_progression] Initial Crowbar stage: %s' + ):format(tostring(stage))) +end) + +RegisterNetEvent( + 'how_progression:client:resetProgression', + function() + ClearGpsPlayerWaypoint() + vinnyFenceUnlocked = false + mackMission1DeliveryMarkerActive = false + + if DoesBlipExist(crowbarBlip) then + RemoveBlip(crowbarBlip) + crowbarBlip = nil + end + + if DoesBlipExist(mackBlip) then + RemoveBlip(mackBlip) + mackBlip = nil + end + + if DoesBlipExist(gabeBlip) then + RemoveBlip(gabeBlip) + gabeBlip = nil + end + + if DoesBlipExist(crowbarMission1Blip) then + RemoveBlip(crowbarMission1Blip) + crowbarMission1Blip = nil + end + + if DoesBlipExist(crowbarMission2Blip) then + RemoveBlip(crowbarMission2Blip) + crowbarMission2Blip = nil + end + + crowbarMission1Vehicle = nil + crowbarMission1ReturnActive = false + crowbarMission2Vehicle = nil + end +) + +print('[how_progression] client.lua loaded') + +RegisterNetEvent( + 'how_progression:client:cleanupCrowbarMission1', + function() + crowbarMission1Vehicle = nil + crowbarMission1ReturnActive = false + + if DoesBlipExist(crowbarMission1Blip) then + RemoveBlip(crowbarMission1Blip) + crowbarMission1Blip = nil + end + + ClearGpsPlayerWaypoint() + end +) + +RegisterNetEvent( + 'how_progression:client:cleanupCrowbarMission2', + function() + crowbarMission2Vehicle = nil + + if DoesBlipExist(crowbarMission2Blip) then + RemoveBlip(crowbarMission2Blip) + crowbarMission2Blip = nil + end + + ClearGpsPlayerWaypoint() + end +) + +-- Remove mission package when its carrier dies. +local function crowbarPackageCarrierDied() + TriggerServerEvent( + 'how_progression:server:crowbarPackageCarrierDied' + ) +end + +AddEventHandler( + 'baseevents:onPlayerDied', + crowbarPackageCarrierDied +) + +AddEventHandler( + 'baseevents:onPlayerKilled', + crowbarPackageCarrierDied +) + +-- Ammu-Nation Body Armor +exports('useBodyArmor', function(data, slot) + local ped = PlayerPedId() + + if GetPedArmour(ped) >= 100 then + lib.notify({ + title = 'Body Armor', + description = 'Your armor is already full.', + type = 'error' + }) + return + end + + exports.ox_inventory:useItem(data, function(used) + if not used then + return + end + + SetPedArmour(ped, 100) + + lib.notify({ + title = 'Body Armor', + description = 'Body armor equipped.', + type = 'success' + }) + end) +end) + +-- Temporary NPWD auto-contact bridge +RegisterNetEvent('how_progression:client:addPhoneContact', function(contact) + exports.npwd:sendNPWDMessage('CONTACTS', 'npwd:autoAddContact', contact) +end) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8daf910 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,122 @@ +# How Progression: current inventory and migration plan + +This describes the live `client.lua` and `server.lua` as audited in September 2026 and the planned migration. The `.bak*` and `.pre-*` files are historical copies; `fxmanifest.lua` still runs only `client.lua` and `server.lua` as gameplay entry points. Do not infer live behavior from the copies. + +## Live inventory + +| Area | Implemented behavior | +| --- | --- | +| Introduction | Appearance save triggers UNKNOWN's one-time NPWD SMS and sets `unknown_introduced` and Crowbar stage 1. Login sync restores Crowbar and Mack/Vinny state. `/testunknown` and `/prog unknown` exercise it. | +| Crowbar world | Local Clay ped, smoking scenario, talk target, map blip, locked frozen Daemon. Talk dialog chooses missions by `crowbar_stage`; active mission suppresses interaction. | +| Crowbar 1, The Introduction | Server-spawned Emperor, crew keys, retrieve/return objectives and blips/marker; initiator gets $500, eligible crew get 5 Crowbar REP and stage 2. Startup removes leftover mission Emperors. | +| Crowbar 2, Get Some Wheels | Server-spawned locked Premier, three lockpicks per participant; delivery persists a Premier through `qbx_vehicles:CreatePlayerVehicle` for the owner, with submitted properties; eligible crew get 5 REP/stage 3 and Crowbar SMS. Physical mission Premier is deliberately retained. | +| Crowbar 3, Quick Score | Harmony store index 13, one unloaded pistol to initiator; `ff_storerobbery` event advances from robbery to losing police. Client escape monitoring completes; eligible crew get 5 REP/stage 4 and marked-cash SMS. | +| Crowbar 4, Wash the Cash | Mack dialog opens Gabe route, Gabe/money-wash event advances objective, `randol_moneywash` completion event ends mission; eligible crew get 5 REP/stage 5 and Crowbar SMS. Mack stage becomes 1 when spoken to. Gabe is currently a blip/location, not a spawned ped here. | +| Crowbar 5, Buy In | Temporary ox_inventory stash with `crowbar_package`, permanent local crate/target, inventory swap hook, package-carrier ambush with two trucks and Lost peds, escape/return objective, death removes package. Eligible crew get 5 REP/stage 6; carrier gets $750. | +| Mack world | Mack ped and Deke ped physically spawn at resource start, plus festival bus, damaged Emperor, forklift, six wandering workers. Mack interaction is gated by server state; blip is unlocked separately. Deke opens ox_inventory `DekeTools`. | +| Mack 1, Easy Rider | Accepted crew each get `WEAPON_BAT`; initiator spawns 1–5 mission Sanchezes. Leave yard, trash detour car, deliver bikes to Vinny, then speak to Vinny. Server tracks delivered bike indices and crew state; each eligible participant gets $300, 5 Mack REP and stage 2. Several Mack SMS messages drive the detour. | +| Vinny world/service | Vinny ped/blip and five local locked frozen display Sanchezes; target calls existing `qbx_vehicleshop` purchase. Delayed SMS 60–90 seconds after Easy Rider completion saves `vinny_fence_unlocked`. Persistent fence marker allows up to three unowned non-mission Sanchezes per player per game day for one `markedbills` item worth $200 each. Server checks ownership, location, quota, item capacity and vehicle deletion. | +| Cosmic Dave | Local Omega ped with partying scenario; no blip, interaction, phone behavior or mission in this resource yet. | +| Other | `useBodyArmor` ox_inventory item export; NPWD contact bridge; `/testcontact`, `/givemarked`, `/skipcrowbar1`, `/prog`. | + +## Responsibilities in current files + +`fxmanifest.lua` declares ox_lib, oxmysql and runtime dependencies and loads one gameplay script per side. `client.lua` mixes contact presentation, dialogs, local scenery, mission vehicles/peds/props, blips/GPS, per-frame markers, mission polling, targeting, inventory use and NPWD UI bridge. `server.lua` mixes schema alteration, SQL reads/writes, crew snapshots/rewards/tracker, phone SMS, mission registration/state, entity validation, fence economy, login restoration, developer commands and cleanup. Passive contact/location definitions and an unregistered mission framework now exist; neither drives current gameplay. + +## Foundation implemented, migration pending + +`shared/contacts/*.lua` holds seven passive story contact definitions; `shared/contacts/init.lua` checks IDs and permanent phone-number uniqueness when required. `shared/locations.lua` mirrors mission locations, `shared/mission_ids.lua` defines string mission/phase IDs and parses human `/prog` notation, and `server/services/mission_registry.lua` supplies an instance registry without registering missions or events. `shared/debug_contact.lua` records the temporary Michael fixture's avatar separately. The manifest packages shared files but does not run them. Legacy `client.lua` and `server.lua` remain authoritative for every active spawn, interaction, mission, SMS, SQL write, event, callback, export and command. Definitions are verified copies and become authoritative only as individual systems migrate. + +## Proposed layout + +```text +how_progression/ + fxmanifest.lua + shared/ + contacts/{unknown,crowbar,mack,gabe,deke,vinny,cosmic_dave}.lua + locations.lua + mission_ids.lua + client/ + bootstrap.lua + services/{contacts,world,markers,routes,phone,mission_view}.lua + missions/crowbar/{introduction,get_some_wheels,quick_score,wash_the_cash,buy_in}.lua + missions/mack/easy_rider.lua + services/body_armor.lua + server/ + bootstrap.lua + services/{progression,phone,participants,crew_tracker,rewards,mission_registry,entities,restoration}.lua + missions/crowbar/{introduction,get_some_wheels,quick_score,wash_the_cash,buy_in}.lua + missions/mack/easy_rider.lua + services/vinny_fence.lua + debug/{commands,phases}.lua + docs/{ARCHITECTURE,MISSION_DESIGN,PROGRESSION,CONTACTS,PHONE,CREWS}.md +``` + +Files under `shared/contacts` are data descriptors, with optional explicit callback IDs for interactions. Functions, live handles and side effects belong in services. Contact descriptors should allow `world.alwaysPresent` independent of `services.requires`, `blip.requires`, and `phone.requires`. Use ordered manifest loading or explicit `require` with one bootstrap; do not duplicate event registration during migration. No new SQL table or phone-number allocator is required for this layout. + +## Move map (preserve event/export names) + +| Current functions/events or blocks | Destination | +| --- | --- | +| `getProgression`, `getPlayerProgression`, `setCrowbarProgression`, `advanceCrowbar`, schema ALTER and SQL projection | `server/services/progression.lua`; preserve existing columns and initialization timing | +| `syncProgressionState`, player-loaded/appearance hooks; client `syncProgression`, initial world thread, unload/reset | `server/services/restoration.lua`, `client/bootstrap.lua`, `client/services/world.lua` | +| `sendSmsToPlayer`, UNKNOWN introduction, `addPhoneContact` bridge | `server/services/phone.lua`, `client/services/phone.lua`; introduction trigger in contact/mission orchestration | +| `getMissionParticipants`, `getCitizenIdFromSource`, `participantIsValid` | `server/services/participants.lua` | +| `rewardCrowbarParticipants`, `rewardMackParticipants`, mission cash/items | `server/services/rewards.lua`, with mission-specific reward declarations | +| Repeated `*MissionN`, `*MissionNByCitizenId`, `clear/start/complete`, active lookup | `server/services/mission_registry.lua` plus individual server mission files | +| All `start/phase/cleanup/reward` client events, mission polling and spawned mission entities | Corresponding `client/missions//.lua` | +| `createCrowbar`, `createMack`, `createDeke`, `createVinny`, `createCosmicDave`, display bikes and scrapyard ambience | Contact definitions plus `client/services/contacts.lua` and `client/services/world.lua` | +| `create*Blip`, `setMackMission1Route`, all `SetNewWaypoint`, mission/fence `DrawMarker` threads | `client/services/routes.lua` and `markers.lua`, driven by objective views | +| All `updatePartyTasks`/`sendPartyNotification` calls | `server/services/crew_tracker.lua`; preserve exact task labels and status types initially | +| `fenceSanchez`, fence count/day, leave-bike event | `server/services/vinny_fence.lua`, `client/services/world.lua`; keep separate from Easy Rider mission | +| `/prog`, `/testunknown`, `/skipcrowbar1`, `/givemarked`, `/testcontact` | `server/debug/commands.lua` and `phases.lua` | +| `useBodyArmor` export | `client/services/body_armor.lua`; preserve export name used by ox_inventory | +| `onResourceStop`, mission entity deletion and local target cleanup | Each module's cleanup registered through one lifecycle coordinator | + +The move map covers the public event families listed in each mission section, not a rename plan. Keep current event names during extraction so external resources and running clients continue to communicate. + +### Public handler inventory by destination + +Keep these names while moving handlers. `client:` and `server:` below expand to `how_progression:client:` and `how_progression:server:`. + +| Destination | Existing handlers/callbacks | +| --- | --- | +| Restoration/contact services | `client:unlockCrowbar`, `client:syncProgression`, `client:unlockMack`, `client:unlockGabe`, `client:resetProgression`, `client:unlockVinnyFence`; `QBCore:Client:OnPlayerUnload`, `QBCore:Server:OnPlayerLoaded`, `illenium-appearance:server:saveAppearance`; `server:getCrowbarMission`, `server:isCrowbarMissionActive`, `server:canTalkToMack` | +| Crowbar 1 | `server:startCrowbarMission1`, `server:crowbarMission1VehicleAcquired`, `server:completeCrowbarMission1`; `client:startCrowbarMission1`, `client:crowbarMission1Return`, `client:cleanupCrowbarMission1`, `client:crowbarMission1Reward` | +| Crowbar 2 | `server:startCrowbarMission2`, `server:completeCrowbarMission2`; `client:startCrowbarMission2`, `client:cleanupCrowbarMission2` | +| Crowbar 3 | `server:startCrowbarMission3`, local `server:storeRegisterRobbed` from `ff_storerobbery`, `server:completeCrowbarMission3`; `client:startCrowbarMission3`, `client:crowbarMission3Escape` | +| Crowbar 4 | `server:startCrowbarMission4`, `server:getCrowbarMission4State`, `server:crowbarMission4TalkedToMack`, local `server:moneywashStarted`, local `server:moneyWashed`; `client:startCrowbarMission4`, `client:crowbarMission4Mack`, `client:crowbarMission4Gabe`, `client:cleanupCrowbarMission4` | +| Crowbar 5 | `server:startCrowbarMission5`, `server:crowbarMission5Escaped`, `server:completeCrowbarMission5`, `server:crowbarPackageCarrierDied`; `client:startCrowbarMission5`, `client:crowbarMission5PackageTaken`, `client:crowbarMission5Escaped`, `client:cleanupCrowbarMission5`, `client:crowbarMission5Complete`; `baseevents:onPlayerDied`, `baseevents:onPlayerKilled`, ox_inventory `swapItems` hook | +| Easy Rider | `server:startMackMission1`, `server:mackMission1BikeSpawned`, `server:mackMission1Detour`, `server:mackMission1AtDetour`, `server:mackMission1CarTrashed`, `server:mackMission1BikeDelivered`, `server:completeMackMission1`, `server:getMackMission1State`; `client:startMackMission1`, `client:mackMission1TrashCar`, `client:mackMission1DetourExplained`, `client:mackMission1DeliverBikes`, `client:mackMission1DeliveryCount`, `client:mackMission1TalkVinny`, `client:mackMission1Complete` | +| Rewards | `client:crowbarReward`, `client:mackReward` | +| Vinny fence | `server:fenceSanchez`, `client:leaveFenceBike` (unlock event listed under restoration) | +| Phone | `client:addPhoneContact`; NPWD exports are calls, not how_progression event registrations | +| Lifecycle | `onResourceStop` server handler; client cleanup currently spread across unload and mission handlers | + +The body-armor `useBodyArmor` client export and commands `/testunknown`, `/skipcrowbar1`, `/prog`, `/givemarked`, `/testcontact` are also public interfaces. No handler for Gabe/Deke/Cosmic Dave missions exists today. + +## Migration order and gates + +1. Freeze a behavior inventory: event names, SQL columns, item names, text, coordinates, rewards, phase behavior and external call sites. Record a small solo/crew smoke test for every live mission and `/prog` branch. +2. Add shared contact/location data without switching runtime use. Verify values against live Lua. Move only pure constants and phone numbers into descriptors; reload and test contact visibility. +3. Extract server progression, phone and participant helpers behind existing call signatures. Keep SQL and NPWD calls identical. Test login, SMS, stage sync and one crew snapshot. +4. Extract client world, contact ped/asset, blip and marker services while preserving physical spawn timing and gated access separately. Test reconnect, repeated sync, resource restart and reset. +5. Add mission registry and crew tracker adapters, initially routing to old mission handlers. Preserve exact task payloads and `citizenid` maps. Test solo/crew eligibility. +6. Move one complete mission at a time, with its client and server parts together: Crowbar 1–5, then Easy Rider. At each step, run start, phase, completion, abort/reset and reconnect checks. Do not move a shared helper simultaneously with a mission unless required. +7. Extract Vinny fence, body-armor export and debug commands. Implement deterministic `/prog` reconstruction one mission/phase at a time, preserving existing shorthand until replaced and tested. +8. Once all callers use modules, remove the old live blocks only after event/export and in-game parity checks. Historical copies remain untouched unless separately approved. + +Every intermediate manifest must load each event, callback and export exactly once. Keep `client.lua`/`server.lua` as temporary bootstraps if that makes extraction safer. + +## Risks and audit findings + +- The SQL table creation DDL is not in this resource; only the three Vinny fence columns are altered at startup. Inspect the live schema before changing SQL. `getProgression` inserts a default row and then reads it. +- Mission state is memory-only, generally one global instance per mission plus a citizenid map. Resource restart loses active mission state. Existing stop cleanup explicitly deletes only Crowbar 1/2 server vehicles. Client world assets/targets and other mission entities have uneven cleanup. Treat as migration risks, not permission to change gameplay in the extraction pass. +- Several mission starts call `clear*` on a global mission, which can replace another crew's mission. Easy Rider instead rejects a concurrent start. Preserve current behavior until a separate multiplayer design decision. +- `participantIsValid` compares the snapshotted source's current citizenid; a reconnect with a new source does not regain active mission participation. This is distinct from durable identity for progression. Improve through citizenid resolution only with deliberate mission-lifecycle tests. +- `clearMackMission1`, `setCrowbarProgression`, `advanceCrowbar`, and client `unlockMack`/`unlockGabe` have no in-resource caller in the live files. They may be intended APIs/legacy; search external call sites before removal. `testcontact` intentionally randomizes a temporary Michael number; it must not influence permanent contact numbers. +- `/prog` parses integer text components rather than Lua floats, but current shortcuts depend on an already active mission for most later phases; phase 5.3 has a distinct client event path. These are not deterministic reconstruction yet. +- `getCrowbarActiveMission` and `getMackMission1State` are separate lookup paths. Crew task status values mix `true/false` and `current/pending/done`; standardize only after confirming `bs_groupsystem` behavior. +- The resource uses `ff_storerobbery`, `randol_moneywash`, `qbx_vehicles`, `qbx_vehicleshop`, `ox_inventory`, `ox_target`, `qbx_vehiclekeys`, NPWD and `bs_groupsystem` APIs beyond the manifest dependency list. Preserve server.cfg ordering; inspect installed APIs before adding dependencies. +- The Vinny daily quota uses a client-provided GTA calendar date and SQL count. The server validates shape and monotonicity but does not independently derive the game day. This is a trust boundary to review separately. +- `client.lua.bak*` and `server.lua.bak*` are not loaded, but are numerous and easy to mistake for live code. Keep a clear live-file index; do not delete them as incidental cleanup. diff --git a/docs/CONTACTS.md b/docs/CONTACTS.md new file mode 100644 index 0000000..ef74ac2 --- /dev/null +++ b/docs/CONTACTS.md @@ -0,0 +1,17 @@ +# Contacts and physical world + +Each significant character gets a dedicated data definition during migration. Fields may include `id`, `displayName`, fixed `phone.number`, `phone.avatarUrl`, `ped.model`, position/heading, scenario/animation, invincible/frozen/blocking settings, blip, interaction ID/label/gate, progression gate, world assets and mission IDs. Omit unknown fields; do not fabricate a phone number or ped. Runtime services spawn and gate them. Permanent 555 numbers must remain stable because NPWD threads use them. + +| Contact | Current implementation | +| --- | --- | +| UNKNOWN | Phone-only introduction, fixed `555-0100`; no ped/world position in this resource. | +| Crowbar | `ig_clay` at `vec4(1699.67,3775.30,34.74,207.22)`, smoking, invincible/frozen; talk target and blip after introduction; frozen locked Daemon at `1699.70,3771.63,34.53,258.21`; five missions; `555-7384`. | +| Mack | `s_m_y_xmech_01` at `vec4(2366.60,3154.91,48.21,17.84)`, clipboard, invincible/frozen; physical ped always created at startup, talk gated, blip unlocked; Easy Rider; `555-6842`. Scrapyard bus, junk Emperor, forklift and six workers are associated world assets. | +| Gabe | Current resource defines a money-laundering blip/location at `vec3(2340.96,3126.11,48.21)` and Crowbar 4 step; no Gabe ped/model or phone number here. Actual wash mechanics live in `randol_moneywash`. | +| Deke | `s_m_m_autoshop_02` at `vec4(2359.89,3136.74,48.21,110.90)`, impatient scenario, invincible/frozen, spawned with Mack. Talk target opens `ox_inventory` shop `DekeTools`; no phone number, blip or mission here. | +| Vinny | `a_m_m_business_01` at `vec4(1362.57,3602.49,34.96,236.75)`, invincible/frozen; spawned during Easy Rider and restored with fence unlock, blip, five frozen display Sanchezes and purchase targets. Permanent fence point `vec4(1356.79,3617.37,34.88,105.38)` after delayed SMS; `555-3176`. | +| Cosmic Dave | `ig_omega` at `vec4(2447.63,3775.08,41.43,23.30)`, partying scenario, invincible/frozen, physically spawned at startup. No target, blip, number or mission yet. | + +Scrapyard workers and Crowbar 5 Lost ambushers are ambient/mission actors, not contact definitions. The `testcontact` Michael contact is a temporary debug fixture with randomized number, not a permanent contact. Preserve all existing coordinates/models from the live Lua during extraction; this table is an index, not a replacement for source values. + +World existence, blip visibility, interaction eligibility, mission access, phone availability and fence/shop service availability need independent gates. In particular, Mack/Deke/Cosmic Dave physical presence is unconditional today, while Crowbar and Vinny are currently spawned by unlock/mission flow. diff --git a/docs/CREWS.md b/docs/CREWS.md new file mode 100644 index 0000000..3b52b1c --- /dev/null +++ b/docs/CREWS.md @@ -0,0 +1,7 @@ +# Crew behavior + +`getMissionParticipants` uses `bs_groupsystem:getPlayerPartyId(citizenid)` and `sendToPartyMembers` to snapshot online crew members at mission acceptance. It resolves each member's current Qbox player via `GetPlayerByCitizenId`, then records `{citizenid, source}`. Solo missions snapshot the initiator. Each mission keeps a citizenid→mission map and typically one global mission instance. `participantIsValid` currently requires the snapshotted source to still represent the same citizenid; reconnecting to a new source does not automatically rejoin a live mission. + +Crew objective UI uses `updatePartyTasks` and sometimes `sendPartyNotification`. Existing task labels and status formats vary: some missions use `current/pending/done`, others use booleans. Preserve payloads during extraction; inspect the installed group-system behavior before standardizing. Easy Rider shares bike count and delivery count with the original crew, with one bike per participant up to five. Crowbar 5's package carrier owns the ambush and cash payout; other snapshotted eligible players receive progression. Mid-mission joiners do not receive mission rewards. + +The target crew service should own snapshot, membership validation, source resolution, objective fan-out, tracker updates and notifications. Mission modules should declare collective versus owner/carrier objectives and reward policy. Use citizenid for eligibility and source only for current delivery. Define disconnect/abort policy explicitly before changing it; the current implementation does not offer general reconnect reconstruction. diff --git a/docs/MISSION_DESIGN.md b/docs/MISSION_DESIGN.md new file mode 100644 index 0000000..fc6d01d --- /dev/null +++ b/docs/MISSION_DESIGN.md @@ -0,0 +1,31 @@ +# Mission design contract + +Read [ARCHITECTURE.md](ARCHITECTURE.md), [PROGRESSION.md](PROGRESSION.md), [CREWS.md](CREWS.md), [CONTACTS.md](CONTACTS.md) and [PHONE.md](PHONE.md) before creating or substantially changing a mission. This is the target contract for migration; current gaps are identified below. + +## Registration and phases + +Each mission has a stable string ID, owning contact, eligibility stage, objective state machine, client/server entry points, crew tasks, reward policy, and explicit phase manifest. Keep durable IDs such as `crowbar:1:return_vehicle`; parse `/prog crowbar 1.2` as integer mission `1` and integer phase `2`, then resolve through a registry. Never store or compare `1.2` as a floating-point number. Numeric progression stage in existing SQL remains a separate concept. + +Every advertised phase must reconstruct from a clean baseline, not assume a previous shortcut ran. A phase descriptor must say which entities, vehicles, props, NPCs, blips, markers, targets, routes, crew tasks, items and mission state exist; it must also remove things belonging to earlier/later phases. Phase setup must be repeatable. Completion shortcuts should go through the same server reward/progression path as gameplay, with explicit exceptions for development overrides. Do not promise deterministic phases until tested in game. + +Current `/prog` phases exist only for Crowbar: 1.1–1.3, 2.1–2.2, 3.1–3.3, 4.1–4.4, 5.1–5.4. Plain `X` sets a stage. Mack and Tina support plain stages only. Current later phases often require an active mission, so the proposed contract is an improvement, not a description of existing behavior. + +## Participation and authority + +- Support solo players and crews. Snapshot eligible crew members when accepting a mission. Store `citizenid` as durable identity; resolve current source only for live actions. A mid-mission joiner does not automatically receive progress or rewards. +- Server owns mission objective, completion validation, progression, currency, ownership and inventory rewards. Clients may present the world and report observations; validate any network ID, model, location, item and participant before reward. +- Send objective changes to all valid snapshotted participants and update `bs_groupsystem` tasks at the same transition. Define whether each objective is collective, owner-only or per-member. Scale counts/actors where the mission calls for it (Easy Rider uses one bike per participant up to five). +- Declare reward recipients explicitly. Existing behavior differs: Crowbar 1 pays the initiator cash, Crowbar 5 pays the carrier cash, Easy Rider pays every eligible participant cash. Do not silently normalize these. + +## World and lifecycle + +- Track ownership of every spawned entity or target: server networked mission vehicle, client-local scenery, client-owned networked vehicle, prop, ped, blip, marker and target. Give each a deterministic spawn key and cleanup path. Prevent duplicate creation on sync/restart. +- Define start, objective transition, completion, abort, reset, player disconnect and resource-stop behavior. Keep preserved property/owned vehicles distinct from temporary mission assets; Crowbar 2 deliberately leaves the physical Premier after transfer. +- Route and GPS must match the current objective for each participant, and clear when the objective completes or is aborted. Draw active markers every frame. Make reconstruction replace stale routes and markers. +- World presence and service availability are separate: a ped or yard prop can exist before its mission/interaction is unlocked. Contact definitions express both. +- Use shared NPWD/contact services for SMS and established sender numbers. Keep existing dialogue, timing and text unless a separate gameplay change is approved. +- Prefer installed resources for mechanics already present: `ox_inventory` stash/items, `qbx_vehicles` ownership, `qbx_vehicleshop` purchase, `ff_storerobbery` register completion, `randol_moneywash` laundering, `bs_groupsystem` tasks. Do not implement duplicate economies or shops in mission code. + +## Current gaps to standardize deliberately + +Global singleton mission instances, inconsistent reset/stop cleanup, source IDs retained in snapshots, mixed crew task status types, no universal abort path, and non-reconstructing `/prog` phases require separate decisions and in-game parity tests. The current mission state is not persisted in SQL. Avoid changing these semantics as an incidental consequence of splitting files. diff --git a/docs/PHONE.md b/docs/PHONE.md new file mode 100644 index 0000000..6fc0872 --- /dev/null +++ b/docs/PHONE.md @@ -0,0 +1,7 @@ +# Phone and NPWD integration + +`server.lua` sends NPWD SMS through `exports.npwd:emitMessage` to `PlayerData.charinfo.phone`. Most missions use `sendSmsToPlayer` with `pcall`; UNKNOWN introduction currently duplicates the direct call. The client `addPhoneContact` event forwards to `exports.npwd:sendNPWDMessage('CONTACTS','npwd:autoAddContact', contact)`. `/testcontact` exercises `exports.npwd:addContact` with a temporary Michael fixture and random 555 number. Preserve this debug exception separately from permanent contacts. + +Permanent sender numbers: UNKNOWN `555-0100`, Crowbar `555-7384`, Mack `555-6842`, Vinny `555-3176`. Gabe, Deke and Cosmic Dave have no established number in this resource. Store permanent numbers once in contact definitions; do not generate them at resource start or change existing values. Preserve every SMS body and timing when moving code. Vinny's fence message is delayed 60–90 seconds after Easy Rider completion; the unlock is persisted only after successful send and then exposed through login sync. + +The installed NPWD resource has local custom behavior, including the SMS popup duration patch in its compiled UI asset. A how_progression refactor must keep its current NPWD APIs and not overwrite NPWD files. Before changing phone code, inspect the installed NPWD exports and client bridge, check phone number/thread compatibility, and test first introduction, mission SMS, delayed Vinny SMS, reconnect and contact display. diff --git a/docs/PROGRESSION.md b/docs/PROGRESSION.md new file mode 100644 index 0000000..572e04e --- /dev/null +++ b/docs/PROGRESSION.md @@ -0,0 +1,9 @@ +# Progression and persistence + +The server's `how_progression` SQL row is authoritative and keyed by `citizenid`. `getProgression` reads it or inserts a default row. This resource does not contain the original `CREATE TABLE` DDL; inspect the live database/schema source before migration. Do not replace these values with Qbox metadata. + +Live columns referenced: `citizenid`, `unknown_introduced`, `crowbar_stage`, `crowbar_rep`, `mack_stage`, `mack_rep`, `tina_stage`, `tina_rep`, `story_stage`, `vinny_fence_unlocked`, `vinny_fence_day`, `vinny_fence_count`. The last three are added via `MySQL.ready` ALTERs. Contact REP is independent; Total REP is derived elsewhere, not stored here. Stage numbers represent availability/completion, while active objectives live only in server memory. + +UNKNOWN's successful first SMS sets introduction and at least Crowbar stage 1. Crowbar missions advance expected stages 1→2→3→4→5→6 and grant 5 REP each. Speaking to Mack during Crowbar 4 sets Mack stage at least 1. Easy Rider advances Mack 1→2, grants 5 REP and $300 to eligible participants. Vinny's delayed post-mission SMS separately sets `vinny_fence_unlocked`; it is not inferred from Mack stage. Fence day/count enforce three per game day. Login sync sends a projection of the row to the client to restore Crowbar, Mack and Vinny world access; transient mission state is not reconstructed at login today. + +`/prog` is a development command, not a durable phase store. Plain Crowbar/Mack/Tina stages update SQL; `/prog reset` clears the row and inventory to a development baseline. Crowbar `X.Y` shortcuts invoke live mission paths but do not yet rebuild arbitrary phase state. Keep stage and phase APIs separate when modularizing. Preserve column types/defaults and read/write order until a migration is justified and tested. diff --git a/fxmanifest.lua b/fxmanifest.lua new file mode 100644 index 0000000..2030fd6 --- /dev/null +++ b/fxmanifest.lua @@ -0,0 +1,30 @@ +fx_version 'cerulean' +game 'gta5' + +author 'Howler Design' +description 'Sandy Shores progression and mission system' +version '0.1.0' + +shared_script '@ox_lib/init.lua' + +-- Package passive definitions for future client/server modules. These are not +-- script entry points and do not run alongside the current gameplay files. +files { + 'shared/*.lua', + 'shared/contacts/*.lua' +} + +client_script 'client.lua' +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server.lua' +} + +dependencies { + 'qbx_core', + 'qbx_vehiclekeys', + 'ox_lib', + 'npwd', + 'illenium-appearance', + 'oxmysql' +} diff --git a/server.lua b/server.lua new file mode 100644 index 0000000..98d543c --- /dev/null +++ b/server.lua @@ -0,0 +1,3816 @@ +print('[how_progression] server.lua loaded') + +local vinnyFenceSchemaReady = false + +local function isVinnyFenceUnlocked(value) + return value == true or tonumber(value) == 1 +end + +MySQL.ready(function() + local ok, err = pcall(function() + MySQL.query.await([[ + ALTER TABLE how_progression + ADD COLUMN IF NOT EXISTS vinny_fence_unlocked TINYINT(1) NOT NULL DEFAULT 0 + ]]) + + MySQL.query.await([[ + ALTER TABLE how_progression + ADD COLUMN IF NOT EXISTS vinny_fence_day INT NOT NULL DEFAULT 0 + ]]) + + MySQL.query.await([[ + ALTER TABLE how_progression + ADD COLUMN IF NOT EXISTS vinny_fence_count TINYINT UNSIGNED NOT NULL DEFAULT 0 + ]]) + end) + + if not ok then + print(('[how_progression] Vinny fence schema migration failed: %s') + :format(tostring(err))) + return + end + + vinnyFenceSchemaReady = true +end) + +-- ============================================================ +-- Progression +-- ============================================================ + +local function getProgression(citizenid) + if not citizenid then return nil end + + local row = MySQL.single.await( + 'SELECT * FROM how_progression WHERE citizenid = ?', + { citizenid } + ) + + if row then + return row + end + + MySQL.insert.await( + 'INSERT INTO how_progression (citizenid) VALUES (?)', + { citizenid } + ) + + return MySQL.single.await( + 'SELECT * FROM how_progression WHERE citizenid = ?', + { citizenid } + ) +end + +local function getPlayerProgression(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return nil, nil end + + return getProgression(player.PlayerData.citizenid), player +end + +local function setCrowbarProgression(citizenid, stage, rep) + if not citizenid then return false end + + local result = MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = GREATEST(crowbar_stage, ?), + crowbar_rep = GREATEST(crowbar_rep, ?) + WHERE citizenid = ? + ]], + { stage, rep, citizenid } + ) + + return result ~= nil +end + +local function advanceCrowbar(src, stageIncrease, repIncrease) + local progression, player = getPlayerProgression(src) + + if not progression or not player then + return nil + end + + local currentStage = progression.crowbar_stage or 0 + local currentRep = progression.crowbar_rep or 0 + + local newStage = currentStage + (stageIncrease or 0) + local newRep = math.min( + currentRep + (repIncrease or 0), + 100 + ) + + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = ?, + crowbar_rep = ? + WHERE citizenid = ? + ]], + { + newStage, + newRep, + player.PlayerData.citizenid + } + ) + + return newStage, newRep +end + +-- ============================================================ +-- Crew / party helpers +-- ============================================================ + +-- Returns a snapshot of the players participating in a mission. +-- +-- A solo player gets one participant. +-- A player in a bs_groupsystem party gets the current party +-- members at the moment the mission begins. +-- +-- Progression belongs to each character individually. +-- Mission state belongs to the shared mission. +local function getMissionParticipants(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return nil, nil end + + local citizenid = player.PlayerData.citizenid + local partyId = exports['bs_groupsystem']:getPlayerPartyId(citizenid) + + local participants = {} + + if partyId then + exports['bs_groupsystem']:sendToPartyMembers( + partyId, + function(_, memberCitizenId) + if memberCitizenId then + -- bs_groupsystem may retain an old FiveM source ID. + -- Use the citizenid to resolve the character's current + -- live source through Qbox instead. + local member = + exports.qbx_core:GetPlayerByCitizenId( + memberCitizenId + ) + + if member then + participants[#participants + 1] = { + source = member.PlayerData.source, + citizenid = memberCitizenId + } + end + end + end + ) + else + participants[1] = { + source = src, + citizenid = citizenid + } + end + + return participants, partyId +end + +local function getCitizenIdFromSource(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return nil end + + return player.PlayerData.citizenid +end + +local function participantIsValid(participant) + if not participant then return false end + + local player = exports.qbx_core:GetPlayer(participant.source) + if not player then return false end + + return player.PlayerData.citizenid == participant.citizenid +end + +local function rewardCrowbarParticipants( + participants, + requiredStage, + nextStage, + repReward +) + for _, participant in ipairs(participants) do + if participantIsValid(participant) then + local progression, player = getPlayerProgression(participant.source) + + if progression and player then + local currentStage = progression.crowbar_stage or 0 + local currentRep = progression.crowbar_rep or 0 + + -- Only advance players who were actually at the + -- expected mission stage when the mission began. + if currentStage == requiredStage then + local newRep = math.min( + currentRep + repReward, + 100 + ) + + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = ?, + crowbar_rep = ? + WHERE citizenid = ? + ]], + { + nextStage, + newRep, + participant.citizenid + } + ) + + TriggerClientEvent( + 'how_progression:client:crowbarReward', + participant.source, + repReward, + newRep, + nextStage + ) + + print(( + '[how_progression] Crowbar progression advanced for %s | stage %s | REP %s' + ):format( + participant.citizenid, + nextStage, + newRep + )) + else + print(( + '[how_progression] %s did not advance: expected stage %s, current stage %s' + ):format( + participant.citizenid, + requiredStage, + currentStage + )) + end + end + end + end +end + +-- ============================================================ +-- Mack progression helpers +-- ============================================================ + +local function rewardMackParticipants( + participants, + requiredStage, + nextStage, + repReward, + cashReward +) + for _, participant in ipairs(participants) do + if participantIsValid(participant) then + local progression, player = + getPlayerProgression(participant.source) + + if progression and player then + local currentStage = progression.mack_stage or 0 + local currentRep = progression.mack_rep or 0 + + if currentStage == requiredStage then + local newRep = math.min( + currentRep + repReward, + 100 + ) + + MySQL.update.await( + [[ + UPDATE how_progression + SET + mack_stage = ?, + mack_rep = ? + WHERE citizenid = ? + ]], + { + nextStage, + newRep, + participant.citizenid + } + ) + + if cashReward and cashReward > 0 then + player.Functions.AddMoney( + 'cash', + cashReward, + 'mack-mission-reward' + ) + end + + TriggerClientEvent( + 'how_progression:client:mackReward', + participant.source, + repReward, + newRep, + nextStage, + cashReward or 0 + ) + + print(( + '[how_progression] Mack progression advanced for %s | stage %s | REP %s' + ):format( + participant.citizenid, + nextStage, + newRep + )) + else + print(( + '[how_progression] %s did not advance Mack: expected stage %s, current stage %s' + ):format( + participant.citizenid, + requiredStage, + currentStage + )) + end + end + end + end +end + +-- ============================================================ +-- UNKNOWN introduction +-- ============================================================ + +local function sendSmsToPlayer(src, senderNumber, message) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local citizenid = player.PlayerData.citizenid + local phone = player.PlayerData.charinfo.phone + + if not phone then + print(( + '[how_progression] SMS failed for %s: no phone number' + ):format(citizenid)) + return false + end + + local ok, err = pcall(function() + exports.npwd:emitMessage({ + senderNumber = senderNumber, + targetNumber = phone, + message = message + }) + end) + + if not ok then + print(( + '[how_progression] SMS failed for %s: %s' + ):format(citizenid, tostring(err))) + return false + end + + return true +end + +local function introduceUnknown(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then + print(( + '[how_progression] Could not find Qbox player for source %s' + ):format(tostring(src))) + return false + end + + local citizenid = player.PlayerData.citizenid + local progression = getProgression(citizenid) + + if not progression then + print(( + '[how_progression] Could not create/load progression for %s' + ):format(citizenid)) + return false + end + + if progression.unknown_introduced == 1 then + return false + end + + local phone = player.PlayerData.charinfo.phone + + if not phone then + print(( + '[how_progression] No phone number for %s' + ):format(citizenid)) + return false + end + + local message = + "Heard you're new in town. Keep this number. Call me whatever you want. " .. + "Online, I go by UNKNOWN.\n\n" .. + "If you're looking to make some money, find Crowbar in Sandy. " .. + "He's near the Ammu-Nation. He'll have something for you.\n\n" .. + "Don't ask him about me. I'll be in touch." + + local ok, err = pcall(function() + exports.npwd:emitMessage({ + senderNumber = '555-0100', + targetNumber = phone, + message = message + }) + end) + + if not ok then + print(( + '[how_progression] UNKNOWN SMS failed for %s: %s' + ):format(citizenid, tostring(err))) + return false + end + + MySQL.update.await( + [[ + UPDATE how_progression + SET + unknown_introduced = 1, + crowbar_stage = GREATEST(crowbar_stage, 1) + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + src + ) + + print(( + '[how_progression] UNKNOWN introduced %s to Crowbar' + ):format(citizenid)) + + return true +end + +-- ============================================================ +-- Persistent progression state sync +-- Restores unlocked world state whenever a character loads. +-- ============================================================ + +local function syncProgressionState(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then + print(('[how_progression] State sync: no player for source %s') + :format(tostring(src))) + return false + end + + local citizenid = player.PlayerData.citizenid + local progression = getProgression(citizenid) + + if not progression then + print(('[how_progression] State sync: no progression for %s') + :format(citizenid)) + return false + end + + TriggerClientEvent( + 'how_progression:client:syncProgression', + src, + { + unknown_introduced = progression.unknown_introduced or 0, + crowbar_stage = progression.crowbar_stage or 0, + crowbar_rep = progression.crowbar_rep or 0, + mack_stage = progression.mack_stage or 0, + vinny_fence_unlocked = progression.vinny_fence_unlocked or 0, + mack_rep = progression.mack_rep or 0, + tina_stage = progression.tina_stage or 0, + tina_rep = progression.tina_rep or 0, + story_stage = progression.story_stage or 0 + } + ) + + print(('[how_progression] Restored persistent state for %s') + :format(citizenid)) + + return true +end + +RegisterNetEvent('QBCore:Server:OnPlayerLoaded') +AddEventHandler('QBCore:Server:OnPlayerLoaded', function() + local src = source + + print(('[how_progression] OnPlayerLoaded received from source %s') + :format(tostring(src))) + + CreateThread(function() + Wait(3000) + + local player = exports.qbx_core:GetPlayer(src) + + if player then + print(('[how_progression] Player ready: source=%s citizenid=%s') + :format( + tostring(src), + tostring(player.PlayerData.citizenid) + )) + else + print(('[how_progression] Player NOT ready for source %s') + :format(tostring(src))) + end + + syncProgressionState(src) + end) +end) + +-- Appearance save is our initial character hook. +RegisterNetEvent( + 'illenium-appearance:server:saveAppearance', + function() + local src = source + + CreateThread(function() + Wait(3000) + introduceUnknown(src) + end) + end +) + +-- ============================================================ +-- Development: UNKNOWN +-- ============================================================ + +RegisterCommand('testunknown', function(source) + if source == 0 then + print('[how_progression] /testunknown must be run by a player') + return + end + + local success = introduceUnknown(source) + + if success then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'UNKNOWN introduction sent.', + type = 'success' + }) + else + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'UNKNOWN introduction was not sent.', + type = 'error' + }) + end +end, false) + +-- ============================================================ +-- Crowbar Mission 1 +-- The Introduction +-- ============================================================ + +local crowbarMission1 = nil +local crowbarMission1ByCitizenId = {} + +local function clearCrowbarMission1() + if not crowbarMission1 then + return + end + + if crowbarMission1.vehicle + and DoesEntityExist(crowbarMission1.vehicle) then + DeleteEntity(crowbarMission1.vehicle) + end + + for _, participant in ipairs(crowbarMission1.participants) do + crowbarMission1ByCitizenId[participant.citizenid] = nil + end + + crowbarMission1 = nil +end + +local function startCrowbarMission1(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local progression = getProgression( + player.PlayerData.citizenid + ) + + if not progression or progression.crowbar_stage ~= 1 then + print(( + '[how_progression] Mission 1 rejected for %s: invalid stage' + ):format(player.PlayerData.citizenid)) + return false + end + + local participants, partyId = getMissionParticipants(src) + + if not participants or #participants == 0 then + print(( + '[how_progression] Could not determine Mission 1 participants for %s' + ):format(src)) + return false + end + + -- Only one Crowbar Mission 1 can be active at a time. + clearCrowbarMission1() + + local vehicle = CreateVehicleServerSetter( + `emperor`, + 'automobile', + 911.40, + 3589.50, + 33.25, + 91.36 + ) + + if not vehicle or vehicle == 0 then + print( + '[how_progression] Failed to spawn Crowbar Mission 1 vehicle' + ) + return false + end + + while not DoesEntityExist(vehicle) do + Wait(0) + end + + local snapshot = {} + + for _, participant in ipairs(participants) do + snapshot[#snapshot + 1] = { + source = participant.source, + citizenid = participant.citizenid + } + end + + crowbarMission1 = { + missionNumber = 1, + objective = 'retrieve_vehicle', + vehicle = vehicle, + netId = NetworkGetNetworkIdFromEntity(vehicle), + participants = snapshot, + partyId = partyId, + completed = false + } + + for _, participant in ipairs(snapshot) do + crowbarMission1ByCitizenId[participant.citizenid] = crowbarMission1 + + exports.qbx_vehiclekeys:GiveKeys( + participant.source, + vehicle + ) + + TriggerClientEvent( + 'how_progression:client:startCrowbarMission1', + participant.source, + crowbarMission1.netId + ) + end + + if partyId then + exports['bs_groupsystem']:updatePartyTasks( + partyId, + { + { + name = "Steal Crowbar's target vehicle", + status = 'current' + }, + { + name = "Bring the vehicle back to Crowbar", + status = 'pending' + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + partyId, + { + title = 'Crowbar', + description = 'Your crew is on the job.', + icon = 'car' + } + ) + end + + print(( + '[how_progression] Crowbar Mission 1 started | owner: %s | participants: %s | party: %s | netId: %s' + ):format( + player.PlayerData.citizenid, + #snapshot, + tostring(partyId), + tostring(crowbarMission1.netId) + )) + + return true +end + +RegisterNetEvent( + 'how_progression:server:startCrowbarMission1', + function() + startCrowbarMission1(source) + end +) + + +local function setCrowbarMission1ReturnPhase(src) + local mission = crowbarMission1 + if not mission or mission.completed then + return false + end + + local citizenid = getCitizenIdFromSource(src) + + if not citizenid + or crowbarMission1ByCitizenId[citizenid] ~= mission + then + return false + end + + mission.objective = 'return_vehicle' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:crowbarMission1Return', + participant.source + ) + end + end + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = "Steal Crowbar's target vehicle", + status = 'done' + }, + { + name = "Bring the vehicle back to Crowbar", + status = 'current' + } + } + ) + end + + return true +end + +RegisterNetEvent( + 'how_progression:server:crowbarMission1VehicleAcquired', + function() + setCrowbarMission1ReturnPhase(source) + end +) + +local function completeCrowbarMission1(src, netId, devOverride) + local mission = crowbarMission1 + + if not mission or mission.completed then + print( + '[how_progression] Mission 1 completion rejected: no active mission' + ) + return false + end + + local citizenid = getCitizenIdFromSource(src) + + if not citizenid + or crowbarMission1ByCitizenId[citizenid] ~= mission + then + print(( + '[how_progression] Mission 1 completion rejected for source %s: not a participant' + ):format(src)) + return false + end + + if not devOverride then + local submittedVehicle = + NetworkGetEntityFromNetworkId(netId) + + if submittedVehicle ~= mission.vehicle then + print(( + '[how_progression] Mission 1 completion rejected for source %s: vehicle mismatch' + ):format(src)) + return false + end + + if not DoesEntityExist(mission.vehicle) then + print( + '[how_progression] Mission 1 completion rejected: vehicle no longer exists' + ) + return false + end + + if mission.objective ~= 'return_vehicle' then + print( + '[how_progression] Mission 1 completion rejected: vehicle not acquired' + ) + return false + end + end + + mission.completed = true + mission.objective = 'complete' + + if DoesEntityExist(mission.vehicle) then + DeleteEntity(mission.vehicle) + end + + local owner = exports.qbx_core:GetPlayer(src) + + if not owner then + mission.completed = false + return false + end + + local cashReward = 500 + local repReward = 5 + + owner.Functions.AddMoney( + 'cash', + cashReward, + 'crowbar-mission-1' + ) + + rewardCrowbarParticipants( + mission.participants, + 1, + 2, + repReward + ) + + TriggerClientEvent( + 'how_progression:client:crowbarMission1Reward', + src, + cashReward, + repReward + ) + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = "Steal Crowbar's target vehicle", + status = 'done' + }, + { + name = "Bring the vehicle back to Crowbar", + status = 'done' + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + mission.partyId, + { + title = 'Crowbar', + description = 'Job done. You proved you can follow directions.', + icon = 'car' + } + ) + end + + print(( + '[how_progression] Crowbar Mission 1 complete | owner: %s | $%s | participants: %s' + ):format( + owner.PlayerData.citizenid, + cashReward, + #mission.participants + )) + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:cleanupCrowbarMission1', + participant.source + ) + end + + crowbarMission1ByCitizenId[participant.citizenid] = nil + end + + crowbarMission1 = nil + + return true +end + +RegisterNetEvent( + 'how_progression:server:completeCrowbarMission1', + function(netId) + completeCrowbarMission1( + source, + netId, + false + ) + end +) + +-- ============================================================ +-- Development cleanup for Mission 1 +-- ============================================================ + +CreateThread(function() + Wait(2000) + + local locations = { + vec3(911.40, 3589.50, 33.25), + vec3(1705.50, 3771.76, 34.49) + } + + local removed = 0 + + for _, vehicle in ipairs(GetAllVehicles()) do + if DoesEntityExist(vehicle) + and GetEntityModel(vehicle) == `emperor` then + + local coords = GetEntityCoords(vehicle) + + for _, location in ipairs(locations) do + if #(coords - location) < 50.0 then + DeleteEntity(vehicle) + removed = removed + 1 + break + end + end + end + end + + print(( + '[how_progression] Cleaned up %s leftover Crowbar Mission 1 Emperor(s)' + ):format(removed)) +end) + +-- ============================================================ +-- Crowbar Mission 2 +-- Get Some Wheels +-- ============================================================ + +local crowbarMission2 = nil +local crowbarMission2ByCitizenId = {} + +local function clearCrowbarMission2() + if not crowbarMission2 then + return + end + + for _, participant in ipairs(crowbarMission2.participants) do + crowbarMission2ByCitizenId[participant.citizenid] = nil + end + + if crowbarMission2.vehicle + and DoesEntityExist(crowbarMission2.vehicle) then + DeleteEntity(crowbarMission2.vehicle) + end + + crowbarMission2 = nil +end + +local function startCrowbarMission2(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local progression = getProgression( + player.PlayerData.citizenid + ) + + if not progression or progression.crowbar_stage ~= 2 then + print(( + '[how_progression] Mission 2 rejected for %s: invalid stage' + ):format(player.PlayerData.citizenid)) + return false + end + + local participants, partyId = getMissionParticipants(src) + + if not participants or #participants == 0 then + print(( + '[how_progression] Could not determine Mission 2 participants for %s' + ):format(src)) + return false + end + + clearCrowbarMission2() + + local vehicle = CreateVehicleServerSetter( + `premier`, + 'automobile', + 2684.30, + 3292.18, + 54.75, + 240.63 + ) + + if not vehicle or vehicle == 0 then + print( + '[how_progression] Failed to spawn Crowbar Mission 2 vehicle' + ) + return false + end + + while not DoesEntityExist(vehicle) do + Wait(0) + end + + SetVehicleDoorsLocked(vehicle, 2) + + local snapshot = {} + + for _, participant in ipairs(participants) do + snapshot[#snapshot + 1] = { + source = participant.source, + citizenid = participant.citizenid + } + end + + crowbarMission2 = { + missionNumber = 2, + objective = 'steal_vehicle', + vehicle = vehicle, + netId = NetworkGetNetworkIdFromEntity(vehicle), + participants = snapshot, + partyId = partyId, + ownerSource = src, + completed = false + } + + for _, participant in ipairs(snapshot) do + crowbarMission2ByCitizenId[participant.citizenid] = + crowbarMission2 + + exports.ox_inventory:AddItem( + participant.source, + 'lockpick', + 3 + ) + + TriggerClientEvent( + 'how_progression:client:startCrowbarMission2', + participant.source, + crowbarMission2.netId + ) + end + + if partyId then + exports['bs_groupsystem']:updatePartyTasks( + partyId, + { + { + name = "Find the car Crowbar told you about", + status = 'current' + }, + { + name = 'Break into the car', + status = 'pending' + }, + { + name = 'Steal the car', + status = 'pending' + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + partyId, + { + title = 'Crowbar', + description = 'Three lockpicks each. Go get yourselves some wheels.', + icon = 'key' + } + ) + end + + print(( + '[how_progression] Crowbar Mission 2 started | owner: %s | participants: %s | party: %s | netId: %s' + ):format( + player.PlayerData.citizenid, + #snapshot, + tostring(partyId), + tostring(crowbarMission2.netId) + )) + + return true +end + +RegisterNetEvent( + 'how_progression:server:startCrowbarMission2', + function() + startCrowbarMission2(source) + end +) + + +local function completeCrowbarMission2(src, netId, props, devOverride) + local mission = crowbarMission2 + + if not mission or mission.completed then + print( + '[how_progression] Mission 2 completion rejected: no active mission' + ) + return false + end + + local citizenid = getCitizenIdFromSource(src) + + if not citizenid + or crowbarMission2ByCitizenId[citizenid] ~= mission + then + print(( + '[how_progression] Mission 2 completion rejected for source %s: not a participant' + ):format(src)) + return false + end + + -- Normal gameplay validates the submitted vehicle and its properties. + -- Development completion uses the authoritative server mission vehicle + -- and creates a stock owned Premier without requiring client streaming. + if not devOverride then + local submittedVehicle = + NetworkGetEntityFromNetworkId(netId) + + if submittedVehicle ~= mission.vehicle then + print(( + '[how_progression] Mission 2 completion rejected for source %s: vehicle mismatch' + ):format(src)) + return false + end + + if not DoesEntityExist(mission.vehicle) then + print( + '[how_progression] Mission 2 completion rejected: vehicle no longer exists' + ) + return false + end + + if GetEntityModel(mission.vehicle) ~= `premier` then + print( + '[how_progression] Mission 2 completion rejected: wrong vehicle model' + ) + return false + end + + if type(props) ~= 'table' then + print( + '[how_progression] Mission 2 completion rejected: missing vehicle properties' + ) + return false + end + end + + mission.completed = true + mission.objective = 'complete' + + local owner = + exports.qbx_core:GetPlayerByCitizenId( + mission.participants[1].citizenid + ) + + -- Prefer the original mission initiator when still connected. + local originalOwner = + exports.qbx_core:GetPlayer(mission.ownerSource) + + if originalOwner then + owner = originalOwner + end + + if not owner then + mission.completed = false + print( + '[how_progression] Mission 2 completion rejected: owner unavailable' + ) + return false + end + + local ownerCitizenId = + owner.PlayerData.citizenid + + local request = { + model = 'premier', + citizenid = ownerCitizenId + } + + if not devOverride and type(props) == 'table' then + request.props = props + end + + local vehicleId, err = + exports.qbx_vehicles:CreatePlayerVehicle(request) + + if not vehicleId then + print(( + '[how_progression] Failed to create owned Premier for %s: %s' + ):format( + ownerCitizenId, + json.encode(err) + )) + + mission.completed = false + mission.objective = 'steal_vehicle' + return false + end + + rewardCrowbarParticipants( + mission.participants, + 2, + 3, + 5 + ) + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = "Find the car Crowbar told you about", + status = 'done' + }, + { + name = 'Break into the car', + status = 'done' + }, + { + name = 'Steal the car', + status = 'done' + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + mission.partyId, + { + title = 'Crowbar', + description = 'Job done. The Premier is yours.', + icon = 'car' + } + ) + end + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-7384', + "Hey, it's Crowbar. Save my contact. " .. + "And keep the car—you earned it. " .. + "I'll be in touch when I've got more work." + ) + + TriggerClientEvent( + 'how_progression:client:cleanupCrowbarMission2', + participant.source + ) + end + + crowbarMission2ByCitizenId[participant.citizenid] = nil + end + + print(( + '[how_progression] Crowbar Mission 2 complete | owner: %s | vehicleId: %s | participants: %s' + ):format( + ownerCitizenId, + tostring(vehicleId), + #mission.participants + )) + + -- Do not delete the physical mission Premier. + crowbarMission2 = nil + + return true +end + +RegisterNetEvent( + 'how_progression:server:completeCrowbarMission2', + function(netId, props) + completeCrowbarMission2( + source, + netId, + props, + false + ) + end +) + +-- ============================================================ +-- Crowbar Mission 3: Quick Score +-- ============================================================ + +local crowbarMission3 = nil +local crowbarMission3ByCitizenId = {} + +local function clearCrowbarMission3() + if not crowbarMission3 then return end + + for _, participant in ipairs(crowbarMission3.participants) do + crowbarMission3ByCitizenId[participant.citizenid] = nil + end + + crowbarMission3 = nil +end + +local function startCrowbarMission3(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local progression = getProgression( + player.PlayerData.citizenid + ) + + if not progression or progression.crowbar_stage ~= 3 then + print(( + '[how_progression] Mission 3 rejected for %s: invalid stage' + ):format(player.PlayerData.citizenid)) + return false + end + + local participants, partyId = getMissionParticipants(src) + + if not participants or #participants == 0 then + print(( + '[how_progression] Could not determine Mission 3 participants for %s' + ):format(src)) + return false + end + + clearCrowbarMission3() + + local snapshot = {} + + for _, participant in ipairs(participants) do + snapshot[#snapshot + 1] = { + source = participant.source, + citizenid = participant.citizenid + } + end + + crowbarMission3 = { + missionNumber = 3, + objective = 'rob_register', + participants = snapshot, + partyId = partyId, + ownerSource = src, + targetStoreIndex = 13, + registerRobbed = false, + completed = false + } + + for _, participant in ipairs(snapshot) do + crowbarMission3ByCitizenId[participant.citizenid] = + crowbarMission3 + + print(( + '[how_progression] Mission 3 client target: initiator=%s participant=%s citizenid=%s' + ):format( + tostring(src), + tostring(participant.source), + tostring(participant.citizenid) + )) + + TriggerClientEvent( + 'how_progression:client:startCrowbarMission3', + participant.source + ) + end + + -- One unloaded pistol for the crew. + exports.ox_inventory:AddItem( + src, + 'weapon_pistol', + 1 + ) + + if partyId then + exports['bs_groupsystem']:updatePartyTasks( + partyId, + { + { + name = 'Rob the Harmony 24/7 register', + status = false + }, + { + name = 'Lose the cops', + status = false + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + partyId, + 'Crowbar: Quick Score' + ) + end + + print(( + '[how_progression] Crowbar Mission 3 started by %s with %s participant(s)' + ):format( + player.PlayerData.citizenid, + #snapshot + )) + + return true +end + +RegisterNetEvent( + 'how_progression:server:startCrowbarMission3', + function() + startCrowbarMission3(source) + end +) + +AddEventHandler( + 'how_progression:server:storeRegisterRobbed', + function(src, storeIndex) + local mission = crowbarMission3 + + if not mission or mission.completed then return end + if mission.registerRobbed then return end + if storeIndex ~= mission.targetStoreIndex then return end + + local citizenid = getCitizenIdFromSource(src) + + print(( + '[how_progression] M3 robbery validation | src=%s citizenid=%s store=%s member=%s' + ):format( + tostring(src), + tostring(citizenid), + tostring(storeIndex), + tostring(crowbarMission3ByCitizenId[citizenid] == mission) + )) + + if crowbarMission3ByCitizenId[citizenid] ~= mission then return end + + mission.registerRobbed = true + mission.objective = 'lose_cops' + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = 'Rob the Harmony 24/7 register', + status = true + }, + { + name = 'Lose the cops', + status = false + } + } + ) + end + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:crowbarMission3Escape', + participant.source + ) + end + end + + print(( + '[how_progression] Crowbar Mission 3 register robbed | store %s' + ):format(storeIndex)) + end +) + +local function completeCrowbarMission3(src, devOverride) + local mission = crowbarMission3 + + if not mission or mission.completed then + return false + end + + local citizenid = getCitizenIdFromSource(src) + if not citizenid then + return false + end + + if crowbarMission3ByCitizenId[citizenid] ~= mission then + return false + end + + -- Normal gameplay requires the register robbery first. + -- /prog X.2 may bypass the objective for development testing. + if not devOverride and not mission.registerRobbed then + return false + end + + mission.completed = true + mission.objective = 'complete' + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = 'Rob the Harmony 24/7 register', + status = true + }, + { + name = 'Lose the cops', + status = true + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + mission.partyId, + 'Quick Score complete' + ) + end + + rewardCrowbarParticipants( + mission.participants, + 3, + 4, + 5 + ) + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-7384', + "Not bad. One problem—the cash is marked. " .. + "Don't spend it. Come see me. " .. + "I know a guy who can clean it." + ) + end + + crowbarMission3ByCitizenId[participant.citizenid] = nil + end + + crowbarMission3 = nil + + print( + '[how_progression] Crowbar Mission 3 complete | stage 4' + ) + + return true +end + +RegisterNetEvent( + 'how_progression:server:completeCrowbarMission3', + function() + completeCrowbarMission3(source, false) + end +) + +-- ============================================================ +-- Crowbar Mission 4: Wash the Cash +-- ============================================================ + +local crowbarMission4 = nil +local crowbarMission4ByCitizenId = {} + +local function clearCrowbarMission4() + if not crowbarMission4 then return end + + for _, participant in ipairs(crowbarMission4.participants) do + crowbarMission4ByCitizenId[participant.citizenid] = nil + end + + crowbarMission4 = nil +end + +local function startCrowbarMission4(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local progression = getProgression( + player.PlayerData.citizenid + ) + + if not progression or progression.crowbar_stage ~= 4 then + return false + end + + local participants, partyId = getMissionParticipants(src) + + if not participants or #participants == 0 then + return false + end + + clearCrowbarMission4() + + local snapshot = {} + + for _, participant in ipairs(participants) do + snapshot[#snapshot + 1] = { + source = participant.source, + citizenid = participant.citizenid + } + end + + crowbarMission4 = { + missionNumber = 4, + objective = 'meet_mack', + participants = snapshot, + partyId = partyId, + ownerSource = src, + completed = false + } + + for _, participant in ipairs(snapshot) do + crowbarMission4ByCitizenId[participant.citizenid] = + crowbarMission4 + + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:startCrowbarMission4', + participant.source + ) + end + end + + if partyId then + exports['bs_groupsystem']:updatePartyTasks( + partyId, + { + { + name = 'Meet Mack at the scrapyard', + status = false + }, + { + name = 'Take the marked cash to Gabe', + status = false + }, + { + name = 'Wash the marked cash', + status = false + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + partyId, + 'Crowbar: Wash the Cash' + ) + end + + return true +end + +RegisterNetEvent( + 'how_progression:server:startCrowbarMission4', + function() + startCrowbarMission4(source) + end +) + +lib.callback.register( + 'how_progression:server:canTalkToMack', + function(source) + local progression, player = getPlayerProgression(source) + if not progression or not player then return false end + + local citizenid = player.PlayerData.citizenid + + return (progression.mack_stage or 0) >= 1 + or crowbarMission4ByCitizenId[citizenid] ~= nil + end +) + +lib.callback.register( + 'how_progression:server:getCrowbarMission4State', + function(source) + local progression, player = getPlayerProgression(source) + if not progression or not player then return nil end + + local mission = + crowbarMission4ByCitizenId[ + player.PlayerData.citizenid + ] + + if mission and not mission.completed then + return mission.objective + end + + if (progression.mack_stage or 0) >= 1 then + return 'unlocked' + end + + return nil + end +) + +local function crowbarMission4TalkedToMack(src) + local citizenid = getCitizenIdFromSource(src) + if not citizenid then return false end + + local mission = crowbarMission4ByCitizenId[citizenid] + + if not mission + or mission.completed + or mission.objective ~= 'meet_mack' + then + return false + end + + mission.objective = 'meet_gabe' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + MySQL.update.await( + [[ + UPDATE how_progression + SET mack_stage = GREATEST(mack_stage, 1) + WHERE citizenid = ? + ]], + { participant.citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:crowbarMission4Mack', + participant.source + ) + end + end + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = 'Meet Mack at the scrapyard', + status = true + }, + { + name = 'Take the marked cash to Gabe', + status = false + }, + { + name = 'Wash the marked cash', + status = false + } + } + ) + end + + return true +end + +RegisterNetEvent( + 'how_progression:server:crowbarMission4TalkedToMack', + function() + crowbarMission4TalkedToMack(source) + end +) + +local function crowbarMission4TalkedToGabe(src) + local citizenid = getCitizenIdFromSource(src) + if not citizenid then return false end + + local mission = crowbarMission4ByCitizenId[citizenid] + + if not mission + or mission.completed + or mission.objective ~= 'meet_gabe' + then + return false + end + + mission.objective = 'wash_cash' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:crowbarMission4Gabe', + participant.source + ) + end + end + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = 'Meet Mack at the scrapyard', + status = true + }, + { + name = 'Take the marked cash to Gabe', + status = true + }, + { + name = 'Wash the marked cash', + status = false + } + } + ) + end + + return true +end + +AddEventHandler( + 'how_progression:server:moneywashStarted', + function(src) + crowbarMission4TalkedToGabe(src) + end +) + +local function completeCrowbarMission4(src, devOverride) + local citizenid = getCitizenIdFromSource(src) + if not citizenid then return false end + + local mission = crowbarMission4ByCitizenId[citizenid] + + if not mission or mission.completed then + return false + end + + if not devOverride and mission.objective ~= 'wash_cash' then + return false + end + + mission.completed = true + mission.objective = 'complete' + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = 'Meet Mack at the scrapyard', + status = true + }, + { + name = 'Take the marked cash to Gabe', + status = true + }, + { + name = 'Wash the marked cash', + status = true + } + } + ) + + exports['bs_groupsystem']:sendPartyNotification( + mission.partyId, + 'Wash the Cash complete' + ) + end + + rewardCrowbarParticipants( + mission.participants, + 4, + 5, + 5 + ) + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-7384', + "Good. Clean money spends clean. " .. + "Come back and see me. " .. + "I got something new for ya." + ) + + TriggerClientEvent( + 'how_progression:client:cleanupCrowbarMission4', + participant.source + ) + end + + crowbarMission4ByCitizenId[participant.citizenid] = nil + end + + crowbarMission4 = nil + + print( + '[how_progression] Crowbar Mission 4 complete | stage 5' + ) + + return true +end + +AddEventHandler( + 'how_progression:server:moneyWashed', + function(src) + completeCrowbarMission4(src, false) + end +) + +-- ============================================================ +-- Mack Mission 1: Easy Rider +-- ============================================================ + +local mackMission1 = nil +local mackMission1ByCitizenId = {} + +local function clearMackMission1() + if not mackMission1 then return end + + for _, participant in ipairs(mackMission1.participants) do + mackMission1ByCitizenId[participant.citizenid] = nil + end + + mackMission1 = nil +end + +local function getMackMission1ForSource(src) + local citizenid = getCitizenIdFromSource(src) + if not citizenid then return nil end + + return mackMission1ByCitizenId[citizenid] +end + +local function broadcastMackMission1(mission, eventName, ...) + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + eventName, + participant.source, + ... + ) + end + end +end + +local function startMackMission1(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local progression = + getProgression(player.PlayerData.citizenid) + + if not progression + or (progression.mack_stage or 0) ~= 1 + then + return false + end + + if mackMission1 then + TriggerClientEvent( + 'ox_lib:notify', + src, + { + title = 'Mack', + description = 'I already have somebody handling that delivery.', + type = 'error' + } + ) + + return false + end + + local participants, partyId = + getMissionParticipants(src) + + if not participants or #participants == 0 then + return false + end + + local snapshot = {} + + for _, participant in ipairs(participants) do + snapshot[#snapshot + 1] = { + source = participant.source, + citizenid = participant.citizenid + } + end + + mackMission1 = { + missionNumber = 1, + objective = 'leave_scrapyard', + participants = snapshot, + partyId = partyId, + ownerSource = src, + bikeCount = math.min(#snapshot, 5), + bikesDelivered = {}, + deliveredCount = 0, + completed = false + } + + for _, participant in ipairs(snapshot) do + mackMission1ByCitizenId[ + participant.citizenid + ] = mackMission1 + end + + for _, participant in ipairs(mackMission1.participants) do + if participantIsValid(participant) then + local added = exports.ox_inventory:AddItem( + participant.source, + 'WEAPON_BAT', + 1 + ) + + if not added then + TriggerClientEvent('ox_lib:notify', participant.source, { + title = 'Mack', + description = 'Could not carry the bat. Make room in your inventory.', + type = 'error' + }) + end + + TriggerClientEvent( + 'how_progression:client:startMackMission1', + participant.source, + participant.source == mackMission1.ownerSource, + mackMission1.bikeCount + ) + end + end + + return true +end + +RegisterNetEvent( + 'how_progression:server:startMackMission1', + function() + startMackMission1(source) + end +) + +RegisterNetEvent( + 'how_progression:server:mackMission1BikeSpawned', + function(bikeIndex, netId) + local src = source + local mission = getMackMission1ForSource(src) + + if not mission + or mission.completed + or src ~= mission.ownerSource + then + return + end + + bikeIndex = tonumber(bikeIndex) + netId = tonumber(netId) + + if not bikeIndex + or bikeIndex < 1 + or bikeIndex > mission.bikeCount + or not netId + then + return + end + + CreateThread(function() + local vehicle = 0 + + -- Network ownership can reach the server slightly after + -- the spawning client sends us the network ID. + for attempt = 1, 20 do + vehicle = NetworkGetEntityFromNetworkId(netId) + + if vehicle and vehicle ~= 0 + and DoesEntityExist(vehicle) + then + break + end + + Wait(100) + end + + if not vehicle + or vehicle == 0 + or not DoesEntityExist(vehicle) + then + print(( + '[how_progression] M1 failed to resolve bike %d netId %d' + ):format(bikeIndex, netId)) + return + end + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + exports.qbx_vehiclekeys:GiveKeys( + participant.source, + vehicle, + true + ) + end + end + end) + end +) + +RegisterNetEvent( + 'how_progression:server:mackMission1Detour', + function() + local mission = + getMackMission1ForSource(source) + + if not mission + or mission.completed + or mission.objective ~= 'leave_scrapyard' + then + return + end + + mission.objective = 'trash_car' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-6842', + "Hold up. Got something else for you while you're out. Sending you an address." + ) + + TriggerClientEvent( + 'how_progression:client:mackMission1TrashCar', + participant.source, + participant.source == mission.ownerSource + ) + end + end + end +) + +RegisterNetEvent( + 'how_progression:server:mackMission1AtDetour', + function() + local mission = + getMackMission1ForSource(source) + + if not mission + or mission.completed + or mission.objective ~= 'trash_car' + then + return + end + + if mission.detourExplained then + return + end + + mission.detourExplained = true + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-6842', + "See that car? Guy owes me money. I'm tired of asking. Don't kill anybody. Just make sure his next ride is on a tow truck." + ) + + TriggerClientEvent( + 'how_progression:client:mackMission1DetourExplained', + participant.source + ) + end + end + end +) + +RegisterNetEvent( + 'how_progression:server:mackMission1CarTrashed', + function() + local mission = + getMackMission1ForSource(source) + + if not mission + or mission.completed + or mission.objective ~= 'trash_car' + or not mission.detourExplained + or source ~= mission.ownerSource + then + return + end + + mission.objective = 'deliver_bikes' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-6842', + "Yeah. That'll get the point across. Get those bikes to Vinny." + ) + + TriggerClientEvent( + 'how_progression:client:mackMission1DeliverBikes', + participant.source, + mission.deliveredCount, + mission.bikeCount + ) + end + end + end +) + +RegisterNetEvent( + 'how_progression:server:mackMission1BikeDelivered', + function(bikeIndex) + local mission = + getMackMission1ForSource(source) + + if not mission + or mission.completed + or mission.objective ~= 'deliver_bikes' + or source ~= mission.ownerSource + then + return + end + + bikeIndex = tonumber(bikeIndex) + + if not bikeIndex + or bikeIndex < 1 + or bikeIndex > mission.bikeCount + or mission.bikesDelivered[bikeIndex] + then + return + end + + mission.bikesDelivered[bikeIndex] = true + mission.deliveredCount = + mission.deliveredCount + 1 + + broadcastMackMission1( + mission, + 'how_progression:client:mackMission1DeliveryCount', + mission.deliveredCount, + mission.bikeCount + ) + + if mission.deliveredCount >= mission.bikeCount then + mission.objective = 'talk_vinny' + + broadcastMackMission1( + mission, + 'how_progression:client:mackMission1TalkVinny' + ) + end + end +) + +RegisterNetEvent( + 'how_progression:server:completeMackMission1', + function() + local mission = + getMackMission1ForSource(source) + + if not mission + or mission.completed + or mission.objective ~= 'talk_vinny' + then + return + end + + mission.completed = true + mission.objective = 'complete' + + -- First Mack mission: + -- $300 clean cash + 5 Mack REP for every snapshotted participant. + rewardMackParticipants( + mission.participants, + 1, + 2, + 5, + 300 + ) + + broadcastMackMission1( + mission, + 'how_progression:client:mackMission1Complete' + ) + + -- Vinny follows up after the crew has left. + local participants = mission.participants + + SetTimeout(math.random(60000, 90000), function() + for _, participant in ipairs(participants) do + if participantIsValid(participant) then + local smsSent = sendSmsToPlayer( + participant.source, + '555-3176', + "Hey. Vinny. Told ya you'd like this.\n\n" .. + "I buy Sanchezes. Don't care where you find 'em.\n\n" .. + "Bring 'em around back. I handle the VIN and paperwork, you get paid.\n\n" .. + "Marked bills. Don't ask.\n\n" .. + "I can only move so many a day, so don't get greedy." + ) + + if smsSent then + getProgression(participant.citizenid) + local saved = MySQL.update.await( + [[ + UPDATE how_progression + SET vinny_fence_unlocked = 1 + WHERE citizenid = ? + ]], + { participant.citizenid } + ) + + local progression = saved ~= nil + and getProgression(participant.citizenid) + + if progression and isVinnyFenceUnlocked( + progression.vinny_fence_unlocked + ) then + TriggerClientEvent( + 'how_progression:client:unlockVinnyFence', + participant.source + ) + end + end + end + end + end) + + for _, participant in ipairs(participants) do + mackMission1ByCitizenId[ + participant.citizenid + ] = nil + end + + mackMission1 = nil + + print( + '[how_progression] Mack Mission 1 complete | stage 2' + ) + end +) + +local vinnyFenceBusy = {} +local vinnyFenceCoords = vec3(1356.79, 3617.37, 34.88) + +lib.callback.register('how_progression:server:fenceSanchez', function(source, netId, gameDay) + if not vinnyFenceSchemaReady then + return false, 'Vinny is not ready yet. Try again shortly.' + end + + local player = exports.qbx_core:GetPlayer(source) + if not player then return false, 'Player unavailable.' end + + local citizenid = player.PlayerData.citizenid + if vinnyFenceBusy[citizenid] then return false, 'Vinny is handling your last bike.' end + vinnyFenceBusy[citizenid] = true + + local function finish(success, message) + vinnyFenceBusy[citizenid] = nil + return success, message + end + + local progression = getProgression(citizenid) + if not progression or not isVinnyFenceUnlocked( + progression.vinny_fence_unlocked + ) then + return finish(false, 'Vinny has not offered to buy bikes yet.') + end + + gameDay = tonumber(gameDay) + if not gameDay or gameDay ~= math.floor(gameDay) then + return finish(false, 'Could not verify the in-game day.') + end + + local year = math.floor(gameDay / 10000) + local month = math.floor(gameDay / 100) % 100 + local day = gameDay % 100 + if year < 1900 or year > 2100 or month < 0 or month > 12 or day < 1 or day > 31 + or gameDay < (progression.vinny_fence_day or 0) + then + return finish(false, 'Could not verify the in-game day.') + end + + local vehicle = NetworkGetEntityFromNetworkId(tonumber(netId) or 0) + local ped = GetPlayerPed(source) + if not vehicle or vehicle == 0 or not DoesEntityExist(vehicle) + or GetEntityModel(vehicle) ~= `sanchez` + or not ped or ped == 0 + or GetVehiclePedIsIn(ped, false) ~= vehicle + or GetPedInVehicleSeat(vehicle, -1) ~= ped + or #(GetEntityCoords(vehicle) - vinnyFenceCoords) > 3.0 + or #(GetEntityCoords(ped) - vinnyFenceCoords) > 4.0 + or Entity(vehicle).state.howMackMission1Bike + then + return finish(false, 'Bring an unowned Sanchez into Vinny\'s marker.') + end + + local plate = GetVehicleNumberPlateText(vehicle) + if Entity(vehicle).state.vehicleid + or exports.qbx_vehicles:GetVehicleIdByPlate(plate) + then + return finish(false, 'Vinny does not buy player-owned bikes.') + end + + if not exports.ox_inventory:CanCarryItem(source, 'markedbills', 1, { worth = 200 }) then + return finish(false, 'Make room for the marked bills first.') + end + + local reserved = MySQL.update.await([[ + UPDATE how_progression + SET vinny_fence_count = IF(vinny_fence_day = ?, vinny_fence_count + 1, 1), + vinny_fence_day = ? + WHERE citizenid = ? AND vinny_fence_unlocked = 1 + AND (vinny_fence_day < ? OR vinny_fence_count < 3) + ]], { gameDay, gameDay, citizenid, gameDay }) + + if reserved ~= 1 then + return finish(false, 'Vinny has bought three Sanchezes from you today.') + end + + local function releaseQuota() + MySQL.update.await([[ + UPDATE how_progression + SET vinny_fence_count = GREATEST(vinny_fence_count - 1, 0) + WHERE citizenid = ? AND vinny_fence_day = ? + ]], { citizenid, gameDay }) + end + + TriggerClientEvent('how_progression:client:leaveFenceBike', source, netId) + local timeout = GetGameTimer() + 4000 + while GetVehiclePedIsIn(ped, false) == vehicle and GetGameTimer() < timeout do + Wait(100) + end + + if not DoesEntityExist(vehicle) + or GetVehiclePedIsIn(ped, false) == vehicle + or #(GetEntityCoords(vehicle) - vinnyFenceCoords) > 3.0 + or #(GetEntityCoords(ped) - vinnyFenceCoords) > 5.0 + then + releaseQuota() + return finish(false, 'Leave the Sanchez parked in the marker.') + end + + DeleteEntity(vehicle) + Wait(100) + if DoesEntityExist(vehicle) then + releaseQuota() + return finish(false, 'Vinny could not take that bike. Try again.') + end + + local added = exports.ox_inventory:AddItem(source, 'markedbills', 1, { worth = 200 }) + if not added then + releaseQuota() + return finish(false, 'The marked bills could not be added to your inventory.') + end + + return finish(true, 'Vinny paid you $200 in marked bills.') +end) + +lib.callback.register( + 'how_progression:server:getMackMission1State', + function(source) + local progression, player = + getPlayerProgression(source) + + if not progression or not player then + return nil + end + + local mission = + mackMission1ByCitizenId[ + player.PlayerData.citizenid + ] + + if mission and not mission.completed then + return { + objective = mission.objective, + deliveredCount = mission.deliveredCount, + bikeCount = mission.bikeCount + } + end + + if (progression.mack_stage or 0) == 1 then + return { + objective = 'available', + deliveredCount = 0 + } + end + + if (progression.mack_stage or 0) >= 2 then + return { + objective = 'complete', + deliveredCount = 3 + } + end + + return nil + end +) + +-- ============================================================ +-- Crowbar Mission 5: Buy In +-- ============================================================ + +local crowbarMission5 = nil +local crowbarMission5ByCitizenId = {} + +local function clearCrowbarMission5() + if not crowbarMission5 then return end + + for _, participant in ipairs(crowbarMission5.participants) do + crowbarMission5ByCitizenId[ + participant.citizenid + ] = nil + end + + crowbarMission5 = nil +end + +local function startCrowbarMission5(src) + local player = exports.qbx_core:GetPlayer(src) + if not player then return false end + + local progression = getProgression( + player.PlayerData.citizenid + ) + + if not progression + or progression.crowbar_stage ~= 5 + then + return false + end + + local participants, partyId = + getMissionParticipants(src) + + if not participants or #participants == 0 then + return false + end + + clearCrowbarMission5() + + local snapshot = {} + + for _, participant in ipairs(participants) do + snapshot[#snapshot + 1] = { + source = participant.source, + citizenid = participant.citizenid + } + end + + local stashId = + exports.ox_inventory:CreateTemporaryStash({ + label = "Crowbar's Package", + slots = 1, + maxWeight = 5000, + items = { + { + 'crowbar_package', + 1, + { + description = + "A sealed package. Crowbar wants it unopened." + } + } + } + }) + + if not stashId then + return false + end + + crowbarMission5 = { + missionNumber = 5, + objective = 'get_package', + participants = snapshot, + partyId = partyId, + ownerSource = src, + stashId = stashId, + completed = false + } + + for _, participant in ipairs(snapshot) do + crowbarMission5ByCitizenId[ + participant.citizenid + ] = crowbarMission5 + + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:startCrowbarMission5', + participant.source, + stashId + ) + end + end + + return true +end + +RegisterNetEvent( + 'how_progression:server:startCrowbarMission5', + function() + startCrowbarMission5(source) + end +) + +local function crowbarMission5PackageTaken(src) + local citizenid = + getCitizenIdFromSource(src) + + if not citizenid then return false end + + local mission = + crowbarMission5ByCitizenId[citizenid] + + if not mission + or mission.completed + or mission.objective ~= 'get_package' + then + return false + end + + mission.objective = 'escape_ambush' + mission.carrierSource = src + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + sendSmsToPlayer( + participant.source, + '555-7384', + "Get out of there! You're about to have company! Lose em!" + ) + end + end + + print(( + '[how_progression] M5 package taken | carrier=%s | participants=%s' + ):format( + tostring(src), + tostring(#mission.participants) + )) + + for _, participant in ipairs(mission.participants) do + local valid = participantIsValid(participant) + + print(( + '[how_progression] M5 participant | source=%s citizenid=%s valid=%s ambushOwner=%s' + ):format( + tostring(participant.source), + tostring(participant.citizenid), + tostring(valid), + tostring(participant.source == src) + )) + + if valid then + TriggerClientEvent( + 'how_progression:client:crowbarMission5PackageTaken', + participant.source, + participant.source == src + ) + end + end + + return true +end + +RegisterNetEvent( + 'how_progression:server:crowbarMission5Escaped', + function() + local src = source + local citizenid = getCitizenIdFromSource(src) + + if not citizenid then return end + + local mission = + crowbarMission5ByCitizenId[citizenid] + + if not mission + or mission.completed + or mission.objective ~= 'escape_ambush' + or mission.carrierSource ~= src + then + return + end + + mission.objective = 'return_package' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:crowbarMission5Escaped', + participant.source + ) + end + end + end +) + +exports.ox_inventory:registerHook( + 'swapItems', + function(payload) + if not crowbarMission5 then + return + end + + if crowbarMission5.objective ~= 'get_package' then + return + end + + local fromInventory = payload.fromInventory + local fromId = fromInventory + + if type(fromInventory) == 'table' then + fromId = + fromInventory.id + or fromInventory.name + end + + if tostring(fromId) + ~= tostring(crowbarMission5.stashId) + then + return + end + + -- Moving the package from Crowbar's stash into a + -- player's inventory starts the ambush. + if payload.toType ~= 'player' then + return + end + + local itemName + + if type(payload.fromSlot) == 'table' then + itemName = payload.fromSlot.name + end + + if itemName ~= 'crowbar_package' then + return + end + + -- swapItems is a pre-hook. Let ox_inventory finish + -- moving the item, then advance the mission. + SetTimeout(250, function() + crowbarMission5PackageTaken( + payload.source + ) + end) + end, + { + itemFilter = { + crowbar_package = true + } + } +) + +local function completeCrowbarMission5( + src, + devOverride +) + local citizenid = + getCitizenIdFromSource(src) + + if not citizenid then return false end + + local mission = + crowbarMission5ByCitizenId[citizenid] + + if not mission or mission.completed then + return false + end + + if not devOverride + and mission.objective ~= 'return_package' + then + return false + end + + if not devOverride then + local count = + exports.ox_inventory:GetItemCount( + src, + 'crowbar_package' + ) + + if not count or count < 1 then + TriggerClientEvent( + 'ox_lib:notify', + src, + { + title = 'Crowbar', + description = + "You don't have my package.", + type = 'error' + } + ) + + return false + end + + exports.ox_inventory:RemoveItem( + src, + 'crowbar_package', + 1 + ) + end + + mission.completed = true + mission.objective = 'complete' + + rewardCrowbarParticipants( + mission.participants, + 5, + 6, + 5 + ) + + -- The carrier gets the cash payment. + local carrier = + exports.qbx_core:GetPlayer(src) + + if carrier then + carrier.Functions.AddMoney( + 'cash', + 750, + 'crowbar-buy-in' + ) + end + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:cleanupCrowbarMission5', + participant.source + ) + + TriggerClientEvent( + 'how_progression:client:crowbarMission5Complete', + participant.source + ) + end + + crowbarMission5ByCitizenId[ + participant.citizenid + ] = nil + end + + crowbarMission5 = nil + + print( + '[how_progression] Crowbar Mission 5 complete | stage 6' + ) + + return true +end + +RegisterNetEvent( + 'how_progression:server:completeCrowbarMission5', + function() + completeCrowbarMission5( + source, + false + ) + end +) + +-- ============================================================ +-- Crowbar active mission state +-- ============================================================ + +local function getCrowbarActiveMission(citizenid) + local checks = { + { mission = crowbarMission1, members = crowbarMission1ByCitizenId }, + { mission = crowbarMission2, members = crowbarMission2ByCitizenId }, + { mission = crowbarMission3, members = crowbarMission3ByCitizenId }, + { mission = crowbarMission4, members = crowbarMission4ByCitizenId }, + { mission = crowbarMission5, members = crowbarMission5ByCitizenId } + } + + for _, check in ipairs(checks) do + local mission = check.mission + + if mission + and not mission.completed + and check.members[citizenid] == mission + then + return { + missionNumber = mission.missionNumber, + objective = mission.objective + } + end + end + + return nil +end + +-- ============================================================ +-- Crowbar progression callback +-- ============================================================ + +lib.callback.register( + 'how_progression:server:getCrowbarMission', + function(source) + local progression = getPlayerProgression(source) + + if not progression then + return 0 + end + + return progression.crowbar_stage or 0 + end +) + + +lib.callback.register( + 'how_progression:server:isCrowbarMissionActive', + function(source) + local citizenid = getCitizenIdFromSource(source) + if not citizenid then return false end + + return getCrowbarActiveMission(citizenid) ~= nil + end +) + +-- ============================================================ +-- Development: skip Crowbar Mission 1 +-- ============================================================ + +RegisterCommand('skipcrowbar1', function(source) + if source == 0 then + print( + '[how_progression] /skipcrowbar1 must be run by a player' + ) + return + end + + local progression, player = + getPlayerProgression(source) + + if not progression or not player then + return + end + + local currentRep = + progression.crowbar_rep or 0 + + local newRep = + math.min(currentRep + 5, 100) + + MySQL.update.await( + [[ + UPDATE how_progression + SET + unknown_introduced = 1, + crowbar_stage = GREATEST(crowbar_stage, 2), + crowbar_rep = GREATEST(crowbar_rep, ?) + WHERE citizenid = ? + ]], + { + newRep, + player.PlayerData.citizenid + } + ) + + TriggerClientEvent( + 'how_progression:client:crowbarReward', + source, + 5, + newRep, + 2 + ) + + print(( + '[how_progression] %s skipped Crowbar Mission 1 | stage 2 | REP %s' + ):format( + player.PlayerData.citizenid, + newRep + )) +end, false) + +-- ============================================================ +-- Development: progression control +-- Usage: +-- /prog crowbar 0 +-- /prog crowbar 3 +-- /prog mack 3 +-- /prog tina 5 +-- ============================================================ + +RegisterCommand('prog', function(source, args) + if source == 0 then + print('[how_progression] /prog must be run by a player') + return + end + + local contact = args[1] and string.lower(args[1]) + local stageInput = args[2] + + -- Complete development reset. + if contact == 'reset' then + local player = exports.qbx_core:GetPlayer(source) + + if not player then + return + end + + local citizenid = player.PlayerData.citizenid + + -- Abort all currently active Crowbar missions. + clearCrowbarMission1() + clearCrowbarMission2() + clearCrowbarMission3() + clearCrowbarMission4() + clearCrowbarMission5() + + -- Reset all custom progression, including UNKNOWN. + MySQL.update.await( + [[ + UPDATE how_progression + SET + unknown_introduced = 0, + crowbar_stage = 0, + crowbar_rep = 0, + mack_stage = 0, + vinny_fence_unlocked = 0, + vinny_fence_day = 0, + vinny_fence_count = 0, + mack_rep = 0, + tina_stage = 0, + tina_rep = 0, + story_stage = 0 + WHERE citizenid = ? + ]], + { citizenid } + ) + + -- Development reset intentionally removes everything + -- currently carried by this character. + -- Reset inventory to the standard development baseline. + exports.ox_inventory:ClearInventory(source) + + exports.ox_inventory:AddItem( + source, + 'phone', + 1 + ) + + exports.ox_inventory:AddItem( + source, + 'money', + 500 + ) + + -- Clear local mission state, temporary routes/blips, + -- and locked contact blips. + TriggerClientEvent( + 'how_progression:client:resetProgression', + source + ) + + print(( + '[how_progression] Full progression reset for %s' + ):format(citizenid)) + + return + end + + -- UNKNOWN development control. + -- + -- /prog unknown 0 = reset UNKNOWN introduction state + -- /prog unknown 1 = perform the real UNKNOWN introduction + if contact == 'unknown' then + local player = exports.qbx_core:GetPlayer(source) + + if not player then + return + end + + local citizenid = player.PlayerData.citizenid + + if stageInput == '0' then + MySQL.update.await( + [[ + UPDATE how_progression + SET + unknown_introduced = 0, + crowbar_stage = 0 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:resetProgression', + source + ) + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'UNKNOWN reset.', + type = 'success' + }) + + return + + elseif stageInput == '1' then + if not introduceUnknown(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'UNKNOWN introduction was not sent.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'UNKNOWN introduction triggered.', + type = 'success' + }) + + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Use /prog unknown 0 or /prog unknown 1.', + type = 'error' + }) + + return + end + + if not contact or not stageInput then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Usage: /prog crowbar 0-10 or /prog crowbar 1.1', + type = 'error' + }) + return + end + + -- Development mission shortcuts: + -- X = mission available + -- X.Y = mission X, phase Y + -- + -- Each mission defines what its phases mean. The final + -- phase always performs the real mission completion path. + local missionInput, phaseInput = + stageInput:match('^(%d+)%.(%d+)$') + + local stage + local phase + + if missionInput then + stage = tonumber(missionInput) + phase = tonumber(phaseInput) + + if not phase or phase < 1 then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Mission phase must be 1 or greater.', + type = 'error' + }) + return + end + else + if not stageInput:match('^%d+$') then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Use X or X.Y, for example 1, 1.1, or 1.2.', + type = 'error' + }) + return + end + + stage = tonumber(stageInput) + end + + if not stage or stage < 0 or stage > 10 then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Stage must be between 0 and 10.', + type = 'error' + }) + return + end + + local player = exports.qbx_core:GetPlayer(source) + + if not player then + return + end + + local citizenid = player.PlayerData.citizenid + local column + + if contact == 'crowbar' then + column = 'crowbar_stage' + elseif contact == 'mack' then + column = 'mack_stage' + elseif contact == 'tina' then + column = 'tina_stage' + else + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Valid contacts: crowbar, mack, tina', + type = 'error' + }) + return + end + + -- Mission phase shortcuts. + if phase then + if contact ~= 'crowbar' then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Mission phase shortcuts are currently only implemented for Crowbar.', + type = 'error' + }) + return + end + + -- ==================================================== + -- Crowbar Mission 1 + -- 1.1 = accepted / retrieve Emperor + -- 1.2 = Emperor acquired / return to Crowbar + -- 1.3 = completion + -- ==================================================== + + if stage == 1 then + if phase == 1 then + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = 1, + unknown_introduced = 1 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + source + ) + + if not startCrowbarMission1(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Could not start Crowbar Mission 1.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 1 started.', + type = 'success' + }) + return + + elseif phase == 2 then + if not setCrowbarMission1ReturnPhase(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 1 is not currently active.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 1 advanced to return phase.', + type = 'success' + }) + return + + elseif phase == 3 then + if not completeCrowbarMission1( + source, + nil, + true + ) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 1 could not be completed.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 1 completed.', + type = 'success' + }) + return + end + + -- ==================================================== + -- Crowbar Mission 2 + -- 2.1 = accepted / steal Premier + -- 2.2 = completion + -- ==================================================== + + elseif stage == 2 then + if phase == 1 then + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = 2, + unknown_introduced = 1 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + source + ) + + if not startCrowbarMission2(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Could not start Crowbar Mission 2.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 2 started.', + type = 'success' + }) + return + + elseif phase == 2 then + if not completeCrowbarMission2( + source, + nil, + nil, + true + ) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 2 could not be completed.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 2 completed.', + type = 'success' + }) + return + end + + -- ==================================================== + -- Crowbar Mission 3 + -- 3.1 = accepted / rob Harmony register + -- 3.2 = register robbed / lose cops + -- 3.3 = completion + -- ==================================================== + + elseif stage == 3 then + if phase == 1 then + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = 3, + unknown_introduced = 1 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + source + ) + + if not startCrowbarMission3(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Could not start Crowbar Mission 3.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 3 started.', + type = 'success' + }) + return + + elseif phase == 2 then + local mission = crowbarMission3 + + if not mission + or mission.completed + or crowbarMission3ByCitizenId[citizenid] ~= mission + then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 3 is not currently active.', + type = 'error' + }) + return + end + + mission.registerRobbed = true + mission.objective = 'lose_cops' + + for _, participant in ipairs(mission.participants) do + if participantIsValid(participant) then + TriggerClientEvent( + 'how_progression:client:crowbarMission3Escape', + participant.source + ) + end + end + + if mission.partyId then + exports['bs_groupsystem']:updatePartyTasks( + mission.partyId, + { + { + name = 'Rob the Harmony 24/7 register', + status = 'done' + }, + { + name = 'Lose the cops', + status = 'current' + } + } + ) + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 3 advanced to escape phase.', + type = 'success' + }) + return + + elseif phase == 3 then + if not completeCrowbarMission3(source, true) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 3 could not be completed.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 3 completed.', + type = 'success' + }) + return + end + end + + -- ==================================================== + -- Crowbar Mission 4: Wash the Cash + -- 4.1 = accepted / meet Mack + -- 4.2 = Mack spoken to / meet Gabe + -- 4.3 = Gabe contacted / wash cash + -- 4.4 = completion + -- ==================================================== + + if stage == 4 then + if phase == 1 then + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = 4, + unknown_introduced = 1 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + source + ) + + if not startCrowbarMission4(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Could not start Crowbar Mission 4.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 4 started.', + type = 'success' + }) + return + + elseif phase == 2 then + if not crowbarMission4TalkedToMack(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Mission 4 is not waiting for Mack.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Mission 4 advanced to Gabe.', + type = 'success' + }) + return + + elseif phase == 3 then + if not crowbarMission4TalkedToGabe(source) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Mission 4 is not waiting for Gabe.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Mission 4 advanced to money washing.', + type = 'success' + }) + return + + elseif phase == 4 then + if not completeCrowbarMission4(source, true) then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 4 could not be completed.', + type = 'error' + }) + return + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Crowbar Mission 4 completed.', + type = 'success' + }) + return + end + end + + -- Crowbar Mission 5: Buy In + if stage == 5 then + if phase == 1 then + MySQL.update.await( + [[ + UPDATE how_progression + SET + crowbar_stage = 5, + unknown_introduced = 1 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + source + ) + + if not startCrowbarMission5(source) then + TriggerClientEvent( + 'ox_lib:notify', + source, + { + title = 'Development', + description = 'Could not start Crowbar Mission 5.', + type = 'error' + } + ) + end + + return + + elseif phase == 2 then + if not crowbarMission5PackageTaken(source) then + TriggerClientEvent( + 'ox_lib:notify', + source, + { + title = 'Development', + description = 'Mission 5 could not advance to the ambush.', + type = 'error' + } + ) + end + + return + + elseif phase == 3 then + -- Fight or escape: there is deliberately no + -- kill requirement. This represents reaching + -- the return-package phase. + local mission = + crowbarMission5ByCitizenId[citizenid] + + if mission then + mission.objective = + 'return_package' + + TriggerClientEvent( + 'how_progression:client:crowbarMission5PackageTaken', + source + ) + end + + return + + elseif phase == 4 then + completeCrowbarMission5( + source, + true + ) + + return + end + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = ('Crowbar Mission %s phase %s is not implemented.'):format( + stage, + phase + ), + type = 'error' + }) + return + end + + -- Normal progression stage. + MySQL.update.await( + ('UPDATE how_progression SET %s = ? WHERE citizenid = ?'):format(column), + { stage, citizenid } + ) + + -- Setting Crowbar to 0 also resets his introduction and REP. + if contact == 'crowbar' and stage == 0 then + MySQL.update.await( + [[ + UPDATE how_progression + SET + unknown_introduced = 0, + crowbar_rep = 0 + WHERE citizenid = ? + ]], + { citizenid } + ) + end + + -- Active Crowbar stages require the character to have been introduced. + if contact == 'crowbar' and stage > 0 then + MySQL.update.await( + [[ + UPDATE how_progression + SET unknown_introduced = 1 + WHERE citizenid = ? + ]], + { citizenid } + ) + + TriggerClientEvent( + 'how_progression:client:unlockCrowbar', + source + ) + end + + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = ( + '%s progression set to stage %s.' + ):format( + contact:gsub("^%l", string.upper), + stage + ), + type = 'success' + }) + + print(( + '[how_progression] %s set %s progression to stage %s' + ):format( + citizenid, + contact, + stage + )) +end, false) + +-- ============================================================ +-- Resource cleanup +-- ============================================================ + +AddEventHandler('onResourceStop', function(resourceName) + if resourceName ~= GetCurrentResourceName() then + return + end + + if crowbarMission1 then + if crowbarMission1.vehicle + and DoesEntityExist(crowbarMission1.vehicle) then + DeleteEntity(crowbarMission1.vehicle) + end + end + + if crowbarMission2 then + if crowbarMission2.vehicle + and DoesEntityExist(crowbarMission2.vehicle) then + DeleteEntity(crowbarMission2.vehicle) + end + end +end) + +-- Development helper: give properly formatted marked bills. +lib.addCommand('givemarked', { + help = 'Give yourself marked bills for development testing', + params = { + { + name = 'amount', + type = 'number', + help = 'Dollar value of the marked bills' + } + }, + restricted = false +}, function(source, args) + local amount = math.floor(tonumber(args.amount) or 0) + + if amount <= 0 then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = 'Usage: /givemarked ', + type = 'error' + }) + return + end + + local success = exports.ox_inventory:AddItem( + source, + 'markedbills', + 1, + { worth = amount } + ) + + if success then + TriggerClientEvent('ox_lib:notify', source, { + title = 'Development', + description = ('$%s in marked bills added.'):format(amount), + type = 'success' + }) + end +end) + +RegisterNetEvent( + 'how_progression:server:crowbarPackageCarrierDied', + function() + local src = source + + local count = exports.ox_inventory:Search( + src, + 'count', + 'crowbar_package' + ) or 0 + + if count > 0 then + exports.ox_inventory:RemoveItem( + src, + 'crowbar_package', + count + ) + + TriggerClientEvent( + 'ox_lib:notify', + src, + { + title = 'Buy In', + description = "Crowbar's package was lost.", + type = 'error' + } + ) + end + end +) + +-- Temporary NPWD contact test +RegisterCommand('testcontact', function(source) + if source == 0 then return end + + local number = ('555-%04d'):format(math.random(0, 9999)) + local contact = exports.npwd:addContact(source, { + display = 'Michael', + number = number, + avatar = 'https://fivemedia.howlerdesign.com/media/6801a2d6-659f-46e3-b0ee-028a9a4ac215.png' + }) + + if not contact then + print('[how_progression] Failed to add NPWD test contact') + return + end + + TriggerClientEvent('how_progression:client:addPhoneContact', source, contact) + + SetTimeout(500, function() + sendSmsToPlayer(source, number, 'Hey. Testing the new contact system.') + end) +end, false) diff --git a/server/services/mission_registry.lua b/server/services/mission_registry.lua new file mode 100644 index 0000000..261a246 --- /dev/null +++ b/server/services/mission_registry.lua @@ -0,0 +1,45 @@ +-- Dormant framework: no missions, events, callbacks or commands are registered +-- until a future migration explicitly calls register on an instance. +local ids = require 'shared.mission_ids' + +local Registry = {} +Registry.__index = Registry + +function Registry.new() + return setmetatable({ missions = {} }, Registry) +end + +function Registry:register(mission) + assert(type(mission) == 'table' and type(mission.id) == 'string', 'mission.id is required') + assert(ids.definitions[mission.id], 'unknown mission ID: ' .. mission.id) + assert(not self.missions[mission.id], 'duplicate mission ID: ' .. mission.id) + self.missions[mission.id] = mission + return mission +end + +function Registry:get(missionId) + return self.missions[missionId] +end + +function Registry:resolveProgPhase(contact, notation) + local phase = ids.parseProgPhase(contact, notation) + if not phase then return nil end + + local mission = self.missions[phase.missionId] + if not mission or not mission.phases or not mission.phases[phase.phaseName] then + return nil + end + + return mission, mission.phases[phase.phaseName], phase +end + +function Registry:list() + local result = {} + for id, mission in pairs(self.missions) do + result[#result + 1] = { id = id, mission = mission } + end + table.sort(result, function(a, b) return a.id < b.id end) + return result +end + +return Registry diff --git a/shared/contacts/cosmic_dave.lua b/shared/contacts/cosmic_dave.lua new file mode 100644 index 0000000..f6d732d --- /dev/null +++ b/shared/contacts/cosmic_dave.lua @@ -0,0 +1,14 @@ +return { + id = 'cosmic_dave', + displayName = 'Cosmic Dave', + ped = { + model = 'ig_omega', + coords = { x = 2447.63, y = 3775.08, z = 41.43, w = 23.30 }, + spawnZOffset = -1.0, + scenario = 'WORLD_HUMAN_PARTYING', + missionEntity = true, invincible = true, frozen = true, + blockNonTemporaryEvents = true, + }, + world = { alwaysPresent = true }, + missions = {}, +} diff --git a/shared/contacts/crowbar.lua b/shared/contacts/crowbar.lua new file mode 100644 index 0000000..8b45c30 --- /dev/null +++ b/shared/contacts/crowbar.lua @@ -0,0 +1,37 @@ +return { + id = 'crowbar', + displayName = 'Crowbar', + phone = { number = '555-7384' }, + ped = { + model = 'ig_clay', + coords = { x = 1699.67, y = 3775.30, z = 34.74, w = 207.22 }, + spawnZOffset = -1.0, + scenario = 'WORLD_HUMAN_SMOKING', + invincible = true, + frozen = true, + blockNonTemporaryEvents = true, + }, + world = { + requires = { column = 'crowbar_stage', min = 1 }, + vehicles = { + { + id = 'crowbar_daemon', model = 'daemon', + coords = { x = 1699.70, y = 3771.63, z = 34.53, w = 258.21 }, + localEntity = true, locked = 2, invincible = true, frozen = true, + }, + }, + }, + blip = { + label = 'Crowbar', sprite = 280, scale = 0.75, shortRange = false, + requires = { column = 'crowbar_stage', min = 1 }, + }, + interaction = { + name = 'how_progression_crowbar_talk', icon = 'fa-solid fa-comment', + label = 'Talk to Crowbar', distance = 2.0, + gateCallback = 'how_progression:server:isCrowbarMissionActive', + gateAllowsWhen = false, + stageCallback = 'how_progression:server:getCrowbarMission', + }, + progression = { stageColumn = 'crowbar_stage', repColumn = 'crowbar_rep' }, + missions = { 'crowbar:1', 'crowbar:2', 'crowbar:3', 'crowbar:4', 'crowbar:5' }, +} diff --git a/shared/contacts/deke.lua b/shared/contacts/deke.lua new file mode 100644 index 0000000..1f8736c --- /dev/null +++ b/shared/contacts/deke.lua @@ -0,0 +1,18 @@ +return { + id = 'deke', + displayName = 'Deke', + ped = { + model = 's_m_m_autoshop_02', + coords = { x = 2359.89, y = 3136.74, z = 48.21, w = 110.90 }, + spawnZOffset = -1.0, + scenario = 'WORLD_HUMAN_STAND_IMPATIENT', + invincible = true, frozen = true, blockNonTemporaryEvents = true, + }, + world = { alwaysPresent = true, spawnedWith = 'mack' }, + interaction = { + name = 'how_progression_deke_shop', icon = 'fa-solid fa-toolbox', + label = 'Talk to Deke', distance = 2.0, + inventory = { kind = 'shop', type = 'DekeTools', id = 1 }, + }, + missions = {}, +} diff --git a/shared/contacts/gabe.lua b/shared/contacts/gabe.lua new file mode 100644 index 0000000..63fe732 --- /dev/null +++ b/shared/contacts/gabe.lua @@ -0,0 +1,13 @@ +return { + id = 'gabe', + displayName = 'Gabe', + location = { x = 2340.96, y = 3126.11, z = 48.21 }, + blip = { + label = 'Money Laundering', sprite = 500, display = 4, + scale = 0.7, shortRange = true, + createdDuring = 'crowbar:4', + }, + service = { resource = 'randol_moneywash' }, + associatedMissions = { 'crowbar:4' }, + missions = {}, +} diff --git a/shared/contacts/init.lua b/shared/contacts/init.lua new file mode 100644 index 0000000..069b65d --- /dev/null +++ b/shared/contacts/init.lua @@ -0,0 +1,27 @@ +-- Passive definitions. Requiring this module does not create world entities. +local byId = { + unknown = require 'shared.contacts.unknown', + crowbar = require 'shared.contacts.crowbar', + mack = require 'shared.contacts.mack', + gabe = require 'shared.contacts.gabe', + deke = require 'shared.contacts.deke', + vinny = require 'shared.contacts.vinny', + cosmic_dave = require 'shared.contacts.cosmic_dave', +} + +local phoneOwners = {} +for id, contact in pairs(byId) do + assert(contact.id == id, 'contact ID mismatch: ' .. id) + local number = contact.phone and contact.phone.number + if number then + assert(number:match('^555%-%d%d%d%d$'), 'invalid contact number: ' .. id) + assert(not phoneOwners[number], 'duplicate contact number: ' .. number) + phoneOwners[number] = id + end +end + +return { + byId = byId, + get = function(id) return byId[id] end, + phoneOwner = function(number) return phoneOwners[number] end, +} diff --git a/shared/contacts/mack.lua b/shared/contacts/mack.lua new file mode 100644 index 0000000..5a64e09 --- /dev/null +++ b/shared/contacts/mack.lua @@ -0,0 +1,67 @@ +return { + id = 'mack', + displayName = 'Mack', + phone = { number = '555-6842' }, + ped = { + model = 's_m_y_xmech_01', + coords = { x = 2366.60, y = 3154.91, z = 48.21, w = 17.84 }, + spawnZOffset = -1.0, + scenario = 'WORLD_HUMAN_CLIPBOARD', + invincible = true, frozen = true, blockNonTemporaryEvents = true, + }, + world = { + alwaysPresent = true, + vehicles = { + { + id = 'festival_bus', model = 'pbus2', + coords = { x = 2349.54, y = 3133.84, z = 48.21, w = 77.73 }, + localEntity = true, locked = 2, missionEntity = true, + invincible = true, frozen = true, + }, + { + id = 'junk_emperor', model = 'emperor', + coords = { x = 2343.67, y = 3141.72, z = 48.21, w = 211.39 }, + localEntity = true, locked = 2, missionEntity = true, + invincible = true, frozen = true, + damage = { + engineHealth = 100.0, bodyHealth = 300.0, + smashedWindows = { 0, 1, 2, 3 }, brokenDoors = { 0 }, + burstTyres = { 0, 4 }, + }, + }, + { + id = 'forklift', model = 'forklift', + coords = { x = 2350.42, y = 3142.49, z = 48.21, w = 231.27 }, + localEntity = true, locked = 2, missionEntity = true, + invincible = true, frozen = true, + }, + }, + ambientPeds = { + { model = 's_m_y_xmech_02', coords = { x = 2355.0, y = 3148.0, z = 48.21, w = 180.0 }, wanderRadius = 12.0 }, + { model = 's_m_m_autoshop_01', coords = { x = 2345.0, y = 3150.0, z = 48.21, w = 90.0 }, wanderRadius = 12.0 }, + { model = 's_m_y_construct_01', coords = { x = 2360.0, y = 3143.0, z = 48.21, w = 270.0 }, wanderRadius = 15.0 }, + { model = 's_m_y_construct_02', coords = { x = 2341.0, y = 3130.0, z = 48.21, w = 0.0 }, wanderRadius = 10.0 }, + { model = 's_m_m_dockwork_01', coords = { x = 2334.0, y = 3125.0, z = 48.21, w = 180.0 }, wanderRadius = 15.0 }, + { model = 's_m_m_gardener_01', coords = { x = 2368.0, y = 3130.0, z = 48.21, w = 90.0 }, wanderRadius = 22.0 }, + }, + ambientPedBehavior = { + spawnZOffset = -1.0, missionEntity = true, invincible = true, + blockNonTemporaryEvents = true, canRagdoll = false, + fleeAttributes = 0, wanderMinLength = 2.0, wanderTimeBetweenWalks = 10.0, + }, + }, + blip = { + label = 'Mack', sprite = 280, scale = 0.75, shortRange = false, + requires = { column = 'mack_stage', min = 1 }, + createdDuring = 'crowbar:4', + }, + interaction = { + name = 'how_progression_mack_talk', icon = 'fa-solid fa-comment', + label = 'Talk to Mack', distance = 2.0, + gateCallback = 'how_progression:server:canTalkToMack', + gateAllowsWhen = true, + missionStateCallback = 'how_progression:server:getMackMission1State', + }, + progression = { stageColumn = 'mack_stage', repColumn = 'mack_rep' }, + missions = { 'mack:1' }, +} diff --git a/shared/contacts/unknown.lua b/shared/contacts/unknown.lua new file mode 100644 index 0000000..c75489c --- /dev/null +++ b/shared/contacts/unknown.lua @@ -0,0 +1,7 @@ +return { + id = 'unknown', + displayName = 'UNKNOWN', + phone = { number = '555-0100' }, + progression = { introductionFlag = 'unknown_introduced' }, + missions = {}, +} diff --git a/shared/contacts/vinny.lua b/shared/contacts/vinny.lua new file mode 100644 index 0000000..3565681 --- /dev/null +++ b/shared/contacts/vinny.lua @@ -0,0 +1,46 @@ +return { + id = 'vinny', + displayName = 'Vinny', + phone = { number = '555-3176' }, + ped = { + model = 'a_m_m_business_01', + coords = { x = 1362.57, y = 3602.49, z = 34.96, w = 236.75 }, + spawnZOffset = -1.0, + scenario = 'WORLD_HUMAN_STAND_IMPATIENT', + missionEntity = true, invincible = true, frozen = true, + blockNonTemporaryEvents = true, + }, + world = { + createdDuring = 'mack:1', + restoredWhen = { vinny_fence_unlocked = 1 }, + vehicles = { + { model = 'sanchez', coords = { x = 1368.39, y = 3594.54, z = 34.34, w = 237.54 } }, + { model = 'sanchez', coords = { x = 1366.95, y = 3598.24, z = 34.37, w = 244.92 } }, + { model = 'sanchez', coords = { x = 1368.76, y = 3592.05, z = 34.37, w = 241.99 } }, + { model = 'sanchez', coords = { x = 1357.67, y = 3601.45, z = 34.44, w = 194.85 } }, + { model = 'sanchez', coords = { x = 1364.94, y = 3603.77, z = 34.33, w = 195.30 } }, + }, + vehicleBehavior = { + localEntity = true, missionEntity = true, locked = 2, + invincible = true, frozen = true, + purchaseEvent = 'qbx_vehicleshop:server:buyShowroomVehicle', + }, + }, + blip = { + label = 'Vinny', sprite = 226, scale = 0.7, shortRange = true, + createdDuring = 'mack:1', restoredWhen = { vinny_fence_unlocked = 1 }, + }, + interaction = { + name = 'how_progression_vinny_talk', icon = 'fa-solid fa-comment', + label = 'Talk to Vinny', distance = 2.0, + requiresObjective = 'talk_vinny', + }, + fence = { + coords = { x = 1356.79, y = 3617.37, z = 34.88, w = 105.38 }, + requires = { vinny_fence_unlocked = 1 }, + vehicleModel = 'sanchez', dailyLimit = 3, + payoutItem = 'markedbills', payoutWorth = 200, + }, + associatedMissions = { 'mack:1' }, + missions = {}, +} diff --git a/shared/debug_contact.lua b/shared/debug_contact.lua new file mode 100644 index 0000000..27451a4 --- /dev/null +++ b/shared/debug_contact.lua @@ -0,0 +1,6 @@ +-- /testcontact's temporary NPWD fixture, not a permanent story contact. +-- Its 555 number is intentionally generated by the legacy debug command. +return { + displayName = 'Michael', + avatarUrl = 'https://fivemedia.howlerdesign.com/media/6801a2d6-659f-46e3-b0ee-028a9a4ac215.png', +} diff --git a/shared/locations.lua b/shared/locations.lua new file mode 100644 index 0000000..6557967 --- /dev/null +++ b/shared/locations.lua @@ -0,0 +1,28 @@ +-- Named mission locations copied from the live scripts. Legacy scripts still +-- own gameplay until each mission is migrated. +return { + crowbar = { + mission1Vehicle = { x = 911.40, y = 3589.50, z = 33.25, w = 91.36 }, + mission1Return = { x = 1705.50, y = 3771.76, z = 34.49 }, + mission2Vehicle = { x = 2684.30, y = 3292.18, z = 54.75, w = 240.63 }, + harmonyRegister = { x = 549.33, y = 2669.57, z = 42.16, storeIndex = 13 }, + mission5Package = { x = 1537.56, y = 1700.49, z = 109.68 }, + mission5CrateZ = 108.6397, + mission5Ambush = { + { model = 'rebel', spawn = { x = 1464.0, y = 1760.0, z = 108.0, w = 130.0 }, destination = { x = 1521.0, y = 1715.0, z = 109.0 } }, + { model = 'bodhi2', spawn = { x = 1448.0, y = 1774.0, z = 108.0, w = 130.0 }, destination = { x = 1513.0, y = 1723.0, z = 109.0 } }, + }, + }, + mack = { + easyRiderBikeSpawns = { + { x = 2360.90, y = 3114.91, z = 48.21, w = 239.14 }, + { x = 2360.28, y = 3113.93, z = 48.21, w = 245.54 }, + { x = 2359.64, y = 3112.59, z = 48.21, w = 265.43 }, + { x = 2359.45, y = 3111.15, z = 48.21, w = 246.34 }, + { x = 2361.34, y = 3117.44, z = 48.21, w = 234.06 }, + }, + easyRiderDetour = { x = 252.87, y = 2814.15, z = 43.19 }, + easyRiderTrashCar = { x = 285.10, y = 2827.15, z = 42.89, w = 116.30 }, + easyRiderVinnyDelivery = { x = 1373.86, y = 3615.83, z = 34.89 }, + }, +} diff --git a/shared/mission_ids.lua b/shared/mission_ids.lua new file mode 100644 index 0000000..5ab398a --- /dev/null +++ b/shared/mission_ids.lua @@ -0,0 +1,62 @@ +-- Mission IDs and debug phases are strings. Existing /prog still parses its +-- own arguments; this module is for future migration of that command. +local definitions = { + ['crowbar:1'] = { contact = 'crowbar', number = 1, name = 'The Introduction', phases = { 'retrieve_vehicle', 'return_vehicle', 'complete' } }, + ['crowbar:2'] = { contact = 'crowbar', number = 2, name = 'Get Some Wheels', phases = { 'steal_vehicle', 'complete' } }, + ['crowbar:3'] = { contact = 'crowbar', number = 3, name = 'Quick Score', phases = { 'rob_register', 'lose_cops', 'complete' } }, + ['crowbar:4'] = { contact = 'crowbar', number = 4, name = 'Wash the Cash', phases = { 'meet_mack', 'meet_gabe', 'wash_cash', 'complete' } }, + ['crowbar:5'] = { contact = 'crowbar', number = 5, name = 'Buy In', phases = { 'get_package', 'escape_ambush', 'return_package', 'complete' } }, + ['mack:1'] = { contact = 'mack', number = 1, name = 'Easy Rider', phases = {} }, +} + +local M = { definitions = definitions } + +function M.missionId(contact, number) + if type(contact) ~= 'string' or not contact:match('^[a-z][a-z_]*$') + or type(number) ~= 'number' or number % 1 ~= 0 + or number < 1 or number > 999 + then + return nil + end + + return ('%s:%d'):format(contact, number) +end + +function M.phaseId(missionId, phaseName) + if not definitions[missionId] or type(phaseName) ~= 'string' + or not phaseName:match('^[a-z][a-z_]*$') + then + return nil + end + + return missionId .. ':' .. phaseName +end + +function M.parseProgPhase(contact, notation) + if type(notation) ~= 'string' then return nil end + + local missionText, phaseText = notation:match('^(%d+)%.(%d+)$') + if not missionText then return nil end + if #missionText > 3 or #phaseText > 3 then return nil end + + local missionNumber, phaseNumber = tonumber(missionText), tonumber(phaseText) + if not missionNumber or not phaseNumber or missionNumber < 1 or phaseNumber < 1 then + return nil + end + + local id = M.missionId(contact, missionNumber) + local definition = definitions[id] + local phaseName = definition and definition.phases[phaseNumber] + if not phaseName then return nil end + + return { + missionId = id, + phaseId = M.phaseId(id, phaseName), + missionNumber = missionNumber, + phaseNumber = phaseNumber, + phaseName = phaseName, + notation = notation, + } +end + +return M