服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
181 lines
6.4 KiB
Go
181 lines
6.4 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
func parsePVPTotalTeamName(body []byte, tail int) (string, []byte, error) {
|
|
if len(body) < 4 {
|
|
return "", nil, fmt.Errorf("missing team name")
|
|
}
|
|
n := uint64(binary.LittleEndian.Uint32(body))
|
|
if n == 0 || n > 31 || n+4+uint64(tail) != uint64(len(body)) {
|
|
return "", nil, fmt.Errorf("invalid team name size")
|
|
}
|
|
raw := body[4 : 4+int(n)]
|
|
name, err := decodeCharacterName(raw)
|
|
if err != nil || bytes.IndexByte(raw, 0) >= 0 || !bytes.Equal(rosterNameBytes(name), raw) {
|
|
return "", nil, fmt.Errorf("invalid team name encoding")
|
|
}
|
|
if err := dnfpvp.ValidateTotalTeamName(name); err != nil {
|
|
return "", nil, err
|
|
}
|
|
return name, body[4+int(n):], nil
|
|
}
|
|
|
|
func (s *Service) checkPVPTotalTeamName(ctx context.Context, repos dnfrepo.Group, accountID, name string) error {
|
|
finder, ok := repos.Account.(dnfrepo.PVPTotalTeamNameFinder)
|
|
if !ok {
|
|
return fmt.Errorf("team name index unavailable")
|
|
}
|
|
owner, found, err := finder.FindAccountIDByPVPTotalTeamName(ctx, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found && owner != accountID {
|
|
return fmt.Errorf("team name already registered")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) handlePVPTotalTeam(session *gameSession, opcode uint16, body []byte) error {
|
|
if session == nil || session.accountID == "" {
|
|
return nil
|
|
}
|
|
tail := 0
|
|
if opcode == 871 {
|
|
tail = 3
|
|
}
|
|
if opcode == 873 {
|
|
tail = 2
|
|
}
|
|
name, extra, err := parsePVPTotalTeamName(body, tail)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
s.pvpMu.Lock()
|
|
defer s.pvpMu.Unlock()
|
|
if session.pvpRoom != nil {
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
for _, queued := range s.pvpMatching {
|
|
if queued.member.session == session {
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
}
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return s.sendGameUpperFailure(session, opcode, 159)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
if err := s.checkPVPTotalTeamName(ctx, repos, session.accountID, name); err != nil {
|
|
return s.sendGameUpperFailure(session, opcode, 3)
|
|
}
|
|
if opcode == 872 {
|
|
return s.sendGameUpperSuccess(session, opcode, nil)
|
|
}
|
|
if opcode == 873 {
|
|
if session.selectedCharacterID == 0 {
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
slot := binary.LittleEndian.Uint16(extra)
|
|
if err := dnfpvp.RenameTotalTeam(ctx, repos, session.accountID, strconv.Itoa(int(session.selectedCharacterID)), name, slot, s.gameplayNow()); err != nil {
|
|
s.logGameEvent(session, "game-pvp-total-rename-rejected", "error", err)
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
var w packetWriter
|
|
w.writeRawDstr(rosterNameBytes(name))
|
|
if err := s.sendGameUpperSuccess(session, opcode, w.bytes()); err != nil {
|
|
return err
|
|
}
|
|
return s.sendSelectedIncrementalItemSlotRefreshes(session, "pvp_total_rename", []alignedcmd.ItemSlotRefresh{{ListType: 0, SlotIndex: int16(slot)}})
|
|
}
|
|
if opcode != 871 {
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
if session.selectedCharacterID == 0 {
|
|
return s.sendGameUpperFailure(session, opcode, 1)
|
|
}
|
|
prior, err := dnfpvp.ReadTotalTeam(ctx, repos, session.accountID)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, opcode, 159)
|
|
}
|
|
// Changing the roster never bypasses the separate native rename-card path.
|
|
if prior.Team.Name != "" && prior.Team.Name != name {
|
|
return s.sendGameUpperFailure(session, opcode, 4)
|
|
}
|
|
rows, err := repos.Character.ListByAccount(ctx, session.accountID, dnfrepo.MaxCharacterSlotLimit)
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, opcode, 159)
|
|
}
|
|
s.initialEquipmentMu.Lock()
|
|
archive, err := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if err != nil {
|
|
return s.sendGameUpperFailure(session, opcode, 159)
|
|
}
|
|
team, err := dnfpvp.ResolveTotalTeam(session.accountID, name, [3]byte(extra), rows, archive)
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-pvp-total-team-rejected", "error", err)
|
|
return s.sendGameUpperFailure(session, opcode, 7)
|
|
}
|
|
if err := dnfpvp.SaveTotalTeam(ctx, repos.Account, session.accountID, team.Team, s.gameplayNow()); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-pvp-total-team-saved", "characters", team.Team.Characters, "slots", team.Slots)
|
|
// Native op871 (1CFD700) only closes the editor and requests op7 when
|
|
// switching representatives. It never stores the saved team. Publish op801
|
|
// while the current scene actor still exists, before that ACK can leave it.
|
|
if err := s.sendGameUpperRawClass(session, 801, buildPVPTotalTeamSelection(team), 0); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-pvp-total-team-state-sent", "source", "save_before_ack", "char_id", session.selectedCharacterID, "slots", team.Slots)
|
|
return s.sendGameUpperSuccess(session, opcode, team.Slots[:])
|
|
}
|
|
|
|
func buildPVPTotalTeamSelection(team dnfpvp.TotalTeamSnapshot) []byte {
|
|
var w packetWriter
|
|
w.writeRawDstr(rosterNameBytes(team.Team.Name))
|
|
w.writeBytes(team.Slots[:])
|
|
return w.bytes()
|
|
}
|
|
|
|
func (s *Service) sendCurrentPVPTotalTeamSelection(session *gameSession) error {
|
|
// Native op801 (1D589B0) dereferences the scene actor's +4CC state before
|
|
// reading its body. Selector roster objects do not supply that actor.
|
|
if session == nil || session.selectedCharacterID == 0 || !isCurrentPVPTotalChannel(session.channel) {
|
|
return nil
|
|
}
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return fmt.Errorf("total-match repository unavailable")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
team, err := dnfpvp.ReadTotalTeam(ctx, repos, session.accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if team.Team.Name == "" || team.Slots[0] == 255 || team.Slots[1] == 255 || team.Slots[2] == 255 {
|
|
return nil
|
|
}
|
|
if err := s.sendGameUpperRawClass(session, 801, buildPVPTotalTeamSelection(team), 0); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-pvp-total-team-state-sent", "source", "scene_actor_ready", "char_id", session.selectedCharacterID, "slots", team.Slots)
|
|
loaded, err := s.loadCurrentPVPTotalTeam(ctx, session)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.sendCurrentPVPTotalActors(session, []*currentPVPMember{{session: session, characterID: session.selectedCharacterID, total: loaded}})
|
|
}
|