lawless/docs/技术文档/TDD-06-离线挂机结算系统设计.md

53 KiB

TDD-06 离线挂机结算系统设计

文档类型技术设计文档Technical Design Document 版本1.1 日期2026-07-02 关联文档TDD-04《数据库表结构设计》、TDD-05《API接口设计》、GDD-03《战斗系统设计》、GDD-06《经济系统设计》、GDD-07《帮派门派社交系统设计》、GDD-22《开放世界随机事件》、GDD-23《能量体系与功法相性设计》


1. 文档信息

项目 说明
目标 为《洪荒大陆》挂机手游定义离线挂机结算系统的服务端技术方案,覆盖离线时间快进、资源批量结算、ATB战斗离线结算、弟子代挂、游历事件触发、上线结算面板等核心模块。
读者 服务端开发Nakama/Go、数值策划、测试
技术栈 Nakama 3.x + Go插件 + PostgreSQL 16 + Valkey + Nacos 2.x
核心约束 无任务系统、无赛季重置、概率/机遇驱动、文字战报、ATB行动条、功法加持、能量体系非体力
游戏时间 现实:游戏 = 1:3

2. 系统总览

2.1 离线结算的核心循环

玩家下线
    ↓
记录 last_online_at + 当前状态快照
    ↓
服务端定时任务扫描离线玩家(每 5 分钟)
    ↓
时间快进算法:计算离线期间的事件队列
    ↓
批量结算:资源产出 / 弟子代挂 / 游历事件 / 战斗
    ↓
生成结算结果 JSON压缩存储到 Valkey
    ↓
玩家上线
    ↓
拉取结算结果 → 展示结算面板 → 应用状态变更到 DB

2.2 设计原则

原则 说明
服务端权威 所有离线结算逻辑在服务端完成,客户端只负责展示结算面板
延迟结算 离线期间只记录事件队列,玩家上线时才批量执行结算(减少 DB 写入压力)
时间快进 离线时间按游戏时间 1:3 换算后,以固定步长快进模拟事件触发
幂等性 结算结果带版本号,重复登录不会重复结算
上限兜底 背包满/货币上限/弟子异常等边界情况在结算时统一处理

3. 离线期间事件触发的服务端模拟逻辑

3.1 时间快进算法

离线期间不实时模拟,而是玩家上线时通过"时间快进"算法一次性计算离线期间的所有事件。

算法OfflineTimeWarp(character_id, last_online_at, now)

输入:
  last_online_at  — 玩家最后在线时间timestamptz
  now             — 当前时间timestamptz

输出:
  event_queue     — 离线期间触发的事件列表

步骤:
  1. 计算离线时长(现实时间)
     offline_duration_real = now - last_online_at
     offline_duration_game = offline_duration_real × 3  // 现实:游戏 = 1:3

  2. 限制最大离线结算时长(按境界递增,防止超长离线导致结算爆炸)
     max_offline_hours = 按角色境界动态确定(见下方境界递增表)
     if offline_duration_real > max_offline_hours:
         offline_duration_real = max_offline_hours
         offline_duration_game = max_offline_hours × 3

     境界递增离线上限表:
       炼气期 → 72 现实小时默认基准,约3天
       筑基期 → 96 现实小时约4天
       金丹期 → 120 现实小时约5天,新默认上限
       元婴期及以上 → 无上限(完全离线结算,不限制时长)

     // 通过 Nacos 配置可覆盖:
     //   offline.max_settle_hours.default = 120默认上限,金丹期
     //   offline.max_settle_hours.qi_refining = 72
     //   offline.max_settle_hours.foundation = 96
     //   offline.max_settle_hours.golden_core = 120
     //   offline.max_settle_hours.nascent_soul = 00表示无上限

  3. 确定时间步长tick_interval
     // 每个 tick 代表一段游戏时间,期间可能触发 0~N 个事件
     tick_interval_game = 1 游戏小时  // 可通过 Nacos 配置

  4. 加载玩家离线前状态快照
     snapshot = load_character_snapshot(character_id)
     // 包含:境界、属性、装备、功法、弟子列表、当前挂机状态

  5. 加载区域事件池
     zone_events = load_zone_event_pool(snapshot.zone_id, snapshot.world_tier)
     // 来自 GDD-22 的区域事件配置

  6. 时间快进循环
     current_game_time = last_online_at × 3  // 转为游戏时间
     end_game_time = now × 3
     event_queue = []

     while current_game_time < end_game_time:
         // 6a. 检查定时触发事件
         for event in zone_events:
             if event.trigger_type == "periodic":
                 if current_game_time % event.period == 0:
                     event_queue.append(Event(event, current_game_time))

         // 6b. 概率触发事件(游历/挂机遭遇)
         for event in zone_events:
             if event.trigger_type == "probability":
                 roll = random(0, 1)
                 // 概率衰减:连续触发同类事件时概率递减
                 decay = get_fatigue_decay(character_id, event.category)
                 effective_rate = event.base_rate × decay
                 if roll < effective_rate:
                     event_queue.append(Event(event, current_game_time))

         // 6c. 弟子代挂事件检查
         for disciple in snapshot.disciples:
             if disciple.status == "dispatched":
                 disciple_tick_result = simulate_disciple_tick(disciple, current_game_time)
                 event_queue.append(disciple_tick_result)

         // 6d. 推进时间
         current_game_time += tick_interval_game

  7. 返回 event_queue

3.2 事件队列结构

type OfflineEvent struct {
    EventID       string                 `json:"event_id"`
    EventType     string                 `json:"event_type"`     // combat / gather / disciple / encounter / resource
    TriggerTime   int64                  `json:"trigger_time"`   // 游戏时间戳
    ZoneID        string                 `json:"zone_id"`
    WorldTier     int                    `json:"world_tier"`
    RealmTier     int                    `json:"realm_tier"`
    Payload       map[string]interface{} `json:"payload"`        // 事件特定数据
    Resolved      bool                   `json:"resolved"`       // 是否已结算
    Result        map[string]interface{} `json:"result"`         // 结算结果
}

3.3 事件队列回放

// 玩家上线时调用
func SettleOfflineEvents(characterID string) (*SettlementReport, error) {
    // 1. 加载事件队列
    events := loadEventQueue(characterID)

    // 2. 按时间顺序回放
    report := &SettlementReport{CharacterID: characterID}
    for _, event := range events {
        if event.Resolved {
            continue  // 已结算跳过(幂等)
        }

        switch event.EventType {
        case "combat":
            result := resolveOfflineCombat(event)
            report.Battles = append(report.Battles, result)
        case "gather":
            result := resolveOfflineGather(event)
            report.Resources = append(report.Resources, result)
        case "disciple":
            result := resolveDiscipleEvent(event)
            report.DiscipleEvents = append(report.DiscipleEvents, result)
        case "encounter":
            result := resolveEncounter(event)
            report.Encounters = append(report.Encounters, result)
        }

        event.Resolved = true
        saveEventResult(event)
    }

    // 3. 批量应用状态变更
    applySettlementToDatabase(report)

    // 4. 压缩存储结算报告
    saveCompressedReport(characterID, report)

    return report, nil
}

4. 离线产出计算公式

4.1 挂机资源产出(接入 GDD-06 经济参数)

离线挂机产出遵循 GDD-06 §5.3 的"单位时间/风险期望产出参考"。

离线挂机资源产出公式:

  base_output = zone_base_rate × realm_multiplier × game_hours
  actual_output = base_output × quality_modifier × fatigue_decay × facility_bonus

参数说明:
  zone_base_rate    — 当前区域基础产出率(来自 GDD-08-附录A
  realm_multiplier  — 境界倍率(炼气 1.0 / 筑基 1.5 / 金丹 2.5 / 元婴 4.0 / 化神 6.5 / 合体 10.0
  game_hours        — 离线游戏时长(现实小时 × 3
  quality_modifier  — 弟子/装备品质修正1.0~2.5
  fatigue_decay     — 疲劳衰减系数(连续挂机同一区域,每游戏小时 -5%,最低 0.3
  facility_bonus    — 门派/领地设施加成1.0~2.2

接入 GDD-06 各境界产出基线

境界 1 游戏小时基础产出 离线挂机上限(现实 24h
炼气期 3~6 铜钱 或 低品灵石碎片×0.2 216~432 铜钱
筑基期 15~30 铜钱 或 灵石×0.3~0.5 1080~2160 铜钱
金丹期 灵石中品×0.4~0.8 28.8~57.6 中品灵石
元婴期 魂晶/仙晶碎片×0.3~0.6 21.6~43.2 碎片
化神期 仙晶×0.2~0.4 14.4~28.8 仙晶
合体期 仙晶上品×0.15~0.3 10.8~21.6 上品仙晶

4.2 弟子代挂产出(接入 GDD-07 弟子系统)

弟子代挂产出公式:

  disciple_output = base_output × disciple_quality × race_match × skill_match × guild_facility

参数说明:
  base_output       — 同 §4.1 的区域基础产出
  disciple_quality  — 弟子品质系数(凡品 0.8 / 良品 1.0 / 优品 1.3 / 极品 1.8 / 仙品 2.5
  race_match        — 种族匹配系数(地精挖矿 1.3 / 矮人锻造 1.2 / 其他 1.0
  skill_match       — 生活技能匹配系数(匹配 1.5 / 不匹配 1.0
  guild_facility    — 门派设施等级加成Lv1 1.0 / Lv2 1.2 / Lv3 1.45 / Lv4 1.8 / Lv5 2.2

弟子品质与产出对照表GDD-07 §2.3.2

品质 出现概率 效率加成 特殊能力
凡品 55% 80%
良品 30% 100% 小幅降低意外率
优品 12% 130% 可学习一种生活技能加成
极品 2.8% 180% 有概率触发双倍产出
仙品 0.2% 250% 可独立触发小型奇遇

4.3 游历事件产出

游历事件产出公式:

  explore_output = event_reward × risk_multiplier × time_efficiency × sect_bonus

参数说明:
  event_reward      — 事件基础奖励(来自 GDD-22 事件池配置)
  risk_multiplier   — 风险倍率(闲逛 1.0 / 历练 1.3 / 秘境 1.8 / 古迹 2.5 / 禁地 4.0
  time_efficiency   — 时间效率(离线游历按实际游戏时长折算,非满效率)
  sect_bonus        — 系统门派游历加成(门派等级 × 2.5%,最高 25%

5. 离线 ATB 战斗结算(接入 GDD-03 战斗公式)

5.1 离线战斗触发条件

离线期间战斗由以下场景触发:

  • 游历途中遭遇野怪/精英/BossGDD-03 §6
  • 弟子代挂遭遇战斗
  • 被其他玩家 PVP 挑战离线战书默认接受,GDD-03 14

5.2 服务端完整 ATB 战斗计算

离线战斗采用与在线战斗完全相同的 ATB 引擎,服务端一次性完整计算。

// 离线ATB战斗结算接入 GDD-03 §三 ATB 机制)
func SimulateOfflineBattle(attacker, defender *CombatUnit, context BattleContext) *BattleResult {
    // 1. 初始化战斗状态
    battle := &BattleState{
        Units:      []*CombatUnit{attacker, defender},
        Tick:       0,
        MaxTicks:   3000,   // ✅10 战斗行动时间上限
        MaxActions: 50,     // ✅10 单方最多50次行动
    }

    // 2. 初始化行动条(先手/伏击判定)
    // GDD-03 §3.1:普通遭遇双方初始 0;先手方初始 50
    attacker.ATBGauge = context.AttackerInitGauge  // 0 或 50
    defender.ATBGauge = context.DefenderInitGauge   // 0 或 50

    // 3. 加载技能候选池
    attacker.Skills = loadActiveSkills(attacker.CharacterID)
    defender.Skills = loadActiveSkills(defender.CharacterID)

    // 4. ATB 主循环
    report := &BattleReport{Rounds: []RoundLog{}}

    for battle.Tick < battle.MaxTicks {
        battle.Tick++

        // 4a. 行动条填充GDD-03 §3.1
        for _, unit := range battle.Units {
            if unit.HP > 0 {
                increment := unit.Speed * 0.1  // ATB_BASE_COEFFICIENT = 0.1
                unit.ATBGauge += increment
            }
        }

        // 4b. 检查可行动单位
        readyUnits := []*CombatUnit{}
        for _, unit := range battle.Units {
            if unit.ATBGauge >= 100 && unit.HP > 0 {
                readyUnits = append(readyUnits, unit)
            }
        }

        // 4c. 同 tick 满条冲突判定GDD-03 §3.1
        if len(readyUnits) > 1 {
            sort.Slice(readyUnits, func(i, j int) bool {
                vi := readyUnits[i].Speed * (1 + randomFloat(-0.1, 0.1))
                vj := readyUnits[j].Speed * (1 + randomFloat(-0.1, 0.1))
                return vi > vj
            })
        }

        // 4d. 逐个执行行动
        for _, unit := range readyUnits {
            target := getOpponent(battle, unit)

            // 控制状态检查
            if unit.HasStatus("stun") {
                report.Rounds = append(report.Rounds, RoundLog{
                    Tick: battle.Tick, Actor: unit.ID,
                    Action: "stunned", Message: "眩晕中无法行动",
                })
                unit.ATBGauge = 0
                continue
            }

            // 逃跑判定GDD-03 §3.8 ✅32-✅34
            // 玩家预设血量/能量/SAN阈值,达到阈值后每次ATB满自动尝试逃跑
            if unit.IsPlayer && shouldAttemptEscape(unit, context.EscapeConfig) {
                escapeRate := calculateEscapeRate(unit, target, context)
                if random(100) < escapeRate {
                    // 逃跑成功:本场战利品清零,无死亡惩罚
                    report.Rounds = append(report.Rounds, RoundLog{
                        Tick: battle.Tick, Actor: unit.ID,
                        Action: "escape_success", Message: "成功逃离战斗",
                    })
                    result := &BattleResult{
                        Winner:          "escape",
                        EndCondition:    "escape_success",
                        LootCleared:     true,  // ✅33 逃跑成功战利品清零
                        DeathPenalty:    false,  // ✅33 无死亡惩罚
                    }
                    report.Result = result
                    return result
                } else {
                    // 逃跑失败:无惩罚,继续战斗
                    report.Rounds = append(report.Rounds, RoundLog{
                        Tick: battle.Tick, Actor: unit.ID,
                        Action: "escape_fail", Message: "逃跑失败",
                    })
                    unit.ATBGauge = 0
                    continue
                }
            }

            // 技能选择GDD-03 §3.3 ✅24 触发率机制)
            skill := selectSkill(unit, target, battle)

            // 命中判定
            hitChance := 100 - target.DodgeRate + unit.HitBonus
            if random(100) > hitChance {
                report.Rounds = append(report.Rounds, RoundLog{
                    Tick: battle.Tick, Actor: unit.ID, Skill: skill.ID,
                    Action: "miss", Message: "身形一晃,闪过了攻击",
                })
                unit.ATBGauge = 0
                continue
            }

            // 伤害计算GDD-03 §2.2
            damage := calculateDamage(unit, target, skill, context)

            // 暴击判定GDD-03 §2.2 ✅18
            critRate := unit.Luck * 0.15 + skill.CritBonus
            if critRate > 60 { critRate = 60 }  // 上限60%
            isCrit := random(100) < critRate
            if isCrit {
                critMultiplier := 1.5  // 基础×1.5
                if critMultiplier > 2.0 { critMultiplier = 2.0 }  // 上限×2.0
                damage = damage * critMultiplier
            }

            // 应用伤害
            target.HP -= damage
            if target.HP < 0 { target.HP = 0 }

            // 记录战报
            report.Rounds = append(report.Rounds, RoundLog{
                Tick:    battle.Tick,
                Actor:   unit.ID,
                Skill:   skill.ID,
                Damage:  damage,
                IsCrit:  isCrit,
                HPAfter: map[string]int{attacker.ID: attacker.HP, defender.ID: defender.HP},
            })

            // 行动条归零
            unit.ATBGauge = 0

            // 行动次数计数
            unit.ActionCount++
            if unit.ActionCount >= battle.MaxActions {
                goto endBattle
            }

            // 死亡判定
            if target.HP <= 0 {
                goto endBattle
            }

            // 天赋触发判定GDD-03 §4.2
            checkAndTriggerTalents(unit, target, battle, report)
        }

        // 4e. DOT/状态效果结算
        processStatusEffects(battle, report)

        // 4f. 战斗结束条件检查GDD-03 §3.7
        if attacker.HP <= 0 || defender.HP <= 0 {
            break
        }
    }

endBattle:
    // 5. 判定胜负GDD-03 §3.7 超时处理)
    result := determineWinner(battle, context)
    report.Result = result

    return result
}

// 逃跑判定辅助函数GDD-03 §3.8 ✅32-✅34

// shouldAttemptEscape 判断是否应该尝试逃跑
func shouldAttemptEscape(unit *CombatUnit, config EscapeConfig) bool {
    // 检查血量阈值
    if config.HPThreshold > 0 {
        hpPercent := float64(unit.HP) / float64(unit.MaxHP) * 100
        if hpPercent <= config.HPThreshold {
            return true
        }
    }
    // 检查能量阈值
    if config.EPThreshold > 0 {
        epPercent := float64(unit.EP) / float64(unit.MaxEP) * 100
        if epPercent <= config.EPThreshold {
            return true
        }
    }
    // 检查SAN阈值
    if config.SANThreshold > 0 && unit.SAN > 0 {
        if unit.SAN <= config.SANThreshold {
            return true
        }
    }
    return false
}

// calculateEscapeRate 计算逃跑成功率GDD-03 §3.8.3 ✅34
func calculateEscapeRate(escaper, chaser *CombatUnit, context BattleContext) float64 {
    // 基础率35%
    baseRate := 35.0

    // 速度差系数:己方速度/对方速度上限2.0,下限0.5
    speedRatio := float64(escaper.Speed) / float64(chaser.Speed)
    if speedRatio > 2.0 { speedRatio = 2.0 }
    if speedRatio < 0.5 { speedRatio = 0.5 }

    // 状态修正
    statusMod := 1.0
    if escaper.HasStatus("stun") || escaper.HasStatus("root") {
        return 0  // 眩晕/定身无法逃跑
    }
    if escaper.HasStatus("slow") {
        statusMod = 0.7
    }
    if escaper.HasStatus("haste") {
        statusMod = 1.3
    }

    // 道具修正
    itemMod := 1.0
    if context.HasItem("escape_talisman") {
        itemMod = 1.5
    }
    if context.HasItem("teleport_talisman") {
        return 100  // 瞬移符直接成功
    }

    finalRate := baseRate * speedRatio * statusMod * itemMod
    if finalRate > 100 { finalRate = 100 }
    return finalRate
}

5.3 战斗伤害计算公式(接入 GDD-03 §2.2

// 物理伤害GDD-03 §2.2
func calculatePhysicalDamage(attacker, defender *CombatUnit, skill *Skill) float64 {
    baseDamage := float64(attacker.Str)*1.0 + attacker.WeaponAtk  // 攻击系数=1.0
    skillDamage := baseDamage * skill.DamageCoeff

    // 亲和度+共鸣修正GDD-03 §4.4 ✅20
    affinityMod := 1.0 + skill.AffinityMod
    resonanceMod := 1.0 + skill.ResonanceMod
    skillDamage = skillDamage * affinityMod * resonanceMod

    // 物理减伤率GDD-03 §2.2
    defCoeff := 0.5
    defConst := getDefenseConstant(defender.WorldTier)  // Tier1:100 / Tier2:150 / ... / Tier6:640
    phyReduce := float64(defender.Vit)*defCoeff / (float64(defender.Vit)*defCoeff + float64(defConst))

    finalDamage := skillDamage * (1 - phyReduce)

    // 阵营伤害修正GDD-03 §2.3 ✅12
    alignmentMod := getAlignmentModifier(skill.Alignment, defender.Alignment)
    finalDamage = finalDamage * alignmentMod

    // 状态修正
    finalDamage = finalDamage * attacker.StatusAtkMod

    return math.Max(finalDamage, 1)  // 最低1点伤害
}

// 法术伤害GDD-03 §2.2
func calculateMagicalDamage(attacker, defender *CombatUnit, skill *Skill) float64 {
    baseDamage := float64(attacker.Spi)*1.0 + attacker.ArtifactAtk  // 法攻系数=1.0
    skillDamage := baseDamage * skill.DamageCoeff

    // 亲和度+共鸣修正
    affinityMod := 1.0 + skill.AffinityMod
    resonanceMod := 1.0 + skill.ResonanceMod
    skillDamage = skillDamage * affinityMod * resonanceMod

    // 法术减伤率
    magCoeff := 0.5
    magConst := getDefenseConstant(defender.WorldTier)  // 与防御常数一致
    magReduce := float64(defender.Spi)*magCoeff / (float64(defender.Spi)*magCoeff + float64(magConst))

    finalDamage := skillDamage * (1 - magReduce)

    // 元素克制修正GDD-03 §2.4 ✅19
    elementMod := getElementModifier(skill.Element, defender.Element)
    finalDamage = finalDamage * elementMod

    // 阵营伤害修正
    alignmentMod := getAlignmentModifier(skill.Alignment, defender.Alignment)
    finalDamage = finalDamage * alignmentMod

    return math.Max(finalDamage, 1)
}

5.4 离线战斗性能优化

离线战斗与在线战斗共用同一套 ATB 引擎,但有以下优化:

优化项 说明
跳过战报文案渲染 离线计算只生成结构化数据,不生成文字战报文案;文案在客户端展示时即时生成
批量预加载 一次性加载角色战斗快照 + 技能列表 + 天赋列表,减少 DB 查询
计算结果缓存 战斗结果缓存到 Valkey,上线时直接读取
异步结算 玩家上线时先返回"结算中"状态,后台 Goroutine 异步完成计算

5.5 离线战斗场景与惩罚对照(接入 GDD-03 §6.2

战斗场景 失败惩罚 离线处理
普通野怪战败 不触发死亡惩罚;产出 -60% 事件标记为 combat_loss_normal
精英怪战败 轻度死亡惩罚(进度 -10% 事件标记为 combat_loss_elite,应用轻度惩罚
Boss 战败 完整死亡惩罚(进度 -20%~30% + 纯度 + 道伤) 事件标记为 combat_loss_boss,应用完整惩罚
PVP 失败 完整死亡惩罚 事件标记为 pvp_loss,应用完整惩罚

6. 离线上限与溢出处理

6.1 背包满处理

// 离线产出溢出处理
func handleInventoryOverflow(characterID string, items []ItemDrop) {
    inventory := loadInventory(characterID)
    maxSlots := inventory.MaxSlots  // 由境界/装备决定

    for _, item := range items {
        if inventory.UsedSlots >= maxSlots {
            // 背包满:转入邮件系统
            sendToMail(characterID, item, MailType_OfflineOverflow, "离线产出溢出")
            continue
        }

        // 可堆叠物品尝试合并
        if existing := inventory.FindStackable(item.ItemID); existing != nil {
            remaining := existing.MaxStack - existing.Quantity
            if remaining >= item.Quantity {
                existing.Quantity += item.Quantity
            } else {
                existing.Quantity = existing.MaxStack
                overflow := item.Quantity - remaining
                sendToMail(characterID, Item{ItemID: item.ItemID, Quantity: overflow},
                    MailType_OfflineOverflow, "离线产出溢出")
            }
        } else {
            inventory.AddItem(item)
        }
    }
}

6.2 货币上限处理

// 离线货币产出上限处理(接入 GDD-06 经济参数)
func handleCurrencyOverflow(characterID string, currencyCode string, amount decimal.Decimal) {
    balance := loadCurrencyBalance(characterID, currencyCode)
    maxBalance := getCurrencyMaxBalance(currencyCode)  // 由 Nacos 配置

    if balance.Amount + amount > maxBalance {
        overflow := (balance.Amount + amount) - maxBalance
        balance.Amount = maxBalance

        // 溢出部分按比例折算为低一档货币或转入邮件
        converted := convertOverflowCurrency(currencyCode, overflow)
        if converted != nil {
            addCurrency(characterID, converted.Code, converted.Amount)
        }
    } else {
        balance.Amount += amount
    }

    // 写入经济审计日志TDD-04 §5.4
    writeEconomyAudit(characterID, currencyCode, "faucet", "offline_settle", amount)
}

6.3 弟子状态异常处理

异常状态 处理方式
弟子死亡 标记 status = "dead",记录 died_at,生成墓碑数据GDD-07,不产出后续收益
弟子叛逃 标记 status = "deserted",已产出保留,后续产出取消
弟子受伤 标记临时 debuff,产出效率 -30%~50%,持续 N 游戏小时
弟子顿悟 标记 status = "insight",产出效率 +50%,持续 N 游戏小时,可能解锁新技能

7. 上线结算面板数据结构

7.1 结算报告 JSON Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "OfflineSettlementReport",
  "type": "object",
  "required": ["report_id", "character_id", "offline_duration", "settlement_time", "summary"],
  "properties": {
    "report_id": {
      "type": "string",
      "description": "结算报告唯一IDUUID"
    },
    "character_id": {
      "type": "string",
      "description": "角色ID"
    },
    "settlement_version": {
      "type": "integer",
      "description": "结算版本号,用于幂等校验"
    },
    "offline_duration": {
      "type": "object",
      "properties": {
        "real_seconds": { "type": "integer", "description": "离线现实秒数" },
        "game_hours": { "type": "number", "description": "离线游戏时长(小时)" },
        "capped": { "type": "boolean", "description": "是否触及上限截断" }
      }
    },
    "settlement_time": {
      "type": "string",
      "format": "date-time",
      "description": "结算执行时间"
    },
    "summary": {
      "type": "object",
      "description": "收益汇总",
      "properties": {
        "total_currency": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "currency_code": { "type": "string" },
              "amount": { "type": "string", "description": "Decimal字符串" },
              "source": { "type": "string", "enum": ["gather", "disciple", "combat_drop", "encounter"] }
            }
          }
        },
        "total_items": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "item_id": { "type": "string" },
              "item_name": { "type": "string" },
              "quantity": { "type": "integer" },
              "rarity": { "type": "string" }
            }
          }
        },
        "overflow_items_count": { "type": "integer", "description": "溢出到邮件的物品数" },
        "exp_gained": { "type": "string", "description": "修为增长" },
        "energy_change": { "type": "string", "description": "能量变化" }
      }
    },
    "battles": {
      "type": "array",
      "description": "战斗摘要列表",
      "items": {
        "type": "object",
        "properties": {
          "battle_id": { "type": "string" },
          "battle_type": { "type": "string", "enum": ["expedition_pve", "pvp", "disciple_combat"] },
          "trigger_time": { "type": "string", "format": "date-time" },
          "opponent_name": { "type": "string" },
          "opponent_race": { "type": "string" },
          "opponent_realm": { "type": "string" },
          "result": { "type": "string", "enum": ["win", "lose", "draw"] },
          "end_condition": { "type": "string", "enum": ["hp_zero", "timeout", "surrender"] },
          "rounds_count": { "type": "integer", "description": "行动次数" },
          "damage_dealt": { "type": "string" },
          "damage_taken": { "type": "string" },
          "death_penalty_applied": { "type": "boolean" },
          "drops_summary": {
            "type": "object",
            "properties": {
              "currency": { "type": "array" },
              "items": { "type": "array" }
            }
          }
        }
      }
    },
    "disciple_events": {
      "type": "array",
      "description": "弟子代挂事件列表",
      "items": {
        "type": "object",
        "properties": {
          "disciple_id": { "type": "string" },
          "disciple_name": { "type": "string" },
          "disciple_quality": { "type": "string" },
          "event_type": { "type": "string", "enum": ["gather", "combat", "insight", "death", "desertion", "treasure"] },
          "trigger_time": { "type": "string", "format": "date-time" },
          "result": { "type": "string", "enum": ["success", "fail", "death", "insight"] },
          "rewards": { "type": "object" },
          "message": { "type": "string", "description": "叙事化描述" }
        }
      }
    },
    "encounters": {
      "type": "array",
      "description": "游历事件列表",
      "items": {
        "type": "object",
        "properties": {
          "encounter_id": { "type": "string" },
          "encounter_type": { "type": "string" },
          "trigger_time": { "type": "string", "format": "date-time" },
          "zone_name": { "type": "string" },
          "title": { "type": "string" },
          "description": { "type": "string" },
          "auto_choice": { "type": "string", "description": "离线自动选择的分支" },
          "choice_reason": { "type": "string", "description": "选择策略说明" },
          "result": { "type": "object" }
        }
      }
    },
    "state_changes": {
      "type": "object",
      "description": "角色状态变更",
      "properties": {
        "hp_change": { "type": "string" },
        "energy_change": { "type": "string" },
        "san_change": { "type": "string" },
        "crime_score_change": { "type": "string" },
        "realm_exp_change": { "type": "string" },
        "death_occurred": { "type": "boolean" },
        "tribulation_triggered": { "type": "boolean" }
      }
    }
  }
}

7.2 结算面板 API

// gRPC 接口定义
service OfflineSettlementService {
  // 拉取离线结算报告(玩家上线时调用)
  rpc GetSettlementReport (GetSettlementReq) returns (SettlementReportResp);

  // 确认结算(客户端展示完面板后调用,应用状态变更)
  rpc ConfirmSettlement (ConfirmSettlementReq) returns (ConfirmSettlementResp);

  // 查询历史结算报告
  rpc ListSettlementHistory (ListSettlementReq) returns (ListSettlementResp);
}

message GetSettlementReq {
  string character_id = 1;
}

message SettlementReportResp {
  int32 code = 1;
  string message = 2;
  SettlementReport data = 3;
}

message SettlementReport {
  string report_id = 1;
  int64 settlement_version = 2;
  OfflineDuration offline_duration = 3;
  SettlementSummary summary = 4;
  repeated BattleSummary battles = 5;
  repeated DiscipleEventSummary disciple_events = 6;
  repeated EncounterSummary encounters = 7;
  StateChanges state_changes = 8;
  string compressed_full_report = 9;  // gzip 压缩的完整战报 JSON
}

8. 弟子代挂结算(接入 GDD-07 弟子系统)

8.1 弟子代挂结算流程

弟子代挂结算流程:

1. 加载弟子列表status = "dispatched"
2. 计算离线游戏时长
3. 逐弟子结算:
   a. 检查派遣目标(自建门派资源点 / 帮派领地 / 历练点)
   b. 计算基础产出§4.2 公式)
   c. 随机事件判定(弟子遭遇/顿悟/死亡)
   d. 累计产出到结算报告
4. 批量更新弟子状态

8.2 弟子品质对产出的影响

// 弟子代挂产出计算
func calculateDiscipleOutput(disciple *Disciple, zone *Zone, gameHours float64) *OutputResult {
    // 基础产出
    baseRate := zone.BaseOutputRate

    // 品质系数GDD-07 §2.3.2
    qualityCoeff := map[string]float64{
        "common":    0.8,
        "fine":      1.0,
        "excellent": 1.3,
        "perfect":   1.8,
        "immortal":  2.5,
    }[disciple.Quality]

    // 种族匹配系数
    raceCoeff := calculateRaceMatch(disciple.RaceID, zone.ZoneType)

    // 生活技能匹配
    skillCoeff := 1.0
    if hasMatchingSkill(disciple, zone) {
        skillCoeff = 1.5
    }

    // 门派设施加成
    facilityCoeff := getGuildFacilityBonus(disciple.GuildID, zone)

    // 极品/仙品双倍产出概率GDD-07 §2.3.2
    doubleChance := 0.0
    if disciple.Quality == "perfect" {
        doubleChance = 0.15  // 15% 概率双倍
    } else if disciple.Quality == "immortal" {
        doubleChance = 0.25  // 25% 概率双倍
    }

    // 计算总产出
    totalOutput := baseRate * qualityCoeff * raceCoeff * skillCoeff * facilityCoeff * gameHours

    // 随机波动 ±20%
    totalOutput = totalOutput * randomFloat(0.8, 1.2)

    // 双倍产出判定
    if randomFloat(0, 1) < doubleChance {
        totalOutput *= 2
    }

    return &OutputResult{Amount: totalOutput}
}

8.3 弟子死亡判定(接入 GDD-07 T4 / GDD-13

// 弟子离线死亡判定
func checkDiscipleDeath(disciple *Disciple, missionType string, difficulty int) bool {
    // 基础死亡率(随难度递增)
    baseDeathRate := map[string]float64{
        "gathering":    0.001,  // 采集0.1%
        "training":     0.005,  // 历练0.5%
        "mercenary":    0.02,   // 佣兵委托2%
        "sect_proxy":   0.01,   // 门派代挂1%
    }[missionType]

    // 难度修正
    difficultyMod := 1.0 + float64(difficulty-1)*0.5  // 每星 +50%

    // 品质修正(高品质弟子生存率更高)
    qualityMod := map[string]float64{
        "common":    1.2,   // 凡品 +20% 死亡率
        "fine":      1.0,
        "excellent": 0.8,   // 优品 -20%
        "perfect":   0.6,   // 极品 -40%
        "immortal":  0.4,   // 仙品 -60%
    }[disciple.Quality]

    // 保险道具修正
    insuranceMod := 1.0
    if disciple.InsuranceItemID != "" {
        insuranceMod = 0.3  // 有保险道具,死亡率 -70%
    }

    // 弟子死亡率修正字段(培养/装备可降低)
    deathRateMod := 1.0 - disciple.DeathRateModifier

    finalDeathRate := baseDeathRate * difficultyMod * qualityMod * insuranceMod * deathRateMod

    // 每游戏小时判定一次
    return randomFloat(0, 1) < finalDeathRate
}

8.4 弟子事件池

事件类型 触发概率 效果 叙事示例
正常产出 85% 获得资源 "弟子在矿脉中采集到灵石×N"
发现宝藏 3% 额外稀有材料 "弟子在洞窟深处发现一株千年灵草"
顿悟 5% 弟子属性/技能提升 "弟子在采集时顿悟,棍法熟练度+8%"
遭遇战斗 4% 胜利获额外奖励,失败受伤 "弟子遭遇妖兽袭击,奋力击退"
受伤 2% 产出效率临时下降 "弟子不慎中毒,需要休养"
死亡 0.1%~2% 弟子永久损失 "弟子在禁地中殒落..."
叛逃 0.05% 弟子永久损失(仅低忠诚度) "弟子心生异志,携物资潜逃"

9. 游历事件离线触发(接入 GDD-22

9.1 随机事件池抽取

// 离线游历事件抽取
func pickOfflineEncounter(character *Character, zone *Zone, gameTime int64) *Encounter {
    // 加载区域事件池GDD-22 §三)
    eventPool := loadZoneEventPool(zone.ID)

    // 过滤不适用离线的事件
    validEvents := filterEvents(eventPool, func(e Event) bool {
        return e.OfflineEligible && character.RealmTier >= e.MinRealmTier
    })

    // 计算每个事件的权重
    weightedEvents := []WeightedEvent{}
    for _, event := range validEvents {
        weight := event.BaseWeight

        // 时段权重GDD-03 ✅5昼夜增益按游戏时间判定
        timeMod := getTimeModifier(gameTime, event.TimePreference)
        weight *= timeMod

        // 天气/环境权重
        envMod := getEnvironmentModifier(zone.Weather, event.ElementPreference)
        weight *= envMod

        // 疲劳衰减(同一区域连续探索)
        fatigueMod := getFatigueDecay(character.ID, event.Category)
        weight *= fatigueMod

        // 卜算/感知类天赋加成
        if character.HasTalent("divination") {
            weight *= 1.3
        }

        weightedEvents = append(weightedEvents, WeightedEvent{Event: event, Weight: weight})
    }

    // 加权随机抽取
    selected := weightedRandomSelect(weightedEvents)
    return selected
}

9.2 分支选择策略

离线状态下玩家无法实时决策,系统按预设策略自动选择:

// 离线自动分支选择策略
func autoSelectBranch(encounter *Encounter, character *Character) string {
    // 策略优先级:
    // 1. 安全优先(默认策略):选择风险最低的选项
    // 2. 收益优先:选择预期收益最高的选项
    // 3. 玩家预设:玩家上线时可设置离线偏好

    strategy := character.OfflineStrategy  // "safe" / "greedy" / "balanced"

    switch strategy {
    case "safe":
        return selectSafestOption(encounter.Options, character)
    case "greedy":
        return selectHighestRewardOption(encounter.Options, character)
    case "balanced":
        return selectBalancedOption(encounter.Options, character)
    default:
        return selectSafestOption(encounter.Options, character)
    }
}

// 安全优先选择
func selectSafestOption(options []EncounterOption, character *Character) string {
    safest := options[0]
    for _, opt := range options[1:] {
        if opt.RiskLevel < safest.RiskLevel {
            safest = opt
        }
    }
    return safest.ID
}

// 收益优先选择
func selectHighestRewardOption(options []EncounterOption, character *Character) string {
    best := options[0]
    bestEV := calculateExpectedValue(best, character)
    for _, opt := range options[1:] {
        ev := calculateExpectedValue(opt, character)
        if ev > bestEV {
            best = opt
            bestEV = ev
        }
    }
    return best.ID
}

// 期望值计算
func calculateExpectedValue(option EncounterOption, character *Character) float64 {
    rewardEV := option.RewardValue * option.SuccessRate
    riskEV := option.RiskValue * (1 - option.SuccessRate) * option.DeathPenaltyWeight
    return rewardEV - riskEV
}

9.3 离线事件类型与处理

事件类型 离线处理方式 接入文档
灵气异动 自动吸纳(安全策略)或标记(收益策略) GDD-22 §三
古修残魂 默认超度(最安全),不选择夺舍 GDD-22 §三
行脚商人 默认无视(避免被抢) GDD-22 §三
心魔低语 默认压制(最安全) GDD-22 §三
同族求救 默认无视(避免风险) GDD-22 §三
天降宝箱 默认开启 GDD-22 §三
遭遇战斗 自动按 ATB 引擎结算 GDD-03 §六
采集点 自动采集(消耗能量) GDD-06 §三

10. 性能优化

10.1 批量结算的并发模型

玩家上线触发结算
    ↓
Goroutine PoolN=CPU核心数
    ├── Goroutine 1: 挂机资源结算
    ├── Goroutine 2: 弟子代挂结算
    ├── Goroutine 3: 游历事件结算
    ├── Goroutine 4: 战斗结算(可能多个)
    └── Goroutine 5: 状态变更合并
    ↓
Channel 汇总结果
    ↓
批量写入 DB单事务
// 并发结算模型
func SettleOfflineConcurrent(characterID string) (*SettlementReport, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // 并发执行各模块结算
    var (
        gatherResult    *GatherResult
        discipleResult  *DiscipleResult
        encounterResult *EncounterResult
        battleResults   []*BattleResult
        wg              sync.WaitGroup
        mu              sync.Mutex
        errs            []error
    )

    wg.Add(4)

    // 挂机资源结算
    go func() {
        defer wg.Done()
        r, err := settleOfflineGather(ctx, characterID)
        mu.Lock()
        if err != nil { errs = append(errs, err) } else { gatherResult = r }
        mu.Unlock()
    }()

    // 弟子代挂结算
    go func() {
        defer wg.Done()
        r, err := settleOfflineDisciples(ctx, characterID)
        mu.Lock()
        if err != nil { errs = append(errs, err) } else { discipleResult = r }
        mu.Unlock()
    }()

    // 游历事件结算
    go func() {
        defer wg.Done()
        r, err := settleOfflineEncounters(ctx, characterID)
        mu.Lock()
        if err != nil { errs = append(errs, err) } else { encounterResult = r }
        mu.Unlock()
    }()

    // 战斗结算(可能包含多场战斗)
    go func() {
        defer wg.Done()
        results, err := settleOfflineBattles(ctx, characterID)
        mu.Lock()
        if err != nil { errs = append(errs, err) } else { battleResults = results }
        mu.Unlock()
    }()

    wg.Wait()

    if len(errs) > 0 {
        return nil, fmt.Errorf("settlement errors: %v", errs)
    }

    // 合并结果
    report := mergeResults(gatherResult, discipleResult, encounterResult, battleResults)

    // 批量写入 DB单事务
    err := applySettlementInTransaction(ctx, characterID, report)
    if err != nil {
        return nil, err
    }

    return report, nil
}

10.2 数据库写入优化

优化策略 说明
单事务批量写入 所有结算结果在一个事务中写入,减少事务开销
UPSERT 代替 INSERT+UPDATE 货币余额、背包物品使用 INSERT ... ON CONFLICT UPDATE
延迟写入经济审计 经济审计日志先缓存到 Valkey,定时批量刷入 DB每 5 分钟)
分区表利用 battle_logs 按周分区,economy_audit_logs 按月分区,写入只命中当前分区
连接池调优 结算高峰期使用独立连接池,避免影响在线请求
// 单事务批量写入
func applySettlementInTransaction(ctx context.Context, characterID string, report *SettlementReport) error {
    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 1. 更新角色状态
    _, err = tx.ExecContext(ctx, `
        UPDATE characters SET
            exp = exp + $1,
            san_current = GREATEST(0, LEAST(san_max, san_current + $2)),
            crime_score = GREATEST(0, crime_score + $3),
            last_online_at = NOW(),
            updated_at = NOW()
        WHERE id = $4
    `, report.ExpGained, report.SanChange, report.CrimeChange, characterID)

    // 2. 批量更新货币余额UPSERT
    for _, curr := range report.TotalCurrency {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO currency_balances (character_id, currency_code, amount, total_earned, updated_at)
            VALUES ($1, $2, $3, $3, NOW())
            ON CONFLICT (character_id, currency_code) DO UPDATE SET
                amount = currency_balances.amount + EXCLUDED.amount,
                total_earned = currency_balances.total_earned + EXCLUDED.total_earned,
                updated_at = NOW()
        `, characterID, curr.CurrencyCode, curr.Amount)
    }

    // 3. 批量插入背包物品
    for _, item := range report.TotalItems {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO inventories (id, character_id, item_id, slot_type, quantity, instance_data, acquired_at, created_at)
            VALUES (gen_random_uuid(), $1, $2, 'bag', $3, $4, NOW(), NOW())
        `, characterID, item.ItemID, item.Quantity, item.InstanceData)
    }

    // 4. 更新弟子状态
    for _, de := range report.DiscipleEvents {
        if de.EventType == "death" {
            _, err = tx.ExecContext(ctx, `
                UPDATE disciples SET status = 'dead', died_at = NOW(), tombstone_data = $1
                WHERE id = $2
            `, de.TombstoneData, de.DiscipleID)
        }
    }

    // 5. 写入战斗记录
    for _, battle := range report.Battles {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO battles (id, battle_type, world_tier, realm_tier, attacker_id, defender_id, status, result_summary, created_at)
            VALUES ($1, $2, $3, $4, $5, $6, 'completed', $7, $8)
        `, battle.ID, battle.Type, battle.WorldTier, battle.RealmTier,
            battle.AttackerID, battle.DefenderID, battle.ResultSummary, battle.CreatedAt)
    }

    // 6. 写入经济审计日志
    for _, audit := range report.EconomyAudits {
        _, err = tx.ExecContext(ctx, `
            INSERT INTO economy_audit_logs (character_id, entity_type, entity_id, currency_code, flow_type, reason_code, amount, balance_after, world_tier, created_at)
            VALUES ($1, 'character', $1, $2, 'faucet', 'offline_settle', $3, $4, $5, NOW())
        `, characterID, audit.CurrencyCode, audit.Amount, audit.BalanceAfter, audit.WorldTier)
    }

    // 7. 标记结算完成
    _, err = tx.ExecContext(ctx, `
        UPDATE characters SET offline_settle_version = offline_settle_version + 1 WHERE id = $1
    `, characterID)

    return tx.Commit()
}

10.3 结算结果压缩存储

// 结算结果压缩存储到 Valkey
func saveCompressedReport(characterID string, report *SettlementReport) error {
    // JSON 序列化
    jsonBytes, err := json.Marshal(report)
    if err != nil {
        return err
    }

    // gzip 压缩
    var buf bytes.Buffer
    gz := gzip.NewWriter(&buf)
    if _, err := gz.Write(jsonBytes); err != nil {
        return err
    }
    gz.Close()

    // 存储到 Valkey,TTL = 7 天
    key := fmt.Sprintf("offline_settle:%s:%d", characterID, report.SettlementVersion)
    return valkey.Set(ctx, key, buf.Bytes(), 7*24*time.Hour)
}

// 从 Valkey 读取并解压
func loadCompressedReport(characterID string, version int64) (*SettlementReport, error) {
    key := fmt.Sprintf("offline_settle:%s:%d", characterID, version)
    data, err := valkey.Get(ctx, key).Bytes()
    if err != nil {
        return nil, err
    }

    // gzip 解压
    gz, err := gzip.NewReader(bytes.NewReader(data))
    if err != nil {
        return nil, err
    }
    defer gz.Close()

    var report SettlementReport
    if err := json.NewDecoder(gz).Decode(&report); err != nil {
        return nil, err
    }

    return &report, nil
}

10.4 性能指标与监控

指标 目标值 监控方式
单次结算耗时24h 离线) < 500ms Prometheus histogram
单次结算耗时120h 离线) < 3s Prometheus histogram
结算并发数 100/s峰值 Grafana dashboard
DB 写入延迟 < 100ms PostgreSQL slow query log
Valkey 缓存命中率 > 95% Valkey INFO
结算失败率 < 0.1% AlertManager

11. 已确认决策记录

# 决策 来源
O01 离线结算采用"延迟结算"模式:离线期间只记录事件队列,上线时批量结算 本文确认
O02 最大离线结算时长按境界递增炼气72h→筑基96h→金丹120h→元婴+无上限;默认上限120h,通过 Nacos 可配 本文确认
O03 离线战斗使用与在线完全相同的 ATB 引擎,服务端权威计算 GDD-03 1
O04 离线游历事件自动选择分支,默认"安全优先"策略,玩家可设置偏好 GDD-22 三
O05 弟子离线死亡判定每游戏小时执行一次,死亡率与难度/品质/保险相关 GDD-07 T4
O06 结算结果压缩存储到 Valkey,TTL 7 天,支持幂等重复拉取 本文确认
O07 背包满时溢出物品转入邮件系统,货币溢出按比例折算低档货币 TDD-04
O08 结算面板展示战报摘要(不含完整行动序列),完整战报按需加载 TDD-05
O09 离线产出遵循 GDD-06 经济参数,受疲劳衰减/区域警觉值约束 GDD-06 §5.3
O10 弟子代挂产出按品质/种族/技能/设施四维系数计算 GDD-07 §2.3.2
O11 离线 PVP 战书默认接受GDD-03 14,被挑战方上线后查看战报 GDD-03 §8.2
O12 结算版本号机制保证幂等性,重复登录不会重复结算 本文确认

12. 验收标准

# 验收条目 测试方法
1 玩家离线 24 现实小时后上线,结算面板正确显示挂机资源/弟子产出/事件列表 离线 24h mock + 上线验证
2 离线战斗结果与在线相同条件下手动触发的战斗结果一致(相同随机种子) 相同参数对比测试
3 背包满时溢出物品正确转入邮件,货币溢出正确折算 背包填满后离线验证
4 弟子离线死亡概率符合 GDD-07 设定(品质/难度/保险修正) 10000 次蒙特卡洛模拟
5 离线游历事件按"安全优先"策略自动选择,不选择高风险选项 事件池 mock 验证
6 结算结果幂等:同一结算版本重复调用不产生额外收益 重复调用验证
7 72h 离线结算耗时 < 2s单角色 性能基准测试
8 100 并发结算请求下系统稳定,无 DB 死锁 压力测试
9 结算报告 JSON Schema 验证通过 JSON Schema 校验工具
10 离线期间的经济审计日志完整记录(每笔产出/消耗) 查询 economy_audit_logs
11 弟子品质/数量上限符合 GDD-07 T4炼气2→合体10,弟子居+2 边界值测试
12 离线 PVP 被挑战默认接受,上线后战报正确展示 PVP mock 测试
13 Valkey 缓存 TTL 过期后结算报告正确降级到 DB 查询 TTL 过期测试
14 结算版本号递增正确,旧版本报告不再重复应用 版本号递增测试

13. 数据库新增/变更(补充 TDD-04

13.1 离线结算相关新增字段

characters 表新增字段

字段 类型 说明
offline_settle_version bigint 离线结算版本号,每次结算 +1
offline_strategy varchar(16) 离线事件选择策略safe / greedy / balanced,默认 safe
last_settle_at timestamptz 上次结算时间

新增表offline_event_queue

字段 类型 约束 说明
id uuid PK
character_id uuid FK, IX
event_type varchar(32) IX combat / gather / disciple / encounter
trigger_time_game bigint IX 触发时间(游戏时间戳)
payload jsonb 事件数据
resolved boolean IX 是否已结算
result jsonb 结算结果
settle_version bigint 关联的结算版本
created_at timestamptz IX

分区:按 created_at 日分区,保留 7 天。

新增表offline_settlement_reports

字段 类型 约束 说明
id uuid PK
character_id uuid FK, IX
settle_version bigint UQ(character_id, settle_version) 结算版本
offline_duration_real_sec int 离线现实秒数
offline_duration_game_hours numeric(10,2) 离线游戏时长
summary jsonb 收益汇总(压缩)
compressed_report bytea gzip 压缩的完整报告
created_at timestamptz IX

14. Nacos 动态配置项

配置键 类型 默认值 说明
offline.max_settle_hours.default int 120 默认最大离线结算时长(现实小时,金丹期)
offline.max_settle_hours.qi_refining int 72 炼气期离线上限(现实小时)
offline.max_settle_hours.foundation int 96 筑基期离线上限(现实小时)
offline.max_settle_hours.golden_core int 120 金丹期离线上限(现实小时)
offline.max_settle_hours.nascent_soul int 0 元婴期+离线上限0=无上限)
offline.tick_interval_game_hours float 1.0 时间快进步长(游戏小时)
offline.event_check_interval int 300 定时扫描离线玩家间隔(秒)
offline.gather.fatigue_decay_per_hour float 0.05 挂机疲劳衰减率(每游戏小时)
offline.gather.fatigue_min float 0.3 疲劳衰减最低系数
offline.disciple.death_rate_base.gathering float 0.001 弟子采集基础死亡率
offline.disciple.death_rate_base.mercenary float 0.02 弟子佣兵基础死亡率
offline.encounter.safe_strategy_weight float 0.8 安全策略权重
offline.encounter.greedy_strategy_weight float 0.3 收益策略权重
offline.battle.max_ticks int 3000 战斗最大行动时间
offline.battle.max_actions int 50 单方最大行动次数
offline.report.cache_ttl_hours int 168 结算报告缓存 TTL小时

15. 版本记录

版本 日期 修订内容 作者
1.1 2026-07-02 离线结算上限按境界递增炼气72h→筑基96h→金丹120h→元婴+无上限;默认上限从72h调整为120h;Nacos配置拆分为按境界独立配置 Claude Code
1.0 2026-07-02 初始版本时间快进算法、离线产出公式、ATB战斗结算、弟子代挂、游历事件、上线结算面板、性能优化、数据库变更、Nacos配置 Claude Code

TDD-06 v1.1 | 2026-07-02 | 离线结算上限按境界递增调整炼气72h→筑基96h→金丹120h→元婴+无上限 | 前序v1.0