No description
  • JavaScript 70.4%
  • Lua 15.9%
  • MDX 4.8%
  • Vue 4.2%
  • TypeScript 1.7%
  • Other 2.9%
Find a file
Nils van Lück fd17109f45
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
feat: add Forgejo Actions workflow for auto-deploying via SFTP
Builds NUI frontends, rsync's resources and configs to production,
then hot-reloads via RCON without full server restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 02:58:00 +01:00
.claude Implementieren des neuen Inventar- und Waffensystems 2026-03-16 02:28:15 +01:00
.forgejo/workflows feat: add Forgejo Actions workflow for auto-deploying via SFTP 2026-03-16 02:58:00 +01:00
.vscode update FXServer launch configuration to use PowerShell script 2026-03-08 02:23:58 +01:00
concept_docs Implementieren des neuen Inventar- und Waffensystems 2026-03-16 02:28:15 +01:00
concepts feat: add hairstyle selection for hat compatibility 2026-03-15 01:11:28 +01:00
scripts fix: fix ymap 2026-03-16 00:39:23 +01:00
server Implementieren des neuen Inventar- und Waffensystems 2026-03-16 02:28:15 +01:00
.gitattributes feat: update .gitattributes to include additional file types for LFS 2026-03-14 23:18:12 +01:00
.gitignore Add SVG logos for Node, PHP, Python, and Ruby; implement remToPx utility; enhance MDX processing with rehype and remark plugins; create search functionality for MDX sections; configure Tailwind CSS typography and styles; set up TypeScript configuration and types. 2026-03-15 22:17:59 +01:00
COMMANDS.md add claude instructions 2026-03-10 22:04:43 +01:00
CONCEPT_INVENTORY.md feat: implement comprehensive inventory system expansion (30 features) 2026-03-14 03:21:34 +01:00
CONCEPT_VIITALS.md add claude instructions 2026-03-10 22:04:43 +01:00
docker-compose.yaml fix: add MARIADB_DATABASE environment variable to mariadb service 2026-03-14 00:48:08 +01:00
inv_todo.md feat: implement comprehensive inventory system expansion (30 features) 2026-03-14 03:21:34 +01:00
INVENTORY_GUIDE.md add claude instructions 2026-03-10 22:04:43 +01:00
oldclothes.json feat(migrations): add decay_clothes table migration 2026-03-14 23:27:28 +01:00
olditems.json Refactor code structure for improved readability and maintainability 2026-03-14 21:55:56 +01:00
README.md add claude instructions 2026-03-10 22:04:43 +01:00
VISION.md init frontend changes 2026-03-07 20:06:55 +01:00
zones.md Add detailed zone descriptions for gameplay mechanics and RP relevance 2026-03-16 00:37:58 +01:00

Project Decay

Setup

  1. Run the scripts/Install-Server.ps1 script. It downloads the latest server files into the server-data directory.
  2. Run the server using the scripts/Run-Server.ps1 script.
  3. In the txAdmin setup wizard, select "Existing server" and paste in the absolute path of the server directory.

decay-db — Datenbank-Handler

Alle Datenbank-Operationen laufen ueber decay-db. Das betrifft Migrations, Seeders, den Query Builder, Models und Schema-Introspection. ox_core's eigene SQL-Ausfuehrung ist deaktiviert — die Tabellen werden ausschliesslich durch decay-db verwaltet.

Boot-Reihenfolge (server.cfg)

ensure ox_lib
ensure oxmysql
ensure decay-db          # VOR ox_core — blockiert abhaengige Resources bis Migrations fertig
ensure ox_core
ensure ox_target
ensure [decay]

decay-db blockiert ox_core und ox_target automatisch per onResourceStarting, fuehrt alle Migrations und Seeders aus und startet die blockierten Resources danach.


Eigene Resource anbinden

fxmanifest.lua:

fx_version 'bodacious'
game 'gta5'
lua54 'yes'

author 'ProjectDecay'
description 'Meine Resource'
version '1.0.0'

shared_script '@ox_lib/init.lua'

server_script {
    '@oxmysql/lib/MySQL.lua',
    '@decay-db/lib/init.lua',       -- decay-db Library
    'migrations/*.lua',              -- Migrations automatisch laden
    'seeders/*.lua',                 -- Seeders automatisch laden
    'Server/s_main.lua',
}

Server/s_main.lua:

CreateThread(function()
    db.awaitReady()       -- Wartet bis decay-db fertig ist
    db.runMigrations()    -- Fuehrt ausstehende Migrations aus
    db.runSeeders()       -- Fuehrt ausstehende Seeders aus

    print('[meine-resource] Bereit')
end)

Migration erstellen

Datei: migrations/001_create_meine_tabelle.lua

db.defineMigration('001', 'create_meine_tabelle', {
    up = function(schema)
        schema.create('meine_tabelle', function(t)
            t:increments('id')
            t:unsignedInt('char_id'):notNull()
            t:string('name', 50):notNull()
            t:json('data'):default('{}')
            t:timestamps()

            t:index('char_id')
            t:foreign('char_id'):references('characters', 'charId')
                :onDelete('CASCADE'):onUpdate('CASCADE')
        end)
    end,

    down = function(schema)
        schema.dropIfExists('meine_tabelle')
    end
})

Column-Typen

Methode SQL
t:increments(name) INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
t:bigIncrements(name) BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
t:int(name) INT
t:unsignedInt(name) INT UNSIGNED
t:tinyInt(name) TINYINT
t:unsignedTinyInt(name) TINYINT UNSIGNED
t:smallInt(name) SMALLINT
t:bigInt(name) BIGINT
t:float(name) FLOAT
t:double(name) DOUBLE
t:decimal(name, p, s) DECIMAL(p,s)
t:string(name, len) VARCHAR(len)
t:char(name, len) CHAR(len)
t:text(name) TEXT
t:mediumText(name) MEDIUMTEXT
t:longText(name) LONGTEXT
t:boolean(name) TINYINT(1)
t:json(name) JSON
t:timestamp(name) TIMESTAMP
t:date(name) DATE
t:dateTime(name) DATETIME
t:enum(name, values) ENUM(...)
t:blob(name) BLOB
t:timestamps() created_at + updated_at
t:softDeletes() deleted_at TIMESTAMP NULL

Column Modifiers

:notNull()                    -- NOT NULL
:nullable()                   -- NULL (default)
:default(value)               -- DEFAULT value
:defaultCurrent()             -- DEFAULT CURRENT_TIMESTAMP
:defaultCurrentOnUpdate()     -- DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
:unique()                     -- UNIQUE
:after('spalte')              -- AFTER spalte (fuer ALTER)
:check('expr')                -- CHECK (expr)
:comment('text')              -- COMMENT 'text'

Index & Foreign Key

t:index('col1', 'col2')           -- INDEX
t:uniqueIndex('col1')             -- UNIQUE INDEX
t:fullTextIndex('col1')           -- FULLTEXT INDEX
t:compositeKey('col1', 'col2')    -- Zusammengesetzter Primary Key

t:foreign('col'):references('tabelle', 'spalte')
    :onDelete('CASCADE'):onUpdate('CASCADE')

ALTER TABLE

db.defineMigration('002', 'add_rarity', {
    up = function(schema)
        schema.alter('player_items', function(t)
            t:addColumn('rarity', 'VARCHAR(20)'):default('common'):after('item_name')
            t:addIndex('rarity')
        end)
    end,
    down = function(schema)
        schema.alter('player_items', function(t)
            t:dropIndex('idx_player_items_rarity')
            t:dropColumn('rarity')
        end)
    end
})

Raw SQL (fuer MariaDB-spezifisches)

db.defineMigration('003', 'special_feature', {
    up = function(schema)
        schema.raw([[
            CREATE TABLE IF NOT EXISTS `meine_tabelle` (
                -- MariaDB-spezifisches SQL hier
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
        ]])
    end,
    down = function(schema)
        schema.dropIfExists('meine_tabelle')
    end
})

Query Builder

SELECT

-- Alle Zeilen
db.table('player_items'):where('char_id', charId):get()

-- Einzelne Zeile
db.table('player_items'):where('id', itemId):first()

-- Einzelner Wert
db.table('player_items'):where('id', itemId):value('durability')

-- Mit Spalten
db.table('player_items'):select('id', 'item_name', 'amount'):get()

-- Distinct
db.table('player_items'):select('item_name'):distinct():get()

WHERE

:where('col', value)                            -- col = value
:where('col', '>', value)                       -- col > value
:orWhere('col', value)                          -- OR col = value
:whereIn('col', { 'a', 'b', 'c' })             -- col IN (...)
:whereNotIn('col', { 'a', 'b' })               -- col NOT IN (...)
:whereNull('col')                               -- col IS NULL
:whereNotNull('col')                            -- col IS NOT NULL
:whereBetween('col', 1, 10)                     -- col BETWEEN 1 AND 10
:whereRaw('JSON_EXTRACT(data, "$.x") > ?', {5})  -- Raw SQL

-- Gruppiert (Klammern)
:where(function(q)
    q:where('status', 'active')
    q:orWhere('status', 'pending')
end)
-- => (status = 'active' OR status = 'pending')

INSERT

-- Einzeln (gibt Insert-ID zurueck)
local id = db.table('player_items'):insert({
    char_id = charId,
    item_name = 'bandage',
    amount = 3,
})

-- Mehrere
db.table('player_items'):insertMany({
    { char_id = charId, item_name = 'bandage', amount = 1 },
    { char_id = charId, item_name = 'water',   amount = 2 },
})

-- Upsert (INSERT ... ON DUPLICATE KEY UPDATE)
db.table('stats'):upsert(
    { char_id = charId, stat_name = 'kills', value = 1 },
    { 'char_id', 'stat_name' },
    { value = 'value + 1' }
)

UPDATE / DELETE

-- Update (gibt affected rows zurueck)
db.table('player_items'):where('id', itemId):update({ durability = 50 })

-- Increment / Decrement
db.table('player_items'):where('id', itemId):increment('amount', 5)
db.table('player_items'):where('id', itemId):decrement('durability', 10)

-- Delete (WHERE ist Pflicht!)
db.table('player_items'):where('id', itemId):delete()

-- Truncate
db.table('player_items'):truncate()

JOINS

db.table('player_items AS pi')
    :join('crafting_recipes AS cr', 'cr.name', '=', 'pi.item_name')
    :leftJoin('loot_tables AS lt', 'lt.item', '=', 'pi.item_name')
    :select('pi.*', 'cr.category')
    :get()

Aggregationen

db.table('player_items'):where('char_id', charId):count()
db.table('player_items'):where('char_id', charId):sum('amount')
db.table('player_items'):where('char_id', charId):avg('durability')
db.table('player_items'):where('char_id', charId):max('durability')
db.table('player_items'):where('char_id', charId):min('durability')
db.table('player_items'):where('char_id', charId):exists()

Pagination

local result = db.table('player_items')
    :where('char_id', charId)
    :orderBy('created_at', 'DESC')
    :paginate(1, 20)

-- result.data      = { ... }
-- result.total     = 150
-- result.page      = 1
-- result.perPage   = 20
-- result.lastPage  = 8

Transactions

db.transaction(function(tx)
    tx:table('accounts'):where('id', senderId):decrement('balance', amount)
    tx:table('accounts'):where('id', receiverId):increment('balance', amount)
    tx:table('accounts_transactions'):insert({
        actorId = charId,
        fromId = senderId,
        toId = receiverId,
        amount = amount,
        message = 'Transfer',
    })
end)

Async (Callback-Variante)

db.table('player_items'):where('char_id', charId):getAsync(function(items) ... end)
db.table('player_items'):where('id', itemId):firstAsync(function(item) ... end)
db.table('player_items'):where('char_id', charId):countAsync(function(count) ... end)
db.table('player_items'):insertAsync({ ... }, function(id) ... end)
db.table('player_items'):where('id', id):updateAsync({ ... }, function(affected) ... end)
db.table('player_items'):where('id', id):deleteAsync(function(affected) ... end)

Raw Queries

db.raw('SELECT * FROM player_items WHERE MATCH(item_name) AGAINST(? IN BOOLEAN MODE)', { term })
db.rawAsync('SELECT COUNT(*) FROM player_items', {}, function(result) ... end)

Debug

local sql, params = db.table('player_items'):where('char_id', 1):toSQL()
print(sql)    -- SELECT * FROM `player_items` WHERE `char_id` = ?
print(params) -- { 1 }

Model / Repository

local PlayerItem = db.defineModel('player_items', {
    primaryKey = 'id',
    timestamps = true,
    softDeletes = false,

    fillable = { 'char_id', 'item_name', 'amount', 'durability', 'metadata' },
    hidden = {},
    defaults = { amount = 1, durability = 100.0 },

    casts = {
        metadata = 'json',
        durability = 'float',
        amount = 'integer',
    },

    validate = {
        char_id   = { type = 'number', required = true, min = 1 },
        item_name = { type = 'string', required = true, maxLen = 50 },
        amount    = { type = 'number', min = 1, max = 9999 },
    },

    scopes = {
        forCharacter = function(q, charId) return q:where('char_id', charId) end,
        usable = function(q) return q:where('durability', '>', 0) end,
    },
})

Model-Nutzung

-- Find
local item = PlayerItem:find(42)
local item = PlayerItem:findOrFail(42)

-- Create (mit Validierung)
local item, err = PlayerItem:create({ char_id = 1, item_name = 'bandage', amount = 3 })
if err then print('Fehler: ' .. err) end

-- Update via Instance
item.durability = 50
item:save()

-- Delete
item:destroy()

-- Alle abrufen
local all = PlayerItem:all()

-- Scopes
local items = PlayerItem:scope('forCharacter', charId):scope('usable'):get()

-- Direkter Query Builder
PlayerItem:query():where('rarity', 'military'):count()

-- Hooks
PlayerItem:hook('beforeCreate', function(data) return data end)
PlayerItem:hook('afterCreate', function(data, id) end)
PlayerItem:hook('beforeUpdate', function(data, pk) return data end)
PlayerItem:hook('afterUpdate', function(data, pk) end)
PlayerItem:hook('beforeDelete', function(instance) end)
PlayerItem:hook('afterDelete', function(instance) end)

-- JSON Export
print(item:toJSON())

Seeder

-- seeders/seed_items.lua
db.defineSeeder('basis_items', {
    table = 'items',
    runWhen = 'empty',     -- 'empty' | 'once' | 'always'
    data = {
        { name = 'Bandage', weight = 0.2 },
        { name = 'Wasser',  weight = 0.5 },
    }
})

-- Mit Custom Funktion
db.defineSeeder('komplexe_daten', {
    runWhen = 'once',
    fn = function()
        db.table('config'):insert({ key = 'version', value = '1.0' })
    end
})

Schema Introspection

-- Tabellen auflisten
local tables = db.listTables()

-- Tabelle existiert?
db.tableExists('player_items')
db.columnExists('player_items', 'durability')

-- Tabelle beschreiben
local columns = db.describeTable('player_items')

-- Vollstaendige Info (Columns, Indexes, CREATE Statement)
local info = db.introspect('player_items')

-- Foreign Keys
local fks = db.getForeignKeys('player_items')

-- Tabellengroesse
local size = db.getTableSize('player_items')
-- size.rows, size.data_kb, size.index_kb, size.total_kb

Console Commands

Command Beschreibung
decay-db:status [resource] Migration-Status anzeigen
decay-db:migrate <resource> Ausstehende Migrations ausfuehren
decay-db:rollback <resource> [steps] Migrations zurueckrollen
decay-db:fresh <resource> Alle Migrations zurueck + neu
decay-db:seed <resource> Seeders ausfuehren
decay-db:tables Alle Tabellen mit Groesse
decay-db:describe <tabelle> Tabelle detailliert anzeigen
decay-db:generate <tabelle> Migration aus bestehender Tabelle generieren
decay-db:dump <tabelle> CREATE TABLE Statement ausgeben

Hooks & Debug

-- Slow Query Warnung (Default: 150ms)
db.onSlowQuery(100, function(sql, params, duration)
    print(('[SLOW %dms] %s'):format(duration, sql))
end)

-- Globale Query-Hooks
db.hook('beforeQuery', function(sql, params) end)
db.hook('afterQuery', function(sql, params, duration, result) end)

-- Debug-Modus (alle Queries loggen)
-- In server.cfg: set decay_db_debug "true"

Praxisbeispiel: Komplette Resource

-- ═══════════════════════════════════════════════
-- Server/s_main.lua
-- ═══════════════════════════════════════════════

-- Model definieren
local PlayerItem = db.defineModel('player_items', {
    fillable = { 'char_id', 'item_name', 'amount', 'durability', 'metadata' },
    defaults = { amount = 1, durability = 100.0 },
    casts    = { metadata = 'json', durability = 'float', amount = 'integer' },
    validate = {
        char_id   = { type = 'number', required = true, min = 1 },
        item_name = { type = 'string', required = true, maxLen = 50 },
        amount    = { type = 'number', min = 1, max = 9999 },
    },
    scopes = {
        forCharacter = function(q, charId) return q:where('char_id', charId) end,
        usable       = function(q) return q:where('durability', '>', 0) end,
    },
})

-- Startup
CreateThread(function()
    db.awaitReady()
    db.runMigrations()
    db.runSeeders()
    print('[meine-resource] Bereit')
end)

-- Callback: Items abrufen
lib.callback.register('inventory:getItems', function(source)
    local player = exports.ox_core:GetPlayer(source)
    if not player then return {} end

    return PlayerItem:scope('forCharacter', player.charId)
        :scope('usable')
        :orderBy('slot', 'ASC')
        :get()
end)

-- Event: Item bewegen
RegisterNetEvent('inventory:moveItem')
AddEventHandler('inventory:moveItem', function(data)
    local src = source
    if not data or type(data) ~= 'table' then return end

    local player = exports.ox_core:GetPlayer(src)
    if not player then return end

    local item = PlayerItem:find(data.itemId)
    if not item or item.char_id ~= player.charId then return end

    item.slot = data.targetSlot
    item:save()
end)

-- Transaction: Sicherer Item-Transfer
local function transferItem(fromCharId, toCharId, itemId, amount)
    return db.transaction(function(tx)
        tx:table('player_items')
            :where('id', itemId)
            :where('char_id', fromCharId)
            :decrement('amount', amount)

        tx:table('player_items'):insert({
            char_id = toCharId,
            item_name = 'transferred_item',
            amount = amount,
        })
    end)
end