JamesNK / Newtonsoft.Json

Json.NET is a popular high-performance JSON framework for .NET
https://www.newtonsoft.com/json
MIT License
10.64k stars 3.24k forks source link

JsonConvert.DeserializeObject appends value for no reason #2966

Closed benjamin-achouche closed 2 weeks ago

benjamin-achouche commented 2 weeks ago

Hello,

I'm facing a weird issue with JsonConvert.DeserializeObject.

I'm using it in the context of loading/saving game save files. The issue occurs with this :

loadedData = JsonConvert.DeserializeObject<GameData>(dataToLoad);

dataToLoad and loadedData are supposed to contain the exact same thing, except that one is a json and the other an instance of GameData.

If I make it simple, before the deserialization, the data is :

{
  "index": 0,
  "infos": null,
  "masteryGauges": null,
  "upgrades": null,
  "actionStages": {
    "Shadow": {
      "Act": {
        "ShadowAct1": {
          "missions": [
            {
              "bestScore": 1234,
              "bestTime": 56.0,
              "bestRings": 78,
              "bestTotalScore": 910,
              "bestRank": 2,
              "isCompleted": true
            },
            {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
            {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
          ],
          "isUnlocked": true
        }
      }
    }
  },
  "episodes": null,
  "events": null,
  "settings": null
}

And after :

{
  "index": 0,
  "infos": null,
  "masteryGauges": null,
  "upgrades": null,
  "actionStages": {
    "Shadow": {
      "Act": {
        "ShadowAct1": {
          "missions": [
           {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
            {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
            {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
            {
              "bestScore": 1234,
              "bestTime": 56.0,
              "bestRings": 78,
              "bestTotalScore": 910,
              "bestRank": 2,
              "isCompleted": true
            },
            {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
            {
              "bestScore": 0,
              "bestTime": 0.0,
              "bestRings": 0,
              "bestTotalScore": 0,
              "bestRank": 0,
              "isCompleted": false
            },
          ],
          "isUnlocked": true
        }
      }
    }
  },
  "episodes": null,
  "events": null,
  "settings": null
}

Can you see the 3 items in the actionsStages.Shadow.Act.ShadowAct1.missions that have been added before the ones that were already there ? (See how the 4th item's values are the previous's first item's values)

Here's my GameData class (I purposefully removed the other methods, as they are commented anyway) :

using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;

public class GameInfo {
  public Character lastCharacterPlayed = Character.Sonic;
  public float totalPlayTime = 0f;
  public DateTime lastSave = default;

  public GameInfo() {
  }
}

public class GameData {

  public int index;
  public GameInfo infos;
  public Dictionary<Emerald, float> masteryGauges;
  public Dictionary<Character, Dictionary<Upgrade, bool>> upgrades // Upgrades sorted by character
  public Dictionary<Character, Dictionary<ActionStageType, Dictionary<SceneName, ActionStageSaveData>>> actionStages // Stages sorted by character and type
  public Dictionary<Character, EpisodeSaveData> episodes;

  public Events events;
  public Settings settings;

  public GameData(int index) {
    this.index = index;
  }

  internal void InitProps() {
    actionStages = InitActionStages();
  }

  private static Dictionary<Character, Dictionary<ActionStageType, Dictionary<SceneName, ActionStageSaveData>>> InitActionStages() {
    return new() {
      { Character.Shadow, new() { { ActionStageType.Act, new() { { SceneName.ShadowAct1, new() } } } } }
    };
  }

  private static Dictionary<ActionStageType, Dictionary<SceneName, ActionStageSaveData>> GetActionStageTypeDico(List<ActionStageData> stages) {
    Dictionary<ActionStageType, Dictionary<SceneName, ActionStageSaveData>> typeDico = new();

    List<ActionStageData> actStages = stages.Where(stage => stage.type == ActionStageType.Act).ToList();
    List<ActionStageData> townStages = stages.Where(stage => stage.type == ActionStageType.Town).ToList();
    List<ActionStageData> bossStages = stages.Where(stage => stage.type == ActionStageType.Boss).ToList();

    typeDico.Add(ActionStageType.Act, GetSceneNameDico(actStages));
    typeDico.Add(ActionStageType.Town, GetSceneNameDico(townStages));
    typeDico.Add(ActionStageType.Boss, GetSceneNameDico(bossStages));

    return typeDico;
  }

  private static Dictionary<SceneName, ActionStageSaveData> GetSceneNameDico(List<ActionStageData> stages) {
    Dictionary<SceneName, ActionStageSaveData> sceneNameDico = new();

    foreach (ActionStageData stage in stages) {
      sceneNameDico.Add(stage.sceneName, new());
    }

    return sceneNameDico;
  }

}

And now here's the ActionStageSaveData :

public class ActionStageSaveData {

  public List<ActionStageSaveMission> missions = new() { new(), new(), new() };
  public bool isUnlocked = true;
  internal bool isCompleted => missions.Any(m => m.isCompleted);
  internal bool isAllCompleted => missions.All(m => m.isCompleted);
  internal bool isAllSRanks => missions.All(m => m.bestRank == Rank.S);

  public ActionStageSaveData() {
  }

  internal void Set(int i, int bestScore, float bestTime, int bestRingCount, int bestTotalScore, Rank bestRank) {
    missions[i].bestScore = bestScore;
    missions[i].bestTime = bestTime;
    missions[i].bestRings = bestRingCount;
    missions[i].bestTotalScore = bestTotalScore;
    missions[i].bestRank = bestRank;
    missions[i].isCompleted = true;
  }

}

And finally the ActionStageSaveMission class :

public class ActionStageSaveMission {

  public int bestScore = 0;
  public float bestTime = 0f;
  public int bestRings = 0;
  public int bestTotalScore = 0;
  public Rank bestRank = Rank.D;
  public bool isCompleted = false;

}

Well can you see the

public List<ActionStageSaveMission> missions = new() { new(), new(), new() };

For some reason I can't explain, the quantity of added items is the quantity of "new()" in the new() { }

For example, if I change it to

missions = new() { new() }

only 1 item would be added.

EDIT: Here's the full Load method too :

internal static GameData Load(int index) {
    string fullPath = System.IO.Path.Combine(dataDirPath, $"{dataFileName}{""}{index}{fileExtension}");
    GameData loadedData = null;

    if (File.Exists(fullPath)) {
      try {
        string dataToLoad = "";

        using (FileStream stream = new(fullPath, FileMode.Open)) {
          using (StreamReader reader = new(stream)) {
            dataToLoad = reader.ReadToEnd();
          }
        }

        loadedData = JsonConvert.DeserializeObject<GameData>(dataToLoad);
      } catch (Exception e) {
        Debug.LogError("An error occured when trying to load data from file: " + fullPath + "\n" + e);
      }
    }

    return loadedData;
  }

Also, it only happens with this "actionStages.Character.ActionStageType.SceneName.missions" data (here being "actionStages.Shadow.Act.ShadowAct1.missions"). The others work fine. Though this one is the only 3-level nested dictionaries. No idea why it would matter though, nor why it would use the new(), new() thing either...

elgonzo commented 2 weeks ago

It looks like you are one of many who got surprised by Newtonsoft.Json's default behavior regarding the ObjectCreationHandling setting (https://www.newtonsoft.com/json/help/html/SerializationSettings.htm#ObjectCreationHandling). (Just search this issue tracker here for any open or closed issue that mentions ObjectCreationHandling, and you'll see how many people got suprised about this like you did.)

For a more detailed explanation of what's going on here, see this comment: https://github.com/JamesNK/Newtonsoft.Json/issues/2706#issuecomment-1183252408

Essentially, to get the desired outcome, you will have to set Newtonsoft.Json's ObjectCreationHandling setting to ObjectCreationHandling.Replace.

(Side note: It's probably a good idea to read about all of the serialization settings and their default values/behaviors in the Newtonsoft.Json documentation, because the defaults can sometimes be different from what many would intuitively expect them to be. It would potentially save you from being surprised again by some other default behavior of Newtonsoft.Json.)

benjamin-achouche commented 2 weeks ago

Yes that seemed to have fixed it, thank you so much !