Een metin2 marktsysteem is het cash shop / item mall-mechanisme waarmee de premiumvaluta die een speler met echt geld (of donatiepunten) koopt, in het spel kan worden uitgegeven. Het moeilijke is niet één functie, maar het veilig verbinden van drie aparte werelden: de betaalprovider op de website, de MySQL-database en de game core. In deze gids leg ik uit hoe een coin reist van de portemonnee tot in de inventory van de speler, en waar het mis kan gaan, op basis van de architectuur die ik op mijn eigen server gebruik.
De drie lagen van het systeem
Zie een marktsysteem niet als één script; er zijn drie lagen die met elkaar praten:
- Web-betaallaag: de speler koopt een pakket op de site en de provider (PayPal, Stripe, PaySafeCard, mobiel betalen) meldt het resultaat via een
callback. Deze laag schrijft coins bij op het account. - Databaselaag: het coin-saldo staat in de
account-database. Aankopen en coin-bewegingen worden naar aparte logtabellen geschreven. - In-game item mall: de speler opent de shop, koopt items met coins en het item wordt veilig in zijn inventory geleverd.
Deze drie lagen rechtstreeks aan elkaar koppelen (bijvoorbeeld het web items in de tabel player.item laten schrijven) is de meest voorkomende fatale fout in Metin2 — straks leg ik uit waarom.
Databaseschema
Laten we eerst de persistentielaag bouwen. We voegen het coin-saldo toe aan de accounttabel en maken een transactielog zodat elke beweging controleerbaar is. In de account-database:
ALTER TABLE account.account
ADD COLUMN `coins` BIGINT UNSIGNED NOT NULL DEFAULT 0;
CREATE TABLE account.coin_log (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` INT UNSIGNED NOT NULL,
`delta` BIGINT NOT NULL, -- + opwaardering, - besteding
`balance_after` BIGINT UNSIGNED NOT NULL,
`reason` VARCHAR(32) NOT NULL, -- 'payment','mall_buy','refund'
`ref` VARCHAR(64) NOT NULL, -- provider tx-id / item-vnum
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_payment` (`reason`,`ref`),
KEY `account_id` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
De samengestelde unieke sleutel uniq_payment is cruciaal: zelfs als dezelfde betaalmelding twee keer binnenkomt, kunnen coins niet twee keer worden bijgeschreven. Dit heet idempotentie en het is de ruggengraat van elke betaalintegratie.
Web-betaalintegratie
Wanneer een aankoop is voltooid, stuurt de provider een HTTP-verzoek (webhook/callback) naar je server. De regels zijn duidelijk: vertrouw nooit een "succes"-parameter die van de browser komt; verifieer altijd de handtekening/IPN van de provider aan de serverkant. Een typische PHP callback-handler ziet er zo uit:
<?php
// 1) Verifieer de handtekening van de provider (voorbeeld: HMAC)
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, PROVIDER_SECRET);
if (!hash_equals($expected, $sig)) {
http_response_code(403);
exit('invalid signature');
}
$data = json_decode($payload, true);
$txid = $data['transaction_id'];
$status = $data['status'];
$amount = (int) $data['coins']; // coins per pakket
$login = $data['account_login'];
if ($status !== 'completed' || $amount <= 0) {
exit('ignored');
}
// 2) Idempotente bijschrijving — binnen één transactie
$pdo->beginTransaction();
try {
$acc = $pdo->prepare('SELECT id FROM account WHERE login = ? FOR UPDATE');
$acc->execute([$login]);
$accountId = $acc->fetchColumn();
if (!$accountId) throw new Exception('account not found');
// dankzij UNIQUE(reason,ref) geeft een herhaalde txid een fout
$log = $pdo->prepare(
'INSERT INTO coin_log (account_id, delta, balance_after, reason, ref, created_at)
SELECT ?, ?, coins + ?, "payment", ?, NOW() FROM account WHERE id = ?');
$log->execute([$accountId, $amount, $amount, $txid, $accountId]);
$pdo->prepare('UPDATE account SET coins = coins + ? WHERE id = ?')
->execute([$amount, $accountId]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
// 23000 = duplicate key → betaling al verwerkt, geen probleem
if ($e->getCode() !== '23000') { http_response_code(500); }
}
echo 'OK';
De rij-lock FOR UPDATE zet twee gelijktijdige meldingen voor hetzelfde account in de wachtrij; de UNIQUE-constraint blokkeert dubbel bijschrijven. Samen verslaan deze twee mechanismen race conditions en replay-aanvallen.
De in-game item mall en de "DB cache"-val
Nu het meest kritieke deel. Zolang een speler online is, houdt de Metin2 game core zijn inventory in het geheugen (in de DB cache-laag). Als het web een item rechtstreeks in de tabel player.item schrijft, overschrijft de game core dit bij het uitloggen met zijn eigen geheugenkopie en is je item weg. Daarom moet de levering van coins/items altijd via de game core lopen.
Het veilige patroon is een leveringswachtrij: het web schrijft alleen een record "geef dit account dit item" naar een tabel; het spel verwerkt dat record terwijl de speler online is en levert het item via de officiële API.
CREATE TABLE player.item_award (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` INT UNSIGNED NOT NULL,
`item_vnum` INT UNSIGNED NOT NULL,
`count` INT UNSIGNED NOT NULL DEFAULT 1,
`claimed` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `claimed` (`account_id`, `claimed`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
Wanneer de speler coins uitgeeft in de shop (dit kan via een item shop-venster op sourceniveau of via een quest-NPC), gebeuren er twee dingen: account.coins wordt verlaagd en er wordt een rij in item_award ingevoegd. De levering wordt vervolgens aan de questkant afgehandeld met een periodieke controle of een login-event:
quest cash_delivery begin
state start begin
when login begin
-- controleer openstaande beloningen bij elke login (voorbeeldlogica)
local rows = mysql_direct_query(
"SELECT id, item_vnum, count FROM player.item_award "..
"WHERE account_id = "..pc.get_account_id()..
" AND claimed = 0 LIMIT 10")
for i, row in ipairs(rows) do
pc.give_item2(tonumber(row.item_vnum), tonumber(row.count))
mysql_direct_query(
"UPDATE player.item_award SET claimed = 1 WHERE id = "..row.id)
end
end
end
end
Hier plaatst pc.give_item2 het item in de echte inventory van de speler (gesynchroniseerd tussen geheugen en DB); als de inventory vol is, kun je het met de standaardlogica in de mailbox laten vallen. Verlaag de coins en schrijf de item_award-rij binnen één MySQL-transactie, zodat een onderbroken aankoop niet de coins afpakt zonder het item te geven, en ook niet andersom.
Beveiliging en fraudepreventie
Zodra er geld in het spel is, is het marktsysteem je aanvalsoppervlak. Onderhandel niet over deze punten:
- Validatie aan serverkant: coins worden altijd geactiveerd door de geverifieerde callback van de provider — geen door de client geleverde data verleent op zichzelf autorisatie.
- Idempotentie: herhaalde meldingen (providers sturen bij netwerkfouten dezelfde webhook meerdere keren) worden opgevangen door de
UNIQUE-sleutel. - Controle op negatief/overflow: coin-bedrag en prijs worden aan serverkant gevalideerd; vertrouw nooit een door de client verzonden prijs.
- Chargebacks: bouw voor PayPal-/creditcardterugbetalingen een
reason='refund'-flow die de coins terughaalt; anders maken fraudeurs gratis coins. - Audit trail:
coin_logis het bewijs van elke beweging; bij een geschil lees je daar de hele geschiedenis van het account.
Veelgestelde vragen
Kan ik het item niet gewoon rechtstreeks in de inventory van de speler schrijven?
Niet zolang de speler online is. De game core houdt de inventory in het geheugen; een directe MySQL-schrijfactie wordt bij het uitloggen overschreven en gaat verloren. De combinatie leveringswachtrij + pc.give_item2 is de enige veilige manier.
Wat gebeurt er als de betaalprovider de webhook twee keer stuurt?
Niets — precies wat je wilt. De UNIQUE(reason, ref)-constraint op coin_log weigert het tweede record, dus coins worden maar één keer bijgeschreven. Dit is idempotentie.
Moet ik de item mall met een quest of in de source bouwen?
Voor kleine/middelgrote servers is een quest-gebaseerde item shop snel en makkelijk te onderhouden. Wil je een etalage met honderden items, pagina's en filters, dan geeft een aangepast mall-venster op sourceniveau een soepelere ervaring; maar de architectuur (coin → wachtrij → levering) blijft in beide gevallen hetzelfde.
Wil je je marktsysteem veilig opzetten? Ik bouw de hele keten — van web-betaalintegratie tot de in-game item mall — op maat van jouw server. Laten we het over je project hebben: neem contact met me op.