Chargement en cours…

En attente du fichier src/data/content.js

Alice Apprend ⭐

Bonjour ! Choisis ton niveau !

🌱 CE1 ⭐ 0 étoiles
🚀 CE2 ⭐ 0 étoiles
CM1 ⭐ 0 étoiles
🏆 CM2 ⭐ 0 étoiles

Choisis ta matière !

Qu'est-ce qu'on apprend aujourd'hui ?

🔢 Maths
📖 Français
🌿 Sciences
🌍 Histoire & Géo
1 / 10
⭐ 0

Réfléchis bien !

Question ici…

Résultats !

Bravo !

0 / 10

Bien joué !

🃏 Ma Collection 0/64 cartes
⏳ Chargement des cartes…
👫 Mes Amis 0 ami(s)
🪪 Mon code ami
LAPIN7
Partage ce code avec tes amis pour qu'ils puissent te trouver !
➕ Ajouter un ami
📬 Demandes reçues
🦊
Renard99
veut être ton ami !
⭐ Mes amis
🐬
Dauphin42
Niv. 5  •  ⭐ 1 240
🏆 Classement
🥈
🐬
Dauphin42
⭐ 980
2e
👑
🦁
Lion2015
⭐ 1 530
1er
🥉
🐻
Ourson7
⭐ 710
3e
4
🐸
Grenouille ⭐ 620
5
Moi 👈 ⭐ 540
🏪 Marché des Cartes
🔒 Entre amis seulement
📥 Propositions reçues
🦊 Renard99 te propose un échange !
📝 Dictée (Rare) Il t'offre
Addition (Commune) Il veut
📤 Mes propositions envoyées
À 🐬 Dauphin42   ⏳ En attente
🌱 Photosynthèse (Épique) Tu offres
🏰 Chevaliers (Légendaire) Tu veux
À 🦊 Renard99   ✅ Accepté !
Addition x2 (Commune) Tu offres
📝 Dictée (Rare) Tu veux
🔄 Créer un échange
1️⃣ À qui tu proposes ?
2️⃣ J'offre :

Tape sur une carte pour la sélectionner (tu peux en choisir plusieurs)

Addition 3
🌱 Photosynthèse 2
3️⃣ Je veux :

Sélectionne dans la collection de l'ami choisi

👆

Choisis d'abord un ami ci-dessus !

📋 Résumé de l'échange
Tu offres
Rien encore
Tu veux
Rien encore
// // // ============================================================ // CONFIG FIREBASE — À remplacer avec tes propres clés // Tutoriel: https://console.firebase.google.com // 1. Crée un projet Firebase // 2. Ajoute une app Web // 3. Copie la config ici const FIREBASE_CONFIG = { apiKey: "VOTRE_API_KEY", authDomain: "VOTRE_PROJECT.firebaseapp.com", projectId: "VOTRE_PROJECT_ID", storageBucket: "VOTRE_PROJECT.appspot.com", messagingSenderId: "VOTRE_SENDER_ID", appId: "VOTRE_APP_ID" }; // ============================================================ // CONSTANTES INTERNES // ============================================================ // Consonnes + chiffres lisibles par des enfants (pas de O/0, I/1 pour éviter confusions) const FRIENDLY_CONSONANTS = ["B","C","D","F","G","H","J","K","L","M","N","P","R","S","T","V","Z"]; const FRIENDLY_ANIMALS = ["LAPIN","CHAT","OURS","LUNE","CHAT","LION","TIGR","CERF","DAIM","LOUP"]; const LOCAL_STORAGE_KEY = "alice_apprend_player_id"; const LOCAL_PLAYER_KEY = "alice_apprend_player_data"; const LOCAL_CARDS_KEY = "alice_apprend_cards"; const LOCAL_FRIENDS_KEY = "alice_apprend_friends"; const LOCAL_TRADES_KEY = "alice_apprend_trades"; const LOCAL_REQUESTS_KEY = "alice_apprend_friend_requests"; // ============================================================ // UTILITAIRES INTERNES // ============================================================ /** * Génère un code ami de 6 caractères style "LAPIN7" * Format: 4-5 lettres (animal/mot) + 1-2 chiffres */ function _generateCandidateCode() { const animal = FRIENDLY_ANIMALS[Math.floor(Math.random() * FRIENDLY_ANIMALS.length)]; const numbers = Math.floor(Math.random() * 99) + 1; // 1–99 const numStr = numbers < 10 ? "0" + numbers : String(numbers); // On prend les 4 premiers chars de l'animal pour tenir en 6 chars const prefix = animal.substring(0, 4); return (prefix + numStr).substring(0, 6).toUpperCase(); } /** * Génère un ID unique simple (timestamp + random) */ function _generateLocalId() { return Date.now().toString(36) + Math.random().toString(36).substring(2, 7); } /** * Récupère la date ISO de la semaine en cours (lundi) */ function _getCurrentWeekMonday() { const now = new Date(); const day = now.getDay(); // 0=dimanche const diff = now.getDate() - day + (day === 0 ? -6 : 1); const mon = new Date(now.setDate(diff)); mon.setHours(0, 0, 0, 0); return mon; } /** * Vérifie si le reset hebdomadaire des étoiles est nécessaire */ function _needsWeekReset(weekResetTimestamp) { if (!weekResetTimestamp) return true; const resetDate = weekResetTimestamp.toDate ? weekResetTimestamp.toDate() : new Date(weekResetTimestamp); return resetDate < _getCurrentWeekMonday(); } /** * Stockage local : lecture sécurisée */ function _localGet(key, fallback = null) { try { const raw = localStorage.getItem(key); return raw ? JSON.parse(raw) : fallback; } catch (e) { console.warn("[Alice] Erreur lecture localStorage:", key, e); return fallback; } } /** * Stockage local : écriture sécurisée */ function _localSet(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn("[Alice] Erreur écriture localStorage:", key, e); } } // ============================================================ // MODULE PRINCIPAL // ============================================================ const FirebaseDB = { /** Instance Firestore */ db: null, /** True si Firebase est correctement configuré et initialisé */ isConfigured: false, /** Listeners actifs (pour pouvoir les désinscrire) */ _listeners: {}, // ---------------------------------------------------------- // INITIALISATION // ---------------------------------------------------------- /** * Initialise Firebase et Firestore. * Si la config est vide ou invalide, bascule en mode localStorage. * @returns {boolean} true si Firebase est actif, false si mode local */ init() { // Vérifie que la config n'est pas vide (placeholder) const isPlaceholder = !FIREBASE_CONFIG.apiKey || FIREBASE_CONFIG.apiKey === "VOTRE_API_KEY" || !FIREBASE_CONFIG.projectId || FIREBASE_CONFIG.projectId === "VOTRE_PROJECT_ID"; if (isPlaceholder) { console.warn( "[Alice] Firebase non configuré — Mode local activé.\n" + "Configure tes clés dans src/firebase.js pour jouer avec tes amis !" ); this.isConfigured = false; this._showOfflineMessage(); return false; } try { // Évite la double initialisation if (!firebase.apps.length) { firebase.initializeApp(FIREBASE_CONFIG); } this.db = firebase.firestore(); // Active la persistance hors-ligne (cache local automatique) this.db.enablePersistence({ synchronizeTabs: true }).catch((err) => { if (err.code === "failed-precondition") { console.warn("[Alice] Persistance Firestore désactivée (plusieurs onglets ouverts)."); } else if (err.code === "unimplemented") { console.warn("[Alice] Persistance Firestore non supportée par ce navigateur."); } }); this.isConfigured = true; console.info("[Alice] Firebase initialisé avec succès ✓"); return true; } catch (err) { console.error("[Alice] Erreur init Firebase:", err); this.isConfigured = false; this._showOfflineMessage(); return false; } }, /** * Affiche un message discret informant l'utilisateur du mode local */ _showOfflineMessage() { // On émet un événement custom que l'UI peut écouter window.dispatchEvent(new CustomEvent("alice:offlineMode", { detail: { message: "Mode local — Configure Firebase pour jouer avec tes amis !" } })); }, // ---------------------------------------------------------- // JOUEUR // ---------------------------------------------------------- /** * Crée un nouveau joueur dans Firestore (ou localStorage en mode local). * Génère automatiquement un code ami unique à 6 caractères. * * @param {string} username Prénom choisi (max 12 chars) * @param {string} avatar Emoji avatar ex: "🦊" * @param {string} level Niveau: "CE1" | "CE2" | "CM1" | "CM2" * @returns {Promise<{playerId: string, friendCode: string, ...}>} */ async createPlayer(username, avatar, level) { // Validation des paramètres if (!username || typeof username !== "string") { throw new Error("Le prénom est obligatoire."); } username = username.trim().substring(0, 12); if (!username) throw new Error("Le prénom ne peut pas être vide."); const validLevels = ["CE1", "CE2", "CM1", "CM2"]; if (!validLevels.includes(level)) { throw new Error("Niveau invalide. Choisir parmi: CE1, CE2, CM1, CM2."); } // Génère un friendCode unique const friendCode = await this._generateUniqueFriendCode(); const now = new Date(); const playerId = this.isConfigured ? null // Firestore génèrera l'ID : _generateLocalId(); const playerData = { username, friendCode, avatar: avatar || "🐱", level, totalStars: 0, weeklyStars: 0, weekReset: now, friends: [], createdAt: now }; if (this.isConfigured) { try { const docRef = await this.db.collection("players").add({ ...playerData, weekReset: firebase.firestore.Timestamp.fromDate(now), createdAt: firebase.firestore.Timestamp.fromDate(now) }); const result = { playerId: docRef.id, ...playerData }; this.saveToLocal(docRef.id); return result; } catch (err) { console.error("[Alice] Erreur création joueur Firestore:", err); throw new Error("Impossible de créer le joueur. Vérifie ta connexion."); } } else { // Mode local const localPlayer = { playerId, ...playerData }; const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); allPlayers[playerId] = localPlayer; _localSet(LOCAL_PLAYER_KEY, allPlayers); this.saveToLocal(playerId); return localPlayer; } }, /** * Récupère les données d'un joueur. * * @param {string} playerId * @returns {Promise} */ async getPlayer(playerId) { if (!playerId) throw new Error("playerId est requis."); if (this.isConfigured) { try { const doc = await this.db.collection("players").doc(playerId).get(); if (!doc.exists) return null; return { playerId: doc.id, ...doc.data() }; } catch (err) { console.error("[Alice] Erreur récupération joueur:", err); throw new Error("Impossible de récupérer le joueur."); } } else { const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); return allPlayers[playerId] || null; } }, /** * Ajoute des étoiles au joueur (total + hebdomadaire). * Remet les étoiles hebdomadaires à zéro si nouvelle semaine. * * @param {string} playerId * @param {number} starsToAdd Nombre d'étoiles à ajouter (doit être positif) * @returns {Promise<{totalStars: number, weeklyStars: number}>} */ async updateStars(playerId, starsToAdd) { if (!playerId) throw new Error("playerId est requis."); if (typeof starsToAdd !== "number" || starsToAdd < 0) { throw new Error("starsToAdd doit être un nombre positif."); } if (this.isConfigured) { try { const playerRef = this.db.collection("players").doc(playerId); // Transaction pour éviter les conflits concurrents return await this.db.runTransaction(async (transaction) => { const doc = await transaction.get(playerRef); if (!doc.exists) throw new Error("Joueur introuvable."); const data = doc.data(); const needsReset = _needsWeekReset(data.weekReset); const newTotal = (data.totalStars || 0) + starsToAdd; const newWeekly = needsReset ? starsToAdd : (data.weeklyStars || 0) + starsToAdd; const updateData = { totalStars: newTotal, weeklyStars: newWeekly }; if (needsReset) { updateData.weekReset = firebase.firestore.Timestamp.fromDate(new Date()); } transaction.update(playerRef, updateData); return { totalStars: newTotal, weeklyStars: newWeekly }; }); } catch (err) { console.error("[Alice] Erreur mise à jour étoiles:", err); throw new Error("Impossible de mettre à jour les étoiles."); } } else { // Mode local const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); const player = allPlayers[playerId]; if (!player) throw new Error("Joueur introuvable."); const needsReset = _needsWeekReset(player.weekReset); player.totalStars = (player.totalStars || 0) + starsToAdd; player.weeklyStars = needsReset ? starsToAdd : (player.weeklyStars || 0) + starsToAdd; if (needsReset) player.weekReset = new Date().toISOString(); allPlayers[playerId] = player; _localSet(LOCAL_PLAYER_KEY, allPlayers); return { totalStars: player.totalStars, weeklyStars: player.weeklyStars }; } }, /** * Charge le playerId depuis le localStorage du navigateur. * @returns {string|null} */ loadFromLocal() { return _localGet(LOCAL_STORAGE_KEY, null); }, /** * Sauvegarde le playerId dans le localStorage du navigateur. * @param {string} playerId */ saveToLocal(playerId) { _localSet(LOCAL_STORAGE_KEY, playerId); }, // ---------------------------------------------------------- // GÉNÉRATION DU CODE AMI // ---------------------------------------------------------- /** * Génère un code ami unique (vérifie l'unicité dans Firestore ou localStorage). * @returns {Promise} */ async _generateUniqueFriendCode() { const MAX_ATTEMPTS = 20; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { const code = _generateCandidateCode(); const isUnique = await this._isFriendCodeAvailable(code); if (isUnique) return code; } // Fallback ultime: timestamp pour garantir l'unicité const fallback = "A" + Date.now().toString(36).substring(-5).toUpperCase(); return fallback.substring(0, 6); }, /** * Vérifie qu'un code ami n'est pas déjà utilisé. * @param {string} code * @returns {Promise} */ async _isFriendCodeAvailable(code) { if (this.isConfigured) { try { const snapshot = await this.db .collection("players") .where("friendCode", "==", code) .limit(1) .get(); return snapshot.empty; } catch (err) { console.warn("[Alice] Impossible de vérifier le code ami:", err); return true; // On laisse passer en cas d'erreur réseau } } else { const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); return !Object.values(allPlayers).some(p => p.friendCode === code); } }, // ---------------------------------------------------------- // AMIS // ---------------------------------------------------------- /** * Envoie une demande d'ami en cherchant le joueur par son code ami. * * @param {string} myPlayerId ID du joueur qui envoie la demande * @param {string} friendCode Code ami de 6 chars du destinataire * @returns {Promise<{requestId: string}>} */ async sendFriendRequest(myPlayerId, friendCode) { if (!myPlayerId) throw new Error("myPlayerId est requis."); if (!friendCode || friendCode.length !== 6) { throw new Error("Le code ami doit faire exactement 6 caractères."); } const code = friendCode.trim().toUpperCase(); // Récupère les données du joueur qui envoie const me = await this.getPlayer(myPlayerId); if (!me) throw new Error("Votre profil est introuvable."); if (me.friendCode === code) { throw new Error("Tu ne peux pas t'ajouter toi-même !"); } if (this.isConfigured) { try { // Trouve le joueur cible par son code ami const snapshot = await this.db .collection("players") .where("friendCode", "==", code) .limit(1) .get(); if (snapshot.empty) { throw new Error("Code ami introuvable. Vérifie les lettres et les chiffres."); } const targetDoc = snapshot.docs[0]; const toPlayerId = targetDoc.id; const targetPlayer = targetDoc.data(); // Vérifie qu'ils ne sont pas déjà amis if (me.friends && me.friends.includes(toPlayerId)) { throw new Error("Vous êtes déjà amis !"); } // Vérifie qu'une demande n'est pas déjà en cours const existingReq = await this.db .collection("friendRequests") .where("fromPlayer.id", "==", myPlayerId) .where("toPlayerId", "==", toPlayerId) .where("status", "==", "pending") .limit(1) .get(); if (!existingReq.empty) { throw new Error("Tu as déjà envoyé une demande à ce joueur."); } const now = firebase.firestore.Timestamp.fromDate(new Date()); const requestRef = await this.db.collection("friendRequests").add({ fromPlayer: { id: myPlayerId, username: me.username, avatar: me.avatar, friendCode: me.friendCode }, toPlayerId, status: "pending", createdAt: now }); return { requestId: requestRef.id, toUsername: targetPlayer.username }; } catch (err) { // Re-throw les erreurs métier sans les encapsuler if (err.message && !err.code) throw err; console.error("[Alice] Erreur envoi demande ami:", err); throw new Error("Impossible d'envoyer la demande. Vérifie ta connexion."); } } else { // Mode local const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); const target = Object.values(allPlayers).find(p => p.friendCode === code); if (!target) { throw new Error("Code ami introuvable. Vérifie les lettres et les chiffres."); } if (me.friends && me.friends.includes(target.playerId)) { throw new Error("Vous êtes déjà amis !"); } const allRequests = _localGet(LOCAL_REQUESTS_KEY, {}); // Vérifie doublon const alreadySent = Object.values(allRequests).some( r => r.fromPlayer.id === myPlayerId && r.toPlayerId === target.playerId && r.status === "pending" ); if (alreadySent) throw new Error("Tu as déjà envoyé une demande à ce joueur."); const requestId = _generateLocalId(); allRequests[requestId] = { requestId, fromPlayer: { id: myPlayerId, username: me.username, avatar: me.avatar, friendCode: me.friendCode }, toPlayerId: target.playerId, status: "pending", createdAt: new Date().toISOString() }; _localSet(LOCAL_REQUESTS_KEY, allRequests); return { requestId, toUsername: target.username }; } }, /** * Accepte une demande d'ami et met à jour la liste d'amis des deux joueurs. * * @param {string} requestId ID de la demande * @param {string} myPlayerId ID du joueur qui accepte * @returns {Promise} */ async acceptFriendRequest(requestId, myPlayerId) { if (!requestId) throw new Error("requestId est requis."); if (!myPlayerId) throw new Error("myPlayerId est requis."); if (this.isConfigured) { try { const requestRef = this.db.collection("friendRequests").doc(requestId); await this.db.runTransaction(async (transaction) => { const requestDoc = await transaction.get(requestRef); if (!requestDoc.exists) throw new Error("Demande introuvable."); const request = requestDoc.data(); if (request.toPlayerId !== myPlayerId) { throw new Error("Tu n'es pas autorisé à accepter cette demande."); } if (request.status !== "pending") { throw new Error("Cette demande n'est plus en attente."); } const fromId = request.fromPlayer.id; // Ajoute chacun dans la liste d'amis de l'autre const meRef = this.db.collection("players").doc(myPlayerId); const fromRef = this.db.collection("players").doc(fromId); transaction.update(requestRef, { status: "accepted" }); transaction.update(meRef, { friends: firebase.firestore.FieldValue.arrayUnion(fromId) }); transaction.update(fromRef, { friends: firebase.firestore.FieldValue.arrayUnion(myPlayerId) }); }); } catch (err) { if (err.message && !err.code) throw err; console.error("[Alice] Erreur acceptation demande ami:", err); throw new Error("Impossible d'accepter la demande."); } } else { const allRequests = _localGet(LOCAL_REQUESTS_KEY, {}); const request = allRequests[requestId]; if (!request) throw new Error("Demande introuvable."); if (request.toPlayerId !== myPlayerId) { throw new Error("Tu n'es pas autorisé à accepter cette demande."); } if (request.status !== "pending") { throw new Error("Cette demande n'est plus en attente."); } request.status = "accepted"; allRequests[requestId] = request; _localSet(LOCAL_REQUESTS_KEY, allRequests); // Mise à jour des listes d'amis const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); const me = allPlayers[myPlayerId]; const from = allPlayers[request.fromPlayer.id]; if (me) { me.friends = me.friends || []; if (!me.friends.includes(request.fromPlayer.id)) me.friends.push(request.fromPlayer.id); allPlayers[myPlayerId] = me; } if (from) { from.friends = from.friends || []; if (!from.friends.includes(myPlayerId)) from.friends.push(myPlayerId); allPlayers[request.fromPlayer.id] = from; } _localSet(LOCAL_PLAYER_KEY, allPlayers); } }, /** * Rejette une demande d'ami. * * @param {string} requestId * @returns {Promise} */ async rejectFriendRequest(requestId) { if (!requestId) throw new Error("requestId est requis."); if (this.isConfigured) { try { await this.db.collection("friendRequests").doc(requestId).update({ status: "rejected" }); } catch (err) { console.error("[Alice] Erreur rejet demande ami:", err); throw new Error("Impossible de rejeter la demande."); } } else { const allRequests = _localGet(LOCAL_REQUESTS_KEY, {}); if (!allRequests[requestId]) throw new Error("Demande introuvable."); allRequests[requestId].status = "rejected"; _localSet(LOCAL_REQUESTS_KEY, allRequests); } }, /** * Récupère les demandes d'ami en attente pour un joueur. * * @param {string} myPlayerId * @returns {Promise} */ async getFriendRequests(myPlayerId) { if (!myPlayerId) throw new Error("myPlayerId est requis."); if (this.isConfigured) { try { const snapshot = await this.db .collection("friendRequests") .where("toPlayerId", "==", myPlayerId) .where("status", "==", "pending") .orderBy("createdAt", "desc") .get(); return snapshot.docs.map(doc => ({ requestId: doc.id, ...doc.data() })); } catch (err) { console.error("[Alice] Erreur récupération demandes ami:", err); throw new Error("Impossible de récupérer les demandes d'ami."); } } else { const allRequests = _localGet(LOCAL_REQUESTS_KEY, {}); return Object.values(allRequests).filter( r => r.toPlayerId === myPlayerId && r.status === "pending" ); } }, /** * Récupère la liste des amis d'un joueur avec leurs données complètes. * * @param {string} playerId * @returns {Promise} */ async getFriends(playerId) { if (!playerId) throw new Error("playerId est requis."); const player = await this.getPlayer(playerId); if (!player || !player.friends || player.friends.length === 0) return []; if (this.isConfigured) { try { // Firestore limite "in" à 30 éléments — on pagine si besoin const friendIds = player.friends; const batchSize = 30; const allFriends = []; for (let i = 0; i < friendIds.length; i += batchSize) { const batch = friendIds.slice(i, i + batchSize); const snapshot = await this.db .collection("players") .where(firebase.firestore.FieldPath.documentId(), "in", batch) .get(); snapshot.docs.forEach(doc => { allFriends.push({ playerId: doc.id, ...doc.data() }); }); } return allFriends; } catch (err) { console.error("[Alice] Erreur récupération amis:", err); throw new Error("Impossible de récupérer la liste d'amis."); } } else { const allPlayers = _localGet(LOCAL_PLAYER_KEY, {}); return player.friends .map(id => allPlayers[id]) .filter(Boolean); } }, // ---------------------------------------------------------- // CLASSEMENT // ---------------------------------------------------------- /** * Retourne le classement hebdomadaire parmi le joueur et ses amis. * Trié par weeklyStars décroissant. * * @param {string} playerId * @returns {Promise>} */ async getLeaderboard(playerId) { if (!playerId) throw new Error("playerId est requis."); try { const player = await this.getPlayer(playerId); if (!player) throw new Error("Joueur introuvable."); const friends = await this.getFriends(playerId); // Construit le classement: moi + mes amis const allPlayers = [ { playerId, ...player, isMe: true }, ...friends.map(f => ({ ...f, isMe: false })) ]; // Trie par étoiles hebdomadaires décroissantes, puis total en tie-break allPlayers.sort((a, b) => { const wDiff = (b.weeklyStars || 0) - (a.weeklyStars || 0); if (wDiff !== 0) return wDiff; return (b.totalStars || 0) - (a.totalStars || 0); }); // Ajoute le rang return allPlayers.map((p, index) => ({ rank: index + 1, playerId: p.playerId, username: p.username, avatar: p.avatar, level: p.level, weeklyStars: p.weeklyStars || 0, totalStars: p.totalStars || 0, isMe: p.isMe || false })); } catch (err) { if (err.message && !err.code) throw err; console.error("[Alice] Erreur classement:", err); throw new Error("Impossible de charger le classement."); } }, // ---------------------------------------------------------- // CARTES À COLLECTIONNER // ---------------------------------------------------------- /** * Ajoute des cartes à la collection d'un joueur. * Si la carte existe déjà, incrémente la quantité. * * @param {string} playerId * @param {string} cardId Identifiant de la carte * @param {number} quantity Quantité à ajouter (défaut: 1) * @returns {Promise<{cardId, quantity}>} */ async addCardToCollection(playerId, cardId, quantity = 1) { if (!playerId) throw new Error("playerId est requis."); if (!cardId) throw new Error("cardId est requis."); if (typeof quantity !== "number" || quantity < 1) { throw new Error("quantity doit être un entier positif."); } if (this.isConfigured) { try { const cardRef = this.db .collection("players").doc(playerId) .collection("cards").doc(cardId); return await this.db.runTransaction(async (transaction) => { const cardDoc = await transaction.get(cardRef); const now = firebase.firestore.Timestamp.fromDate(new Date()); let newQuantity; if (cardDoc.exists) { newQuantity = (cardDoc.data().quantity || 0) + quantity; transaction.update(cardRef, { quantity: newQuantity }); } else { newQuantity = quantity; transaction.set(cardRef, { cardId, quantity: newQuantity, obtainedAt: now }); } return { cardId, quantity: newQuantity }; }); } catch (err) { console.error("[Alice] Erreur ajout carte:", err); throw new Error("Impossible d'ajouter la carte à la collection."); } } else { // Mode local: { playerId: { cardId: {cardId, quantity, obtainedAt} } } const allCards = _localGet(LOCAL_CARDS_KEY, {}); if (!allCards[playerId]) allCards[playerId] = {}; const existing = allCards[playerId][cardId]; const newQty = (existing ? existing.quantity : 0) + quantity; allCards[playerId][cardId] = { cardId, quantity: newQty, obtainedAt: existing ? existing.obtainedAt : new Date().toISOString() }; _localSet(LOCAL_CARDS_KEY, allCards); return { cardId, quantity: newQty }; } }, /** * Récupère toutes les cartes d'un joueur. * * @param {string} playerId * @returns {Promise>} */ async getCollection(playerId) { if (!playerId) throw new Error("playerId est requis."); if (this.isConfigured) { try { const snapshot = await this.db .collection("players").doc(playerId) .collection("cards") .get(); return snapshot.docs.map(doc => ({ cardId: doc.id, ...doc.data() })); } catch (err) { console.error("[Alice] Erreur récupération collection:", err); throw new Error("Impossible de récupérer la collection."); } } else { const allCards = _localGet(LOCAL_CARDS_KEY, {}); return Object.values(allCards[playerId] || {}); } }, /** * Récupère les collections de plusieurs joueurs (pour le marché d'échange). * * @param {string[]} playerIds Liste d'IDs de joueurs * @returns {Promise} { playerId: [{cardId, quantity, ...}] } */ async getCollections(playerIds) { if (!Array.isArray(playerIds) || playerIds.length === 0) return {}; const results = {}; // On récupère en parallèle pour aller plus vite await Promise.all( playerIds.map(async (id) => { try { results[id] = await this.getCollection(id); } catch (err) { console.warn(`[Alice] Impossible de récupérer la collection de ${id}:`, err); results[id] = []; } }) ); return results; }, // ---------------------------------------------------------- // UTILITAIRE INTERNE — Transfert de cartes // ---------------------------------------------------------- /** * Vérifie qu'un joueur possède bien les cartes requises. * @param {string} playerId * @param {Array<{cardId, quantity}>} cards * @returns {Promise} */ async _hasCards(playerId, cards) { const collection = await this.getCollection(playerId); const cardMap = {}; collection.forEach(c => { cardMap[c.cardId] = c.quantity; }); return cards.every(c => (cardMap[c.cardId] || 0) >= c.quantity); }, /** * Transfère des cartes d'un joueur à un autre dans une transaction Firestore. * Usage interne lors de l'acceptation d'un échange. * * @param {object} transaction Transaction Firestore active * @param {string} fromId Joueur qui donne * @param {string} toId Joueur qui reçoit * @param {Array<{cardId, quantity}>} cards */ async _transferCards(transaction, fromId, toId, cards) { const now = firebase.firestore.Timestamp.fromDate(new Date()); for (const { cardId, quantity } of cards) { const fromRef = this.db .collection("players").doc(fromId) .collection("cards").doc(cardId); const toRef = this.db .collection("players").doc(toId) .collection("cards").doc(cardId); const [fromDoc, toDoc] = await Promise.all([ transaction.get(fromRef), transaction.get(toRef) ]); // Débite le donnant const fromQty = (fromDoc.exists ? fromDoc.data().quantity : 0) - quantity; if (fromQty < 0) throw new Error(`Cartes insuffisantes pour ${cardId}.`); if (fromQty === 0) { transaction.delete(fromRef); } else { transaction.update(fromRef, { quantity: fromQty }); } // Crédite le recevant const toQty = (toDoc.exists ? toDoc.data().quantity : 0) + quantity; if (toDoc.exists) { transaction.update(toRef, { quantity: toQty }); } else { transaction.set(toRef, { cardId, quantity: toQty, obtainedAt: now }); } } }, /** * Transfère des cartes en mode localStorage. */ _transferCardsLocal(fromId, toId, cards, allCards) { if (!allCards[fromId]) allCards[fromId] = {}; if (!allCards[toId]) allCards[toId] = {}; for (const { cardId, quantity } of cards) { const fromQty = (allCards[fromId][cardId]?.quantity || 0) - quantity; if (fromQty < 0) throw new Error(`Cartes insuffisantes pour ${cardId}.`); if (fromQty === 0) { delete allCards[fromId][cardId]; } else { allCards[fromId][cardId].quantity = fromQty; } const toQty = (allCards[toId][cardId]?.quantity || 0) + quantity; allCards[toId][cardId] = { cardId, quantity: toQty, obtainedAt: allCards[toId][cardId]?.obtainedAt || new Date().toISOString() }; } }, // ---------------------------------------------------------- // ÉCHANGES (MARKETPLACE) // ---------------------------------------------------------- /** * Crée une proposition d'échange entre deux amis. * * @param {string} fromPlayerId Joueur qui propose l'échange * @param {string} toPlayerId Ami ciblé * @param {Array<{cardId, quantity}>} offeredCards Cartes offertes * @param {Array<{cardId, quantity}>} requestedCards Cartes demandées * @returns {Promise<{tradeId: string}>} */ async createTrade(fromPlayerId, toPlayerId, offeredCards, requestedCards) { if (!fromPlayerId) throw new Error("fromPlayerId est requis."); if (!toPlayerId) throw new Error("toPlayerId est requis."); if (fromPlayerId === toPlayerId) throw new Error("Tu ne peux pas échanger avec toi-même."); if (!Array.isArray(offeredCards) || offeredCards.length === 0) { throw new Error("Tu dois offrir au moins une carte."); } if (!Array.isArray(requestedCards) || requestedCards.length === 0) { throw new Error("Tu dois demander au moins une carte."); } // Récupère les deux joueurs pour construire les données de l'échange const [me, friend] = await Promise.all([ this.getPlayer(fromPlayerId), this.getPlayer(toPlayerId) ]); if (!me) throw new Error("Ton profil est introuvable."); if (!friend) throw new Error("Le profil de l'ami est introuvable."); // Vérifie que ce sont bien des amis if (!me.friends || !me.friends.includes(toPlayerId)) { throw new Error("Tu peux seulement échanger avec tes amis."); } // Vérifie que le joueur possède les cartes offertes const hasCards = await this._hasCards(fromPlayerId, offeredCards); if (!hasCards) { throw new Error("Tu ne possèdes pas toutes les cartes que tu proposes."); } const tradeData = { fromPlayer: { id: fromPlayerId, username: me.username, avatar: me.avatar }, toPlayer: { id: toPlayerId, username: friend.username, avatar: friend.avatar }, offeredCards, requestedCards, status: "pending", createdAt: new Date(), updatedAt: new Date() }; if (this.isConfigured) { try { const now = firebase.firestore.Timestamp.fromDate(new Date()); const ref = await this.db.collection("trades").add({ ...tradeData, createdAt: now, updatedAt: now }); return { tradeId: ref.id }; } catch (err) { console.error("[Alice] Erreur création échange:", err); throw new Error("Impossible de créer l'échange."); } } else { const tradeId = _generateLocalId(); const allTrades = _localGet(LOCAL_TRADES_KEY, {}); allTrades[tradeId] = { tradeId, ...tradeData, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; _localSet(LOCAL_TRADES_KEY, allTrades); return { tradeId }; } }, /** * Accepte un échange: transfère les cartes entre les deux joueurs. * * @param {string} tradeId * @param {string} myPlayerId Doit être le joueur destinataire (toPlayer) * @returns {Promise} */ async acceptTrade(tradeId, myPlayerId) { if (!tradeId) throw new Error("tradeId est requis."); if (!myPlayerId) throw new Error("myPlayerId est requis."); if (this.isConfigured) { try { const tradeRef = this.db.collection("trades").doc(tradeId); await this.db.runTransaction(async (transaction) => { const tradeDoc = await transaction.get(tradeRef); if (!tradeDoc.exists) throw new Error("Échange introuvable."); const trade = tradeDoc.data(); if (trade.toPlayer.id !== myPlayerId) { throw new Error("Tu n'es pas autorisé à accepter cet échange."); } if (trade.status !== "pending") { throw new Error("Cet échange n'est plus en attente."); } const fromId = trade.fromPlayer.id; // Transfère les cartes offertes: fromPlayer → toPlayer await this._transferCards(transaction, fromId, myPlayerId, trade.offeredCards); // Transfère les cartes demandées: toPlayer → fromPlayer await this._transferCards(transaction, myPlayerId, fromId, trade.requestedCards); const now = firebase.firestore.Timestamp.fromDate(new Date()); transaction.update(tradeRef, { status: "accepted", updatedAt: now }); }); } catch (err) { if (err.message && !err.code) throw err; console.error("[Alice] Erreur acceptation échange:", err); throw new Error("Impossible d'accepter l'échange."); } } else { const allTrades = _localGet(LOCAL_TRADES_KEY, {}); const trade = allTrades[tradeId]; if (!trade) throw new Error("Échange introuvable."); if (trade.toPlayer.id !== myPlayerId) { throw new Error("Tu n'es pas autorisé à accepter cet échange."); } if (trade.status !== "pending") { throw new Error("Cet échange n'est plus en attente."); } const allCards = _localGet(LOCAL_CARDS_KEY, {}); const fromId = trade.fromPlayer.id; this._transferCardsLocal(fromId, myPlayerId, trade.offeredCards, allCards); this._transferCardsLocal(myPlayerId, fromId, trade.requestedCards, allCards); _localSet(LOCAL_CARDS_KEY, allCards); trade.status = "accepted"; trade.updatedAt = new Date().toISOString(); allTrades[tradeId] = trade; _localSet(LOCAL_TRADES_KEY, allTrades); } }, /** * Rejette une proposition d'échange (côté destinataire). * * @param {string} tradeId * @returns {Promise} */ async rejectTrade(tradeId) { if (!tradeId) throw new Error("tradeId est requis."); if (this.isConfigured) { try { const now = firebase.firestore.Timestamp.fromDate(new Date()); await this.db.collection("trades").doc(tradeId).update({ status: "rejected", updatedAt: now }); } catch (err) { console.error("[Alice] Erreur rejet échange:", err); throw new Error("Impossible de rejeter l'échange."); } } else { const allTrades = _localGet(LOCAL_TRADES_KEY, {}); if (!allTrades[tradeId]) throw new Error("Échange introuvable."); allTrades[tradeId].status = "rejected"; allTrades[tradeId].updatedAt = new Date().toISOString(); _localSet(LOCAL_TRADES_KEY, allTrades); } }, /** * Annule une proposition d'échange (côté expéditeur uniquement). * * @param {string} tradeId * @param {string} myPlayerId Doit être le fromPlayer * @returns {Promise} */ async cancelTrade(tradeId, myPlayerId) { if (!tradeId) throw new Error("tradeId est requis."); if (!myPlayerId) throw new Error("myPlayerId est requis."); if (this.isConfigured) { try { const tradeRef = this.db.collection("trades").doc(tradeId); const tradeDoc = await tradeRef.get(); if (!tradeDoc.exists) throw new Error("Échange introuvable."); const trade = tradeDoc.data(); if (trade.fromPlayer.id !== myPlayerId) { throw new Error("Seul l'expéditeur peut annuler un échange."); } if (trade.status !== "pending") { throw new Error("Seuls les échanges en attente peuvent être annulés."); } const now = firebase.firestore.Timestamp.fromDate(new Date()); await tradeRef.update({ status: "cancelled", updatedAt: now }); } catch (err) { if (err.message && !err.code) throw err; console.error("[Alice] Erreur annulation échange:", err); throw new Error("Impossible d'annuler l'échange."); } } else { const allTrades = _localGet(LOCAL_TRADES_KEY, {}); const trade = allTrades[tradeId]; if (!trade) throw new Error("Échange introuvable."); if (trade.fromPlayer.id !== myPlayerId) { throw new Error("Seul l'expéditeur peut annuler un échange."); } if (trade.status !== "pending") { throw new Error("Seuls les échanges en attente peuvent être annulés."); } trade.status = "cancelled"; trade.updatedAt = new Date().toISOString(); allTrades[tradeId] = trade; _localSet(LOCAL_TRADES_KEY, allTrades); } }, /** * Récupère tous les échanges en cours pour un joueur * (qu'il soit expéditeur ou destinataire), statut "pending". * * @param {string} playerId * @returns {Promise} */ async getMyTrades(playerId) { if (!playerId) throw new Error("playerId est requis."); if (this.isConfigured) { try { // Firestore ne supporte pas OR sur des champs différents avec une seule query // On fait deux requêtes et on fusionne const [sentSnapshot, receivedSnapshot] = await Promise.all([ this.db.collection("trades") .where("fromPlayer.id", "==", playerId) .where("status", "==", "pending") .orderBy("createdAt", "desc") .get(), this.db.collection("trades") .where("toPlayer.id", "==", playerId) .where("status", "==", "pending") .orderBy("createdAt", "desc") .get() ]); const sent = sentSnapshot.docs.map(doc => ({ tradeId: doc.id, direction: "sent", ...doc.data() })); const received = receivedSnapshot.docs.map(doc => ({ tradeId: doc.id, direction: "received", ...doc.data() })); // Fusionne et trie par date décroissante const all = [...sent, ...received]; all.sort((a, b) => { const da = a.createdAt?.toDate ? a.createdAt.toDate() : new Date(a.createdAt); const db = b.createdAt?.toDate ? b.createdAt.toDate() : new Date(b.createdAt); return db - da; }); return all; } catch (err) { console.error("[Alice] Erreur récupération échanges:", err); throw new Error("Impossible de récupérer les échanges en cours."); } } else { const allTrades = _localGet(LOCAL_TRADES_KEY, {}); return Object.values(allTrades) .filter(t => t.status === "pending" && (t.fromPlayer.id === playerId || t.toPlayer.id === playerId) ) .map(t => ({ ...t, direction: t.fromPlayer.id === playerId ? "sent" : "received" })) .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } }, /** * Écoute en temps réel les échanges d'un joueur (pending uniquement). * Appelle le callback à chaque mise à jour. * Retourne une fonction pour arrêter l'écoute (unsubscribe). * * @param {string} playerId * @param {Function} callback Reçoit (trades: Array, error: Error|null) * @returns {Function} unsubscribe — appeler pour arrêter le listener */ listenToTrades(playerId, callback) { if (!playerId) throw new Error("playerId est requis."); if (typeof callback !== "function") throw new Error("callback doit être une fonction."); if (!this.isConfigured) { // Mode local : pas de temps réel, on retourne les données locales immédiatement this.getMyTrades(playerId) .then(trades => callback(trades, null)) .catch(err => callback([], err)); // Retourne une fonction vide (pas de listener à arrêter) return () => {}; } // Deux listeners Firestore (sent + received) fusionnés let sentTrades = []; let receivedTrades = []; let initialized = [false, false]; const merge = () => { if (!initialized[0] || !initialized[1]) return; const all = [...sentTrades, ...receivedTrades]; all.sort((a, b) => { const da = a.createdAt?.toDate ? a.createdAt.toDate() : new Date(a.createdAt); const db = b.createdAt?.toDate ? b.createdAt.toDate() : new Date(b.createdAt); return db - da; }); callback(all, null); }; const unsubSent = this.db.collection("trades") .where("fromPlayer.id", "==", playerId) .where("status", "==", "pending") .orderBy("createdAt", "desc") .onSnapshot( (snapshot) => { sentTrades = snapshot.docs.map(doc => ({ tradeId: doc.id, direction: "sent", ...doc.data() })); initialized[0] = true; merge(); }, (err) => { console.error("[Alice] Listener échanges envoyés:", err); callback([], err); } ); const unsubReceived = this.db.collection("trades") .where("toPlayer.id", "==", playerId) .where("status", "==", "pending") .orderBy("createdAt", "desc") .onSnapshot( (snapshot) => { receivedTrades = snapshot.docs.map(doc => ({ tradeId: doc.id, direction: "received", ...doc.data() })); initialized[1] = true; merge(); }, (err) => { console.error("[Alice] Listener échanges reçus:", err); callback([], err); } ); // Retourne la fonction d'arrêt return () => { unsubSent(); unsubReceived(); }; } }; // ============================================================ // EXPORT // ============================================================ window.FirebaseDB = FirebaseDB; if (typeof module !== "undefined") module.exports = FirebaseDB;