fix issue with view stats and reset stats failing

fix issue with set level returning wrong error message if setting a client to the same level they're currently at
update CoD4x parser version
update nuget packages
This commit is contained in:
RaidMax 2020-12-16 13:11:30 -06:00
parent 928cbef845
commit 4afc478076
11 changed files with 139 additions and 70 deletions

View File

@ -25,13 +25,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Jint" Version="3.0.0-beta-1632" /> <PackageReference Include="Jint" Version="3.0.0-beta-1632" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.10">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="3.1.10" />
<PackageReference Include="RestEase" Version="1.5.0" /> <PackageReference Include="RestEase" Version="1.5.1" />
<PackageReference Include="System.Text.Encoding.CodePages" Version="4.7.1" /> <PackageReference Include="System.Text.Encoding.CodePages" Version="5.0.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
@ -39,7 +39,6 @@
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection> <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<TieredCompilation>true</TieredCompilation> <TieredCompilation>true</TieredCompilation>
<LangVersion>Latest</LangVersion> <LangVersion>Latest</LangVersion>
<StartupObject></StartupObject>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Prerelease|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Prerelease|AnyCPU'">

View File

@ -68,7 +68,10 @@ namespace IW4MAdmin.Application
private static async void OnCancelKey(object sender, ConsoleCancelEventArgs e) private static async void OnCancelKey(object sender, ConsoleCancelEventArgs e)
{ {
ServerManager?.Stop(); ServerManager?.Stop();
await ApplicationTask; if (ApplicationTask != null)
{
await ApplicationTask;
}
} }
/// <summary> /// <summary>

View File

@ -25,12 +25,12 @@ var plugin = {
rconParser.Configuration.Dvar.AddMapping(110, 4); // dvar info rconParser.Configuration.Dvar.AddMapping(110, 4); // dvar info
rconParser.Configuration.GuidNumberStyle = 7; // Integer rconParser.Configuration.GuidNumberStyle = 7; // Integer
rconParser.Configuration.NoticeLineSeparator = '. '; // CoD4x does not support \n in the client notice rconParser.Configuration.NoticeLineSeparator = '. '; // CoD4x does not support \n in the client notice
rconParser.Version = 'CoD4 X - win_mingw-x86 build 963 Mar 12 2019'; rconParser.Version = 'CoD4 X - win_mingw-x86 build 1056 Dec 12 2020';
rconParser.GameName = 1; // IW3 rconParser.GameName = 1; // IW3
eventParser.Configuration.GameDirectory = 'main'; eventParser.Configuration.GameDirectory = 'main';
eventParser.Configuration.GuidNumberStyle = 7; // Integer eventParser.Configuration.GuidNumberStyle = 7; // Integer
eventParser.Version = 'CoD4 X - win_mingw-x86 build 963 Mar 12 2019'; eventParser.Version = 'CoD4 X - win_mingw-x86 build 1056 Dec 12 2020';
eventParser.GameName = 1; // IW3 eventParser.GameName = 1; // IW3
eventParser.URLProtocolFormat = 'cod4://{{ip}}:{{port}}'; eventParser.URLProtocolFormat = 'cod4://{{ip}}:{{port}}';
}, },

View File

@ -23,40 +23,44 @@ namespace IW4MAdmin.Plugins.Stats.Commands
Permission = EFClient.Permission.User; Permission = EFClient.Permission.User;
RequiresTarget = false; RequiresTarget = false;
AllowImpersonation = true; AllowImpersonation = true;
_contextFactory = contextFactory;
} }
public override async Task ExecuteAsync(GameEvent E) public override async Task ExecuteAsync(GameEvent gameEvent)
{ {
if (E.Origin.ClientNumber >= 0) if (gameEvent.Origin.ClientNumber >= 0)
{ {
var serverId = Helpers.StatManager.GetIdForServer(gameEvent.Owner);
long serverId = Helpers.StatManager.GetIdForServer(E.Owner);
EFClientStatistics clientStats;
await using var context = _contextFactory.CreateContext(); await using var context = _contextFactory.CreateContext();
clientStats = await context.Set<EFClientStatistics>() var clientStats = await context.Set<EFClientStatistics>()
.Where(s => s.ClientId == E.Origin.ClientId) .Where(s => s.ClientId == gameEvent.Origin.ClientId)
.Where(s => s.ServerId == serverId) .Where(s => s.ServerId == serverId)
.FirstAsync(); .FirstOrDefaultAsync();
clientStats.Deaths = 0; // want to prevent resetting stats before they've gotten any kills
clientStats.Kills = 0; if (clientStats != null)
clientStats.SPM = 0.0; {
clientStats.Skill = 0.0; clientStats.Deaths = 0;
clientStats.TimePlayed = 0; clientStats.Kills = 0;
// todo: make this more dynamic clientStats.SPM = 0.0;
clientStats.EloRating = 200.0; clientStats.Skill = 0.0;
clientStats.TimePlayed = 0;
// todo: make this more dynamic
clientStats.EloRating = 200.0;
await context.SaveChangesAsync();
}
// reset the cached version // reset the cached version
Plugin.Manager.ResetStats(E.Origin); Plugin.Manager.ResetStats(gameEvent.Origin);
await context.SaveChangesAsync(); gameEvent.Origin.Tell(_translationLookup["PLUGINS_STATS_COMMANDS_RESET_SUCCESS"]);
E.Origin.Tell(_translationLookup["PLUGINS_STATS_COMMANDS_RESET_SUCCESS"]);
} }
else else
{ {
E.Origin.Tell(_translationLookup["PLUGINS_STATS_COMMANDS_RESET_FAIL"]); gameEvent.Origin.Tell(_translationLookup["PLUGINS_STATS_COMMANDS_RESET_FAIL"]);
} }
} }
} }

View File

@ -19,7 +19,6 @@ namespace IW4MAdmin.Plugins.Stats.Commands
public ViewStatsCommand(CommandConfiguration config, ITranslationLookup translationLookup, public ViewStatsCommand(CommandConfiguration config, ITranslationLookup translationLookup,
IDatabaseContextFactory contextFactory) : base(config, translationLookup) IDatabaseContextFactory contextFactory) : base(config, translationLookup)
{ {
Name = "stats"; Name = "stats";
Description = translationLookup["PLUGINS_STATS_COMMANDS_VIEW_DESC"]; Description = translationLookup["PLUGINS_STATS_COMMANDS_VIEW_DESC"];
Alias = "xlrstats"; Alias = "xlrstats";
@ -34,16 +33,13 @@ namespace IW4MAdmin.Plugins.Stats.Commands
} }
}; };
_config = config;
_contextFactory = contextFactory; _contextFactory = contextFactory;
} }
private readonly CommandConfiguration _config;
public override async Task ExecuteAsync(GameEvent E) public override async Task ExecuteAsync(GameEvent E)
{ {
string statLine; string statLine;
EFClientStatistics pStats; EFClientStatistics pStats = null;
if (E.Data.Length > 0 && E.Target == null) if (E.Data.Length > 0 && E.Target == null)
{ {
@ -55,48 +51,67 @@ namespace IW4MAdmin.Plugins.Stats.Commands
} }
} }
long serverId = StatManager.GetIdForServer(E.Owner); var serverId = StatManager.GetIdForServer(E.Owner);
// getting stats for a particular client
if (E.Target != null) if (E.Target != null)
{ {
int performanceRanking = await Plugin.Manager.GetClientOverallRanking(E.Target.ClientId); var performanceRanking = await Plugin.Manager.GetClientOverallRanking(E.Target.ClientId);
string performanceRankingString = performanceRanking == 0 ? _translationLookup["WEBFRONT_STATS_INDEX_UNRANKED"] : $"{_translationLookup["WEBFRONT_STATS_INDEX_RANKED"]} #{performanceRanking}"; var performanceRankingString = performanceRanking == 0
? _translationLookup["WEBFRONT_STATS_INDEX_UNRANKED"]
: $"{_translationLookup["WEBFRONT_STATS_INDEX_RANKED"]} #{performanceRanking}";
if (E.Owner.GetClientsAsList().Any(_client => _client.Equals(E.Target))) // target is currently connected so we want their cached stats if they exist
if (E.Owner.GetClientsAsList().Any(client => client.Equals(E.Target)))
{ {
pStats = E.Target.GetAdditionalProperty<EFClientStatistics>(StatManager.CLIENT_STATS_KEY); pStats = E.Target.GetAdditionalProperty<EFClientStatistics>(StatManager.CLIENT_STATS_KEY);
} }
else // target is not connected so we want to look up via database
if (pStats == null)
{ {
await using var context = _contextFactory.CreateContext(false); await using var context = _contextFactory.CreateContext(false);
pStats = (await context.Set<EFClientStatistics>().FirstAsync(c => c.ServerId == serverId && c.ClientId == E.Target.ClientId)); pStats = (await context.Set<EFClientStatistics>()
.FirstOrDefaultAsync(c => c.ServerId == serverId && c.ClientId == E.Target.ClientId));
} }
statLine = $"^5{pStats.Kills} ^7{_translationLookup["PLUGINS_STATS_TEXT_KILLS"]} | ^5{pStats.Deaths} ^7{_translationLookup["PLUGINS_STATS_TEXT_DEATHS"]} | ^5{pStats.KDR} ^7KDR | ^5{pStats.Performance} ^7{_translationLookup["PLUGINS_STATS_COMMANDS_PERFORMANCE"].ToUpper()} | {performanceRankingString}";
// if it's still null then they've not gotten a kill or death yet
statLine = pStats == null
? _translationLookup["PLUGINS_STATS_COMMANDS_NOTAVAILABLE"]
: $"^5{pStats.Kills} ^7{_translationLookup["PLUGINS_STATS_TEXT_KILLS"]} | ^5{pStats.Deaths} ^7{_translationLookup["PLUGINS_STATS_TEXT_DEATHS"]} | ^5{pStats.KDR} ^7KDR | ^5{pStats.Performance} ^7{_translationLookup["PLUGINS_STATS_COMMANDS_PERFORMANCE"].ToUpper()} | {performanceRankingString}";
} }
// getting self stats
else else
{ {
int performanceRanking = await Plugin.Manager.GetClientOverallRanking(E.Origin.ClientId); var performanceRanking = await Plugin.Manager.GetClientOverallRanking(E.Origin.ClientId);
string performanceRankingString = performanceRanking == 0 ? _translationLookup["WEBFRONT_STATS_INDEX_UNRANKED"] : $"{_translationLookup["WEBFRONT_STATS_INDEX_RANKED"]} #{performanceRanking}"; var performanceRankingString = performanceRanking == 0
? _translationLookup["WEBFRONT_STATS_INDEX_UNRANKED"]
: $"{_translationLookup["WEBFRONT_STATS_INDEX_RANKED"]} #{performanceRanking}";
if (E.Owner.GetClientsAsList().Any(_client => _client.Equals(E.Origin))) // check if current client is connected to the server
if (E.Owner.GetClientsAsList().Any(client => client.Equals(E.Origin)))
{ {
pStats = E.Origin.GetAdditionalProperty<EFClientStatistics>(StatManager.CLIENT_STATS_KEY); pStats = E.Origin.GetAdditionalProperty<EFClientStatistics>(StatManager.CLIENT_STATS_KEY);
} }
else // happens if the user has not gotten a kill/death since connecting
if (pStats == null)
{ {
await using var context = _contextFactory.CreateContext(false); await using var context = _contextFactory.CreateContext(false);
pStats = (await context.Set<EFClientStatistics>().FirstAsync(c => c.ServerId == serverId && c.ClientId == E.Origin.ClientId)); pStats = (await context.Set<EFClientStatistics>()
.FirstOrDefaultAsync(c => c.ServerId == serverId && c.ClientId == E.Origin.ClientId));
} }
statLine = $"^5{pStats.Kills} ^7{_translationLookup["PLUGINS_STATS_TEXT_KILLS"]} | ^5{pStats.Deaths} ^7{_translationLookup["PLUGINS_STATS_TEXT_DEATHS"]} | ^5{pStats.KDR} ^7KDR | ^5{pStats.Performance} ^7{_translationLookup["PLUGINS_STATS_COMMANDS_PERFORMANCE"].ToUpper()} | {performanceRankingString}";
// if it's still null then they've not gotten a kill or death yet
statLine = pStats == null
? _translationLookup["PLUGINS_STATS_COMMANDS_NOTAVAILABLE"]
: $"^5{pStats.Kills} ^7{_translationLookup["PLUGINS_STATS_TEXT_KILLS"]} | ^5{pStats.Deaths} ^7{_translationLookup["PLUGINS_STATS_TEXT_DEATHS"]} | ^5{pStats.KDR} ^7KDR | ^5{pStats.Performance} ^7{_translationLookup["PLUGINS_STATS_COMMANDS_PERFORMANCE"].ToUpper()} | {performanceRankingString}";
} }
if (E.Message.IsBroadcastCommand(_config.BroadcastCommandPrefix)) if (E.Message.IsBroadcastCommand(_config.BroadcastCommandPrefix))
{ {
string name = E.Target == null ? E.Origin.Name : E.Target.Name; var name = E.Target == null ? E.Origin.Name : E.Target.Name;
E.Owner.Broadcast(_translationLookup["PLUGINS_STATS_COMMANDS_VIEW_SUCCESS"].FormatExt(name)); E.Owner.Broadcast(_translationLookup["PLUGINS_STATS_COMMANDS_VIEW_SUCCESS"].FormatExt(name));
E.Owner.Broadcast(statLine); E.Owner.Broadcast(statLine);
} }

View File

@ -1161,6 +1161,13 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
public void ResetStats(EFClient client) public void ResetStats(EFClient client)
{ {
var stats = client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY); var stats = client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
// the cached stats have not been loaded yet
if (stats == null)
{
return;
}
stats.Kills = 0; stats.Kills = 0;
stats.Deaths = 0; stats.Deaths = 0;
stats.SPM = 0; stats.SPM = 0;

View File

@ -726,6 +726,12 @@ namespace SharedLibraryCore.Commands
return; return;
} }
else if (gameEvent.Target.Level == Permission.Flagged)
{
gameEvent.Origin.Tell(_translationLookup["COMMANDS_SETLEVEL_FLAGGED"].FormatExt(gameEvent.Target.Name));
return;
}
// stepped privilege is enabled, but the new level is too high // stepped privilege is enabled, but the new level is too high
else if (steppedPrivileges && !canPromoteSteppedPriv) else if (steppedPrivileges && !canPromoteSteppedPriv)
{ {
@ -748,9 +754,19 @@ namespace SharedLibraryCore.Commands
if (result.Failed) if (result.Failed)
{ {
// user is the same level
if (result.FailReason == GameEvent.EventFailReason.Invalid)
{
gameEvent.Origin.Tell(_translationLookup["COMMANDS_SETLEVEL_INVALID"]
.FormatExt(gameEvent.Target.Name, newPerm.ToString()));
return;
}
using (LogContext.PushProperty("Server", gameEvent.Origin.CurrentServer?.ToString())) using (LogContext.PushProperty("Server", gameEvent.Origin.CurrentServer?.ToString()))
{ {
logger.LogWarning("Failed to set level of client {origin}", gameEvent.Origin.ToString()); logger.LogWarning("Failed to set level of client {origin} {reason}",
gameEvent.Origin.ToString(),
result.FailReason);
} }
gameEvent.Origin.Tell(_translationLookup["SERVER_ERROR_COMMAND_INGAME"]); gameEvent.Origin.Tell(_translationLookup["SERVER_ERROR_COMMAND_INGAME"]);
return; return;

View File

@ -28,35 +28,35 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="9.1.3" /> <PackageReference Include="FluentValidation" Version="9.3.0" />
<PackageReference Include="Humanizer.Core" Version="2.8.26" /> <PackageReference Include="Humanizer.Core" Version="2.8.26" />
<PackageReference Include="Humanizer.Core.ru" Version="2.8.26" /> <PackageReference Include="Humanizer.Core.ru" Version="2.8.26" />
<PackageReference Include="Humanizer.Core.de" Version="2.8.26" /> <PackageReference Include="Humanizer.Core.de" Version="2.8.26" />
<PackageReference Include="Humanizer.Core.es" Version="2.8.26" /> <PackageReference Include="Humanizer.Core.es" Version="2.8.26" />
<PackageReference Include="Humanizer.Core.pt" Version="2.8.26" /> <PackageReference Include="Humanizer.Core.pt" Version="2.8.26" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.1.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.10">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.10" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Localization" Version="3.1.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="3.1.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.1.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="3.1.7" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="3.1.10" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Npgsql" Version="4.1.4" /> <PackageReference Include="Npgsql" Version="4.1.7" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.1.2" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.2.4" />
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" /> <PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
<PackageReference Include="SimpleCrypto.NetCore" Version="1.0.0" /> <PackageReference Include="SimpleCrypto.NetCore" Version="1.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(Configuration)'=='Debug'"> <ItemGroup Condition="'$(Configuration)'=='Debug'">
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="3.1.7" /> <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="3.1.10" />
</ItemGroup> </ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent"> <Target Name="PreBuild" BeforeTargets="PreBuildEvent">

View File

@ -6,9 +6,9 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FakeItEasy" Version="6.2.0" /> <PackageReference Include="FakeItEasy" Version="6.2.1" />
<PackageReference Include="FluentAssertions" Version="5.10.3" /> <PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.7.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" />
<PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit" Version="3.12.0" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0"> <PackageReference Include="NUnit3TestAdapter" Version="3.17.0">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>

View File

@ -532,6 +532,31 @@ namespace ApplicationTests
Assert.IsNotEmpty(mockEventHandler.Events.Where(_event => _event.Type == GameEvent.EventType.Tell)); Assert.IsNotEmpty(mockEventHandler.Events.Where(_event => _event.Type == GameEvent.EventType.Tell));
Assert.IsNotEmpty(mockEventHandler.Events.Where(_event => _event.Type == GameEvent.EventType.ChangePermission && !_event.Failed)); Assert.IsNotEmpty(mockEventHandler.Events.Where(_event => _event.Type == GameEvent.EventType.ChangePermission && !_event.Failed));
} }
[Test]
public async Task Test_SetLevelFail_WhenFlagged()
{
var server = serviceProvider.GetRequiredService<IW4MServer>();
var cmd = serviceProvider.GetRequiredService<SetLevelCommand>();
var origin = ClientGenerators.CreateBasicClient(server);
origin.Level = Permission.Owner;
var target = ClientGenerators.CreateBasicClient(server);
target.Level = Permission.Flagged;
var gameEvent = new GameEvent()
{
Target = target,
Origin = origin,
Data = "Banned",
Owner = server,
};
await cmd.ExecuteAsync(gameEvent);
Assert.AreEqual(Permission.Flagged, target.Level);
Assert.IsNotEmpty(mockEventHandler.Events.Where(_event => _event.Type == GameEvent.EventType.Tell));
Assert.IsEmpty(mockEventHandler.Events.Where(_event => _event.Type == GameEvent.EventType.ChangePermission));
}
#endregion #endregion
#region PREFIX_PROCESSING #region PREFIX_PROCESSING

View File

@ -68,8 +68,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="BuildWebCompiler" Version="1.12.405" /> <PackageReference Include="BuildWebCompiler" Version="1.12.405" />
<PackageReference Include="BundlerMinifier.Core" Version="3.2.449" /> <PackageReference Include="BundlerMinifier.Core" Version="3.2.449" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.1.2" /> <PackageReference Include="FluentValidation.AspNetCore" Version="9.3.0" />
<PackageReference Include="Microsoft.Web.LibraryManager.Build" Version="2.1.76" /> <PackageReference Include="Microsoft.Web.LibraryManager.Build" Version="2.1.113" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>