服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
263 lines
8.3 KiB
Go
263 lines
8.3 KiB
Go
// 本文件把 DNF PVF 角色成长表接入 dnfbridge 的 USERINFO 拼包流程。
|
|
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dnfcharstat "longheng.io/server/internal/modules/dnf/charstat"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
csharpPremiumHPBonus int64 = 9800
|
|
csharpPremiumMPBonus int64 = 10500
|
|
)
|
|
|
|
func (s *Service) preloadCharacterStatTable(ctx context.Context) error {
|
|
table, err := s.loadCharacterStatTable(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.logPacketEvent("dnf-character-stat-pvf-loaded", "jobs", table.Snapshot().Jobs)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) loadCharacterStatTable(ctx context.Context) (*dnfcharstat.Table, error) {
|
|
if s == nil {
|
|
return nil, fmt.Errorf("dnfbridge service is nil")
|
|
}
|
|
s.characterStatsMu.Lock()
|
|
if s.characterStats != nil {
|
|
table := s.characterStats
|
|
s.characterStatsMu.Unlock()
|
|
return table, nil
|
|
}
|
|
if s.characterStatsLoadErr != nil {
|
|
err := s.characterStatsLoadErr
|
|
s.characterStatsMu.Unlock()
|
|
return nil, err
|
|
}
|
|
s.characterStatsMu.Unlock()
|
|
|
|
s.initialEquipmentMu.Lock()
|
|
archive, err := s.initialEquipmentArchiveLocked()
|
|
s.initialEquipmentMu.Unlock()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load character stat pvf archive: %w", err)
|
|
}
|
|
table, err := dnfcharstat.Load(ctx, archive, dnfcharstat.Options{})
|
|
|
|
s.characterStatsMu.Lock()
|
|
defer s.characterStatsMu.Unlock()
|
|
if err != nil {
|
|
s.characterStatsLoadErr = fmt.Errorf("load character stat pvf table: %w", err)
|
|
return nil, s.characterStatsLoadErr
|
|
}
|
|
s.characterStats = table
|
|
return table, nil
|
|
}
|
|
|
|
func (s *Service) characterPVFStatsForUserInfo(ctx context.Context, session *gameSession, character dnfrepo.CharacterRecord, hasCharacter bool) (dnfcharstat.Vector, bool) {
|
|
if !hasCharacter {
|
|
return dnfcharstat.Vector{}, false
|
|
}
|
|
jobText := strings.TrimSpace(character.Job)
|
|
if jobText == "" {
|
|
s.logCharacterStatMissing(session, character, "job_missing", nil)
|
|
return dnfcharstat.Vector{}, false
|
|
}
|
|
job, err := strconv.Atoi(jobText)
|
|
if err != nil || job < 0 || job > 0xff {
|
|
s.logCharacterStatMissing(session, character, "job_invalid", err)
|
|
return dnfcharstat.Vector{}, false
|
|
}
|
|
level := character.Level
|
|
if level <= 0 {
|
|
level = 1
|
|
}
|
|
growType := byte(numericCharacterStatValue(character, "grow_type"))
|
|
s.characterStatsMu.Lock()
|
|
table := s.characterStats
|
|
loadErr := s.characterStatsLoadErr
|
|
s.characterStatsMu.Unlock()
|
|
if table == nil {
|
|
s.logCharacterStatMissing(session, character, "pvf_table_not_preloaded", loadErr)
|
|
return dnfcharstat.Vector{}, false
|
|
}
|
|
stats, err := table.Compute(byte(job), growType, level)
|
|
if err != nil {
|
|
s.logCharacterStatMissing(session, character, "pvf_compute_failed", err)
|
|
return dnfcharstat.Vector{}, false
|
|
}
|
|
pvfHPMax := stats.HPMax
|
|
pvfMPMax := stats.MPMax
|
|
stats = applyCSharpHPMPCompatibility(stats)
|
|
if bonus, bonusErr := s.currentCollectboxBonus(ctx, session); bonusErr != nil {
|
|
s.logGameEvent(session, "game-collectbox-bonus-deferred", "char_id", character.CharacterID, "reason", bonusErr)
|
|
} else {
|
|
stats = applyCurrentCollectboxBonus(stats, bonus)
|
|
}
|
|
s.logPacketEvent("dnf-character-stat-pvf-computed",
|
|
"conn_id", sessionConnID(session),
|
|
"character_id", character.CharacterID,
|
|
"job", job,
|
|
"grow_type", growType,
|
|
"level", level,
|
|
"hp_max", pvfHPMax,
|
|
"mp_max", pvfMPMax,
|
|
"userinfo_hp_max", stats.HPMax,
|
|
"userinfo_mp_max", stats.MPMax,
|
|
"hp_compatibility_bonus", csharpPremiumHPBonus,
|
|
"mp_compatibility_bonus", csharpPremiumMPBonus,
|
|
"strength", stats.Strength,
|
|
"physical_attack", stats.PhysicalAttack,
|
|
"source", "pvf_character_growth_base_plus_csharp_hpmp_compatibility")
|
|
return stats, true
|
|
}
|
|
|
|
func applyCSharpHPMPCompatibility(stats dnfcharstat.Vector) dnfcharstat.Vector {
|
|
// 86JP CharacterStatComputer.BuildAdditionalInfo starts from a freshly
|
|
// computed PVF vector and then adds these two fixed compatibility values.
|
|
// Keeping the operation on the fresh vector makes repeated recalculation
|
|
// idempotent; no other stat is changed here.
|
|
stats.HPMax += csharpPremiumHPBonus
|
|
stats.MPMax += csharpPremiumMPBonus
|
|
return stats
|
|
}
|
|
|
|
func isPreCSharpHPMPCompatibilityStat(column string, storedValue int64, computedValue int64) bool {
|
|
switch column {
|
|
case "stat_hp_max":
|
|
return computedValue >= csharpPremiumHPBonus && storedValue == computedValue-csharpPremiumHPBonus
|
|
case "stat_mp_max":
|
|
return computedValue >= csharpPremiumMPBonus && storedValue == computedValue-csharpPremiumMPBonus
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (s *Service) applyCharacterPVFStatsForCreate(ctx context.Context, record *dnfrepo.CharacterRecord) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
stats, ok := s.characterPVFStatsForUserInfo(ctx, nil, *record, true)
|
|
if !ok {
|
|
return
|
|
}
|
|
applyCharacterPVFStats(record, stats)
|
|
}
|
|
|
|
func characterPVFStatsNeedRepair(record dnfrepo.CharacterRecord, stats dnfcharstat.Vector) bool {
|
|
if _, ok := record.Stats["stat_level"]; !ok {
|
|
return true
|
|
}
|
|
for key, value := range characterPVFStatValues(stats) {
|
|
if record.Stats[key] != value {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func repairSelectedCharacterPVFStats(
|
|
ctx context.Context,
|
|
repository dnfrepo.CharacterRepository,
|
|
record dnfrepo.CharacterRecord,
|
|
) error {
|
|
return dnfrepo.SaveCharacterFields(ctx, repository, record, dnfrepo.CharacterFieldStats)
|
|
}
|
|
|
|
func applyCharacterPVFStats(record *dnfrepo.CharacterRecord, stats dnfcharstat.Vector) {
|
|
if record == nil {
|
|
return
|
|
}
|
|
if record.Stats == nil {
|
|
record.Stats = make(map[string]int64)
|
|
}
|
|
for key, value := range characterPVFStatValues(stats) {
|
|
record.Stats[key] = value
|
|
}
|
|
if _, ok := record.Stats["stat_level"]; !ok {
|
|
record.Stats["stat_level"] = csharpSubtype1ProtocolStatLevel
|
|
}
|
|
}
|
|
|
|
func characterPVFStatValues(stats dnfcharstat.Vector) map[string]int64 {
|
|
return map[string]int64{
|
|
"stat_hp_max": stats.HPMax,
|
|
"stat_mp_max": stats.MPMax,
|
|
"stat_strength": stats.Strength,
|
|
"stat_intelligence": stats.Intelligence,
|
|
"stat_vitality": stats.Vitality,
|
|
"stat_spirit": stats.Spirit,
|
|
"stat_physical_attack": stats.PhysicalAttack,
|
|
"stat_physical_defense": stats.PhysicalDefense,
|
|
"stat_magical_attack": stats.MagicalAttack,
|
|
"stat_magical_defense": stats.MagicalDefense,
|
|
"stat_independent_attack": stats.IndependentAttack,
|
|
"stat_fire_resistance": stats.FireResistance,
|
|
"stat_water_resistance": stats.WaterResistance,
|
|
"stat_dark_resistance": stats.DarkResistance,
|
|
"stat_light_resistance": stats.LightResistance,
|
|
"stat_inventory_limit": stats.InventoryLimit,
|
|
"stat_hp_regen_speed": stats.HPRegenSpeed,
|
|
"stat_mp_regen_speed": stats.MPRegenSpeed,
|
|
"stat_move_speed": stats.MoveSpeed,
|
|
"stat_attack_speed": stats.AttackSpeed,
|
|
"stat_cast_speed": stats.CastSpeed,
|
|
"stat_hit_recovery": stats.HitRecovery,
|
|
"stat_jump_power": stats.JumpPower,
|
|
"stat_weight": stats.Weight,
|
|
}
|
|
}
|
|
|
|
func (s *Service) characterPVFStatValuesForLevel(character dnfrepo.CharacterRecord, level int) (map[string]int64, error) {
|
|
jobText := strings.TrimSpace(character.Job)
|
|
job, err := strconv.Atoi(jobText)
|
|
if err != nil || job < 0 || job > 0xff {
|
|
return nil, fmt.Errorf("compute character level stats: invalid job %q", character.Job)
|
|
}
|
|
s.characterStatsMu.Lock()
|
|
table := s.characterStats
|
|
loadErr := s.characterStatsLoadErr
|
|
s.characterStatsMu.Unlock()
|
|
if table == nil {
|
|
if loadErr == nil {
|
|
table, loadErr = s.loadCharacterStatTable(context.Background())
|
|
}
|
|
if loadErr != nil {
|
|
return nil, fmt.Errorf("compute character level stats: %w", loadErr)
|
|
}
|
|
}
|
|
stats, err := table.Compute(byte(job), byte(numericCharacterStatValue(character, "grow_type")), level)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("compute character level stats: %w", err)
|
|
}
|
|
return characterPVFStatValues(applyCSharpHPMPCompatibility(stats)), nil
|
|
}
|
|
|
|
func (s *Service) logCharacterStatMissing(session *gameSession, character dnfrepo.CharacterRecord, reason string, err error) {
|
|
fields := []any{
|
|
"conn_id", sessionConnID(session),
|
|
"character_id", character.CharacterID,
|
|
"job", character.Job,
|
|
"level", character.Level,
|
|
"grow_type", numericCharacterStatValue(character, "grow_type"),
|
|
"reason", reason,
|
|
}
|
|
if err != nil {
|
|
fields = append(fields, "error", err)
|
|
}
|
|
s.logPacketEvent("dnf-character-stat-pvf-missing", fields...)
|
|
}
|
|
|
|
func sessionConnID(session *gameSession) string {
|
|
if session == nil {
|
|
return ""
|
|
}
|
|
return session.connID
|
|
}
|