服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
252 lines
9.6 KiB
Go
252 lines
9.6 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
cryptorand "crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||
)
|
||
|
||
type currentDungeonAdmissionCounterCommit struct {
|
||
session *gameSession
|
||
runtime *runtimeDungeonState
|
||
characterID uint16
|
||
dungeonID int64
|
||
ownerKind string
|
||
policies []dnfdungeonadmission.Policy
|
||
receipt string
|
||
restored bool
|
||
}
|
||
|
||
// currentDungeonAdmissionCounterBatch is reserved before inventory and
|
||
// runtime commits. Every later failure restores it after runtime and inventory
|
||
// compensation, preserving the reverse order of the admission transaction.
|
||
type currentDungeonAdmissionCounterBatch struct {
|
||
owner *dnfdungeonadmission.Owner
|
||
reservedAt time.Time
|
||
commits []*currentDungeonAdmissionCounterCommit
|
||
}
|
||
|
||
func (s *Service) reserveCurrentDungeonAdmissionCounters(
|
||
ctx context.Context,
|
||
leader *gameSession,
|
||
leaderRuntime *runtimeDungeonState,
|
||
preparedFollowers []*runtimePartyPreparedDungeonEntry,
|
||
) (*currentDungeonAdmissionCounterBatch, error) {
|
||
if leader == nil || leaderRuntime == nil || currentDungeonRuntimeIsPractice(leaderRuntime) {
|
||
return nil, nil
|
||
}
|
||
// 周常五图的账号额度上限要加上冒险团分档加成;这里(有 session)刷新缓存,
|
||
// 供下面的 currentDungeonAdmissionPolicies 读取。
|
||
s.refreshAbisAdventureGroupLevel(leader)
|
||
policies, err := s.currentDungeonAdmissionPolicies(uint32(leaderRuntime.Dungeon.ID))
|
||
if err != nil || len(policies) == 0 {
|
||
return nil, err
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok {
|
||
return nil, dnfdungeonadmission.ErrOwnerUnavailable
|
||
}
|
||
owner, err := dnfdungeonadmission.NewOwner(repositories)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
members := make([]*currentDungeonAdmissionCounterCommit, 0, len(preparedFollowers)+1)
|
||
members = append(members, ¤tDungeonAdmissionCounterCommit{
|
||
session: leader, runtime: leaderRuntime, characterID: leader.selectedCharacterID,
|
||
dungeonID: leaderRuntime.Dungeon.ID, ownerKind: "dungeon", policies: policies,
|
||
})
|
||
for _, prepared := range preparedFollowers {
|
||
if prepared == nil || prepared.session == nil || prepared.runtime == nil {
|
||
return nil, errors.New("prepared party member admission counter is incomplete")
|
||
}
|
||
if currentDungeonRuntimeIsPractice(prepared.runtime) {
|
||
return nil, errors.New("party admission mixes practice and ordinary runtimes")
|
||
}
|
||
memberPolicies, policyErr := s.currentDungeonAdmissionPolicies(uint32(prepared.runtime.Dungeon.ID))
|
||
if policyErr != nil {
|
||
return nil, policyErr
|
||
}
|
||
if !sameCurrentDungeonAdmissionPolicies(policies, memberPolicies) {
|
||
return nil, fmt.Errorf("party member %d admission policies differ from leader", prepared.session.selectedCharacterID)
|
||
}
|
||
members = append(members, ¤tDungeonAdmissionCounterCommit{
|
||
session: prepared.session, runtime: prepared.runtime, characterID: prepared.session.selectedCharacterID,
|
||
dungeonID: prepared.runtime.Dungeon.ID, ownerKind: "dungeon", policies: memberPolicies,
|
||
})
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
bootNonce, err := s.currentDungeonAdmissionReceiptNonce()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// The leader token identifies this complete party attempt; each receipt also
|
||
// contains a boot-unique nonce and member ID. The nonce prevents a service
|
||
// restart in the same service window from replaying an old durable receipt.
|
||
attemptToken := s.allocateDungeonLifecycleToken()
|
||
if attemptToken == 0 {
|
||
return nil, errors.New("dungeon admission attempt token is unavailable")
|
||
}
|
||
for _, member := range members {
|
||
if member.runtime.lifecycleToken != 0 {
|
||
return nil, fmt.Errorf("character %d prepared runtime already has lifecycle token", member.session.selectedCharacterID)
|
||
}
|
||
member.receipt = fmt.Sprintf(
|
||
"dungeon-admission-v2:boot:%s:attempt:%d:character:%d:dungeon:%d",
|
||
bootNonce,
|
||
attemptToken,
|
||
member.session.selectedCharacterID,
|
||
leaderRuntime.Dungeon.ID,
|
||
)
|
||
if _, err := owner.Preflight(ctx, fmt.Sprint(member.session.selectedCharacterID), member.policies, now); err != nil {
|
||
return nil, fmt.Errorf("preflight character %d dungeon admission counter: %w", member.session.selectedCharacterID, err)
|
||
}
|
||
}
|
||
|
||
batch := ¤tDungeonAdmissionCounterBatch{owner: owner, reservedAt: now, commits: make([]*currentDungeonAdmissionCounterCommit, 0, len(members))}
|
||
for _, member := range members {
|
||
// Register the exact receipt before Commit. SQL commit errors can be
|
||
// outcome-ambiguous; Restore is receipt-aware and therefore compensates
|
||
// a commit that actually landed while remaining a no-op when it did not.
|
||
batch.commits = append(batch.commits, member)
|
||
if _, err := owner.Commit(ctx, fmt.Sprint(member.session.selectedCharacterID), member.policies, member.receipt, now); err != nil {
|
||
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(batch); restoreErr != nil {
|
||
err = errors.Join(err, restoreErr)
|
||
}
|
||
return nil, fmt.Errorf("reserve character %d dungeon admission counter: %w", member.session.selectedCharacterID, err)
|
||
}
|
||
member.runtime.lifecycleToken = s.allocateDungeonLifecycleToken()
|
||
if member.runtime.lifecycleToken == 0 {
|
||
err := errors.New("dungeon runtime lifecycle token is unavailable")
|
||
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(batch); restoreErr != nil {
|
||
err = errors.Join(err, restoreErr)
|
||
}
|
||
return nil, err
|
||
}
|
||
s.logGameEvent(member.session, "game-dungeon-admission-counter-reserved",
|
||
"char_id", member.session.selectedCharacterID,
|
||
"dungeon_id", leaderRuntime.Dungeon.ID,
|
||
"policy_count", len(member.policies),
|
||
"attempt_token", attemptToken,
|
||
"runtime_token", member.runtime.lifecycleToken)
|
||
}
|
||
return batch, nil
|
||
}
|
||
|
||
func (s *Service) restoreCurrentDungeonAdmissionCounterBatch(batch *currentDungeonAdmissionCounterBatch) error {
|
||
if batch == nil || batch.owner == nil {
|
||
return nil
|
||
}
|
||
var restoreErr error
|
||
for index := len(batch.commits) - 1; index >= 0; index-- {
|
||
commit := batch.commits[index]
|
||
if commit == nil || commit.characterID == 0 || commit.restored {
|
||
continue
|
||
}
|
||
restoreCtx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
_, err := batch.owner.Restore(
|
||
restoreCtx,
|
||
fmt.Sprint(commit.characterID),
|
||
commit.policies,
|
||
commit.receipt,
|
||
batch.reservedAt,
|
||
)
|
||
cancel()
|
||
if err != nil {
|
||
if commit.session != nil {
|
||
s.logGameEvent(commit.session, "game-admission-counter-restore-failed",
|
||
"owner_kind", commit.ownerKind,
|
||
"char_id", commit.characterID,
|
||
"error", err)
|
||
}
|
||
restoreErr = errors.Join(restoreErr, fmt.Errorf("restore character %d %s admission counter: %w", commit.characterID, commit.ownerKind, err))
|
||
continue
|
||
}
|
||
commit.restored = true
|
||
if commit.session != nil {
|
||
s.logGameEvent(commit.session, "game-admission-counter-restored",
|
||
"owner_kind", commit.ownerKind,
|
||
"char_id", commit.characterID,
|
||
"dungeon_id", commit.dungeonID,
|
||
"policy_count", len(commit.policies))
|
||
}
|
||
}
|
||
return restoreErr
|
||
}
|
||
|
||
func (s *Service) currentDungeonAdmissionReceiptNonce() (string, error) {
|
||
if s == nil {
|
||
return "", errors.New("dungeon admission receipt owner is unavailable")
|
||
}
|
||
s.dungeonAdmissionReceiptOnce.Do(func() {
|
||
if s.dungeonAdmissionReceiptNonce != "" {
|
||
return
|
||
}
|
||
var raw [16]byte
|
||
if _, err := cryptorand.Read(raw[:]); err != nil {
|
||
s.dungeonAdmissionReceiptErr = fmt.Errorf("generate cryptographic process nonce: %w", err)
|
||
return
|
||
}
|
||
s.dungeonAdmissionReceiptNonce = hex.EncodeToString(raw[:])
|
||
})
|
||
if s.dungeonAdmissionReceiptErr != nil {
|
||
return "", s.dungeonAdmissionReceiptErr
|
||
}
|
||
if s.dungeonAdmissionReceiptNonce == "" {
|
||
return "", errors.New("dungeon admission receipt process nonce is unavailable")
|
||
}
|
||
return s.dungeonAdmissionReceiptNonce, nil
|
||
}
|
||
|
||
func (s *Service) currentDungeonAdmissionPolicies(dungeonID uint32) ([]dnfdungeonadmission.Policy, error) {
|
||
if s == nil || dungeonID == 0 {
|
||
return nil, nil
|
||
}
|
||
// 【2026-10-12 接通】神秘异次元遗迹五图(4906~4910)不进 catalog:
|
||
// etc/accountcharacdungeonincountchn.etc 只声明周周期,而实际准入是日额度
|
||
// (账号 5 次 + 冒险团分档,角色各图 2/1/1/1/1)。catalog.loadExplicitWeekly
|
||
// 对这份 PVF 只产出 Evidence、不产出 Policy,所以 PoliciesForDungeon 对它们
|
||
// 返回空。这里补上按天生成的策略,让它们复用既有的
|
||
// 预留 → 失败回滚 → 提交 全套语义(见 reserveCurrentDungeonAdmissionCounters)。
|
||
if spec, found := abisDungeonSpecFor(dungeonID); found {
|
||
// 冒险团等级取自角色 Stats["manage_level"];缺省 0 时 [adventure group
|
||
// add enterable count chn] 的加成是 0,等于纯基础 5 次。
|
||
return abisEntryPolicies(spec, s.currentAbisAdventureGroupLevel())
|
||
}
|
||
s.worldMapMu.RLock()
|
||
catalog := s.dungeonAdmissionCatalog
|
||
s.worldMapMu.RUnlock()
|
||
if catalog == nil {
|
||
// Synthetic bridge fixtures install their own worldmap without a complete
|
||
// active-PVF archive. Production preload always installs the catalog and
|
||
// fails startup if its structured sources cannot be audited.
|
||
s.initialEquipmentMu.Lock()
|
||
hasRuntimePVF := s.initialEquipmentArchive != nil
|
||
s.initialEquipmentMu.Unlock()
|
||
if hasRuntimePVF {
|
||
return nil, errors.New("dungeon admission counter catalog is unavailable")
|
||
}
|
||
return nil, nil
|
||
}
|
||
return catalog.PoliciesForDungeon(dungeonID), nil
|
||
}
|
||
|
||
func sameCurrentDungeonAdmissionPolicies(left, right []dnfdungeonadmission.Policy) bool {
|
||
if len(left) != len(right) {
|
||
return false
|
||
}
|
||
for index := range left {
|
||
if left[index].Key != right[index].Key || left[index].Scope != right[index].Scope ||
|
||
left[index].Period != right[index].Period || left[index].Limit != right[index].Limit ||
|
||
left[index].ResetWeekday != right[index].ResetWeekday {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|