Files
90CNServer/server/internal/services/dnfbridge/adventure_runtime.go
T
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

913 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
import (
"context"
"encoding/binary"
"errors"
"math"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfhonor "longheng.io/server/internal/modules/dnf/honor"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const currentAdventureGenericFailureCode = 1
// 冒险团商店(op1414)的结果码取自客户端 0x00C79940 的跳转表(重启后只认这些):
//
// 19 → dstr 44465 "超过购买限制"
// 22 → adv 100002122 "购买材料不足。"
// 23 → adv 100002156 "无法购买决斗场专用角色。"
// 204 → dstr 746 "物品栏空间不足"
// 235 → adv 100003018 "冒险团Lv较低。"
//
// 表里其余的码(**包括我们过去一律回的 1**)落到 default 分支 = 什么都不弹。
// "买超限了却没提示"就是这么来的。
const (
currentAdventureShopPurchaseLimitCode = 19
currentAdventureShopInsufficientCode = 22
currentAdventureShopLevelTooLowCode = 235
currentAdventureShopInventoryFullCode = 204
)
// currentAdventureShopFailureCode 把拒绝原因翻成客户端认识的 19/22/204,
// 认不出来的原因仍回笼统码(静默)——宁可不说,也不能把"背包满"说成"材料不足"。
func currentAdventureShopFailureCode(err error) byte {
switch {
case errors.Is(err, adventuregroup.ErrPurchaseLimitReached):
return currentAdventureShopPurchaseLimitCode
case errors.Is(err, adventuregroup.ErrInsufficientShopPoints):
return currentAdventureShopInsufficientCode
case errors.Is(err, errDungeonPickupInventoryFull),
errors.Is(err, dnfdungeon.ErrInventoryFull):
return currentAdventureShopInventoryFullCode
default:
return currentAdventureGenericFailureCode
}
}
// The current NoPack executable and its DNF90 content profile cap characters
// at level 90. The progression PVF contains later table rows for compatibility,
// so its row count is not a playable-level cap.
const currentAdventureCharacterLevelCap = progression.MaximumCharacterLevel
var (
errCurrentAdventureRuntimeUnavailable = errors.New("current adventure-group runtime is unavailable")
errCurrentAdventureRequestInvalid = errors.New("current adventure-group request is invalid")
errCurrentAdventureStateInvalid = errors.New("current adventure-group state is invalid")
)
type currentAdventureSelectedState struct {
Repositories dnfrepo.Group
AccountID string
CharacterID string
Character dnfrepo.CharacterRecord
Account dnfrepo.AccountRecord
Characters []dnfrepo.CharacterRecord
Summary adventuregroup.Summary
Config adventuregroup.RuntimeConfig
Runtime adventuregroup.RuntimeState
}
func (s *Service) currentAdventureSelectedState(
ctx context.Context,
session *gameSession,
) (currentAdventureSelectedState, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return currentAdventureSelectedState{}, errCurrentAdventureRuntimeUnavailable
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Account == nil || repositories.Character == nil {
return currentAdventureSelectedState{}, errCurrentAdventureRuntimeUnavailable
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
characterID := strconv.Itoa(int(session.selectedCharacterID))
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return currentAdventureSelectedState{}, err
}
if !found || strings.TrimSpace(character.AccountID) != accountID {
return currentAdventureSelectedState{}, errCurrentAdventureStateInvalid
}
account, found, err := repositories.Account.Load(ctx, accountID)
if err != nil {
return currentAdventureSelectedState{}, err
}
if !found || strings.TrimSpace(account.AccountID) != accountID {
return currentAdventureSelectedState{}, errCurrentAdventureStateInvalid
}
state, err := s.currentAccountAdventureGroupState(ctx, character, true, session)
if err != nil {
return currentAdventureSelectedState{}, err
}
config := s.adventureGroupTable.Runtime()
runtime, err := adventuregroup.ParseRuntimeState(account, config, s.gameplayNow())
if err != nil {
return currentAdventureSelectedState{}, err
}
return currentAdventureSelectedState{
Repositories: repositories,
AccountID: accountID,
CharacterID: characterID,
Character: character,
Account: account,
Characters: state.Characters,
Summary: state.Summary,
Config: config,
Runtime: runtime,
}, nil
}
func saveCurrentAdventureRuntime(
ctx context.Context,
state currentAdventureSelectedState,
now time.Time,
) error {
value, err := state.Runtime.Marshal()
if err != nil {
return err
}
if state.Repositories.CharacterSettlement == nil {
return errCurrentAdventureRuntimeUnavailable
}
return state.Repositories.CharacterSettlement.WithinCharacterSettlement(ctx, state.CharacterID, func(tx dnfrepo.Group) error {
account, found, err := tx.Account.Load(ctx, state.AccountID)
if err != nil {
return err
}
if !found || account.AccountID != state.AccountID ||
account.Metadata[adventuregroup.RuntimeStateMetadataKey] != state.Account.Metadata[adventuregroup.RuntimeStateMetadataKey] {
return errCurrentAdventureStateInvalid
}
// Reject a stale expedition write if settlement or another session accrued currency.
return dnfrepo.SaveAccountMetadataEntry(ctx, tx.Account, account, adventuregroup.RuntimeStateMetadataKey, value, now)
})
}
func (s *Service) handleCurrentAdventureExpeditionInfo(session *gameSession, request []byte) error {
if len(request) < 1 || int(request[0])+1 != len(request) {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryInfo), currentAdventureGenericFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
s.adventureGroupRuntimeMu.Lock()
state, err := s.currentAdventureSelectedState(ctx, session)
s.adventureGroupRuntimeMu.Unlock()
if err != nil {
s.logGameEvent(session, "game-adventure-expedition-info-rejected", "error", err)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryInfo), currentAdventureGenericFailureCode)
}
body := buildCurrentAdventureExpeditionStateBody(currentAdventureExpeditionProjection(state, s.gameplayNow()), s.gameplayNow())
return s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketMercenaryInfo), body)
}
type currentAdventureExpeditionStartRequest struct {
Area byte
DurationSeconds uint32
Members []currentAdventureExpeditionMemberRequest
}
type currentAdventureExpeditionMemberRequest struct {
Selector byte
Job byte
GrowType byte
}
func parseCurrentAdventureExpeditionStartRequest(body []byte) (currentAdventureExpeditionStartRequest, error) {
if len(body) < 7 {
return currentAdventureExpeditionStartRequest{}, errCurrentAdventureRequestInvalid
}
count := int(binary.LittleEndian.Uint16(body[5:7]))
if count <= 0 || count > 4 || len(body) != 7+count*3 {
return currentAdventureExpeditionStartRequest{}, errCurrentAdventureRequestInvalid
}
request := currentAdventureExpeditionStartRequest{
Area: body[0],
DurationSeconds: binary.LittleEndian.Uint32(body[1:5]),
Members: make([]currentAdventureExpeditionMemberRequest, count),
}
for index := range request.Members {
offset := 7 + index*3
request.Members[index] = currentAdventureExpeditionMemberRequest{
Selector: body[offset],
Job: body[offset+1],
GrowType: body[offset+2],
}
}
return request, nil
}
func (s *Service) handleCurrentAdventureExpeditionStart(session *gameSession, body []byte) error {
request, err := parseCurrentAdventureExpeditionStartRequest(body)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), currentAdventureGenericFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
now := s.gameplayNow()
s.adventureGroupRuntimeMu.Lock()
defer s.adventureGroupRuntimeMu.Unlock()
state, err := s.currentAdventureSelectedState(ctx, session)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), currentAdventureGenericFailureCode)
}
area, found := state.Config.Area(request.Area)
if !found || state.Summary.ManageLevel < area.RequiredManageLevel {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), 7)
}
if _, exists := state.Runtime.Expeditions[adventuregroup.ExpeditionKey(request.Area)]; exists {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), 14)
}
if _, found := area.RewardRates[request.DurationSeconds]; !found {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), 18)
}
members, rewardInputs, ok := resolveCurrentAdventureExpeditionMembers(
request.Members,
state.Characters,
state.Runtime,
)
if !ok {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), 22)
}
// The designated attribute set comes from the same rotated snapshot the
// client was shown, so the paid reward matches the preview.
reward, ok := state.Config.ExpeditionReward(
request.Area,
request.DurationSeconds,
rewardInputs,
state.Config.AreaAttributes(request.Area, now),
)
if !ok {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), 22)
}
startedAt := now.Unix()
state.Runtime.Expeditions[adventuregroup.ExpeditionKey(request.Area)] = adventuregroup.Expedition{
Area: request.Area,
State: 1,
StartedAt: startedAt,
EndsAt: startedAt + int64(request.DurationSeconds),
Attributes: state.Config.AreaAttributes(request.Area, now),
Members: members,
Reward: reward,
}
if err := saveCurrentAdventureRuntime(ctx, state, now); err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetition), currentAdventureGenericFailureCode)
}
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketMercenaryCompetition), nil); err != nil {
return err
}
return s.sendCurrentAdventureExpeditionStatePush(session, state, now)
}
func resolveCurrentAdventureExpeditionMembers(
requests []currentAdventureExpeditionMemberRequest,
characters []dnfrepo.CharacterRecord,
runtime adventuregroup.RuntimeState,
) ([]adventuregroup.ExpeditionMember, []adventuregroup.ExpeditionMemberInput, bool) {
used := make(map[byte]struct{}, len(requests))
members := make([]adventuregroup.ExpeditionMember, 0, len(requests))
inputs := make([]adventuregroup.ExpeditionMemberInput, 0, len(requests))
for _, request := range requests {
if _, duplicate := used[request.Selector]; duplicate {
return nil, nil, false
}
used[request.Selector] = struct{}{}
var character dnfrepo.CharacterRecord
found := false
for _, candidate := range characters {
// The selector is a roster slot. A character whose numeric ID
// happens to equal it is a different character, and a stale
// selector left over from a roster reorder must not resolve.
if candidate.Slot == int(request.Selector) {
character = candidate
found = true
break
}
}
job := byte(numericCharacterStat(character.Job))
grow := byte(numericCharacterStatValue(character, "grow_type")) & 0x0f
if !found || job != request.Job || grow != request.GrowType || character.Level <= 0 {
return nil, nil, false
}
// Busy is decided by durable identity, so reordering the roster cannot
// let the same character be sent out twice.
if currentAdventureExpeditionCharacterBusy(runtime, character) {
return nil, nil, false
}
members = append(members, adventuregroup.ExpeditionMember{
CharacterID: uint16(request.Selector),
PersistentCharacterID: character.CharacterID,
Name: character.Name,
Level: uint32(character.Level),
Job: job,
GrowType: grow,
})
inputs = append(inputs, adventuregroup.ExpeditionMemberInput{
Level: character.Level, Job: job, GrowType: grow,
})
}
return members, inputs, true
}
// currentAdventureExpeditionCharacterBusy reports whether this character is
// already sent out. It matches on the durable character ID first, and falls
// back to the profession row because the native candidate table holds one row
// per profession and old saves only recorded that pair.
func currentAdventureExpeditionCharacterBusy(runtime adventuregroup.RuntimeState, character dnfrepo.CharacterRecord) bool {
for _, expedition := range runtime.Expeditions {
for _, member := range expedition.Members {
if member.PersistentCharacterID == character.CharacterID ||
(member.Job == byte(numericCharacterStat(character.Job)) &&
member.GrowType&0x0f == byte(numericCharacterStatValue(character, "grow_type"))&0x0f) {
return true
}
}
}
return false
}
// currentAdventureExpeditionProjection builds the state the client reads. The
// native sub_D44860 accepts area 0 as the idle-candidate table and rebuilds the
// whole list from it, so every eligible character is published there; without
// it the expedition window has no characters to offer.
func currentAdventureExpeditionProjection(state currentAdventureSelectedState, now time.Time) adventuregroup.RuntimeState {
projection := state.Runtime
projection.Expeditions = make(map[string]adventuregroup.Expedition, len(state.Config.ExpeditionAreas)+1)
idle := adventuregroup.Expedition{}
for _, character := range state.Characters {
if character.Slot < 0 || character.Slot > math.MaxUint8 ||
currentAdventureExpeditionCharacterBusy(state.Runtime, character) {
continue
}
idle.Members = append(idle.Members, adventuregroup.ExpeditionMember{
CharacterID: uint16(character.Slot),
PersistentCharacterID: character.CharacterID,
Name: character.Name,
Level: uint32(character.Level),
Job: byte(numericCharacterStat(character.Job)),
GrowType: byte(numericCharacterStatValue(character, "grow_type")) & 0x0f,
})
}
projection.Expeditions["0"] = idle
for _, area := range state.Config.ExpeditionAreas {
key := adventuregroup.ExpeditionKey(area.Index)
expedition, exists := state.Runtime.Expeditions[key]
if !exists {
expedition = adventuregroup.Expedition{Area: area.Index, Attributes: state.Config.AreaAttributes(area.Index, now)}
}
projection.Expeditions[key] = expedition
}
return projection
}
func (s *Service) refreshCurrentAdventureExpeditionState(ctx context.Context, session *gameSession) error {
// Representative ACKs update the collection only; op1342 rebuilds the cache.
state, err := s.currentAdventureSelectedState(ctx, session)
if err != nil {
return err
}
return s.sendCurrentAdventureExpeditionStatePush(session, state, s.gameplayNow())
}
func (s *Service) handleCurrentAdventureExpeditionCancel(session *gameSession, body []byte) error {
if len(body) != 1 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionCancle), currentAdventureGenericFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
now := s.gameplayNow()
s.adventureGroupRuntimeMu.Lock()
defer s.adventureGroupRuntimeMu.Unlock()
state, err := s.currentAdventureSelectedState(ctx, session)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionCancle), currentAdventureGenericFailureCode)
}
key := adventuregroup.ExpeditionKey(body[0])
if _, found := state.Runtime.Expeditions[key]; !found {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionCancle), currentAdventureGenericFailureCode)
}
delete(state.Runtime.Expeditions, key)
if err := saveCurrentAdventureRuntime(ctx, state, now); err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionCancle), currentAdventureGenericFailureCode)
}
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketMercenaryCompetitionCancle), nil); err != nil {
return err
}
return s.sendCurrentAdventureExpeditionStatePush(session, state, now)
}
func (s *Service) handleCurrentAdventureExpeditionReward(session *gameSession, body []byte) error {
if len(body) != 1 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest), currentAdventureGenericFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
now := s.gameplayNow()
s.adventureGroupRuntimeMu.Lock()
defer s.adventureGroupRuntimeMu.Unlock()
state, err := s.currentAdventureSelectedState(ctx, session)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest), currentAdventureGenericFailureCode)
}
key := adventuregroup.ExpeditionKey(body[0])
expedition, found := state.Runtime.Expeditions[key]
if !found || expedition.State == 0 || expedition.EndsAt > now.Unix() || expedition.EndsAt <= expedition.StartedAt || expedition.EndsAt-expedition.StartedAt > math.MaxUint32 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest), currentAdventureGenericFailureCode)
}
// Recompute old saved estimates with the native formula and the departure
// attribute snapshot, so pending expeditions also stop using level * rate.
rewardInputs := make([]adventuregroup.ExpeditionMemberInput, 0, len(expedition.Members))
for _, member := range expedition.Members {
rewardInputs = append(rewardInputs, adventuregroup.ExpeditionMemberInput{Level: int(member.Level), Job: member.Job, GrowType: member.GrowType})
}
reward, ok := state.Config.ExpeditionReward(expedition.Area, uint32(expedition.EndsAt-expedition.StartedAt), rewardInputs, expedition.Attributes)
if !ok {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest), currentAdventureGenericFailureCode)
}
delete(state.Runtime.Expeditions, key)
previousGlory := state.Runtime.ShopPoints[adventuregroup.ShopPointGlory]
state.Runtime.AddGlory(state.Config, reward)
if err := saveCurrentAdventureRuntime(ctx, state, now); err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest), currentAdventureGenericFailureCode)
}
s.logGameEvent(session, "game-adventure-expedition-reward-committed",
"area", expedition.Area, "duration_seconds", expedition.EndsAt-expedition.StartedAt,
"members", len(expedition.Members), "previous_estimate", expedition.Reward,
"native_reward", reward, "granted", state.Runtime.ShopPoints[adventuregroup.ShopPointGlory]-previousGlory)
var response packetWriter
response.writeUint32(state.Runtime.ShopPoints[adventuregroup.ShopPointGlory] - previousGlory)
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketMercenaryCompetitionRewardRequest), response.bytes()); err != nil {
return err
}
if err := s.sendCurrentAdventureExpeditionStatePush(session, state, now); err != nil {
return err
}
return s.sendCurrentAdventureInfoPushFromAccount(session, session.selectedCharacterID, "expedition_reward")
}
func (s *Service) handleCurrentAdventurePointRecalculate(session *gameSession, body []byte) error {
if len(body) != 0 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryPointRecalculate), currentAdventureGenericFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
s.adventureGroupRuntimeMu.Lock()
state, err := s.currentAdventureSelectedState(ctx, session)
s.adventureGroupRuntimeMu.Unlock()
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketMercenaryPointRecalculate), currentAdventureGenericFailureCode)
}
var response packetWriter
response.writeUint32(state.Runtime.ShopPoints[adventuregroup.ShopPointGlory])
response.writeUint32(state.Runtime.ShopPoints[adventuregroup.ShopPointBrave])
response.writeByte(0)
return s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketMercenaryPointRecalculate), response.bytes())
}
func buildCurrentAdventureExpeditionStateBody(state adventuregroup.RuntimeState, now time.Time) []byte {
keys := make([]int, 0, len(state.Expeditions))
for key := range state.Expeditions {
value, err := strconv.Atoi(key)
// Area 0 is the native idle-candidate table the client reads to list
// the characters it may send out; filtering it out leaves that list
// empty and the expedition window shows no characters at all.
if err == nil && value >= 0 && value <= math.MaxUint8 {
keys = append(keys, value)
}
}
sort.Ints(keys)
if len(keys) > math.MaxUint8 {
keys = keys[:math.MaxUint8]
}
var writer packetWriter
writer.writeByte(byte(len(keys)))
for _, areaValue := range keys {
expedition := state.Expeditions[strconv.Itoa(areaValue)]
expeditionState := expedition.State
// The idle table carries state 0 with no timestamps; only a real
// expedition can expire.
if expedition.State != 0 && expedition.EndsAt <= now.Unix() {
expeditionState = 2
}
writer.writeByte(byte(areaValue))
writer.writeByte(expeditionState)
writer.writeUint32(clampAdventureUnix(expedition.StartedAt))
writer.writeUint32(clampAdventureUnix(expedition.EndsAt))
attributeCount := len(expedition.Attributes)
if attributeCount > math.MaxUint8 {
attributeCount = math.MaxUint8
}
writer.writeByte(byte(attributeCount))
for _, attribute := range expedition.Attributes[:attributeCount] {
writer.writeByte(attribute)
}
memberCount := len(expedition.Members)
if memberCount > math.MaxUint16 {
memberCount = math.MaxUint16
}
writer.writeUint16(uint16(memberCount))
for _, member := range expedition.Members[:memberCount] {
writer.writeUint16(member.CharacterID)
writer.writeRawDstr(rosterNameBytes(member.Name))
writer.writeUint32(member.Level)
writer.writeByte(member.Job)
writer.writeByte(member.GrowType)
writer.writeByte(member.Status)
}
}
return writer.bytes()
}
func clampAdventureUnix(value int64) uint32 {
if value <= 0 {
return 0
}
if value > math.MaxUint32 {
return math.MaxUint32
}
return uint32(value)
}
func (s *Service) sendCurrentAdventureExpeditionStatePush(
session *gameSession,
selected currentAdventureSelectedState,
now time.Time,
) error {
var body packetWriter
body.writeByte(1)
body.writeBytes(buildCurrentAdventureExpeditionStateBody(currentAdventureExpeditionProjection(selected, now), now))
return s.sendGameUpperRawClass(session, 1342, body.bytes(), 0)
}
type currentAdventureShopCommit struct {
Inventory dnfrepo.InventoryRecord
// AccountInventory / AccountSlots 是账号共享仓那一侧(无色小晶块、灵魂落 354..365)。
AccountInventory dnfrepo.AccountInventoryRecord
AccountSlots []uint16
Slots []uint16
}
func (s *Service) handleCurrentAdventureShopPurchase(session *gameSession, body []byte) error {
if len(body) != 5 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureGenericFailureCode)
}
categoryIndex := body[0]
selector := binary.LittleEndian.Uint32(body[1:5])
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
now := s.gameplayNow()
s.adventureGroupRuntimeMu.Lock()
defer s.adventureGroupRuntimeMu.Unlock()
state, err := s.currentAdventureSelectedState(ctx, session)
if err != nil || state.Repositories.CeraShopAssets == nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureGenericFailureCode)
}
category, found := state.Config.Shop(categoryIndex)
if !found {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureGenericFailureCode)
}
product, found := resolveCurrentAdventureShopProduct(category, selector)
if !found {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureGenericFailureCode)
}
if state.Summary.ManageLevel < product.RequiredManageLevel {
// 客户端 235 = "冒险团Lv较低。"(0x00C79A2B)。回笼统码会让它什么都不弹。
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureShopLevelTooLowCode)
}
catalog, err := s.currentPVFItemCatalog()
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureGenericFailureCode)
}
definition, err := catalog.ResolveItem(product.ItemID)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), currentAdventureGenericFailureCode)
}
var result currentAdventureShopCommit
err = state.Repositories.CeraShopAssets.WithinCeraShopAssets(
ctx,
state.AccountID,
state.CharacterID,
"adventure_group_shop:"+state.CharacterID,
func(
accounts dnfrepo.AccountRepository,
accountInventories dnfrepo.AccountInventoryRepository,
characters dnfrepo.CharacterRepository,
inventories dnfrepo.InventoryRepository,
_ dnfrepo.EquipmentRepository,
_ dnfrepo.SettingsRepository,
) error {
account, found, err := accounts.Load(ctx, state.AccountID)
if err != nil || !found {
return errCurrentAdventureStateInvalid
}
character, found, err := characters.Load(ctx, state.CharacterID)
if err != nil || !found || strings.TrimSpace(character.AccountID) != state.AccountID {
return errCurrentAdventureStateInvalid
}
inventory, found, err := inventories.Load(ctx, state.CharacterID)
if err != nil || !found {
return errCurrentAdventureStateInvalid
}
runtime, err := adventuregroup.ParseRuntimeState(account, state.Config, now)
if err != nil {
return err
}
if err := runtime.Spend(category, product); err != nil {
return err
}
// 账号共享优先:冒险团商店直接卖传说灵魂(PVF [adventurer shop purchase info]
// 里的 `10099775 15 70 3 3`),它的家是账号共享仓的 365 格。
// 账号记录与本事务同生共死(扣点、发货一起提交)。
accountInventory, accountFound, err := accountInventories.Load(ctx, state.AccountID)
if err != nil {
return errCurrentAdventureStateInvalid
}
if !accountFound {
accountInventory = dnfrepo.AccountInventoryRecord{AccountID: state.AccountID}
}
accountInventory = dnfrepo.CloneAccountInventory(accountInventory)
accountInventory.AccountID = state.AccountID
if accountInventory.Slots == nil {
accountInventory.Slots = make(map[string]dnfrepo.ItemStack)
}
var accountSlot int16
shared := grantCurrentAccountSharedFirst(&accountInventory, definition, 1, "adventure_shop")
if shared.Changed {
accountSlot = shared.Slot
}
var slots []uint16
if shared.Remainder > 0 {
bagSlots, err := grantCurrentCeraShopProduct(&inventory, definition, shared.Remainder, s.currentInventoryPlacementMainSlotCount(ctx, &inventory, ""))
if err != nil {
return err
}
slots = append(slots, bagSlots...)
}
if accountSlot == 0 && len(slots) == 0 {
return errCurrentAdventureStateInvalid
}
encoded, err := runtime.Marshal()
if err != nil {
return err
}
account = dnfrepo.CloneAccount(account)
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[adventuregroup.RuntimeStateMetadataKey] = encoded
account.UpdatedAt = now.UTC()
inventory.UpdatedAt = now.UTC()
if err := accounts.Save(ctx, account); err != nil {
return err
}
if accountSlot != 0 {
accountInventory.UpdatedAt = now.UTC()
if err := accountInventories.Save(ctx, accountInventory); err != nil {
return err
}
}
if err := inventories.Save(ctx, inventory); err != nil {
return err
}
result = currentAdventureShopCommit{
Inventory: dnfrepo.CloneInventory(inventory),
AccountInventory: dnfrepo.CloneAccountInventory(accountInventory),
Slots: append([]uint16(nil), slots...),
}
if accountSlot != 0 {
result.AccountSlots = []uint16{uint16(accountSlot)}
}
return nil
},
)
if err != nil {
failureCode := currentAdventureShopFailureCode(err)
s.logGameEvent(session, "game-adventure-shop-purchase-rejected",
"category", categoryIndex, "selector", selector, "item_id", product.ItemID,
"failure_code", failureCode, "error", err)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), failureCode)
}
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketAdventurerShopPurchase), nil); err != nil {
return err
}
if err := sendCurrentAdventureInventoryUpdates(s, session, result); err != nil {
return err
}
return s.sendCurrentAdventureInfoPushFromAccount(session, session.selectedCharacterID, "adventure_shop_purchase")
}
func resolveCurrentAdventureShopProduct(category adventuregroup.ShopCategory, selector uint32) (adventuregroup.ShopProduct, bool) {
for _, product := range category.Products {
if product.ItemID == selector {
return product, true
}
}
if uint64(selector) < uint64(len(category.Products)) {
return category.Products[selector], true
}
return adventuregroup.ShopProduct{}, false
}
func sendCurrentAdventureInventoryUpdates(
service *Service,
session *gameSession,
result currentAdventureShopCommit,
) error {
entries := make([]currentItemListEntry, 0, len(result.Slots)+len(result.AccountSlots))
for _, slot := range result.Slots {
signedSlot := int16(slot)
stack, found := result.Inventory.Slots[currentDungeonPickupMainSlotKey(signedSlot)]
if !found {
continue
}
entries = append(entries, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, signedSlot, stack))
}
// 账号共享槽也是 list-0 线地址(354..365):不加这一行,客户端要等下一次
// 列表刷新才看得到买到的灵魂。
for _, slot := range result.AccountSlots {
signedSlot := int16(slot)
stack, found := result.AccountInventory.Slots[dnfrepo.AccountSharedInventorySlotKey(signedSlot)]
if !found {
continue
}
entries = append(entries, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, signedSlot, stack))
}
if len(entries) == 0 {
return nil
}
sortCurrentItemListEntries(entries)
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, entries)
return service.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0)
}
type currentAdventureGrowthResult struct {
PreviousLevel int
Character dnfrepo.CharacterRecord
Skill dnfrepo.SkillRecord
}
func (s *Service) handleCurrentAdventureGrowthCapsule(session *gameSession, body []byte) error {
if len(body) != 0 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
now := s.gameplayNow()
s.adventureGroupRuntimeMu.Lock()
defer s.adventureGroupRuntimeMu.Unlock()
state, err := s.currentAdventureSelectedState(ctx, session)
if err != nil || state.Repositories.CharacterSettlement == nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
s.initialEquipmentMu.Lock()
archive, err := s.initialEquipmentArchiveLocked()
s.initialEquipmentMu.Unlock()
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
tables, err := progression.Load(ctx, archive)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
preAwardExperience, present := state.Character.Stats["exp"]
if !present || preAwardExperience < 0 || uint64(preAwardExperience) > math.MaxUint32 {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
preAwardHonorExpertGain, err := currentHonorExpertExperienceGain(
tables,
state.Character.Level,
uint32(preAwardExperience),
state.Config.Capsule.GrantedExperience,
)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
var honorTables *dnfhonor.Tables
if preAwardHonorExpertGain > 0 {
honorTables, err = s.loadHonorTable(ctx)
if err != nil {
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
}
var result currentAdventureGrowthResult
err = state.Repositories.CharacterSettlement.WithinCharacterSettlement(ctx, state.CharacterID, func(tx dnfrepo.Group) error {
if tx.Account == nil {
return errCurrentAdventureRuntimeUnavailable
}
account, found, err := tx.Account.Load(ctx, state.AccountID)
if err != nil || !found {
return errCurrentAdventureStateInvalid
}
character, found, err := tx.Character.Load(ctx, state.CharacterID)
if err != nil || !found || strings.TrimSpace(character.AccountID) != state.AccountID {
return errCurrentAdventureStateInvalid
}
skill, found, err := tx.Skill.Load(ctx, state.CharacterID)
if err != nil || !found || skill.Points.SyncedLevel != character.Level {
return errCurrentAdventureStateInvalid
}
capsule := state.Config.Capsule
if character.Level < capsule.MinimumLevel || character.Level > capsule.MaximumLevel {
return errCurrentAdventureRequestInvalid
}
runtime, err := adventuregroup.ParseRuntimeState(account, state.Config, now)
if err != nil || !runtime.ConsumeCapsule(state.Config) {
return errCurrentAdventureRequestInvalid
}
currentExperience := character.Stats["exp"]
if currentExperience < 0 || uint64(currentExperience) > math.MaxUint32 {
return errCurrentAdventureStateInvalid
}
plan, err := dnfdungeon.PlanSettlementProgressionAtCap(
tables,
character.Level,
uint32(currentExperience),
capsule.GrantedExperience,
skill.Points,
currentAdventureCharacterLevelCap,
)
if err != nil {
return err
}
honorExpertGain, err := currentHonorExpertExperienceGain(
tables,
character.Level,
uint32(currentExperience),
capsule.GrantedExperience,
)
if err != nil {
return err
}
previousLevel := character.Level
character = dnfrepo.CloneCharacter(character)
skill = dnfrepo.CloneSkill(skill)
account = dnfrepo.CloneAccount(account)
character.Level = plan.Experience.NewLevel
character.Stats["exp"] = int64(plan.Experience.NewExperience)
if honorExpertGain > 0 {
nextHonor, err := planCurrentHonorExpertProgress(honorTables, character, honorExpertGain)
if err != nil {
return err
}
for key, value := range currentHonorExpertStats(nextHonor) {
character.Stats[key] = value
}
}
character.UpdatedAt = now.UTC()
skill.Points = plan.SkillPoints.New
skill.UpdatedAt = now.UTC()
encoded, err := runtime.Marshal()
if err != nil {
return err
}
if account.Metadata == nil {
account.Metadata = make(map[string]string)
}
account.Metadata[adventuregroup.RuntimeStateMetadataKey] = encoded
account.UpdatedAt = now.UTC()
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, dnfrepo.SkillFieldPoints); err != nil {
return err
}
if err := tx.Account.Save(ctx, account); err != nil {
return err
}
result = currentAdventureGrowthResult{
PreviousLevel: previousLevel,
Character: character,
Skill: skill,
}
return nil
})
if err != nil {
s.logGameEvent(session, "game-adventure-growth-capsule-rejected", "error", err)
return s.sendGameUpperFailure(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), currentAdventureGenericFailureCode)
}
if err := s.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketAdventureGrowthcapsuleExp), nil); err != nil {
return err
}
characterBody := buildCurrentFinishLoadingCharacterStateBody(result.Character, result.Skill.Points)
if err := s.sendGameUpperRawClass(session, currentDungeonCharacterStateMsgID, characterBody, 0); err != nil {
return err
}
if result.Character.Level != result.PreviousLevel {
if err := s.sendCurrentAcceptableQuestListForSession(
session,
"adventure_growth_capsule_after_atomic_level_change",
); err != nil {
return err
}
}
return s.sendCurrentAdventureInfoPushFromAccount(session, session.selectedCharacterID, "adventure_growth_capsule")
}