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

544 lines
20 KiB
Go

package dnfbridge
import (
"context"
"crypto/sha256"
"fmt"
"math/rand/v2"
"sort"
"strconv"
"strings"
"time"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnfprofession "longheng.io/server/internal/modules/dnf/profession"
"longheng.io/server/internal/modules/dnf/progression"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
"longheng.io/server/internal/modules/dnf/skillcmd"
)
const companionProgressionReceiptPrefix = "companion_progression_receipt_"
// syncCurrentDungeonCompanionProgression mirrors one already committed owner
// event to the account's selected support soldiers. The owner is committed
// first; each support soldier then commits in its own settlement transaction.
// The hashed event receipt makes retries safe without adding a new table.
func (s *Service) syncCurrentDungeonCompanionProgression(
ctx context.Context,
session *gameSession,
eventKey string,
tables *progression.Tables,
gain uint32,
ownerQuest *dnfrepo.QuestRecord,
) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
(strings.TrimSpace(eventKey) == "") || (gain == 0 && ownerQuest == nil) {
return
}
if ctx == nil {
ctx = context.Background()
}
repos, ok := s.repositoryGroup()
if !ok || repos.CharacterSettlement == nil || repos.Character == nil || repos.Skill == nil || (gain > 0 && tables == nil) {
s.logGameEvent(session, "game-dungeon-companion-progression-skipped",
"event_key", eventKey, "reason", "repository_or_progression_unavailable")
return
}
ownerID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
if owner, found, loadErr := repos.Character.Load(ctx, ownerID); loadErr == nil && found {
accountID = strings.TrimSpace(owner.AccountID)
}
}
cfg, _, _, err := s.loadCurrentDungeonCompanionConfig(ctx, ownerID, accountID, repos)
if err != nil {
s.logGameEvent(session, "game-dungeon-companion-progression-skipped", "event_key", eventKey, "reason", "config_unavailable", "error", err)
return
}
if !cfg.Enabled || !cfg.ProgressionEnabled {
return
}
if accountID == "" {
return
}
// Every quest sync already carries the owner's complete committed quest
// snapshot. Carry its durable effects too, even when the support soldier's
// quest rows were copied by an older version and need no further changes.
var ownerCharacter *dnfrepo.CharacterRecord
if ownerQuest != nil {
owner, found, loadErr := repos.Character.Load(ctx, ownerID)
if loadErr != nil || !found || owner.CharacterID != ownerID ||
strings.TrimSpace(owner.AccountID) != accountID || ownerQuest.CharacterID != ownerID {
s.logGameEvent(session, "game-dungeon-companion-progression-skipped",
"event_key", eventKey, "reason", "committed_owner_unavailable_or_mismatched", "error", loadErr)
return
}
ownerCharacter = &owner
}
profiles, skillCatalog, resourceErr := s.currentProfessionResources(ctx)
if resourceErr != nil {
// Ordinary synchronization can proceed without automatic learning.
// A pending profession change requires these resources and aborts that source's
// settlement below so quest state cannot outpace its durable effects.
s.logGameEvent(session, "game-dungeon-companion-skill-auto-learn-deferred",
"event_key", eventKey, "reason", "skill_pvf_unavailable", "error", resourceErr)
profiles, skillCatalog = nil, nil
}
for _, sourceID := range cfg.Sources() {
if sourceID == ownerID {
continue
}
if identity, online := s.onlineGameSessionCharacterSnapshot(parseCurrentCompanionCharacterID(sourceID)); online && identity.session != session {
s.logGameEvent(session, "game-dungeon-companion-progression-skipped",
"source_character_id", sourceID, "event_key", eventKey, "reason", "source_online")
continue
}
deferred, err := s.applyCurrentDungeonCompanionProgression(
ctx, repos, accountID, sourceID, ownerID+":"+eventKey, tables, gain, ownerQuest,
ownerCharacter, profiles, skillCatalog,
)
if err != nil {
s.logGameEvent(session, "game-dungeon-companion-progression-failed",
"source_character_id", sourceID, "event_key", eventKey, "gain", gain, "error", err)
continue
}
if deferred {
s.logGameEvent(session, "game-dungeon-companion-awakening-deferred",
"source_character_id", sourceID, "event_key", eventKey,
"reason", "source_profession_does_not_support_owner_awakening")
}
s.logGameEvent(session, "game-dungeon-companion-progression-applied",
"source_character_id", sourceID, "event_key", eventKey, "gain", gain)
s.queueCurrentDungeonCompanionProgressionRefresh(session, sourceID)
}
}
func parseCurrentCompanionCharacterID(raw string) uint16 {
value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 16)
if err != nil || value == 0 {
return 0
}
return uint16(value)
}
func companionProgressionReceiptKey(eventKey string) string {
hash := sha256.Sum256([]byte(strings.TrimSpace(eventKey)))
return fmt.Sprintf("%s%x", companionProgressionReceiptPrefix, hash[:])
}
// currentCompanionMonsterExperienceEventKey 为「打怪经验」这笔结算生成一个
// 可重放的稳定事件键。清图回执要等 boss 死才创建,所以这里用**本次副本入场
// 的身份**(入场时间戳 + 种子 + 副本 + 层 + object key)当前缀,避免同一副本
// 的多次进场共用同一份回执、把第二次的经验镜像误判成已完成。
func currentCompanionMonsterExperienceEventKey(runtime *runtimeDungeonState, objectKey uint32) string {
if runtime == nil {
return fmt.Sprintf("monster:0:0:0:0:%d", objectKey)
}
return fmt.Sprintf("monster:%d:%d:%d:%d:%d", runtime.startedAt.UnixNano(),
runtime.Seed, runtime.Dungeon.ID, runtime.MazeIndex, objectKey)
}
func (s *Service) applyCurrentDungeonCompanionProgression(
ctx context.Context,
repos dnfrepo.Group,
accountID, sourceID, eventKey string,
tables *progression.Tables,
gain uint32,
ownerQuest *dnfrepo.QuestRecord,
ownerCharacter *dnfrepo.CharacterRecord,
profiles *dnfprofession.Profiles,
skillCatalog *dnfskill.Table,
) (bool, error) {
if repos.CharacterSettlement == nil {
return false, dnfrepo.ErrCharacterSettlementTransactionUnavailable
}
receiptKey := companionProgressionReceiptKey(eventKey)
now := time.Now().UTC()
var awakeningDeferred bool
err := repos.CharacterSettlement.WithinCharacterSettlement(ctx, sourceID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Skill == nil {
return dnfrepo.ErrCharacterSettlementTransactionUnavailable
}
character, found, err := tx.Character.Load(ctx, sourceID)
if err != nil {
return err
}
if !found || character.CharacterID != sourceID || strings.TrimSpace(character.AccountID) != accountID {
return fmt.Errorf("support soldier character/account mismatch: source=%s", sourceID)
}
// Receipts suppress EXP only. A replay must still be able to repair
// quest effects missed by the previous state-only implementation.
applyGain := gain > 0 && character.Stats[receiptKey] != 1
skill, found, err := tx.Skill.Load(ctx, sourceID)
if err != nil {
return err
}
if !found || skill.CharacterID != sourceID {
return fmt.Errorf("support soldier skill record missing: source=%s", sourceID)
}
character = dnfrepo.CloneCharacter(character)
skill = dnfrepo.CloneSkill(skill)
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
// 历史数据自愈:技能记录的 synced_level 可能**超前**于角色等级
// (GM 改过等级、克隆角色只复制了一半记录等)。实测取证:角色 3
// level=87 但 skill.synced_level=88,导致下面的
// PlanSettlementProgressionAtCap 以
// "dungeon settlement persisted state is invalid" 拒绝整笔镜像,
// 支援兵于是永远停在 grow_type=0(未觉醒)。
//
// 角色等级是权威,把记账字段拉回角色等级即可;技能本体和 SP/TP 都不动。
// 反向(synced_level 落后)不做静默对齐——那会跳过中间等级的 SP 发放,
// 宁可报错也不篡改成长账目。
if skill.Points.SyncedLevel != character.Level {
if skill.Points.SyncedLevel > character.Level {
skill.Points.SyncedLevel = character.Level
} else {
return fmt.Errorf("support soldier skill synced level behind character: source=%s synced=%d level=%d",
sourceID, skill.Points.SyncedLevel, character.Level)
}
}
if applyGain {
experience, present := character.Stats["exp"]
if !present || experience < 0 || character.Level <= 0 {
return fmt.Errorf("support soldier progression state invalid: source=%s", sourceID)
}
if uint64(experience) > ^uint64(0)>>32 {
return fmt.Errorf("support soldier experience outside u32: source=%s", sourceID)
}
planned, planErr := dnfdungeon.PlanSettlementProgressionAtCap(
tables, character.Level, uint32(experience), gain,
skill.Points, progression.CharacterLevelCap,
)
if planErr != nil {
return planErr
}
character.Level = planned.Experience.NewLevel
character.Stats["exp"] = int64(planned.Experience.NewExperience)
skill.Points = planned.SkillPoints.New
if planned.Experience.NewLevel != planned.Experience.PreviousLevel {
if levelStats, statErr := s.characterPVFStatValuesForLevel(character, planned.Experience.NewLevel); statErr == nil {
for key, value := range levelStats {
character.Stats[key] = value
}
}
}
}
effectsChanged, deferred, err := applyCurrentDungeonCompanionQuestEffects(&character, &skill, ownerCharacter, profiles, skillCatalog)
if err != nil {
return err
}
awakeningDeferred = deferred
if profiles != nil && skillCatalog != nil {
if err := autoLearnCurrentDungeonCompanionSkills(character, &skill, profiles, skillCatalog); err != nil {
return err
}
}
if applyGain {
character.Stats[receiptKey] = 1
}
if applyGain || effectsChanged {
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
return err
}
}
skill.UpdatedAt = now
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, dnfrepo.SkillFieldSkills, dnfrepo.SkillFieldPoints, dnfrepo.SkillFieldLayouts); err != nil {
return err
}
if ownerQuest != nil {
if tx.Quest == nil {
return dnfrepo.ErrCharacterSettlementTransactionUnavailable
}
if err := mergeCurrentDungeonCompanionQuest(ctx, tx.Quest, sourceID, *ownerQuest, now); err != nil {
return err
}
}
return nil
})
return awakeningDeferred && err == nil, err
}
// Quest completion and its durable effects share the support soldier's
// settlement transaction. Ordinary item/gold rewards remain suppressed.
// An unchosen class is selected randomly from the source job's PVF branches,
// never copied from the owner. Existing classes are retained, and awakening
// advances that same branch. Jobs without class branches keep branch zero.
func applyCurrentDungeonCompanionQuestEffects(
character *dnfrepo.CharacterRecord,
skill *dnfrepo.SkillRecord,
owner *dnfrepo.CharacterRecord,
profiles *dnfprofession.Profiles,
catalog *dnfskill.Table,
) (changed, deferred bool, err error) {
if owner == nil {
return false, false, nil
}
ownerGrow, sourceGrow := owner.Stats["grow_type"], character.Stats["grow_type"]
if ownerGrow < 0 || ownerGrow > 255 || sourceGrow < 0 || sourceGrow > 255 {
return false, false, fmt.Errorf("support soldier grow_type invalid: owner=%d source=%d", ownerGrow, sourceGrow)
}
ownerBranch, ownerAwakening := dnfprofession.Decode(byte(ownerGrow))
branch, sourceAwakening := dnfprofession.Decode(byte(sourceGrow))
if ownerAwakening > 2 || sourceAwakening > 2 {
return false, false, fmt.Errorf("support soldier awakening stage invalid: owner=%d source=%d", ownerAwakening, sourceAwakening)
}
if character.Stats == nil {
character.Stats = make(map[string]int64)
}
// Retain every existing source bit; only proven owner unlocks are added.
beforeSlots := character.Stats["ex_equip_slot_stat"]
ownerSlots := owner.Stats["ex_equip_slot_stat"]
if ownerSlots < 0 || beforeSlots < 0 {
return false, false, fmt.Errorf("support soldier equipment slot state invalid")
}
if slots := beforeSlots | (ownerSlots & int64(dnfquest.ExEquipSlotAll)); slots != beforeSlots {
character.Stats["ex_equip_slot_stat"] = slots
changed = true
}
needsClassChange := sourceGrow == 0 && (ownerBranch != 0 || ownerAwakening != 0)
if !needsClassChange && sourceAwakening >= ownerAwakening {
return changed, false, nil
}
if profiles == nil || catalog == nil {
return false, false, dnfprofession.ErrSourceRequired
}
job, parseErr := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if parseErr != nil {
return false, false, fmt.Errorf("support soldier job invalid: %w", parseErr)
}
if needsClassChange {
if !profiles.SupportsTarget(byte(job), 0) {
return false, false, fmt.Errorf("%w: support soldier job=%d", dnfprofession.ErrProfileMissing, job)
}
choices := make([]dnfprofession.Transition, 0, 4)
for target := byte(1); target <= 0x0f; target++ {
transition, planErr := profiles.PlanTransition(byte(job), 0, dnfprofession.Request{
Kind: dnfprofession.KindClassChange, ChainType: 1, GrowNumber: target, JobChangeQuest: 1,
})
if planErr == nil {
choices = append(choices, transition)
}
}
if len(choices) != 0 {
transition := choices[rand.IntN(len(choices))]
updated, skillErr := profiles.ApplySkillTransition(catalog, byte(job), character.Level, transition, *skill, skill.Points)
if skillErr != nil {
return false, false, skillErr
}
character.Stats["grow_type"] = int64(transition.NewGrowType)
branch = transition.FirstGrowType
*skill = updated
changed = true
}
}
for stage := sourceAwakening + 1; stage <= ownerAwakening; stage++ {
target, _ := dnfprofession.Encode(branch, stage)
if !profiles.SupportsTarget(byte(job), target) {
return changed, true, nil
}
request, requestErr := dnfprofession.ParseReward(int(stage)+1, "[awakening type]", []int64{int64(stage)})
if requestErr != nil {
return false, false, requestErr
}
transition, planErr := profiles.PlanTransition(byte(job), byte(character.Stats["grow_type"]), request)
if planErr != nil {
return false, false, planErr
}
updated, skillErr := profiles.ApplySkillTransition(catalog, byte(job), character.Level, transition, *skill, skill.Points)
if skillErr != nil {
return false, false, skillErr
}
character.Stats["grow_type"] = int64(transition.NewGrowType)
*skill = updated
changed = true
}
return changed, false, nil
}
// autoLearnCurrentDungeonCompanionSkills keeps PVF profession grants and
// fixed-level skills in sync, then spends newly available SP/TP on the first
// level of every newly eligible skill in deterministic PVF order. Existing
// player allocations are preserved; only an unlearned skill can be added.
func autoLearnCurrentDungeonCompanionSkills(
character dnfrepo.CharacterRecord,
record *dnfrepo.SkillRecord,
profiles *dnfprofession.Profiles,
catalog *dnfskill.Table,
) error {
if record == nil || profiles == nil || catalog == nil {
return nil
}
jobValue, err := strconv.ParseUint(strings.TrimSpace(character.Job), 10, 8)
if err != nil {
return err
}
job := byte(jobValue)
growType := byte(numericCharacterStatValue(character, "grow_type"))
first, awakening := dnfprofession.Decode(growType)
if record.Skills == nil {
record.Skills = make(map[int64]dnfrepo.SkillState)
}
if grants, ok := profiles.FreeGrantsAtCharacterLevel(catalog, job, first, awakening, character.Level); ok {
for _, grant := range grants {
state := record.Skills[int64(grant.SkillID)]
if state.Level < grant.Level {
state.Level = grant.Level
state.Enabled = true
record.Skills[int64(grant.SkillID)] = state
}
}
}
definitions := catalog.SkillsForJob(job)
sort.SliceStable(definitions, func(i, j int) bool {
if definitions[i].RequiredLevel != definitions[j].RequiredLevel {
return definitions[i].RequiredLevel < definitions[j].RequiredLevel
}
return definitions[i].ID < definitions[j].ID
})
for pass := 0; pass < len(definitions); pass++ {
changed := false
for _, definition := range definitions {
if definition.RequiredLevel > character.Level || !definition.SupportsCharacterGrowth(int(growType)) {
continue
}
state := record.Skills[int64(definition.ID)]
if state.Level > 0 || skillPrerequisitesBrokenForCompanion(record.Skills, definition) {
continue
}
maximum := definition.MaximumLevelForCharacterGrowth(int(growType))
if maximum == 0 {
continue
}
cost := definition.LevelCost(0)
if cost < 0 {
continue
}
if definition.IsTPSkill() {
if record.Points.RemainingTP < cost {
continue
}
record.Points.RemainingTP -= cost
} else {
if record.Points.RemainingSP < cost {
continue
}
record.Points.RemainingSP -= cost
}
record.Skills[int64(definition.ID)] = dnfrepo.SkillState{Level: 1, Enabled: true}
changed = true
}
if !changed {
break
}
}
existing := dnfrepo.SkillLayout(nil)
if record.Layouts != nil {
existing = record.Layouts[0]
}
layout, err := skillcmd.BuildCurrentSkillLayout(catalog, job, 0, record.Skills, existing)
if err != nil {
return err
}
if record.Layouts == nil {
record.Layouts = make(map[int]dnfrepo.SkillLayout)
}
record.Layouts[0] = layout
return nil
}
func skillPrerequisitesBrokenForCompanion(states map[int64]dnfrepo.SkillState, definition dnfskill.Skill) bool {
for _, prerequisite := range definition.Prerequisites {
if states[int64(prerequisite.SkillID)].Level < prerequisite.Level {
return true
}
}
return false
}
func mergeCurrentDungeonCompanionQuest(
ctx context.Context,
repo dnfrepo.QuestRepository,
sourceID string,
owner dnfrepo.QuestRecord,
now time.Time,
) error {
current, found, err := repo.Load(ctx, sourceID)
if err != nil {
return err
}
if !found {
current = dnfrepo.QuestRecord{CharacterID: sourceID}
}
current = dnfrepo.CloneQuest(current)
changedStates := mergeCompanionQuestStates(&current.States, owner.States)
changedProgress := mergeCompanionQuestStates(&current.Progress, owner.Progress)
if !changedStates && !changedProgress {
return nil
}
current.CharacterID = sourceID
current.UpdatedAt = now
fields := make([]dnfrepo.QuestField, 0, 2)
if changedStates {
fields = append(fields, dnfrepo.QuestFieldStates)
}
if changedProgress {
fields = append(fields, dnfrepo.QuestFieldProgress)
}
return dnfrepo.SaveQuestFields(ctx, repo, current, fields...)
}
func mergeCompanionQuestStates(target *map[int64]dnfrepo.QuestState, source map[int64]dnfrepo.QuestState) bool {
if target == nil || len(source) == 0 {
return false
}
if *target == nil {
*target = make(map[int64]dnfrepo.QuestState)
}
changed := false
for id, state := range source {
if !isCompanionQuestCompletedStatus(state.Status) {
continue
}
previous, exists := (*target)[id]
if exists && isCompanionQuestCompletedStatus(previous.Status) {
continue
}
// Copy completion semantics, never the owner's reward receipt or active
// objectives. The source receives EXP separately and cannot claim these
// rewards again under the owner's character/account receipt.
extra := map[string]string{
"reward_state": "granted", "auto_completed": "true",
"auto_complete_reason": "companion_progression",
}
for _, key := range []string{"pvf_path", "quest_type", "clear_flag"} {
if value := state.Extra[key]; value != "" {
extra[key] = value
}
}
state.Extra = extra
state.ProgressValue, state.RewardSelectIndex, state.Multiplier = 0, 0, 0
(*target)[id] = state
changed = true
}
return changed
}
func isCompanionQuestCompletedStatus(status string) bool {
status = strings.ToLower(strings.TrimSpace(status))
status = strings.ReplaceAll(status, "_", "")
status = strings.ReplaceAll(status, "-", "")
status = strings.ReplaceAll(status, " ", "")
switch status {
case "complete", "completed", "cleared", "finished", "done":
return true
default:
return false
}
}