Ein Metin2 Marktsystem (metin2 market system) ist der Cash-Shop-/Item-Mall-Mechanismus, mit dem die Premiumwährung, die ein Spieler mit echtem Geld (oder Spendenpunkten) kauft, im Spiel ausgegeben werden kann. Das Schwierige ist nicht ein einzelnes Feature, sondern das sichere Verbinden von drei getrennten Welten: dem Zahlungsanbieter auf der Website, der MySQL-Datenbank und dem Game Core. In diesem Leitfaden erkläre ich, wie eine Coin von der Geldbörse bis ins Inventar des Spielers wandert und wo es schiefgehen kann — anhand der Architektur, die ich auf meinem eigenen Server einsetze.
Die drei Schichten des Systems
Betrachten Sie ein Marktsystem nicht als ein einzelnes Skript; es gibt drei Schichten, die miteinander kommunizieren:
- Web-Zahlungsschicht: Der Spieler kauft ein Paket auf der Seite und der Anbieter (PayPal, Stripe, PaySafeCard, Mobile Payment) meldet das Ergebnis per
callback. Diese Schicht schreibt dem Account Coins gut. - Datenbankschicht: Der Coin-Saldo liegt in der
account-Datenbank. Käufe und Coin-Bewegungen werden in eigene Log-Tabellen geschrieben. - In-Game-Item-Mall: Der Spieler öffnet den Shop, kauft Items mit Coins und das Item wird sicher in sein Inventar geliefert.
Diese drei Schichten direkt miteinander zu verdrahten (etwa das Web Items in die Tabelle player.item schreiben zu lassen) ist der häufigste fatale Fehler bei Metin2 — warum, sehen wir gleich.
Datenbankschema
Bauen wir zuerst die Persistenzschicht. Wir fügen den Coin-Saldo zur Account-Tabelle hinzu und erstellen ein Transaktionslog, damit jede Bewegung prüfbar ist. In der account-Datenbank:
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, -- + Aufladung, - Ausgabe
`balance_after` BIGINT UNSIGNED NOT NULL,
`reason` VARCHAR(32) NOT NULL, -- 'payment','mall_buy','refund'
`ref` VARCHAR(64) NOT NULL, -- Anbieter-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;
Der zusammengesetzte Unique-Key uniq_payment ist entscheidend: Selbst wenn dieselbe Zahlungsbenachrichtigung zweimal eintrifft, können Coins nicht zweimal gutgeschrieben werden. Das nennt man Idempotenz und es ist das Rückgrat jeder Zahlungsintegration.
Web-Zahlungsintegration
Wenn ein Kauf abgeschlossen ist, sendet der Anbieter eine HTTP-Anfrage (Webhook/Callback) an Ihren Server. Die Regeln sind klar: Vertrauen Sie niemals einem „Erfolg"-Parameter, der vom Browser kommt; prüfen Sie immer die Signatur/IPN des Anbieters serverseitig. Ein typischer PHP-Callback-Handler sieht so aus:
<?php
// 1) Anbieter-Signatur prüfen (Beispiel: 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 je Paket
$login = $data['account_login'];
if ($status !== 'completed' || $amount <= 0) {
exit('ignored');
}
// 2) Idempotente Gutschrift — in einer einzigen Transaktion
$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');
// dank UNIQUE(reason,ref) erzeugt eine wiederholte txid einen Fehler
$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 → Zahlung bereits verarbeitet, kein Problem
if ($e->getCode() !== '23000') { http_response_code(500); }
}
echo 'OK';
Der Zeilensperre FOR UPDATE reiht zwei gleichzeitige Benachrichtigungen für denselben Account in eine Warteschlange ein; der UNIQUE-Constraint verhindert die doppelte Gutschrift. Zusammen schlagen diese beiden Mechanismen Race Conditions und Replay-Angriffe.
Die In-Game-Item-Mall und die „DB-Cache"-Falle
Jetzt der kritischste Teil. Solange ein Spieler online ist, hält der Metin2 Game Core sein Inventar im Arbeitsspeicher (in der DB-Cache-Schicht). Schreibt das Web ein Item direkt in die Tabelle player.item, überschreibt der Game Core es beim Ausloggen mit seiner eigenen Speicherkopie und Ihr Item ist verloren. Deshalb muss die Lieferung von Coins/Items immer über den Game Core laufen.
Das sichere Muster ist eine Lieferwarteschlange: Das Web schreibt nur einen Datensatz „gib diesem Account dieses Item" in eine Tabelle; das Spiel verarbeitet diesen Datensatz, während der Spieler online ist, und liefert das Item über die offizielle 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;
Wenn der Spieler im Shop Coins ausgibt (das kann ein Item-Shop-Fenster auf Source-Ebene oder ein Quest-NPC steuern), passieren zwei Dinge: account.coins wird verringert und eine Zeile in item_award eingefügt. Die Lieferung übernimmt dann die Quest-Seite per periodischer Prüfung oder Login-Event:
quest cash_delivery begin
state start begin
when login begin
-- bei jedem Login offene Belohnungen prüfen (Beispiellogik)
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 legt pc.give_item2 das Item in das echte Inventar des Spielers (synchron zwischen Speicher und DB); ist das Inventar voll, können Sie es mit der Standardlogik in den Briefkasten legen. Verringern Sie die Coins und schreiben Sie die item_award-Zeile in einer einzigen MySQL-Transaktion, damit ein abgebrochener Kauf weder die Coins ohne Item nimmt noch umgekehrt.
Sicherheit und Betrugsprävention
Sobald Geld im Spiel ist, ist das Marktsystem Ihre Angriffsfläche. Verhandeln Sie nicht über diese Punkte:
- Serverseitige Validierung: Coins werden immer durch den verifizierten Callback des Anbieters ausgelöst — keine vom Client gelieferten Daten erteilen für sich allein eine Berechtigung.
- Idempotenz: Wiederholte Benachrichtigungen (Anbieter senden bei Netzwerkfehlern denselben Webhook mehrfach) werden vom
UNIQUE-Key abgefangen. - Negativ-/Overflow-Prüfung: Coin-Betrag und Preis werden serverseitig validiert; vertrauen Sie niemals einem vom Client gesendeten Preis.
- Rückbuchungen (Chargebacks): Bauen Sie für PayPal-/Kreditkarten-Rückerstattungen einen
reason='refund'-Ablauf, der die Coins zurückholt; sonst erzeugen Betrüger kostenlose Coins. - Audit-Trail:
coin_logist der Beweis jeder Bewegung; bei einem Streitfall lesen Sie dort die gesamte Historie des Accounts.
Häufige Fragen
Kann ich das Item nicht einfach direkt ins Inventar des Spielers schreiben?
Nicht solange der Spieler online ist. Der Game Core hält das Inventar im Speicher; ein direkter MySQL-Schreibvorgang wird beim Ausloggen überschrieben und geht verloren. Die Kombination Lieferwarteschlange + pc.give_item2 ist der einzige sichere Weg.
Was passiert, wenn der Zahlungsanbieter den Webhook zweimal sendet?
Nichts — genau das, was Sie wollen. Der UNIQUE(reason, ref)-Constraint auf coin_log weist den zweiten Datensatz ab, sodass Coins nur einmal gutgeschrieben werden. Das ist Idempotenz.
Sollte ich die Item-Mall mit einer Quest oder in der Source bauen?
Für kleine/mittlere Server ist ein questbasierter Item-Shop schnell und einfach zu warten. Wollen Sie eine Auslage mit Hunderten Items, Seiten und Filtern, bietet ein eigenes Mall-Fenster auf Source-Ebene ein flüssigeres Erlebnis; die Architektur (Coin → Warteschlange → Lieferung) bleibt aber in beiden Fällen gleich.
Möchten Sie Ihr Marktsystem sicher einrichten? Ich baue die gesamte Kette — von der Web-Zahlungsintegration bis zur In-Game-Item-Mall — maßgeschneidert für Ihren Server. Sprechen wir über Ihr Projekt: kontaktieren Sie mich.