This commit is contained in:
2025-01-17 13:10:42 +01:00
commit 4536213c91
15115 changed files with 1442174 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
using System;
using Unity.Multiplayer.Center.Questionnaire;
using UnityEditor.PackageManager;
using UnityEngine;
namespace Unity.Multiplayer.Center.Recommendations
{
[Serializable]
internal class PreReleaseHandling
{
[SerializeReference]
PreReleaseHandlingBase[] m_PreReleaseHandlings =
{
};
public event Action OnAllChecksFinished;
public bool IsReady => m_PreReleaseHandlings != null &&
Array.TrueForAll(m_PreReleaseHandlings, p => p is {IsReady: true});
public void CheckForUpdates()
{
foreach (var package in m_PreReleaseHandlings)
{
package.OnCheckFinished += OnOnePackageVersionCheckFinished;
package.CheckForUpdates();
}
}
public void PatchPackages(RecommendationViewData toPatch)
{
foreach (var package in m_PreReleaseHandlings)
{
package.PatchPackages(toPatch);
}
}
public void PatchRecommenderSystemData()
{
foreach (var package in m_PreReleaseHandlings)
{
package.PatchRecommenderSystemData();
}
}
void OnOnePackageVersionCheckFinished()
{
var allVersionChecksDone = true;
foreach (var package in m_PreReleaseHandlings)
{
allVersionChecksDone &= package.IsReady;
if (package.IsReady)
{
package.OnCheckFinished -= OnOnePackageVersionCheckFinished;
}
}
if (allVersionChecksDone)
{
OnAllChecksFinished?.Invoke();
}
}
}
[Serializable]
internal abstract class PreReleaseHandlingBase
{
/// <summary>
/// Whether the versions data is ready to be used.
/// </summary>
public bool IsReady => m_VersionsInfo != null && !string.IsNullOrEmpty(m_VersionsInfo.latestCompatible);
/// <summary>
/// Triggered when this instance is ready
/// </summary>
public event Action OnCheckFinished;
/// <summary>
/// The package id e.g. com.unity.netcode
/// </summary>
public abstract string PackageId { get; }
/// <summary>
/// The minimum version that we target.
/// </summary>
public abstract string MinVersion { get; }
/// <summary>
/// The cached versions of the package.
/// </summary>
[SerializeField]
protected VersionsInfo m_VersionsInfo;
/// <summary>
/// version which would be installed by package manager
/// </summary>
[SerializeField]
protected string m_DefaultVersion;
PackageManagement.VersionChecker m_VersionChecker;
/// <summary>
/// Start (online) request to check for available versions.
/// </summary>
public void CheckForUpdates()
{
m_VersionChecker = new PackageManagement.VersionChecker(PackageId);
m_VersionChecker.OnVersionFound += OnVersionFound;
}
internal string GetPreReleaseVersion(string version, VersionsInfo versionsInfo)
{
if (version != null && version.StartsWith(MinVersion))
return null; // no need for a pre-release version
return versionsInfo.latestCompatible.StartsWith(MinVersion) ? versionsInfo.latestCompatible : null;
}
/// <summary>
/// Patch the recommendation view data directly.
/// </summary>
/// <param name="toPatch">The view data to patch</param>
public abstract void PatchPackages(RecommendationViewData toPatch);
/// <summary>
/// Patch the recommender system data, which will be used for every use of the recommendation.
/// </summary>
public abstract void PatchRecommenderSystemData();
protected virtual void BeforeRaisingCheckFinished() { }
void OnVersionFound(PackageInfo packageInfo)
{
m_DefaultVersion = packageInfo.version;
m_VersionsInfo = packageInfo.versions;
if (m_VersionChecker != null) // null observed in tests
m_VersionChecker.OnVersionFound -= OnVersionFound;
m_VersionChecker = null;
BeforeRaisingCheckFinished();
OnCheckFinished?.Invoke();
}
}
/// <summary>
/// Implementation that fetches unconditionally the version starting with a prefix for a given package, even if it
/// is an experimental package
/// </summary>
[Serializable]
internal class SimplePreReleaseHandling : PreReleaseHandlingBase
{
[SerializeField] string m_MinVersion;
[SerializeField] string m_PackageId;
public override string MinVersion => m_MinVersion;
public override string PackageId => m_PackageId;
public SimplePreReleaseHandling(string packageId, string minVersion)
{
m_PackageId = packageId;
m_MinVersion = minVersion;
}
private SimplePreReleaseHandling() { }
public override void PatchPackages(RecommendationViewData toPatch)
{
// Nothing to do, we only patch the package details in the recommender system data
}
public override void PatchRecommenderSystemData()
{
if (!IsReady) return;
var allPackages = RecommenderSystemDataObject.instance.RecommenderSystemData.Packages;
foreach (var package in allPackages)
{
if (package.Id == PackageId)
{
package.PreReleaseVersion = GetPreReleaseVersion(m_DefaultVersion, m_VersionsInfo);
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 23342257960b4b618e6f7c444137663c
timeCreated: 1718177126

View File

@@ -0,0 +1,272 @@
#if MULTIPLAYER_CENTER_DEV_MODE
using System;
using Unity.Multiplayer.Center.Questionnaire;
namespace Unity.Multiplayer.Center.Recommendations
{
/// <summary>
/// This file contains all data relevant for authoring recommendations.
/// </summary>
internal static class Packages
{
public const string VivoxId = "com.unity.services.vivox";
public const string MultiplayerSdkId = "com.unity.services.multiplayer";
public const string MultiplayerWidgetsId = "com.unity.multiplayer.widgets";
public const string NetcodeForEntitiesId = "com.unity.netcode";
public const string NetcodeGameObjectsId = "com.unity.netcode.gameobjects";
public const string MultiplayerToolsId = "com.unity.multiplayer.tools";
public const string MultiplayerPlayModeId = "com.unity.multiplayer.playmode";
public const string CloudCodeId = "com.unity.services.cloudcode";
public const string TransportId = "com.unity.transport";
public const string DeploymentPackageId = "com.unity.services.deployment";
public const string DedicatedServerPackageId = "com.unity.dedicated-server";
public const string EntitiesGraphics = "com.unity.entities.graphics";
}
internal static class Reasons
{
public const string MultiplayerPlayModeRecommended = "Multiplayer Play Mode enables you to iterate faster by testing locally without the need to create builds.";
public const string VivoxRecommended = "Vivox adds real-time communication and is always recommended to boost players engagement.";
public const string MultiplayerToolsRecommended = "Tools speed up your development workflow for games using Netcode for GameObjects.";
public const string MultiplayerToolsIncompatible = "Multiplayer Tools is only compatible with Netcode for GameObjects.";
public const string MultiplayerSdkRecommended = "The Multiplayer Services package makes it easy to connect players together using Unity Gaming Services.";
public const string MultiplayerSdkRecommendedNoNetcode = "The Multiplayer Services package makes it easy to connect players together using Unity Gaming Services. This can also be useful in a no-netcode context.";
public const string MultiplayerWidgetsRecommended = "Widgets provide pre-implemented building blocks to help you connect players together using services as fast as possible.";
public const string MultiplayerWidgetsNotRecommendedBecauseWrongNetcode = "The Multiplayer Widgets package is currently only recommended with Netcode for GameObjects and Netcode for Entities.";
public const string DedicatedServerPackageRecommended = "The Dedicated Server package, which includes Content Selection, helps with the development for the dedicated server platforms.";
public const string DedicatedServerPackageNotRecommended = "The Dedicated Server package is only recommended when you have a dedicated server architecture and use Netcode for Gameobjects.";
public const string DedicatedServerPackageNotRecommendedN4E = "The Dedicated Server package is currently not recommended when using Netcode for Entities.";
public const string DistributedAuthorityIncompatible = "Distributed Authority is only available when using Netcode for GameObjects.";
public const string DistributedAuthorityNotAvailable = "Distributed Authority is not available yet.";
public const string EntitiesGraphicsRecommended = "While you do not need Entities Graphics to use Netcode for Entities, it will make it easier for you to get started";
public const string EntitiesGraphicsNotRecommended = "You would typically only use Entities Graphics with Netcode for Entities";
public const string TransportRecommended = "Unity Transport is the low-level interface for connecting and sending data through a network, and can be the basis of your custom netcode solution.";
public const string DeploymentRecommended = "The deployment package is necessary to upload your C# Cloud Code scripts.";
}
internal static class DocLinks
{
public const string DedicatedServer = "https://docs-multiplayer.unity3d.com/netcode/current/terms-concepts/network-topologies/#dedicated-game-server";
public const string ListenServer = "https://docs-multiplayer.unity3d.com/netcode/current/terms-concepts/network-topologies/#client-hosted-listen-server";
public const string NetcodeForGameObjects = "https://docs-multiplayer.unity3d.com/netcode/current/about/";
public const string NetcodeForEntities = "https://docs.unity3d.com/Packages/com.unity.netcode@latest";
public const string Vivox = "https://docs.unity.com/ugs/en-us/manual/vivox-unity/manual/Unity/Unity";
public const string MultiplayerTools = "https://docs-multiplayer.unity3d.com/tools/current/about/";
public const string MultiplayerPlayMode = "https://docs-multiplayer.unity3d.com/mppm/current/about/";
public const string MultiplayerSdk = "https://docs.unity3d.com/Packages/com.unity.services.multiplayer@latest";
public const string MultiplayerWidgets = "https://docs.unity3d.com/Packages/com.unity.multiplayer.widgets@latest";
public const string CloudCode = "https://docs.unity.com/ugs/manual/cloud-code/manual";
public const string Transport = "https://docs.unity3d.com/Packages/com.unity.transport@latest";
public const string DedicatedServerPackage = "https://docs.unity3d.com/Packages/com.unity.dedicated-server@latest";
public const string DistributedAuthority = "https://docs-multiplayer.unity3d.com/netcode/current/terms-concepts/distributed-authority/";
public const string EntitiesGraphics = "https://docs.unity3d.com/Packages/com.unity.entities.graphics@latest";
public const string DeploymentPackage = "https://docs.unity3d.com/Packages/com.unity.services.deployment@latest";
}
internal static class SolutionDescriptionAndReason
{
public const string DistributedAuthority = "The authority over the game will be distributed across different players. This hosting model [dynamic].";
public const string DedicatedServer = "A dedicated server has authority over the game logic. This hosting model [dynamic].";
public const string ListenServer = "A player will be the host of your game. This hosting model [dynamic].";
public const string NetcodeForGameObjects = "Netcode for GameObject is a high-level networking library built for GameObjects to abstract networking logic. It is made for simplicity. It [dynamic].";
public const string NetcodeForEntities = "Netcode for Entities is a multiplayer solution with server authority and client prediction. It is made for performance. It [dynamic].";
public const string CloudCode = "When gameplay does not require a synchronous multiplayer experience, Cloud Code allows to run player interaction logic directly on the backend side. It [dynamic].";
public const string CustomNetcode = "Custom or third-party netcode, not provided by Unity. It [dynamic].";
public const string NoNetcode = "Your game doesn't require realtime synchronization. No Netcode [dynamic].";
}
internal static class CatchPhrases
{
public const string NetcodeForGameObjects = "Multiplayer synchronization for gameplay based on GameObjects.";
public const string NetcodeForEntities = "Multiplayer synchronization for gameplay based on Entity Component System.";
public const string Vivox = "Connect players through voice and text chat.";
public const string MultiplayerTools = "Debug and optimize your multiplayer gameplay.";
public const string MultiplayerPlayMode = "Test multiplayer gameplay in separated processes from the same project.";
public const string MultiplayerSdk = "Connect players together in sessions for lobby, matchmaker, etc.";
public const string MultiplayerWidgets = "Experiment rapidly with multiplayer services.";
public const string DedicatedServerPackage = "Streamline dedicated server builds.";
public const string CloudCode = "Run game logic as serverless functions.";
public const string EntitiesGraphics = "Optimized rendering for Entity Component System.";
public const string DeploymentPackage = "Deploy assets to Unity Gaming Services from the Editor.";
public const string Transport = "Low-level networking communication layer.";
}
internal static class Titles
{
public const string DedicatedServer = "Dedicated Server";
public const string ListenServer = "Client Hosted";
public const string NetcodeForGameObjects = "Netcode for GameObjects";
public const string NetcodeForEntities = "Netcode for Entities";
public const string Vivox = "Voice/Text chat (Vivox)";
public const string MultiplayerTools = "Multiplayer Tools";
public const string MultiplayerPlayMode = "Multiplayer Play Mode";
public const string NoUnityNetcode = "No Netcode";
public const string MultiplayerSdk = "Multiplayer Services";
public const string MultiplayerWidgets = "Multiplayer Widgets";
public const string CloudCode = "Cloud Code";
public const string CustomNetcode = "Custom or Third-party Netcode";
public const string Transport = "Transport";
public const string DedicatedServerPackage = "Dedicated Server Package";
public const string DeploymentPackage = "Deployment Package";
public const string EntitiesGraphics = "Entities Graphics";
public const string DistributedAuthority = "Distributed Authority";
}
static class RecommendationAssetUtils
{
public static RecommenderSystemData PopulateDefaultRecommendationData()
{
var data = new RecommenderSystemData();
data.TargetUnityVersion = UnityEngine.Application.unityVersion;
data.RecommendedSolutions = new RecommendedSolution[]
{
new()
{
Type = PossibleSolution.LS,
Title = Titles.ListenServer,
DocUrl = DocLinks.ListenServer,
ShortDescription = SolutionDescriptionAndReason.ListenServer,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.DedicatedServerPackageId, RecommendationType.Incompatible, Reasons.DedicatedServerPackageNotRecommended),
}
},
new()
{
Type = PossibleSolution.DS,
Title = Titles.DedicatedServer,
DocUrl = DocLinks.DedicatedServer,
ShortDescription = SolutionDescriptionAndReason.DedicatedServer,
RecommendedPackages = new RecommendedPackage[]
{
}
},
new()
{
Type = PossibleSolution.DA,
Title = Titles.DistributedAuthority,
DocUrl = DocLinks.DistributedAuthority,
ShortDescription = SolutionDescriptionAndReason.DistributedAuthority,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.MultiplayerSdkId, RecommendationType.HostingFeatured, "Distributed Authority needs the Multiplayer Services package to work."),
new(Packages.DedicatedServerPackageId, RecommendationType.Incompatible, Reasons.DedicatedServerPackageNotRecommended),
}
},
new()
{
Type = PossibleSolution.CloudCode,
Title = Titles.CloudCode,
MainPackageId = Packages.CloudCodeId,
ShortDescription = SolutionDescriptionAndReason.CloudCode,
DocUrl = DocLinks.CloudCode,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.DedicatedServerPackageId, RecommendationType.Incompatible, Reasons.DedicatedServerPackageNotRecommended),
new(Packages.DeploymentPackageId, RecommendationType.HostingFeatured, Reasons.DeploymentRecommended),
}
},
new()
{
Type = PossibleSolution.NGO,
Title = Titles.NetcodeForGameObjects,
MainPackageId = Packages.NetcodeGameObjectsId,
ShortDescription = SolutionDescriptionAndReason.NetcodeForGameObjects,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.TransportId, RecommendationType.Hidden, null),
new(Packages.DeploymentPackageId, RecommendationType.Hidden, null),
new(Packages.MultiplayerToolsId, RecommendationType.NetcodeFeatured, Reasons.MultiplayerToolsRecommended),
new(Packages.DedicatedServerPackageId, RecommendationType.HostingFeatured, Reasons.DedicatedServerPackageRecommended ),
new(Packages.EntitiesGraphics, RecommendationType.Hidden, Reasons.EntitiesGraphicsNotRecommended),
new(Packages.VivoxId, RecommendationType.OptionalStandard, Reasons.VivoxRecommended),
new(Packages.MultiplayerSdkId, RecommendationType.OptionalStandard, Reasons.MultiplayerSdkRecommended),
new(Packages.MultiplayerWidgetsId, RecommendationType.OptionalStandard, Reasons.MultiplayerWidgetsRecommended),
new(Packages.MultiplayerPlayModeId, RecommendationType.OptionalStandard, Reasons.MultiplayerPlayModeRecommended),
}
},
new()
{
Type = PossibleSolution.N4E,
Title = Titles.NetcodeForEntities,
MainPackageId = Packages.NetcodeForEntitiesId,
ShortDescription = SolutionDescriptionAndReason.NetcodeForEntities,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.TransportId, RecommendationType.Hidden, null),
new(Packages.DeploymentPackageId, RecommendationType.Hidden, null),
new(Packages.EntitiesGraphics, RecommendationType.NetcodeFeatured, Reasons.EntitiesGraphicsRecommended),
new(Packages.VivoxId, RecommendationType.OptionalStandard, Reasons.VivoxRecommended),
new(Packages.MultiplayerSdkId, RecommendationType.OptionalStandard, Reasons.MultiplayerSdkRecommended),
new(Packages.MultiplayerWidgetsId, RecommendationType.OptionalStandard, Reasons.MultiplayerWidgetsRecommended),
new(Packages.MultiplayerToolsId, RecommendationType.Incompatible, Reasons.MultiplayerToolsIncompatible),
new(Packages.MultiplayerPlayModeId, RecommendationType.OptionalStandard, Reasons.MultiplayerPlayModeRecommended),
new(Packages.DedicatedServerPackageId, RecommendationType.NotRecommended, Reasons.DedicatedServerPackageNotRecommended)
},
IncompatibleSolutions = new IncompatibleSolution[]{new(PossibleSolution.DA, Reasons.DistributedAuthorityIncompatible)}
},
new()
{
Type = PossibleSolution.NoNetcode,
Title = Titles.NoUnityNetcode,
DocUrl = null,
MainPackageId = null,
ShortDescription = SolutionDescriptionAndReason.NoNetcode,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.TransportId, RecommendationType.Hidden, null),
new(Packages.DeploymentPackageId, RecommendationType.Hidden, null),
new(Packages.MultiplayerToolsId, RecommendationType.Incompatible, Reasons.MultiplayerToolsIncompatible),
new(Packages.EntitiesGraphics, RecommendationType.Hidden, Reasons.EntitiesGraphicsNotRecommended),
new(Packages.VivoxId, RecommendationType.OptionalStandard, Reasons.VivoxRecommended),
new(Packages.MultiplayerSdkId, RecommendationType.OptionalStandard, Reasons.MultiplayerSdkRecommendedNoNetcode),
new(Packages.MultiplayerWidgetsId, RecommendationType.NotRecommended, Reasons.MultiplayerWidgetsNotRecommendedBecauseWrongNetcode),
new(Packages.MultiplayerPlayModeId, RecommendationType.OptionalStandard, Reasons.MultiplayerPlayModeRecommended),
new(Packages.DedicatedServerPackageId, RecommendationType.NotRecommended, Reasons.DedicatedServerPackageNotRecommended),
},
IncompatibleSolutions = new IncompatibleSolution[]{new(PossibleSolution.DA, Reasons.DistributedAuthorityIncompatible)}
},
new()
{
Type = PossibleSolution.CustomNetcode,
Title = Titles.CustomNetcode,
MainPackageId = null,
ShortDescription = SolutionDescriptionAndReason.CustomNetcode,
RecommendedPackages = new RecommendedPackage[]
{
new(Packages.TransportId, RecommendationType.NetcodeFeatured, Reasons.TransportRecommended),
new(Packages.EntitiesGraphics, RecommendationType.Hidden, Reasons.EntitiesGraphicsNotRecommended),
new(Packages.DeploymentPackageId, RecommendationType.Hidden, null),
new(Packages.VivoxId, RecommendationType.OptionalStandard, Reasons.VivoxRecommended),
new(Packages.MultiplayerSdkId, RecommendationType.OptionalStandard, Reasons.MultiplayerSdkRecommended),
new(Packages.MultiplayerWidgetsId, RecommendationType.NotRecommended, Reasons.MultiplayerWidgetsNotRecommendedBecauseWrongNetcode),
new(Packages.MultiplayerToolsId, RecommendationType.Incompatible, Reasons.MultiplayerToolsIncompatible),
new(Packages.MultiplayerPlayModeId, RecommendationType.OptionalStandard, Reasons.MultiplayerPlayModeRecommended),
new(Packages.DedicatedServerPackageId, RecommendationType.NotRecommended, Reasons.DedicatedServerPackageNotRecommendedN4E),
},
IncompatibleSolutions = new IncompatibleSolution[]{new(PossibleSolution.DA, Reasons.DistributedAuthorityIncompatible)}
}
};
data.Packages = new PackageDetails[]
{
new(Packages.VivoxId, Titles.Vivox, CatchPhrases.Vivox, DocLinks.Vivox),
new(Packages.MultiplayerSdkId, Titles.MultiplayerSdk, CatchPhrases.MultiplayerSdk, DocLinks.MultiplayerSdk),
new(Packages.MultiplayerWidgetsId, Titles.MultiplayerWidgets, CatchPhrases.MultiplayerWidgets, DocLinks.MultiplayerWidgets),
new(Packages.NetcodeForEntitiesId, Titles.NetcodeForEntities, CatchPhrases.NetcodeForEntities, DocLinks.NetcodeForEntities),
new(Packages.NetcodeGameObjectsId, Titles.NetcodeForGameObjects, CatchPhrases.NetcodeForGameObjects, DocLinks.NetcodeForGameObjects),
new(Packages.MultiplayerToolsId, Titles.MultiplayerTools, CatchPhrases.MultiplayerTools, DocLinks.MultiplayerTools),
new(Packages.MultiplayerPlayModeId, Titles.MultiplayerPlayMode, CatchPhrases.MultiplayerPlayMode, DocLinks.MultiplayerPlayMode),
new(Packages.CloudCodeId, Titles.CloudCode, CatchPhrases.CloudCode, DocLinks.CloudCode),
new(Packages.TransportId, Titles.Transport, CatchPhrases.Transport, DocLinks.Transport),
new(Packages.DedicatedServerPackageId, Titles.DedicatedServerPackage, CatchPhrases.DedicatedServerPackage, DocLinks.DedicatedServerPackage),
new(Packages.EntitiesGraphics, Titles.EntitiesGraphics, CatchPhrases.EntitiesGraphics, DocLinks.EntitiesGraphics),
new(Packages.DeploymentPackageId, Titles.DeploymentPackage, CatchPhrases.DeploymentPackage, DocLinks.DeploymentPackage)
};
return data;
}
}
}
#endif

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 71b3035ad4d343cbbe033e93369d187e
timeCreated: 1711113941

View File

@@ -0,0 +1,316 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &1
MonoBehaviour:
m_ObjectHideFlags: 53
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: b6a02e450cd04bb781e7728f2056251c, type: 3}
m_Name:
m_EditorClassIdentifier:
RecommenderSystemData:
TargetUnityVersion: 6000.0.13f1
RecommendedSolutions:
- Type: 2
Title: Client Hosted
MainPackageId:
DocUrl: https://docs-multiplayer.unity3d.com/netcode/current/terms-concepts/network-topologies/#client-hosted-listen-server
ShortDescription: A player will be the host of your game. This hosting model
[dynamic].
RecommendedPackages:
- PackageId: com.unity.dedicated-server
Type: 7
Reason: The Dedicated Server package is only recommended when you have a
dedicated server architecture and use Netcode for Gameobjects.
IncompatibleSolutions: []
- Type: 3
Title: Dedicated Server
MainPackageId:
DocUrl: https://docs-multiplayer.unity3d.com/netcode/current/terms-concepts/network-topologies/#dedicated-game-server
ShortDescription: A dedicated server has authority over the game logic. This
hosting model [dynamic].
RecommendedPackages: []
IncompatibleSolutions: []
- Type: 4
Title: Distributed Authority
MainPackageId:
DocUrl: https://docs-multiplayer.unity3d.com/netcode/current/terms-concepts/distributed-authority/
ShortDescription: The authority over the game will be distributed across different
players. This hosting model [dynamic].
RecommendedPackages:
- PackageId: com.unity.services.multiplayer
Type: 4
Reason: Distributed Authority needs the Multiplayer Services package to work.
- PackageId: com.unity.dedicated-server
Type: 7
Reason: The Dedicated Server package is only recommended when you have a
dedicated server architecture and use Netcode for Gameobjects.
IncompatibleSolutions: []
- Type: 7
Title: Cloud Code
MainPackageId: com.unity.services.cloudcode
DocUrl: https://docs.unity.com/ugs/manual/cloud-code/manual
ShortDescription: When gameplay does not require a synchronous multiplayer
experience, Cloud Code allows to run player interaction logic directly on
the backend side. It [dynamic].
RecommendedPackages:
- PackageId: com.unity.dedicated-server
Type: 7
Reason: The Dedicated Server package is only recommended when you have a
dedicated server architecture and use Netcode for Gameobjects.
- PackageId: com.unity.services.deployment
Type: 4
Reason: The deployment package is necessary to upload your C# Cloud Code
scripts.
IncompatibleSolutions: []
- Type: 0
Title: Netcode for GameObjects
MainPackageId: com.unity.netcode.gameobjects
DocUrl:
ShortDescription: Netcode for GameObject is a high-level networking library
built for GameObjects to abstract networking logic. It is made for simplicity.
It [dynamic].
RecommendedPackages:
- PackageId: com.unity.transport
Type: 8
Reason:
- PackageId: com.unity.services.deployment
Type: 8
Reason:
- PackageId: com.unity.multiplayer.tools
Type: 3
Reason: Tools speed up your development workflow for games using Netcode
for GameObjects.
- PackageId: com.unity.dedicated-server
Type: 4
Reason: The Dedicated Server package, which includes Content Selection, helps
with the development for the dedicated server platforms.
- PackageId: com.unity.entities.graphics
Type: 8
Reason: You would typically only use Entities Graphics with Netcode for Entities
- PackageId: com.unity.services.vivox
Type: 5
Reason: Vivox adds real-time communication and is always recommended to boost
players engagement.
- PackageId: com.unity.services.multiplayer
Type: 5
Reason: The Multiplayer Services package makes it easy to connect players
together using Unity Gaming Services.
- PackageId: com.unity.multiplayer.widgets
Type: 5
Reason: Widgets provide pre-implemented building blocks to help you connect
players together using services as fast as possible.
- PackageId: com.unity.multiplayer.playmode
Type: 5
Reason: Multiplayer Play Mode enables you to iterate faster by testing locally
without the need to create builds.
IncompatibleSolutions: []
- Type: 1
Title: Netcode for Entities
MainPackageId: com.unity.netcode
DocUrl:
ShortDescription: Netcode for Entities is a multiplayer solution with server
authority and client prediction. It [dynamic].
RecommendedPackages:
- PackageId: com.unity.transport
Type: 8
Reason:
- PackageId: com.unity.services.deployment
Type: 8
Reason:
- PackageId: com.unity.entities.graphics
Type: 3
Reason: While you do not need Entities Graphics to use Netcode for Entities,
it will make it easier for you to get started
- PackageId: com.unity.services.vivox
Type: 5
Reason: Vivox adds real-time communication and is always recommended to boost
players engagement.
- PackageId: com.unity.services.multiplayer
Type: 5
Reason: The Multiplayer Services package makes it easy to connect players
together using Unity Gaming Services.
- PackageId: com.unity.multiplayer.widgets
Type: 5
Reason: Widgets provide pre-implemented building blocks to help you connect
players together using services as fast as possible.
- PackageId: com.unity.multiplayer.tools
Type: 7
Reason: Multiplayer Tools is only compatible with Netcode for GameObjects.
- PackageId: com.unity.multiplayer.playmode
Type: 5
Reason: Multiplayer Play Mode enables you to iterate faster by testing locally
without the need to create builds.
- PackageId: com.unity.dedicated-server
Type: 6
Reason: The Dedicated Server package is only recommended when you have a
dedicated server architecture and use Netcode for Gameobjects.
IncompatibleSolutions:
- Solution: 4
Reason: Distributed Authority is only available when using Netcode for GameObjects.
- Type: 6
Title: No Netcode
MainPackageId:
DocUrl:
ShortDescription: Your game doesn't require realtime synchronization. No Netcode
[dynamic].
RecommendedPackages:
- PackageId: com.unity.transport
Type: 8
Reason:
- PackageId: com.unity.services.deployment
Type: 8
Reason:
- PackageId: com.unity.multiplayer.tools
Type: 7
Reason: Multiplayer Tools is only compatible with Netcode for GameObjects.
- PackageId: com.unity.entities.graphics
Type: 8
Reason: You would typically only use Entities Graphics with Netcode for Entities
- PackageId: com.unity.services.vivox
Type: 5
Reason: Vivox adds real-time communication and is always recommended to boost
players engagement.
- PackageId: com.unity.services.multiplayer
Type: 5
Reason: The Multiplayer Services package makes it easy to connect players
together using Unity Gaming Services. This can also be useful in a no-netcode
context.
- PackageId: com.unity.multiplayer.widgets
Type: 6
Reason: The Multiplayer Widgets package is currently only recommended with
Netcode for GameObjects and Netcode for Entities.
- PackageId: com.unity.multiplayer.playmode
Type: 5
Reason: Multiplayer Play Mode enables you to iterate faster by testing locally
without the need to create builds.
- PackageId: com.unity.dedicated-server
Type: 6
Reason: The Dedicated Server package is only recommended when you have a
dedicated server architecture and use Netcode for Gameobjects.
IncompatibleSolutions:
- Solution: 4
Reason: Distributed Authority is only available when using Netcode for GameObjects.
- Type: 5
Title: Custom or Third-party Netcode
MainPackageId:
DocUrl:
ShortDescription: Custom or third-party netcode, not provided by Unity. It
[dynamic].
RecommendedPackages:
- PackageId: com.unity.transport
Type: 3
Reason: Unity Transport is the low-level interface for connecting and sending
data through a network, and can be the basis of your custom netcode solution.
- PackageId: com.unity.entities.graphics
Type: 8
Reason: You would typically only use Entities Graphics with Netcode for Entities
- PackageId: com.unity.services.deployment
Type: 8
Reason:
- PackageId: com.unity.services.vivox
Type: 5
Reason: Vivox adds real-time communication and is always recommended to boost
players engagement.
- PackageId: com.unity.services.multiplayer
Type: 5
Reason: The Multiplayer Services package makes it easy to connect players
together using Unity Gaming Services.
- PackageId: com.unity.multiplayer.widgets
Type: 6
Reason: The Multiplayer Widgets package is currently only recommended with
Netcode for GameObjects and Netcode for Entities.
- PackageId: com.unity.multiplayer.tools
Type: 7
Reason: Multiplayer Tools is only compatible with Netcode for GameObjects.
- PackageId: com.unity.multiplayer.playmode
Type: 5
Reason: Multiplayer Play Mode enables you to iterate faster by testing locally
without the need to create builds.
- PackageId: com.unity.dedicated-server
Type: 6
Reason: The Dedicated Server package is currently not recommended when using
Netcode for Entities.
IncompatibleSolutions:
- Solution: 4
Reason: Distributed Authority is only available when using Netcode for GameObjects.
Packages:
- Id: com.unity.services.vivox
Name: Voice/Text chat (Vivox)
ShortDescription: Connect players through voice and text chat.
DocsUrl: https://docs.unity.com/ugs/en-us/manual/vivox-unity/manual/Unity/Unity
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.services.multiplayer
Name: Multiplayer Services
ShortDescription: Connect players together in sessions for lobby, matchmaker,
etc.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.services.multiplayer@latest
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.multiplayer.widgets
Name: Multiplayer Widgets
ShortDescription: Experiment rapidly with multiplayer services.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.multiplayer.widgets@latest
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.netcode
Name: Netcode for Entities
ShortDescription: Multiplayer synchronization for gameplay based on Entity
Component System.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.netcode@latest
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.netcode.gameobjects
Name: Netcode for GameObjects
ShortDescription: Multiplayer synchronization for gameplay based on GameObjects.
DocsUrl: https://docs-multiplayer.unity3d.com/netcode/current/about/
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.multiplayer.tools
Name: Multiplayer Tools
ShortDescription: Debug and optimize your multiplayer gameplay.
DocsUrl: https://docs-multiplayer.unity3d.com/tools/current/about/
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.multiplayer.playmode
Name: Multiplayer Play Mode
ShortDescription: Test multiplayer gameplay in separated processes from the
same project.
DocsUrl: https://docs-multiplayer.unity3d.com/mppm/current/about/
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.services.cloudcode
Name: Cloud Code
ShortDescription: Run game logic as serverless functions.
DocsUrl: https://docs.unity.com/ugs/manual/cloud-code/manual
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.transport
Name: Transport
ShortDescription: Low-level networking communication layer.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.transport@latest
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.dedicated-server
Name: Dedicated Server Package
ShortDescription: Streamline dedicated server builds.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.dedicated-server@latest
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.entities.graphics
Name: Entities Graphics
ShortDescription: Optimized rendering for Entity Component System.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.entities.graphics@latest
AdditionalPackages: []
PreReleaseVersion:
- Id: com.unity.services.deployment
Name: Deployment Package
ShortDescription: Deploy assets to Unity Gaming Services from the Editor.
DocsUrl: https://docs.unity3d.com/Packages/com.unity.services.deployment@latest
AdditionalPackages: []
PreReleaseVersion:

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b66d076cdcfe3b14388de66307a0e7ff
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,70 @@
using System;
namespace Unity.Multiplayer.Center.Recommendations
{
/// <summary>
/// Types of recommendation that are both used in ViewData and in the ground truth data for the recommendation.
/// Architecture choices must be made. Packages are typically optional.
/// </summary>
[Serializable]
internal enum RecommendationType
{
/// <summary>
/// Invalid value, indicates error.
/// </summary>
None,
/// <summary>
/// Featured option (e.g. NGO if NGO is the recommended architecture)
/// Note that in the case of architecture choice, the user should select something.
/// </summary>
MainArchitectureChoice,
/// <summary>
/// Non-featured option (e.g. N4E if NGO is the recommended architecture)
/// </summary>
SecondArchitectureChoice,
/// <summary>
/// Associated feature in the Netcode section
/// </summary>
NetcodeFeatured,
/// <summary>
/// Associated feature in the Hosting Model section
/// </summary>
HostingFeatured,
/// <summary>
/// Optional but not highlighted
/// </summary>
OptionalStandard,
/// <summary>
/// Not recommended, but not incompatible with the user intent.
/// </summary>
NotRecommended,
/// <summary>
/// Incompatible with the user intent. Might even break something, we need to warn the user
/// </summary>
Incompatible,
/// <summary>
/// Packages that are not visible for the User but useful for the analytics
/// </summary>
Hidden
}
internal static class RecommendationTypeExtensions
{
public static bool IsRecommendedPackage(this RecommendationType type)
=> type is RecommendationType.OptionalStandard or RecommendationType.NetcodeFeatured or RecommendationType.HostingFeatured;
public static bool IsRecommendedSolution(this RecommendationType type)
=> type is RecommendationType.MainArchitectureChoice;
public static bool IsInstallableAsDirectDependency(this RecommendationType type)
=> type is not RecommendationType.Incompatible and not RecommendationType.Hidden;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b46a48f780f84026b9d1e73ee1b0121d
timeCreated: 1702991052

View File

@@ -0,0 +1,322 @@
using System;
using System.Collections.Generic;
using System.Text;
using Unity.Multiplayer.Center.Common;
using Unity.Multiplayer.Center.Onboarding;
using Unity.Multiplayer.Center.Questionnaire;
using UnityEngine;
namespace Unity.Multiplayer.Center.Recommendations
{
internal static class RecommendationUtils
{
public static List<RecommendedPackageViewData> PackagesToInstall(RecommendationViewData recommendation,
SolutionsToRecommendedPackageViewData solutionToPackageData)
{
var packagesToInstall = new List<RecommendedPackageViewData>();
// Can happen on first load
if (recommendation?.NetcodeOptions == null)
return packagesToInstall;
var selectedNetcode = GetSelectedNetcode(recommendation);
if (selectedNetcode == null)
return packagesToInstall;
// add features based on netcode
if (selectedNetcode.MainPackage != null)
packagesToInstall.Add(selectedNetcode.MainPackage);
var selectedServerArchitecture = GetSelectedHostingModel(recommendation);
if (selectedServerArchitecture.MainPackage != null)
packagesToInstall.Add(selectedServerArchitecture.MainPackage);
foreach (var package in solutionToPackageData.GetPackagesForSelection(selectedNetcode.Solution, selectedServerArchitecture.Solution))
{
if (package.Selected)
{
packagesToInstall.Add(package);
}
}
return packagesToInstall;
}
public static RecommendedSolutionViewData GetSelectedHostingModel(RecommendationViewData recommendation)
{
return GetSelectedSolution(recommendation.ServerArchitectureOptions);
}
public static RecommendedSolutionViewData GetSelectedNetcode(RecommendationViewData recommendation)
{
return GetSelectedSolution(recommendation.NetcodeOptions);
}
/// <summary>
/// Finds the first selected solution in the input array.
/// </summary>
/// <param name="availableSolutions">The available solutions.</param>
/// <returns>Returns the first selected solution. If no solution is selected, it returns null.</returns>
public static RecommendedSolutionViewData GetSelectedSolution(RecommendedSolutionViewData[] availableSolutions)
{
foreach (var solution in availableSolutions)
{
if (solution.Selected)
{
return solution;
}
}
return default;
}
public static PackageDetails GetPackageDetailForPackageId(string packageId)
{
var idToPackageDetailDict = RecommenderSystemDataObject.instance.RecommenderSystemData.PackageDetailsById;
if (idToPackageDetailDict.TryGetValue(packageId, out var packageDetail))
return packageDetail;
Debug.LogError("Trying to get package detail for package id that does not exist: " + packageId);
return null;
}
/// <summary>
/// Returns all the packages passed via packageIds and their informal dependencies (stored in AdditionalPackages)
/// </summary>
/// <returns>List of PackageDetails</returns>
/// <param name="packages">List of package id</param>
/// <param name="toolTip">tooltip text</param>
public static void GetPackagesWithAdditionalPackages(List<RecommendedPackageViewData> packages,
out List<string> ids, out List<string> names, out string toolTip)
{
ids = new List<string>();
names = new List<string>();
var toolTipBuilder = new StringBuilder();
foreach (var package in packages)
{
var packageDetail = GetPackageDetailForPackageId(package.PackageId);
var id = string.IsNullOrEmpty(package.PreReleaseVersion)? package.PackageId : $"{package.PackageId}@{package.PreReleaseVersion}";
var name = string.IsNullOrEmpty(package.PreReleaseVersion)? packageDetail.Name : $"{packageDetail.Name} {package.PreReleaseVersion}";
ids.Add(id);
names.Add(name);
toolTipBuilder.Append(name);
if (packageDetail.AdditionalPackages is {Length: > 0})
{
toolTipBuilder.Append(" + ");
foreach (var additionalPackageId in packageDetail.AdditionalPackages)
{
var additionalPackage = GetPackageDetailForPackageId(additionalPackageId);
ids.Add(additionalPackage.Id);
names.Add(additionalPackage.Name);
toolTipBuilder.Append(additionalPackage.Name);
toolTipBuilder.Append(",");
}
}
toolTipBuilder.Append("\n");
}
// remove last newline
toolTip = toolTipBuilder.ToString().TrimEnd('\n');
ids.Add(QuickstartIsMissingView.PackageId);
}
/// <summary>
/// Reapplies the previous selection on the view data
/// </summary>
/// <param name="recommendation">The recommendation view data to update</param>
/// <param name="data">The previous selection data</param>
public static void ApplyPreviousSelection(RecommendationViewData recommendation, SelectedSolutionsData data)
{
if (data == null || recommendation == null)
return;
if (data.SelectedNetcodeSolution != SelectedSolutionsData.NetcodeSolution.None)
{
foreach (var d in recommendation.NetcodeOptions)
{
d.Selected = Logic.ConvertNetcodeSolution(d) == data.SelectedNetcodeSolution;
}
}
if (data.SelectedHostingModel != SelectedSolutionsData.HostingModel.None)
{
foreach (var view in recommendation.ServerArchitectureOptions)
{
view.Selected = Logic.ConvertInfrastructure(view) == data.SelectedHostingModel;
}
}
}
/// <summary>
/// Returns the packages that are of the given recommendation type
/// </summary>
/// <param name="packages">All the package view data as returned by the recommender system</param>
/// <param name="type">The target recommendation type</param>
/// <returns>The filtered list</returns>
public static List<RecommendedPackageViewData> FilterByType(IEnumerable<RecommendedPackageViewData> packages, RecommendationType type)
{
var filteredPackages = new List<RecommendedPackageViewData>();
foreach (var package in packages)
{
if (package.RecommendationType == type)
{
filteredPackages.Add(package);
}
}
return filteredPackages;
}
public static int IndexOfMaximumScore(RecommendedSolutionViewData[] array)
{
var maxIndex = -1;
var maxScore = float.MinValue;
for (var index = 0; index < array.Length; index++)
{
var solution = array[index];
if (solution.RecommendationType == RecommendationType.Incompatible)
continue;
if (solution.Score > maxScore)
{
maxScore = solution.Score;
maxIndex = index;
}
}
return maxIndex;
}
/// <summary>
/// Finds the recommended solution among the scored solutions.
/// </summary>
/// <param name="scoredSolutions">An array of tuples, where each tuple contains a PossibleSolution and its corresponding Scoring.</param>
/// <returns>Returns the recommended solution with the maximum total score.</returns>
public static PossibleSolution FindRecommendedSolution((PossibleSolution, Scoring)[] scoredSolutions)
{
var maxScore = float.MinValue;
PossibleSolution recommendedSolution = default;
foreach (var (possibleSolution, scoring) in scoredSolutions)
{
if (scoring.TotalScore > maxScore)
{
maxScore = scoring.TotalScore;
recommendedSolution = possibleSolution;
}
}
return recommendedSolution;
}
/// <summary>
/// Looks through the hosting models and marks them incompatible if necessary (deselected and recommendation
/// type incompatible).
/// Note that this might leave the recommendation in an invalid state (no hosting model selected)
/// </summary>
/// <param name="recommendation">The recommendation data to modify.</param>
public static void MarkIncompatibleHostingModels(RecommendationViewData recommendation)
{
var netcode = GetSelectedNetcode(recommendation);
for (var index = 0; index < recommendation.ServerArchitectureOptions.Length; index++)
{
var hosting = recommendation.ServerArchitectureOptions[index];
var isCompatible = RecommenderSystemDataObject.instance.RecommenderSystemData.IsHostingModelCompatibleWithNetcode(netcode.Solution, hosting.Solution, out _);
if (!isCompatible)
{
hosting.RecommendationType = RecommendationType.Incompatible;
hosting.Selected = false;
}
else
{
hosting.RecommendationType = RecommendationType.SecondArchitectureChoice;
}
}
}
/// <summary>
/// Finds a recommended package view by its ID from a list.
/// </summary>
/// <param name="packages">A list of Recommended Package ViewData representing the packages.</param>
/// <param name="id">The ID of the package to find.</param>
/// <returns>Returns the RecommendedPackageViewData object with the matching ID. If none is found, it returns null.</returns>
public static RecommendedPackageViewData FindRecommendedPackageViewById( List<RecommendedPackageViewData> packages, string id)
{
RecommendedPackageViewData featureToSet = default;
foreach (var package in packages)
{
if (package.PackageId == id)
{
featureToSet = package;
break;
}
}
return featureToSet;
}
/// <summary>
/// Checks if the specific question has been answered.
/// </summary>
/// <param name="question">The question to check.</param>
/// <returns>Returns true if the question has been answered by the user, otherwise returns false.</returns>
public static bool IsQuestionAnswered(Question question)
{
return Logic.TryGetAnswerByQuestionId(UserChoicesObject.instance.UserAnswers, question.Id, out _);
}
/// <summary>
/// Compares two arrays of type <typeparamref name="T"/> for equality.
/// </summary>
/// <typeparam name="T">The type of the elements in the arrays.</typeparam>
/// <param name="a">The first array to compare.</param>
/// <param name="b">The second array to compare.</param>
/// <returns>
/// <c>true</c> if all elements are equal and they are in the same order,
/// <c>false</c> otherwise.
/// </returns>
public static bool AreArraysEqual<T>(T[] a, T[] b)
{
if (a.Length != b.Length)
{
return false;
}
for (var i = 0; i < a.Length; i++)
{
if (!EqualityComparer<T>.Default.Equals(a[i], b[i]))
{
return false;
}
}
return true;
}
/// <summary>
/// Gets the section type names in order from the provided section mapping.
/// </summary>
/// <param name="sectionMapping">A dictionary mapping OnboardingSectionCategory to SectionList types.</param>
/// <returns>A list of section type names sorted in ascending order.</returns>
public static List<string> GetSectionTypeNamesInOrder(Dictionary<OnboardingSectionCategory, Type[]> sectionMapping)
{
var sectionTypeNamesList = new List<string>();
foreach (var sectionTypeArray in sectionMapping.Values)
{
foreach (var sectionType in sectionTypeArray)
{
sectionTypeNamesList.Add(sectionType.AssemblyQualifiedName);
}
}
sectionTypeNamesList.Sort(StringComparer.InvariantCulture);
return sectionTypeNamesList;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 52a77c59214a4cb5a43fc7b0e097d832
timeCreated: 1700470204

View File

@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using Unity.Multiplayer.Center.Questionnaire;
using UnityEngine;
namespace Unity.Multiplayer.Center.Recommendations
{
/// <summary>
/// Contains all the architectural options that we offer to users, with a score specific to their answers.
/// This is the data that we show in the UI.
/// </summary>
[Serializable]
internal class RecommendationViewData
{
/// <summary>
/// NGO or N4E or Other
/// </summary>
public RecommendedSolutionViewData[] NetcodeOptions;
/// <summary>
/// Client hosted / dedicated server.
/// It comes with UGS services that we might or might not recommend
/// </summary>
public RecommendedSolutionViewData[] ServerArchitectureOptions;
}
/// <summary>
/// For each selection (netcode, hosting) model, there is a specific set of recommended packages
/// This class stores this information and provides access to it.
/// </summary>
[Serializable]
internal class SolutionsToRecommendedPackageViewData
{
[SerializeField] RecommendedPackageViewDataArray[] m_Packages;
public SolutionSelection[] Selections;
// Because we cannot serialize two-dimensional arrays
[Serializable]
internal struct RecommendedPackageViewDataArray
{
public RecommendedPackageViewData[] Packages;
}
public SolutionsToRecommendedPackageViewData(SolutionSelection[] selections, RecommendedPackageViewData[][] packages)
{
Debug.Assert(selections.Length == packages.Length, "Selections and packages must have the same length");
Selections = selections;
m_Packages = new RecommendedPackageViewDataArray[packages.Length];
for (var i = 0; i < packages.Length; i++)
{
m_Packages[i] = new RecommendedPackageViewDataArray {Packages = packages[i]};
}
}
public RecommendedPackageViewData[] GetPackagesForSelection(PossibleSolution netcode, PossibleSolution hosting)
{
return GetPackagesForSelection(new SolutionSelection(netcode, hosting));
}
public RecommendedPackageViewData[] GetPackagesForSelection(SolutionSelection selection)
{
var index = Array.IndexOf(Selections, selection);
return index < 0 ? Array.Empty<RecommendedPackageViewData>() : m_Packages[index].Packages;
}
}
/// <summary>
/// Base fields for things that we can recommend (typically a package or a solution).
/// </summary>
[Serializable]
internal class RecommendedItemViewData
{
/// <summary>
/// How much we want to recommend this item. With the score, this is what is modified by the recommender system.
/// The architecture option with the best score will be marked as MainArchitectureChoice, the other as SecondArchitectureChoice,
/// which enables us to highlight the recommended option.
/// </summary>
public RecommendationType RecommendationType;
/// <summary>
/// Item is part of the selection, because it was preselected or user selected it.
/// </summary>
public bool Selected;
/// <summary>
/// Optional: reason why this recommendation was made
/// </summary>
public string Reason;
/// <summary>
/// Url to feature documentation
/// </summary>
public string DocsUrl;
/// <summary>
/// Recommendation is installed and direct dependency
/// </summary>
public bool IsInstalledAsProjectDependency;
/// <summary>
/// Installed version number of a package
/// </summary>
public string InstalledVersion;
}
/// <summary>
/// Architectural solution (netcode or server architecture) that we offer. It comes with an optional main package and
/// associated recommended packages.
/// </summary>
[Serializable]
internal class RecommendedSolutionViewData : RecommendedItemViewData
{
public string Title;
public PossibleSolution Solution;
/// <summary>
/// How much of a match is this item. Computed by recommender system based on answers.
/// </summary>
public float Score;
/// <summary>
/// The main package to install for this solution (note that this might be null, e.g. for client hosted game)
/// </summary>
public RecommendedPackageViewData MainPackage;
public string WarningString;
public RecommendedSolutionViewData(RecommenderSystemData data, RecommendedSolution solution,
RecommendationType type, Scoring scoring, Dictionary<string, string> installedPackageDictionary)
{
if (!string.IsNullOrEmpty(solution.MainPackageId))
{
var mainPackageDetails = data.PackageDetailsById[solution.MainPackageId];
DocsUrl = string.IsNullOrEmpty(solution.DocUrl) ? mainPackageDetails.DocsUrl : solution.DocUrl;
if (installedPackageDictionary.ContainsKey(solution.MainPackageId))
{
InstalledVersion = installedPackageDictionary[solution.MainPackageId];
IsInstalledAsProjectDependency = PackageManagement.IsDirectDependency(solution.MainPackageId);
}
MainPackage = new RecommendedPackageViewData(mainPackageDetails, type, InstalledVersion);
}
else
{
DocsUrl = solution.DocUrl;
}
RecommendationType = type;
Title = solution.Title;
Reason = scoring?.GetReasonString();
Score = scoring?.TotalScore ?? 0f;
Selected = RecommendationType.IsRecommendedSolution();
Solution = solution.Type;
}
}
/// <summary>
/// Single package that is part of a recommendation.
/// </summary>
[Serializable]
internal class RecommendedPackageViewData : RecommendedItemViewData
{
public string PackageId;
public string Name;
public string PreReleaseVersion;
/// <summary>
/// A short description of the feature.
/// </summary>
public string ShortDescription = "Short description not added yet";
public RecommendedPackageViewData(PackageDetails details, RecommendationType type, string installedVersion=null, string reason = null)
{
RecommendationType = type;
PackageId = details.Id;
Name = details.Name;
PreReleaseVersion = details.PreReleaseVersion;
Selected = type.IsRecommendedPackage();
ShortDescription = details.ShortDescription;
Reason = reason;
DocsUrl = details.DocsUrl;
InstalledVersion = installedVersion;
IsInstalledAsProjectDependency = installedVersion != null && PackageManagement.IsDirectDependency(PackageId);
}
public RecommendedPackageViewData(PackageDetails details, RecommendedPackage recommendation, string installedVersion=null)
: this(details, recommendation.Type, installedVersion, recommendation.Reason)
{
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0ddc3b8a12c84454b04e4d6bdd88241c
timeCreated: 1695734131

View File

@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using Unity.Multiplayer.Center.Common;
using Unity.Multiplayer.Center.Questionnaire;
using UnityEngine;
namespace Unity.Multiplayer.Center.Recommendations
{
using AnswerWithQuestion = Tuple<Question, Answer>;
/// <summary>
/// Builds recommendation views based on Questionnaire data and Answer data.
/// The recommendation is based on the scoring of the answers, which is controlled by the RecommenderSystemData.
/// </summary>
internal static class RecommenderSystem
{
/// <summary>
/// Main entry point for the recommender system: computes the recommendation based on the questionnaire data and
/// the answers.
/// If no answer has been given or the questionnaire does not match the answers, this returns null.
/// </summary>
/// <param name="questionnaireData">The questionnaire that the user filled.</param>
/// <param name="answerData">The answers the user gave.</param>
/// <returns>The recommendation view data.</returns>
public static RecommendationViewData GetRecommendation(QuestionnaireData questionnaireData, AnswerData answerData)
{
var answers = CollectAnswers(questionnaireData, answerData);
// Note: valid now only because we do not have multiple answers per question
if (answers.Count < questionnaireData.Questions.Length) return null;
var data = RecommenderSystemDataObject.instance.RecommenderSystemData;
var scoredSolutions = CalculateScore(data, answers);
return CreateRecommendation(data, scoredSolutions);
}
/// <summary>
/// Get the view data for all possible solution selections.
/// </summary>
/// <returns>The constructed set of views</returns>
public static SolutionsToRecommendedPackageViewData GetSolutionsToRecommendedPackageViewData()
{
var data = RecommenderSystemDataObject.instance.RecommenderSystemData;
var installedPackageDictionary = PackageManagement.InstalledPackageDictionary();
var selections = new SolutionSelection[16];
var packages = new RecommendedPackageViewData[16][];
PossibleSolution[] netcodes = { PossibleSolution.NGO, PossibleSolution.N4E, PossibleSolution.CustomNetcode, PossibleSolution.NoNetcode };
PossibleSolution[] hostings = { PossibleSolution.LS, PossibleSolution.DS, PossibleSolution.CloudCode, PossibleSolution.DA };
var index = 0;
foreach (var netcode in netcodes)
{
foreach (var hosting in hostings)
{
var selection = new SolutionSelection(netcode, hosting);
selections[index] = selection;
packages[index] = BuildRecommendationForSelection(data, selection, installedPackageDictionary);
++index;
}
}
return new SolutionsToRecommendedPackageViewData(selections, packages);
}
public static void AdaptRecommendationToNetcodeSelection(RecommendationViewData recommendation)
{
RecommendationUtils.MarkIncompatibleHostingModels(recommendation);
var maxIndex = RecommendationUtils.IndexOfMaximumScore(recommendation.ServerArchitectureOptions);
recommendation.ServerArchitectureOptions[maxIndex].RecommendationType = RecommendationType.MainArchitectureChoice;
if (RecommendationUtils.GetSelectedHostingModel(recommendation) == null)
recommendation.ServerArchitectureOptions[maxIndex].Selected = true;
}
static List<AnswerWithQuestion> CollectAnswers(QuestionnaireData questionnaireData, AnswerData answerData)
{
if (questionnaireData?.Questions == null || questionnaireData.Questions.Length == 0)
throw new ArgumentException("Questionnaire data is null or empty", nameof(questionnaireData));
List<AnswerWithQuestion> givenAnswers = new();
var answers = answerData.Answers;
foreach (var answeredQuestion in answers)
{
// find question for the answer
if (!Logic.TryGetQuestionByQuestionId(questionnaireData, answeredQuestion.QuestionId, out var question))
continue;
// find answer object for the given answer id
foreach (var answerId in answeredQuestion.Answers)
{
if (!Logic.TryGetAnswerByAnswerId(question, answerId, out var choice))
continue;
givenAnswers.Add(Tuple.Create(question, choice));
}
}
return givenAnswers;
}
static Dictionary<PossibleSolution, Scoring> CalculateScore(RecommenderSystemData data, List<AnswerWithQuestion> answers)
{
var possibleSolutions = Enum.GetValues(typeof(PossibleSolution));
Dictionary<PossibleSolution, Scoring> scores = new(possibleSolutions.Length);
foreach (var solution in possibleSolutions)
{
var solutionObject = data.SolutionsByType[(PossibleSolution) solution];
scores.Add((PossibleSolution) solution, new Scoring(solutionObject.ShortDescription));
}
foreach (var (question, answer) in answers)
{
foreach (var scoreImpact in answer.ScoreImpacts)
{
scores[scoreImpact.Solution].AddScore(scoreImpact.Score * question.GlobalWeight, scoreImpact.Comment);
}
}
return scores;
}
static RecommendationViewData CreateRecommendation(RecommenderSystemData data, IReadOnlyDictionary<PossibleSolution, Scoring> scoredSolutions)
{
RecommendationViewData recommendation = new();
var installedPackageDictionary = PackageManagement.InstalledPackageDictionary();
recommendation.NetcodeOptions = BuildRecommendedSolutions(data, new [] {
(PossibleSolution.NGO, scoredSolutions[PossibleSolution.NGO]),
(PossibleSolution.N4E, scoredSolutions[PossibleSolution.N4E]),
(PossibleSolution.CustomNetcode, scoredSolutions[PossibleSolution.CustomNetcode]),
(PossibleSolution.NoNetcode, scoredSolutions[PossibleSolution.NoNetcode]) },
installedPackageDictionary);
recommendation.ServerArchitectureOptions = BuildRecommendedSolutions(data, new [] {
(PossibleSolution.LS, scoredSolutions[PossibleSolution.LS]),
(PossibleSolution.DS, scoredSolutions[PossibleSolution.DS]),
(PossibleSolution.CloudCode, scoredSolutions[PossibleSolution.CloudCode]),
(PossibleSolution.DA, scoredSolutions[PossibleSolution.DA]) },
installedPackageDictionary);
AdaptRecommendationToNetcodeSelection(recommendation);
return recommendation;
}
static RecommendedSolutionViewData[] BuildRecommendedSolutions(RecommenderSystemData data, (PossibleSolution, Scoring)[] scoredSolutions, Dictionary<string, string> installedPackageDictionary)
{
var recommendedSolution = RecommendationUtils.FindRecommendedSolution(scoredSolutions);
var result = new RecommendedSolutionViewData[scoredSolutions.Length];
for (var index = 0; index < scoredSolutions.Length; index++)
{
var scoredSolution = scoredSolutions[index];
var recoType = scoredSolution.Item1 == recommendedSolution ? RecommendationType.MainArchitectureChoice : RecommendationType.SecondArchitectureChoice;
var reco = new RecommendedSolutionViewData(data, data.SolutionsByType[scoredSolution.Item1], recoType, scoredSolution.Item2, installedPackageDictionary);
result[index] = reco;
}
return result;
}
static RecommendedPackageViewData[] BuildRecommendationForSelection(RecommenderSystemData data, SolutionSelection selection, Dictionary<string, string> installedPackageDictionary)
{
// Note: working on a copy that we modify
var netcodePackages = (RecommendedPackage[]) data.SolutionsByType[selection.Netcode].RecommendedPackages.Clone();
var hostingOverrides = data.SolutionsByType[selection.HostingModel].RecommendedPackages;
foreach (var package in hostingOverrides)
{
var existing = Array.FindIndex(netcodePackages, p => p.PackageId == package.PackageId);
if (existing == -1)
{
Debug.LogError($"Malformed data for hosting model {selection.HostingModel}: package {package.PackageId} not found in netcode packages of {selection.Netcode}.");
continue;
}
netcodePackages[existing] = package;
}
var result = new RecommendedPackageViewData[netcodePackages.Length];
for (var index = 0; index < netcodePackages.Length; index++)
{
var package = netcodePackages[index];
installedPackageDictionary.TryGetValue(package.PackageId, out var installedVersion);
result[index] = new RecommendedPackageViewData( data.PackageDetailsById[package.PackageId], package, installedVersion);
}
return result;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a1c995459c0d418c88dd274e04840c5d
timeCreated: 1695733765

View File

@@ -0,0 +1,278 @@
using System;
using System.Collections.Generic;
using Unity.Multiplayer.Center.Questionnaire;
namespace Unity.Multiplayer.Center.Recommendations
{
/// <summary>
/// The source of data for the recommender system. Based on scoring and this data, the recommender system will
/// populate the recommendation view data.
/// </summary>
[Serializable]
internal class RecommenderSystemData
{
/// <summary>
/// The Unity version for which this recommendation data is valid.
/// </summary>
public string TargetUnityVersion;
/// <summary>
/// Stores all the recommended solutions. This is serialized.
/// </summary>
public RecommendedSolution[] RecommendedSolutions;
/// <summary>
/// Stores all the package details.
/// This is serialized.
/// </summary>
public PackageDetails[] Packages;
/// <summary>
/// Provides convenient access to the package details by package id.
/// </summary>
public Dictionary<string, PackageDetails> PackageDetailsById
{
get
{
if (m_PackageDetailsById != null) return m_PackageDetailsById;
m_PackageDetailsById = Utils.ToDictionary(Packages, p => p.Id);
return m_PackageDetailsById;
}
}
public Dictionary<PossibleSolution, RecommendedSolution> SolutionsByType
{
get
{
if (m_SolutionsByType != null) return m_SolutionsByType;
m_SolutionsByType = Utils.ToDictionary(RecommendedSolutions, s => s.Type);
return m_SolutionsByType;
}
}
/// <summary>
/// Checks for incompatibility between the netcode and hosting model.
/// </summary>
/// <param name="netcode">The netcode type</param>
/// <param name="hostingModel">The hosting model</param>
/// <param name="reason">The reason for the incompatibility, filled when this function returns false.</param>
/// <returns>True if compatible (default), False otherwise</returns>
public bool IsHostingModelCompatibleWithNetcode(PossibleSolution netcode, PossibleSolution hostingModel, out string reason)
{
m_IncompatibleHostingModels ??= Utils.ToDictionary(RecommendedSolutions);
return !m_IncompatibleHostingModels.TryGetValue(new SolutionSelection(netcode, hostingModel), out reason);
}
/// <summary>
/// Patch incompatibility values.
/// </summary>
/// <param name="netcode">Netcode solution</param>
/// <param name="hostingModel">Hosting model solution</param>
/// <param name="newIsCompatible">Whether we should now consider the two solutions compatible</param>
/// <param name="reason">If incompatible, why it is incompatible.</param>
internal void UpdateIncompatibility(PossibleSolution netcode, PossibleSolution hostingModel, bool newIsCompatible, string reason=null)
{
Utils.UpdateIncompatibilityInSolutions(RecommendedSolutions, netcode, hostingModel, newIsCompatible, reason);
m_IncompatibleHostingModels = Utils.ToDictionary(RecommendedSolutions);
m_SolutionsByType = Utils.ToDictionary(RecommendedSolutions, s => s.Type);
}
Dictionary<string, PackageDetails> m_PackageDetailsById;
Dictionary<PossibleSolution, RecommendedSolution> m_SolutionsByType;
Dictionary<SolutionSelection, string> m_IncompatibleHostingModels;
}
[Serializable]
internal struct SolutionSelection
{
public PossibleSolution Netcode;
public PossibleSolution HostingModel;
public SolutionSelection(PossibleSolution netcode, PossibleSolution hostingModel)
{
Netcode = netcode;
HostingModel = hostingModel;
}
}
/// <summary>
/// A possible solution and whether packages are recommended or not
/// </summary>
[Serializable]
internal class RecommendedSolution
{
/// <summary>
/// The type of solution
/// </summary>
public PossibleSolution Type;
/// <summary>
/// The name of the solution as shown in the UI.
/// </summary>
public string Title;
/// <summary>
/// Optional package ID associated with that solution (e.g. a netcode package or the cloud code package).
/// Use this field if the package has to mandatorily be installed when the solution is selected.
/// </summary>
public string MainPackageId;// only id because scoring will impact the rest
/// <summary>
/// Url to documentation describing the solution.
/// </summary>
public string DocUrl;
/// <summary>
/// Short description of the solution.
/// </summary>
public string ShortDescription;
/// <summary>
/// The packages and the associated recommendation type.
/// If the Type is a netcode Type, all the possible packages should be in this array.
/// If the Type is a hosting model, this will contain only overrides in case a package is incompatible or
/// featured for the hosting model.
/// </summary>
public RecommendedPackage[] RecommendedPackages;
/// <summary>
/// Solutions that are incompatible with this solution.
/// Typically used for netcode solutions.
/// </summary>
public IncompatibleSolution[] IncompatibleSolutions = Array.Empty<IncompatibleSolution>();
}
/// <summary>
/// Stores why a solution is incompatible with something and why.
/// </summary>
[Serializable]
internal class IncompatibleSolution
{
/// <summary>
/// What is incompatible.
/// </summary>
public PossibleSolution Solution;
/// <summary>
/// Why it is incompatible.
/// </summary>
public string Reason;
public IncompatibleSolution(PossibleSolution solution, string reason)
{
Solution = solution;
Reason = reason;
}
}
/// <summary>
/// A package, whether it is recommended or not (context dependent), and why.
/// </summary>
[Serializable]
internal class RecommendedPackage
{
/// <summary>
/// The package id (e.g. com.unity.netcode)
/// </summary>
public string PackageId;
/// <summary>
/// Whether it is recommended or not.
/// </summary>
public RecommendationType Type;
/// <summary>
/// Why it is recommended or not.
/// </summary>
public string Reason;
public RecommendedPackage(string packageId, RecommendationType type, string reason)
{
PackageId = packageId;
Type = type;
Reason = reason;
}
}
[Serializable]
internal class PackageDetails
{
public string Id;
public string Name;
public string ShortDescription;
public string DocsUrl;
public string[] AdditionalPackages;
/// <summary>
/// In case we want to promote a specific pre-release version, this is set (by default, this is null
/// and the default package manager version is used).
/// </summary>
public string PreReleaseVersion;
/// <summary>
/// Details about the package.
/// </summary>
/// <param name="id">Package ID</param>
/// <param name="name">Package Name (for display)</param>
/// <param name="shortDescription">Short description.</param>
/// <param name="docsUrl">Link to Docs</param>
/// <param name="additionalPackages">Ids of packages that should be installed along this one, but are not formal dependencies.</param>
public PackageDetails(string id, string name, string shortDescription, string docsUrl, string[] additionalPackages = null)
{
Id = id;
Name = name;
ShortDescription = shortDescription;
DocsUrl = docsUrl;
AdditionalPackages = additionalPackages;
}
}
static class Utils
{
public static Dictionary<TKey, T> ToDictionary<T, TKey>(T[] array, Func<T, TKey> keySelector)
{
if (array == null) return null;
var result = new Dictionary<TKey, T>();
foreach (var item in array)
{
result[keySelector(item)] = item;
}
return result;
}
public static Dictionary<SolutionSelection, string> ToDictionary(RecommendedSolution[] solutions)
{
var result = new Dictionary<SolutionSelection, string>();
foreach (var recommendedSolution in solutions)
{
foreach (var incompatibleHostingModel in recommendedSolution.IncompatibleSolutions)
{
var key = new SolutionSelection(recommendedSolution.Type, incompatibleHostingModel.Solution);
result.Add(key, incompatibleHostingModel.Reason);
}
}
return result;
}
public static void UpdateIncompatibilityInSolutions(RecommendedSolution[] solutions, PossibleSolution netcode,
PossibleSolution hostingModel, bool newIsCompatible, string reason)
{
foreach (var recommendedSolution in solutions)
{
if (recommendedSolution.Type != netcode) continue;
var incompatibleSolution = Array.Find(recommendedSolution.IncompatibleSolutions, s => s.Solution == hostingModel);
if (newIsCompatible && incompatibleSolution != null)
{
recommendedSolution.IncompatibleSolutions = Array.FindAll(recommendedSolution.IncompatibleSolutions, s => s.Solution != hostingModel);
}
else if (!newIsCompatible && incompatibleSolution == null)
{
Array.Resize(ref recommendedSolution.IncompatibleSolutions, recommendedSolution.IncompatibleSolutions.Length + 1);
recommendedSolution.IncompatibleSolutions[^1] = new IncompatibleSolution(hostingModel, reason);
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 38a11839c22b4e67aa2e508d906edf65
timeCreated: 1702913830

View File

@@ -0,0 +1,35 @@
using UnityEditor;
namespace Unity.Multiplayer.Center.Recommendations
{
/// <summary>
/// Current way to fetch recommendation data from disk. Will probably change to fetching something from a server.
/// </summary>
[FilePath(PathConstants.RecommendationDataPath, FilePathAttribute.Location.ProjectFolder)]
internal class RecommenderSystemDataObject : ScriptableSingleton<RecommenderSystemDataObject>
{
public RecommenderSystemData RecommenderSystemData;
#if MULTIPLAYER_CENTER_DEV_MODE
[MenuItem("Multiplayer/Recommendations/Populate Default Recommendation Data")]
public static void CreateDefaultInstance()
{
instance.RecommenderSystemData = RecommendationAssetUtils.PopulateDefaultRecommendationData();
instance.ForceSave();
}
void ForceSave()
{
base.Save(saveAsText:true);
AssetDatabase.Refresh();
DestroyImmediate(this);
}
#endif
}
static class PathConstants
{
const string k_RootPath = "Packages/com.unity.multiplayer.center/Editor/Recommendations/";
public const string RecommendationDataPath = k_RootPath + "RecommendationData_6000.0.recommendations";
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: b6a02e450cd04bb781e7728f2056251c
timeCreated: 1702913905

View File

@@ -0,0 +1,130 @@
using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
namespace Unity.Multiplayer.Center.Recommendations
{
struct ScoreWithReason
{
public float Score;
public string Reason;
public ScoreWithReason(float score, string reason)
{
Score = score;
Reason = reason;
}
}
/// <summary>
/// Aggregates scores for a given solution and fetches the reasons for the highest score
/// </summary>
internal class Scoring
{
public const string DynamicKeyword = "[dynamic]";
string m_PrimaryDescription = null;
List<ScoreWithReason> m_AllScores = new();
public float TotalScore { get; private set; } = 0f;
public Scoring(string primaryDescription)
{
m_PrimaryDescription = primaryDescription;
}
public void AddScore(float score, string reason)
{
TotalScore += score;
if(m_AllScores.Count == 0)
{
m_AllScores.Add(new ScoreWithReason(score, reason));
return;
}
int insertIndex = 0;
for (int i = 0; i < m_AllScores.Count; i++)
{
if (score > m_AllScores[i].Score)
{
insertIndex = i;
break;
}
}
m_AllScores.Insert(insertIndex, new ScoreWithReason(score, reason));
}
/// <summary>
/// Gets the reason for increased scores
/// </summary>
/// <returns>The explanatory string</returns>
public string GetReasonString() => GetAllContributionsReasons();
string GetAllContributionsReasons()
{
return string.IsNullOrEmpty(m_PrimaryDescription)? OneReasonPerLine() : CombineReasonsInOneSentence();
}
string OneReasonPerLine()
{
var stringBuilder = new System.Text.StringBuilder();
foreach (var score in m_AllScores)
{
stringBuilder.AppendLine(score.Reason);
}
return stringBuilder.ToString();
}
string CombineReasonsInOneSentence()
{
var length = m_AllScores.Count;
if(length == 0)
return RemoveSentenceWithDynamicKeyword(m_PrimaryDescription);
var explanation = length switch
{
1 => m_AllScores[0].Reason,
2 => $"{m_AllScores[0].Reason} and {m_AllScores[1].Reason}",
_ => Combine(m_AllScores)
};
return m_PrimaryDescription.Replace(DynamicKeyword, explanation);
}
static string RemoveSentenceWithDynamicKeyword(string primaryDescription)
{
var sentences = primaryDescription.Split('.');
var sentencesWithoutKeyword = new StringBuilder(capacity:sentences.Length);
var index = 0;
foreach (var sentence in sentences)
{
if (!string.IsNullOrEmpty(sentence) && !sentence.Contains(DynamicKeyword))
{
sentencesWithoutKeyword.Append(sentence);
sentencesWithoutKeyword.Append(".");
index++;
}
}
return sentencesWithoutKeyword.ToString();
}
static string Combine(List<ScoreWithReason> scores)
{
var stringBuilder = new System.Text.StringBuilder();
for(var i = 0; i < scores.Count -2; i++)
{
stringBuilder.Append(scores[i].Reason);
stringBuilder.Append(", ");
}
stringBuilder.Append(scores[^2].Reason);
stringBuilder.Append(" and ");
stringBuilder.Append(scores[^1].Reason);
return stringBuilder.ToString();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f526d484b0c04a1896c469f31c713786
timeCreated: 1699892140