Un système de boutique Metin2 (metin2 market system) est le mécanisme de cash shop / item mall où la monnaie premium qu'un joueur achète avec de l'argent réel (ou des points de don) peut être dépensée dans le jeu. La difficulté n'est pas une seule fonctionnalité : c'est de relier en toute sécurité trois mondes distincts — le prestataire de paiement du site web, la base de données MySQL et le game core. Dans ce guide, j'explique comment une coin voyage du portefeuille jusqu'à l'inventaire du joueur, et où tout peut déraper, en m'appuyant sur l'architecture que j'utilise sur mon propre serveur.
Les trois couches du système
Ne voyez pas un système de boutique comme un seul script ; il y a trois couches qui dialoguent :
- Couche de paiement web : le joueur achète un pack sur le site et le prestataire (PayPal, Stripe, PaySafeCard, paiement mobile) signale le résultat via un
callback. Cette couche crédite le compte en coins. - Couche base de données : le solde de coins se trouve dans la base
account. Les achats et mouvements de coins sont écrits dans des tables de log dédiées. - Item mall en jeu : le joueur ouvre la boutique, achète des objets avec ses coins et l'objet est livré en toute sécurité dans son inventaire.
Vouloir câbler ces trois couches directement entre elles (par exemple laisser le web écrire des objets dans la table player.item) est l'erreur fatale la plus fréquente sur Metin2 — nous verrons pourquoi dans un instant.
Schéma de base de données
Construisons d'abord la couche de persistance. Nous ajoutons le solde de coins à la table compte et créons un journal des transactions pour que chaque mouvement soit auditable. Dans la base account :
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, -- + recharge, - dépense
`balance_after` BIGINT UNSIGNED NOT NULL,
`reason` VARCHAR(32) NOT NULL, -- 'payment','mall_buy','refund'
`ref` VARCHAR(64) NOT NULL, -- id tx prestataire / vnum objet
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_payment` (`reason`,`ref`),
KEY `account_id` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
La clé unique composite uniq_payment est cruciale : même si la même notification de paiement arrive deux fois, les coins ne peuvent pas être crédités deux fois. C'est ce qu'on appelle l'idempotence, l'épine dorsale de toute intégration de paiement.
Intégration du paiement web
Quand un achat se termine, le prestataire envoie une requête HTTP (webhook/callback) à votre serveur. Les règles sont claires : ne faites jamais confiance à un paramètre « succès » venant du navigateur ; vérifiez toujours la signature/IPN du prestataire côté serveur. Un gestionnaire de callback PHP typique ressemble à ceci :
<?php
// 1) Vérifier la signature du prestataire (exemple : 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 selon le pack
$login = $data['account_login'];
if ($status !== 'completed' || $amount <= 0) {
exit('ignored');
}
// 2) Crédit idempotent — dans une seule transaction
$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');
// grâce à UNIQUE(reason,ref) un txid répété déclenche une erreur
$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 = clé dupliquée → paiement déjà traité, aucun souci
if ($e->getCode() !== '23000') { http_response_code(500); }
}
echo 'OK';
Le verrou de ligne FOR UPDATE met en file deux notifications concurrentes pour le même compte ; la contrainte UNIQUE bloque le double crédit. Ensemble, ces deux mécanismes neutralisent les race conditions et les attaques par rejeu.
L'item mall en jeu et le piège du « DB cache »
Voici la partie la plus critique. Tant qu'un joueur est en ligne, le game core de Metin2 garde son inventaire en mémoire (dans la couche DB cache). Si le web écrit un objet directement dans la table player.item, le game core l'écrase avec sa propre copie mémoire à la déconnexion et votre objet est perdu. C'est pourquoi la livraison des coins/objets doit toujours passer par le game core.
Le modèle sûr est une file de livraison : le web écrit seulement un enregistrement « donner cet objet à ce compte » dans une table ; le jeu traite cet enregistrement pendant que le joueur est en ligne et livre l'objet via l'API officielle.
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;
Quand le joueur dépense des coins dans la boutique (cela peut être géré par une fenêtre item shop au niveau source ou par un PNJ de quête), deux choses se produisent : account.coins est réduit et une ligne est insérée dans item_award. La livraison est ensuite gérée côté quête par une vérification périodique ou un événement de connexion :
quest cash_delivery begin
state start begin
when login begin
-- vérifier les récompenses en attente à chaque connexion (logique exemple)
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
Ici, pc.give_item2 place l'objet dans l'inventaire réel du joueur (synchronisé entre mémoire et DB) ; si l'inventaire est plein, vous pouvez le déposer dans la boîte aux lettres avec la logique standard. Réduisez les coins et écrivez la ligne item_award dans une seule transaction MySQL, afin qu'un achat interrompu ne prenne pas les coins sans donner l'objet, ni l'inverse.
Sécurité et prévention de la fraude
Dès que de l'argent est en jeu, le système de boutique est votre surface d'attaque. Ne négociez pas sur ces points :
- Validation côté serveur : les coins sont toujours déclenchés par le callback vérifié du prestataire — aucune donnée fournie par le client n'accorde d'autorité à elle seule.
- Idempotence : les notifications répétées (les prestataires renvoient le même webhook plusieurs fois en cas d'erreur réseau) sont absorbées par la clé
UNIQUE. - Contrôles négatif/dépassement : le montant de coins et le prix sont validés côté serveur ; ne faites jamais confiance à un prix envoyé par le client.
- Rétrofacturations (chargebacks) : pour les remboursements PayPal/carte bancaire, construisez un flux
reason='refund'qui récupère les coins ; sinon les fraudeurs créent des coins gratuites. - Piste d'audit :
coin_logest la preuve de chaque mouvement ; en cas de litige, vous y lisez tout l'historique du compte.
Questions fréquentes
Ne puis-je pas écrire l'objet directement dans l'inventaire du joueur ?
Pas tant que le joueur est en ligne. Le game core garde l'inventaire en mémoire ; une écriture MySQL directe est écrasée à la déconnexion et perdue. La combinaison file de livraison + pc.give_item2 est la seule voie sûre.
Que se passe-t-il si le prestataire envoie le webhook deux fois ?
Rien — ce qui est exactement le résultat souhaité. La contrainte UNIQUE(reason, ref) sur coin_log rejette le second enregistrement, les coins ne sont donc crédités qu'une fois. C'est l'idempotence.
Faut-il construire l'item mall en quête ou en source ?
Pour les petits/moyens serveurs, un item shop basé sur une quête est rapide et facile à maintenir. Si vous voulez une vitrine avec des centaines d'objets, des pages et des filtres, une fenêtre mall personnalisée au niveau source offre une expérience plus fluide ; mais l'architecture (coin → file → livraison) reste la même.
Vous voulez installer votre système de boutique en toute sécurité ? Je construis toute la chaîne — de l'intégration du paiement web à l'item mall en jeu — adaptée à votre serveur. Parlons de votre projet : contactez-moi.