Chat commands and better monster AI

This commit is contained in:
xeon 2024-02-15 00:22:21 +03:00
parent 8506dbdf71
commit 7ad0f2bfcc
19 changed files with 428 additions and 4 deletions

View file

@ -0,0 +1,12 @@
namespace GameServer.Controllers.Attributes;
[AttributeUsage(AttributeTargets.Method)]
internal class ChatCommandAttribute : Attribute
{
public string Command { get; }
public ChatCommandAttribute(string command)
{
Command = command;
}
}

View file

@ -0,0 +1,12 @@
namespace GameServer.Controllers.Attributes;
[AttributeUsage(AttributeTargets.Class)]
internal class ChatCommandCategoryAttribute : Attribute
{
public string Category { get; }
public ChatCommandCategoryAttribute(string category)
{
Category = category;
}
}

View file

@ -0,0 +1,12 @@
namespace GameServer.Controllers.Attributes;
[AttributeUsage(AttributeTargets.Method)]
internal class ChatCommandDescAttribute : Attribute
{
public string Description { get; }
public ChatCommandDescAttribute(string description)
{
Description = description;
}
}

View file

@ -0,0 +1,77 @@
using System.Collections.Immutable;
using System.Linq.Expressions;
using System.Reflection;
using GameServer.Controllers.Attributes;
using Microsoft.Extensions.DependencyInjection;
namespace GameServer.Controllers.ChatCommands;
internal class ChatCommandManager
{
private delegate Task ChatCommandDelegate(IServiceProvider serviceProvider, string[] args);
private static readonly ImmutableDictionary<string, ImmutableDictionary<string, ChatCommandDelegate>> s_commandCategories;
private static readonly ImmutableArray<string> s_commandDescriptions;
private readonly IServiceProvider _serviceProvider;
static ChatCommandManager()
{
(s_commandCategories, s_commandDescriptions) = RegisterCommands();
}
public static IEnumerable<string> CommandDescriptions => s_commandDescriptions;
public ChatCommandManager(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task InvokeCommandAsync(string category, string command, string[] args)
{
if (s_commandCategories.TryGetValue(category, out var commands))
{
if (commands.TryGetValue(command, out ChatCommandDelegate? commandDelegate))
await commandDelegate(_serviceProvider, args);
}
}
private static (ImmutableDictionary<string, ImmutableDictionary<string, ChatCommandDelegate>>, ImmutableArray<string>) RegisterCommands()
{
IEnumerable<Type> types = Assembly.GetExecutingAssembly().GetTypes()
.Where(type => type.GetCustomAttribute<ChatCommandCategoryAttribute>() != null);
MethodInfo getServiceMethod = typeof(ServiceProviderServiceExtensions).GetMethod(nameof(ServiceProviderServiceExtensions.GetRequiredService), [typeof(IServiceProvider)])!;
var categories = ImmutableDictionary.CreateBuilder<string, ImmutableDictionary<string, ChatCommandDelegate>>();
var descriptions = ImmutableArray.CreateBuilder<string>();
foreach (Type type in types)
{
var commands = ImmutableDictionary.CreateBuilder<string, ChatCommandDelegate>();
foreach (MethodInfo method in type.GetMethods())
{
ChatCommandAttribute? cmdAttribute = method.GetCustomAttribute<ChatCommandAttribute>();
if (cmdAttribute == null) continue;
ParameterExpression serviceProviderParam = Expression.Parameter(typeof(IServiceProvider));
ParameterExpression argsParam = Expression.Parameter(typeof(string[]));
MethodCallExpression getServiceCall = Expression.Call(getServiceMethod.MakeGenericMethod(type), serviceProviderParam);
Expression handlerCall = Expression.Call(getServiceCall, method, argsParam);
if (method.ReturnType == typeof(void)) // Allow non-async methods as well
handlerCall = Expression.Block(handlerCall, Expression.Constant(Task.CompletedTask));
Expression<ChatCommandDelegate> lambda = Expression.Lambda<ChatCommandDelegate>(handlerCall, serviceProviderParam, argsParam);
commands.Add(cmdAttribute.Command, lambda.Compile());
ChatCommandDescAttribute? desc = method.GetCustomAttribute<ChatCommandDescAttribute>();
if (desc != null)
descriptions.Add(desc.Description);
}
ChatCommandCategoryAttribute categoryAttribute = type.GetCustomAttribute<ChatCommandCategoryAttribute>()!;
categories.Add(categoryAttribute.Category, commands.ToImmutable());
}
return (categories.ToImmutable(), descriptions.ToImmutable());
}
}

View file

@ -0,0 +1,62 @@
using Core.Config;
using GameServer.Controllers.Attributes;
using GameServer.Models;
using GameServer.Models.Chat;
using GameServer.Network;
using GameServer.Systems.Entity;
using Protocol;
namespace GameServer.Controllers.ChatCommands;
[ChatCommandCategory("spawn")]
internal class ChatSpawnCommandHandler
{
private readonly ChatRoom _helperRoom;
private readonly EntitySystem _entitySystem;
private readonly EntityFactory _entityFactory;
private readonly PlayerSession _session;
private readonly ConfigManager _configManager;
public ChatSpawnCommandHandler(ModelManager modelManager, EntitySystem entitySystem, EntityFactory entityFactory, PlayerSession session, ConfigManager configManager)
{
_helperRoom = modelManager.Chat.GetChatRoom(1338);
_entitySystem = entitySystem;
_entityFactory = entityFactory;
_session = session;
_configManager = configManager;
}
[ChatCommand("monster")]
[ChatCommandDesc("/spawn monster [id] [x] [y] [z] - spawns monster with specified id and coordinates")]
public async Task OnSpawnMonsterCommand(string[] args)
{
if (args.Length != 4 ||
!(int.TryParse(args[0], out int levelEntityId) &&
int.TryParse(args[1], out int x) &&
int.TryParse(args[2], out int y) &&
int.TryParse(args[3], out int z)))
{
_helperRoom.AddMessage(1338, 0, "Usage: /spawn monster [id] [x] [y] [z]");
return;
}
MonsterEntity monster = _entityFactory.CreateMonster(levelEntityId);
monster.Pos = new()
{
X = x,
Y = y,
Z = z
};
_entitySystem.Create(monster);
monster.InitProps(_configManager.GetConfig<BasePropertyConfig>(600000100)!); // TODO: monster property config
await _session.Push(MessageId.EntityAddNotify, new EntityAddNotify
{
IsAdd = true,
EntityPbs = { monster.Pb }
});
_helperRoom.AddMessage(1338, 0, $"Successfully spawned monster with id {levelEntityId} at ({x}, {y}, {z})");
}
}

View file

@ -0,0 +1,88 @@
using System.Text;
using GameServer.Controllers.Attributes;
using GameServer.Controllers.ChatCommands;
using GameServer.Models;
using GameServer.Models.Chat;
using GameServer.Network;
using GameServer.Network.Messages;
using Protocol;
namespace GameServer.Controllers;
internal class ChatController : Controller
{
private readonly ModelManager _modelManager;
public ChatController(PlayerSession session, ModelManager modelManager) : base(session)
{
_modelManager = modelManager;
}
[NetEvent(MessageId.PrivateChatDataRequest)]
public async Task<ResponseMessage> OnPrivateChatDataRequest()
{
if (!_modelManager.Chat.AllChatRooms.Any())
{
ChatRoom chatRoom = _modelManager.Chat.GetChatRoom(1338); // Reversed Helper
chatRoom.AddMessage(1338, (int)ChatContentType.Text, BuildWelcomeMessage());
}
await PushPrivateChatHistory();
return Response(MessageId.PrivateChatDataResponse, new PrivateChatDataResponse()); // Response doesn't contain any useful info, everything is in notifies.
}
[NetEvent(MessageId.PrivateChatRequest)]
public async Task<ResponseMessage> OnPrivateChatRequest(PrivateChatRequest request, ChatCommandManager chatCommandManager)
{
ChatRoom chatRoom = _modelManager.Chat.GetChatRoom(1338);
chatRoom.AddMessage(_modelManager.Player.Id, request.ChatContentType, request.Content);
if (!request.Content.StartsWith('/'))
{
chatRoom.AddMessage(1338, 0, "huh?");
}
else
{
string[] split = request.Content[1..].Split(' ');
if (split.Length >= 2)
{
await chatCommandManager.InvokeCommandAsync(split[0], split[1], split[2..]);
}
}
await PushPrivateChatHistory();
return Response(MessageId.PrivateChatResponse, new PrivateChatResponse());
}
[NetEvent(MessageId.PrivateChatOperateRequest)]
public ResponseMessage OnPrivateChatOperateRequest() => Response(MessageId.PrivateChatOperateResponse, new PrivateChatOperateResponse());
private async Task PushPrivateChatHistory()
{
await Session.Push(MessageId.PrivateChatHistoryNotify, new PrivateChatHistoryNotify
{
AllChats =
{
_modelManager.Chat.AllChatRooms
.Select(chatRoom => new PrivateChatHistoryContentProto
{
TargetUid = chatRoom.TargetUid,
Chats = { chatRoom.ChatHistory }
})
}
});
}
private static string BuildWelcomeMessage()
{
StringBuilder builder = new();
builder.AppendLine("Welcome to ReversedRooms WutheringWaves private server!\nTo get support, join:\ndiscord.gg/reversedrooms");
builder.AppendLine("List of all commands:");
foreach (string description in ChatCommandManager.CommandDescriptions)
{
builder.AppendLine(description);
}
return builder.ToString();
}
}

View file

@ -28,6 +28,7 @@ internal class CreatureController : Controller
{ {
_modelManager.Creature.SetSceneLoadingData(instanceId); _modelManager.Creature.SetSceneLoadingData(instanceId);
CreateTeamPlayerEntities(); CreateTeamPlayerEntities();
CreateWorldEntities();
await Session.Push(MessageId.JoinSceneNotify, new JoinSceneNotify await Session.Push(MessageId.JoinSceneNotify, new JoinSceneNotify
{ {
@ -150,6 +151,13 @@ internal class CreatureController : Controller
.FirstOrDefault(e => e is PlayerEntity playerEntity && playerEntity.ConfigId == roleId && playerEntity.PlayerId == _modelManager.Player.Id) as PlayerEntity; .FirstOrDefault(e => e is PlayerEntity playerEntity && playerEntity.ConfigId == roleId && playerEntity.PlayerId == _modelManager.Player.Id) as PlayerEntity;
} }
public IEnumerable<PlayerEntity> GetPlayerEntities()
{
return _entitySystem.EnumerateEntities()
.Where(e => e is PlayerEntity entity && entity.PlayerId == _modelManager.Player.Id)
.Cast<PlayerEntity>();
}
public async Task SwitchPlayerEntity(int roleId) public async Task SwitchPlayerEntity(int roleId)
{ {
PlayerEntity? prevEntity = GetPlayerEntity(); PlayerEntity? prevEntity = GetPlayerEntity();
@ -235,7 +243,10 @@ internal class CreatureController : Controller
if (i == 0) _modelManager.Creature.PlayerEntityId = entity.Id; if (i == 0) _modelManager.Creature.PlayerEntityId = entity.Id;
} }
}
private void CreateWorldEntities()
{
// Test monster // Test monster
MonsterEntity monster = _entityFactory.CreateMonster(102000014); // Monster001 MonsterEntity monster = _entityFactory.CreateMonster(102000014); // Monster001
monster.Pos = new() monster.Pos = new()

View file

@ -12,5 +12,25 @@ internal class FriendSystemController : Controller
} }
[NetEvent(MessageId.FriendAllRequest)] [NetEvent(MessageId.FriendAllRequest)]
public ResponseMessage OnFriendAllRequest() => Response(MessageId.FriendAllResponse, new FriendAllResponse()); public ResponseMessage OnFriendAllRequest() => Response(MessageId.FriendAllResponse, new FriendAllResponse
{
FriendInfoList =
{
CreateDummyFriendInfo(1338, "Taoqi", "discord.gg/reversedrooms", 1601)
}
});
private static FriendInfo CreateDummyFriendInfo(int id, string name, string signature, int headIconId) => new()
{
Info = new()
{
PlayerId = id,
Name = name,
Signature = signature,
Level = 5,
HeadId = headIconId,
IsOnline = true,
LastOfflineTime = -1
}
};
} }

View file

@ -1,6 +1,7 @@
using GameServer.Controllers.Attributes; using GameServer.Controllers.Attributes;
using GameServer.Models; using GameServer.Models;
using GameServer.Network; using GameServer.Network;
using GameServer.Network.Messages;
using GameServer.Systems.Event; using GameServer.Systems.Event;
using Protocol; using Protocol;
@ -56,4 +57,22 @@ internal class PlayerInfoController : Controller
await Session.Push(MessageId.BasicInfoNotify, basicInfo); await Session.Push(MessageId.BasicInfoNotify, basicInfo);
} }
[NetEvent(MessageId.PlayerBasicInfoGetRequest)]
public ResponseMessage OnPlayerBasicInfoGetRequest()
{
return Response(MessageId.PlayerBasicInfoGetResponse, new PlayerBasicInfoGetResponse
{
Info = new PlayerDetails
{
Name = "Taoqi",
Signature = "discord.gg/reversedrooms",
HeadId = 1601,
PlayerId = 1338,
IsOnline = true,
LastOfflineTime = -1,
Level = 5
}
});
}
} }

View file

@ -1,5 +1,6 @@
using System.Reflection; using System.Reflection;
using GameServer.Controllers; using GameServer.Controllers;
using GameServer.Controllers.Attributes;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
namespace GameServer.Extensions; namespace GameServer.Extensions;
@ -17,4 +18,17 @@ internal static class ServiceCollectionExtensions
return services; return services;
} }
public static IServiceCollection AddCommands(this IServiceCollection services)
{
IEnumerable<Type> handlerTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => t.GetCustomAttribute<ChatCommandCategoryAttribute>() != null);
foreach (Type type in handlerTypes)
{
services.AddScoped(type);
}
return services;
}
} }

View file

@ -5,8 +5,13 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Content Include="icon.ico" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
</ItemGroup> </ItemGroup>

View file

@ -0,0 +1,32 @@
using Google.Protobuf;
using Protocol;
namespace GameServer.Models.Chat;
internal class ChatRoom
{
private readonly List<ChatContentProto> _messages;
private int _msgIdCounter;
public int TargetUid { get; }
public ChatRoom(int targetUid)
{
TargetUid = targetUid;
_messages = [];
}
public IEnumerable<ChatContentProto> ChatHistory => _messages;
public void AddMessage(int senderId, int contentType, string content)
{
_messages.Add(new ChatContentProto
{
SenderUid = senderId,
ChatContentType = contentType,
Content = content,
MsgId = NextMessageId().ToString()
});
}
private int NextMessageId() => Interlocked.Increment(ref _msgIdCounter);
}

View file

@ -0,0 +1,29 @@
using GameServer.Models.Chat;
namespace GameServer.Models;
internal class ChatModel
{
private readonly Dictionary<int, ChatRoom> _rooms;
public ChatModel()
{
_rooms = [];
}
/// <summary>
/// Gets chat room for specified player id.
/// Creates new one if it doesn't exist.
/// </summary>
public ChatRoom GetChatRoom(int id)
{
if (!_rooms.TryGetValue(id, out ChatRoom? chatRoom))
{
chatRoom = new ChatRoom(id);
_rooms[id] = chatRoom;
}
return chatRoom;
}
public IEnumerable<ChatRoom> AllChatRooms => _rooms.Values;
}

View file

@ -30,4 +30,5 @@ internal class ModelManager
public CreatureModel Creature => _creatureModel ?? throw new InvalidOperationException($"Trying to access {nameof(CreatureModel)} instance before initialization!"); public CreatureModel Creature => _creatureModel ?? throw new InvalidOperationException($"Trying to access {nameof(CreatureModel)} instance before initialization!");
public FormationModel Formation { get; } = new(); public FormationModel Formation { get; } = new();
public ChatModel Chat { get; } = new();
} }

View file

@ -1,5 +1,6 @@
using Core.Config; using Core.Config;
using Core.Extensions; using Core.Extensions;
using GameServer.Controllers.ChatCommands;
using GameServer.Controllers.Factory; using GameServer.Controllers.Factory;
using GameServer.Controllers.Manager; using GameServer.Controllers.Manager;
using GameServer.Extensions; using GameServer.Extensions;
@ -22,22 +23,37 @@ internal static class Program
{ {
private static async Task Main(string[] args) private static async Task Main(string[] args)
{ {
Console.Title = "Wuthering Waves | Game Server";
Console.WriteLine(" __ __ __ .__ .__ __ __ \r\n/ \\ / \\__ ___/ |_| |__ ___________|__| ____ ____ / \\ / \\_____ ___ __ ____ ______\r\n\\ \\/\\/ / | \\ __\\ | \\_/ __ \\_ __ \\ |/ \\ / ___\\ \\ \\/\\/ /\\__ \\\\ \\/ // __ \\ / ___/\r\n \\ /| | /| | | Y \\ ___/| | \\/ | | \\/ /_/ > \\ / / __ \\\\ /\\ ___/ \\___ \\ \r\n \\__/\\ / |____/ |__| |___| /\\___ >__| |__|___| /\\___ / \\__/\\ / (____ /\\_/ \\___ >____ >\r\n \\/ \\/ \\/ \\//_____/ \\/ \\/ \\/ \\/ \n");
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(); builder.Logging.AddConsole();
builder.SetupConfiguration(); builder.SetupConfiguration();
builder.Services.UseLocalResources() builder.Services.UseLocalResources()
.AddControllers() .AddControllers()
.AddCommands()
.AddSingleton<ConfigManager>() .AddSingleton<ConfigManager>()
.AddSingleton<KcpGateway>().AddScoped<PlayerSession>() .AddSingleton<KcpGateway>().AddScoped<PlayerSession>()
.AddScoped<MessageManager>().AddSingleton<EventHandlerFactory>() .AddScoped<MessageManager>().AddSingleton<EventHandlerFactory>()
.AddScoped<RpcManager>().AddScoped<IRpcEndPoint, RpcSessionEndPoint>() .AddScoped<RpcManager>().AddScoped<IRpcEndPoint, RpcSessionEndPoint>()
.AddSingleton<SessionManager>() .AddSingleton<SessionManager>()
.AddScoped<EventSystem>().AddScoped<EntitySystem>().AddScoped<EntityFactory>() .AddScoped<EventSystem>().AddScoped<EntitySystem>().AddScoped<EntityFactory>()
.AddScoped<ModelManager>().AddScoped<ControllerManager>() .AddScoped<ModelManager>().AddScoped<ControllerManager>().AddScoped<ChatCommandManager>()
.AddHostedService<WWGameServer>(); .AddHostedService<WWGameServer>();
await builder.Build().RunAsync(); IHost host = builder.Build();
ILogger logger = host.Services.GetRequiredService<ILoggerFactory>().CreateLogger("WutheringWaves");
logger.LogInformation("Support: discord.gg/reversedrooms or discord.xeondev.com");
logger.LogInformation("Preparing server...");
host.Services.GetRequiredService<IHostApplicationLifetime>().ApplicationStarted.Register(() =>
{
logger.LogInformation("Server started! Let's play Wuthering Waves!");
});
await host.RunAsync();
} }
private static void SetupConfiguration(this HostApplicationBuilder builder) private static void SetupConfiguration(this HostApplicationBuilder builder)

View file

@ -28,10 +28,19 @@ internal class MonsterEntity : EntityBase
aiComponent.AiTeamInitId = 100; aiComponent.AiTeamInitId = 100;
EntityFsmComponent fsm = ComponentSystem.Create<EntityFsmComponent>(); EntityFsmComponent fsm = ComponentSystem.Create<EntityFsmComponent>();
fsm.Fsms.Add(new DFsm fsm.Fsms.Add(new DFsm
{ {
FsmId = 10007, // Main State Machine FsmId = 10007, // Main State Machine
CurrentState = 10009, // Standby Entry CurrentState = 10013, // Battle Branching
Status = 1, // ??
Flag = (int)EFsmStateFlag.Confirmed
});
fsm.Fsms.Add(new DFsm
{
FsmId = 10007, // Main State Machine
CurrentState = 10015, // Moving Combat
Status = 1, // ?? Status = 1, // ??
Flag = (int)EFsmStateFlag.Confirmed Flag = (int)EFsmStateFlag.Confirmed
}); });

BIN
GameServer/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

View file

@ -4,8 +4,13 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>icon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Content Include="icon.ico" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\.editorconfig" Link=".editorconfig" /> <None Include="..\.editorconfig" Link=".editorconfig" />
</ItemGroup> </ItemGroup>

BIN
SDKServer/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB