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(¤t.States, owner.States) changedProgress := mergeCompanionQuestStates(¤t.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 } }