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.
279 lines
10 KiB
C#
279 lines
10 KiB
C#
using Yuanshen.Common.Data.Excel;
|
|
using Yuanshen.Gameserver.Controllers.Attributes;
|
|
using Yuanshen.Gameserver.Controllers.Result;
|
|
using Yuanshen.Gameserver.Database;
|
|
using Yuanshen.Gameserver.Game;
|
|
using Yuanshen.Gameserver.Network.Session;
|
|
using Yuanshen.Protocol;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Yuanshen.Gameserver.Controllers;
|
|
|
|
[NetController]
|
|
internal sealed class GachaController : ControllerBase
|
|
{
|
|
private const uint MaxConstellation = 6;
|
|
private const uint StarglitterItemId = 221;
|
|
|
|
private static readonly Random Random = new();
|
|
|
|
[NetCommand(CmdType.GetGachaInfoReq)]
|
|
public ValueTask<IResult> OnGetGachaInfoReq(GachaBannerTable banners)
|
|
{
|
|
GetGachaInfoRsp rsp = new()
|
|
{
|
|
Retcode = Retcode.RET_SUCC,
|
|
GachaRandom = (uint)Random.Next(1, int.MaxValue)
|
|
};
|
|
|
|
foreach (GachaBanner banner in banners.Banners)
|
|
rsp.GachaInfoList.Add(banner.Info.Clone());
|
|
|
|
return ValueTask.FromResult(Response(CmdType.GetGachaInfoRsp, rsp));
|
|
}
|
|
|
|
[NetCommand(CmdType.DoGachaReq)]
|
|
public async ValueTask<IResult> OnDoGachaReq(NetSession session, Player player, SceneManager scene,
|
|
PlayerPersistence persistence, GachaBannerTable banners, ExcelTableCollection excel,
|
|
ILogger<GachaController> logger)
|
|
{
|
|
DoGachaReq req = Packet!.DecodeBody<DoGachaReq>();
|
|
|
|
GachaBanner? banner = banners.Find(req.GachaType, req.GachaScheduleId);
|
|
if (banner is null)
|
|
return Response(CmdType.DoGachaRsp, new DoGachaRsp { Retcode = Retcode.RET_GACHA_SCHEDULE_NOT_MATCH });
|
|
|
|
uint times = req.GachaTimes == 10 ? 10u : 1u;
|
|
uint costItemId = banner.Info.CostItemId;
|
|
uint costItemNum = times == 10 ? banner.Info.TenCostItemNum : banner.Info.CostItemNum;
|
|
|
|
if (player.Materials.GetValueOrDefault(costItemId) < costItemNum)
|
|
{
|
|
logger.LogInformation("Gacha refused: need {num}x {item}", costItemNum, costItemId);
|
|
return Response(CmdType.DoGachaRsp, new DoGachaRsp { Retcode = Retcode.RET_GACHA_COST_ITEM_NOT_ENOUGH });
|
|
}
|
|
|
|
player.TakeMaterial(costItemId, costItemNum);
|
|
|
|
StoreItemChangeNotify store = new() { StoreType = StoreType.StorePack };
|
|
store.ItemList.Add(BuildMaterial(player, costItemId));
|
|
|
|
List<GameAvatar> added = new();
|
|
List<GameAvatar> upgraded = new();
|
|
DoGachaRsp rsp = new()
|
|
{
|
|
Retcode = Retcode.RET_SUCC,
|
|
GachaType = req.GachaType,
|
|
GachaTimes = times,
|
|
GachaScheduleId = req.GachaScheduleId,
|
|
NewGachaRandom = (uint)Random.Next(1, int.MaxValue),
|
|
CostItemId = costItemId,
|
|
CostItemNum = banner.Info.CostItemNum,
|
|
TenCostItemId = banner.Info.TenCostItemId,
|
|
TenCostItemNum = banner.Info.TenCostItemNum,
|
|
LeftGachaTimes = banner.Info.LeftGachaTimes,
|
|
GachaTimesLimit = banner.Info.GachaTimesLimit
|
|
};
|
|
|
|
for (uint pull = 0; pull < times; pull++)
|
|
{
|
|
uint itemId = Draw(banner, player);
|
|
bool isNew = await AwardAsync(player, excel, itemId, store, added, upgraded);
|
|
|
|
(string label, string kind) = Describe(excel, itemId);
|
|
|
|
player.GachaLog.Add(new GachaLogEntry
|
|
{
|
|
GachaType = req.GachaType,
|
|
ItemId = itemId,
|
|
RankLevel = RankOf(banner, itemId),
|
|
Name = label,
|
|
Kind = kind,
|
|
Time = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
|
|
});
|
|
|
|
rsp.GachaItemList.Add(new GachaItem
|
|
{
|
|
GachaItem_ = new ItemParam { ItemId = itemId, Count = 1 },
|
|
IsGachaItemNew = isNew
|
|
});
|
|
}
|
|
|
|
await session.NotifyAsync(CmdType.StoreItemChangeNotify, store);
|
|
|
|
foreach (GameAvatar avatar in added)
|
|
await session.NotifyAsync(CmdType.AvatarAddNotify, new AvatarAddNotify
|
|
{
|
|
Avatar = avatar.AsAvatarInfo(),
|
|
IsInTeam = false
|
|
});
|
|
|
|
if (added.Count != 0 || upgraded.Count != 0)
|
|
await scene.RefreshAvatarDataAsync();
|
|
|
|
if (upgraded.Count != 0)
|
|
await scene.RefreshTeamAsync();
|
|
|
|
await persistence.SaveAsync();
|
|
|
|
logger.LogInformation("Gacha {type} x{times} -> {items}", req.GachaType, times,
|
|
string.Join(", ", rsp.GachaItemList.Select(item => item.GachaItem_.ItemId)));
|
|
|
|
return Response(CmdType.DoGachaRsp, rsp);
|
|
}
|
|
|
|
private static uint Draw(GachaBanner banner, Player player)
|
|
{
|
|
uint gachaType = banner.Info.GachaType;
|
|
uint counter = player.GachaPity.GetValueOrDefault(gachaType) + 1;
|
|
bool forced = banner.PityCount != 0 && counter >= banner.PityCount;
|
|
|
|
GachaBucket bucket = forced
|
|
? banner.Buckets.FirstOrDefault(candidate => banner.PityTypes.Contains(candidate.Type)) ?? PickBucket(banner)
|
|
: PickBucket(banner);
|
|
|
|
player.GachaPity[gachaType] = banner.PityTypes.Contains(bucket.Type) ? 0 : counter;
|
|
|
|
if (!banner.FeaturedPools.TryGetValue(bucket.Type, out IReadOnlyList<uint>? featured) || featured.Count == 0)
|
|
return bucket.ItemIds[Random.Next(bucket.ItemIds.Count)];
|
|
|
|
bool carriesGuarantee = banner.GuaranteeTypes.Contains(bucket.Type);
|
|
bool takeFeatured = (carriesGuarantee && player.GachaGuarantee.GetValueOrDefault(gachaType) != 0)
|
|
|| Random.Next(100) < banner.FeaturedRate;
|
|
|
|
if (carriesGuarantee)
|
|
player.GachaGuarantee[gachaType] = takeFeatured ? 0u : 1u;
|
|
|
|
return takeFeatured
|
|
? featured[Random.Next(featured.Count)]
|
|
: bucket.ItemIds[Random.Next(bucket.ItemIds.Count)];
|
|
}
|
|
|
|
private static (string Label, string Kind) Describe(ExcelTableCollection excel, uint itemId)
|
|
{
|
|
WeaponExcel? weapon = excel.GetTable(ExcelType.Weapon).Items.OfType<WeaponExcel>()
|
|
.FirstOrDefault(candidate => candidate.Id == itemId);
|
|
|
|
if (weapon is not null)
|
|
return (FriendlyWeapon(weapon.WeaponType), "weapon");
|
|
|
|
MaterialExcel? material = excel.GetTable(ExcelType.Material).Items.OfType<MaterialExcel>()
|
|
.FirstOrDefault(candidate => candidate.Id == itemId);
|
|
uint avatarId = material?.GainedAvatarId ?? 0;
|
|
|
|
if (avatarId == 0)
|
|
return ($"Item {itemId}", "item");
|
|
|
|
AvatarExcel? avatar = excel.GetTable(ExcelType.Avatar).Items.OfType<AvatarExcel>()
|
|
.FirstOrDefault(candidate => candidate.Id == avatarId);
|
|
|
|
string icon = avatar?.IconName ?? string.Empty;
|
|
int cut = icon.LastIndexOf('_');
|
|
string name = cut >= 0 && cut + 1 < icon.Length ? icon[(cut + 1)..] : $"Avatar {avatarId}";
|
|
|
|
return (name, "avatar");
|
|
}
|
|
|
|
private static string FriendlyWeapon(string weaponType) => weaponType switch
|
|
{
|
|
"WEAPON_SWORD_ONE_HAND" => "Sword",
|
|
"WEAPON_CLAYMORE" => "Claymore",
|
|
"WEAPON_POLE" => "Polearm",
|
|
"WEAPON_CATALYST" => "Catalyst",
|
|
"WEAPON_BOW" => "Bow",
|
|
_ => "Weapon"
|
|
};
|
|
|
|
private static uint RankOf(GachaBanner banner, uint itemId)
|
|
{
|
|
foreach (GachaBucket bucket in banner.Buckets)
|
|
{
|
|
if (bucket.ItemIds.Contains(itemId))
|
|
return bucket.Type.EndsWith("S5") ? 5u : bucket.Type.EndsWith("S4") ? 4u : 3u;
|
|
}
|
|
|
|
foreach ((string type, IReadOnlyList<uint> pool) in banner.FeaturedPools)
|
|
{
|
|
if (pool.Contains(itemId))
|
|
return type.EndsWith("S5") ? 5u : type.EndsWith("S4") ? 4u : 3u;
|
|
}
|
|
|
|
return 3;
|
|
}
|
|
|
|
private static GachaBucket PickBucket(GachaBanner banner)
|
|
{
|
|
uint roll = (uint)Random.Next(10000);
|
|
uint accumulated = 0;
|
|
|
|
foreach (GachaBucket bucket in banner.Buckets)
|
|
{
|
|
accumulated += bucket.Weight;
|
|
if (roll < accumulated)
|
|
return bucket;
|
|
}
|
|
|
|
return banner.Buckets[^1];
|
|
}
|
|
|
|
private static async ValueTask<bool> AwardAsync(Player player, ExcelTableCollection excel, uint itemId,
|
|
StoreItemChangeNotify store, List<GameAvatar> added, List<GameAvatar> upgraded)
|
|
{
|
|
bool isWeapon = excel.GetTable(ExcelType.Weapon).Items.OfType<WeaponExcel>().Any(weapon => weapon.Id == itemId);
|
|
|
|
if (isWeapon)
|
|
{
|
|
store.ItemList.Add(player.CreateWeapon(itemId).AsItem());
|
|
return await ValueTask.FromResult(true);
|
|
}
|
|
|
|
MaterialExcel? material = excel.GetTable(ExcelType.Material).Items.OfType<MaterialExcel>()
|
|
.FirstOrDefault(candidate => candidate.Id == itemId);
|
|
uint avatarId = material?.GainedAvatarId ?? 0;
|
|
|
|
if (avatarId == 0)
|
|
{
|
|
player.GiveMaterial(itemId, 1);
|
|
store.ItemList.Add(BuildMaterial(player, itemId));
|
|
return await ValueTask.FromResult(false);
|
|
}
|
|
|
|
GameAvatar? owned = player.Avatars.FirstOrDefault(avatar => avatar.AvatarId == avatarId);
|
|
|
|
if (owned is null)
|
|
{
|
|
GameAvatar? granted = player.GrantAvatar(avatarId);
|
|
if (granted is not null)
|
|
{
|
|
added.Add(granted);
|
|
return await ValueTask.FromResult(true);
|
|
}
|
|
|
|
player.GiveMaterial(itemId, 1);
|
|
store.ItemList.Add(BuildMaterial(player, itemId));
|
|
return await ValueTask.FromResult(false);
|
|
}
|
|
|
|
if (owned.Constellation < MaxConstellation)
|
|
{
|
|
owned.Constellation++;
|
|
upgraded.Add(owned);
|
|
return await ValueTask.FromResult(false);
|
|
}
|
|
|
|
AvatarExcel? avatarExcel = excel.GetTable(ExcelType.Avatar).Items.OfType<AvatarExcel>()
|
|
.FirstOrDefault(candidate => candidate.Id == avatarId);
|
|
uint starglitter = avatarExcel?.QualityType == "QUALITY_ORANGE" ? 25u : 5u;
|
|
|
|
player.GiveMaterial(StarglitterItemId, starglitter);
|
|
store.ItemList.Add(BuildMaterial(player, StarglitterItemId));
|
|
return await ValueTask.FromResult(false);
|
|
}
|
|
|
|
private static Item BuildMaterial(Player player, uint itemId) => new()
|
|
{
|
|
ItemId = itemId,
|
|
Guid = player.MaterialGuid(itemId),
|
|
Material = new Material { Count = player.Materials.GetValueOrDefault(itemId) }
|
|
};
|
|
}
|