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

1193 lines
46 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"
"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
}