--- name: multiplayer-migration description: Migration du jeu solo vers le multijoueur via FishNet + Steam (en cours, démarré juin 2026) metadata: type: project --- Le jeu de survie Ashwild passe en multijoueur coop. Stack réseau choisie : - **FishNet 4.7.2** (déjà installé dans `Assets/FishNet/`) - **Steamworks.NET** (wrapper API Steam) — choisi plutôt que Facepunch.Steamworks - **FishySteamworks** (transport FishNet, utilise SteamNetworkingSockets → relais Steam, pas de port forwarding) Objectif matchmaking : **lobby Steam** offrant à la fois 1. un **code de session** partageable, et 2. les **invitations Steam natives** (overlay « Inviter », clic droit → « Rejoindre » dans la liste d'amis, lancement via invitation). Contraintes projet : bêta-testeurs dans ~2 semaines → priorité stabilité. Tout passe par le bus `PlayerEvents` (voir CLAUDE.md). **App IDs Steam** (dans `steam_appid.txt` à la racine) : - **App ID actif = `4282850`** = vrai jeu « Aetheris » (app privée/incomplète sur Steamworks). Choisi pour les **tests internes** : le user préfère tester sur son propre jeu Steam. Chaque testeur doit avoir accès à l'app (collaborateur Steamworks ou app en bibliothèque), sinon `SteamAPI.Init()` échoue chez lui. - `480` (Spacewar) = alternative de dev (accès gratuit pour tous) si besoin de tester avec des comptes sans accès à l'app. **Phase 1 (fondation) — code écrit, scripts dans `Assets/GAME/Script/Network/`** : - `GameSession.cs` : holder statique (slot choisi, Host/Join, code) qui survit au chargement de scène. - `NetworkSessionManager.cs` : singleton (sur le GameObject NetworkManager). `HostSession()` / `JoinSession(code)` / `StopSession()`. Démarre server+client, charge la GameScene en réseau via `SceneManager.LoadGlobalScenes` (ReplaceOption.All), génère le code = SteamID64, logs `[NetworkSession]`, branché sur le bus. - `PlayerSpawner.cs` : sur le NetworkManager. S'abonne à `OnClientLoadedStartScenes`, spawn le prefab joueur (NetworkObject) par connexion à un `PlayerSpawnPoint`. - `PlayerSpawnPoint.cs` : marqueur de point de spawn (avec gizmo). - `PlayerNetworkController.cs` : NetworkBehaviour sur le joueur. En `OnStartClient`, n'active les composants « owner-only » (input, caméra, audio, stats, inventaire…) que si `IsOwner`. Liste à câbler dans l'inspector. - `SavePanel.LoadGame` modifié : appelle `NetworkSessionManager.Instance.HostSession()` (repli `SceneManager.LoadScene` si pas câblé). - Bus `PlayerEvents` étendu : états `IsOnline/IsHost/SessionCode` + events `SessionStarting/Started/Joining/Joined/Stopped/Error`, `RemotePlayerCountChanged`, `LocalPlayerSpawned`. **Correctif Phase 1 (NRE au spawn)** : les UI de scène (`HotbarUI`, `InventoryUI`, `CraftingUI`, `DeathScreen`, `PlayerStatsUI`) lisaient `PlayerInventory.Instance`/`PlayerStats.Instance` dans `Start()` → NRE car le joueur est spawné par le réseau APRÈS le chargement de scène. Corrigé : init dépendante du joueur différée via `PlayerEvents.LocalPlayerSpawned` (+ flag `bound`, + repli si l'Instance existe déjà au Start). **Dette technique Phase 2 (validée avec le user)** : le pattern « chaque UI attend LocalPlayerSpawned puis se bind aux singletons » marche mais répète du boilerplate. Version propre à faire plus tard : router les UI **uniquement via le bus statique `PlayerEvents`** (events `HealthChanged`/`HungerChanged`/`ThirstChanged`/`InventorySlotChanged`/`SelectedHotbarSlotChanged` existent déjà) au lieu des singletons `.Instance` + leurs `UnityEvent` d'instance → supprime tout le problème de timing. Option : classe de base `PlayerBoundUI` si on garde le pattern actuel. **Refacto Crafting (couplage UI/manager, validé user)** : `CraftingUI` est devenue une vue 100% bête (reçoit des délégués `canCraft`/`countItem`/`craft` du manager, ne connaît plus ni `PlayerInventory` ni `CraftingManager`). `CraftingManager` est le contrôleur : possède `recipes` + une réf `craftingUI`, `Build()` la vue, gère l'attente joueur + refresh sur changement d'inventaire. Manip Unity : assigner `Crafting UI` sur le CraftingManager. **Pickables en réseau (Phase 1)** : `Pickable` est passé en `NetworkBehaviour`. Ramassage **autoritaire serveur** : `Interact()`→`Pickup()`→`RequestPickupServerRpc(NetworkConnection conn)` (RequireOwnership=false) → le serveur résout l'inventaire du joueur via `conn.FirstObject.GetComponent()` (PAS le singleton), `AddItem`, puis `Despawn()` (garde `taken` anti-double). `PlayerInventory.DropItem` spawne désormais le pickable via `InstanceFinder.ServerManager.Spawn` quand il est serveur (sinon warning + abort, Phase 2). Manips Unity : ajouter `NetworkObject` aux prefabs/instances Pickable + drop prefabs (`item.WorldPrefab`), et Refresh Default Prefabs pour les drops. - Limite Phase 2 : pour un client distant, l'`AddItem` se fait côté serveur sur sa copie de `PlayerInventory` → ne remonte pas tant que l'inventaire n'est pas réseau ; le **type d'item** d'un drop runtime ne se réplique pas aux autres clients sans item-id SyncVar. **Phase 2 — Inventaire réseau (approche CLIENT-AUTHORITATIVE, validée user)** : pas de SyncList serveur-auth (trop lourd + faudrait réseauter le crafting). À la place : - `ItemDatabase` (Resources/ItemDatabase.asset) = registre `id ↔ ItemData`, rempli par **Tools ▸ Ashwild ▸ Rebuild Item Database** (`ItemDatabaseBuilder`). Nécessaire car on ne peut pas envoyer un ScriptableObject sur le réseau. - `PlayerInventory` est passé `MonoBehaviour` → `NetworkBehaviour`. Slots restent **locaux** (tout le code crafting/use/hotbar inchangé). Singleton posé **uniquement pour l'owner** en `OnStartNetwork` (plus de clobber Awake destructeur). `GrantItemFromServer(item,qty)` (serveur) → `TargetGrantItem` (owner) → `AddItem` local. `DropItem` → `SpawnDropServerRpc` (serveur spawn le pickable). Ajout `CanFit()`. - `Pickable` : `RequestPickupServerRpc` → serveur résout l'inventaire via `conn.FirstObject`, `GrantItemFromServer`, `Despawn`. SyncVar `syncItemId` pour que les drops s'affichent correctement chez les autres clients. Pré-check `CanFit` côté client avant de demander. - Compromis assumé : inventaire pas anti-triche (ok coop entre amis). **Pickups UNIFIÉS (validé user : un seul script pour tout)** : on ne peut PAS faire de chaque objet un NetworkObject (limite FishNet 254 imbriqués + trop lourd pour le scatter de masse). `Pickable` ET `ScatterPickup` SUPPRIMÉS, remplacés par UN seul `WorldPickup` + le registre : - `WorldPickup` (MonoBehaviour, PAS réseau, IInteractable) : pour TOUT (scatter, posés main, drops). `id >= 0` = pickup de scène (baké) ; `id < 0` = drop runtime. `Interact` → `registry.RequestPickupServerRpc(id)`. `HideAsClaimed()`/`InitializeAsDrop()`. - `WorldPickupRegistry` (NetworkBehaviour, 1 par scène, dans `Assets/GAME/Script/Network/`) : `SyncHashSet pickedSceneIds` + `SyncDictionary activeDrops`. `RequestPickupServerRpc(id)` (serveur résout l'item depuis sa copie de scène ou le record drop, `GrantItemFromServer`). `RequestDropServerRpc(itemId,qty,pos,rot)` (serveur ajoute à activeDrops). OnChange/OnStartClient → cache scène / instancie ou détruit les visuels de drop localement. - `WorldPickupIdAssigner` (éditeur) : **Tools ▸ Ashwild ▸ Assign World Pickup IDs**. - `PlayerInventory.DropItem` → `WorldPickupRegistry.Instance.RequestDropServerRpc(...)` (plus de SpawnDropServerRpc/Pickable). - Conséquence : UN seul prefab par item suffit (le même `WorldPickup` sert au scatter ET de `ItemData.WorldPrefab` pour les drops). - Renommé ensuite `WorldPickup`→`Pickable`, `WorldPickupRegistry`→`PickableRegistry`. **Base partagée + Harvestables réseau (validé user : code clean, polymorphisme, pas de god-object)** : - `WorldObject` (abstract MonoBehaviour) : base de tous les objets de monde locaux (id baké, `HideAsInactive(bool fresh)`, `ShowActive()`, `GetRegistry()` abstrait, `ShouldAutoRegister`, `SetId`). Dossier `Network/`. - `WorldObjectRegistry` (abstract NetworkBehaviour, RequireComponent NetworkObject) : plomberie commune = `SyncHashSet inactiveIds`, dict `registered`, catch-up OnStartClient, OnChange→hide/show, `MarkInactive/MarkActive`, `ResolveInventory`. `Awake/OnDestroy` virtuels (le singleton typé est posé par la sous-classe). - `Pickable : WorldObject` + `PickableRegistry : WorldObjectRegistry` (ajoute `activeDrops` SyncDictionary + RPC pickup/drop). - `Harvestable : WorldObject` : devient data+feedback (MaxHealth/CanRespawn/RespawnDelay/RollDrops/PlayHitEffect/PlayBlockedFeedback) ; vie/depletion/respawn RETIRÉS (→ registre). `HideAsInactive(fresh)` joue `onDepleted` seulement si `fresh`. - `HarvestableRegistry : WorldObjectRegistry` : vie serveur (`Dictionary`), `RequestHitServerRpc(id,damage,toolId,hitDir)` (dégâts client-auth, loot+état serveur-auth), `PlayHitObserversRpc` (feedback chez tous SAUF le frappeur via `LocalConnection.ClientId`), respawn coroutine. - `ToolBehaviour.TryHarvest` : blocked→local ; sinon `PlayHitEffect` local immédiat + `HarvestableRegistry.Instance.RequestHitServerRpc(...)`. - Outil unique **Tools ▸ Ashwild ▸ Assign World Object IDs** (`WorldObjectIdAssigner`, remplace PickableIdAssigner) : ids sur TOUS les `WorldObject` (Pickable+Harvestable). - Risque à valider au 1er compile : SyncTypes répartis base/sous-classe NetworkBehaviour (héritage non générique — normalement OK ; repli = remettre les SyncTypes dans les sous-classes). - Ennemis = hors registre (NetworkObjects mobiles, futur). - Manips Unity : poser 1 `PickableRegistry` + 1 `HarvestableRegistry` dans TestScene ; `Pickable`/`Harvestable` sur les prefabs (id -1) ; (re)placer → Assign World Object IDs → save scene. **✅ PlayerStats networké (FAIT, juin 2026)** : `PlayerStats` est passé `MonoBehaviour`→`NetworkBehaviour`, même pattern que PlayerInventory (client-authoritative). `Awake` destructeur SUPPRIMÉ ; `Instance` posé owner-only en `OnStartNetwork`, nettoyé en `OnStopNetwork`. Simulation (drain/dégâts/regen) inchangée mais gardée par `if (IsSpawned && !IsOwner) return;` (remotes ne simulent pas ; hors-ligne IsSpawned=false → tourne quand même pour les test scenes). Ajout SyncVars `netHealth/netHunger/netThirst/netDead` : l'owner pousse un snapshot via `PushStatsServerRpc` (throttle `replicationInterval`=0.2s + push immédiat sur dégâts/mort/revive) → les autres clients mirrorent via `OnChange` (met à jour `currentHealth` local + invoke les `UnityEvent` d'instance, JAMAIS le bus statique `PlayerEvents`). `OnStartServer` seed les SyncVars à max pour les late joiners. → barres de vie d'équipiers possibles plus tard en lisant `playerStats.HealthNormalized` sur le NetworkObject distant. Manip Unity : vérifier que `PlayerStats` est bien dans la liste `ownerOnlyBehaviours` du `PlayerNetworkController`, rouvrir Unity pour le codegen FishNet. **Fix PlayerEvents (FAIT)** : ajout `ResetPlayerState()` appelé dans `RaiseSessionStopped` → reset des flags joueur-local statiques (IsDead/IsInventoryOpen/IsPaused/IsGrounded…) entre deux sessions (sinon mourir puis quitter laissait l'état collé). **✅ Audit gating IsOwner (FAIT)** : le prefab `Player.prefab` confirme que `PlayerNetworkController.ownerOnlyBehaviours` contient TOUS les systèmes joueur (PlayerInputRouter, PlayerInput, PlayerLocomotion, PlayerCamera, PlayerToolHolder, **PlayerStats**, PlayerInventory, HotbarController, PlayerInteractor, PlayerAudio, **PlayerLifecycle**) et `ownerOnlyObjects` = Main Camera + AudioListener + Tools + Tools Camera. Donc les puppets distants sont passifs (désactivés). Gating solide. Held items (ToolBehaviour/ConsumableBehaviour) sous le holder Tools (désactivé sur remotes) → OK. - **Seul trou trouvé + corrigé** : `DeathScreen.BindToPlayer` faisait `FindFirstObjectByType()` → trouve aussi les Behaviours désactivés tant que leur GameObject est actif (les puppets distants le restent) → pouvait respawn le mauvais joueur. Remplacé par `PlayerStats.Instance.GetComponent()` (joueur local garanti). - Latent mineur restant : `PlayerInventory.Start` fait `FindAnyObjectByType()` (UI de scène unique, pas un objet joueur → OK mais fragile). Limite rejoin : `DeathScreen`/UI gardent `bound=true` après 1er spawn → pas de rebind si le joueur local despawn/respawn dans la même session. **Coquille corrigée** : en réécrivant `PlayerStats`, j'avais redéclaré `public enum DamageType` en bas du fichier → CS0101 (il vit déjà en bas de `PlayerEvents.cs`). Retiré. **Manips Unity restantes** : prefab→NetworkObject+NetworkTransform (déjà sur le prefab : NetworkObject + NetworkTransform clientAuthoritative présents ✓), NetworkManager dans MenuScene, retirer le joueur en dur de GameScene, PlayerSpawnPoint, enregistrer le prefab dans DefaultPrefabObjects. Rouvrir Unity pour le codegen FishNet (PlayerStats devenu NetworkBehaviour).