服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1193 lines
46 KiB
Go
1193 lines
46 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
|
||
dnfenum "longheng.io/server/internal/modules/dnf/dnfenum"
|
||
dnfdungeonpermission "longheng.io/server/internal/modules/dnf/dungeonpermission"
|
||
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
|
||
dnfpremium "longheng.io/server/internal/modules/dnf/premium"
|
||
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
|
||
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
type GMWalletRequest struct {
|
||
CharacterID string `json:"character_id,omitempty"`
|
||
CharacterName string `json:"character_name,omitempty"`
|
||
Currency string `json:"currency,omitempty"`
|
||
Mode string `json:"mode,omitempty"`
|
||
Amount int64 `json:"amount,omitempty"`
|
||
}
|
||
|
||
type GMWalletResult struct {
|
||
Character GMCharacterInfo `json:"character"`
|
||
Currency string `json:"currency,omitempty"`
|
||
Mode string `json:"mode,omitempty"`
|
||
Before int64 `json:"before,omitempty"`
|
||
After int64 `json:"after,omitempty"`
|
||
Cera int64 `json:"cera"`
|
||
TokenCera int64 `json:"token_cera"`
|
||
HappyTokenCera int64 `json:"happy_token_cera"`
|
||
Gold int64 `json:"gold"`
|
||
Online bool `json:"online"`
|
||
Refreshed bool `json:"refreshed"`
|
||
RefreshError string `json:"refresh_error,omitempty"`
|
||
}
|
||
|
||
type GMHappyTokenCeraRepairResult struct {
|
||
Character GMCharacterInfo `json:"character"`
|
||
// Granted 是本次补发的**欢乐代币券**数量(保持原有语义不变:既有调用方与
|
||
// 用例都按这个口径读)。
|
||
Granted int64 `json:"granted"`
|
||
// TokenCeraGranted 是本次补发的**代币券**数量(2681762)。
|
||
TokenCeraGranted int64 `json:"token_cera_granted"`
|
||
Cera int64 `json:"cera"`
|
||
TokenCera int64 `json:"token_cera"`
|
||
HappyTokenCera int64 `json:"happy_token_cera"`
|
||
Online bool `json:"online"`
|
||
Refreshed bool `json:"refreshed"`
|
||
RefreshError string `json:"refresh_error,omitempty"`
|
||
}
|
||
|
||
type GMCharacterSkillInfo struct {
|
||
CharacterID string `json:"character_id"`
|
||
Name string `json:"name"`
|
||
Level int `json:"level"`
|
||
TotalSP int `json:"total_sp"`
|
||
RemainingSP int `json:"remaining_sp"`
|
||
TotalTP int `json:"total_tp"`
|
||
RemainingTP int `json:"remaining_tp"`
|
||
}
|
||
|
||
type GMCharacterSkillUpdate struct {
|
||
CharacterID string `json:"character_id"`
|
||
RemainingSP *int `json:"remaining_sp,omitempty"`
|
||
RemainingTP *int `json:"remaining_tp,omitempty"`
|
||
// ExtendTotal authorizes this loopback-admin request to permanently raise
|
||
// the character's SP/TP ceiling when a requested remaining value is above
|
||
// the current earned total. The delta is persisted as bonus_sp/bonus_tp so
|
||
// later level, scene and skill-tree ledger repairs retain the GM grant.
|
||
ExtendTotal bool `json:"extend_total,omitempty"`
|
||
}
|
||
|
||
type GMNativeRefreshResult struct {
|
||
Online bool `json:"online"`
|
||
Refreshed bool `json:"refreshed"`
|
||
RefreshError string `json:"refresh_error,omitempty"`
|
||
}
|
||
|
||
type GMCharacterSkillUpdateResult struct {
|
||
GMNativeRefreshResult
|
||
CharacterID string `json:"character_id"`
|
||
ActiveTree int `json:"active_tree"`
|
||
TotalSP int `json:"total_sp"`
|
||
RemainingSP int `json:"remaining_sp"`
|
||
AddedSP int `json:"added_sp,omitempty"`
|
||
TotalTP int `json:"total_tp"`
|
||
RemainingTP int `json:"remaining_tp"`
|
||
AddedTP int `json:"added_tp,omitempty"`
|
||
}
|
||
|
||
type GMCharacterLevelResult struct {
|
||
GMNativeRefreshResult
|
||
CharacterID string `json:"character_id"`
|
||
PreviousLevel int `json:"previous_level"`
|
||
Level int `json:"level"`
|
||
}
|
||
|
||
type GMCharacterJobInfo struct {
|
||
CharacterID string `json:"character_id"`
|
||
Job int `json:"job"`
|
||
FirstGrow int `json:"first_grow"`
|
||
SecondGrow int `json:"second_grow"`
|
||
}
|
||
|
||
type GMCharacterJobUpdate struct {
|
||
CharacterID string `json:"character_id"`
|
||
Job int `json:"job"`
|
||
FirstGrow int `json:"first_grow"`
|
||
SecondGrow int `json:"second_grow"`
|
||
}
|
||
|
||
type GMCharacterJobUpdateResult struct {
|
||
GMNativeRefreshResult
|
||
CharacterID string `json:"character_id"`
|
||
Job int `json:"job"`
|
||
PreviousGrowType int `json:"previous_grow_type"`
|
||
GrowType int `json:"grow_type"`
|
||
TransitionCount int `json:"transition_count"`
|
||
// QuestResetIDs lists the class-change/awakening quest rows removed when
|
||
// the character was returned to its base profession.
|
||
QuestResetIDs []int64 `json:"quest_reset_ids,omitempty"`
|
||
}
|
||
|
||
type GMDungeonUnlockRequest struct {
|
||
CharacterID string `json:"character_id"`
|
||
Dungeons map[string]int `json:"dungeons"`
|
||
}
|
||
|
||
type GMDungeonUnlockResult struct {
|
||
GMNativeRefreshResult
|
||
CharacterID string `json:"character_id"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
type GMEquipSlotUnlockResult struct {
|
||
GMNativeRefreshResult
|
||
CharacterID string `json:"character_id"`
|
||
Before int64 `json:"before"`
|
||
After int64 `json:"after"`
|
||
}
|
||
|
||
type GMBlackDiamondRequest struct {
|
||
CharacterID string `json:"character_id"`
|
||
Days int `json:"days,omitempty"`
|
||
}
|
||
|
||
type GMBlackDiamondResult struct {
|
||
GMNativeRefreshResult
|
||
CharacterID string `json:"character_id"`
|
||
AccountID string `json:"account_id"`
|
||
Days int `json:"days"`
|
||
ExpiresAt int64 `json:"expires_at"`
|
||
RemainingSeconds int64 `json:"remaining_seconds"`
|
||
Fatigue int64 `json:"fatigue"`
|
||
FatigueLimit int64 `json:"fatigue_limit"`
|
||
Deferred bool `json:"deferred,omitempty"`
|
||
}
|
||
|
||
// GMBlackDiamondQuery 黑钻只读查询:按角色 ID 或角色名定位账号,不修改任何数据。
|
||
type GMBlackDiamondQuery struct {
|
||
CharacterID string `json:"character_id,omitempty"`
|
||
CharacterName string `json:"character_name,omitempty"`
|
||
}
|
||
|
||
// GMBlackDiamondStatus 黑钻当前状态快照(只读)。
|
||
//
|
||
// ExpiresAt/RemainingSeconds 直接来自账号级权益 premium_expire_1000,
|
||
// 与登录选人包、在线刷新读的是同一份数据;ServerNow 是服务端当前时间,
|
||
// 工具端据此换算剩余天数,不受客户端/运维机时钟差影响。
|
||
type GMBlackDiamondStatus struct {
|
||
CharacterID string `json:"character_id"`
|
||
CharacterName string `json:"character_name"`
|
||
AccountID string `json:"account_id"`
|
||
Active bool `json:"active"`
|
||
ExpiresAt int64 `json:"expires_at"`
|
||
RemainingSeconds int64 `json:"remaining_seconds"`
|
||
RemainingDays int64 `json:"remaining_days"`
|
||
Fatigue int64 `json:"fatigue"`
|
||
FatigueLimit int64 `json:"fatigue_limit"`
|
||
// BlackDiamondFatigueLimit 黑钻生效时的疲劳上限(当前 EXE 为 188),
|
||
// 供工具端识别"权益已生效但疲劳上限还没被刷新"这类中间态。
|
||
BlackDiamondFatigueLimit int64 `json:"black_diamond_fatigue_limit"`
|
||
Online bool `json:"online"`
|
||
ServerNow int64 `json:"server_now"`
|
||
}
|
||
|
||
const gmAllEquipSlotsMask int64 = int64(dnfquest.ExEquipSlotAll)
|
||
|
||
func (s *Service) gmCharacter(ctx context.Context, repositories dnfrepo.Group, characterID, characterName string) (dnfrepo.CharacterRecord, error) {
|
||
if repositories.Character == nil {
|
||
return dnfrepo.CharacterRecord{}, ErrGMRepositoryUnavailable
|
||
}
|
||
characterID = strings.TrimSpace(characterID)
|
||
characterName = strings.TrimSpace(characterName)
|
||
if characterID == "" {
|
||
var found bool
|
||
var err error
|
||
characterID, found, err = repositories.Character.FindIDByName(ctx, characterName)
|
||
if err != nil {
|
||
return dnfrepo.CharacterRecord{}, err
|
||
}
|
||
if !found {
|
||
return dnfrepo.CharacterRecord{}, ErrGMCharacterNotFound
|
||
}
|
||
}
|
||
character, found, err := repositories.Character.Load(ctx, characterID)
|
||
if err != nil {
|
||
return dnfrepo.CharacterRecord{}, err
|
||
}
|
||
// The loopback, token-authenticated GM API manages every character in the
|
||
// active repository. The configured account ID is the gameplay bootstrap
|
||
// owner, not an authorization boundary for launcher accounts stored beside
|
||
// it in MySQL or SQLite.
|
||
if !found || character.Stats["delete_flag"] != 0 || strings.TrimSpace(character.AccountID) == "" ||
|
||
(characterName != "" && strings.TrimSpace(character.Name) != characterName) {
|
||
return dnfrepo.CharacterRecord{}, ErrGMCharacterNotFound
|
||
}
|
||
return character, nil
|
||
}
|
||
|
||
func (s *Service) gmCharacterInfo(ctx context.Context, repositories dnfrepo.Group, character dnfrepo.CharacterRecord) (GMCharacterInfo, error) {
|
||
cera := int64(0)
|
||
if repositories.Account != nil {
|
||
account, found, err := repositories.Account.Load(ctx, character.AccountID)
|
||
if err != nil {
|
||
return GMCharacterInfo{}, err
|
||
}
|
||
if found {
|
||
cera = currentAccountCera(account)
|
||
}
|
||
}
|
||
numericID, _ := strconv.ParseUint(character.CharacterID, 10, 16)
|
||
_, online := s.onlineGameSession(uint16(numericID))
|
||
return GMCharacterInfo{
|
||
CharacterID: character.CharacterID,
|
||
AccountID: character.AccountID,
|
||
Name: character.Name,
|
||
Level: character.Level,
|
||
Gold: character.Stats["gold"],
|
||
Cera: cera,
|
||
Online: online,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) GMReadWallet(ctx context.Context, request GMWalletRequest) (GMWalletResult, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available {
|
||
return GMWalletResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, request.CharacterID, request.CharacterName)
|
||
if err != nil {
|
||
return GMWalletResult{}, err
|
||
}
|
||
info, err := s.gmCharacterInfo(ctx, repositories, character)
|
||
if err != nil {
|
||
return GMWalletResult{}, err
|
||
}
|
||
wallet := s.loadAccountWallet(ctx, repositories, character.AccountID)
|
||
return GMWalletResult{
|
||
Character: info,
|
||
Cera: wallet.Cera,
|
||
TokenCera: wallet.TokenCera,
|
||
HappyTokenCera: wallet.HappyTokenCera,
|
||
Gold: info.Gold,
|
||
Online: info.Online,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) GMUpdateWallet(ctx context.Context, request GMWalletRequest) (GMWalletResult, error) {
|
||
request.Currency = strings.ToLower(strings.TrimSpace(request.Currency))
|
||
request.Mode = strings.ToLower(strings.TrimSpace(request.Mode))
|
||
// 钱包三种货币(点券 / 代币券 / 欢乐代币券)+ 金币。
|
||
// 后两种是本轮新加的:没有它们就没法在真机上造"点券为 0、只有代币券"这类
|
||
// 测试条件,也就没法验证支付瀑布与券入账。
|
||
walletCurrency := dnfcerashop.WalletCurrency(request.Currency)
|
||
isWalletCurrency := walletCurrency.Known()
|
||
if (!isWalletCurrency && request.Currency != "gold") ||
|
||
(request.Mode != "set" && request.Mode != "add") ||
|
||
request.Amount < 0 || request.Amount > math.MaxInt32 {
|
||
return GMWalletResult{}, ErrGMRequestInvalid
|
||
}
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.CharacterSettlement == nil || repositories.Account == nil {
|
||
return GMWalletResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
selected, err := s.gmCharacter(ctx, repositories, request.CharacterID, request.CharacterName)
|
||
if err != nil {
|
||
return GMWalletResult{}, err
|
||
}
|
||
result := GMWalletResult{Currency: request.Currency, Mode: request.Mode}
|
||
err = repositories.CharacterSettlement.WithinCharacterSettlement(ctx, selected.CharacterID, func(tx dnfrepo.Group) error {
|
||
character, err := s.gmCharacter(ctx, tx, selected.CharacterID, selected.Name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
account, found, err := tx.Account.Load(ctx, character.AccountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrGMRepositoryUnavailable
|
||
}
|
||
before := character.Stats["gold"]
|
||
if isWalletCurrency {
|
||
if current, ok := walletCurrency.BalanceOf(account); ok {
|
||
before = current
|
||
}
|
||
}
|
||
after := request.Amount
|
||
if request.Mode == "add" {
|
||
if before < 0 || before > math.MaxInt32 || request.Amount > math.MaxInt32-before {
|
||
return fmt.Errorf("%w: wallet balance exceeds client range", ErrGMRequestInvalid)
|
||
}
|
||
after = before + request.Amount
|
||
}
|
||
if isWalletCurrency {
|
||
metadataKey, _ := walletCurrency.MetadataKey()
|
||
if !walletCurrency.SetOn(&account, after) {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
if err := dnfrepo.SaveAccountMetadataEntry(ctx, tx.Account, account, metadataKey, strconv.FormatInt(after, 10), time.Now().UTC()); err != nil {
|
||
return err
|
||
}
|
||
} else {
|
||
if character.Stats == nil {
|
||
character.Stats = make(map[string]int64)
|
||
}
|
||
character.Stats["gold"] = after
|
||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
result.Before, result.After = before, after
|
||
wallet := currentAccountWallet(account)
|
||
result.Cera, result.TokenCera, result.HappyTokenCera = wallet.Cera, wallet.TokenCera, wallet.HappyTokenCera
|
||
result.Gold = character.Stats["gold"]
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return GMWalletResult{}, err
|
||
}
|
||
updated, err := s.gmCharacter(ctx, repositories, selected.CharacterID, selected.Name)
|
||
if err != nil {
|
||
return GMWalletResult{}, err
|
||
}
|
||
result.Character, err = s.gmCharacterInfo(ctx, repositories, updated)
|
||
if err != nil {
|
||
return GMWalletResult{}, err
|
||
}
|
||
result.Cera, result.Gold = result.Character.Cera, result.Character.Gold
|
||
wallet := s.loadAccountWallet(ctx, repositories, selected.AccountID)
|
||
result.Cera, result.TokenCera, result.HappyTokenCera = wallet.Cera, wallet.TokenCera, wallet.HappyTokenCera
|
||
characterID, _ := strconv.ParseUint(selected.CharacterID, 10, 16)
|
||
session, online := s.onlineGameSession(uint16(characterID))
|
||
result.Online = online
|
||
if !online {
|
||
return result, nil
|
||
}
|
||
// 用独立 context 推送,**不能用请求 ctx**:钱此刻已经落库,
|
||
// 客户端断开(运维工具 8 秒超时)不该让这次刷新被事件循环丢弃。
|
||
// 详见 gmPostCommitPushContext 的说明 —— 泡点"点券不实时刷新"就是这个坑。
|
||
pushCtx, pushCancel := gmPostCommitPushContext()
|
||
defer pushCancel()
|
||
if err := s.callGameSession(pushCtx, session, "gm-admin-wallet-refresh", func() error {
|
||
if session.selectedCharacterID != uint16(characterID) {
|
||
return errors.New("selected character changed before wallet refresh")
|
||
}
|
||
// 三种钱包货币都推同一包(class0/op53 一次带三个数字);
|
||
// 只有金币才走金币体。早先这里判的是 `request.Currency == "cera"`,
|
||
// 于是 GM 改代币券/欢乐代币券时会掉到金币分支 —— 钱包已落库、客户端却不变,
|
||
// 而"GM 把代币券设成 10 万、商城还是 0"会被误读成支付瀑布坏了。
|
||
if isWalletCurrency {
|
||
return s.sendGameUpperRawClass(session, currentCeraShopUpdateMsgID, buildCurrentCeraShopWalletBody(result.Cera, result.TokenCera, result.HappyTokenCera), 0)
|
||
}
|
||
return s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), buildCurrentGoldStateBody(result.Gold), 0)
|
||
}); err != nil {
|
||
result.RefreshError = err.Error()
|
||
return result, nil
|
||
}
|
||
result.Refreshed = true
|
||
return result, nil
|
||
}
|
||
|
||
// GMRepairHappyTokenCeraVoucher migrates rows written by the old mailbox
|
||
// implementation, which incorrectly stored item 2681917 in the material bag.
|
||
// The removal and account-currency credit share one MySQL transaction or one
|
||
// SQLite mutation gate, so the voucher can never be duplicated or lost.
|
||
//
|
||
// 现在同时覆盖**代币券**(2681762):它以前完全没有代码引用,凡是把它当普通道具
|
||
// 发进背包的路径都会留下同样用不掉的"材料"行。判据换成共享的券识别
|
||
// (cerashop.VoucherKindOf),两种券各记各的账。
|
||
func (s *Service) GMRepairHappyTokenCeraVoucher(ctx context.Context, request GMWalletRequest) (GMHappyTokenCeraRepairResult, error) {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.MailboxAssets == nil || repositories.Account == nil || repositories.Inventory == nil {
|
||
return GMHappyTokenCeraRepairResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
selected, err := s.gmCharacter(ctx, repositories, request.CharacterID, request.CharacterName)
|
||
if err != nil {
|
||
return GMHappyTokenCeraRepairResult{}, err
|
||
}
|
||
var result GMHappyTokenCeraRepairResult
|
||
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, 1)
|
||
err = repositories.MailboxAssets.WithinMailboxAssets(ctx, selected.CharacterID, selected.CharacterID, func(
|
||
accounts dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
characters dnfrepo.CharacterRepository,
|
||
inventories dnfrepo.InventoryRepository,
|
||
_ dnfrepo.MailboxRepository,
|
||
) error {
|
||
character, err := s.gmCharacter(ctx, dnfrepo.Group{Character: characters}, selected.CharacterID, selected.Name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
account, found, err := accounts.Load(ctx, character.AccountID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrGMRepositoryUnavailable
|
||
}
|
||
inventory, inventoryFound, err := inventories.Load(ctx, character.CharacterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if inventoryFound {
|
||
inventory = dnfrepo.CloneInventory(inventory)
|
||
for key, stack := range inventory.Slots {
|
||
listType, slot, ok := parseSceneInventorySlotKey(key)
|
||
if !ok || listType != dnfrepo.MainInventoryListType || stack.Count <= 0 {
|
||
continue
|
||
}
|
||
voucher := dnfcerashop.VoucherKindOf(stack.ItemID)
|
||
if voucher == dnfcerashop.VoucherNone {
|
||
continue
|
||
}
|
||
if voucher == dnfcerashop.VoucherTokenCera {
|
||
if result.TokenCeraGranted > math.MaxInt32-stack.Count {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
result.TokenCeraGranted += stack.Count
|
||
} else {
|
||
if result.Granted > math.MaxInt32-stack.Count {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
result.Granted += stack.Count
|
||
}
|
||
delete(inventory.Slots, key)
|
||
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{ListType: listType, SlotIndex: slot})
|
||
}
|
||
}
|
||
wallet := currentAccountWallet(account)
|
||
if result.Granted > 0 || result.TokenCeraGranted > 0 {
|
||
// 全有或全无:任一项溢出就整笔拒绝,避免"代币券补发了、欢乐代币券没补"
|
||
// 这种半截状态(库存行已经删掉就没法回退了)。
|
||
updated, creditErr := dnfcerashop.CreditVoucherCounts(&account, dnfcerashop.VoucherCounts{
|
||
TokenCera: result.TokenCeraGranted,
|
||
HappyTokenCera: result.Granted,
|
||
})
|
||
if creditErr != nil {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
account.UpdatedAt = time.Now().UTC()
|
||
if err := accounts.Save(ctx, account); err != nil {
|
||
return err
|
||
}
|
||
inventory.UpdatedAt = account.UpdatedAt
|
||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||
return err
|
||
}
|
||
wallet = updated
|
||
}
|
||
result.Cera = wallet.Cera
|
||
result.TokenCera = wallet.TokenCera
|
||
result.HappyTokenCera = wallet.HappyTokenCera
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return GMHappyTokenCeraRepairResult{}, err
|
||
}
|
||
result.Character, err = s.gmCharacterInfo(ctx, repositories, selected)
|
||
if err != nil {
|
||
return GMHappyTokenCeraRepairResult{}, err
|
||
}
|
||
sort.Slice(refreshes, func(left, right int) bool { return refreshes[left].SlotIndex < refreshes[right].SlotIndex })
|
||
characterID, _ := strconv.ParseUint(selected.CharacterID, 10, 16)
|
||
session, online := s.onlineGameSession(uint16(characterID))
|
||
result.Online = online
|
||
// 两种券**任一种**有补发就要刷新:`Granted` 只统计欢乐代币券(保持既有语义),
|
||
// 只补代币券时它是 0。早先只判 `Granted == 0`,于是"背包里只有代币券行"这种
|
||
// 情况会整段跳过刷新 —— 行已删、钱包已入账,客户端两样都看不到,还会点那行幽灵券。
|
||
if !online || (result.Granted == 0 && result.TokenCeraGranted == 0) {
|
||
return result, nil
|
||
}
|
||
// 补发已经落库(行已删、钱包已入账),推送用独立 context,避免客户端断开把刷新丢掉。
|
||
pushCtx, pushCancel := gmPostCommitPushContext()
|
||
defer pushCancel()
|
||
if err := s.callGameSession(pushCtx, session, "gm-admin-happy-token-cera-repair", func() error {
|
||
if session.selectedCharacterID != uint16(characterID) {
|
||
return errors.New("selected character changed before happy-token Cera repair refresh")
|
||
}
|
||
if err := s.sendSelectedIncrementalItemSlotRefreshes(session, "gm_happy_token_cera_repair", refreshes); err != nil {
|
||
return err
|
||
}
|
||
return s.sendGameUpperRawClass(session, currentCeraShopUpdateMsgID, buildCurrentCeraShopWalletBody(result.Cera, result.TokenCera, result.HappyTokenCera), 0)
|
||
}); err != nil {
|
||
result.RefreshError = err.Error()
|
||
return result, nil
|
||
}
|
||
result.Refreshed = true
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) GMReadCharacterSkill(ctx context.Context, characterID string) (GMCharacterSkillInfo, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.Skill == nil {
|
||
return GMCharacterSkillInfo{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, characterID, "")
|
||
if err != nil {
|
||
return GMCharacterSkillInfo{}, err
|
||
}
|
||
skill, _, err := repositories.Skill.Load(ctx, character.CharacterID)
|
||
if err != nil {
|
||
return GMCharacterSkillInfo{}, err
|
||
}
|
||
points := skill.Points
|
||
if activeTree, treeErr := currentSceneActiveSkillTree(character); treeErr == nil {
|
||
if state, found := dnfrepo.SkillTree(skill, int(activeTree)); found {
|
||
points = state.Points
|
||
}
|
||
}
|
||
return GMCharacterSkillInfo{
|
||
CharacterID: character.CharacterID, Name: character.Name, Level: character.Level,
|
||
TotalSP: points.TotalSP, RemainingSP: points.RemainingSP,
|
||
TotalTP: points.TotalTP, RemainingTP: points.RemainingTP,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) GMUpdateCharacterSkill(ctx context.Context, request GMCharacterSkillUpdate) (GMCharacterSkillUpdateResult, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.CharacterProgression == nil {
|
||
return GMCharacterSkillUpdateResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, request.CharacterID, "")
|
||
if err != nil {
|
||
return GMCharacterSkillUpdateResult{}, err
|
||
}
|
||
if request.RemainingSP == nil && request.RemainingTP == nil {
|
||
return GMCharacterSkillUpdateResult{}, fmt.Errorf("%w: remaining_sp or remaining_tp is required", ErrGMRequestInvalid)
|
||
}
|
||
if request.RemainingSP != nil && (*request.RemainingSP < 0 || *request.RemainingSP > math.MaxUint16) {
|
||
return GMCharacterSkillUpdateResult{}, fmt.Errorf("%w: remaining_sp=%d is outside 0..%d", ErrGMRequestInvalid, *request.RemainingSP, uint64(math.MaxUint16))
|
||
}
|
||
if request.RemainingTP != nil && (*request.RemainingTP < 0 || *request.RemainingTP > math.MaxUint16) {
|
||
return GMCharacterSkillUpdateResult{}, fmt.Errorf("%w: remaining_tp=%d is outside 0..%d", ErrGMRequestInvalid, *request.RemainingTP, uint64(math.MaxUint16))
|
||
}
|
||
result := GMCharacterSkillUpdateResult{CharacterID: character.CharacterID}
|
||
err = repositories.CharacterProgression.WithinCharacterProgression(ctx, character.CharacterID, func(characters dnfrepo.CharacterRepository, skills dnfrepo.SkillRepository) error {
|
||
current, found, err := characters.Load(ctx, character.CharacterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
return ErrGMCharacterNotFound
|
||
}
|
||
activeTree, err := currentSceneActiveSkillTree(current)
|
||
if err != nil {
|
||
return fmt.Errorf("%w: %v", ErrGMRequestInvalid, err)
|
||
}
|
||
result.ActiveTree = int(activeTree)
|
||
record, found, err := skills.Load(ctx, character.CharacterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
record.CharacterID = character.CharacterID
|
||
}
|
||
state, treeFound := dnfrepo.SkillTree(record, int(activeTree))
|
||
if !treeFound {
|
||
if activeTree != 0 {
|
||
return fmt.Errorf("%w: active skill tree %d is missing", ErrGMRequestInvalid, activeTree)
|
||
}
|
||
state = dnfrepo.SkillTreeState{Skills: record.Skills, Points: record.Points}
|
||
}
|
||
addedSP, addedTP := 0, 0
|
||
if request.RemainingSP != nil {
|
||
if found && *request.RemainingSP > state.Points.TotalSP {
|
||
if !request.ExtendTotal {
|
||
return fmt.Errorf("%w: remaining_sp=%d exceeds total_sp=%d; set extend_total to grant the difference", ErrGMRequestInvalid, *request.RemainingSP, state.Points.TotalSP)
|
||
}
|
||
addedSP = *request.RemainingSP - state.Points.TotalSP
|
||
}
|
||
if !found {
|
||
state.Points.TotalSP = *request.RemainingSP
|
||
}
|
||
state.Points.RemainingSP = *request.RemainingSP
|
||
}
|
||
if request.RemainingTP != nil {
|
||
if found && *request.RemainingTP > state.Points.TotalTP {
|
||
if !request.ExtendTotal {
|
||
return fmt.Errorf("%w: remaining_tp=%d exceeds total_tp=%d; set extend_total to grant the difference", ErrGMRequestInvalid, *request.RemainingTP, state.Points.TotalTP)
|
||
}
|
||
addedTP = *request.RemainingTP - state.Points.TotalTP
|
||
}
|
||
if !found {
|
||
state.Points.TotalTP = *request.RemainingTP
|
||
}
|
||
}
|
||
if addedSP > 0 || addedTP > 0 {
|
||
if err := extendGMSkillPointTotals(&record, addedSP, addedTP); err != nil {
|
||
return err
|
||
}
|
||
current = dnfrepo.CloneCharacter(current)
|
||
if current.Stats == nil {
|
||
current.Stats = make(map[string]int64)
|
||
}
|
||
bonusSP := current.Stats["bonus_sp"]
|
||
bonusTP := current.Stats["bonus_tp"]
|
||
if bonusSP < 0 || bonusTP < 0 || bonusSP > int64(math.MaxUint16-addedSP) || bonusTP > int64(math.MaxUint16-addedTP) {
|
||
return fmt.Errorf("%w: persisted bonus SP/TP cannot represent added_sp=%d added_tp=%d", ErrGMRequestInvalid, addedSP, addedTP)
|
||
}
|
||
current.Stats["bonus_sp"] = bonusSP + int64(addedSP)
|
||
current.Stats["bonus_tp"] = bonusTP + int64(addedTP)
|
||
if err := dnfrepo.SaveCharacterFields(ctx, characters, current, dnfrepo.CharacterFieldStats); err != nil {
|
||
return err
|
||
}
|
||
state, treeFound = dnfrepo.SkillTree(record, int(activeTree))
|
||
if !treeFound && activeTree == 0 {
|
||
state = dnfrepo.SkillTreeState{Skills: record.Skills, Points: record.Points}
|
||
}
|
||
}
|
||
if request.RemainingSP != nil {
|
||
state.Points.RemainingSP = *request.RemainingSP
|
||
}
|
||
if request.RemainingTP != nil {
|
||
state.Points.RemainingTP = *request.RemainingTP
|
||
}
|
||
dnfrepo.SetSkillTree(&record, int(activeTree), state)
|
||
result.TotalSP = state.Points.TotalSP
|
||
result.RemainingSP = state.Points.RemainingSP
|
||
result.AddedSP = addedSP
|
||
result.TotalTP = state.Points.TotalTP
|
||
result.RemainingTP = state.Points.RemainingTP
|
||
result.AddedTP = addedTP
|
||
return dnfrepo.SaveSkillFields(ctx, skills, record, dnfrepo.SkillFieldPoints, dnfrepo.SkillFieldTrees)
|
||
})
|
||
if err != nil {
|
||
return GMCharacterSkillUpdateResult{}, err
|
||
}
|
||
result.GMNativeRefreshResult = s.gmRefreshOnlineCharacter(character.CharacterID, "gm-admin-sptp", func(session *gameSession) error {
|
||
return s.sendSelectedActorCurrentSceneSkillInfo(session, "gm_admin_sptp_after_atomic_commit")
|
||
})
|
||
return result, nil
|
||
}
|
||
|
||
func extendGMSkillPointTotals(record *dnfrepo.SkillRecord, addedSP, addedTP int) error {
|
||
if record == nil || addedSP < 0 || addedTP < 0 {
|
||
return fmt.Errorf("%w: invalid SP/TP total extension", ErrGMRequestInvalid)
|
||
}
|
||
if err := extendGMSkillPointLedger(&record.Points, addedSP, addedTP); err != nil {
|
||
return err
|
||
}
|
||
for treeIndex, tree := range record.Trees {
|
||
if err := extendGMSkillPointLedger(&tree.Points, addedSP, addedTP); err != nil {
|
||
return fmt.Errorf("%w: tree=%d", err, treeIndex)
|
||
}
|
||
record.Trees[treeIndex] = tree
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func extendGMSkillPointLedger(points *dnfrepo.SkillPointState, addedSP, addedTP int) error {
|
||
if points == nil || points.TotalSP < 0 || points.RemainingSP < 0 || points.TotalTP < 0 || points.RemainingTP < 0 ||
|
||
points.RemainingSP > points.TotalSP || points.RemainingTP > points.TotalTP ||
|
||
points.TotalSP > math.MaxUint16-addedSP || points.RemainingSP > math.MaxUint16-addedSP ||
|
||
points.TotalTP > math.MaxUint16-addedTP || points.RemainingTP > math.MaxUint16-addedTP {
|
||
return fmt.Errorf("%w: SP/TP ledger cannot be extended by sp=%d tp=%d", ErrGMRequestInvalid, addedSP, addedTP)
|
||
}
|
||
points.TotalSP += addedSP
|
||
points.RemainingSP += addedSP
|
||
points.TotalTP += addedTP
|
||
points.RemainingTP += addedTP
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) GMSetCharacterLevel(ctx context.Context, characterID string, level int) (GMCharacterLevelResult, error) {
|
||
if level < 1 || level > currentAdventureCharacterLevelCap {
|
||
return GMCharacterLevelResult{}, ErrGMRequestInvalid
|
||
}
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.CharacterProgression == nil {
|
||
return GMCharacterLevelResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, characterID, "")
|
||
if err != nil {
|
||
return GMCharacterLevelResult{}, err
|
||
}
|
||
targetExperience, err := s.currentPVFLevelEntryExperience(ctx, level)
|
||
if err != nil {
|
||
return GMCharacterLevelResult{}, fmt.Errorf("resolve PVF entry experience for level %d: %w", level, err)
|
||
}
|
||
result := GMCharacterLevelResult{CharacterID: character.CharacterID, PreviousLevel: character.Level, Level: level}
|
||
err = repositories.CharacterProgression.WithinCharacterProgression(ctx, character.CharacterID, func(characters dnfrepo.CharacterRepository, skills dnfrepo.SkillRepository) error {
|
||
current, err := s.gmCharacter(ctx, dnfrepo.Group{Character: characters}, character.CharacterID, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result.PreviousLevel = current.Level
|
||
if level < current.Level {
|
||
return fmt.Errorf("%w: level regression has no in-game progression transition", ErrGMRequestInvalid)
|
||
}
|
||
current.Level = level
|
||
if current.Stats == nil {
|
||
current.Stats = make(map[string]int64)
|
||
}
|
||
// Stats["exp"] is cumulative. Writing zero for a level above one makes
|
||
// the current client subtract that level's PVF entry threshold and show
|
||
// a negative value. Start the target level at its exact PVF baseline.
|
||
current.Stats["exp"] = int64(targetExperience)
|
||
// 等级跳跃必须同步剧情摘要基线。客户端只在收到 class0/op1378 时更新
|
||
// 内部 last_level;这里不改持久值,玩家下一次进副本就会因为
|
||
// 「客户端旧 last_level 所在段 != 当前等级所在段」而自动补播章节动画。
|
||
current.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] = int64(level)
|
||
current.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] =
|
||
int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion)
|
||
target, err := s.currentPVFSkillPointTarget(ctx, current)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
skill, found, err := skills.Load(ctx, character.CharacterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
skill = dnfrepo.SkillRecord{CharacterID: character.CharacterID, Points: target}
|
||
} else {
|
||
for tree := 0; tree < currentSkillInfoTreeCount; tree++ {
|
||
state, treeFound := dnfrepo.SkillTree(skill, tree)
|
||
if !treeFound {
|
||
continue
|
||
}
|
||
next, _, syncErr := syncCurrentSkillPointState(state.Points, target)
|
||
if syncErr != nil {
|
||
return syncErr
|
||
}
|
||
state.Points = next
|
||
dnfrepo.SetSkillTree(&skill, tree, state)
|
||
}
|
||
}
|
||
if err := dnfrepo.SaveSkillFields(ctx, skills, skill, dnfrepo.SkillFieldPoints, dnfrepo.SkillFieldTrees); err != nil {
|
||
return err
|
||
}
|
||
return dnfrepo.SaveCharacterFields(ctx, characters, current, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats)
|
||
})
|
||
if err != nil {
|
||
return GMCharacterLevelResult{}, err
|
||
}
|
||
result.GMNativeRefreshResult = s.gmRefreshOnlineCharacter(character.CharacterID, "gm-admin-level", func(session *gameSession) error {
|
||
return s.sendGMLevelPostCommitState(session)
|
||
})
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) GMReadCharacterJob(ctx context.Context, characterID string) (GMCharacterJobInfo, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available {
|
||
return GMCharacterJobInfo{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, characterID, "")
|
||
if err != nil {
|
||
return GMCharacterJobInfo{}, err
|
||
}
|
||
job, err := strconv.Atoi(strings.TrimSpace(character.Job))
|
||
if err != nil {
|
||
job = int(character.Stats["job"])
|
||
}
|
||
grow := int(character.Stats["grow_type"])
|
||
return GMCharacterJobInfo{CharacterID: character.CharacterID, Job: job, FirstGrow: grow & 0x0f, SecondGrow: (grow >> 4) & 0x0f}, nil
|
||
}
|
||
|
||
func (s *Service) GMSetCharacterJob(ctx context.Context, request GMCharacterJobUpdate) (GMCharacterJobUpdateResult, error) {
|
||
if request.Job < 0 || request.Job > 15 || request.FirstGrow < 0 || request.FirstGrow > 15 || request.SecondGrow < 0 || request.SecondGrow > 2 {
|
||
return GMCharacterJobUpdateResult{}, ErrGMRequestInvalid
|
||
}
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.CharacterSettlement == nil {
|
||
return GMCharacterJobUpdateResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, request.CharacterID, "")
|
||
if err != nil {
|
||
return GMCharacterJobUpdateResult{}, err
|
||
}
|
||
if s.gmOnlineCharacterInDungeon(character.CharacterID) {
|
||
return GMCharacterJobUpdateResult{}, fmt.Errorf("%w: profession transition is only valid while the online character is in town", ErrGMRequestInvalid)
|
||
}
|
||
profiles, skillCatalog, err := s.currentProfessionResources(ctx)
|
||
if err != nil {
|
||
return GMCharacterJobUpdateResult{}, err
|
||
}
|
||
// Returning to the base profession must also retire the persisted
|
||
// class-change/awakening quest rows; otherwise the profession chain cannot
|
||
// be accepted again. Resolve the catalog before the transaction so a PVF
|
||
// failure aborts the request instead of committing a half-reset state.
|
||
var questCatalog *dnfquest.Catalog
|
||
if request.FirstGrow == 0 && request.SecondGrow == 0 {
|
||
questCatalog, err = s.loadQuestCatalog(ctx)
|
||
if err != nil {
|
||
return GMCharacterJobUpdateResult{}, err
|
||
}
|
||
}
|
||
result := GMCharacterJobUpdateResult{CharacterID: character.CharacterID, Job: request.Job}
|
||
err = repositories.CharacterSettlement.WithinCharacterSettlement(ctx, character.CharacterID, func(tx dnfrepo.Group) error {
|
||
current, err := s.gmCharacter(ctx, tx, character.CharacterID, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
currentJob, err := strconv.Atoi(strings.TrimSpace(current.Job))
|
||
if err != nil {
|
||
currentJob = int(current.Stats["job"])
|
||
}
|
||
// A base-job conversion has no equivalent in-game profession quest or
|
||
// current-client completion packet. Refuse it instead of fabricating a
|
||
// grow-type ACK that would leave the actor and skill manager divergent.
|
||
if currentJob != request.Job {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
if current.Stats == nil {
|
||
current.Stats = make(map[string]int64)
|
||
}
|
||
currentGrowValue := current.Stats["grow_type"]
|
||
if currentGrowValue < 0 || currentGrowValue > math.MaxUint8 {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
targetGrow, err := dnfprofession.Encode(byte(request.FirstGrow), byte(request.SecondGrow))
|
||
if err != nil {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
result.PreviousGrowType = int(currentGrowValue)
|
||
result.GrowType = int(targetGrow)
|
||
currentFirst, currentAwakening := dnfprofession.Decode(byte(currentGrowValue))
|
||
targetFirst, targetAwakening := dnfprofession.Decode(targetGrow)
|
||
administrativeRebase := byte(currentGrowValue) != targetGrow &&
|
||
(currentFirst != targetFirst || targetAwakening < currentAwakening)
|
||
var transitions []dnfprofession.Transition
|
||
if administrativeRebase {
|
||
// Validate the complete target path from the base profession. The
|
||
// rebuild below intentionally does not relax ordinary quest
|
||
// transition rules; this is the explicit GM-only branch-rebase path.
|
||
if _, err := gmProfessionTransitions(profiles, byte(request.Job), 0, targetGrow); err != nil {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
} else {
|
||
transitions, err = gmProfessionTransitions(profiles, byte(request.Job), byte(currentGrowValue), targetGrow)
|
||
if err != nil {
|
||
return ErrGMRequestInvalid
|
||
}
|
||
}
|
||
if !administrativeRebase && len(transitions) == 0 {
|
||
return nil
|
||
}
|
||
skill, found, err := tx.Skill.Load(ctx, character.CharacterID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !administrativeRebase && (!found || len(skill.Skills) == 0) {
|
||
return fmt.Errorf("GM profession transition requires an initialized skill record for character %s", character.CharacterID)
|
||
}
|
||
if !found {
|
||
skill = dnfrepo.SkillRecord{CharacterID: character.CharacterID}
|
||
}
|
||
pointTarget, err := s.currentPVFSkillPointTarget(ctx, current)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if administrativeRebase {
|
||
skill, err = profiles.RebuildSkillRecordForGrowType(
|
||
skillCatalog,
|
||
byte(request.Job),
|
||
current.Level,
|
||
targetGrow,
|
||
skill,
|
||
pointTarget,
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result.TransitionCount = 1
|
||
} else {
|
||
for tree := 0; tree < currentSkillInfoTreeCount; tree++ {
|
||
state, treeFound := dnfrepo.SkillTree(skill, tree)
|
||
if !treeFound {
|
||
continue
|
||
}
|
||
next, _, syncErr := syncCurrentSkillPointState(state.Points, pointTarget)
|
||
if syncErr != nil {
|
||
return syncErr
|
||
}
|
||
state.Points = next
|
||
dnfrepo.SetSkillTree(&skill, tree, state)
|
||
}
|
||
for _, transition := range transitions {
|
||
skill, err = profiles.ApplySkillTransition(
|
||
skillCatalog,
|
||
byte(request.Job),
|
||
current.Level,
|
||
transition,
|
||
skill,
|
||
skill.Points,
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
result.TransitionCount = len(transitions)
|
||
}
|
||
current.Stats["grow_type"] = int64(targetGrow)
|
||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, current, dnfrepo.CharacterFieldStats); err != nil {
|
||
return err
|
||
}
|
||
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, dnfrepo.AllSkillFields()...); err != nil {
|
||
return err
|
||
}
|
||
if questCatalog != nil && administrativeRebase && targetFirst == 0 && targetAwakening == 0 {
|
||
// Base-career reset: drop [job change quest] types 1..3 inside the
|
||
// same settlement transaction as the character and skill rebuild.
|
||
// Expert-job type 20 and ordinary quests remain untouched.
|
||
if tx.Quest == nil {
|
||
return ErrGMRepositoryUnavailable
|
||
}
|
||
questRecord, questFound, questErr := tx.Quest.Load(ctx, character.CharacterID)
|
||
if questErr != nil {
|
||
return questErr
|
||
}
|
||
if questFound {
|
||
resetPlan, planErr := questCatalog.PlanBaseProfessionQuestReset(questRecord, time.Now().UTC())
|
||
if planErr != nil {
|
||
return planErr
|
||
}
|
||
if len(resetPlan.ChangedFields) != 0 {
|
||
if saveErr := dnfrepo.SaveQuestFields(ctx, tx.Quest, resetPlan.Record, resetPlan.ChangedFields...); saveErr != nil {
|
||
return saveErr
|
||
}
|
||
}
|
||
result.QuestResetIDs = append([]int64(nil), resetPlan.ResetQuestIDs...)
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return GMCharacterJobUpdateResult{}, err
|
||
}
|
||
result.GMNativeRefreshResult = s.gmRefreshOnlineCharacter(character.CharacterID, "gm-admin-profession", func(session *gameSession) error {
|
||
return s.sendGMProfessionPostCommitState(session)
|
||
})
|
||
return result, nil
|
||
}
|
||
|
||
func gmProfessionTransitions(profiles *dnfprofession.Profiles, job byte, current, target byte) ([]dnfprofession.Transition, error) {
|
||
currentFirst, currentAwakening := dnfprofession.Decode(current)
|
||
targetFirst, targetAwakening := dnfprofession.Decode(target)
|
||
if targetFirst == 0 && targetAwakening != 0 {
|
||
return nil, dnfprofession.ErrTransitionInvalid
|
||
}
|
||
if currentFirst != 0 && currentFirst != targetFirst {
|
||
return nil, dnfprofession.ErrTransitionOutOfStep
|
||
}
|
||
if targetAwakening < currentAwakening {
|
||
return nil, dnfprofession.ErrTransitionOutOfStep
|
||
}
|
||
out := make([]dnfprofession.Transition, 0, 3)
|
||
working := current
|
||
if currentFirst == 0 && targetFirst != 0 {
|
||
transition, err := profiles.PlanTransition(job, working, dnfprofession.Request{
|
||
Kind: dnfprofession.KindClassChange, ChainType: 1, GrowNumber: targetFirst,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, transition)
|
||
working = transition.NewGrowType
|
||
_, currentAwakening = dnfprofession.Decode(working)
|
||
} else if currentFirst == 0 && targetFirst == 0 && targetAwakening == 0 {
|
||
return out, nil
|
||
}
|
||
for stage := currentAwakening + 1; stage <= targetAwakening; stage++ {
|
||
transition, err := profiles.PlanTransition(job, working, dnfprofession.Request{
|
||
Kind: dnfprofession.KindAwakening, ChainType: 2, GrowNumber: stage,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, transition)
|
||
working = transition.NewGrowType
|
||
}
|
||
if working != target {
|
||
return nil, dnfprofession.ErrTransitionOutOfStep
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *Service) GMUnlockDungeons(ctx context.Context, request GMDungeonUnlockRequest) (GMDungeonUnlockResult, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.DungeonPermission == nil {
|
||
return GMDungeonUnlockResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
if len(request.Dungeons) == 0 || len(request.Dungeons) > 4096 {
|
||
return GMDungeonUnlockResult{}, ErrGMRequestInvalid
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, request.CharacterID, "")
|
||
if err != nil {
|
||
return GMDungeonUnlockResult{}, err
|
||
}
|
||
ids := make([]int, 0, len(request.Dungeons))
|
||
states := make(map[int]byte, len(request.Dungeons))
|
||
for rawID, rawState := range request.Dungeons {
|
||
id, err := strconv.Atoi(strings.TrimSpace(rawID))
|
||
if err != nil || id <= 0 || uint64(id) > math.MaxUint32 || rawState < 0 || rawState > math.MaxUint8 {
|
||
return GMDungeonUnlockResult{}, ErrGMRequestInvalid
|
||
}
|
||
if rawState == 0 {
|
||
continue
|
||
}
|
||
ids = append(ids, id)
|
||
states[id] = byte(rawState)
|
||
}
|
||
sort.Ints(ids)
|
||
owner, err := dnfdungeonpermission.NewOwner(repositories)
|
||
if err != nil {
|
||
return GMDungeonUnlockResult{}, err
|
||
}
|
||
accountID := strings.TrimSpace(character.AccountID)
|
||
accountStates := make(map[uint32]byte, len(ids))
|
||
for _, id := range ids {
|
||
accountStates[uint32(id)] = states[id]
|
||
}
|
||
if _, _, err := owner.ApplyMax(ctx, accountID, accountStates); err != nil {
|
||
return GMDungeonUnlockResult{CharacterID: character.CharacterID}, err
|
||
}
|
||
result := GMDungeonUnlockResult{CharacterID: character.CharacterID, Count: len(ids)}
|
||
result.GMNativeRefreshResult = s.gmRefreshOnlineCharacter(character.CharacterID, "gm-admin-dungeon-permissions", func(session *gameSession) error {
|
||
return s.sendGMDungeonPermissionPostCommitState(ctx, session)
|
||
})
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) GMUnlockEquipSlots(ctx context.Context, characterID string) (GMEquipSlotUnlockResult, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.CharacterAssets == nil {
|
||
return GMEquipSlotUnlockResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, characterID, "")
|
||
if err != nil {
|
||
return GMEquipSlotUnlockResult{}, err
|
||
}
|
||
result := GMEquipSlotUnlockResult{CharacterID: character.CharacterID}
|
||
err = repositories.CharacterAssets.WithinCharacterAssets(ctx, character.CharacterID, func(characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
||
current, err := s.gmCharacter(ctx, dnfrepo.Group{Character: characters}, character.CharacterID, "")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if current.Stats == nil {
|
||
current.Stats = make(map[string]int64)
|
||
}
|
||
result.Before = current.Stats["ex_equip_slot_stat"]
|
||
result.After = result.Before | gmAllEquipSlotsMask
|
||
current.Stats["ex_equip_slot_stat"] = result.After
|
||
return dnfrepo.SaveCharacterFields(ctx, characters, current, dnfrepo.CharacterFieldStats)
|
||
})
|
||
if err != nil {
|
||
return GMEquipSlotUnlockResult{}, err
|
||
}
|
||
result.GMNativeRefreshResult = s.gmRefreshOnlineCharacter(character.CharacterID, "gm-admin-equip-slots", func(session *gameSession) error {
|
||
return s.sendGMEquipSlotPostCommitState(session)
|
||
})
|
||
return result, err
|
||
}
|
||
|
||
// GMBlackDiamondStatus 只读查询某个角色所属账号的黑钻现状。
|
||
//
|
||
// 只读语义:不落盘、不发包、不触发任何刷新,随便查多少次都不会改变游戏状态。
|
||
// 黑钻是账号级权益,所以任取该账号下的一个角色都能查到同一份到期时间。
|
||
func (s *Service) GMBlackDiamondStatus(ctx context.Context, query GMBlackDiamondQuery) (GMBlackDiamondStatus, error) {
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.Account == nil || repositories.Character == nil {
|
||
return GMBlackDiamondStatus{}, ErrGMRepositoryUnavailable
|
||
}
|
||
if strings.TrimSpace(query.CharacterID) == "" && strings.TrimSpace(query.CharacterName) == "" {
|
||
return GMBlackDiamondStatus{}, ErrGMRequestInvalid
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, query.CharacterID, query.CharacterName)
|
||
if err != nil {
|
||
return GMBlackDiamondStatus{}, err
|
||
}
|
||
account, found, err := repositories.Account.Load(ctx, character.AccountID)
|
||
if err != nil {
|
||
return GMBlackDiamondStatus{}, err
|
||
}
|
||
if !found {
|
||
return GMBlackDiamondStatus{}, errors.New("Black Diamond account was not found")
|
||
}
|
||
now := s.gameplayNow().UTC()
|
||
expiresAt := dnfpremium.BlackDiamondExpireAt(account)
|
||
remaining := expiresAt - now.Unix()
|
||
if remaining < 0 {
|
||
remaining = 0
|
||
}
|
||
status := GMBlackDiamondStatus{
|
||
CharacterID: character.CharacterID,
|
||
CharacterName: character.Name,
|
||
AccountID: character.AccountID,
|
||
Active: remaining > 0,
|
||
ExpiresAt: expiresAt,
|
||
RemainingSeconds: remaining,
|
||
RemainingDays: remaining / int64(24*time.Hour/time.Second),
|
||
Fatigue: character.Stats["fatigue"],
|
||
FatigueLimit: character.Stats["fatigue_limit"],
|
||
BlackDiamondFatigueLimit: dnffatigue.BlackDiamondLimit,
|
||
ServerNow: now.Unix(),
|
||
}
|
||
if numericID, parseErr := strconv.ParseUint(character.CharacterID, 10, 16); parseErr == nil && numericID > 0 {
|
||
_, status.Online = s.onlineGameSession(uint16(numericID))
|
||
}
|
||
return status, nil
|
||
}
|
||
|
||
func (s *Service) GMActivateBlackDiamond(ctx context.Context, request GMBlackDiamondRequest) (GMBlackDiamondResult, error) {
|
||
if request.Days == 0 {
|
||
request.Days = 30
|
||
}
|
||
if request.Days < 1 || request.Days > 3650 {
|
||
return GMBlackDiamondResult{}, ErrGMRequestInvalid
|
||
}
|
||
repositories, available := s.repositoryGroup()
|
||
if !available || repositories.RentalAssets == nil {
|
||
return GMBlackDiamondResult{}, ErrGMRepositoryUnavailable
|
||
}
|
||
character, err := s.gmCharacter(ctx, repositories, request.CharacterID, "")
|
||
if err != nil {
|
||
return GMBlackDiamondResult{}, err
|
||
}
|
||
activated, err := s.activateBlackDiamondForCharacter(
|
||
ctx,
|
||
character.AccountID,
|
||
character.CharacterID,
|
||
time.Duration(request.Days)*24*time.Hour,
|
||
)
|
||
if err != nil {
|
||
return GMBlackDiamondResult{}, err
|
||
}
|
||
remaining := activated.ExpiresAt.Unix() - s.gameplayNow().UTC().Unix()
|
||
if remaining < 0 {
|
||
remaining = 0
|
||
}
|
||
result := GMBlackDiamondResult{
|
||
CharacterID: character.CharacterID,
|
||
AccountID: character.AccountID,
|
||
Days: request.Days,
|
||
ExpiresAt: activated.ExpiresAt.Unix(),
|
||
RemainingSeconds: remaining,
|
||
Fatigue: activated.Character.Stats["fatigue"],
|
||
FatigueLimit: activated.Character.Stats["fatigue_limit"],
|
||
}
|
||
deferred := false
|
||
result.GMNativeRefreshResult = s.gmRefreshOnlineCharacter(character.CharacterID, "gm-admin-black-diamond", func(session *gameSession) error {
|
||
var sendErr error
|
||
deferred, sendErr = s.sendCurrentBlackDiamondActivationState(
|
||
ctx,
|
||
session,
|
||
activated.ExpiresAt,
|
||
"gm_admin_black_diamond",
|
||
)
|
||
return sendErr
|
||
})
|
||
if deferred && result.Online && result.RefreshError == "" {
|
||
result.Refreshed = false
|
||
result.Deferred = true
|
||
}
|
||
return result, nil
|
||
}
|