package dnfbridge import ( "context" "errors" "fmt" "strconv" "strings" dnfcharstat "longheng.io/server/internal/modules/dnf/charstat" dnfrepo "longheng.io/server/internal/modules/dnf/repository" ) // Companion panel snapshot (Option B): the client captures the source // character's final character-info panel values and stores them in a settings // record; the support-soldier projection prefers this snapshot over the // PVF-derived base vector so the companion can mirror the source panel. const ( companionPanelSnapshotKeyPrefix = "character:" companionPanelSnapshotKeySuffix = ":companion-panel-v1" ) var errCompanionPanelCharacterID = errors.New("companion panel: character_id is required") type companionPanelSnapshot struct { HPMax int64 MPMax int64 Strength int64 Intelligence int64 Vitality int64 Spirit int64 PhysicalAttack int64 MagicalAttack int64 IndependentAttack int64 PhysicalDefense int64 MagicalDefense int64 FireResistance int64 WaterResistance int64 DarkResistance int64 LightResistance int64 MoveSpeed int64 AttackSpeed int64 CastSpeed int64 HitRecovery int64 JumpPower int64 Weight int64 InventoryLimit int64 HPRegenSpeed int64 MPRegenSpeed int64 SourceClient string // The following values are shown on the character-info panel but are not // part of the 92-byte companion mode1 blob; they are stored so the GM panel // can mirror the full panel and future projections can consume them. // Percentages are stored scaled by 10 (70.0% -> 700); the three speed fields // above use the blob's scale of 100 (60.50% -> 6050). PhysicalCritical int64 MagicalCritical int64 MagicResistance int64 HitRate int64 DodgeRate int64 Stiffness int64 FireDamage int64 WaterDamage int64 LightDamage int64 DarkDamage int64 } func companionPanelSnapshotScope(characterID string) string { return companionPanelSnapshotKeyPrefix + strings.TrimSpace(characterID) + companionPanelSnapshotKeySuffix } func companionPanelSnapshotInt(values map[string]string, names ...string) int64 { for _, name := range names { raw, ok := values[name] if !ok { continue } parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) if err == nil { return parsed } } return 0 } func loadCompanionPanelSnapshot( ctx context.Context, repos dnfrepo.Group, characterID string, ) (companionPanelSnapshot, bool) { if repos.Settings == nil || strings.TrimSpace(characterID) == "" { return companionPanelSnapshot{}, false } record, found, err := repos.Settings.Load( ctx, companionPanelSnapshotScope(characterID)) if err != nil || !found { return companionPanelSnapshot{}, false } values := record.Values snapshot := companionPanelSnapshot{ HPMax: companionPanelSnapshotInt(values, "hp_max"), MPMax: companionPanelSnapshotInt(values, "mp_max"), Strength: companionPanelSnapshotInt(values, "strength"), Intelligence: companionPanelSnapshotInt(values, "intelligence"), Vitality: companionPanelSnapshotInt(values, "vitality"), Spirit: companionPanelSnapshotInt(values, "spirit"), PhysicalAttack: companionPanelSnapshotInt(values, "physical_attack"), MagicalAttack: companionPanelSnapshotInt(values, "magical_attack"), IndependentAttack: companionPanelSnapshotInt(values, "independent_attack"), PhysicalDefense: companionPanelSnapshotInt(values, "physical_defense"), MagicalDefense: companionPanelSnapshotInt(values, "magical_defense"), FireResistance: companionPanelSnapshotInt(values, "fire_resistance"), WaterResistance: companionPanelSnapshotInt(values, "water_resistance"), DarkResistance: companionPanelSnapshotInt(values, "dark_resistance"), LightResistance: companionPanelSnapshotInt(values, "light_resistance"), MoveSpeed: companionPanelSnapshotInt(values, "move_speed"), AttackSpeed: companionPanelSnapshotInt(values, "attack_speed"), CastSpeed: companionPanelSnapshotInt(values, "cast_speed"), HitRecovery: companionPanelSnapshotInt(values, "hit_recovery"), JumpPower: companionPanelSnapshotInt(values, "jump_power"), Weight: companionPanelSnapshotInt(values, "weight"), InventoryLimit: companionPanelSnapshotInt(values, "inventory_limit"), HPRegenSpeed: companionPanelSnapshotInt(values, "hp_regen"), MPRegenSpeed: companionPanelSnapshotInt(values, "mp_regen"), SourceClient: strings.TrimSpace(values["source_client"]), PhysicalCritical: companionPanelSnapshotInt(values, "physical_critical"), MagicalCritical: companionPanelSnapshotInt(values, "magical_critical"), MagicResistance: companionPanelSnapshotInt(values, "magic_resistance"), HitRate: companionPanelSnapshotInt(values, "hit_rate"), DodgeRate: companionPanelSnapshotInt(values, "dodge_rate"), Stiffness: companionPanelSnapshotInt(values, "stiffness"), FireDamage: companionPanelSnapshotInt(values, "fire_damage"), WaterDamage: companionPanelSnapshotInt(values, "water_damage"), LightDamage: companionPanelSnapshotInt(values, "light_damage"), DarkDamage: companionPanelSnapshotInt(values, "dark_damage"), } if snapshot.HPMax <= 0 || snapshot.MPMax <= 0 || snapshot.Strength <= 0 { return companionPanelSnapshot{}, false } return snapshot, true } func (s companionPanelSnapshot) Vector() dnfcharstat.Vector { return dnfcharstat.Vector{ HPMax: s.HPMax, MPMax: s.MPMax, Strength: s.Strength, Intelligence: s.Intelligence, Vitality: s.Vitality, Spirit: s.Spirit, PhysicalAttack: s.PhysicalAttack, PhysicalDefense: s.PhysicalDefense, MagicalAttack: s.MagicalAttack, MagicalDefense: s.MagicalDefense, IndependentAttack: s.IndependentAttack, FireResistance: s.FireResistance, WaterResistance: s.WaterResistance, DarkResistance: s.DarkResistance, LightResistance: s.LightResistance, InventoryLimit: s.InventoryLimit, HPRegenSpeed: s.HPRegenSpeed, MPRegenSpeed: s.MPRegenSpeed, MoveSpeed: s.MoveSpeed, AttackSpeed: s.AttackSpeed, CastSpeed: s.CastSpeed, HitRecovery: s.HitRecovery, JumpPower: s.JumpPower, Weight: s.Weight, } } // CompanionPanelValues returns the raw stored panel snapshot values for the GM // editor. found is false when no snapshot has been saved yet. func (s *Service) CompanionPanelValues(ctx context.Context, characterID string) (map[string]string, bool, error) { repos, ok := s.repositoryGroup() if !ok { return nil, false, ErrGMRepositoryUnavailable } characterID = strings.TrimSpace(characterID) if characterID == "" { return nil, false, errCompanionPanelCharacterID } record, found, err := repos.Settings.Load(ctx, companionPanelSnapshotScope(characterID)) if err != nil { return nil, false, err } if !found { return map[string]string{}, false, nil } values := make(map[string]string, len(record.Values)) nonEmpty := false for k, v := range record.Values { values[k] = v if strings.TrimSpace(v) != "" { nonEmpty = true } } // A record whose values are all empty is a leftover of a blank GM save and // carries no usable panel; report it as not configured so the editor does // not show a misleading "已读取". if !nonEmpty { return map[string]string{}, false, nil } return values, true, nil } // SaveCompanionPanelValues replaces the stored panel snapshot values. func (s *Service) SaveCompanionPanelValues(ctx context.Context, characterID string, values map[string]string) error { repos, ok := s.repositoryGroup() if !ok { return ErrGMRepositoryUnavailable } characterID = strings.TrimSpace(characterID) if characterID == "" { return errCompanionPanelCharacterID } cleaned := make(map[string]string, len(values)) for k, v := range values { key := strings.TrimSpace(k) if key == "" { continue } cleaned[key] = strings.TrimSpace(v) } return repos.Settings.Save(ctx, dnfrepo.SettingsRecord{ Scope: companionPanelSnapshotScope(characterID), Values: cleaned, }) } // GenerateCompanionPanelSnapshot builds a best-effort panel snapshot from the // server's PVF character growth plus the character's worn equipment PVF stats // (and weapon forging). It deliberately does not model enchant cards, // amplification, reinforcement, quality, set effects, titles, auras or pets: // the saved values are a BASE ESTIMATE that the GM page can correct. func (s *Service) GenerateCompanionPanelSnapshot(ctx context.Context, characterID string) (map[string]string, error) { repos, ok := s.repositoryGroup() if !ok { return nil, ErrGMRepositoryUnavailable } characterID = strings.TrimSpace(characterID) if characterID == "" { return nil, errCompanionPanelCharacterID } character, found, err := repos.Character.Load(ctx, characterID) if err != nil { return nil, err } if !found { return nil, fmt.Errorf("character %s not found", characterID) } base, ok := s.characterPVFStatsForUserInfo(ctx, nil, character, true) if !ok { return nil, fmt.Errorf("character %s PVF stats unavailable", characterID) } stats := s.addEquipmentPVFStatsForUserInfo(ctx, nil, character, base) values := companionPanelValuesFromVector(stats, character) return values, nil } func companionPanelValuesFromVector(stats dnfcharstat.Vector, character dnfrepo.CharacterRecord) map[string]string { values := make(map[string]string, 24) put := func(key string, value int64) { values[key] = strconv.FormatInt(value, 10) } put("hp_max", stats.HPMax) put("mp_max", stats.MPMax) put("strength", stats.Strength) put("intelligence", stats.Intelligence) put("vitality", stats.Vitality) put("spirit", stats.Spirit) put("physical_attack", stats.PhysicalAttack) put("magical_attack", stats.MagicalAttack) put("independent_attack", stats.IndependentAttack) put("physical_defense", stats.PhysicalDefense) put("magical_defense", stats.MagicalDefense) put("fire_resistance", stats.FireResistance) put("water_resistance", stats.WaterResistance) put("dark_resistance", stats.DarkResistance) put("light_resistance", stats.LightResistance) put("move_speed", stats.MoveSpeed) put("attack_speed", stats.AttackSpeed) put("cast_speed", stats.CastSpeed) put("hit_recovery", stats.HitRecovery) jump := stats.JumpPower if jump == 0 { jump = character.Stats["stat_jump_power"] } put("jump_power", jump) weight := stats.Weight if weight == 0 { weight = character.Stats["stat_weight"] } put("weight", weight) inventory := stats.InventoryLimit if inventory == 0 { inventory = character.Stats["stat_inventory_limit"] } put("inventory_limit", inventory) put("hp_regen", stats.HPRegenSpeed) put("mp_regen", stats.MPRegenSpeed) values["source_client"] = "server-estimate" return values }