yuanshen/Yuanshen.Gameserver/Game/GmCommandService.cs
wisedev fc2ac1050f First working version of the server
KCP gameserver with packet dispatch, scene streaming, combat, gacha and the
shop, plus a small HTTP server for the login flow and the wish history page.

Game data under assets is pulled out of the client instead of being typed by
hand. The excel tables come from the packed bin container and the spawn points
from the compiled scene scripts.
2026-09-04 02:10:50 +03:00

592 lines
23 KiB
C#

using Microsoft.Extensions.Logging;
using Yuanshen.Common.Data.Excel;
using Yuanshen.Gameserver.Database;
using Yuanshen.Gameserver.Network.Session;
using Yuanshen.Protocol;
namespace Yuanshen.Gameserver.Game;
internal sealed class GmCommandService
{
private const int TeamSize = 4;
private const uint MoraItemId = 202;
private const uint ActionReasonTrifle = 3;
private const uint MaxConstellation = 6;
private readonly Player _player;
private readonly GameState _state;
private readonly SceneManager _scene;
private readonly NetSession _session;
private readonly PlayerPersistence _persistence;
private readonly ExcelTableCollection _excel;
private readonly WorldSpawner _worldSpawner;
private readonly GachaBannerTable _banners;
private readonly ILogger _logger;
public GmCommandService(Player player, GameState state, SceneManager scene, NetSession session,
PlayerPersistence persistence, ExcelTableCollection excel, WorldSpawner worldSpawner,
GachaBannerTable banners, ILogger<GmCommandService> logger)
{
_player = player;
_state = state;
_scene = scene;
_session = session;
_persistence = persistence;
_excel = excel;
_worldSpawner = worldSpawner;
_banners = banners;
_logger = logger;
}
public async ValueTask<IReadOnlyList<string>> ExecuteAsync(string input)
{
string[] parts = input.TrimStart('/').Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
return Help();
string command = parts[0].ToLowerInvariant();
string[] args = parts[1..];
try
{
List<string> reply = command switch
{
"help" => Help(),
"tp" => await TeleportAsync(args),
"spawn" => await SpawnAsync(args),
"clear" => await ClearAsync(),
"respawn" => await RespawnAsync(),
"give" => await GiveAsync(args),
"heal" => await HealAsync(),
"level" => await LevelAsync(args),
"asc" => await AscendAsync(args),
"talent" => await TalentAsync(args),
"const" => await ConstellationAsync(args),
"wlevel" => await WeaponLevelAsync(args),
"refine" => await RefineAsync(args),
"wish" => await WishAsync(args),
"avatar" => await AvatarAsync(args),
"team" => await TeamAsync(args),
"monster" => await SpawnAsync(args),
"trifle" => await GiveAsync(args),
"kill" => await KillAsync(args),
"item" => await ItemAsync(args),
"equip" => await EquipAsync(args),
"break" => await AscendAsync(args),
"skill" => await SkillAsync(args),
"goto" => await TeleportAsync(args),
"scoin" => await ScoinAsync(args),
"hcoin" => await HcoinAsync(args),
"player" => await PlayerAsync(args),
"quest" => Unsupported("quest"),
"dungeon" => Unsupported("dungeon"),
"jump" => Unsupported("jump"),
_ => new List<string> { $"unknown command /{command} - try /help" }
};
_logger.LogInformation("GM /{command} -> {result}", command, string.Join(" | ", reply));
return reply;
}
catch (Exception exception)
{
_logger.LogWarning("GM /{command} failed: {message}", command, exception.Message);
return new List<string> { $"/{command} failed: {exception.Message}" };
}
}
private static List<string> Help() => new()
{
"commands: /tp x y z, /spawn id [n], /clear, /respawn, /give id [n], /heal,",
"/level n, /asc n, /talent n, /const n, /wlevel n, /refine n, /wish [n] (grants fates),",
"team: avatar add id, team add id, team remove id, team list",
"mihoyo syntax: item add id [n], equip add id [lvl] [promote], level n, break n,",
"skill slot n, talent unlock all, goto x y z, scoin n, hcoin n, player level n"
};
private async ValueTask<List<string>> AvatarAsync(string[] args)
{
if (args.Length >= 2 && args[0].Equals("add", StringComparison.OrdinalIgnoreCase))
return await TeamAddAsync(args[1]);
return new List<string> { "usage: avatar add avatarId" };
}
private async ValueTask<List<string>> TeamAsync(string[] args)
{
if (args.Length == 0)
return TeamList();
string action = args[0].ToLowerInvariant();
if (action == "list")
return TeamList();
if (action == "reset")
return await TeamResetAsync();
if (args.Length < 2)
return new List<string> { "usage: team add|remove avatarId, team list, team reset" };
return action switch
{
"add" => await TeamAddAsync(args[1]),
"remove" => await TeamRemoveAsync(args[1]),
_ => new List<string> { "usage: team add|remove avatarId, team list, team reset" }
};
}
private List<string> TeamList()
=> _player.TeamAvatars
.Select(avatar => $"{avatar.AvatarId} guid={avatar.Guid}")
.DefaultIfEmpty("team is empty")
.ToList();
private async ValueTask<List<string>> TeamAddAsync(string rawId)
{
if (!uint.TryParse(rawId, out uint avatarId))
return new List<string> { "usage: team add avatarId" };
GameAvatar? avatar = _player.Avatars.FirstOrDefault(candidate => candidate.AvatarId == avatarId);
if (avatar is null)
{
List<string> granted = await GrantAvatarAsync(avatarId, avatarId);
avatar = _player.Avatars.FirstOrDefault(candidate => candidate.AvatarId == avatarId);
if (avatar is null)
return granted;
}
if (_player.TeamAvatarGuids.Contains(avatar.Guid))
return new List<string> { $"avatar {avatarId} is already in the team" };
if (_player.TeamAvatarGuids.Count >= TeamSize)
return new List<string> { $"team is full, remove one first (max {TeamSize})" };
List<ulong> team = _player.TeamAvatarGuids.Append(avatar.Guid).ToList();
await _scene.SetupTeamAsync(_player.CurrentTeamId, team, _player.CurrentAvatarGuid);
await _persistence.SaveAsync();
return new List<string> { $"added avatar {avatarId} to the team" };
}
private async ValueTask<List<string>> TeamResetAsync()
{
_player.ResetTeam();
await _scene.SetupTeamAsync(_player.CurrentTeamId, _player.TeamAvatarGuids.ToList(), _player.CurrentAvatarGuid);
await _persistence.SaveAsync();
return new List<string> { "team reset to the default avatar" };
}
private async ValueTask<List<string>> TeamRemoveAsync(string rawId)
{
if (!uint.TryParse(rawId, out uint avatarId))
return new List<string> { "usage: team remove avatarId" };
GameAvatar? avatar = _player.Avatars.FirstOrDefault(candidate => candidate.AvatarId == avatarId);
if (avatar is null || !_player.TeamAvatarGuids.Contains(avatar.Guid))
return new List<string> { $"avatar {avatarId} is not in the team" };
if (_player.TeamAvatarGuids.Count <= 1)
return new List<string> { "cannot remove the last team member" };
List<ulong> team = _player.TeamAvatarGuids.Where(guid => guid != avatar.Guid).ToList();
ulong active = _player.CurrentAvatarGuid == avatar.Guid ? team[0] : _player.CurrentAvatarGuid;
await _scene.SetupTeamAsync(_player.CurrentTeamId, team, active);
await _persistence.SaveAsync();
return new List<string> { $"removed avatar {avatarId} from the team" };
}
private static List<string> Unsupported(string verb)
=> new() { $"{verb} is not implemented on this server yet" };
private async ValueTask<List<string>> ItemAsync(string[] args)
{
if (args.Length >= 2 && args[0].Equals("add", StringComparison.OrdinalIgnoreCase))
return await GiveAsync(args[1..]);
return new List<string> { "usage: item add itemId [count]" };
}
private async ValueTask<List<string>> ScoinAsync(string[] args)
{
if (args.Length < 1 || !uint.TryParse(args[0], out uint amount))
return new List<string> { "usage: scoin amount" };
_player.Scoin += amount;
await _session.NotifyAsync(CmdType.PlayerPropNotify, _player.BuildCurrencyProps());
await _persistence.SaveAsync();
return new List<string> { $"mora = {_player.Scoin}" };
}
private async ValueTask<List<string>> HcoinAsync(string[] args)
{
if (args.Length < 1 || !uint.TryParse(args[0], out uint amount))
return new List<string> { "usage: hcoin amount" };
_player.Hcoin += amount;
await _session.NotifyAsync(CmdType.PlayerPropNotify, _player.BuildCurrencyProps());
await _persistence.SaveAsync();
return new List<string> { $"primogems = {_player.Hcoin}" };
}
private async ValueTask<List<string>> PlayerAsync(string[] args)
{
if (args.Length < 2 || !args[0].Equals("level", StringComparison.OrdinalIgnoreCase)
|| !uint.TryParse(args[1], out uint level))
{
return new List<string> { "usage: player level n" };
}
level = Math.Clamp(level, 1u, 60u);
await _session.NotifyAsync(CmdType.PlayerPropNotify, new PlayerPropNotify
{
PropMap = { { 4001u, new PropValue { Type = 4001, Ival = level, Val = level } } }
});
return new List<string> { $"player level = {level} (display only, not persisted)" };
}
private async ValueTask<List<string>> SkillAsync(string[] args)
{
if (args.Length < 2 || !uint.TryParse(args[1], out uint level))
return new List<string> { "usage: skill slot level" };
return await TalentAsync(new[] { level.ToString() });
}
private async ValueTask<List<string>> EquipAsync(string[] args)
{
if (args.Length < 2 || !args[0].Equals("add", StringComparison.OrdinalIgnoreCase)
|| !uint.TryParse(args[1], out uint itemId))
{
return new List<string> { "usage: equip add itemId [level] [promote]" };
}
GameAvatar? avatar = _player.ActiveAvatar;
if (avatar is null)
return new List<string> { "no active avatar" };
uint level = args.Length > 2 && uint.TryParse(args[2], out uint parsedLevel) ? parsedLevel : 90u;
uint promote = args.Length > 3 && uint.TryParse(args[3], out uint parsedPromote) ? parsedPromote : 0u;
bool isWeapon = _excel.GetTable(ExcelType.Weapon).Items.OfType<WeaponExcel>().Any(weapon => weapon.Id == itemId);
if (isWeapon)
{
GameWeapon weapon = _player.CreateWeapon(itemId);
weapon.Level = Math.Clamp(level, 1u, 90u);
weapon.PromoteLevel = Math.Clamp(promote, 0u, 6u);
avatar.WeaponGuid = weapon.Guid;
await _session.NotifyAsync(CmdType.StoreItemChangeNotify, new StoreItemChangeNotify
{
StoreType = StoreType.StorePack,
ItemList = { weapon.AsItem() }
});
await _session.NotifyAsync(CmdType.AvatarEquipChangeNotify, new AvatarEquipChangeNotify
{
AvatarGuid = avatar.Guid,
EquipType = 6,
ItemId = weapon.ItemId,
EquipGuid = weapon.Guid,
Weapon = weapon.AsSceneWeapon(avatar.WeaponEntityId)
});
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"equipped weapon {itemId} lv{weapon.Level} promote {weapon.PromoteLevel}" };
}
GameReliquary? reliquary = _player.FindReliquaryByItemId(itemId);
if (reliquary is null)
return new List<string> { $"item {itemId} is neither a known weapon nor a reliquary" };
reliquary.Level = Math.Max(level, 1u);
avatar.ReliquaryGuids[reliquary.EquipType] = reliquary.Guid;
await _session.NotifyAsync(CmdType.AvatarEquipChangeNotify, new AvatarEquipChangeNotify
{
AvatarGuid = avatar.Guid,
EquipType = reliquary.EquipType,
ItemId = reliquary.ItemId,
EquipGuid = reliquary.Guid,
Reliquary = reliquary.AsSceneReliquary()
});
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"equipped reliquary {itemId} lv{reliquary.Level}" };
}
private async ValueTask<List<string>> KillAsync(string[] args)
{
if (args.Length >= 1 && args[0].Equals("monster", StringComparison.OrdinalIgnoreCase))
return await ClearAsync();
return new List<string> { "usage: kill monster all" };
}
private async ValueTask<List<string>> TeleportAsync(string[] args)
{
if (args.Length < 3 || !float.TryParse(args[0], out float x) || !float.TryParse(args[1], out float y) || !float.TryParse(args[2], out float z))
return new List<string> { "usage: /tp x y z" };
await _scene.TeleportAsync(new Vector { X = x, Y = y, Z = z });
return new List<string> { $"teleported to ({x}, {y}, {z})" };
}
private async ValueTask<List<string>> SpawnAsync(string[] args)
{
if (args.Length < 1 || !uint.TryParse(args[0], out uint monsterId))
return new List<string> { "usage: /spawn monsterId [count]" };
uint count = args.Length > 1 && uint.TryParse(args[1], out uint parsed) ? Math.Min(parsed, 50u) : 1u;
uint spawned = 0;
for (uint i = 0; i < count; i++)
{
double angle = Math.PI * 2 * i / Math.Max(count, 1u);
Vector pos = new()
{
X = _state.CurrentPos.X + (float)(Math.Cos(angle) * 4),
Y = _state.CurrentPos.Y,
Z = _state.CurrentPos.Z + (float)(Math.Sin(angle) * 4)
};
GameMonster monster = _player.SpawnMonster(monsterId, pos, 30, 1000f, 100f, 500f);
await _scene.AppearMonsterAsync(monster);
spawned++;
}
return new List<string> { $"spawned {spawned}x monster {monsterId}" };
}
private async ValueTask<List<string>> ClearAsync()
{
await _scene.ClearMonstersAsync();
return new List<string> { "cleared all monsters" };
}
private async ValueTask<List<string>> RespawnAsync()
{
await _scene.ClearMonstersAsync();
await _worldSpawner.RespawnAsync(_state.CurrentPos);
await _persistence.SaveAsync();
return new List<string> { "respawned all monsters around you" };
}
private async ValueTask<List<string>> GiveAsync(string[] args)
{
if (args.Length < 1 || !uint.TryParse(args[0], out uint itemId))
return new List<string> { "usage: /give itemId [count]" };
uint count = args.Length > 1 && uint.TryParse(args[1], out uint parsed) ? Math.Max(parsed, 1u) : 1u;
MaterialExcel? material = _excel.GetTable(ExcelType.Material).Items.OfType<MaterialExcel>()
.FirstOrDefault(candidate => candidate.Id == itemId);
if (material is not null && material.GainedAvatarId != 0)
return await GrantAvatarAsync(material.GainedAvatarId, itemId);
bool isWeapon = _excel.GetTable(ExcelType.Weapon).Items.OfType<WeaponExcel>().Any(weapon => weapon.Id == itemId);
StoreItemChangeNotify notify = new() { StoreType = StoreType.StorePack };
if (isWeapon)
{
for (uint i = 0; i < Math.Min(count, 50u); i++)
notify.ItemList.Add(_player.CreateWeapon(itemId).AsItem());
}
else
{
_player.GiveMaterial(itemId, count);
notify.ItemList.Add(new Item
{
ItemId = itemId,
Guid = _player.MaterialGuid(itemId),
Material = new Material { Count = _player.Materials[itemId] }
});
}
await _session.NotifyAsync(CmdType.StoreItemChangeNotify, notify);
ItemAddHintNotify hint = new() { Reason = ActionReasonTrifle };
hint.ItemList.Add(new ItemHint { ItemId = itemId, Count = count, IsNew = false });
await _session.NotifyAsync(CmdType.ItemAddHintNotify, hint);
await _persistence.SaveAsync();
return new List<string> { $"gave {count}x {itemId}" };
}
private async ValueTask<List<string>> GrantAvatarAsync(uint avatarId, uint cardItemId)
{
if (_player.Avatars.Any(avatar => avatar.AvatarId == avatarId))
return new List<string> { $"{cardItemId} is the card for avatar {avatarId}, already owned" };
if (!_player.HasAvatarData(avatarId))
return new List<string> { $"avatar {avatarId} is not in this build" };
GameAvatar? granted = _player.GrantAvatar(avatarId);
if (granted is null)
return new List<string> { $"avatar {avatarId} could not be created" };
await _session.NotifyAsync(CmdType.AvatarAddNotify, new AvatarAddNotify
{
Avatar = granted.AsAvatarInfo(),
IsInTeam = false
});
await _scene.RefreshAvatarDataAsync();
await _persistence.SaveAsync();
List<string> reply = new() { $"granted avatar {avatarId} - add it with: team add {avatarId}" };
if (!_player.HasAbilities(avatarId))
reply.Add("warning: no ability data, it will have no skills and may break the UI - use 'team reset' to recover");
return reply;
}
private async ValueTask<List<string>> HealAsync()
{
await _scene.HealTeamAsync();
return new List<string> { "team healed" };
}
private async ValueTask<List<string>> LevelAsync(string[] args)
{
GameAvatar? avatar = _player.ActiveAvatar;
if (avatar is null || args.Length < 1 || !uint.TryParse(args[0], out uint level))
return new List<string> { "usage: /level n" };
avatar.Level = Math.Clamp(level, 1u, 90u);
await _session.NotifyAsync(CmdType.AvatarPropNotify, new AvatarPropNotify
{
AvatarGuid = avatar.Guid,
PropMap = { { 4001u, avatar.Level } }
});
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"avatar {avatar.AvatarId} level = {avatar.Level}" };
}
private async ValueTask<List<string>> AscendAsync(string[] args)
{
GameAvatar? avatar = _player.ActiveAvatar;
if (avatar is null || args.Length < 1 || !uint.TryParse(args[0], out uint promote))
return new List<string> { "usage: /asc n" };
avatar.PromoteLevel = Math.Clamp(promote, 0u, 6u);
await _session.NotifyAsync(CmdType.AvatarPropNotify, new AvatarPropNotify
{
AvatarGuid = avatar.Guid,
PropMap = { { 1002u, avatar.PromoteLevel } }
});
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"avatar {avatar.AvatarId} ascension = {avatar.PromoteLevel}" };
}
private async ValueTask<List<string>> TalentAsync(string[] args)
{
if (args.Length >= 1 && args[0].Equals("unlock", StringComparison.OrdinalIgnoreCase))
return await ConstellationAsync(new[] { MaxConstellation.ToString() });
GameAvatar? avatar = _player.ActiveAvatar;
if (avatar is null || args.Length < 1 || !uint.TryParse(args[0], out uint talent))
return new List<string> { "usage: /talent n, or talent unlock all" };
avatar.TalentLevel = Math.Clamp(talent, 1u, 15u);
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"avatar {avatar.AvatarId} talents = {avatar.TalentLevel} (reopen character screen to see)" };
}
private async ValueTask<List<string>> ConstellationAsync(string[] args)
{
GameAvatar? avatar = _player.ActiveAvatar;
if (avatar is null || args.Length < 1 || !uint.TryParse(args[0], out uint constellation))
return new List<string> { "usage: /const n" };
avatar.Constellation = Math.Clamp(constellation, 0u, 6u);
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"avatar {avatar.AvatarId} constellation = {avatar.Constellation} (reopen character screen to see)" };
}
private async ValueTask<List<string>> WeaponLevelAsync(string[] args)
{
GameAvatar? avatar = _player.ActiveAvatar;
GameWeapon? weapon = avatar is null ? null : _player.FindWeapon(avatar.WeaponGuid);
if (weapon is null || args.Length < 1 || !uint.TryParse(args[0], out uint level))
return new List<string> { "usage: /wlevel n" };
weapon.Level = Math.Clamp(level, 1u, 90u);
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"weapon {weapon.ItemId} level = {weapon.Level}" };
}
private async ValueTask<List<string>> RefineAsync(string[] args)
{
GameAvatar? avatar = _player.ActiveAvatar;
GameWeapon? weapon = avatar is null ? null : _player.FindWeapon(avatar.WeaponGuid);
if (weapon is null || args.Length < 1 || !uint.TryParse(args[0], out uint refine))
return new List<string> { "usage: /refine n" };
weapon.Refinement = Math.Clamp(refine, 0u, 4u);
await _scene.RefreshTeamAsync();
await _persistence.SaveAsync();
return new List<string> { $"weapon {weapon.ItemId} refinement = {weapon.Refinement + 1}" };
}
private async ValueTask<List<string>> WishAsync(string[] args)
{
uint amount = args.Length > 0 && uint.TryParse(args[0], out uint parsed) ? Math.Clamp(parsed, 1u, 9999u) : 10u;
HashSet<uint> costItems = new();
foreach (GachaBanner banner in _banners.Banners)
{
if (banner.Info.CostItemId != 0)
costItems.Add(banner.Info.CostItemId);
if (banner.Info.TenCostItemId != 0)
costItems.Add(banner.Info.TenCostItemId);
}
if (costItems.Count == 0)
return new List<string> { "no banners are shipped, nothing to grant" };
StoreItemChangeNotify notify = new() { StoreType = StoreType.StorePack };
List<string> results = new();
foreach (uint itemId in costItems.OrderBy(id => id))
{
_player.GiveMaterial(itemId, amount);
uint total = _player.Materials.GetValueOrDefault(itemId);
notify.ItemList.Add(new Item
{
ItemId = itemId,
Guid = _player.MaterialGuid(itemId),
Material = new Material { Count = total }
});
results.Add($"+{amount} x {itemId} (now {total})");
}
await _session.NotifyAsync(CmdType.StoreItemChangeNotify, notify);
await _persistence.SaveAsync();
return new List<string> { "granted wish currency - open the wish page and pull for the real animation" }
.Concat(results).ToList();
}
}