Files
2026-08-05 01:29:39 +02:00

567 lines
22 KiB
Lua

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)