1465 行
53 KiB
Markdown
1465 行
53 KiB
Markdown
|
|
# 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 = 0(0表示无上限)
|
|||
|
|
|
|||
|
|
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 事件队列结构
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
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 事件队列回放
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 玩家上线时调用
|
|||
|
|
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 离线战斗触发条件
|
|||
|
|
|
|||
|
|
离线期间战斗由以下场景触发:
|
|||
|
|
- 游历途中遭遇野怪/精英/Boss(GDD-03 §6)
|
|||
|
|
- 弟子代挂遭遇战斗
|
|||
|
|
- 被其他玩家 PVP 挑战(离线战书默认接受,GDD-03 ✅14)
|
|||
|
|
|
|||
|
|
### 5.2 服务端完整 ATB 战斗计算
|
|||
|
|
|
|||
|
|
离线战斗采用与在线战斗完全相同的 ATB 引擎,服务端一次性完整计算。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 离线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)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 物理伤害(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 背包满处理
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 离线产出溢出处理
|
|||
|
|
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 货币上限处理
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 离线货币产出上限处理(接入 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
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"$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": "结算报告唯一ID(UUID)"
|
|||
|
|
},
|
|||
|
|
"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
|
|||
|
|
|
|||
|
|
```protobuf
|
|||
|
|
// 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 弟子品质对产出的影响
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 弟子代挂产出计算
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 弟子离线死亡判定
|
|||
|
|
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 随机事件池抽取
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 离线游历事件抽取
|
|||
|
|
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 分支选择策略
|
|||
|
|
|
|||
|
|
离线状态下玩家无法实时决策,系统按预设策略自动选择:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 离线自动分支选择策略
|
|||
|
|
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 Pool(N=CPU核心数)
|
|||
|
|
├── Goroutine 1: 挂机资源结算
|
|||
|
|
├── Goroutine 2: 弟子代挂结算
|
|||
|
|
├── Goroutine 3: 游历事件结算
|
|||
|
|
├── Goroutine 4: 战斗结算(可能多个)
|
|||
|
|
└── Goroutine 5: 状态变更合并
|
|||
|
|
↓
|
|||
|
|
Channel 汇总结果
|
|||
|
|
↓
|
|||
|
|
批量写入 DB(单事务)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 并发结算模型
|
|||
|
|
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` 按月分区,写入只命中当前分区 |
|
|||
|
|
| **连接池调优** | 结算高峰期使用独立连接池,避免影响在线请求 |
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 单事务批量写入
|
|||
|
|
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 结算结果压缩存储
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 结算结果压缩存储到 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*
|