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

429 lines
15 KiB
Go

package dnfbridge
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const currentDungeonAltarQuestDropReceiptVersion = 1
var errCurrentDungeonAltarQuestDropInvalid = errors.New("current dungeon altar quest drop is invalid")
type currentDungeonAltarQuestDropReceipt struct {
Version int `json:"version"`
MonsterCode int64 `json:"monster_code"`
DropSeed uint32 `json:"drop_seed"`
UpdateBody string `json:"update_body,omitempty"`
}
type currentDungeonAltarQuestDropCommit struct {
Body []byte
Changed bool
Replayed bool
}
// commitCurrentDungeonAltarQuestDropsLocked runs before altar sequences are
// retired. All newly reported sequences share one CharacterSettlement
// transaction, so an op255 batch cannot fan out into as many as 100 catalog
// loads and database transactions. Successful inventory grants and their
// sequence receipts commit atomically; a failure leaves every sequence live.
func (s *Service) commitCurrentDungeonAltarQuestDropsLocked(
session *gameSession,
runtime *runtimeDungeonState,
deaths []currentDungeonAltarDeathSequence,
) error {
if s == nil || session == nil || runtime == nil || runtime.altar == nil || len(deaths) == 0 ||
len(deaths) > currentDungeonAltarMaximumDeathSequences {
return errCurrentDungeonAltarQuestDropInvalid
}
seen := make(map[uint16]struct{}, len(deaths))
uncommitted := make([]currentDungeonAltarDeathSequence, 0, len(deaths))
for _, death := range deaths {
if death.Sequence == 0 || death.Owner.MonsterCode <= 0 || death.Owner.QuestDropSeed == 0 {
return errCurrentDungeonAltarQuestDropInvalid
}
if _, duplicate := seen[death.Sequence]; duplicate {
return errCurrentDungeonAltarQuestDropInvalid
}
seen[death.Sequence] = struct{}{}
if _, cached := runtime.altar.questDropResults[death.Sequence]; !cached {
uncommitted = append(uncommitted, death)
}
}
if currentDungeonRuntimeIsPractice(runtime) {
for _, death := range uncommitted {
runtime.altar.questDropResults[death.Sequence] = currentDungeonAltarQuestDropResult{Delivered: true}
}
uncommitted = nil
}
if len(uncommitted) != 0 {
sequence := reserveCurrentDungeonQuestProgress(session)
var commits map[uint16]currentDungeonAltarQuestDropCommit
var commitErr error
runCurrentDungeonQuestProgressOrdered(session, sequence, func() {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
commits, commitErr = s.commitCurrentDungeonAltarQuestDrops(ctx, session, runtime, uncommitted)
})
if commitErr != nil {
return commitErr
}
for _, death := range uncommitted {
commit, ok := commits[death.Sequence]
if !ok {
return errCurrentDungeonAltarQuestDropInvalid
}
runtime.altar.questDropResults[death.Sequence] = currentDungeonAltarQuestDropResult{
Body: append([]byte(nil), commit.Body...),
Delivered: len(commit.Body) == 0,
}
s.logGameEvent(session, "game-dungeon-altar-quest-drop-committed",
"dungeon_id", runtime.Dungeon.ID,
"sequence", death.Sequence,
"monster_id", death.Owner.MonsterCode,
"inventory_changed", commit.Changed,
"receipt_replayed", commit.Replayed,
"update_body_len", len(commit.Body),
"op255_batch_size", len(deaths))
}
}
for _, death := range deaths {
result, ok := runtime.altar.questDropResults[death.Sequence]
if !ok {
return errCurrentDungeonAltarQuestDropInvalid
}
if !result.Delivered && len(result.Body) != 0 {
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), result.Body, 0); err != nil {
return err
}
result.Delivered = true
runtime.altar.questDropResults[death.Sequence] = result
}
}
return nil
}
func (s *Service) commitCurrentDungeonAltarQuestDrops(
ctx context.Context,
session *gameSession,
runtime *runtimeDungeonState,
deaths []currentDungeonAltarDeathSequence,
) (map[uint16]currentDungeonAltarQuestDropCommit, error) {
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterSettlement == nil {
return nil, fmt.Errorf("%w: character settlement repository unavailable", errCurrentDungeonAltarQuestDropInvalid)
}
characterKey := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
receiptScope := ""
receiptKeys := make(map[uint16]string, len(deaths))
for _, death := range deaths {
scope, key, identityErr := currentDungeonAltarQuestDropReceiptIdentity(characterKey, runtime, death)
if identityErr != nil {
return nil, identityErr
}
if receiptScope != "" && scope != receiptScope {
return nil, errCurrentDungeonAltarQuestDropInvalid
}
receiptScope = scope
receiptKeys[death.Sequence] = key
}
accountID := s.accountIDForSession(session)
questHelperActive := false
growthQuestDropPercent := int64(0)
if repositories.Account != nil {
account, found, loadErr := repositories.Account.Load(ctx, accountID)
questHelperActive = loadErr == nil && found &&
premium.Active(account, premium.DevilSlotType(premium.DevilSlotQuestHelper), s.gameplayNow().UTC())
}
if growthEffect, growthActive := s.currentGrowthContractEffect(ctx, accountID); growthActive {
growthQuestDropPercent = growthEffect.QuestItemDropRatePercent
}
now := s.gameplayNow().UTC()
committed := make(map[uint16]currentDungeonAltarQuestDropCommit, len(deaths))
err := repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterKey, func(tx dnfrepo.Group) error {
if tx.Settings == nil || tx.Quest == nil || tx.Inventory == nil {
return dnfrepo.ErrCharacterSettlementTransactionUnavailable
}
receipts, found, loadErr := tx.Settings.Load(ctx, receiptScope)
if loadErr != nil {
return loadErr
}
pending := make([]currentDungeonAltarDeathSequence, 0, len(deaths))
for _, death := range deaths {
committed[death.Sequence] = currentDungeonAltarQuestDropCommit{}
if found {
if raw, exists := receipts.Values[receiptKeys[death.Sequence]]; exists {
receipt, decodeErr := decodeCurrentDungeonAltarQuestDropReceipt(raw, death.Owner)
if decodeErr != nil {
return decodeErr
}
committed[death.Sequence] = currentDungeonAltarQuestDropCommit{
Body: receipt, Changed: len(receipt) != 0, Replayed: true,
}
continue
}
}
pending = append(pending, death)
}
if len(pending) == 0 {
return nil
}
questRecord, questFound, loadErr := tx.Quest.Load(ctx, characterKey)
if loadErr != nil {
return loadErr
}
if !questFound {
return nil
}
activeIDs := currentQuestDropActiveIDs(questRecord)
if len(activeIDs) == 0 {
return nil
}
catalog, catalogErr := s.loadQuestCatalog(ctx)
if catalogErr != nil || catalog == nil {
return fmt.Errorf("%w: load quest catalog: %v", errCurrentDungeonAltarQuestDropInvalid, catalogErr)
}
candidatesByMonster := make(map[int64][]currentQuestDropCandidate)
anyCandidates := false
for _, death := range pending {
candidates, cached := candidatesByMonster[death.Owner.MonsterCode]
if !cached {
candidates = s.currentQuestDropMatchMonster(
catalog,
activeIDs,
runtime.Dungeon.ID,
int64(runtime.Request.Difficulty),
death.Owner.MonsterCode,
)
candidatesByMonster[death.Owner.MonsterCode] = candidates
}
anyCandidates = anyCandidates || len(candidates) != 0
}
if !anyCandidates {
return nil
}
inventory, inventoryFound, loadErr := tx.Inventory.Load(ctx, characterKey)
if loadErr != nil {
return loadErr
}
if !inventoryFound {
return dnfdungeon.ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
// 账号共享仓要跟角色背包写在同一个事务里:无色/灵魂的落点是账号仓那个固定格,
// 所以先把它取进事务。取不到(账号仓仓库不可用/账号 id 对不上)时**不报错**,
// account 保持 nil,落点退回改造前的"只进背包" —— 宁可少搬一次家,也不能
// 让本来能掉的掉落整体失败(合服/单机库都可能没有账号仓这条记录)。
var account *dnfrepo.AccountInventoryRecord
if tx.AccountInventory != nil && accountID != "" {
record, accountFound, accountErr := tx.AccountInventory.Load(ctx, accountID)
if accountErr != nil {
s.logGameEvent(session, "game-dungeon-altar-quest-drop-account-unavailable",
"account_id", accountID,
"error", accountErr)
} else {
if !accountFound {
record = dnfrepo.AccountInventoryRecord{AccountID: accountID}
}
record = dnfrepo.CloneAccountInventory(record)
record.AccountID = accountID
if record.Slots == nil {
record.Slots = make(map[string]dnfrepo.ItemStack)
}
account = &record
}
}
itemCatalog, catalogErr := s.currentPVFItemCatalog()
if catalogErr != nil || itemCatalog == nil {
return fmt.Errorf("%w: load item catalog: %v", errCurrentDungeonAltarQuestDropInvalid, catalogErr)
}
inventoryChanged := false
accountChanged := false
receiptsChanged := false
if !found {
receipts = dnfrepo.SettingsRecord{Scope: receiptScope, Values: make(map[string]string)}
} else {
receipts = dnfrepo.CloneSettings(receipts)
if receipts.Values == nil {
receipts.Values = make(map[string]string)
}
}
for _, death := range pending {
candidates := candidatesByMonster[death.Owner.MonsterCode]
if len(candidates) == 0 {
continue
}
rng := newCurrentDungeonDropLCG(death.Owner.QuestDropSeed)
grantedSlots := make(map[int16]struct{})
grantedAccountSlots := make([]int16, 0, 2)
for _, candidate := range candidates {
held := currentQuestDropHeldCount(inventory, int64(candidate.ItemID))
if candidate.MaxStack > 0 && held >= candidate.MaxStack {
continue
}
dropRate := currentQuestItemDropRate(candidate.DropRate, growthQuestDropPercent, questHelperActive)
dropCount := currentQuestDropRoll(int(rng.NextUint32()), candidate.Count, dropRate, candidate.MaxStack, held)
if dropCount <= 0 {
continue
}
definition, resolveErr := itemCatalog.ResolveItem(candidate.ItemID)
if resolveErr != nil {
return resolveErr
}
// 落点规则与普通地图杀怪共用同一份实现:账号共享仓优先,余量进背包。
placement, placeErr := currentQuestDropPlaceCandidate(&inventory, account, definition, uint32(dropCount))
if placement.AccountChanged {
accountChanged = true
grantedAccountSlots = append(grantedAccountSlots, placement.AccountSlot)
}
if placement.CharacterChanged {
grantedSlots[placement.CharacterSlot] = struct{}{}
}
if placeErr != nil {
if errors.Is(placeErr, errDungeonPickupInventoryFull) {
continue
}
return placeErr
}
}
if len(grantedSlots) == 0 && len(grantedAccountSlots) == 0 {
continue
}
updates := make([]currentItemListEntry, 0, len(grantedSlots)+len(grantedAccountSlots))
for slot := range grantedSlots {
key := currentCeraShopInventorySlotKey(dnfrepo.MainInventoryListType, slot)
updates = append(updates, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, inventory.Slots[key]))
}
// 账号共享格也是 list-0 线地址(354..365),ACK 行必须从账号记录里取,
// 否则客户端看不到这次掉落、只会等下一次列表刷新。
if account != nil {
for _, slot := range grantedAccountSlots {
key := dnfrepo.AccountSharedInventorySlotKey(slot)
row, rowFound := account.Slots[key]
if !rowFound {
continue
}
updates = append(updates, currentItemListEntryFromStack(dnfrepo.MainInventoryListType, slot, row))
}
}
sortCurrentItemListEntries(updates)
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, updates)
committed[death.Sequence] = currentDungeonAltarQuestDropCommit{Body: body, Changed: true}
rawReceipt, encodeErr := encodeCurrentDungeonAltarQuestDropReceipt(death.Owner, body)
if encodeErr != nil {
return encodeErr
}
receipts.Values[receiptKeys[death.Sequence]] = rawReceipt
inventoryChanged = true
receiptsChanged = true
}
if inventoryChanged {
inventory.UpdatedAt = now
if saveErr := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); saveErr != nil {
return saveErr
}
}
if accountChanged && account != nil {
account.UpdatedAt = now
if saveErr := tx.AccountInventory.Save(ctx, *account); saveErr != nil {
return saveErr
}
}
if receiptsChanged {
receipts.UpdatedAt = now
if saveErr := dnfrepo.SaveSettingsFields(ctx, tx.Settings, receipts, dnfrepo.SettingsFieldValues); saveErr != nil {
return saveErr
}
}
return nil
})
if err != nil {
return nil, err
}
return committed, nil
}
func currentDungeonAltarQuestDropReceiptIdentity(
characterKey string,
runtime *runtimeDungeonState,
death currentDungeonAltarDeathSequence,
) (string, string, error) {
characterKey = strings.TrimSpace(characterKey)
if characterKey == "" || runtime == nil || runtime.lifecycleToken == 0 || runtime.startedAt.IsZero() ||
death.Sequence == 0 || death.Owner.RoomGeneration == 0 || death.Owner.RoundGeneration == 0 ||
death.Owner.MonsterCode <= 0 || death.Owner.QuestDropSeed == 0 {
return "", "", errCurrentDungeonAltarQuestDropInvalid
}
runIdentity := fmt.Sprintf(
"character=%s:dungeon=%d:maze=%d:seed=%d:lifecycle=%d:started=%d",
characterKey,
runtime.Dungeon.ID,
runtime.MazeIndex,
runtime.Seed,
runtime.lifecycleToken,
runtime.startedAt.UnixNano(),
)
digest := sha256.Sum256([]byte(runIdentity))
scope := fmt.Sprintf("character:%s:dungeon_altar_quest_drop:v1:%x", characterKey, digest[:12])
key := fmt.Sprintf("room:%d:round:%d:sequence:%d", death.Owner.RoomGeneration, death.Owner.RoundGeneration, death.Sequence)
return scope, key, nil
}
func encodeCurrentDungeonAltarQuestDropReceipt(
owner currentDungeonAltarSequenceOwner,
body []byte,
) (string, error) {
receipt := currentDungeonAltarQuestDropReceipt{
Version: currentDungeonAltarQuestDropReceiptVersion,
MonsterCode: owner.MonsterCode,
DropSeed: owner.QuestDropSeed,
UpdateBody: base64.StdEncoding.EncodeToString(body),
}
raw, err := json.Marshal(receipt)
if err != nil {
return "", err
}
return string(raw), nil
}
func decodeCurrentDungeonAltarQuestDropReceipt(
raw string,
owner currentDungeonAltarSequenceOwner,
) ([]byte, error) {
var receipt currentDungeonAltarQuestDropReceipt
if err := json.Unmarshal([]byte(raw), &receipt); err != nil {
return nil, fmt.Errorf("%w: decode receipt: %v", errCurrentDungeonAltarQuestDropInvalid, err)
}
if receipt.Version != currentDungeonAltarQuestDropReceiptVersion || receipt.MonsterCode != owner.MonsterCode ||
receipt.DropSeed != owner.QuestDropSeed {
return nil, errCurrentDungeonAltarQuestDropInvalid
}
body, err := base64.StdEncoding.DecodeString(receipt.UpdateBody)
if err != nil {
return nil, fmt.Errorf("%w: decode update body: %v", errCurrentDungeonAltarQuestDropInvalid, err)
}
return body, nil
}