服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
544 lines
20 KiB
Go
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(¤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
|
|
}
|
|
}
|