Transferts, mercato et premium
Cette page documente les règles vérifiées côté backend (Convex) pour les mouvements de joueurs. Sources principales :convex/social/transfers.tsconvex/social/transfers_core.tsconvex/social/transfers_queries.tsconvex/social/transfers_effects.tsconvex/social/recruitment_policy.tsconvex/social/contracts.tsconvex/lib/transfer_windows.tsconvex/_generated/ai/guidelines.md(lu avant toute modification backend)
Types et statuts
Types de mouvement :INVITATIONJOIN_REQUESTTRANSFER_REQUESTKICKLEAVE
PENDINGAPPROVEDEXECUTEDREJECTEDCANCELLEDEXPIRED
Matrice d’autorisation des flux recrutement
Règles de base côté mut. createInvitation
- acteur authentifié requis.
- autorisation sur le club cible via
assertActorCanManageClub(..., includeCoach: true). - rôles acceptés : GM,
MANAGER,CO_MANAGER,COACH,ADMIN.
Règles de base côté mut. createJoinRequest
- acteur authentifié requis.
- joueur cible résolu par
playerProfileIdou joueur connecté. - si acteur non-admin, l’acteur doit être le joueur visé.
- pas de validation de staff requis côté création, uniquement restriction joueur/adversaire.
respond (INVITATION / JOIN_REQUEST)
INVITATION: seul le joueur invité (actor.id === playerProfile.userId) ouADMIN.JOIN_REQUEST: staff de club cible viaassertActorCanManageClub(..., includeCoach: true).
cancel (INVITATION / JOIN_REQUEST)
- le joueur concerné peut annuler sa demande/invitation.
- le staff de gestion recrutement du club (GM + managers + co-managers + coaches + admin via autorisation) peut annuler.
(
⚪ : non requis par ce flux en création ; non une exemption générale.)
Différence Invitation vs Join Request
createInvitation (club → joueur)
- Vérifie :
- profil joueur actif (compte actif).
- pas déjà actif dans le club cible.
- pas de mouvement
INVITATIONdéjà en attente pour ce club. - capacité d’effectif.
- Cas joueur déjà en activité ailleurs :
- bloqué sauf si joueur premium +
openToOffers === true+ staff invitant pas issu du club actuel du joueur.
- bloqué sauf si joueur premium +
- Calcule TTL via
resolveMovementTtlHours('INVITATION', leagueId)+ borne fenêtre (si active). - Sans TTL spécifique de ligue, le défaut est
TRANSFER_EXPIRY_HOURS = 72heures. - Crée
INVITATIONenPENDING. sourceManagerDecisionresteundefined;playerDecisionàPENDING.
createJoinRequest (joueur → club)
- Vérifie :
- acteur peut agir sur ce joueur.
- le joueur ne doit pas avoir de membership actif.
- un seul
JOIN_REQUESTen attente par joueur+club. - capacité d’effectif.
- Calcule TTL via
resolveMovementTtlHours('JOIN_REQUEST', leagueId)+ borne fenêtre. - Sans TTL spécifique de ligue, le défaut est
TRANSFER_EXPIRY_HOURS = 72heures. - Crée
JOIN_REQUESTenPENDING. playerDecisionabsent ;sourceManagerDecision = PENDING.
Réponse
INVITATION: acceptation ou refus côté joueur.JOIN_REQUEST: acceptation/ refus côté staff du club cible.
Cap effectif + passage de plafond
- Vérification centrale
assertClubRosterCapacityAllowsJoin:- cap =
league.maxActiveRoster(+10 si club premium). - compte seulement les memberships actifs (
IN_CLUB,leftAtabsent), puis soustrait le joueur concerné.
- cap =
- Rejet
CLUB_ROSTER_LIMIT_REACHEDsi plafond atteint. - Champ
premiumBonusfourni en erreur pour distinguer cap premium (10) vs non premium (0).
Annulation / expiration
Mécanisme d’expiration
ensureTransferPendingAndNotExpired:- si
expiresAt <= nowsur un transfertPENDING⇒ patchstatus = EXPIRED. - envoi évènements
transfer.invitation.expiredoutransfer.join_request.expired.
- si
respondvérifie d’abord l’expiration (timestamp), puis la fonctionensure...(sécurité complémentaire).cancelappelle aussiensure....
respond
status !== PENDING: retourne le mouvement tel quel.- refus :
status = REJECTED,- décision
playerDecisionousourceManagerDecisionmise à jour selon type, - audit
REJECT_TRANSFER.
- acceptation :
- vérifie fenêtre de mercato (sauf conditions de bypass détaillées ci-dessous),
- vérifie capacité,
- ferme membership source si joueur actif ailleurs + openToOffers premium,
- crée membership
IN_CLUB+role = MEMBER, - passe mouvement en
EXECUTED, - annule les autres mouvements en attente du joueur (
CANCELLED) avec événements de cascade.
cancel
- statut final
CANCELLED, - acteur : joueur visé ou staff club,
- événement dédié, audit
REJECT_TRANSFERavecdecision: CANCELLED.
Règles hors fenêtre, premium, openToOffers
Politique centralisée dansrecruitment_policy.ts via assertOffWindowRecruitmentAllowed :
- pendant fenêtre de ligue : autorisation directe.
- hors fenêtre, selon le nombre de passages de saison (invité/jouable, exécutions
INVITATION+JOIN_REQUEST) :- 0 passage : autorisé.
- 1 passage :
- joueur premium : autorisé.
- joueur non-premium :
- club non premium : refus,
- club premium : autorisé si quota club non utilisé (
< 1recrutement non-premium hors fenêtre dans la saison).
- 2+ passages : refus (
OFF_WINDOW_PLAYER_TOO_MANY_CLUBS_THIS_SEASON).
openToOffers (joueur) :
- utilisé en création d’invitation quand joueur actif ailleurs :
- inviteur doit pouvoir agir et ne pas être staff du club actuel du joueur,
- joueur doit être premium.
- lors de l’acceptation d’invitation sans
sourceClubId, la vérif off-window s’applique toujours viaassertOffWindowRecruitmentAllowed.
- un joueur premium actif ailleurs peut recevoir une invitation hors fenêtre selon ces règles, puis être libéré de son ancien(s) club(s) après acceptation.
- Listing free agents (
/mercato→ onglet « Recherche »,social/freeAgents:list) : un joueur non recrutable hors fenêtre (typiquement 2+ passages,OFF_WINDOW_PLAYER_TOO_MANY_CLUBS_THIS_SEASON) n’est plus masqué — il est affiché avecrecruitable: false+recruitmentBlockReason(le message de refus). La carte le marque « NON RECRUTABLE » et toute tentative d’invitation déclenche un toast explicatif ; le recrutement reste refusé côté serveur viaassertOffWindowRecruitmentAllowed. Seul le refus pour contexte ligue manquant (OFF_WINDOW_LEAGUE_CONTEXT_REQUIRED) reste masqué (erreur de configuration, pas une propriété du joueur). - La recherche du listing est poussée au serveur (nom d’affichage + username + gamertag EA) et bornée (cap + « Charger plus »), afin qu’un agent libre reste trouvable au-delà de la première page. La comparaison compacte les valeurs avant de les vérifier :
fuzetearetrouve par exemple le gamertagfu-ze-tea_h21. Le parcours manager lit la clé compacte dédiée via l’indexby_eaGamertagSearchKey, avec une lecture bornée de l’ancien index pendant le backfill des profils existants ; il ne déclenche aucun scan global.
Premium contract break
MutationpremiumBreak (convex/social/contracts.ts) :
- acteur doit être un joueur premium (
users.isPremium). - nécessite un club actif et un contexte de saison de ligue.
- une seule utilisation max par saison (
premium_contract_breaksuniqueplayerProfileId + season). - fermeture de memberships actifs du club actuel (
IN_CLUB -> FREE_AGENT). - création d’un transfert
LEAVEenEXECUTED(reason = PREMIUM_BREAK_CONTRACT). - création
premium_contract_breakset audit. - événement
transfer.premium_break.executed.
Contrats frontend observés (non règles produit)
- Les hooks club
useClubInvitations/useClubJoinRequestsinterrogentlistForClubInboxavecstatus: PENDING, unlimitde100et, selon la surface, un filtretype. Le centre de recrutement affiche donc uniquement les mouvements encore actionnables ; les statuts finaux relèvent des vues d’historique mercato/admin. ClubRecruitment.tsxreconstruit certains objetsclubaveclogoau lieu delogoUrlattendu par le modèle d’enrichissement.ClubRecruitmentforce localementtype(JOIN_REQUEST/INVITATION) au moment du rendu ; ça masque la valeur venue du backend.- Déviation mineure de contrat front-end : enum de status front contient
ACCEPTEDalors que les statuts persistés validés sont ceux deTRANSFER_STATUS_VALUES(PENDING,APPROVED,EXECUTED,REJECTED,CANCELLED,EXPIRED). filterActiveInvitationsest appliqué dans la Navbar et la page profil pour masquer localement les invitationsPENDINGdontexpiresAtest dépassé. Les inbox club s’appuient plutôt sur le filtre backendPENDINGet le cycle d’expiration Convex.
Premium Stripe (portée)
L’état premium global du club/joueur peut passer par synchronisation Stripe (users.isPremium, clubs.isPremium) ; les statuts Premium éligibles côté Stripe reconnus côté code existent (trialing, active), les autres se comportent comme non-premium selon implémentation.
Remarque
L’écart entre logique backend (TRANSFERS) et représentation front doit être traité au cas par cas, sans en faire une règle métier tant que l’alignement n’est pas décidé.