Files
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

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
}