diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 0e4ee1c..f2e76d6 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -431,5 +431,45 @@ public sealed class WorldSession : IDisposable StateChanged?.Invoke(next); } - public void Dispose() => _net.Dispose(); + /// + /// Graceful shutdown: tell the server we're leaving so it releases the + /// character lock immediately instead of waiting 60s for the session to + /// time out. Pattern from + /// references/holtburger/crates/holtburger-core/src/client/commands.rs + /// lines 879-892: send CharacterLogOff game message (opcode + /// 0xF653, no payload) then send a bare DISCONNECT control + /// packet (header flag 0x8000, no payload). + /// + public void Dispose() + { + if (CurrentState == State.InWorld) + { + try + { + // Tell ACE "player is leaving the world" so it cleans up + // the character immediately. + var logoff = new Packets.PacketWriter(8); + logoff.WriteUInt32(0xF653u); // CharacterLogOff opcode + SendGameMessage(logoff.ToArray()); + + // Tell the transport layer "close this session." + var disconnectHeader = new PacketHeader + { + Sequence = _clientPacketSequence++, + Flags = PacketHeaderFlags.Disconnect, + Id = _sessionClientId, + }; + byte[] disconnectPacket = PacketCodec.Encode( + disconnectHeader, ReadOnlySpan.Empty, outboundIsaac: null); + _net.Send(disconnectPacket); + } + catch + { + // Best-effort — if the socket is already dead, eat the + // exception and let Dispose finish cleaning up. + } + } + + _net.Dispose(); + } }