服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
453 lines
20 KiB
Go
453 lines
20 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"strconv"
|
|
|
|
)
|
|
|
|
// This file is the semantic half of the 86 reference's 1:1 chat
|
|
// (86A21代码 …/Network/Handlers/ChatHandler.cs and ChatHandler.Conversations.cs),
|
|
// rebuilt on the current client's wire. Everything the reference does with
|
|
// packets is done here as well; only the packets themselves differ, and each
|
|
// difference is called out where it bites:
|
|
//
|
|
// - The reference opens a window with a CREATE_GROUP notification that carries a
|
|
// server-allocated conversation id ("status=0 | u32 id | u8 2 | dstr 自己名 |
|
|
// dstr 对方名"), addresses every message by that id, and closes the window
|
|
// with LEAVE_FROM_GROUP / LEAVE_USER_FROM_GROUP. The 90CN client has no such
|
|
// field in any of its chat packets (spec §2.2 / §2.8): its request is
|
|
// `u8 mode | u16 peer | u32 0 | u32 len | msg | u32 nameLen | name | u8`, and
|
|
// its line packet op65 is `u8 mode | u8 status | dstr name | u8 flag |
|
|
// dstr text | dstr extra@0x24`. So the conversation id never travels: the
|
|
// conversation entity below *is* the routing table, the same role the
|
|
// reference gives its `_activeConversations` dictionary.
|
|
// - Live capture 2026-09-22 20:16:02 (`packet_log.txt`): the client whose window
|
|
// was opened by an incoming line sends `u16 peer = 0` and an empty name,
|
|
// while the client that picked its peer from the friend menu sends
|
|
// `u16 peer = 22` and the peer's name. Routing therefore has to fall back to
|
|
// the conversation this server remembers for that session — exactly the
|
|
// reason the reference keeps the mapping on the server.
|
|
// - The reference does not echo the line back to the sender ("The sender's
|
|
// window already displays its own message"). This server used to echo it with
|
|
// the *peer's* name, which is why the sender's own line came back describing a
|
|
// conversation with the peer.
|
|
//
|
|
// What follows the reference one for one: the unordered pair key, the
|
|
// server-allocated id with its own collision skipping, one window per pair,
|
|
// "publish to a member at most once", closing on either side's leave or session
|
|
// end, "a closed conversation's traffic is inert", and the guards (self, offline,
|
|
// 256-byte text, membership).
|
|
|
|
// currentChatConversationMaxTextBytes is the reference's MaximumMessageBytes. The
|
|
// client's own op65 reader caps the text dstr at 0x100 for the same reason, so a
|
|
// longer line would be truncated on arrival.
|
|
const currentChatConversationMaxTextBytes = 256
|
|
|
|
// Body byte 4 of op65 is the selector the client registers the packet's name under
|
|
// (sub_22E85A0(flag, &name)) and the only argument that differs between the
|
|
// renderer's two exits. Live evidence says it is also what decides *which window*
|
|
// the line lands in: sending one copy with 1 split a single conversation into two
|
|
// windows ("the lines that arrived" and "the lines I sent", screenshots
|
|
// 2026-09-22 20:5x), so this server keeps every line of a conversation on 0.
|
|
const currentChatIncomingLineFlag byte = 0
|
|
|
|
// currentChatTargetFromConversation marks a peer that came from this server's own
|
|
// record rather than from the request (see resolveCurrentOneToOneChatTarget).
|
|
const currentChatTargetFromConversation = "conversation_member"
|
|
|
|
// currentChatConversationMember is one side of a window. `published` is the
|
|
// reference's Published flag: a member has been sent a line in this conversation,
|
|
// which is what makes an otherwise targetless request ("peer id 0, empty name")
|
|
// trustworthy enough to route.
|
|
type currentChatConversationMember struct {
|
|
session *gameSession
|
|
characterID uint16
|
|
name string
|
|
published bool
|
|
}
|
|
|
|
// currentChatConversation is the reference's Conversation: one window between two
|
|
// characters, keyed by the unordered pair so both sides address the same entity.
|
|
type currentChatConversation struct {
|
|
id uint32
|
|
key uint64
|
|
first *currentChatConversationMember
|
|
second *currentChatConversationMember
|
|
}
|
|
|
|
func currentChatConversationKey(first, second uint16) uint64 {
|
|
low, high := first, second
|
|
if low > high {
|
|
low, high = high, low
|
|
}
|
|
return uint64(low)<<16 | uint64(high)
|
|
}
|
|
|
|
func (c *currentChatConversation) member(session *gameSession) *currentChatConversationMember {
|
|
if c == nil || session == nil {
|
|
return nil
|
|
}
|
|
if c.first.session == session {
|
|
return c.first
|
|
}
|
|
if c.second.session == session {
|
|
return c.second
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *currentChatConversation) other(member *currentChatConversationMember) *currentChatConversationMember {
|
|
if c == nil || member == nil {
|
|
return nil
|
|
}
|
|
if member == c.first {
|
|
return c.second
|
|
}
|
|
if member == c.second {
|
|
return c.first
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// openCurrentChatConversation returns the window for this pair, creating it when
|
|
// there is none. Reuse is deliberate: the reference's self-test pins
|
|
// "concurrent duplicate opens do not create duplicate native windows", and a
|
|
// second message in the same window must not disturb the first.
|
|
//
|
|
// This client has one window at a time, so both members' previous conversations
|
|
// (with anybody else) are dropped and returned for the caller to report.
|
|
func (s *Service) openCurrentChatConversation(actor, peer *gameSession) (*currentChatConversation, bool, []*currentChatConversation) {
|
|
if s == nil || actor == nil || peer == nil {
|
|
return nil, false, nil
|
|
}
|
|
actorID := actor.selectedCharacterID
|
|
peerID := peer.selectedCharacterID
|
|
if actorID == 0 || peerID == 0 || actorID == peerID {
|
|
return nil, false, nil
|
|
}
|
|
key := currentChatConversationKey(actorID, peerID)
|
|
s.chatMu.Lock()
|
|
defer s.chatMu.Unlock()
|
|
if s.chatConversations == nil {
|
|
s.chatConversations = make(map[uint64]*currentChatConversation)
|
|
}
|
|
if s.chatConversationByMember == nil {
|
|
s.chatConversationByMember = make(map[uint16]*currentChatConversation)
|
|
}
|
|
if existing, ok := s.chatConversations[key]; ok {
|
|
if existing.member(actor) != nil && existing.member(peer) != nil {
|
|
return existing, false, nil
|
|
}
|
|
s.removeCurrentChatConversationLocked(existing)
|
|
}
|
|
var dropped []*currentChatConversation
|
|
for _, characterID := range []uint16{actorID, peerID} {
|
|
previous, ok := s.chatConversationByMember[characterID]
|
|
if !ok || previous == nil {
|
|
continue
|
|
}
|
|
s.removeCurrentChatConversationLocked(previous)
|
|
dropped = append(dropped, previous)
|
|
}
|
|
conversation := ¤tChatConversation{
|
|
id: s.allocateCurrentChatConversationIDLocked(),
|
|
key: key,
|
|
first: ¤tChatConversationMember{session: actor, characterID: actorID},
|
|
second: ¤tChatConversationMember{session: peer, characterID: peerID},
|
|
}
|
|
s.chatConversations[key] = conversation
|
|
s.chatConversationByMember[actorID] = conversation
|
|
s.chatConversationByMember[peerID] = conversation
|
|
return conversation, true, dropped
|
|
}
|
|
|
|
// currentChatConversationForSession is this server's answer to "who is in this
|
|
// client's chat window" — the mapping the reference keeps because its own client
|
|
// request cannot carry one either (§2.8).
|
|
func (s *Service) currentChatConversationForSession(session *gameSession) (*currentChatConversation, *currentChatConversationMember, bool) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return nil, nil, false
|
|
}
|
|
s.chatMu.Lock()
|
|
defer s.chatMu.Unlock()
|
|
conversation, ok := s.chatConversationByMember[session.selectedCharacterID]
|
|
if !ok || conversation == nil {
|
|
return nil, nil, false
|
|
}
|
|
member := conversation.member(session)
|
|
if member == nil {
|
|
return nil, nil, false
|
|
}
|
|
return conversation, member, true
|
|
}
|
|
|
|
// markCurrentChatConversationPublished marks both members as reached: the reference
|
|
// publishes a member before any line arrives, and a member that was never published
|
|
// is what makes an otherwise targetless reply untrustworthy.
|
|
func (s *Service) markCurrentChatConversationPublished(conversation *currentChatConversation) {
|
|
if s == nil || conversation == nil {
|
|
return
|
|
}
|
|
s.chatMu.Lock()
|
|
defer s.chatMu.Unlock()
|
|
if live, ok := s.chatConversations[conversation.key]; !ok || live != conversation {
|
|
return
|
|
}
|
|
for _, member := range []*currentChatConversationMember{conversation.first, conversation.second} {
|
|
if member != nil {
|
|
member.published = true
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// closeCurrentChatConversationsForSession drops every window this session owns.
|
|
// The reference registers this on SessionEnding so a disconnect cannot leave the
|
|
// other side talking into a dead conversation.
|
|
func (s *Service) closeCurrentChatConversationsForSession(session *gameSession) []*currentChatConversation {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return nil
|
|
}
|
|
s.chatMu.Lock()
|
|
defer s.chatMu.Unlock()
|
|
conversation, ok := s.chatConversationByMember[session.selectedCharacterID]
|
|
if !ok || conversation == nil || conversation.member(session) == nil {
|
|
return nil
|
|
}
|
|
s.removeCurrentChatConversationLocked(conversation)
|
|
return []*currentChatConversation{conversation}
|
|
}
|
|
|
|
func (s *Service) removeCurrentChatConversationLocked(conversation *currentChatConversation) {
|
|
if s == nil || conversation == nil {
|
|
return
|
|
}
|
|
if live, ok := s.chatConversations[conversation.key]; ok && live == conversation {
|
|
delete(s.chatConversations, conversation.key)
|
|
}
|
|
for _, member := range []*currentChatConversationMember{conversation.first, conversation.second} {
|
|
if member == nil {
|
|
continue
|
|
}
|
|
if live, ok := s.chatConversationByMember[member.characterID]; ok && live == conversation {
|
|
delete(s.chatConversationByMember, member.characterID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// allocateCurrentChatConversationIDLocked mirrors the reference's allocator: ids
|
|
// start at 1 and never repeat within the live set, 0 and MaxUint32 stay unused so
|
|
// neither can be mistaken for "no conversation" on the wire.
|
|
func (s *Service) allocateCurrentChatConversationIDLocked() uint32 {
|
|
if s.chatConversationSeq == 0 {
|
|
s.chatConversationSeq = 1
|
|
}
|
|
for s.chatConversationSeq == 0 || s.chatConversationSeq == math.MaxUint32 ||
|
|
s.currentChatConversationIDInUseLocked(s.chatConversationSeq) {
|
|
s.chatConversationSeq++
|
|
}
|
|
id := s.chatConversationSeq
|
|
s.chatConversationSeq++
|
|
return id
|
|
}
|
|
|
|
func (s *Service) currentChatConversationIDInUseLocked(id uint32) bool {
|
|
for _, conversation := range s.chatConversations {
|
|
if conversation != nil && conversation.id == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// sendCurrentChatConversationLine writes one conversation line to a reader. This
|
|
// is the port's only wire-specific piece: the reference's MESSAGE_GROUP_CHAT
|
|
// carries `u32 id | dstr sender | dstr text` and ours carries the op65 shape from
|
|
// spec §2.2, with the *speaker's* name — the reference routes "by membership and
|
|
// server sender name, not supplied name or UID".
|
|
func (s *Service) sendCurrentChatConversationLine(reader *gameSession, mode, flag byte, speakerName, peerName string, text []byte) error {
|
|
if reader == nil {
|
|
return nil
|
|
}
|
|
body := buildCurrentOneToOneChatStateBody(mode, flag, rosterNameBytes(speakerName), rosterNameBytes(peerName), text)
|
|
return s.sendGameUpperRawClassCodec(reader, currentTownChatOneToOneStateMsgID, body, 0, true)
|
|
}
|
|
|
|
// resolveCurrentOneToOneChatTarget answers "who is this line for" in the same
|
|
// order the reference does, minus the id it cannot receive here:
|
|
//
|
|
// 1. the peer id the request carries, when the player picked the peer;
|
|
// 2. the peer name it carries, for a client that only knows the name;
|
|
// 3. the conversation this server already remembers for that session — the case
|
|
// a client whose window was opened by an incoming line falls into.
|
|
func (s *Service) resolveCurrentOneToOneChatTarget(
|
|
session *gameSession,
|
|
request currentTownChatRequest,
|
|
) (uint16, *gameSession, string, bool) {
|
|
if request.TargetUniqueID != 0 && request.TargetUniqueID != currentWhisperTargetUnknownID {
|
|
if target, online := s.onlineGameSession(request.TargetUniqueID); online && target != nil && target != session {
|
|
return request.TargetUniqueID, target, "request_peer_id", true
|
|
}
|
|
return 0, nil, "", false
|
|
}
|
|
if len(request.TargetName) > 0 {
|
|
if targetID, target, found := s.currentChatSessionByName(session, request.TargetName); found {
|
|
return targetID, target, "request_peer_name", true
|
|
}
|
|
}
|
|
conversation, member, ok := s.currentChatConversationForSession(session)
|
|
if !ok || !member.published {
|
|
return 0, nil, "", false
|
|
}
|
|
peer := conversation.other(member)
|
|
if peer == nil || peer.characterID == 0 {
|
|
return 0, nil, "", false
|
|
}
|
|
if target, online := s.onlineGameSession(peer.characterID); online && target != nil && target != session {
|
|
return peer.characterID, target, currentChatTargetFromConversation, true
|
|
}
|
|
return 0, nil, "", false
|
|
}
|
|
|
|
// handleCurrentOneToOneChatMessage owns the two 1:1 window modes — 0x21 (the
|
|
// one-to-one window) and 0x24 (the peer menu). Every other private mode keeps the
|
|
// historical op17/op118 path byte for byte; see handleCurrentWhisperChat.
|
|
func (s *Service) handleCurrentOneToOneChatMessage(session *gameSession, request currentTownChatRequest) error {
|
|
sourceID := session.selectedCharacterID
|
|
if sourceID == 0 {
|
|
return nil
|
|
}
|
|
if len(request.Message) == 0 || len(request.Message) > currentChatConversationMaxTextBytes {
|
|
return s.rejectCurrentOneToOneChatRequest(session, request, sourceID, 0, "message_length_out_of_range")
|
|
}
|
|
targetID, targetSession, resolvedBy, resolved := s.resolveCurrentOneToOneChatTarget(session, request)
|
|
if !resolved || targetID == sourceID || targetSession == nil || targetSession == session {
|
|
return s.rejectCurrentOneToOneChatRequest(session, request, sourceID, targetID, "target_unresolved_or_offline")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
loadedSourceID, sourceName, _, sourceFound := s.characterForEnter(ctx, session, sourceID)
|
|
loadedTargetID, targetName, _, targetFound := s.characterForEnter(ctx, targetSession, targetID)
|
|
if !sourceFound || loadedSourceID != sourceID || !targetFound || loadedTargetID != targetID {
|
|
return s.rejectCurrentOneToOneChatRequest(session, request, sourceID, targetID, "repository_identity_mismatch")
|
|
}
|
|
// A peer that came from this server's own record was never named by the
|
|
// request, so there is nothing to compare; a peer the request *did* name is
|
|
// checked, because that name is the only signal that the window the player
|
|
// typed into is the target we resolved.
|
|
if resolvedBy != currentChatTargetFromConversation &&
|
|
!currentChatNameMatches(request.TargetName, rosterNameBytes(targetName)) {
|
|
return s.rejectCurrentOneToOneChatRequest(session, request, sourceID, targetID, "target_name_mismatch")
|
|
}
|
|
// Blacklist gate, both directions like the 86 reference's IsBlocked checks. The
|
|
// client filters blocked lines silently on its own side, which is why the sender
|
|
// used to see nothing at all; this is the missing feedback.
|
|
blockedByTarget := s.currentBlacklistHas(ctx, targetID, sourceName)
|
|
blockedBySource := s.currentBlacklistHas(ctx, sourceID, targetName)
|
|
if blockedByTarget || blockedBySource {
|
|
// Rolled back 2026-09-22 23:4x: reporting the peer's block first (to reach dstr
|
|
// 4763 through the whisper table) is not what the operator wants, so the
|
|
// original precedence -- the sender's own block wins -- is restored.
|
|
status := currentBlacklistStatusTargetBlockedSender
|
|
if blockedBySource {
|
|
status = currentBlacklistStatusSenderBlockedTarget
|
|
}
|
|
s.logGameEvent(session, "game-peer-chat-blocked",
|
|
"source_char_id", sourceID, "target_char_id", targetID,
|
|
"blocked_by_target", blockedByTarget, "blocked_by_source", blockedBySource,
|
|
"status", status)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", "blacklist", map[string]any{
|
|
"target_character_id": strconv.Itoa(int(targetID)),
|
|
"target_name": targetName,
|
|
"status": strconv.Itoa(int(status)),
|
|
})
|
|
return s.sendCurrentChatRejectionNotice(session, request.Mode, status, targetName, request.Message)
|
|
}
|
|
if err := s.sendGameUpperSuccess(session, currentPeerChatStateOpcode, nil); err != nil {
|
|
return err
|
|
}
|
|
conversation, created, dropped := s.openCurrentChatConversation(session, targetSession)
|
|
if conversation == nil {
|
|
return s.rejectCurrentOneToOneChatRequest(session, request, sourceID, targetID, "conversation_unavailable")
|
|
}
|
|
for _, previous := range dropped {
|
|
s.logGameEvent(session, "game-peer-chat-conversation-replaced",
|
|
"conversation_id", previous.id,
|
|
"source_char_id", sourceID,
|
|
"reason", "one_chat_window_per_client")
|
|
}
|
|
// The window binding (class-1 op330) is deliberately not sent. Live test
|
|
// 2026-09-22 21:0x: it opened an *extra empty* window on the receiving side and
|
|
// never merged with the window the op65 lines use, while the lines themselves
|
|
// already create and reuse one window per side. The packet and what it does are
|
|
// recorded in spec 9.6 so it can come back once the client's window key is
|
|
// understood.
|
|
s.markCurrentChatConversationPublished(conversation)
|
|
// ② Two copies of the same line, one per reader, with the same flag: this
|
|
// client opens a *separate window per flag value*, which is what the
|
|
// 2026-09-22 20:5x screenshots showed -- four lines of one conversation split
|
|
// into "the lines that arrived" and "the lines I sent", while the two copies
|
|
// already carried the same name. Every line of a conversation therefore travels
|
|
// with flag 0, and the name it carries is the other party, which is what the
|
|
// window is titled with and what the client's name registry is filled from.
|
|
//
|
|
// The sender's copy has to be sent at all because this client does not draw what
|
|
// the player typed (measured: the sender's window stays empty without it).
|
|
if err := s.sendCurrentChatConversationLine(targetSession, request.Mode, currentChatIncomingLineFlag, sourceName, sourceName, request.Message); err != nil {
|
|
return err
|
|
}
|
|
|
|
// The sender's own copy names the *speaker* (the player), so the line reads as
|
|
// theirs. Naming the peer here was the other half of the live report: "A 发消息
|
|
// 显示的是 B 的名字,B 发消息显示的是 A 的名字".
|
|
if err := s.sendCurrentChatConversationLine(session, request.Mode, currentChatIncomingLineFlag, sourceName, targetName, request.Message); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-peer-chat-forwarded", "source_char_id", sourceID, "target_char_id", targetID, "target_name", targetName, "message_len", len(request.Message), "scope", "online_cross_channel", "notification", currentTownChatOneToOneStateMsgID, "item_links", request.ItemCount)
|
|
s.logGameEvent(session, "game-peer-chat-conversation-advanced",
|
|
"conversation_id", conversation.id,
|
|
"created", created,
|
|
"resolved_by", resolvedBy,
|
|
"source_char_id", sourceID,
|
|
"target_char_id", targetID,
|
|
"line_flag", currentChatIncomingLineFlag)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 1, int(request.ItemCount),
|
|
"ok", "", map[string]any{
|
|
"scope": "online_cross_channel",
|
|
"notification": currentTownChatOneToOneStateMsgID,
|
|
"target_character_id": strconv.Itoa(int(targetID)),
|
|
"target_name": targetName,
|
|
"target_channel_id": targetSession.channel.ID,
|
|
"conversation_id": strconv.FormatUint(uint64(conversation.id), 10),
|
|
"resolved_by": resolvedBy,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// rejectCurrentOneToOneChatRequest answers a 1:1 line that could not be routed.
|
|
// The reference answers a failed CREATE_GROUP with a single status byte to the
|
|
// requester; the current client's equivalent is the failure ack on the shared
|
|
// chat opcode, which is what a typed whisper already gets for the same reasons.
|
|
func (s *Service) rejectCurrentOneToOneChatRequest(
|
|
session *gameSession,
|
|
request currentTownChatRequest,
|
|
sourceID, targetID uint16,
|
|
reason string,
|
|
) error {
|
|
s.logGameEvent(session, "game-peer-chat-request-rejected",
|
|
"source_char_id", sourceID,
|
|
"target_char_id", targetID,
|
|
"requested_target_id", request.TargetUniqueID,
|
|
"target_name", string(request.TargetName),
|
|
"reason", reason)
|
|
s.recordChat(session, request.Mode, currentTownChatModeName(request.Mode), request.Message, 0, int(request.ItemCount),
|
|
"rejected", reason, map[string]any{
|
|
"target_character_id": strconv.Itoa(int(targetID)),
|
|
"target_name": chatRecordText(request.TargetName),
|
|
})
|
|
return s.sendGameUpperFailure(session, currentPeerChatStateOpcode, currentTownChatGenericError)
|
|
}
|