Skip to main content

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.ts
  • convex/social/transfers_core.ts
  • convex/social/transfers_queries.ts
  • convex/social/transfers_effects.ts
  • convex/social/recruitment_policy.ts
  • convex/social/contracts.ts
  • convex/lib/transfer_windows.ts
  • convex/_generated/ai/guidelines.md (lu avant toute modification backend)

Types et statuts

Types de mouvement :
  • INVITATION
  • JOIN_REQUEST
  • TRANSFER_REQUEST
  • KICK
  • LEAVE
Statuts possibles :
  • PENDING
  • APPROVED
  • EXECUTED
  • REJECTED
  • CANCELLED
  • EXPIRED

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 playerProfileId ou 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) ou ADMIN.
  • JOIN_REQUEST : staff de club cible via assertActorCanManageClub(..., 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.
Vue compacte : ( : 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 INVITATION dé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.
  • Calcule TTL via resolveMovementTtlHours('INVITATION', leagueId) + borne fenêtre (si active).
  • Sans TTL spécifique de ligue, le défaut est TRANSFER_EXPIRY_HOURS = 72 heures.
  • Crée INVITATION en PENDING.
  • sourceManagerDecision reste undefined ; 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_REQUEST en 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 = 72 heures.
  • Crée JOIN_REQUEST en PENDING.
  • playerDecision absent ; 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, leftAt absent), puis soustrait le joueur concerné.
  • Rejet CLUB_ROSTER_LIMIT_REACHED si plafond atteint.
  • Champ premiumBonus fourni en erreur pour distinguer cap premium (10) vs non premium (0).

Annulation / expiration

Mécanisme d’expiration

  • ensureTransferPendingAndNotExpired :
    • si expiresAt <= now sur un transfert PENDING ⇒ patch status = EXPIRED.
    • envoi évènements transfer.invitation.expired ou transfer.join_request.expired.
  • respond vérifie d’abord l’expiration (timestamp), puis la fonction ensure... (sécurité complémentaire).
  • cancel appelle aussi ensure....

respond

  • status !== PENDING : retourne le mouvement tel quel.
  • refus :
    • status = REJECTED,
    • décision playerDecision ou sourceManagerDecision mise à 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_TRANSFER avec decision: CANCELLED.

Règles hors fenêtre, premium, openToOffers

Politique centralisée dans recruitment_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é (< 1 recrutement 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 via assertOffWindowRecruitmentAllowed.
Conséquence observée :
  • 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é avec recruitable: 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 via assertOffWindowRecruitmentAllowed. 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 : fuzetea retrouve par exemple le gamertag fu-ze-tea_h21. Le parcours manager lit la clé compacte dédiée via l’index by_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

Mutation premiumBreak (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_breaks unique playerProfileId + season).
  • fermeture de memberships actifs du club actuel (IN_CLUB -> FREE_AGENT).
  • création d’un transfert LEAVE en EXECUTED (reason = PREMIUM_BREAK_CONTRACT).
  • création premium_contract_breaks et audit.
  • événement transfer.premium_break.executed.

Contrats frontend observés (non règles produit)

  • Les hooks club useClubInvitations / useClubJoinRequests interrogent listForClubInbox avec status: PENDING, un limit de 100 et, selon la surface, un filtre type. Le centre de recrutement affiche donc uniquement les mouvements encore actionnables ; les statuts finaux relèvent des vues d’historique mercato/admin.
  • ClubRecruitment.tsx reconstruit certains objets club avec logo au lieu de logoUrl attendu par le modèle d’enrichissement.
  • ClubRecruitment force localement type (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 ACCEPTED alors que les statuts persistés validés sont ceux de TRANSFER_STATUS_VALUES (PENDING, APPROVED, EXECUTED, REJECTED, CANCELLED, EXPIRED).
  • filterActiveInvitations est appliqué dans la Navbar et la page profil pour masquer localement les invitations PENDING dont expiresAt est dépassé. Les inbox club s’appuient plutôt sur le filtre backend PENDING et 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é.
Last modified on August 10, 2026