DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,698 @@
package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfguild "longheng.io/server/internal/modules/dnf/guild"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnffriend "longheng.io/server/internal/modules/dnf/unitedfriend"
)
const (
currentPeerChatStateOpcode = uint16(17)
currentPeerChatNotifyOpcode = uint16(118)
currentFriendNotifyOpcode = uint16(274)
currentFriendListOpcode = uint16(273)
currentFriendAddOpcode = uint16(290)
currentFriendDeleteOpcode = uint16(291)
currentDstrMaximumLength = 4096
defaultCurrentRegion = byte(1)
currentFriendFailureMissing = byte(0x15)
currentFriendFailureSelf = byte(0x01)
currentFriendFailureFull = byte(0x04)
currentFriendFailureDelete = byte(0x05)
)
var errCurrentSocialBodyInvalid = errors.New("current social command body is invalid")
type currentPeerChatRequest struct {
MessageType byte
TargetID uint16
Unknown uint32
Message []byte
TargetName []byte
}
type currentFriendAddRequest struct {
TargetID uint16
Operation byte
TargetName []byte
}
type currentFriendDeleteRequest struct {
Operation byte
TargetID uint32
TargetName []byte
}
type currentFriendPresence struct {
ChannelID uint16
Busy bool
}
type currentFriendPresenceOverride struct {
Name string
Presence currentFriendPresence
}
func (s *Service) handleOnlineSocialCommand(session *gameSession, typ uint16, body []byte) (bool, error) {
switch typ {
case currentPeerChatStateOpcode:
// Live current-client op17 owns every chat mode (whisper, party,
// area, guild, megaphone and raid) on both legacy and aligned
// transports. Keep one decoder/owner so an aligned whisper and a
// legacy town message cannot diverge.
return true, s.handleCurrentTownChat(session, body)
case uint16(dnfenum.CmdPacketItemHyperlinkMessage):
// op424 is op17 with linked items appended. Same owner, same audience
// rules; only the notification differs.
return true, s.handleCurrentTownChatHyperlink(session, body)
case currentFriendAddOpcode:
return true, s.handleCurrentFriendAdd(session, typ, body)
case currentFriendDeleteOpcode:
return true, s.handleCurrentFriendDelete(session, typ, body)
case uint16(dnfenum.CmdPacketRegisiterToBlacklist), uint16(dnfenum.CmdPacketDeleteToBlacklist):
return true, s.handleCurrentBlacklistMutation(session, typ, body)
default:
return false, nil
}
}
func (s *Service) handleCurrentPeerChat(session *gameSession, typ uint16, body []byte) error {
request, err := decodeCurrentPeerChatRequest(body)
if err != nil {
s.logGameEvent(session, "game-peer-chat-request-rejected", "body_len", len(body), "error", err)
return nil
}
sourceID := selectedCharacterID(session)
if !s.currentSocialPeerAvailable(sourceID, request.TargetID) {
s.logGameEvent(session, "game-peer-chat-request-rejected",
"source_char_id", sourceID,
"target_char_id", request.TargetID,
"message_type", request.MessageType,
"reason", "target_not_online_in_same_area")
return nil
}
targetSession, online := s.onlineGameSession(request.TargetID)
if !online {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
loadedSourceID, sourceName, _, sourceFound := s.characterForEnter(ctx, session, sourceID)
loadedTargetID, targetName, _, targetFound := s.characterForEnter(ctx, targetSession, request.TargetID)
if !sourceFound || loadedSourceID != sourceID || !targetFound || loadedTargetID != request.TargetID {
s.logGameEvent(session, "game-peer-chat-request-rejected",
"source_char_id", sourceID,
"target_char_id", request.TargetID,
"reason", "repository_identity_unavailable")
return nil
}
if len(request.Message) == 0 {
return nil
}
if err := s.sendGameUpperSuccess(session, typ, nil); err != nil {
return err
}
peerState := buildCurrentPeerChatStateBody(request.TargetID, request.MessageType)
if err := s.sendGameUpperRawClassCodec(session, currentPeerChatStateOpcode, peerState, 0, true); err != nil {
return err
}
incomingState := buildCurrentPeerChatStateBody(sourceID, request.MessageType)
if err := s.sendGameUpperRawClassCodec(targetSession, currentPeerChatStateOpcode, incomingState, 0, true); err != nil {
return err
}
notice := buildCurrentPeerChatNoticeBody(
request.MessageType,
byte(targetSession.channel.ID),
sourceID,
rosterNameBytes(sourceName),
request.Message,
)
if err := s.sendGameUpperRawClassCodec(targetSession, currentPeerChatNotifyOpcode, notice, 0, true); err != nil {
return err
}
s.logGameEvent(session, "game-peer-chat-forwarded",
"source_char_id", sourceID,
"target_char_id", request.TargetID,
"target_name", targetName,
"message_type", request.MessageType,
"message_len", len(request.Message),
"body_source", "current_exe_class1_op17_ack_class0_op17_state_class0_op118_message")
return nil
}
func (s *Service) handleCurrentFriendAdd(session *gameSession, typ uint16, body []byte) error {
request, err := decodeCurrentFriendAddRequest(body)
if err != nil {
s.logGameEvent(session, "game-friend-add-request-rejected", "body_len", len(body), "error", err)
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
sourceID := selectedCharacterID(session)
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
_, sourceName, _, found := s.selectedCharacterForEnter(ctx, session)
if !found || sourceName == "" {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
targetName, err := decodeCharacterName(request.TargetName)
if err != nil {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
repositories, ok := s.repositoryGroup()
if !ok {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
owner, err := dnffriend.NewOwner(repositories, nil)
if err != nil {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
s.friendMu.Lock()
friend, err := owner.Add(ctx, sourceName, sourceID, targetName, request.TargetID)
s.friendMu.Unlock()
if err != nil {
code := currentFriendFailureMissing
switch {
case errors.Is(err, dnffriend.ErrSelf):
code = currentFriendFailureSelf
case errors.Is(err, dnffriend.ErrFull):
code = currentFriendFailureFull
}
return s.sendGameUpperFailure(session, typ, code)
}
if err := s.sendGameUpperSuccess(session, typ, buildCurrentFriendSuccessBody()); err != nil {
return err
}
// Current NoPack's op273 parser marks an already-owned scene actor as a
// friend while installing the absolute list. Do not replay mode0/mode1/op9
// here: recreating an actor that is already represented by the party HUD can
// overwrite its live HP/MP presentation, and cross-area creation is the old
// source of the residual white-shadow object.
if err := s.sendCurrentUnitedFriendList(session, sourceName, "friend_add_committed"); err != nil {
return err
}
s.logGameEvent(session, "game-friend-add-response-send",
"source_char_id", sourceID,
"target_char_id", request.TargetID,
"target_name", friend.Character.Name,
"body_source", "current_exe_class1_op290_13b_ack_then_class0_op273_double_refresh")
return nil
}
func (s *Service) handleCurrentFriendDelete(session *gameSession, typ uint16, body []byte) error {
request, err := decodeCurrentFriendDeleteRequest(body)
if err != nil {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
targetName, err := decodeCharacterName(request.TargetName)
if err != nil {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
_, sourceName, _, found := s.selectedCharacterForEnter(ctx, session)
if !found || sourceName == "" {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
repositories, ok := s.repositoryGroup()
if !ok {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
owner, err := dnffriend.NewOwner(repositories, nil)
if err != nil {
return s.sendGameUpperFailure(session, typ, currentFriendFailureMissing)
}
s.friendMu.Lock()
err = owner.Remove(ctx, sourceName, targetName)
s.friendMu.Unlock()
if err != nil {
code := currentFriendFailureMissing
if errors.Is(err, dnffriend.ErrRelationNotFound) {
code = currentFriendFailureDelete
}
return s.sendGameUpperFailure(session, typ, code)
}
if err := s.sendGameUpperSuccess(session, typ, buildCurrentFriendSuccessBody()); err != nil {
return err
}
if err := s.sendGameUpperRawClassCodec(session, currentFriendListOpcode, buildCurrentUnitedFriendDeleteBody(s.currentRegionID(), targetName), 0, true); err != nil {
return err
}
return s.sendCurrentUnitedFriendList(session, sourceName, "friend_delete_committed")
}
func (s *Service) currentRegionID() byte {
if s != nil && s.options.regionID > 0 && s.options.regionID < 250 {
return byte(s.options.regionID)
}
return defaultCurrentRegion
}
func (s *Service) handleCurrentBlacklistMutation(session *gameSession, typ uint16, body []byte) error {
// This is the upper/class-1 transport's entry for 118/119 (an unlisted class-1
// command falls through to the aligned owner). It used to answer by echoing the
// name and storing nothing, so a block made over that transport never reached the
// store and the list came back empty after a relog. Route both transports through
// the ONE storing owner -- the same rule the chat path follows -- and keep the
// historic echo only when there is nothing to persist (no character or no store),
// so the client still receives its ack.
if s.currentBlacklistCanStore(session) {
switch typ {
case uint16(dnfenum.CmdPacketRegisiterToBlacklist):
return s.handleCurrentBlacklistRegister(session, body)
case uint16(dnfenum.CmdPacketDeleteToBlacklist):
return s.handleCurrentBlacklistDelete(session, body)
}
}
name, next, ok := readCurrentRawDstr(body, 0)
if !ok || next != len(body) || len(name) == 0 {
s.logGameEvent(session, "game-blacklist-mutation-rejected",
"type", typ,
"body_len", len(body),
"reason", "exact_name_dstr_required")
return nil
}
var response packetWriter
response.writeRawDstr(name)
s.logGameEvent(session, "game-blacklist-mutation-ack",
"type", typ,
"selected_character_id", selectedCharacterID(session),
"name_len", len(name),
"body_source", "current_exe_class1_callback_reads_echoed_name_dstr")
return s.sendGameUpperSuccess(session, typ, response.bytes())
}
func (s *Service) currentSocialPeerAvailable(sourceID, targetID uint16) bool {
return sourceID != 0 && targetID != 0 && sourceID != targetID &&
s != nil && s.onlinePlayers != nil && s.onlinePlayers.PeerInSameArea(sourceID, targetID)
}
func decodeCurrentPeerChatRequest(body []byte) (currentPeerChatRequest, error) {
if len(body) < 15 {
return currentPeerChatRequest{}, errCurrentSocialBodyInvalid
}
request := currentPeerChatRequest{
MessageType: body[0],
TargetID: binary.LittleEndian.Uint16(body[1:3]),
Unknown: binary.LittleEndian.Uint32(body[3:7]),
}
message, next, ok := readCurrentRawDstr(body, 7)
if !ok {
return currentPeerChatRequest{}, errCurrentSocialBodyInvalid
}
targetName, next, ok := readCurrentRawDstr(body, next)
if !ok || next != len(body) || request.TargetID == 0 || len(targetName) == 0 {
return currentPeerChatRequest{}, errCurrentSocialBodyInvalid
}
request.Message = message
request.TargetName = targetName
return request, nil
}
func decodeCurrentFriendAddRequest(body []byte) (currentFriendAddRequest, error) {
if len(body) < 7 {
return currentFriendAddRequest{}, errCurrentSocialBodyInvalid
}
request := currentFriendAddRequest{TargetID: binary.LittleEndian.Uint16(body[:2]), Operation: body[2]}
name, next, ok := readCurrentRawDstr(body, 3)
if !ok || next != len(body) || request.TargetID == 0 || len(name) == 0 {
return currentFriendAddRequest{}, errCurrentSocialBodyInvalid
}
request.TargetName = name
return request, nil
}
func decodeCurrentFriendDeleteRequest(body []byte) (currentFriendDeleteRequest, error) {
if len(body) < 6 {
return currentFriendDeleteRequest{}, errCurrentSocialBodyInvalid
}
name := bytes.TrimRight(body[5:], "\x00")
if len(name) == 0 {
return currentFriendDeleteRequest{}, errCurrentSocialBodyInvalid
}
return currentFriendDeleteRequest{
Operation: body[0],
TargetID: binary.LittleEndian.Uint32(body[1:5]),
TargetName: append([]byte(nil), name...),
}, nil
}
func readCurrentRawDstr(body []byte, offset int) ([]byte, int, bool) {
if offset < 0 || offset > len(body)-4 {
return nil, offset, false
}
length := int32(binary.LittleEndian.Uint32(body[offset : offset+4]))
if length < 0 || length > currentDstrMaximumLength {
return nil, offset, false
}
start := offset + 4
end := start + int(length)
if end < start || end > len(body) {
return nil, offset, false
}
return append([]byte(nil), body[start:end]...), end, true
}
func buildCurrentPeerChatStateBody(peerID uint16, messageType byte) []byte {
var writer packetWriter
writer.writeUint16(peerID)
writer.writeByte(messageType)
return writer.bytes()
}
func buildCurrentPeerChatNoticeBody(messageType, channelID byte, senderID uint16, senderName, message []byte) []byte {
var writer packetWriter
writer.writeByte(messageType)
writer.writeByte(channelID)
writer.writeByte(0)
writer.writeByte(channelID)
writer.writeUint16(senderID)
writer.writeRawDstr(senderName)
writer.writeRawDstr(message)
return writer.bytes()
}
func buildCurrentFriendPresenceBody(region byte, presence currentFriendPresence, name string) []byte {
var writer packetWriter
writer.writeUint16(presence.ChannelID)
writer.writeByte(region)
writer.writeRawDstr(rosterNameBytes(name))
writer.writeByte(boolByte(presence.Busy))
return writer.bytes()
}
func buildCurrentFriendSuccessBody() []byte {
var writer packetWriter
writer.writeUint32(0x1bc6)
writer.writeUint32(10)
writer.writeUint32(2)
return writer.bytes()
}
func buildCurrentUnitedFriendDeleteBody(region byte, name string) []byte {
var writer packetWriter
writer.writeUint32(2)
writer.writeUint32(1)
writer.writeByte(region)
writer.writeRawDstr(rosterNameBytes(name))
return writer.bytes()
}
func (s *Service) sendCurrentUnitedFriendList(session *gameSession, ownerName, source string) error {
return s.sendCurrentUnitedFriendListWithOverride(session, ownerName, source, nil, nil)
}
func (s *Service) sendCurrentUnitedFriendListResumable(session *gameSession, ownerName, source string, cursor *int) error {
return s.sendCurrentUnitedFriendListWithOverride(session, ownerName, source, cursor, nil)
}
func (s *Service) sendCurrentUnitedFriendListWithOverride(
session *gameSession,
ownerName string,
source string,
cursor *int,
override *currentFriendPresenceOverride,
) error {
repositories, ok := s.repositoryGroup()
if !ok || repositories.UnitedFriend == nil {
return nil
}
owner, err := dnffriend.NewOwner(repositories, nil)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
if strings.TrimSpace(ownerName) == "" {
_, ownerName, _, ok = s.selectedCharacterForEnter(ctx, session)
if !ok {
return nil
}
}
list, err := owner.List(ctx, ownerName)
if err != nil {
return err
}
body := s.buildCurrentUnitedFriendListBody(session, list, override)
next := 0
if cursor != nil {
next = *cursor
}
for next < 2 {
if err := s.sendGameUpperRawClassCodec(session, currentFriendListOpcode, body, 0, true); err != nil {
return err
}
next++
if cursor != nil {
*cursor = next
}
}
s.logGameEvent(session, "game-united-friend-list-send", "source", source, "owner_name", ownerName, "count", len(list), "repeat", 2)
return nil
}
func (s *Service) buildCurrentUnitedFriendListBody(
_ *gameSession,
list []dnffriend.Friend,
override *currentFriendPresenceOverride,
) []byte {
var writer packetWriter
writer.writeUint32(0)
writer.writeUint32(uint32(len(list)))
for _, entry := range list {
character := entry.Character
id := uint16(numericCharacterStat(character.CharacterID))
presence := s.currentUnitedFriendPresence(id)
if override != nil && override.Name == character.Name {
presence = override.Presence
}
writer.writeByte(s.currentRegionID())
writer.writeUint16(presence.ChannelID)
writer.writeRawDstr(rosterNameBytes(character.Name))
writer.writeByte(byte(character.Level))
writer.writeUint32(uint32(numericCharacterStat(character.Job)))
writer.writeByte(byte(character.Stats["grow_type"]))
// The state byte and the legacy A21 row's Y/Z pair are the only
// override-able slots; without the final byte row N consumes row N+1's
// region byte and multi-friend lists desynchronize.
// Current NoPack sub_26E8680 consumes level,u32 job,grow,state,Y,Z,tail.
stateByte, trailingU32, tailByte := currentUnitedFriendRowStatus(presence)
writer.writeByte(stateByte)
writer.writeUint32(trailingU32)
writer.writeByte(tailByte)
}
return writer.bytes()
}
// currentUnitedFriendRowStateByte is the friend row's state byte.
//
// Live evidence (2026-09-11): with the historic boolByte(presence.Busy) value an
// online friend in the player's own area renders as "登录中" (logging in) instead
// of "在线" (online), and the friend window then omits every interactive item
// (邀请组队 and friends) regardless of area. The client's three states are
// 在线 / 登录中 / 不在线, so this slot is a state value rather than a boolean.
//
// A probe with the value 1 was confirmed on the wire (packet_log shows the row's
// state byte becoming 01) yet the client still rendered "登录中", so the status
// text may come from this byte, the trailing u32, or the final byte. All three
// are therefore operator-overridable, without a rebuild:
//
// environment DNF90_FRIEND_ROW_STATE, or
// file friend_row_state.txt, holding 1 to 3 space separated decimals:
// <state byte> [trailing u32] [final byte]
//
// Absent the override the historic row is sent unchanged.
func currentUnitedFriendRowStatus(presence currentFriendPresence) (byte, uint32, byte) {
state := boolByte(presence.Busy)
trailing := uint32(0)
tail := byte(0)
values := currentFriendRowStateOverrideValues()
if len(values) > 0 {
state = values[0]
}
if len(values) > 1 {
trailing = uint32(values[1])
}
if len(values) > 2 {
tail = values[2]
}
return state, trailing, tail
}
func currentFriendRowStateOverrideValues() []byte {
const key = "DNF90_FRIEND_ROW_STATE"
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return nil
}
fields := strings.Fields(raw)
if len(fields) > 3 {
fields = fields[:3]
}
values := make([]byte, 0, len(fields))
for _, field := range fields {
parsed, err := strconv.Atoi(field)
if err != nil || parsed < 0 || parsed > 255 {
return nil
}
values = append(values, byte(parsed))
}
return values
}
func (s *Service) currentUnitedFriendPresence(characterID uint16) currentFriendPresence {
if characterID == 0 {
return currentFriendPresence{}
}
session, online := s.onlineGameSession(characterID)
if !online || session == nil {
return currentFriendPresence{}
}
_, inTown := s.onlinePlayers.PlayerForCharacter(characterID)
return currentFriendPresence{
ChannelID: uint16(session.channel.ID),
Busy: !inTown,
}
}
// notifyCurrentUnitedFriendPresence reports which observers were told, so a
// caller that also announces to another directory can skip the ones that
// already have the line.
func (s *Service) notifyCurrentUnitedFriendPresence(friendName string, presence currentFriendPresence, source string) map[uint16]bool {
notified := map[uint16]bool{}
friendName = strings.TrimSpace(friendName)
if friendName == "" {
return notified
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.UnitedFriend == nil || repositories.Character == nil {
return notified
}
owner, err := dnffriend.NewOwner(repositories, nil)
if err != nil {
return notified
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
observers, err := owner.Observers(ctx, friendName)
if err != nil {
s.logPacketEvent("game-united-friend-presence-observers-failed", "friend_name", friendName, "source", source, "error", err)
return notified
}
for _, observerName := range observers {
observerID, found, err := repositories.Character.FindIDByName(ctx, observerName)
if err != nil || !found {
continue
}
observerSession, online := s.onlineGameSession(uint16(numericCharacterStat(observerID)))
if !online || observerSession == nil {
continue
}
body := buildCurrentFriendPresenceBody(s.currentRegionID(), presence, friendName)
if err := s.sendGameUpperRawClassCodec(observerSession, currentFriendNotifyOpcode, body, 0, true); err != nil {
s.logGameEvent(observerSession, "game-united-friend-presence-send-failed", "friend_name", friendName, "source", source, "error", err)
continue
}
notified[observerSession.selectedCharacterID] = true
// Full list refresh carries changed level/job/grow/channel fields. It
// also lets op273 update the friend bit on an actor the scene already
// owns. Never recreate that actor from a social notification: doing so
// corrupts party HP/MP presentation and can leave a white shadow.
override := &currentFriendPresenceOverride{Name: friendName, Presence: presence}
if err := s.sendCurrentUnitedFriendListWithOverride(
observerSession,
observerName,
source+"_absolute_refresh",
nil,
override,
); err != nil {
s.logGameEvent(observerSession, "game-united-friend-list-refresh-failed", "friend_name", friendName, "source", source, "error", err)
}
}
return notified
}
// notifyCurrentGuildMemberPresence gives guild members the same "entered
// channel N" / "left the game" line the friend list already produces, using the
// same op274 notice. Anyone already told as a friend is skipped so a guild mate
// who is also on the friend list does not read the line twice. Only the notice
// is sent: the friend-list refresh that follows it for real friends would be
// meaningless to a guild mate who has not added this character.
func (s *Service) notifyCurrentGuildMemberPresence(
characterID uint16,
name string,
presence currentFriendPresence,
source string,
alreadyNotified map[uint16]bool,
) {
name = strings.TrimSpace(name)
if s == nil || characterID == 0 || name == "" {
return
}
ctx, cancel := s.currentGuildContext()
defer cancel()
var members []uint16
if err := s.readCurrentGuild(ctx, func(_ dnfrepo.Group, registry dnfguild.Registry) error {
guild, _, ok := registry.GuildForCharacter(strconv.Itoa(int(characterID)))
if !ok || guild == nil {
return nil
}
for id := range guild.Members {
if memberID := currentGuildNumericID(id); memberID != 0 {
members = append(members, uint16(memberID))
}
}
return nil
}); err != nil {
s.logPacketEvent("game-guild-presence-announce-load-failed", "char_id", characterID, "source", source, "error", err)
return
}
body := buildCurrentFriendPresenceBody(s.currentRegionID(), presence, name)
delivered := 0
for _, memberID := range members {
if memberID == characterID || alreadyNotified[memberID] {
continue
}
target, online := s.onlineGameSession(memberID)
if !online || target == nil {
continue
}
if err := s.sendGameUpperRawClassCodec(target, currentFriendNotifyOpcode, body, 0, true); err != nil {
s.logGameEvent(target, "game-guild-presence-announce-failed",
"char_id", characterID, "source", source, "error", err)
continue
}
delivered++
}
if delivered > 0 {
s.logPacketEvent("game-guild-presence-announced",
"char_id", characterID, "name", name, "channel_id", presence.ChannelID,
"recipients", delivered, "source", source)
}
}
// publishCurrentSocialPresence announces one character's "now online" state to
// the two directories the client keeps outside the town scene: the united
// friend list and the guild member window. It is deliberately separate from
// town co-presence, which a private Seria room legitimately suppresses.
func (s *Service) publishCurrentSocialPresence(session *gameSession, characterID uint16, name string, source string) {
if s == nil || session == nil || characterID == 0 {
return
}
presence := currentFriendPresence{ChannelID: uint16(session.channel.ID)}
notified := s.notifyCurrentUnitedFriendPresence(name, presence, source+"_friend_online")
s.notifyCurrentGuildMemberPresence(characterID, name, presence, source+"_guild_online", notified)
s.publishCurrentGuildPresence(characterID, source+"_guild_online")
// 公会积分: the client has no 签到 button and signs in automatically on the
// first login of the day, so the grant belongs here, where a character's
// online state is published. Registry.Attend stays idempotent per local day.
s.grantCurrentGuildAttendance(session, source+"_guild_online_attendance")
}