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("materialType")] public string MaterialType { get; set; } = string.Empty;
[JsonPropertyName("gadgetId")] public uint GadgetId { get; set; }
[JsonPropertyName("itemUse")] public List<MaterialItemUse> ItemUse { get; set; } = new();
public override uint ExcelId => Id;

View file

@ -1,7 +1,6 @@
using Yuanshen.Gameserver.Controllers.Attributes;
using Yuanshen.Gameserver.Controllers.Result;
using Yuanshen.Gameserver.Game;
using Yuanshen.Gameserver.Network.Session;
using Yuanshen.Protocol;
using Microsoft.Extensions.Logging;
@ -11,62 +10,27 @@ namespace Yuanshen.Gameserver.Controllers;
internal sealed class AbilityController : ControllerBase
{
[NetCommand(CmdType.AbilityInvocationsNotify)]
public async ValueTask<IResult> OnAbilityInvocationsNotify(NetSession session)
{
AbilityInvocationsNotify notify = Packet!.DecodeBody<AbilityInvocationsNotify>();
await ForwardAsync(session, notify.Invokes);
return Ok();
}
public ValueTask<IResult> OnAbilityInvocationsNotify() => ValueTask.FromResult(Ok());
[NetCommand(CmdType.ClientAbilityChangeNotify)]
public ValueTask<IResult> OnClientAbilityChangeNotify() => ValueTask.FromResult(Ok());
[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>();
foreach (AbilityInvokeEntry entry in notify.Invokes)
{
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);
GameMonster? monster = player.Monsters.FirstOrDefault(candidate => candidate.EntityId == notify.EntityId);
if (monster is not null)
await scene.SyncMonsterLevelAsync(monster);
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)]
public ValueTask<IResult> OnEvtDoSkillSuccNotify(ILogger<AbilityController> logger)
{
logger.LogDebug("DoSkillSucc raw={body}", Convert.ToHexString(Packet!.Body.Span));
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)
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();
@ -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)
{
uint activeEntityId = player.ActiveAvatar?.EntityId ?? 0;
CombatInvocationsNotify forward = new();
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);
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.Result;
using Yuanshen.Gameserver.Database;
@ -20,7 +19,7 @@ internal sealed class GadgetController : ControllerBase
[NetCommand(CmdType.GadgetInteractReq)]
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>();
@ -28,14 +27,32 @@ internal sealed class GadgetController : ControllerBase
if (gadget is null)
return Reply(req, Retcode.RET_GADGET_NOT_EXIST);
if (gadget.IsChest)
return await OpenChestAsync(session, player, scene, drops, excel, persistence, logger, gadget, req);
if (gadget.IsDrop)
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,
DropTable drops, ExcelTableCollection excel, PlayerPersistence persistence, ILogger logger,
DropTable drops, PlayerPersistence persistence, ILogger logger,
GameGadget gadget, GadgetInteractReq req)
{
if (req.OpType == InterOpType.InterOpStart)
@ -53,7 +70,7 @@ internal sealed class GadgetController : ControllerBase
await scene.SetGadgetStateAsync(gadget, ChestOpenedState, false);
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();
@ -64,7 +81,7 @@ internal sealed class GadgetController : ControllerBase
}
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)
{
if (req.OpType != InterOpType.InterOpStart)
@ -87,7 +104,7 @@ internal sealed class GadgetController : ControllerBase
if (rolled.Count == 0 && gadget.Node.GatherItemId != 0)
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 persistence.SaveAsync();
@ -98,7 +115,7 @@ internal sealed class GadgetController : ControllerBase
}
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)
return;

View file

@ -7,6 +7,9 @@ namespace Yuanshen.Gameserver.Controllers;
[NetController]
internal sealed class StubController : ControllerBase
{
private const int PathComplete = 1;
private const int PathFailed = 3;
[NetCommand(CmdType.ClientFpsStatusNotify)]
public ValueTask<IResult> OnClientFpsStatusNotify() => ValueTask.FromResult(Ok());
@ -82,11 +85,50 @@ internal sealed class StubController : ControllerBase
{
QueryPathReq req = Packet!.DecodeBody<QueryPathReq>();
return ValueTask.FromResult(Response(CmdType.QueryPathRsp, new QueryPathRsp
QueryPathRsp rsp = new()
{
Retcode = Retcode.RET_SUCC,
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 Yuanshen.Common.Data.Excel;
using Yuanshen.Gameserver.Database;
using Yuanshen.Gameserver.Network.Session;
using Yuanshen.Protocol;
@ -18,15 +19,19 @@ internal sealed class CombatManager
private readonly Player _player;
private readonly PlayerPersistence _persistence;
private readonly DropTable _drops;
private readonly SceneManager _scene;
private readonly ExcelTableCollection _excel;
private readonly ILogger _logger;
public CombatManager(NetSession session, Player player, PlayerPersistence persistence, DropTable drops,
ILogger<CombatManager> logger)
SceneManager scene, ExcelTableCollection excel, ILogger<CombatManager> logger)
{
_session = session;
_player = player;
_persistence = persistence;
_drops = drops;
_scene = scene;
_excel = excel;
_logger = logger;
}
@ -89,6 +94,46 @@ internal sealed class CombatManager
_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)
{
uint dropId = _drops.DropIdFor(monster.MonsterId, monster.Level);
@ -99,74 +144,27 @@ internal sealed class CombatManager
if (rolled.Count == 0)
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();
bool currencyChanged = false;
int scattered = 0;
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":
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());
await GrantDirectAsync(itemId, count);
}
break;
default:
_player.GiveMaterial(itemId, count);
store.ItemList.Add(new Item
else
{
ItemId = itemId,
Guid = _player.MaterialGuid(itemId),
Material = new Material { Count = _player.Materials.GetValueOrDefault(itemId) }
});
break;
GameGadget drop = _player.SpawnDrop(gadgetId, itemId, count, Scatter(monster.Pos, scattered++));
await _scene.AppearGadgetAsync(drop);
}
hint.ItemList.Add(new ItemHint { ItemId = itemId, Count = count, IsNew = false });
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}",
monster.MonsterId, monster.Level, string.Join(", ", granted));
}

View file

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

View file

@ -10,6 +10,10 @@ internal sealed class GameGadget
public uint GadgetId => Node.GadgetId;
public uint NodeId => Node.NodeId;
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)
{
@ -45,17 +49,25 @@ internal sealed class GameGadget
GroupId = Node.GroupId,
ConfigId = Node.ConfigId,
OwnerEntityId = 0,
BornType = GadgetBornType.GadgetBornNone,
GadgetState = Node.State,
GadgetType = Node.GadgetType,
IsShowCutscene = false,
AuthorityPeerId = Player.PeerId,
IsEnableInteract = true,
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
{
Type = 4001,

View file

@ -175,6 +175,21 @@ internal sealed class Player
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)
{
GameGadget gadget = new(node, 0x04000000 + ++_gadgetEntitySeq);

View file

@ -5,7 +5,7 @@ namespace Yuanshen.Gameserver.Game;
internal sealed class SceneManager
{
private const int LevelSyncAttempts = 3;
private const int LevelSyncAttempts = 6;
private readonly NetSession _session;
private readonly Player _player;
@ -109,6 +109,20 @@ internal sealed class SceneManager
SceneEntityAppearNotify notify = new() { AppearType = VisionType.VisionBorn };
notify.EntityList.Add(monster.AsSceneEntity());
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)

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
{
private const float SpawnRadiusSq = 120f * 120f;
private const float DespawnRadiusSq = 150f * 150f;
private const float GroupLoadSq = 80f * 80f;
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 int ConcurrentCap = 25;
private const int WildlifeCap = 6;
private const int MonsterCap = 60;
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 SceneManager _scene;
private readonly MonsterSpawnTable _table;
private readonly GadgetSpawnTable _gadgets;
private readonly SpawnGroupTable _groups;
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;
_scene = scene;
_table = table;
_gadgets = gadgets;
_groups = groups;
}
public async ValueTask EnterSceneAsync(Vector pos)
{
_player.Monsters.Clear();
_player.Gadgets.Clear();
await _scene.ClearMonstersAsync();
await _scene.ClearGadgetsAsync();
_lastStream = null;
await StreamAsync(pos, force: true);
}
@ -52,42 +61,54 @@ internal sealed class WorldSpawner
_lastStream = pos;
HashSet<uint> active = new();
foreach (GameMonster monster in _player.Monsters)
if (monster.NodeId != 0)
active.Add(monster.NodeId);
await DespawnFarAsync(pos);
await SpawnNearAsync(pos);
await StreamGadgetsAsync(pos);
}
foreach (GameMonster monster in _player.Monsters
.Where(monster => monster.NodeId != 0 && HorizontalSq(pos, monster.Pos) > DespawnRadiusSq)
.ToList())
private async ValueTask DespawnFarAsync(Vector pos)
{
active.Remove(monster.NodeId);
foreach (GameMonster monster in _player.Monsters.Where(monster => monster.NodeId != 0 && IsOutOfRange(pos, monster)).ToList())
await _scene.DespawnMonsterAsync(monster);
}
int wildlife = _player.Monsters.Count(monster => monster.IsWildlife);
foreach (SpawnNode node in _table.Nodes.OrderBy(node => node.IsWildlife))
private bool IsOutOfRange(Vector pos, GameMonster monster)
{
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;
if (active.Contains(node.NodeId) || _player.KilledNodeIds.Contains(node.NodeId))
continue;
if (HorizontalSq(pos, node.Pos) > SpawnRadiusSq)
continue;
if (node.IsWildlife && wildlife >= WildlifeCap)
if (!ShouldLoad(pos, node))
continue;
if (node.IsWildlife)
wildlife++;
GameMonster monster = _player.SpawnMonster(node.MonsterId, node.Pos, node.Level, node.Hp, node.Attack, node.Defense, node.NodeId, node.ConfigId, node.GroupId);
GameMonster monster = _player.SpawnMonster(node.MonsterId, node.Pos, ScaleLevel(node.Level), node.Hp,
node.Attack, node.Defense, node.NodeId, node.ConfigId, node.GroupId);
active.Add(node.NodeId);
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)
@ -97,7 +118,7 @@ internal sealed class WorldSpawner
live.Add(gadget.NodeId);
foreach (GameGadget gadget in _player.Gadgets
.Where(gadget => HorizontalSq(pos, gadget.Node.Pos) > DespawnRadiusSq)
.Where(gadget => HorizontalSq(pos, gadget.Node.Pos) > LooseUnloadSq)
.ToList())
{
live.Remove(gadget.NodeId);
@ -112,7 +133,7 @@ internal sealed class WorldSpawner
continue;
if (node.IsChest && _player.OpenedGadgetIds.Contains(node.NodeId))
continue;
if (HorizontalSq(pos, node.Pos) > SpawnRadiusSq)
if (HorizontalSq(pos, node.Pos) > LooseLoadSq)
continue;
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)
{
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.Sockets;
using Yuanshen.Gameserver.Options;
@ -14,6 +15,8 @@ internal sealed class KcpGateway : IGateway
private readonly IOptions<GatewayOptions> _options;
private readonly NetSessionManager _sessionManager;
private readonly ConcurrentDictionary<string, (uint ConvId, uint Token)> _handshakes = new();
private uint _sessionCounter;
private IKcpTransport<IKcpMultiplexConnection>? _transport;
@ -57,12 +60,29 @@ internal sealed class KcpGateway : IGateway
{
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 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;
KcpConversation conversation = _transport!.Connection.CreateConversation(convId64, clientEndPoint);
_ = _sessionManager.RunSessionAsync(convId64, new KcpNetworkUnit(conversation, clientEndPoint));
_ = RunAndReleaseAsync(convId64, conversation, clientEndPoint, key);
await SendConversationCreatedPacket(clientEndPoint, convId, token);
_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)
{
KcpHandshake handshakeResponse = new()

View file

@ -40,6 +40,7 @@ builder.Services.AddScoped<GmCommandService>();
builder.Services.AddSingleton<MonsterSpawnTable>();
builder.Services.AddSingleton<DropTable>();
builder.Services.AddSingleton<GadgetSpawnTable>();
builder.Services.AddSingleton<SpawnGroupTable>();
builder.Services.AddScoped<WorldSpawner>();
builder.Services.AddScoped<Yuanshen.Gameserver.Database.PlayerPersistence>();
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";
import "Vector.proto";
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";
import "Vector.proto";
message QueryPathRsp {
int32 retcode = 1;
uint32 queryId = 2;
int32 queryId = 2;
int32 queryStatus = 3;
repeated Vector corners = 4;
}

View file

@ -4,12 +4,29 @@ public static class TelemetryHandler
{
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)
{
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");
}
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)
{
string path = $"{request.Path}{request.QueryString}";