DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
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 := ¤tFriendPresenceOverride{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")
|
||||
}
|
||||
Reference in new issue
Block a user