服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
dnfpvp "longheng.io/server/internal/modules/dnf/pvp"
|
|
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
|
)
|
|
|
|
type currentPVPMatch struct {
|
|
total *currentPVPTotalMatch
|
|
dead [currentPVPRoomSeatCount]bool
|
|
kills [currentPVPRoomSeatCount]uint32
|
|
ended [currentPVPRoomSeatCount]bool
|
|
winner byte
|
|
resultSent bool
|
|
settlementPending bool
|
|
startedAt time.Time
|
|
completedAt time.Time
|
|
settled [currentPVPRoomSeatCount]*dnfpvp.Snapshot
|
|
resultStage [currentPVPRoomSeatCount]byte
|
|
missionReports [currentPVPRoomSeatCount]map[uint16]bool
|
|
questPublished bool
|
|
}
|
|
|
|
func (room *currentPVPRoom) combatant(seat int) bool {
|
|
return seat >= 0 && seat < currentPVPRoomSeatCount && room.members[seat] != nil &&
|
|
room.seatState[seat] <= 2
|
|
}
|
|
|
|
func (room *currentPVPRoom) survivingPVPWinner() (byte, bool) {
|
|
winner := byte(0xff)
|
|
for seat := range room.members {
|
|
if !room.combatant(seat) || room.match.dead[seat] {
|
|
continue
|
|
}
|
|
if winner == 0xff {
|
|
winner = byte(seat)
|
|
continue
|
|
}
|
|
if room.battleMode == 1 || room.battleMode == 4 || room.seatState[seat] != room.seatState[winner] {
|
|
return 0xff, false
|
|
}
|
|
}
|
|
return winner, true
|
|
}
|
|
|
|
func (s *Service) handleCurrentPVPDeath(session *gameSession, body []byte) error {
|
|
// sub_24A7470 emits victim/killer actor keys, perfect-kill flag and HP%.
|
|
if session == nil || !isPVPChannel(session.channel) || len(body) != 7 || body[4] > 1 ||
|
|
binary.LittleEndian.Uint16(body[5:]) > 100 || binary.LittleEndian.Uint16(body) != session.selectedCharacterID {
|
|
return nil
|
|
}
|
|
s.pvpMu.Lock()
|
|
defer s.pvpMu.Unlock()
|
|
room := session.pvpRoom
|
|
if room == nil || !room.started || room.match == nil || room.match.resultSent {
|
|
return nil
|
|
}
|
|
if room.match.settlementPending {
|
|
return nil
|
|
}
|
|
seat := room.seatFor(session)
|
|
if !room.combatant(seat) || room.match.dead[seat] {
|
|
return nil
|
|
}
|
|
if m := room.match.total; m != nil && (m.choosing || m.selected[seat] >= 3) {
|
|
return nil
|
|
}
|
|
killerID := binary.LittleEndian.Uint16(body[2:])
|
|
killer := byte(0xff)
|
|
if killerID != 0xffff {
|
|
for other, member := range room.members {
|
|
if room.combatant(other) && other != seat && member.characterID == killerID &&
|
|
(room.battleMode == 1 || room.battleMode == 4 || room.seatState[other] != room.seatState[seat]) {
|
|
killer = byte(other)
|
|
break
|
|
}
|
|
}
|
|
if killer == 0xff {
|
|
return nil
|
|
}
|
|
}
|
|
room.match.dead[seat] = true
|
|
if killer != 0xff {
|
|
room.match.kills[killer]++
|
|
if m := room.match.total; m != nil && m.selected[killer] < 3 {
|
|
m.kills[killer][m.selected[killer]]++
|
|
}
|
|
}
|
|
for _, member := range room.members {
|
|
if member != nil {
|
|
if err := s.sendGameUpperRawClass(member.session, 46, []byte{byte(seat), killer}, 0); err != nil {
|
|
s.logGameEvent(member.session, "game-pvp-death-write-failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
if room.match.total != nil {
|
|
return s.finishCurrentPVPTotalRoundLocked(room, session, seat)
|
|
}
|
|
winner, terminal := room.survivingPVPWinner()
|
|
s.logGameEvent(session, "game-pvp-death-accepted", "room_id", room.id, "seat", seat, "killer", killer, "terminal", terminal)
|
|
if !terminal {
|
|
return nil
|
|
}
|
|
return s.sendCurrentPVPResultLocked(room, session, winner)
|
|
}
|
|
|
|
func (s *Service) sendCurrentPVPResultLocked(room *currentPVPRoom, session *gameSession, winner byte) error {
|
|
if room.match == nil || room.match.resultSent {
|
|
return nil
|
|
}
|
|
if !room.match.settlementPending {
|
|
if room.match.total != nil {
|
|
s.ensureGameplayTimeQueue().Cancel(currentPVPTotalPhaseTimerName(room, room.match))
|
|
}
|
|
room.match.winner = winner
|
|
room.match.settlementPending = true
|
|
room.match.completedAt = s.gameplayNow()
|
|
}
|
|
err := s.settleCurrentPVPDaily(room)
|
|
if err == nil && !room.match.questPublished {
|
|
err = s.settleCurrentPVPWinQuests(room)
|
|
room.match.questPublished = err == nil
|
|
}
|
|
if err == nil {
|
|
err = s.publishCurrentPVPResults(room)
|
|
}
|
|
if err != nil {
|
|
s.logGameEvent(session, "game-pvp-quest-settlement-retry", "room_id", room.id, "error", err)
|
|
match := room.match
|
|
return s.ensureGameplayTimeQueue().ScheduleAfter(currentPVPResultTimerName(room, match), time.Second, func(time.Time) {
|
|
s.pvpMu.Lock()
|
|
defer s.pvpMu.Unlock()
|
|
if s.pvpRooms[room.id] == room && room.match == match && room.started {
|
|
if err := s.sendCurrentPVPResultLocked(room, session, match.winner); err != nil {
|
|
s.logGameEvent(session, "game-pvp-quest-retry-schedule-failed", "error", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
room.match.resultSent = true
|
|
match := room.match
|
|
queue := s.ensureGameplayTimeQueue()
|
|
if err := queue.ScheduleAfter(currentPVPResultTimerName(room, match), 30*time.Second, func(time.Time) {
|
|
s.pvpMu.Lock()
|
|
defer s.pvpMu.Unlock()
|
|
if s.pvpRooms[room.id] == room && room.match == match && room.started {
|
|
s.finishCurrentPVPResultLocked(room)
|
|
}
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
s.logGameEvent(session, "game-pvp-result-sent", "room_id", room.id, "winner_seat", winner)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) settleCurrentPVPWinQuests(room *currentPVPRoom) error {
|
|
// PVF daily_PVP_02/03 require a ranked personVsPerson win. Manual rooms,
|
|
// practice, observers and draws do not satisfy that objective.
|
|
if room.matchingToken == 0 || room.match.winner > 1 || room.battleMode != 2 ||
|
|
!room.combatant(0) || !room.combatant(1) {
|
|
return nil
|
|
}
|
|
member := room.members[room.match.winner]
|
|
if member.session == nil || member.session.channel.Type != 8 || member.session.assault != nil {
|
|
return nil
|
|
}
|
|
for seat := 2; seat < len(room.members); seat++ {
|
|
if room.combatant(seat) {
|
|
return nil
|
|
}
|
|
}
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return dnfquest.ErrOwnerUnavailable
|
|
}
|
|
owner, err := dnfquest.NewOwner(repos)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
catalog, err := s.loadQuestCatalog(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := fmt.Sprintf("pvp:%d:%d:%p", room.id, room.match.startedAt.UnixNano(), room.match)
|
|
result, err := owner.ApplyPVPWin(ctx, catalog, member.session.accountID,
|
|
strconv.FormatUint(uint64(member.characterID), 10), key, room.match.completedAt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result.SnapshotRequired {
|
|
if err := s.sendCurrentActiveQuestSnapshotFromCommittedQuest(member.session, result.Quest, "pvp_win"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildCurrentPVPResultBody(room *currentPVPRoom, recipient *currentPVPMember) []byte {
|
|
var w packetWriter
|
|
w.writeByte(room.match.winner)
|
|
// sub_3291EC0 replaces inventory currency slot 2, as does op37.
|
|
w.writeUint32(recipient.currencySlot2)
|
|
grade, experienceDelta := recipient.grade, int64(0)
|
|
if seat := room.seatFor(recipient.session); seat >= 0 && room.match.settled[seat] != nil {
|
|
state := room.match.settled[seat]
|
|
grade = byte(state.Character.Stats[dnfpvp.GradeKey])
|
|
experienceDelta = state.ExperienceDelta
|
|
}
|
|
if seat := room.seatFor(recipient.session); seat >= 0 && room.match.total != nil && room.match.total.settled[seat] != nil {
|
|
state := room.match.total.settled[seat]
|
|
for i, c := range state.Characters {
|
|
if c.CharacterID == strconv.Itoa(int(recipient.characterID)) {
|
|
grade = byte(c.Stats[dnfpvp.GradeKey])
|
|
experienceDelta = c.Stats[dnfpvp.ExperienceKey] - state.Before[i].Stats[dnfpvp.ExperienceKey]
|
|
}
|
|
}
|
|
}
|
|
w.writeByte(grade)
|
|
// sub_1D681E0 -> sub_3291F60 -> sub_32949A0 draws this signed gain.
|
|
w.writeInt32(int(experienceDelta))
|
|
count := 0
|
|
for seat := range room.members {
|
|
if room.combatant(seat) {
|
|
count++
|
|
}
|
|
}
|
|
w.writeByte(byte(count))
|
|
for seat, member := range room.members {
|
|
if !room.combatant(seat) {
|
|
continue
|
|
}
|
|
// sub_1D681E0 consumes exactly 23 bytes per current-client row.
|
|
w.writeUint16(member.characterID)
|
|
w.writeUint32(room.match.kills[seat])
|
|
grade := member.grade
|
|
if state := room.match.settled[seat]; state != nil {
|
|
grade = byte(state.Character.Stats[dnfpvp.GradeKey])
|
|
}
|
|
w.writeByte(grade)
|
|
w.writeUint32(0)
|
|
beforeRP, afterRP := member.rankPoint, member.rankPoint
|
|
if room.match.total != nil && member.total != nil {
|
|
beforeRP, afterRP = member.total.rankPoint, member.total.rankPoint
|
|
if state := room.match.total.settled[seat]; state != nil {
|
|
afterRP = uint32(state.Stats[dnfpvp.RankPointKey])
|
|
}
|
|
}
|
|
w.writeUint32(beforeRP)
|
|
w.writeUint32(afterRP)
|
|
w.writeUint32(room.match.kills[seat])
|
|
}
|
|
w.writeUint16(0) // no ace bonus
|
|
w.writeUint16(0xffff) // no all-kill bonus
|
|
w.writeUint32(0)
|
|
if room.match.winner == 0xff {
|
|
w.writeByte(1)
|
|
} else {
|
|
w.writeByte(0)
|
|
}
|
|
w.writeByte(0xff)
|
|
w.writeUint32(0)
|
|
w.writeByte(0)
|
|
return w.bytes()
|
|
}
|
|
|
|
func currentPVPResultTimerName(room *currentPVPRoom, match *currentPVPMatch) string {
|
|
return fmt.Sprintf("dnf-pvp-result:%d:%p", room.id, match)
|
|
}
|
|
|
|
func (s *Service) handleCurrentPVPResultEnd(session *gameSession, body []byte) error {
|
|
if session == nil || !isPVPChannel(session.channel) || len(body) != 0 {
|
|
return nil
|
|
}
|
|
s.pvpMu.Lock()
|
|
defer s.pvpMu.Unlock()
|
|
room := session.pvpRoom
|
|
if room == nil || !room.started || room.match == nil || !room.match.resultSent {
|
|
return nil
|
|
}
|
|
seat := room.seatFor(session)
|
|
if seat < 0 {
|
|
return nil
|
|
}
|
|
room.match.ended[seat] = true
|
|
for seat, member := range room.members {
|
|
if member != nil && !room.match.ended[seat] {
|
|
return nil
|
|
}
|
|
}
|
|
s.ensureGameplayTimeQueue().Cancel(currentPVPResultTimerName(room, room.match))
|
|
s.finishCurrentPVPResultLocked(room)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) finishCurrentPVPResultLocked(room *currentPVPRoom) {
|
|
if room.matchingToken != 0 {
|
|
s.closeCurrentPVPMatchingLocked(room, nil)
|
|
return
|
|
}
|
|
room.started = false
|
|
room.match = nil
|
|
room.ready = [currentPVPRoomSeatCount]byte{}
|
|
for _, member := range room.members {
|
|
if member == nil {
|
|
continue
|
|
}
|
|
for seat, actor := range room.members {
|
|
if actor != nil {
|
|
if err := s.sendGameUpperRawClass(member.session, 44, []byte{byte(seat), 0}, 0); err != nil {
|
|
s.logGameEvent(member.session, "game-pvp-ready-reset-write-failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
s.broadcastCurrentPVPPacketLocked(room, nil, currentPVPRoomStateMsgID,
|
|
buildCurrentPVPRoomStateBody(room.id, room.state(), room.manager, room.request.mapIndex, room.battleMode, 0))
|
|
}
|