服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1182 lines
43 KiB
Go
1182 lines
43 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/adventuregroup"
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
dnfcerashop "longheng.io/server/internal/modules/dnf/cerashop"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// GMAccountDetail is the bounded account-management projection exposed to the
|
|
// local GM application. Metadata is deliberately projected as named fields so
|
|
// unrelated runtime keys are never leaked or accidentally overwritten.
|
|
type GMAccountDetail struct {
|
|
Account GMAccountSummary `json:"account"`
|
|
Cera int64 `json:"cera"`
|
|
HonorExperience uint64 `json:"honor_experience"`
|
|
HonorLevel int `json:"honor_level"`
|
|
HonorGrade int `json:"honor_grade"`
|
|
HonorMaximumExperience uint64 `json:"honor_maximum_experience"`
|
|
GrowthCapsuleExperience uint64 `json:"growth_capsule_experience"`
|
|
GrowthCapsuleMaximum uint64 `json:"growth_capsule_maximum"`
|
|
CargoGold int64 `json:"cargo_gold"`
|
|
CargoLevel int64 `json:"cargo_level"`
|
|
CargoCreated bool `json:"cargo_created"`
|
|
OnlineCharacters int `json:"online_characters"`
|
|
}
|
|
|
|
type GMCharacterDetail struct {
|
|
Character GMCharacterSummary `json:"character"`
|
|
Stats map[string]int64 `json:"stats"`
|
|
Skill dnfrepo.SkillPointState `json:"skill_points"`
|
|
BonusSP int64 `json:"bonus_sp"`
|
|
BonusTP int64 `json:"bonus_tp"`
|
|
}
|
|
|
|
type GMInventoryItem struct {
|
|
Container string `json:"container"`
|
|
Key string `json:"key"`
|
|
ListType int `json:"list_type"`
|
|
Slot int `json:"slot"`
|
|
ItemID int64 `json:"item_id"`
|
|
Name string `json:"name"`
|
|
Count int64 `json:"count"`
|
|
Bind bool `json:"bind"`
|
|
ExpireAt time.Time `json:"expire_at,omitempty"`
|
|
ReadOnly bool `json:"read_only"`
|
|
Extra map[string]string `json:"extra,omitempty"`
|
|
}
|
|
|
|
type GMInventorySnapshot struct {
|
|
Character GMCharacterSummary `json:"character"`
|
|
Items []GMInventoryItem `json:"items"`
|
|
}
|
|
|
|
type GMAccountCube struct {
|
|
Selection int `json:"selection"`
|
|
Slot int `json:"slot"`
|
|
ItemID int64 `json:"item_id"`
|
|
Name string `json:"name"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
|
|
type GMAccountCubeSnapshot struct {
|
|
Character GMCharacterSummary `json:"character"`
|
|
Cubes []GMAccountCube `json:"cubes"`
|
|
}
|
|
|
|
type GMItemQuery struct {
|
|
Query string
|
|
Kind string
|
|
Tag string
|
|
Segment string
|
|
Group string
|
|
Job string
|
|
MinLevel int64
|
|
MaxLevel int64
|
|
Rarity int64
|
|
Expiration string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
type GMItemCategoryCount struct {
|
|
Name string `json:"name"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type GMItemCategories struct {
|
|
Total int `json:"total"`
|
|
Equipment []GMItemCategoryCount `json:"equipment"`
|
|
Stackable []GMItemCategoryCount `json:"stackable"`
|
|
}
|
|
|
|
type GMItemPage struct {
|
|
Items []GMItemSummary `json:"items"`
|
|
Total int `json:"total"`
|
|
Limit int `json:"limit"`
|
|
Offset int `json:"offset"`
|
|
}
|
|
|
|
func (s *Service) GMAccount(ctx context.Context, accountID string) (GMAccountDetail, error) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Account == nil || repositories.Character == nil || accountID == "" {
|
|
return GMAccountDetail{}, ErrGMInvalidRequest
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, accountID)
|
|
if err != nil {
|
|
return GMAccountDetail{}, err
|
|
}
|
|
if !found {
|
|
return GMAccountDetail{}, ErrGMAccountNotFound
|
|
}
|
|
detail := GMAccountDetail{
|
|
Account: GMAccountSummary{
|
|
AccountID: account.AccountID, State: account.State,
|
|
RepresentAccountName: account.RepresentAccountName, UpdatedAt: account.UpdatedAt,
|
|
},
|
|
Cera: gmMetadataInt(account.Metadata, "account_cera"),
|
|
HonorExperience: account.HonorExp,
|
|
CargoGold: gmMetadataInt(account.Metadata, "account_cargo_gold"),
|
|
CargoLevel: gmMetadataInt(account.Metadata, "account_cargo_level"),
|
|
CargoCreated: gmMetadataBool(account.Metadata, "account_cargo_created"),
|
|
}
|
|
characters, err := repositories.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
|
|
if err != nil {
|
|
return GMAccountDetail{}, err
|
|
}
|
|
for _, character := range characters {
|
|
_, numericID, parseErr := gmCharacterID(character.CharacterID)
|
|
if parseErr == nil {
|
|
if _, online := s.onlineGameSession(numericID); online {
|
|
detail.OnlineCharacters++
|
|
}
|
|
}
|
|
}
|
|
if table, tableErr := s.loadHonorTable(ctx); tableErr == nil {
|
|
progress, resolveErr := table.Resolve(account.HonorExp)
|
|
if resolveErr != nil {
|
|
return GMAccountDetail{}, resolveErr
|
|
}
|
|
detail.HonorExperience = progress.TotalExperience
|
|
detail.HonorLevel = progress.Level
|
|
detail.HonorGrade = progress.Grade
|
|
detail.HonorMaximumExperience = table.MaxTotalExperience()
|
|
}
|
|
if s.adventureGroupTable != nil {
|
|
config := s.adventureGroupTable.Runtime()
|
|
state, stateErr := adventuregroup.ParseRuntimeState(account, config, s.gameplayNow())
|
|
if stateErr != nil {
|
|
return GMAccountDetail{}, stateErr
|
|
}
|
|
detail.GrowthCapsuleExperience = state.GrowthExperience
|
|
detail.GrowthCapsuleMaximum = config.Capsule.MaximumExperience
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
// GMSetAccountValue changes one explicit account field. Account writes are
|
|
// offline-only because the selected-session projection caches all four values.
|
|
func (s *Service) GMSetAccountValue(ctx context.Context, accountID, field string, value uint64) (GMAccountDetail, error) {
|
|
accountID = strings.TrimSpace(accountID)
|
|
field = strings.ToLower(strings.TrimSpace(field))
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Account == nil || repositories.Character == nil || accountID == "" {
|
|
return GMAccountDetail{}, ErrGMInvalidRequest
|
|
}
|
|
if online, err := s.gmAccountOnline(ctx, repositories, accountID); err != nil {
|
|
return GMAccountDetail{}, err
|
|
} else if online {
|
|
return GMAccountDetail{}, ErrGMCharacterOnline
|
|
}
|
|
account, found, err := repositories.Account.Load(ctx, accountID)
|
|
if err != nil {
|
|
return GMAccountDetail{}, err
|
|
}
|
|
if !found {
|
|
return GMAccountDetail{}, ErrGMAccountNotFound
|
|
}
|
|
now := time.Now().UTC()
|
|
switch field {
|
|
case "cera":
|
|
// 上限必须是 **MaxInt32** 而不是 MaxUint32:客户端把点券按有符号 32 位读,
|
|
// 一旦写进 (MaxInt32, MaxUint32] 区间的值,玩家看到的是负数。
|
|
// 与 cerashop.MaxWalletBalance / WalletCurrency.SetOn 的口径保持一致。
|
|
if value > uint64(dnfcerashop.MaxWalletBalance) {
|
|
return GMAccountDetail{}, fmt.Errorf("%w: cera exceeds the client's signed 32-bit range", ErrGMInvalidRequest)
|
|
}
|
|
err = dnfrepo.SaveAccountMetadataEntry(ctx, repositories.Account, account, "account_cera", strconv.FormatUint(value, 10), now)
|
|
case "cargo_gold":
|
|
if value > math.MaxInt64 {
|
|
return GMAccountDetail{}, fmt.Errorf("%w: cargo gold exceeds int64", ErrGMInvalidRequest)
|
|
}
|
|
err = dnfrepo.SaveAccountMetadataEntry(ctx, repositories.Account, account, "account_cargo_gold", strconv.FormatUint(value, 10), now)
|
|
case "honor_experience":
|
|
if table, tableErr := s.loadHonorTable(ctx); tableErr == nil && value > table.MaxTotalExperience() {
|
|
value = table.MaxTotalExperience()
|
|
}
|
|
account = dnfrepo.CloneAccount(account)
|
|
account.HonorExp = value
|
|
account.UpdatedAt = now
|
|
err = repositories.Account.Save(ctx, account)
|
|
case "growth_capsule_experience":
|
|
if s.adventureGroupTable == nil {
|
|
return GMAccountDetail{}, ErrGMUnavailable
|
|
}
|
|
config := s.adventureGroupTable.Runtime()
|
|
if config.Capsule.MaximumExperience > 0 && value > config.Capsule.MaximumExperience {
|
|
value = config.Capsule.MaximumExperience
|
|
}
|
|
state, stateErr := adventuregroup.ParseRuntimeState(account, config, s.gameplayNow())
|
|
if stateErr != nil {
|
|
return GMAccountDetail{}, stateErr
|
|
}
|
|
state.GrowthExperience = value
|
|
// 这条 GM 路径没有选中角色(没有 CharacterSettlement 事务可挂),
|
|
// 所以直接写账号元数据;走不到 saveCurrentAdventureRuntime 的 CAS 校验。
|
|
encoded, marshalErr := state.Marshal()
|
|
if marshalErr != nil {
|
|
return GMAccountDetail{}, marshalErr
|
|
}
|
|
err = dnfrepo.SaveAccountMetadataEntry(
|
|
ctx, repositories.Account, account, adventuregroup.RuntimeStateMetadataKey, encoded, now,
|
|
)
|
|
default:
|
|
return GMAccountDetail{}, fmt.Errorf("%w: unsupported account field %q", ErrGMInvalidRequest, field)
|
|
}
|
|
if err != nil {
|
|
return GMAccountDetail{}, err
|
|
}
|
|
return s.GMAccount(ctx, accountID)
|
|
}
|
|
|
|
func (s *Service) GMCharacterDetail(ctx context.Context, characterID string) (GMCharacterDetail, error) {
|
|
character, err := s.GMCharacter(ctx, characterID)
|
|
if err != nil {
|
|
return GMCharacterDetail{}, err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.Skill == nil {
|
|
return GMCharacterDetail{}, ErrGMUnavailable
|
|
}
|
|
record, found, err := repositories.Character.Load(ctx, character.CharacterID)
|
|
if err != nil || !found {
|
|
if err != nil {
|
|
return GMCharacterDetail{}, err
|
|
}
|
|
return GMCharacterDetail{}, ErrGMCharacterNotFound
|
|
}
|
|
skill, found, err := repositories.Skill.Load(ctx, character.CharacterID)
|
|
if err != nil {
|
|
return GMCharacterDetail{}, err
|
|
}
|
|
if !found {
|
|
skill = dnfrepo.SkillRecord{CharacterID: character.CharacterID}
|
|
}
|
|
stats := make(map[string]int64, len(record.Stats))
|
|
for key, value := range record.Stats {
|
|
stats[key] = value
|
|
}
|
|
return GMCharacterDetail{Character: character, Stats: stats, Skill: skill.Points, BonusSP: stats["bonus_sp"], BonusTP: stats["bonus_tp"]}, nil
|
|
}
|
|
|
|
// GMSetCharacterSkillBonus persists absolute bonus SP/TP values and adjusts
|
|
// both totals and remaining balances by the same delta, preserving spent points.
|
|
func (s *Service) GMSetCharacterSkillBonus(ctx context.Context, characterID string, bonusSP, bonusTP int64) (GMCharacterDetail, error) {
|
|
characterID, numericID, err := gmCharacterID(characterID)
|
|
if err != nil || bonusSP < 0 || bonusTP < 0 || bonusSP > math.MaxUint16 || bonusTP > math.MaxUint16 {
|
|
return GMCharacterDetail{}, fmt.Errorf("%w: bonus SP/TP must be between 0 and %d", ErrGMInvalidRequest, uint64(math.MaxUint16))
|
|
}
|
|
if _, online := s.onlineGameSession(numericID); online {
|
|
return GMCharacterDetail{}, ErrGMCharacterOnline
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.CharacterProgression == nil {
|
|
return GMCharacterDetail{}, ErrGMUnavailable
|
|
}
|
|
err = repositories.CharacterProgression.WithinCharacterProgression(ctx, characterID, func(characters dnfrepo.CharacterRepository, skills dnfrepo.SkillRepository) error {
|
|
character, found, loadErr := characters.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found {
|
|
return ErrGMCharacterNotFound
|
|
}
|
|
skill, found, loadErr := skills.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("%w: skill ledger is missing", ErrGMInvalidRequest)
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
skill = dnfrepo.CloneSkill(skill)
|
|
oldSP, oldTP := character.Stats["bonus_sp"], character.Stats["bonus_tp"]
|
|
if character.Stats == nil {
|
|
character.Stats = make(map[string]int64)
|
|
}
|
|
spDelta, tpDelta := int(bonusSP-oldSP), int(bonusTP-oldTP)
|
|
nextTotalSP, nextRemainingSP := skill.Points.TotalSP+spDelta, skill.Points.RemainingSP+spDelta
|
|
nextTotalTP, nextRemainingTP := skill.Points.TotalTP+tpDelta, skill.Points.RemainingTP+tpDelta
|
|
if nextTotalSP < 0 || nextRemainingSP < 0 || nextRemainingSP > nextTotalSP || nextTotalSP > math.MaxUint16 ||
|
|
nextTotalTP < 0 || nextRemainingTP < 0 || nextRemainingTP > nextTotalTP || nextTotalTP > math.MaxUint16 {
|
|
return fmt.Errorf("%w: bonus would invalidate the current spent-point ledger", ErrGMInvalidRequest)
|
|
}
|
|
character.Stats["bonus_sp"], character.Stats["bonus_tp"] = bonusSP, bonusTP
|
|
skill.Points.TotalSP, skill.Points.RemainingSP = nextTotalSP, nextRemainingSP
|
|
skill.Points.TotalTP, skill.Points.RemainingTP = nextTotalTP, nextRemainingTP
|
|
now := time.Now().UTC()
|
|
character.UpdatedAt, skill.UpdatedAt = now, now
|
|
if saveErr := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); saveErr != nil {
|
|
return saveErr
|
|
}
|
|
return dnfrepo.SaveSkillFields(ctx, skills, skill, dnfrepo.SkillFieldPoints)
|
|
})
|
|
if err != nil {
|
|
return GMCharacterDetail{}, err
|
|
}
|
|
return s.GMCharacterDetail(ctx, characterID)
|
|
}
|
|
|
|
func (s *Service) GMInventory(ctx context.Context, characterID string) (GMInventorySnapshot, error) {
|
|
characterID, _, err := gmCharacterID(characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.Inventory == nil || repositories.AccountInventory == nil || repositories.Equipment == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
characterRecord, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
if !found {
|
|
return GMInventorySnapshot{}, ErrGMCharacterNotFound
|
|
}
|
|
inventory, _, err := repositories.Inventory.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
accountInventory, _, err := repositories.AccountInventory.Load(ctx, characterRecord.AccountID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
equipment, _, err := repositories.Equipment.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
items := make([]GMInventoryItem, 0, len(inventory.Slots)+len(inventory.Warehouse)+len(accountInventory.Slots)+len(equipment.Entries))
|
|
appendStackMap := func(container string, entries map[string]dnfrepo.ItemStack) {
|
|
for key, stack := range entries {
|
|
listType, slot := gmSlotKey(key)
|
|
items = append(items, GMInventoryItem{Container: container, Key: key, ListType: listType, Slot: slot,
|
|
ItemID: stack.ItemID, Name: s.gmItemName(stack.ItemID), Count: stack.Count, Bind: stack.Bind,
|
|
ExpireAt: stack.ExpireAt, Extra: gmCloneStringMap(stack.Extra)})
|
|
}
|
|
}
|
|
appendStackMap("inventory", inventory.Slots)
|
|
appendStackMap("warehouse", inventory.Warehouse)
|
|
appendStackMap("account", accountInventory.Slots)
|
|
for key, entry := range equipment.Entries {
|
|
items = append(items, GMInventoryItem{Container: "equipment", Key: key, ListType: 3, Slot: int(entry.SlotIndex),
|
|
ItemID: entry.ItemID, Name: s.gmItemName(entry.ItemID), Count: 1, Bind: entry.Bind,
|
|
ExpireAt: entry.ExpireAt, ReadOnly: true, Extra: gmCloneStringMap(entry.Extra)})
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].Container != items[j].Container {
|
|
return items[i].Container < items[j].Container
|
|
}
|
|
if items[i].ListType != items[j].ListType {
|
|
return items[i].ListType < items[j].ListType
|
|
}
|
|
if items[i].Slot != items[j].Slot {
|
|
return items[i].Slot < items[j].Slot
|
|
}
|
|
return items[i].Key < items[j].Key
|
|
})
|
|
return GMInventorySnapshot{Character: s.gmCharacterSummary(characterRecord), Items: items}, nil
|
|
}
|
|
|
|
// GMAccountCubes returns all six fixed account-shared crystal slots, including
|
|
// empty ones, in the current-client native order.
|
|
func (s *Service) GMAccountCubes(ctx context.Context, characterID string) (GMAccountCubeSnapshot, error) {
|
|
characterID, _, err := gmCharacterID(characterID)
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.AccountInventory == nil {
|
|
return GMAccountCubeSnapshot{}, ErrGMUnavailable
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
if !found {
|
|
return GMAccountCubeSnapshot{}, ErrGMCharacterNotFound
|
|
}
|
|
inventory, _, err := repositories.AccountInventory.Load(ctx, character.AccountID)
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
premiumCatalog, err := s.currentPremiumCatalog()
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
cubeItemIDs := premiumCatalog.crystalCubeIDs
|
|
cubes := make([]GMAccountCube, 0, len(cubeItemIDs))
|
|
for selection, itemID := range cubeItemIDs {
|
|
if itemID <= 0 {
|
|
return GMAccountCubeSnapshot{}, ErrGMUnavailable
|
|
}
|
|
slot := dnfrepo.CrystalWarehouseFirstSlot + int16(selection)
|
|
stack := inventory.Slots[dnfrepo.AccountSharedInventorySlotKey(slot)]
|
|
count := int64(0)
|
|
if stack.ItemID == itemID && stack.Count > 0 {
|
|
count = stack.Count
|
|
}
|
|
cubes = append(cubes, GMAccountCube{Selection: selection, Slot: int(slot), ItemID: itemID, Name: s.gmItemName(itemID), Count: count})
|
|
}
|
|
return GMAccountCubeSnapshot{Character: s.gmCharacterSummary(character), Cubes: cubes}, nil
|
|
}
|
|
|
|
// GMSetAccountCube repairs one fixed account-shared crystal stack. It rejects
|
|
// any online character on the account because list-0 is cached in live scenes.
|
|
func (s *Service) GMSetAccountCube(ctx context.Context, characterID string, selection int, count int64) (GMAccountCubeSnapshot, error) {
|
|
characterID, numericID, err := gmCharacterID(characterID)
|
|
if err != nil || selection < 0 || selection >= 6 || count < 0 || uint64(count) > math.MaxUint32 {
|
|
return GMAccountCubeSnapshot{}, fmt.Errorf("%w: cube selection or count is out of range", ErrGMInvalidRequest)
|
|
}
|
|
if _, online := s.onlineGameSession(numericID); online {
|
|
return GMAccountCubeSnapshot{}, ErrGMCharacterOnline
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.AccountItems == nil {
|
|
return GMAccountCubeSnapshot{}, ErrGMUnavailable
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
if !found {
|
|
return GMAccountCubeSnapshot{}, ErrGMCharacterNotFound
|
|
}
|
|
if online, onlineErr := s.gmAccountOnline(ctx, repositories, character.AccountID); onlineErr != nil {
|
|
return GMAccountCubeSnapshot{}, onlineErr
|
|
} else if online {
|
|
return GMAccountCubeSnapshot{}, ErrGMCharacterOnline
|
|
}
|
|
premiumCatalog, err := s.currentPremiumCatalog()
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
itemID := premiumCatalog.crystalCubeIDs[selection]
|
|
if itemID <= 0 || itemID > math.MaxUint32 {
|
|
return GMAccountCubeSnapshot{}, ErrGMUnavailable
|
|
}
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
definition, err := catalog.ResolveItem(uint32(itemID))
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
slot := dnfrepo.CrystalWarehouseFirstSlot + int16(selection)
|
|
key := dnfrepo.AccountSharedInventorySlotKey(slot)
|
|
err = repositories.AccountItems.WithinAccountCharacterItems(ctx, character.AccountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, _ dnfrepo.InventoryRepository) error {
|
|
record, exists, loadErr := accounts.Load(ctx, character.AccountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !exists {
|
|
record = dnfrepo.AccountInventoryRecord{AccountID: character.AccountID, Slots: make(map[string]dnfrepo.ItemStack)}
|
|
} else {
|
|
record = dnfrepo.CloneAccountInventory(record)
|
|
}
|
|
if record.Slots == nil {
|
|
record.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
if count == 0 {
|
|
delete(record.Slots, key)
|
|
} else {
|
|
stack := record.Slots[key]
|
|
if stack.ItemID != itemID {
|
|
stack = dnfrepo.ItemStack{ItemID: itemID, Extra: make(map[string]string)}
|
|
}
|
|
// PVF [stack limit] 是账号金库这一个容器里该道具的总量上限:GM 直接设值
|
|
// 也不能把容器写过上限(同 id 的其它行,包含历史拆行,都要计入)。
|
|
if definition.StackLimit > 0 {
|
|
others := dnfrepo.ContainerItemTotal(record.Slots, dnfrepo.MainInventoryListType, int64(itemID))
|
|
if stack.ItemID == itemID && stack.Count > 0 {
|
|
others -= stack.Count
|
|
}
|
|
if others > definition.StackLimit-count {
|
|
return fmt.Errorf(
|
|
"%w: cube selection=%d count=%d stack_limit=%d already=%d",
|
|
ErrGMInvalidRequest, selection, count, definition.StackLimit, others,
|
|
)
|
|
}
|
|
}
|
|
if stack.Extra == nil {
|
|
stack.Extra = make(map[string]string)
|
|
}
|
|
stack.ItemID, stack.Count = itemID, count
|
|
stack.Extra["item_kind"] = string(definition.Kind)
|
|
stack.Extra["pvf_path"] = definition.PVFPath
|
|
stack.Extra["stackable_type"] = definition.StackableType
|
|
if definition.StackLimit > 0 {
|
|
stack.Extra["stack_limit"] = strconv.FormatInt(definition.StackLimit, 10)
|
|
}
|
|
entry := currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, stack)
|
|
stack.RawEntry = append([]byte(nil), entry.data[:]...)
|
|
record.Slots[key] = stack
|
|
}
|
|
record.UpdatedAt = time.Now().UTC()
|
|
return accounts.Save(ctx, record)
|
|
})
|
|
if err != nil {
|
|
return GMAccountCubeSnapshot{}, err
|
|
}
|
|
return s.GMAccountCubes(ctx, characterID)
|
|
}
|
|
|
|
// GMDeleteInventoryItem 删除某容器里指定格子的一件/一堆物品。
|
|
//
|
|
// 在线角色现在也允许删(旧实现直接返回 ErrGMCharacterOnline,等于"删个东西先踢人")。
|
|
// 安全依据:仓储层每次改动都在事务内重新 Load → 不存在服务端内存快照覆盖问题;
|
|
// 真正会不一致的只有客户端自己缓存的容器显示,提交后用 gmPushInventorySlotRefreshes
|
|
// 走与游戏内改动同一条 op14 增量刷新路径补发变更行(空格子以删除行下发,客户端立刻消失)。
|
|
func (s *Service) GMDeleteInventoryItem(ctx context.Context, characterID, container, key string, count int64) (GMInventorySnapshot, error) {
|
|
characterID, _, err := gmCharacterID(characterID)
|
|
if err != nil || strings.TrimSpace(key) == "" || count < 0 {
|
|
return GMInventorySnapshot{}, ErrGMInvalidRequest
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
if !found {
|
|
return GMInventorySnapshot{}, ErrGMCharacterNotFound
|
|
}
|
|
removedKeys := []string{strings.TrimSpace(key)}
|
|
container = strings.ToLower(strings.TrimSpace(container))
|
|
switch container {
|
|
case "inventory", "warehouse":
|
|
if repositories.CharacterItems == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
err = repositories.CharacterItems.WithinCharacterItems(ctx, characterID, func(inventories dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
|
record, exists, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !exists {
|
|
return ErrGMCharacterNotFound
|
|
}
|
|
record = dnfrepo.CloneInventory(record)
|
|
entries, field := record.Slots, dnfrepo.InventoryFieldSlots
|
|
if container == "warehouse" {
|
|
entries, field = record.Warehouse, dnfrepo.InventoryFieldWarehouse
|
|
}
|
|
if mutateErr := gmRemoveStack(entries, key, count); mutateErr != nil {
|
|
return mutateErr
|
|
}
|
|
record.UpdatedAt = time.Now().UTC()
|
|
return dnfrepo.SaveInventoryFields(ctx, inventories, record, field)
|
|
})
|
|
case "account":
|
|
if repositories.AccountItems == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
// 账号金库(账号共享的晶体/灵魂仓库)同样放开在线:它们是 list 0 的 354..365 槽,
|
|
// 增量刷新路径对这几个槽有专门处理(账号记录优先),删除行会把对应小面板清掉,
|
|
// 正是"删掉了"该有的表现。
|
|
err = repositories.AccountItems.WithinAccountCharacterItems(ctx, character.AccountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, _ dnfrepo.InventoryRepository) error {
|
|
record, exists, loadErr := accounts.Load(ctx, character.AccountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !exists {
|
|
return fmt.Errorf("%w: account inventory is missing", ErrGMInvalidRequest)
|
|
}
|
|
record = dnfrepo.CloneAccountInventory(record)
|
|
if mutateErr := gmRemoveStack(record.Slots, key, count); mutateErr != nil {
|
|
return mutateErr
|
|
}
|
|
record.UpdatedAt = time.Now().UTC()
|
|
return accounts.Save(ctx, record)
|
|
})
|
|
default:
|
|
return GMInventorySnapshot{}, fmt.Errorf("%w: container is read-only or unsupported", ErrGMInvalidRequest)
|
|
}
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
// 提交成功后再推刷新:仓库已改,客户端那份显示必须跟上。
|
|
if refreshErr := s.gmPushInventorySlotRefreshes(ctx, characterID, removedKeys, "gm_admin_inventory_delete"); refreshErr != "" {
|
|
s.logWarn("dnfbridge GM inventory delete live refresh deferred",
|
|
"character_id", characterID, "container", container, "keys", removedKeys, "error", refreshErr)
|
|
}
|
|
return s.GMInventory(ctx, characterID)
|
|
}
|
|
|
|
// GMClearInventory 清空整个容器(不可撤销)。
|
|
//
|
|
// 与 GMDeleteInventoryItem 一样放开在线角色:清空是"把该容器每一格都变成空",
|
|
// 用这些格子的增量刷新(空格 → 删除行)就能让在线客户端立刻清空。
|
|
// 清空前的占用格子必须在事务内先取出来——清完就没法知道要给客户端发哪些行了。
|
|
func (s *Service) GMClearInventory(ctx context.Context, characterID, container string) (GMInventorySnapshot, error) {
|
|
characterID, _, err := gmCharacterID(characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
container = strings.ToLower(strings.TrimSpace(container))
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
if !found {
|
|
return GMInventorySnapshot{}, ErrGMCharacterNotFound
|
|
}
|
|
clearedKeys := make([]string, 0, 64)
|
|
switch container {
|
|
case "inventory", "warehouse":
|
|
if repositories.CharacterItems == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
err = repositories.CharacterItems.WithinCharacterItems(ctx, characterID, func(inventories dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
|
|
record, exists, loadErr := inventories.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !exists {
|
|
return ErrGMCharacterNotFound
|
|
}
|
|
record = dnfrepo.CloneInventory(record)
|
|
field := dnfrepo.InventoryFieldSlots
|
|
if container == "inventory" {
|
|
clearedKeys = inventoryStackKeys(record.Slots)
|
|
record.Slots = make(map[string]dnfrepo.ItemStack)
|
|
} else {
|
|
clearedKeys = inventoryStackKeys(record.Warehouse)
|
|
record.Warehouse = make(map[string]dnfrepo.ItemStack)
|
|
field = dnfrepo.InventoryFieldWarehouse
|
|
}
|
|
record.UpdatedAt = time.Now().UTC()
|
|
return dnfrepo.SaveInventoryFields(ctx, inventories, record, field)
|
|
})
|
|
case "account":
|
|
if repositories.AccountItems == nil {
|
|
return GMInventorySnapshot{}, ErrGMUnavailable
|
|
}
|
|
err = repositories.AccountItems.WithinAccountCharacterItems(ctx, character.AccountID, characterID, func(accounts dnfrepo.AccountInventoryRepository, _ dnfrepo.InventoryRepository) error {
|
|
record, exists, loadErr := accounts.Load(ctx, character.AccountID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !exists {
|
|
return nil
|
|
}
|
|
record = dnfrepo.CloneAccountInventory(record)
|
|
clearedKeys = inventoryStackKeys(record.Slots)
|
|
record.Slots = make(map[string]dnfrepo.ItemStack)
|
|
record.UpdatedAt = time.Now().UTC()
|
|
return accounts.Save(ctx, record)
|
|
})
|
|
default:
|
|
return GMInventorySnapshot{}, fmt.Errorf("%w: container is read-only or unsupported", ErrGMInvalidRequest)
|
|
}
|
|
if err != nil {
|
|
return GMInventorySnapshot{}, err
|
|
}
|
|
if refreshErr := s.gmPushInventorySlotRefreshes(ctx, characterID, clearedKeys, "gm_admin_inventory_clear"); refreshErr != "" {
|
|
s.logWarn("dnfbridge GM inventory clear live refresh deferred",
|
|
"character_id", characterID, "container", container, "slot_count", len(clearedKeys), "error", refreshErr)
|
|
}
|
|
return s.GMInventory(ctx, characterID)
|
|
}
|
|
|
|
// inventoryStackKeys 列出容器里当前占用的格子键(供清空时给在线客户端发删除行)。
|
|
func inventoryStackKeys(entries map[string]dnfrepo.ItemStack) []string {
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(entries))
|
|
for key, stack := range entries {
|
|
if stack.ItemID > 0 && stack.Count > 0 {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// gmPushInventorySlotRefreshes 把若干格子的最新状态推给在线客户端,返回刷新失败原因(空=成功或无人在线)。
|
|
//
|
|
// 为什么 GM 直改仓储之后必须补这一步:
|
|
//
|
|
// GM 写的是仓储(repository),玩家屏幕上显示的是客户端自己缓存的容器。
|
|
// 旧实现因为怕两边不一致,干脆拒绝在线角色(ErrGMCharacterOnline)——代价是
|
|
// "删个东西还得先把人踢下线"。实际上仓储层每次改动都在事务内重新 Load,
|
|
// 服务端并不存在内存快照互相覆盖的问题;会不一致的只有客户端那份显示。
|
|
// 所以在提交之后,用与游戏内改动同一条 op14 增量刷新路径把变更行发下去:
|
|
// 占用的格子发实际物品行,空掉的格子发 0xFFFFFFFF 删除行,客户端立刻反映。
|
|
//
|
|
// 刷新失败**不影响**返回值:仓储改动已经提交,这只是一次尽力而为的实时同步,
|
|
// 失败时由调用方记录日志、下一次打开容器时自然同步。
|
|
func (s *Service) gmPushInventorySlotRefreshes(ctx context.Context, characterID string, keys []string, source string) string {
|
|
if s == nil || len(keys) == 0 {
|
|
return ""
|
|
}
|
|
refreshes := make([]alignedcmd.ItemSlotRefresh, 0, len(keys))
|
|
seen := make(map[string]struct{}, len(keys))
|
|
for _, key := range keys {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[key]; duplicate {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
listType, slot := gmSlotKey(key)
|
|
if listType < 0 || slot < 0 {
|
|
// 键形态不认识就跳过这一个,不要因为一格的问题放弃整批刷新。
|
|
continue
|
|
}
|
|
refreshes = append(refreshes, alignedcmd.ItemSlotRefresh{
|
|
ListType: byte(listType),
|
|
SlotIndex: int16(slot),
|
|
})
|
|
}
|
|
if len(refreshes) == 0 {
|
|
return ""
|
|
}
|
|
result := s.gmRefreshOnlineCharacter(characterID, source, func(session *gameSession) error {
|
|
return s.sendSelectedIncrementalItemSlotRefreshes(session, source, refreshes)
|
|
})
|
|
return result.RefreshError
|
|
}
|
|
|
|
func (s *Service) GMSearchItemPage(ctx context.Context, query GMItemQuery) (GMItemPage, error) {
|
|
if query.Limit <= 0 {
|
|
query.Limit = 100
|
|
}
|
|
if query.Limit > 200 || query.Offset < 0 {
|
|
return GMItemPage{}, ErrGMInvalidRequest
|
|
}
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return GMItemPage{}, err
|
|
}
|
|
q, kind, expiration := strings.ToLower(strings.TrimSpace(query.Query)), strings.ToLower(strings.TrimSpace(query.Kind)), strings.ToLower(strings.TrimSpace(query.Expiration))
|
|
tag, segment := gmItemTypeTag(query.Tag), strings.TrimSpace(query.Segment)
|
|
group, job := strings.ToLower(strings.TrimSpace(query.Group)), gmItemTypeTag(query.Job)
|
|
catalog.mu.Lock()
|
|
ids := make([]uint32, 0, len(catalog.itemRefs))
|
|
for id := range catalog.itemRefs {
|
|
ids = append(ids, id)
|
|
}
|
|
catalog.mu.Unlock()
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
matches := make([]GMItemSummary, 0, query.Limit)
|
|
total := 0
|
|
for _, id := range ids {
|
|
if err := ctx.Err(); err != nil {
|
|
return GMItemPage{}, err
|
|
}
|
|
definition, resolveErr := catalog.ResolveItem(id)
|
|
if resolveErr != nil {
|
|
continue
|
|
}
|
|
item := gmItemSummary(definition)
|
|
if q != "" && !strings.Contains(strings.ToLower(item.Name), q) && !strings.Contains(strings.ToLower(item.PVFPath), q) && strconv.FormatUint(uint64(item.ItemID), 10) != q {
|
|
continue
|
|
}
|
|
if kind != "" && kind != "all" && strings.ToLower(item.Kind) != kind {
|
|
continue
|
|
}
|
|
if tag != "" && gmItemTypeTag(item.EquipmentType) != tag {
|
|
continue
|
|
}
|
|
if segment != "" && gmStackableSegment(item.StackableType) != segment {
|
|
continue
|
|
}
|
|
if group != "" && gmItemGroup(item) != group {
|
|
continue
|
|
}
|
|
if job != "" {
|
|
effectiveJobs := gmItemEffectiveJobs(catalog, definition)
|
|
if len(effectiveJobs) > 0 {
|
|
if !gmJobTagsContain(effectiveJobs, job) {
|
|
continue
|
|
}
|
|
item.UsableJobs = gmBracketJobTags(effectiveJobs)
|
|
} else if !gmItemMatchesJob(item, job) {
|
|
continue
|
|
}
|
|
}
|
|
if query.MinLevel > 0 && item.MinimumLevel < query.MinLevel {
|
|
continue
|
|
}
|
|
if query.MaxLevel > 0 && item.MinimumLevel > query.MaxLevel {
|
|
continue
|
|
}
|
|
if query.Rarity >= 0 && item.Rarity != query.Rarity {
|
|
continue
|
|
}
|
|
if expiration == "permanent" && (item.UsablePeriodDays > 0 || !item.ExpirationDate.IsZero()) {
|
|
continue
|
|
}
|
|
if expiration == "timed" && item.UsablePeriodDays <= 0 && item.ExpirationDate.IsZero() {
|
|
continue
|
|
}
|
|
if total >= query.Offset && len(matches) < query.Limit {
|
|
matches = append(matches, item)
|
|
}
|
|
total++
|
|
}
|
|
return GMItemPage{Items: matches, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
|
}
|
|
|
|
func gmItemTypeTag(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "`", "")))
|
|
return strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"))
|
|
}
|
|
|
|
func gmStackableSegment(stackableType string) string {
|
|
value := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(stackableType, "`", "")))
|
|
switch {
|
|
case strings.HasPrefix(value, "[material]"):
|
|
if strings.HasSuffix(value, "4") {
|
|
return "特殊材料"
|
|
}
|
|
return "材料"
|
|
case strings.HasPrefix(value, "[quest]"):
|
|
return "任务品"
|
|
case strings.HasPrefix(value, "[material expert job]"):
|
|
return "副职业材料"
|
|
case strings.HasPrefix(value, "[avatar emblem]"):
|
|
return "徽章"
|
|
default:
|
|
return "消耗品"
|
|
}
|
|
}
|
|
|
|
func gmItemGroup(item GMItemSummary) string {
|
|
if item.Kind == string(dungeonDropItemStackable) {
|
|
return "stackable"
|
|
}
|
|
tag := gmItemTypeTag(item.EquipmentType)
|
|
switch tag {
|
|
case "creature", "artifact red", "artifact blue", "artifact green":
|
|
return "pet"
|
|
case "hat avatar", "hair avatar", "face avatar", "coat avatar", "breast avatar", "waist avatar", "pants avatar", "shoes avatar", "skin avatar", "aurora avatar", "weapon avatar":
|
|
return "avatar"
|
|
case "weapon", "coat", "shoulder", "pants", "shoes", "waist", "amulet", "wrist", "ring", "support", "magic stone", "earring", "support weapon", "title name", "name tag":
|
|
return "equipment"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|
|
|
|
func gmItemMatchesJob(item GMItemSummary, job string) bool {
|
|
if len(item.UsableJobs) == 0 {
|
|
return true
|
|
}
|
|
for _, candidate := range item.UsableJobs {
|
|
normalized := gmItemTypeTag(candidate)
|
|
if normalized == job || normalized == "all" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// gmItemEffectiveJobs returns explicit profession restrictions first. For a
|
|
// package whose outer PVF declares [all], it walks [package data] and derives
|
|
// restrictions from contained equipment or nested packages. Truly generic
|
|
// consumables remain unrestricted because no specific descendant job exists.
|
|
func gmItemEffectiveJobs(catalog *pvfDungeonDropCatalog, definition dungeonDropItemDefinition) []string {
|
|
return gmCollectItemEffectiveJobs(catalog, definition, make(map[uint32]struct{}), 0)
|
|
}
|
|
|
|
func gmCollectItemEffectiveJobs(
|
|
catalog *pvfDungeonDropCatalog,
|
|
definition dungeonDropItemDefinition,
|
|
visited map[uint32]struct{},
|
|
depth int,
|
|
) []string {
|
|
if jobs := gmSpecificJobTags(definition.UsableJobs); len(jobs) > 0 {
|
|
return jobs
|
|
}
|
|
if catalog == nil || definition.Kind != dungeonDropItemStackable || len(definition.PackageRewardIDs) == 0 || depth >= 8 {
|
|
return nil
|
|
}
|
|
if _, exists := visited[definition.ItemID]; exists {
|
|
return nil
|
|
}
|
|
visited[definition.ItemID] = struct{}{}
|
|
defer delete(visited, definition.ItemID)
|
|
|
|
jobSet := make(map[string]struct{})
|
|
for _, rewardID := range definition.PackageRewardIDs {
|
|
reward, err := catalog.ResolveItem(rewardID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, job := range gmCollectItemEffectiveJobs(catalog, reward, visited, depth+1) {
|
|
jobSet[job] = struct{}{}
|
|
}
|
|
}
|
|
if len(jobSet) == 0 {
|
|
return nil
|
|
}
|
|
jobs := make([]string, 0, len(jobSet))
|
|
for job := range jobSet {
|
|
jobs = append(jobs, job)
|
|
}
|
|
sort.Strings(jobs)
|
|
return jobs
|
|
}
|
|
|
|
func gmSpecificJobTags(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
jobs := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
job := gmItemTypeTag(value)
|
|
if job == "" || job == "all" {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[job]; duplicate {
|
|
continue
|
|
}
|
|
seen[job] = struct{}{}
|
|
jobs = append(jobs, job)
|
|
}
|
|
sort.Strings(jobs)
|
|
return jobs
|
|
}
|
|
|
|
func gmJobTagsContain(values []string, job string) bool {
|
|
job = gmItemTypeTag(job)
|
|
for _, value := range values {
|
|
if gmItemTypeTag(value) == job {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func gmBracketJobTags(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value = gmItemTypeTag(value); value != "" {
|
|
out = append(out, "["+value+"]")
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) GMItemCategories(ctx context.Context) (GMItemCategories, error) {
|
|
catalog, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return GMItemCategories{}, err
|
|
}
|
|
catalog.mu.Lock()
|
|
ids := make([]uint32, 0, len(catalog.itemRefs))
|
|
for id := range catalog.itemRefs {
|
|
ids = append(ids, id)
|
|
}
|
|
catalog.mu.Unlock()
|
|
equipment, stackable := make(map[string]int), make(map[string]int)
|
|
total := 0
|
|
for _, id := range ids {
|
|
if err := ctx.Err(); err != nil {
|
|
return GMItemCategories{}, err
|
|
}
|
|
definition, resolveErr := catalog.ResolveItem(id)
|
|
if resolveErr != nil {
|
|
continue
|
|
}
|
|
total++
|
|
if definition.Kind == dungeonDropItemEquipment {
|
|
tag := gmItemTypeTag(definition.EquipmentType)
|
|
if tag == "" {
|
|
tag = "(无标签)"
|
|
}
|
|
equipment[tag]++
|
|
} else if definition.Kind == dungeonDropItemStackable {
|
|
stackable[gmStackableSegment(definition.StackableType)]++
|
|
}
|
|
}
|
|
toCounts := func(values map[string]int) []GMItemCategoryCount {
|
|
result := make([]GMItemCategoryCount, 0, len(values))
|
|
for name, count := range values {
|
|
result = append(result, GMItemCategoryCount{Name: name, Count: count})
|
|
}
|
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
|
return result
|
|
}
|
|
return GMItemCategories{Total: total, Equipment: toCounts(equipment), Stackable: toCounts(stackable)}, nil
|
|
}
|
|
|
|
func (s *Service) GMCompleteQuestBatch(ctx context.Context, characterID string, questIDs []int64) (GMQuestOverview, error) {
|
|
characterID, numericID, err := gmCharacterID(characterID)
|
|
if err != nil || len(questIDs) == 0 || len(questIDs) > 200 {
|
|
return GMQuestOverview{}, ErrGMInvalidRequest
|
|
}
|
|
if _, online := s.onlineGameSession(numericID); online {
|
|
return GMQuestOverview{}, ErrGMCharacterOnline
|
|
}
|
|
catalog, err := s.loadQuestCatalog(ctx)
|
|
if err != nil {
|
|
return GMQuestOverview{}, err
|
|
}
|
|
seen := make(map[int64]struct{}, len(questIDs))
|
|
for _, questID := range questIDs {
|
|
if questID <= 0 || questID > math.MaxUint16 {
|
|
return GMQuestOverview{}, ErrGMInvalidRequest
|
|
}
|
|
if _, duplicate := seen[questID]; duplicate {
|
|
continue
|
|
}
|
|
seen[questID] = struct{}{}
|
|
definition, found := catalog.Find(questID)
|
|
if !found {
|
|
return GMQuestOverview{}, fmt.Errorf("%w: quest %d is not present in the active Script.pvf", ErrGMInvalidRequest, questID)
|
|
}
|
|
if gmProfessionRewardType(definition.RewardType) {
|
|
return GMQuestOverview{}, fmt.Errorf("%w: batch completion excludes profession quests", ErrGMInvalidRequest)
|
|
}
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.CharacterSettlement == nil {
|
|
return GMQuestOverview{}, ErrGMUnavailable
|
|
}
|
|
err = repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
|
if tx.Character == nil || tx.Quest == nil {
|
|
return ErrGMUnavailable
|
|
}
|
|
if _, found, loadErr := tx.Character.Load(ctx, characterID); loadErr != nil {
|
|
return loadErr
|
|
} else if !found {
|
|
return ErrGMCharacterNotFound
|
|
}
|
|
record, found, loadErr := tx.Quest.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found {
|
|
record = dnfrepo.QuestRecord{CharacterID: characterID}
|
|
} else {
|
|
record = dnfrepo.CloneQuest(record)
|
|
}
|
|
if record.States == nil {
|
|
record.States = make(map[int64]dnfrepo.QuestState)
|
|
}
|
|
now := time.Now().UTC()
|
|
for questID := range seen {
|
|
definition, _ := catalog.Find(questID)
|
|
state, _ := gmQuestState(record, questID)
|
|
if state.Extra == nil {
|
|
state.Extra = make(map[string]string)
|
|
}
|
|
state.Status, state.ProgressValue, state.UpdatedAt = "completed", 0, now
|
|
state.Extra["pvf_path"] = definition.Path
|
|
state.Extra["gm_batch_completed"] = "true"
|
|
state.Extra["gm_rewards_granted"] = "false"
|
|
record.States[questID] = state
|
|
delete(record.Progress, questID)
|
|
}
|
|
record.UpdatedAt = now
|
|
return dnfrepo.SaveQuestFields(ctx, tx.Quest, record, dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress)
|
|
})
|
|
if err != nil {
|
|
return GMQuestOverview{}, err
|
|
}
|
|
return s.GMQuestOverview(ctx, characterID)
|
|
}
|
|
|
|
func (s *Service) gmAccountOnline(ctx context.Context, repositories dnfrepo.Group, accountID string) (bool, error) {
|
|
characters, err := repositories.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, character := range characters {
|
|
_, numericID, parseErr := gmCharacterID(character.CharacterID)
|
|
if parseErr == nil {
|
|
if _, online := s.onlineGameSession(numericID); online {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *Service) gmItemName(itemID int64) string {
|
|
if itemID <= 0 || itemID > math.MaxUint32 {
|
|
return fmt.Sprintf("物品 %d", itemID)
|
|
}
|
|
catalog, loaded, err := s.currentPVFItemCatalogIfLoaded()
|
|
if err != nil || !loaded || catalog == nil {
|
|
return fmt.Sprintf("物品 %d", itemID)
|
|
}
|
|
definition, err := catalog.ResolveItem(uint32(itemID))
|
|
if err != nil {
|
|
return fmt.Sprintf("物品 %d", itemID)
|
|
}
|
|
name := strings.TrimSpace(definition.Name)
|
|
if name == "" {
|
|
name = definition.PVFPath
|
|
}
|
|
return name
|
|
}
|
|
|
|
func gmMetadataInt(metadata map[string]string, key string) int64 {
|
|
value, _ := strconv.ParseInt(strings.TrimSpace(metadata[key]), 10, 64)
|
|
return value
|
|
}
|
|
|
|
func gmMetadataBool(metadata map[string]string, key string) bool {
|
|
value := strings.TrimSpace(metadata[key])
|
|
return value == "1" || strings.EqualFold(value, "true")
|
|
}
|
|
|
|
func gmSlotKey(key string) (int, int) {
|
|
parts := strings.SplitN(strings.TrimSpace(key), ":", 2)
|
|
if len(parts) != 2 {
|
|
return -1, -1
|
|
}
|
|
listType, firstErr := strconv.Atoi(parts[0])
|
|
slot, secondErr := strconv.Atoi(parts[1])
|
|
if firstErr != nil || secondErr != nil {
|
|
return -1, -1
|
|
}
|
|
return listType, slot
|
|
}
|
|
|
|
func gmCloneStringMap(source map[string]string) map[string]string {
|
|
if len(source) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(source))
|
|
for key, value := range source {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func gmRemoveStack(entries map[string]dnfrepo.ItemStack, key string, count int64) error {
|
|
stack, found := entries[key]
|
|
if !found || stack.Count <= 0 {
|
|
return fmt.Errorf("%w: inventory slot %q was not found", ErrGMInvalidRequest, key)
|
|
}
|
|
if count == 0 || count >= stack.Count {
|
|
delete(entries, key)
|
|
return nil
|
|
}
|
|
stack.Count -= count
|
|
entries[key] = stack
|
|
return nil
|
|
}
|