local propertyLocks = {} local characterPurchaseLocks = {} local playerLocation = {} local temporaryInvites = {} local directInvites = {} local pendingDoorbells = {} local configuredPropertyKeys = {} for _, building in ipairs(DevXHousing.Buildings) do for _, unit in ipairs(building.units) do configuredPropertyKeys[unit.key] = true end end local function findBuilding(key) for _, building in ipairs(DevXHousing.Buildings) do if building.key == key then return building end end return nil end local function findUnit(key) for _, building in ipairs(DevXHousing.Buildings) do for _, unit in ipairs(building.units) do if unit.key == key then return unit, building end end end return nil, nil end local function unitRow(propertyKey) return MySQL.single.await([[ SELECT p.id, p.property_key AS propertyKey, p.price, po.character_id AS ownerCharacterId, CONCAT(c.first_name, ' ', c.last_name) AS ownerName FROM devx_properties p LEFT JOIN devx_property_owners po ON po.property_id = p.id LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL WHERE p.property_key = ? LIMIT 1 ]], { propertyKey }) end local function ownedUnitRow(characterId) return MySQL.single.await([[ SELECT p.id, p.property_key AS propertyKey, p.label, p.price, po.character_id AS ownerCharacterId, CONCAT(c.first_name, ' ', c.last_name) AS ownerName FROM devx_property_owners po INNER JOIN devx_properties p ON p.id = po.property_id LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL WHERE po.character_id = ? AND p.property_type = 'apartment' ORDER BY po.purchased_at ASC LIMIT 1 ]], { characterId }) end local function floorBucket(building, floor) return DevXHousing.FloorBucketBase + (building.index * 100) + tonumber(floor or 0) end local function unitBucket(propertyId) return DevXHousing.UnitBucketBase + tonumber(propertyId) end local function setLocation(source, location, bucket) playerLocation[source] = location SetPlayerRoutingBucket(source, bucket or 0) if bucket and bucket > 0 then SetRoutingBucketPopulationEnabled(bucket, false) SetRoutingBucketEntityLockdownMode(bucket, 'relaxed') end end local function resetPlayer(source) playerLocation[source] = nil temporaryInvites[source] = nil directInvites[source] = nil pendingDoorbells[source] = nil SetPlayerRoutingBucket(source, 0) end local function syncProperties() for _, building in ipairs(DevXHousing.Buildings) do for _, unit in ipairs(building.units) do local interior = DevXHousing.Interiors[unit.style] MySQL.query.await([[ INSERT INTO devx_properties (property_key, label, price, property_type, entry_data, interior_data) VALUES (?, ?, ?, 'apartment', ?, ?) ON DUPLICATE KEY UPDATE label = VALUES(label), price = VALUES(price), entry_data = VALUES(entry_data), interior_data = VALUES(interior_data) ]], { unit.key, unit.label, unit.price, json.encode({ buildingKey = building.key, floor = unit.floor, unitNumber = unit.unitNumber, exterior = building.exterior }), json.encode(interior) }) end end -- Soft-deleted or otherwise missing characters must not keep a door sign. -- This also repairs ownership rows left behind by older test versions. MySQL.query.await([[ DELETE po FROM devx_property_owners po LEFT JOIN devx_characters c ON c.id = po.character_id WHERE c.id IS NULL OR c.deleted_at IS NOT NULL ]]) print('[devx_housing] Apartmentdaten v0.4.3 synchronisiert; verwaiste Eigentümer bereinigt.') end MySQL.ready(syncProperties) DevXRegisterCallback('housing:list', function(source) local player = exports.devx_core:GetPlayer(source) if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end local rows = MySQL.query.await([[ SELECT p.id, p.property_key AS propertyKey, p.label, p.price, po.character_id AS ownerCharacterId, CONCAT(c.first_name, ' ', c.last_name) AS ownerName FROM devx_properties p LEFT JOIN devx_property_owners po ON po.property_id = p.id LEFT JOIN devx_characters c ON c.id = po.character_id AND c.deleted_at IS NULL WHERE p.property_type = 'apartment' ORDER BY p.id ]]) or {} local filtered = {} for _, row in ipairs(rows) do if configuredPropertyKeys[row.propertyKey] then -- An ownership row without a valid active character is never exposed -- to the hallway UI as an occupied apartment. if row.ownerCharacterId and (not row.ownerName or row.ownerName == '') then MySQL.query.await('DELETE FROM devx_property_owners WHERE property_id = ?', { row.id }) row.ownerCharacterId = nil row.ownerName = nil end filtered[#filtered + 1] = row end end local owned = ownedUnitRow(player.characterId) if owned and not configuredPropertyKeys[owned.propertyKey] then owned = nil end return { ok = true, properties = filtered, characterId = player.characterId, ownedPropertyKey = owned and owned.propertyKey or nil, ownedPropertyLabel = owned and owned.label or nil } end) DevXRegisterCallback('housing:buy', function(source, payload) local player = exports.devx_core:GetPlayer(source) local unit = findUnit(payload.propertyKey) if not player or not unit then return { ok = false, error = 'Ungültige Wohnung.' } end local existing = ownedUnitRow(player.characterId) if existing then return { ok = false, error = ('Du besitzt bereits %s. Pro Charakter ist derzeit nur ein Apartment erlaubt.'):format(existing.label) } end if characterPurchaseLocks[player.characterId] then return { ok = false, error = 'Ein anderer Wohnungskauf wird bereits verarbeitet.' } end if propertyLocks[unit.key] then return { ok = false, error = 'Die Wohnung wird gerade bearbeitet.' } end characterPurchaseLocks[player.characterId] = true propertyLocks[unit.key] = true local ok, response = pcall(function() local row = unitRow(unit.key) if not row then return { ok = false, error = 'Wohnung fehlt in der Datenbank.' } end if row.ownerCharacterId then return { ok = false, error = 'Die Wohnung ist bereits verkauft.' } end local charged, chargeResult = exports.devx_banking:ChargeCharacter( player.characterId, tonumber(row.price), ('Kauf: %s'):format(unit.label), 'Immobilienverwaltung DevX-City' ) if not charged then return { ok = false, error = chargeResult } end local ownerId = MySQL.insert.await([[ INSERT INTO devx_property_owners (property_id, character_id, purchase_price) VALUES (?, ?, ?) ]], { row.id, player.characterId, row.price }) if not ownerId then exports.devx_banking:CreditCharacter( player.characterId, tonumber(row.price), ('Rückbuchung: %s'):format(unit.label), 'Immobilienverwaltung DevX-City' ) return { ok = false, error = 'Kauf wurde abgebrochen und zurückgebucht.' } end local overview = MySQL.single.await('SELECT balance FROM devx_bank_accounts WHERE id = ?', { player.accountId }) if overview then exports.devx_core:SetBankCache(source, tonumber(overview.balance)) end return { ok = true, propertyKey = unit.key } end) propertyLocks[unit.key] = nil characterPurchaseLocks[player.characterId] = nil if not ok then print(('[devx_housing] Kauf fehlgeschlagen: %s'):format(response)) return { ok = false, error = 'Interner Immobilienfehler.' } end return response end) DevXRegisterCallback('housing:previewStart', function(source, payload) local player = exports.devx_core:GetPlayer(source) local building = findBuilding(payload.buildingKey) if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end local owned = ownedUnitRow(player.characterId) if owned then return { ok = false, error = ('Du besitzt bereits %s.'):format(owned.label or 'ein Apartment') } end local bucket = DevXHousing.PreviewBucketBase + tonumber(source) setLocation(source, { type = 'preview', buildingKey = building.key }, bucket) return { ok = true } end) DevXRegisterCallback('housing:previewEnd', function(source) local location = playerLocation[source] if location and location.type == 'preview' then resetPlayer(source) end return { ok = true } end) DevXRegisterCallback('housing:enterLobby', function(source, payload) local player = exports.devx_core:GetPlayer(source) local building = findBuilding(payload.buildingKey) if not player or not building then return { ok = false, error = 'Ungültiges Gebäude.' } end setLocation(source, { type = 'lobby', buildingKey = building.key, floor = 0 }, floorBucket(building, 0)) return { ok = true, position = DevXHousing.FloorShell.spawn, building = building.key } end) DevXRegisterCallback('housing:goFloor', function(source, payload) local player = exports.devx_core:GetPlayer(source) local building = findBuilding(payload.buildingKey) local floor = tonumber(payload.floor) if not player or not building or not floor or floor < 1 or floor > building.floors then return { ok = false, error = 'Ungültige Etage.' } end setLocation(source, { type = 'floor', buildingKey = building.key, floor = floor }, floorBucket(building, floor)) return { ok = true, position = DevXHousing.FloorShell.spawn, floor = floor } end) DevXRegisterCallback('housing:leaveBuilding', function(source) local location = playerLocation[source] if not location then return { ok = false, error = 'Du bist in keinem Gebäude.' } end local building = findBuilding(location.buildingKey) if not building then return { ok = false, error = 'Gebäude nicht gefunden.' } end resetPlayer(source) return { ok = true, position = building.exterior } end) local function hasUnitAccess(source, row) local player = exports.devx_core:GetPlayer(source) if not player or not row then return false end if tonumber(row.ownerCharacterId) == tonumber(player.characterId) then return true end local invite = temporaryInvites[source] return invite and invite.propertyKey == row.propertyKey and invite.expiresAt >= os.time() end local function enterUnitFor(source, propertyKey) local unit, building = findUnit(propertyKey) local row = unit and unitRow(propertyKey) or nil if not unit or not building or not row or not row.ownerCharacterId then return { ok = false, error = 'Diese Wohnung ist nicht bewohnt.' } end if not hasUnitAccess(source, row) then return { ok = false, error = 'Du hast keine Einladung für diese Wohnung.' } end local interior = DevXHousing.Interiors[unit.style] setLocation(source, { type = 'unit', propertyKey = unit.key, propertyId = tonumber(row.id), buildingKey = building.key, floor = unit.floor, ownerCharacterId = tonumber(row.ownerCharacterId) }, unitBucket(row.id)) return { ok = true, interior = interior, propertyKey = unit.key, label = unit.label, ownerName = row.ownerName, floor = unit.floor, style = unit.style, interactions = interior.interactions or {} } end DevXRegisterCallback('housing:enterUnit', function(source, payload) return enterUnitFor(source, payload.propertyKey) end) DevXRegisterCallback('housing:enterOwned', function(source, payload) local player = exports.devx_core:GetPlayer(source) if not player then return { ok = false, error = 'Kein Charakter aktiv.' } end local owned = ownedUnitRow(player.characterId) if not owned then return { ok = false, error = 'Du besitzt noch kein Apartment.' } end local unit, building = findUnit(owned.propertyKey) if not unit or not building then return { ok = false, error = 'Dein Apartment ist nicht mehr konfiguriert.' } end if payload and payload.buildingKey and payload.buildingKey ~= building.key then return { ok = false, error = ('Dein Apartment befindet sich in %s.'):format(building.label) } end return enterUnitFor(source, owned.propertyKey) end) DevXRegisterCallback('housing:exitUnit', function(source) local location = playerLocation[source] if not location or location.type ~= 'unit' then return { ok = false, error = 'Du bist in keiner Wohnung.' } end local building = findBuilding(location.buildingKey) setLocation(source, { type = 'floor', buildingKey = location.buildingKey, floor = location.floor }, floorBucket(building, location.floor)) return { ok = true, position = DevXHousing.FloorShell.spawn, floor = location.floor } end) local function activeSourceForCharacter(characterId) for source, player in pairs(exports.devx_core:GetActivePlayers()) do if tonumber(player.characterId) == tonumber(characterId) then return tonumber(source) end end return nil end DevXRegisterCallback('housing:ring', function(source, payload) local visitor = exports.devx_core:GetPlayer(source) local row = unitRow(payload.propertyKey) local unit = findUnit(payload.propertyKey) if not visitor or not row or not row.ownerCharacterId or not unit then return { ok = false, error = 'Hier wohnt niemand.' } end local ownerSource = activeSourceForCharacter(row.ownerCharacterId) if not ownerSource then return { ok = false, error = 'Der Bewohner ist derzeit nicht online.' } end pendingDoorbells[ownerSource] = { visitorSource = source, visitorName = visitor.fullName, propertyKey = payload.propertyKey, expiresAt = os.time() + 30 } TriggerClientEvent('devx_housing:client:doorbell', ownerSource, { visitorSource = source, visitorName = visitor.fullName, propertyKey = payload.propertyKey, label = unit.label }) return { ok = true, message = 'Du hast geklingelt.' } end) DevXRegisterCallback('housing:knock', function(source, payload) local visitor = exports.devx_core:GetPlayer(source) local row = unitRow(payload.propertyKey) if not visitor or not row or not row.ownerCharacterId then return { ok = false, error = 'Hier wohnt niemand.' } end for target, location in pairs(playerLocation) do if location.type == 'unit' and location.propertyKey == payload.propertyKey then TriggerClientEvent('devx_housing:client:knock', target, visitor.fullName) end end return { ok = true, message = 'Du hast angeklopft.' } end) DevXRegisterCallback('housing:acceptVisitor', function(source) local owner = exports.devx_core:GetPlayer(source) local location = playerLocation[source] local bell = pendingDoorbells[source] if not owner or not location or location.type ~= 'unit' or not bell or bell.expiresAt < os.time() then return { ok = false, error = 'Keine aktuelle Klingelanfrage.' } end if bell.propertyKey ~= location.propertyKey then return { ok = false, error = 'Die Anfrage gehört zu einer anderen Wohnung.' } end temporaryInvites[bell.visitorSource] = { propertyKey = bell.propertyKey, expiresAt = os.time() + DevXHousing.InvitationSeconds } pendingDoorbells[source] = nil local result = enterUnitFor(bell.visitorSource, bell.propertyKey) if result.ok then TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result) TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName) end return result end) DevXRegisterCallback('housing:inviteCandidates', function(source) local owner = exports.devx_core:GetPlayer(source) local location = playerLocation[source] if not owner or not location or location.type ~= 'unit' then return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' } end if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then return { ok = false, error = 'Nur der Eigentümer kann Spieler einladen.' } end local players = {} for playerSource, player in pairs(exports.devx_core:GetActivePlayers()) do local numericSource = tonumber(playerSource) if numericSource and numericSource ~= tonumber(source) then players[#players + 1] = { source = numericSource, name = player.fullName or GetPlayerName(numericSource) or ('Spieler %s'):format(numericSource) } end end table.sort(players, function(a, b) return a.source < b.source end) return { ok = true, players = players } end) DevXRegisterCallback('housing:invite', function(source, payload) local owner = exports.devx_core:GetPlayer(source) local target = tonumber(payload.target) local location = playerLocation[source] if not owner or not location or location.type ~= 'unit' then return { ok = false, error = 'Du musst dich in deiner Wohnung befinden.' } end if tonumber(location.ownerCharacterId) ~= tonumber(owner.characterId) then return { ok = false, error = 'Nur der Eigentümer kann Gäste einladen.' } end if not target or not exports.devx_core:GetPlayer(target) then return { ok = false, error = 'Spieler nicht gefunden.' } end if target == tonumber(source) then return { ok = false, error = 'Du kannst dich nicht selbst einladen.' } end local unit = findUnit(location.propertyKey) directInvites[target] = { propertyKey = location.propertyKey, invitedBy = owner.fullName, expiresAt = os.time() + DevXHousing.InvitationSeconds } TriggerClientEvent('devx_housing:client:invite', target, { propertyKey = location.propertyKey, invitedBy = owner.fullName, label = unit and unit.label or 'Apartment', expiresIn = DevXHousing.InvitationSeconds }) return { ok = true } end) DevXRegisterCallback('housing:acceptInvite', function(source) local invite = directInvites[source] if not invite or invite.expiresAt < os.time() then directInvites[source] = nil return { ok = false, error = 'Du hast keine gültige Apartment-Einladung.' } end temporaryInvites[source] = { propertyKey = invite.propertyKey, expiresAt = invite.expiresAt } directInvites[source] = nil return enterUnitFor(source, invite.propertyKey) end) DevXRegisterCallback('housing:declineInvite', function(source) directInvites[source] = nil return { ok = true } end) DevXRegisterCallback('housing:declineVisitor', function(source) pendingDoorbells[source] = nil return { ok = true } end) -- /inviteapartment is handled client-side so it can open the player picker. RegisterCommand('acceptapartment', function(source) if source == 0 then return end local invite = directInvites[source] if not invite or invite.expiresAt < os.time() then directInvites[source] = nil TriggerClientEvent('devx_housing:client:notify', source, 'Keine gültige Apartment-Einladung.') return end temporaryInvites[source] = { propertyKey = invite.propertyKey, expiresAt = invite.expiresAt } directInvites[source] = nil local result = enterUnitFor(source, invite.propertyKey) if result.ok then TriggerClientEvent('devx_housing:client:enterGranted', source, result) else TriggerClientEvent('devx_housing:client:notify', source, result.error) end end, false) RegisterCommand('acceptvisitor', function(source) if source == 0 then return end local bell = pendingDoorbells[source] local location = playerLocation[source] if not bell or bell.expiresAt < os.time() or not location or location.type ~= 'unit' then TriggerClientEvent('devx_housing:client:notify', source, 'Keine aktuelle Klingelanfrage.') return end temporaryInvites[bell.visitorSource] = { propertyKey = bell.propertyKey, expiresAt = os.time() + DevXHousing.InvitationSeconds } pendingDoorbells[source] = nil local result = enterUnitFor(bell.visitorSource, bell.propertyKey) if result.ok then TriggerClientEvent('devx_housing:client:enterGranted', bell.visitorSource, result) TriggerClientEvent('devx_housing:client:visitorAccepted', source, bell.visitorName) end end, false) exports('GetPlayerHousingLocation', function(source) local location = playerLocation[tonumber(source)] if not location then return nil end local copy = {} for key, value in pairs(location) do copy[key] = value end if location.propertyKey then local unit = findUnit(location.propertyKey) if unit then copy.style = unit.style copy.interior = DevXHousing.Interiors[unit.style] end end return copy end) exports('GetOwnedApartment', function(characterId) return ownedUnitRow(tonumber(characterId)) end) AddEventHandler('playerDropped', function() resetPlayer(source) end) AddEventHandler('onResourceStart', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end end) AddEventHandler('onResourceStop', function(resourceName) if resourceName ~= GetCurrentResourceName() then return end for _, playerId in ipairs(GetPlayers()) do SetPlayerRoutingBucket(tonumber(playerId), 0) end end)