World drops, group based spawning and a pile of fixes

Drops now land on the ground as gadgets and are picked up with F instead of
appearing in the bag out of nowhere. Chests, ore, crystals and plants all spawn
and can be interacted with.

Monsters load by scene group rather than by a radius around each one, so a camp
shows up as a camp and nothing pops into existence mid fight. The group anchors
come out of the scene Lua, which also killed four invented tuning constants.

The gateway was creating a whole new session for every handshake datagram, and
the client retransmits those until it gets an answer. That is where the piles of
duplicated monsters and the chests coming back from the dead were coming from.

Also: monster level scales to the party now, path queries return an actual two
point path instead of an empty one, NpcTalk closes properly, and the ability and
combat invocations are no longer echoed back to the client that sent them.
This commit is contained in:
wisedev 2026-09-04 17:44:02 +03:00
parent a4b3b669a8
commit 49450270db
21 changed files with 377 additions and 162 deletions

View file

@ -16,6 +16,7 @@ public sealed class MaterialExcel : ExcelItem
[JsonPropertyName("id")] public uint Id { get; set; } [JsonPropertyName("id")] public uint Id { get; set; }
[JsonPropertyName("materialType")] public string MaterialType { get; set; } = string.Empty; [JsonPropertyName("materialType")] public string MaterialType { get; set; } = string.Empty;
[JsonPropertyName("gadgetId")] public uint GadgetId { get; set; }
[JsonPropertyName("itemUse")] public List<MaterialItemUse> ItemUse { get; set; } = new(); [JsonPropertyName("itemUse")] public List<MaterialItemUse> ItemUse { get; set; } = new();
public override uint ExcelId => Id; public override uint ExcelId => Id;

View file

@ -1,7 +1,6 @@
using Yuanshen.Gameserver.Controllers.Attributes; using Yuanshen.Gameserver.Controllers.Attributes;
using Yuanshen.Gameserver.Controllers.Result; using Yuanshen.Gameserver.Controllers.Result;
using Yuanshen.Gameserver.Game; using Yuanshen.Gameserver.Game;
using Yuanshen.Gameserver.Network.Session;
using Yuanshen.Protocol; using Yuanshen.Protocol;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -11,62 +10,27 @@ namespace Yuanshen.Gameserver.Controllers;
internal sealed class AbilityController : ControllerBase internal sealed class AbilityController : ControllerBase
{ {
[NetCommand(CmdType.AbilityInvocationsNotify)] [NetCommand(CmdType.AbilityInvocationsNotify)]
public async ValueTask<IResult> OnAbilityInvocationsNotify(NetSession session) public ValueTask<IResult> OnAbilityInvocationsNotify() => ValueTask.FromResult(Ok());
{
AbilityInvocationsNotify notify = Packet!.DecodeBody<AbilityInvocationsNotify>(); [NetCommand(CmdType.ClientAbilityChangeNotify)]
await ForwardAsync(session, notify.Invokes); public ValueTask<IResult> OnClientAbilityChangeNotify() => ValueTask.FromResult(Ok());
return Ok();
}
[NetCommand(CmdType.ClientAbilityInitFinishNotify)] [NetCommand(CmdType.ClientAbilityInitFinishNotify)]
public async ValueTask<IResult> OnClientAbilityInitFinishNotify(NetSession session, Player player, SceneManager scene, ILogger<AbilityController> logger) public async ValueTask<IResult> OnClientAbilityInitFinishNotify(Player player, SceneManager scene)
{ {
ClientAbilityInitFinishNotify notify = Packet!.DecodeBody<ClientAbilityInitFinishNotify>(); ClientAbilityInitFinishNotify notify = Packet!.DecodeBody<ClientAbilityInitFinishNotify>();
foreach (AbilityInvokeEntry entry in notify.Invokes) GameMonster? monster = player.Monsters.FirstOrDefault(candidate => candidate.EntityId == notify.EntityId);
{
logger.LogDebug("AbilityInit entity={e} arg={arg} fwd={fwd} instAbility={ia} instModifier={im} localId={l} target={t}",
notify.EntityId, entry.ArgumentType, entry.ForwardType,
entry.Head?.InstancedAbilityId, entry.Head?.InstancedModifierId, entry.Head?.LocalId, entry.Head?.TargetId);
}
await ForwardAsync(session, notify.Invokes);
GameMonster? monster = player.Monsters.FirstOrDefault(m => m.EntityId == notify.EntityId);
if (monster is not null) if (monster is not null)
await scene.SyncMonsterLevelAsync(monster); await scene.SyncMonsterLevelAsync(monster);
return Ok(); return Ok();
} }
[NetCommand(CmdType.ClientAbilityChangeNotify)]
public async ValueTask<IResult> OnClientAbilityChangeNotify(NetSession session)
{
ClientAbilityChangeNotify notify = Packet!.DecodeBody<ClientAbilityChangeNotify>();
await ForwardAsync(session, notify.Invokes);
return Ok();
}
[NetCommand(CmdType.EvtDoSkillSuccNotify)] [NetCommand(CmdType.EvtDoSkillSuccNotify)]
public ValueTask<IResult> OnEvtDoSkillSuccNotify(ILogger<AbilityController> logger) public ValueTask<IResult> OnEvtDoSkillSuccNotify(ILogger<AbilityController> logger)
{ {
logger.LogDebug("DoSkillSucc raw={body}", Convert.ToHexString(Packet!.Body.Span)); logger.LogDebug("DoSkillSucc raw={body}", Convert.ToHexString(Packet!.Body.Span));
return ValueTask.FromResult(Ok()); return ValueTask.FromResult(Ok());
} }
internal static async ValueTask ForwardAsync(NetSession session, IEnumerable<AbilityInvokeEntry> invokes)
{
AbilityInvocationsNotify forward = new();
foreach (AbilityInvokeEntry entry in invokes)
{
if (entry.ForwardType is ForwardType.ForwardLocal)
continue;
forward.Invokes.Add(entry);
}
if (forward.Invokes.Count != 0)
await session.NotifyAsync(CmdType.AbilityInvocationsNotify, forward);
}
} }

View file

@ -20,8 +20,6 @@ internal sealed class CombatController : ControllerBase
{ {
if (cmd.MessageId == (uint)CmdType.CombatInvocationsNotify) if (cmd.MessageId == (uint)CmdType.CombatInvocationsNotify)
await ProcessCombatInvocationsAsync(CombatInvocationsNotify.Parser.ParseFrom(cmd.Body), state, player, combat, worldSpawner, logger, session); await ProcessCombatInvocationsAsync(CombatInvocationsNotify.Parser.ParseFrom(cmd.Body), state, player, combat, worldSpawner, logger, session);
else if (cmd.MessageId == (uint)CmdType.AbilityInvocationsNotify)
await AbilityController.ForwardAsync(session, AbilityInvocationsNotify.Parser.ParseFrom(cmd.Body).Invokes);
} }
return Ok(); return Ok();
@ -61,7 +59,6 @@ internal sealed class CombatController : ControllerBase
private static async ValueTask ProcessCombatInvocationsAsync(CombatInvocationsNotify notify, GameState state, Player player, CombatManager combat, WorldSpawner worldSpawner, ILogger logger, NetSession session) private static async ValueTask ProcessCombatInvocationsAsync(CombatInvocationsNotify notify, GameState state, Player player, CombatManager combat, WorldSpawner worldSpawner, ILogger logger, NetSession session)
{ {
uint activeEntityId = player.ActiveAvatar?.EntityId ?? 0; uint activeEntityId = player.ActiveAvatar?.EntityId ?? 0;
CombatInvocationsNotify forward = new();
foreach (CombatInvokeEntry entry in notify.InvokeList) foreach (CombatInvokeEntry entry in notify.InvokeList)
{ {
@ -88,12 +85,6 @@ internal sealed class CombatController : ControllerBase
logger.LogDebug("combat arg={arg} fwd={f}", entry.ArgumentType, entry.ForwardType); logger.LogDebug("combat arg={arg} fwd={f}", entry.ArgumentType, entry.ForwardType);
break; break;
} }
}
if (entry.ForwardType is ForwardType.ForwardToAll or ForwardType.ForwardToHost or ForwardType.ForwardToPeer)
forward.InvokeList.Add(entry);
}
if (forward.InvokeList.Count != 0)
await session.NotifyAsync(CmdType.CombatInvocationsNotify, forward);
} }
} }

View file

@ -1,4 +1,3 @@
using Yuanshen.Common.Data.Excel;
using Yuanshen.Gameserver.Controllers.Attributes; using Yuanshen.Gameserver.Controllers.Attributes;
using Yuanshen.Gameserver.Controllers.Result; using Yuanshen.Gameserver.Controllers.Result;
using Yuanshen.Gameserver.Database; using Yuanshen.Gameserver.Database;
@ -20,7 +19,7 @@ internal sealed class GadgetController : ControllerBase
[NetCommand(CmdType.GadgetInteractReq)] [NetCommand(CmdType.GadgetInteractReq)]
public async ValueTask<IResult> OnGadgetInteractReq(NetSession session, Player player, SceneManager scene, public async ValueTask<IResult> OnGadgetInteractReq(NetSession session, Player player, SceneManager scene,
DropTable drops, ExcelTableCollection excel, PlayerPersistence persistence, ILogger<GadgetController> logger) DropTable drops, PlayerPersistence persistence, ILogger<GadgetController> logger)
{ {
GadgetInteractReq req = Packet!.DecodeBody<GadgetInteractReq>(); GadgetInteractReq req = Packet!.DecodeBody<GadgetInteractReq>();
@ -28,14 +27,32 @@ internal sealed class GadgetController : ControllerBase
if (gadget is null) if (gadget is null)
return Reply(req, Retcode.RET_GADGET_NOT_EXIST); return Reply(req, Retcode.RET_GADGET_NOT_EXIST);
if (gadget.IsChest) if (gadget.IsDrop)
return await OpenChestAsync(session, player, scene, drops, excel, persistence, logger, gadget, req); return await PickUpAsync(session, player, scene, drops, persistence, logger, gadget, req);
return await GatherAsync(session, player, scene, drops, excel, persistence, logger, gadget, req); if (gadget.IsChest)
return await OpenChestAsync(session, player, scene, drops, persistence, logger, gadget, req);
return await GatherAsync(session, player, scene, drops, persistence, logger, gadget, req);
}
private async ValueTask<IResult> PickUpAsync(NetSession session, Player player, SceneManager scene,
DropTable drops, PlayerPersistence persistence, ILogger logger, GameGadget gadget, GadgetInteractReq req)
{
await session.NotifyAsync(CmdType.GadgetInteractRsp, Build(req, Retcode.RET_SUCC, InteractType.InteractPickItem));
await scene.DespawnGadgetAsync(gadget, VisionType.VisionMiss);
Dictionary<uint, uint> picked = new() { [gadget.DropItemId] = gadget.DropCount };
await GrantAsync(session, player, drops, gadget, picked, (uint)ActionReasonType.ActionReasonTrifle);
await persistence.SaveAsync();
logger.LogInformation("Picked up {count}x {itemId}", gadget.DropCount, gadget.DropItemId);
return Ok();
} }
private async ValueTask<IResult> OpenChestAsync(NetSession session, Player player, SceneManager scene, private async ValueTask<IResult> OpenChestAsync(NetSession session, Player player, SceneManager scene,
DropTable drops, ExcelTableCollection excel, PlayerPersistence persistence, ILogger logger, DropTable drops, PlayerPersistence persistence, ILogger logger,
GameGadget gadget, GadgetInteractReq req) GameGadget gadget, GadgetInteractReq req)
{ {
if (req.OpType == InterOpType.InterOpStart) if (req.OpType == InterOpType.InterOpStart)
@ -53,7 +70,7 @@ internal sealed class GadgetController : ControllerBase
await scene.SetGadgetStateAsync(gadget, ChestOpenedState, false); await scene.SetGadgetStateAsync(gadget, ChestOpenedState, false);
Dictionary<uint, uint> rolled = drops.Roll(gadget.Node.DropId, Random); Dictionary<uint, uint> rolled = drops.Roll(gadget.Node.DropId, Random);
await GrantAsync(session, player, drops, excel, gadget, rolled, (uint)ActionReasonType.ActionReasonOpenChest); await GrantAsync(session, player, drops, gadget, rolled, (uint)ActionReasonType.ActionReasonOpenChest);
await persistence.SaveAsync(); await persistence.SaveAsync();
@ -64,7 +81,7 @@ internal sealed class GadgetController : ControllerBase
} }
private async ValueTask<IResult> GatherAsync(NetSession session, Player player, SceneManager scene, private async ValueTask<IResult> GatherAsync(NetSession session, Player player, SceneManager scene,
DropTable drops, ExcelTableCollection excel, PlayerPersistence persistence, ILogger logger, DropTable drops, PlayerPersistence persistence, ILogger logger,
GameGadget gadget, GadgetInteractReq req) GameGadget gadget, GadgetInteractReq req)
{ {
if (req.OpType != InterOpType.InterOpStart) if (req.OpType != InterOpType.InterOpStart)
@ -87,7 +104,7 @@ internal sealed class GadgetController : ControllerBase
if (rolled.Count == 0 && gadget.Node.GatherItemId != 0) if (rolled.Count == 0 && gadget.Node.GatherItemId != 0)
rolled[gadget.Node.GatherItemId] = 1; rolled[gadget.Node.GatherItemId] = 1;
await GrantAsync(session, player, drops, excel, gadget, rolled, (uint)ActionReasonType.ActionReasonGather); await GrantAsync(session, player, drops, gadget, rolled, (uint)ActionReasonType.ActionReasonGather);
await scene.DespawnGadgetAsync(gadget, VisionType.VisionGatherEscape); await scene.DespawnGadgetAsync(gadget, VisionType.VisionGatherEscape);
await persistence.SaveAsync(); await persistence.SaveAsync();
@ -98,7 +115,7 @@ internal sealed class GadgetController : ControllerBase
} }
private static async ValueTask GrantAsync(NetSession session, Player player, DropTable drops, private static async ValueTask GrantAsync(NetSession session, Player player, DropTable drops,
ExcelTableCollection excel, GameGadget gadget, Dictionary<uint, uint> rolled, uint reason) GameGadget gadget, Dictionary<uint, uint> rolled, uint reason)
{ {
if (rolled.Count == 0) if (rolled.Count == 0)
return; return;

View file

@ -7,6 +7,9 @@ namespace Yuanshen.Gameserver.Controllers;
[NetController] [NetController]
internal sealed class StubController : ControllerBase internal sealed class StubController : ControllerBase
{ {
private const int PathComplete = 1;
private const int PathFailed = 3;
[NetCommand(CmdType.ClientFpsStatusNotify)] [NetCommand(CmdType.ClientFpsStatusNotify)]
public ValueTask<IResult> OnClientFpsStatusNotify() => ValueTask.FromResult(Ok()); public ValueTask<IResult> OnClientFpsStatusNotify() => ValueTask.FromResult(Ok());
@ -82,11 +85,50 @@ internal sealed class StubController : ControllerBase
{ {
QueryPathReq req = Packet!.DecodeBody<QueryPathReq>(); QueryPathReq req = Packet!.DecodeBody<QueryPathReq>();
return ValueTask.FromResult(Response(CmdType.QueryPathRsp, new QueryPathRsp QueryPathRsp rsp = new()
{ {
Retcode = Retcode.RET_SUCC, Retcode = Retcode.RET_SUCC,
QueryId = req.QueryId, QueryId = req.QueryId,
QueryStatus = 1 QueryStatus = PathFailed
};
Vector? destination = req.DestinationPos.LastOrDefault();
if (req.SourcePos is not null && destination is not null)
{
rsp.QueryStatus = PathComplete;
rsp.Corners.Add(req.SourcePos);
rsp.Corners.Add(destination);
}
return ValueTask.FromResult(Response(CmdType.QueryPathRsp, rsp));
}
[NetCommand(CmdType.NpcTalkReq)]
public ValueTask<IResult> OnNpcTalkReq()
{
NpcTalkReq req = Packet!.DecodeBody<NpcTalkReq>();
return ValueTask.FromResult(Response(CmdType.NpcTalkRsp, new NpcTalkRsp
{
Retcode = Retcode.RET_SUCC,
NpcEntityId = req.NpcEntityId,
CurTalkId = req.TalkId
})); }));
} }
[NetCommand(CmdType.ClientLockGameTimeNotify)]
public ValueTask<IResult> OnClientLockGameTimeNotify() => ValueTask.FromResult(Ok());
[NetCommand(CmdType.EnterTransPointRegionNotify)]
public ValueTask<IResult> OnEnterTransPointRegionNotify() => ValueTask.FromResult(Ok());
[NetCommand(CmdType.ExitTransPointRegionNotify)]
public ValueTask<IResult> OnExitTransPointRegionNotify() => ValueTask.FromResult(Ok());
[NetCommand(CmdType.EntityAiSyncNotify)]
public ValueTask<IResult> OnEntityAiSyncNotify() => ValueTask.FromResult(Ok());
[NetCommand(CmdType.ObstacleModifyNotify)]
public ValueTask<IResult> OnObstacleModifyNotify() => ValueTask.FromResult(Ok());
} }

View file

@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Yuanshen.Common.Data.Excel;
using Yuanshen.Gameserver.Database; using Yuanshen.Gameserver.Database;
using Yuanshen.Gameserver.Network.Session; using Yuanshen.Gameserver.Network.Session;
using Yuanshen.Protocol; using Yuanshen.Protocol;
@ -18,15 +19,19 @@ internal sealed class CombatManager
private readonly Player _player; private readonly Player _player;
private readonly PlayerPersistence _persistence; private readonly PlayerPersistence _persistence;
private readonly DropTable _drops; private readonly DropTable _drops;
private readonly SceneManager _scene;
private readonly ExcelTableCollection _excel;
private readonly ILogger _logger; private readonly ILogger _logger;
public CombatManager(NetSession session, Player player, PlayerPersistence persistence, DropTable drops, public CombatManager(NetSession session, Player player, PlayerPersistence persistence, DropTable drops,
ILogger<CombatManager> logger) SceneManager scene, ExcelTableCollection excel, ILogger<CombatManager> logger)
{ {
_session = session; _session = session;
_player = player; _player = player;
_persistence = persistence; _persistence = persistence;
_drops = drops; _drops = drops;
_scene = scene;
_excel = excel;
_logger = logger; _logger = logger;
} }
@ -89,6 +94,46 @@ internal sealed class CombatManager
_player.Monsters.Remove(monster); _player.Monsters.Remove(monster);
} }
private static Vector Scatter(Vector origin, int index)
{
double angle = index * 2.39996;
double radius = 0.6 + index * 0.35;
return new Vector
{
X = origin.X + (float)(Math.Cos(angle) * radius),
Y = origin.Y + 0.4f,
Z = origin.Z + (float)(Math.Sin(angle) * radius)
};
}
private async ValueTask GrantDirectAsync(uint itemId, uint count)
{
if (itemId == MoraItemId)
{
_player.Scoin += count;
}
else if (itemId == PrimogemItemId)
{
_player.Hcoin += count;
}
else
{
_player.GiveMaterial(itemId, count);
StoreItemChangeNotify store = new() { StoreType = StoreType.StorePack };
store.ItemList.Add(new Item
{
ItemId = itemId,
Guid = _player.MaterialGuid(itemId),
Material = new Material { Count = _player.Materials.GetValueOrDefault(itemId) }
});
await _session.NotifyAsync(CmdType.StoreItemChangeNotify, store);
return;
}
await _session.NotifyAsync(CmdType.PlayerPropNotify, _player.BuildCurrencyProps());
}
private async ValueTask GrantDropsAsync(GameMonster monster) private async ValueTask GrantDropsAsync(GameMonster monster)
{ {
uint dropId = _drops.DropIdFor(monster.MonsterId, monster.Level); uint dropId = _drops.DropIdFor(monster.MonsterId, monster.Level);
@ -99,74 +144,27 @@ internal sealed class CombatManager
if (rolled.Count == 0) if (rolled.Count == 0)
return; return;
StoreItemChangeNotify store = new() { StoreType = StoreType.StorePack };
ItemAddHintNotify hint = new()
{
Reason = monster.IsWildlife ? (uint)ActionReasonType.ActionReasonMonsterEnvAnimal : (uint)ActionReasonType.ActionReasonMonsterDie,
Position = monster.Pos,
IsPositionValid = true
};
List<string> granted = new(); List<string> granted = new();
bool currencyChanged = false; int scattered = 0;
foreach ((uint itemId, uint count) in rolled) foreach ((uint itemId, uint count) in rolled)
{ {
switch (_drops.TypeOf(itemId)) uint gadgetId = _excel.GetTable(ExcelType.Material).Items.OfType<MaterialExcel>()
.FirstOrDefault(candidate => candidate.Id == itemId)?.GadgetId ?? 0;
if (gadgetId == 0)
{ {
case "weapon": await GrantDirectAsync(itemId, count);
for (uint made = 0; made < Math.Min(count, 10u); made++)
store.ItemList.Add(_player.CreateWeapon(itemId).AsItem());
break;
case "virtual" when itemId == MoraItemId:
_player.Scoin += count;
currencyChanged = true;
break;
case "virtual" when itemId == PrimogemItemId:
_player.Hcoin += count;
currencyChanged = true;
break;
case "virtual":
continue;
case "reliquary":
for (uint made = 0; made < Math.Min(count, 5u); made++)
{
GameReliquary? reliquary = _player.CreateReliquary(itemId, Random);
if (reliquary is not null)
store.ItemList.Add(reliquary.AsItem());
} }
break; else
default:
_player.GiveMaterial(itemId, count);
store.ItemList.Add(new Item
{ {
ItemId = itemId, GameGadget drop = _player.SpawnDrop(gadgetId, itemId, count, Scatter(monster.Pos, scattered++));
Guid = _player.MaterialGuid(itemId), await _scene.AppearGadgetAsync(drop);
Material = new Material { Count = _player.Materials.GetValueOrDefault(itemId) }
});
break;
} }
hint.ItemList.Add(new ItemHint { ItemId = itemId, Count = count, IsNew = false });
granted.Add($"{itemId}x{count}"); granted.Add($"{itemId}x{count}");
} }
if (hint.ItemList.Count == 0)
return;
if (store.ItemList.Count != 0)
await _session.NotifyAsync(CmdType.StoreItemChangeNotify, store);
if (currencyChanged)
await _session.NotifyAsync(CmdType.PlayerPropNotify, _player.BuildCurrencyProps());
await _session.NotifyAsync(CmdType.ItemAddHintNotify, hint);
_logger.LogInformation("Monster {monsterId} lvl {level} dropped {items}", _logger.LogInformation("Monster {monsterId} lvl {level} dropped {items}",
monster.MonsterId, monster.Level, string.Join(", ", granted)); monster.MonsterId, monster.Level, string.Join(", ", granted));
} }

View file

@ -20,6 +20,8 @@ internal readonly record struct GadgetNode(
uint MarkFlag) uint MarkFlag)
{ {
public bool IsChest => Kind == "chest"; public bool IsChest => Kind == "chest";
public bool IsDrop => Kind == "drop";
} }
internal sealed class GadgetSpawnTable internal sealed class GadgetSpawnTable

View file

@ -10,6 +10,10 @@ internal sealed class GameGadget
public uint GadgetId => Node.GadgetId; public uint GadgetId => Node.GadgetId;
public uint NodeId => Node.NodeId; public uint NodeId => Node.NodeId;
public bool IsChest => Node.IsChest; public bool IsChest => Node.IsChest;
public bool IsDrop => Node.IsDrop;
public uint DropItemId { get; init; }
public uint DropCount { get; init; }
public ulong DropGuid { get; init; }
public GameGadget(GadgetNode node, uint entityId) public GameGadget(GadgetNode node, uint entityId)
{ {
@ -45,17 +49,25 @@ internal sealed class GameGadget
GroupId = Node.GroupId, GroupId = Node.GroupId,
ConfigId = Node.ConfigId, ConfigId = Node.ConfigId,
OwnerEntityId = 0, OwnerEntityId = 0,
BornType = GadgetBornType.GadgetBornNone,
GadgetState = Node.State, GadgetState = Node.State,
GadgetType = Node.GadgetType, GadgetType = Node.GadgetType,
IsShowCutscene = false, IsShowCutscene = false,
AuthorityPeerId = Player.PeerId, AuthorityPeerId = Player.PeerId,
IsEnableInteract = true, IsEnableInteract = true,
InteractId = 0, InteractId = 0,
MarkFlag = Node.MarkFlag MarkFlag = Node.MarkFlag,
BornType = GadgetBornType.GadgetBornNone
} }
}; };
if (IsDrop)
entity.Gadget.TrifleItem = new Item
{
ItemId = DropItemId,
Guid = DropGuid,
Material = new Material { Count = DropCount }
};
entity.PropList.Add(new PropPair entity.PropList.Add(new PropPair
{ {
Type = 4001, Type = 4001,

View file

@ -175,6 +175,21 @@ internal sealed class Player
return monster; return monster;
} }
public GameGadget SpawnDrop(uint gadgetId, uint itemId, uint count, Yuanshen.Protocol.Vector pos)
{
GadgetNode node = new(0, 0, 0, gadgetId, 1, pos, new Yuanshen.Protocol.Vector(), "drop", 0, 0, 0, 0, 0, 0);
GameGadget drop = new(node, 0x04000000 + ++_gadgetEntitySeq)
{
DropItemId = itemId,
DropCount = count,
DropGuid = NextGuid()
};
Gadgets.Add(drop);
return drop;
}
public GameGadget SpawnGadget(GadgetNode node) public GameGadget SpawnGadget(GadgetNode node)
{ {
GameGadget gadget = new(node, 0x04000000 + ++_gadgetEntitySeq); GameGadget gadget = new(node, 0x04000000 + ++_gadgetEntitySeq);

View file

@ -5,7 +5,7 @@ namespace Yuanshen.Gameserver.Game;
internal sealed class SceneManager internal sealed class SceneManager
{ {
private const int LevelSyncAttempts = 3; private const int LevelSyncAttempts = 6;
private readonly NetSession _session; private readonly NetSession _session;
private readonly Player _player; private readonly Player _player;
@ -109,6 +109,20 @@ internal sealed class SceneManager
SceneEntityAppearNotify notify = new() { AppearType = VisionType.VisionBorn }; SceneEntityAppearNotify notify = new() { AppearType = VisionType.VisionBorn };
notify.EntityList.Add(monster.AsSceneEntity()); notify.EntityList.Add(monster.AsSceneEntity());
await _session.NotifyAsync(CmdType.SceneEntityAppearNotify, notify); await _session.NotifyAsync(CmdType.SceneEntityAppearNotify, notify);
await SyncMonsterLevelAsync(monster);
}
public async ValueTask ClearGadgetsAsync()
{
if (_player.Gadgets.Count == 0)
return;
SceneEntityDisappearNotify notify = new() { DisappearType = VisionType.VisionRemove };
foreach (GameGadget gadget in _player.Gadgets)
notify.EntityList.Add(gadget.EntityId);
_player.Gadgets.Clear();
await _session.NotifyAsync(CmdType.SceneEntityDisappearNotify, notify);
} }
public async ValueTask AppearGadgetAsync(GameGadget gadget) public async ValueTask AppearGadgetAsync(GameGadget gadget)

View file

@ -0,0 +1,42 @@
using System.Text.Json;
namespace Yuanshen.Gameserver.Game;
internal readonly record struct SpawnGroup(uint GroupId, float X, float Z, float Extent)
{
public bool IsCompact => Extent <= CompactExtent;
private const float CompactExtent = 60f;
}
internal sealed class SpawnGroupTable
{
private readonly Dictionary<uint, SpawnGroup> _groups = new();
public SpawnGroupTable()
{
Load("scene3_groups.json");
}
public bool TryGet(uint groupId, out SpawnGroup group) => _groups.TryGetValue(groupId, out group);
private void Load(string fileName)
{
string path = Path.Combine(AppContext.BaseDirectory, "assets", "scene", fileName);
if (!File.Exists(path))
return;
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(path));
foreach (JsonElement row in document.RootElement.EnumerateArray())
{
uint groupId = row.GetProperty("g").GetUInt32();
_groups[groupId] = new SpawnGroup(
groupId,
(float)row.GetProperty("x").GetDouble(),
(float)row.GetProperty("z").GetDouble(),
(float)row.GetProperty("e").GetDouble());
}
}
}

View file

@ -4,32 +4,41 @@ namespace Yuanshen.Gameserver.Game;
internal sealed class WorldSpawner internal sealed class WorldSpawner
{ {
private const float SpawnRadiusSq = 120f * 120f; private const float GroupLoadSq = 80f * 80f;
private const float DespawnRadiusSq = 150f * 150f; private const float GroupUnloadSq = 110f * 110f;
private const float LooseLoadSq = 80f * 80f;
private const float WildlifeLoadSq = 50f * 50f;
private const float LooseUnloadSq = 110f * 110f;
private const float StreamStepSq = 20f * 20f; private const float StreamStepSq = 20f * 20f;
private const int ConcurrentCap = 25; private const int MonsterCap = 60;
private const int WildlifeCap = 6;
private const int GadgetCap = 40; private const int GadgetCap = 40;
private const int LevelBand = 2;
private const int LevelSpread = 6;
private const int TopNodeLevel = 36;
private const int MaxMonsterLevel = 90;
private readonly Player _player; private readonly Player _player;
private readonly SceneManager _scene; private readonly SceneManager _scene;
private readonly MonsterSpawnTable _table; private readonly MonsterSpawnTable _table;
private readonly GadgetSpawnTable _gadgets; private readonly GadgetSpawnTable _gadgets;
private readonly SpawnGroupTable _groups;
private Vector? _lastStream; private Vector? _lastStream;
public WorldSpawner(Player player, SceneManager scene, MonsterSpawnTable table, GadgetSpawnTable gadgets) public WorldSpawner(Player player, SceneManager scene, MonsterSpawnTable table, GadgetSpawnTable gadgets,
SpawnGroupTable groups)
{ {
_player = player; _player = player;
_scene = scene; _scene = scene;
_table = table; _table = table;
_gadgets = gadgets; _gadgets = gadgets;
_groups = groups;
} }
public async ValueTask EnterSceneAsync(Vector pos) public async ValueTask EnterSceneAsync(Vector pos)
{ {
_player.Monsters.Clear(); await _scene.ClearMonstersAsync();
_player.Gadgets.Clear(); await _scene.ClearGadgetsAsync();
_lastStream = null; _lastStream = null;
await StreamAsync(pos, force: true); await StreamAsync(pos, force: true);
} }
@ -52,42 +61,54 @@ internal sealed class WorldSpawner
_lastStream = pos; _lastStream = pos;
HashSet<uint> active = new(); await DespawnFarAsync(pos);
foreach (GameMonster monster in _player.Monsters) await SpawnNearAsync(pos);
if (monster.NodeId != 0) await StreamGadgetsAsync(pos);
active.Add(monster.NodeId); }
foreach (GameMonster monster in _player.Monsters private async ValueTask DespawnFarAsync(Vector pos)
.Where(monster => monster.NodeId != 0 && HorizontalSq(pos, monster.Pos) > DespawnRadiusSq)
.ToList())
{ {
active.Remove(monster.NodeId); foreach (GameMonster monster in _player.Monsters.Where(monster => monster.NodeId != 0 && IsOutOfRange(pos, monster)).ToList())
await _scene.DespawnMonsterAsync(monster); await _scene.DespawnMonsterAsync(monster);
} }
int wildlife = _player.Monsters.Count(monster => monster.IsWildlife); private bool IsOutOfRange(Vector pos, GameMonster monster)
foreach (SpawnNode node in _table.Nodes.OrderBy(node => node.IsWildlife))
{ {
if (active.Count >= ConcurrentCap) if (_groups.TryGet(monster.GroupId, out SpawnGroup group) && group.IsCompact)
return AnchorSq(pos, group) > GroupUnloadSq;
return HorizontalSq(pos, monster.Pos) > LooseUnloadSq;
}
private async ValueTask SpawnNearAsync(Vector pos)
{
HashSet<uint> active = new();
foreach (GameMonster monster in _player.Monsters)
active.Add(monster.NodeId);
foreach (SpawnNode node in _table.Nodes)
{
if (active.Count >= MonsterCap)
break; break;
if (active.Contains(node.NodeId) || _player.KilledNodeIds.Contains(node.NodeId)) if (active.Contains(node.NodeId) || _player.KilledNodeIds.Contains(node.NodeId))
continue; continue;
if (HorizontalSq(pos, node.Pos) > SpawnRadiusSq) if (!ShouldLoad(pos, node))
continue;
if (node.IsWildlife && wildlife >= WildlifeCap)
continue; continue;
if (node.IsWildlife) GameMonster monster = _player.SpawnMonster(node.MonsterId, node.Pos, ScaleLevel(node.Level), node.Hp,
wildlife++; node.Attack, node.Defense, node.NodeId, node.ConfigId, node.GroupId);
GameMonster monster = _player.SpawnMonster(node.MonsterId, node.Pos, node.Level, node.Hp, node.Attack, node.Defense, node.NodeId, node.ConfigId, node.GroupId);
active.Add(node.NodeId); active.Add(node.NodeId);
await _scene.AppearMonsterAsync(monster); await _scene.AppearMonsterAsync(monster);
} }
}
await StreamGadgetsAsync(pos); private bool ShouldLoad(Vector pos, SpawnNode node)
{
if (_groups.TryGet(node.GroupId, out SpawnGroup group) && group.IsCompact)
return AnchorSq(pos, group) <= GroupLoadSq;
return HorizontalSq(pos, node.Pos) <= (node.IsWildlife ? WildlifeLoadSq : LooseLoadSq);
} }
private async ValueTask StreamGadgetsAsync(Vector pos) private async ValueTask StreamGadgetsAsync(Vector pos)
@ -97,7 +118,7 @@ internal sealed class WorldSpawner
live.Add(gadget.NodeId); live.Add(gadget.NodeId);
foreach (GameGadget gadget in _player.Gadgets foreach (GameGadget gadget in _player.Gadgets
.Where(gadget => HorizontalSq(pos, gadget.Node.Pos) > DespawnRadiusSq) .Where(gadget => HorizontalSq(pos, gadget.Node.Pos) > LooseUnloadSq)
.ToList()) .ToList())
{ {
live.Remove(gadget.NodeId); live.Remove(gadget.NodeId);
@ -112,7 +133,7 @@ internal sealed class WorldSpawner
continue; continue;
if (node.IsChest && _player.OpenedGadgetIds.Contains(node.NodeId)) if (node.IsChest && _player.OpenedGadgetIds.Contains(node.NodeId))
continue; continue;
if (HorizontalSq(pos, node.Pos) > SpawnRadiusSq) if (HorizontalSq(pos, node.Pos) > LooseLoadSq)
continue; continue;
GameGadget gadget = _player.SpawnGadget(node); GameGadget gadget = _player.SpawnGadget(node);
@ -121,6 +142,25 @@ internal sealed class WorldSpawner
} }
} }
private uint ScaleLevel(uint nodeLevel)
{
int reference = (int)(_player.ActiveAvatar?.Level ?? nodeLevel);
if (reference <= (int)nodeLevel)
return nodeLevel;
int top = reference - LevelBand;
int drop = (TopNodeLevel - Math.Min((int)nodeLevel, TopNodeLevel)) * LevelSpread / TopNodeLevel;
return (uint)Math.Clamp(top - drop, 1, MaxMonsterLevel);
}
private static float AnchorSq(Vector pos, SpawnGroup group)
{
float dx = pos.X - group.X;
float dz = pos.Z - group.Z;
return dx * dx + dz * dz;
}
private static float HorizontalSq(Vector a, Vector b) private static float HorizontalSq(Vector a, Vector b)
{ {
float dx = a.X - b.X; float dx = a.X - b.X;

View file

@ -1,4 +1,5 @@
using System.Buffers; using System.Buffers;
using System.Collections.Concurrent;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using Yuanshen.Gameserver.Options; using Yuanshen.Gameserver.Options;
@ -14,6 +15,8 @@ internal sealed class KcpGateway : IGateway
private readonly IOptions<GatewayOptions> _options; private readonly IOptions<GatewayOptions> _options;
private readonly NetSessionManager _sessionManager; private readonly NetSessionManager _sessionManager;
private readonly ConcurrentDictionary<string, (uint ConvId, uint Token)> _handshakes = new();
private uint _sessionCounter; private uint _sessionCounter;
private IKcpTransport<IKcpMultiplexConnection>? _transport; private IKcpTransport<IKcpMultiplexConnection>? _transport;
@ -57,12 +60,29 @@ internal sealed class KcpGateway : IGateway
{ {
try try
{ {
string key = clientEndPoint.ToString();
if (_handshakes.TryGetValue(key, out (uint ConvId, uint Token) existing))
{
await SendConversationCreatedPacket(clientEndPoint, existing.ConvId, existing.Token);
_logger.LogDebug("StartConversation retransmit from {endPoint}, reusing conv={conv}",
clientEndPoint, (long)existing.ConvId << 32 | existing.Token);
return;
}
uint convId = Interlocked.Increment(ref _sessionCounter); uint convId = Interlocked.Increment(ref _sessionCounter);
uint token = (uint)_random.Next(); uint token = (uint)_random.Next();
if (!_handshakes.TryAdd(key, (convId, token)))
{
(uint ConvId, uint Token) winner = _handshakes[key];
await SendConversationCreatedPacket(clientEndPoint, winner.ConvId, winner.Token);
return;
}
long convId64 = (long)convId << 32 | token; long convId64 = (long)convId << 32 | token;
KcpConversation conversation = _transport!.Connection.CreateConversation(convId64, clientEndPoint); KcpConversation conversation = _transport!.Connection.CreateConversation(convId64, clientEndPoint);
_ = _sessionManager.RunSessionAsync(convId64, new KcpNetworkUnit(conversation, clientEndPoint)); _ = RunAndReleaseAsync(convId64, conversation, clientEndPoint, key);
await SendConversationCreatedPacket(clientEndPoint, convId, token); await SendConversationCreatedPacket(clientEndPoint, convId, token);
_logger.LogInformation("StartConversation accepted from {endPoint}, conv={conv}", clientEndPoint, convId64); _logger.LogInformation("StartConversation accepted from {endPoint}, conv={conv}", clientEndPoint, convId64);
@ -73,6 +93,18 @@ internal sealed class KcpGateway : IGateway
} }
} }
private async Task RunAndReleaseAsync(long convId64, KcpConversation conversation, IPEndPoint clientEndPoint, string key)
{
try
{
await _sessionManager.RunSessionAsync(convId64, new KcpNetworkUnit(conversation, clientEndPoint));
}
finally
{
_handshakes.TryRemove(key, out _);
}
}
private async ValueTask SendConversationCreatedPacket(IPEndPoint clientEndPoint, uint convId, uint token) private async ValueTask SendConversationCreatedPacket(IPEndPoint clientEndPoint, uint convId, uint token)
{ {
KcpHandshake handshakeResponse = new() KcpHandshake handshakeResponse = new()

View file

@ -40,6 +40,7 @@ builder.Services.AddScoped<GmCommandService>();
builder.Services.AddSingleton<MonsterSpawnTable>(); builder.Services.AddSingleton<MonsterSpawnTable>();
builder.Services.AddSingleton<DropTable>(); builder.Services.AddSingleton<DropTable>();
builder.Services.AddSingleton<GadgetSpawnTable>(); builder.Services.AddSingleton<GadgetSpawnTable>();
builder.Services.AddSingleton<SpawnGroupTable>();
builder.Services.AddScoped<WorldSpawner>(); builder.Services.AddScoped<WorldSpawner>();
builder.Services.AddScoped<Yuanshen.Gameserver.Database.PlayerPersistence>(); builder.Services.AddScoped<Yuanshen.Gameserver.Database.PlayerPersistence>();
builder.Services.AddSingleton<Yuanshen.Gameserver.Database.PlayerSessionRegistry>(); builder.Services.AddSingleton<Yuanshen.Gameserver.Database.PlayerSessionRegistry>();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,8 @@
syntax = "proto3";
option csharp_namespace = "Yuanshen.Protocol";
message NpcTalkReq {
uint32 npcEntityId = 1;
uint32 talkId = 3;
}

View file

@ -0,0 +1,9 @@
syntax = "proto3";
option csharp_namespace = "Yuanshen.Protocol";
message NpcTalkRsp {
int32 retcode = 1;
uint32 npcEntityId = 2;
uint32 curTalkId = 4;
}

View file

@ -2,6 +2,12 @@ syntax = "proto3";
option csharp_namespace = "Yuanshen.Protocol"; option csharp_namespace = "Yuanshen.Protocol";
import "Vector.proto";
message QueryPathReq { message QueryPathReq {
uint32 queryId = 1; int32 queryType = 1;
int32 queryId = 2;
uint32 sceneId = 3;
Vector sourcePos = 4;
repeated Vector destinationPos = 5;
} }

View file

@ -2,8 +2,11 @@ syntax = "proto3";
option csharp_namespace = "Yuanshen.Protocol"; option csharp_namespace = "Yuanshen.Protocol";
import "Vector.proto";
message QueryPathRsp { message QueryPathRsp {
int32 retcode = 1; int32 retcode = 1;
uint32 queryId = 2; int32 queryId = 2;
int32 queryStatus = 3; int32 queryStatus = 3;
repeated Vector corners = 4;
} }

View file

@ -4,12 +4,29 @@ public static class TelemetryHandler
{ {
private const int PreviewLimit = 1200; private const int PreviewLimit = 1200;
private static readonly object FileLock = new();
private static readonly string LogPath = Path.Combine(AppContext.BaseDirectory, "client-telemetry.log");
public static async Task<IResult> OnLog(HttpRequest request) public static async Task<IResult> OnLog(HttpRequest request)
{ {
Log.Info($"telemetry {request.Path} {await BodyAsync(request)}"); string body = await BodyAsync(request);
Append(request.Path, body);
Log.Info($"telemetry {request.Path} {body}");
return TypedResults.Text("{\"code\":0}", "application/json"); return TypedResults.Text("{\"code\":0}", "application/json");
} }
private static void Append(string path, string body)
{
try
{
lock (FileLock)
File.AppendAllText(LogPath, $"{DateTime.Now:HH:mm:ss} {path} {body}{Environment.NewLine}");
}
catch (IOException)
{
}
}
public static async Task<IResult> OnUnhandled(HttpRequest request) public static async Task<IResult> OnUnhandled(HttpRequest request)
{ {
string path = $"{request.Path}{request.QueryString}"; string path = $"{request.Path}{request.QueryString}";