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

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

278 lines
8.7 KiB
Go

package dnfbridge
import (
"context"
"fmt"
"sort"
"strconv"
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
type currentPVPTotalTeam struct {
snapshot dnfpvp.TotalTeamSnapshot
actors [3][]byte
rankPoint uint32
ratingInitialized bool
}
// sub_2003570 uses the durable UI slot, not the position in the skill list.
// Slot 255 learns an unassigned skill without assigning a shortcut (268FA30).
func buildCurrentPVPTotalSkills(record dnfrepo.SkillRecord, active byte) ([]byte, error) {
state, found := dnfrepo.SkillTree(record, int(active))
if !found || active >= currentSkillInfoTreeCount {
return nil, fmt.Errorf("total-match active skill tree missing")
}
type row struct {
slot byte
id uint16
level byte
}
rows := []row{}
assigned := map[uint16]bool{}
for slot, id := range record.Layouts[int(active)] {
if slot < 0 || slot >= currentSkillSlotCount {
return nil, fmt.Errorf("invalid total-match skill slot %d", slot)
}
level := state.Skills[int64(id)].Level
if level == 0 {
continue
}
if level < 0 || level > 255 || id >= 512 {
return nil, fmt.Errorf("invalid total-match skill %d", id)
}
rows = append(rows, row{byte(slot), id, byte(level)})
assigned[id] = true
}
for id, skill := range state.Skills {
if id < 0 || id >= 512 || skill.Level < 0 || skill.Level > 255 {
return nil, fmt.Errorf("invalid total-match learned skill %d", id)
}
if skill.Level > 0 && !assigned[uint16(id)] {
rows = append(rows, row{255, uint16(id), byte(skill.Level)})
}
}
if len(rows) > 255 {
return nil, fmt.Errorf("too many total-match skills")
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].slot != rows[j].slot {
return rows[i].slot < rows[j].slot
}
return rows[i].id < rows[j].id
})
var w packetWriter
w.writeByte(byte(len(rows)))
w.writeByte(active)
for _, row := range rows {
w.writeByte(row.slot)
w.writeUint16(row.id)
w.writeByte(row.level)
}
return w.bytes(), nil
}
func (s *Service) loadCurrentPVPTotalTeam(ctx context.Context, session *gameSession) (*currentPVPTotalTeam, error) {
repos, ok := s.repositoryGroup()
if !ok || repos.Skill == nil || repos.Equipment == nil {
return nil, fmt.Errorf("total-match actor repositories missing")
}
snapshot, err := dnfpvp.ReadTotalTeam(ctx, repos, session.accountID)
if err != nil {
return nil, err
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return nil, err
}
snapshot, err = dnfpvp.ResolveTotalTeam(session.accountID, snapshot.Team.Name, snapshot.Slots, snapshot.Characters[:], archive)
if err != nil {
return nil, err
}
team := &currentPVPTotalTeam{snapshot: snapshot}
account, found, err := repos.Account.Load(ctx, session.accountID)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("total-match account missing")
}
progress, err := dnfpvp.ReadTotalProgress(account)
if err != nil {
return nil, err
}
team.rankPoint = uint32(progress.Stats[dnfpvp.RankPointKey])
team.ratingInitialized = progress.Stats["pvp_rating_initialized"] != 0
selectedFound := false
for _, c := range snapshot.Characters {
selectedFound = selectedFound || c.CharacterID == strconv.Itoa(int(session.selectedCharacterID))
}
if !selectedFound {
return nil, fmt.Errorf("selected character outside total-match roster")
}
for i, c := range snapshot.Characters {
skills, found, err := repos.Skill.Load(ctx, c.CharacterID)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("total-match character %s skills missing", c.CharacterID)
}
active, err := currentSceneActiveSkillTree(c)
if err != nil {
return nil, err
}
skillBody, err := buildCurrentPVPTotalSkills(skills, active)
if err != nil {
return nil, err
}
stats, found := s.characterPVFStatsForUserInfo(ctx, session, c, true)
if !found {
return nil, fmt.Errorf("total-match character %s PVF stats missing", c.CharacterID)
}
// Preflight equipment errors instead of treating a failed load as naked.
if _, _, err := repos.Equipment.Load(ctx, c.CharacterID); err != nil {
return nil, err
}
r := csharpLegacyUserInfoReader{ctx: ctx, repo: repos.LegacyUserInfo, characterID: c.CharacterID, service: s, session: session, pvfStats: stats, hasPVFStats: true}
team.actors[i], err = r.buildCurrentPVPTotalActor(c, skillBody)
if err != nil {
return nil, err
}
}
return team, nil
}
// Exact special=1 payload of current EXE 2237EB0, called by NOTI 800.
// The enclosing packet owns the name and index; this is not a mode1 record.
func (r *csharpLegacyUserInfoReader) buildCurrentPVPTotalActor(c dnfrepo.CharacterRecord, skills []byte) ([]byte, error) {
job, err := strconv.ParseUint(c.Job, 10, 8)
if err != nil {
return nil, err
}
var w packetWriter
w.writeByte(rosterLevel(c))
w.writeByte(byte(job))
w.writeByte(byte(statU32(c, "grow_type", 0) & 127))
w.writeByte(boolByte(dnfpvp.IsPrivateCharacter(c)))
stats := r.buildCurrentUserInfoMode1StatBlob(r.one("legacy_character_subtype1_fields", csharpUserInfoSubtype1Columns()), c)
w.writeUint32(uint32(len(stats)))
w.writeBytes(stats)
w.writeByte(currentExtraEquipmentSlotState(c))
writeCurrentSceneObjectEquipSummary(&w, currentSceneObjectMode0EquipSummary(c, true))
w.writeUint32(currentCloneTitleItemID(c, true))
w.writeZero(4) // 2002850, 2002880, 1B1D610, reserved u8
rows := r.currentMode1EquipmentObjectRows()
if len(currentMode1EquipmentDeferredUpdateRows(rows)) != 0 {
return nil, fmt.Errorf("total-match equipment has no verified create representation")
}
create := currentMode1EquipmentCreateRows(rows)
w.writeByte(byte(len(create)))
for _, row := range create {
writeCurrentMode1EquipmentCreateRow(&w, row)
}
w.writeUint32(currentCloneTitleItemID(c, true))
// Skill-object +0 has native constructor default zero (268F852). There is
// no persisted counterpart in this server; do not invent a character key.
w.writeUint16(0)
w.writeBytes(skills)
w.writeUint32(0) // same unpopulated auxiliary actor list as normal mode1
// Current PVF excludes Creator (job 10) from matching. Its five auxiliary
// values are still present on the wire and ignored by other actor types.
w.writeZero(20)
creature := r.currentEquippedCreature()
w.writeUint32(creature.itemID)
w.writeUint32(creature.serialOrHandle)
return w.bytes(), nil
}
func writeCurrentPVPTotalSummary(w *packetWriter, team *currentPVPTotalTeam) {
w.writeRawDstr(rosterNameBytes(team.snapshot.Team.Name))
for _, c := range team.snapshot.Characters {
w.writeRawDstr(rosterNameBytes(c.Name))
w.writeByte(byte(numericCharacterStat(c.Job)))
w.writeByte(byte(statU32(c, "grow_type", 0) & 127))
w.writeByte(boolByte(dnfpvp.IsPrivateCharacter(c)))
}
w.writeUint32(team.rankPoint)
}
func buildCurrentPVPTotalActors(members []*currentPVPMember) []byte {
var w packetWriter
w.writeByte(byte(len(members)))
for _, m := range members {
w.writeUint16(m.characterID)
w.writeByte(3)
for i, c := range m.total.snapshot.Characters {
w.writeByte(byte(i))
w.writeRawDstr(rosterNameBytes(c.Name))
w.writeBytes(m.total.actors[i])
}
}
return w.bytes()
}
func buildCurrentPVPTotalSummaries(members []*currentPVPMember) []byte {
var w packetWriter
w.writeByte(byte(len(members)))
for _, m := range members {
w.writeUint32(uint32(m.characterID))
writeCurrentPVPTotalSummary(&w, m.total)
}
return w.bytes()
}
func (s *Service) sendCurrentPVPTotalActors(target *gameSession, members []*currentPVPMember) error {
if len(members) == 0 {
return nil
}
for _, packet := range []struct {
id uint16
body []byte
}{{802, buildCurrentPVPTotalSummaries(members)}, {800, buildCurrentPVPTotalActors(members)}} {
if err := s.sendGameUpperRawClass(target, packet.id, packet.body, 0); err != nil {
return err
}
}
return nil
}
func (s *Service) refreshCurrentPVPTotalRoomLocked(room *currentPVPRoom) error {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
members := []*currentPVPMember{}
loaded := map[*currentPVPMember]*currentPVPTotalTeam{}
for seat, m := range room.members {
if !room.combatant(seat) {
continue
}
team, err := s.loadCurrentPVPTotalTeam(ctx, m.session)
if err != nil {
return err
}
loaded[m] = team
members = append(members, m)
}
// Publish only once the complete room roster has loaded successfully.
for m, team := range loaded {
m.total = team
}
for _, m := range room.members {
if m == nil {
continue
}
if m.total != nil {
if err := s.sendGameUpperRawClass(m.session, 801, buildPVPTotalTeamSelection(m.total.snapshot), 0); err != nil {
return err
}
}
if err := s.sendCurrentPVPTotalActors(m.session, members); err != nil {
return err
}
}
return nil
}