Files
bd2/go/internal/pictorial/service.go
T
2026-09-21 09:53:46 +08:00

279 lines
8.5 KiB
Go

// Package pictorial resolves account-owned collections through the installed
// GameData. Captured protobufs are never runtime inputs.
package pictorial
import (
"errors"
"fmt"
"math"
"sort"
"sync"
"bd2server/internal/gamedata"
"bd2server/internal/player"
"bd2server/internal/wire"
)
type Owned interface {
PictorialCharacters() []player.Character
PictorialCostumes() []player.Costume
PictorialItems() []player.Item
PictorialEquipment() []player.Equipment
PictorialDiscovered() []player.Pictorial
}
type Service struct {
Design *gamedata.PictorialDesign
Owned Owned
baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP
}
func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/PictorialBookInfo" && path != "/AllCharRefresh" {
return 0, nil, false, nil
}
seq, present, err := wire.Varint(request, 1)
if err != nil || !present || seq == 0 {
return 0, nil, true, fmt.Errorf("pictorial: %s missing sequence", path)
}
entries, buffs, err := s.Snapshot()
if err != nil {
return 0, nil, true, err
}
var response []byte
if path == "/PictorialBookInfo" {
for _, entry := range entries {
book := wire.AppendVarint(nil, 1, entry.GroupID)
book = wire.AppendVarint(book, 2, entry.ID)
response = wire.AppendBytes(response, 1, book)
}
return 111, response, true, nil
}
for _, buff := range buffs {
response = wire.AppendBytes(response, 1, BuffWire(buff))
}
return 165, response, true, nil
}
type Entry struct {
GroupID uint64
ID uint64
BuffID uint64
}
func BuffWire(buff gamedata.PictorialBuffStat) []byte {
proto := wire.AppendVarint(nil, 1, buff.StatType)
proto = wire.AppendDouble(proto, 2, buff.Value)
if buff.Category != 0 {
proto = wire.AppendVarint(proto, 3, buff.Category)
}
return proto
}
// Snapshot computes both network responses from the exact same owned-account
// view. Identical buff IDs on distinct completed pictorial entries contribute
// once per entry (e.g. three different collection quests all use buff 2002).
// Duplicate inventory instances of the SAME entry cannot contribute twice.
func (s *Service) Snapshot() ([]Entry, []gamedata.PictorialBuffStat, error) {
if s == nil || s.Design == nil || s.Owned == nil {
return nil, nil, errors.New("pictorial: missing design or account ownership")
}
d := s.Design
unique, talents := map[uint64]bool{}, map[uint64]bool{}
for _, character := range s.Owned.PictorialCharacters() {
meta, found := d.CharMeta[character.ID]
if !found {
return nil, nil, fmt.Errorf("pictorial: unknown owned character design %d", character.ID)
}
if meta.UsePackTemporary {
continue
}
unique[meta.UniqueID] = true
if meta.TalentID != 0 {
talents[meta.TalentID] = true
}
}
costumes := map[uint64]uint64{}
for _, costume := range s.Owned.PictorialCostumes() {
if previous, found := costumes[costume.ID]; !found || costume.Level > previous {
costumes[costume.ID] = costume.Level
}
// Level 0 is the first, owned-only threshold. Presence in the map
// is sufficient even when zero is the stored level.
}
equipment := map[uint64]bool{}
for _, owned := range s.Owned.PictorialEquipment() {
equipment[owned.ID] = true
}
items := map[[2]uint64]bool{}
itemOrder := map[uint64]int{}
itemIndex := 0
books := map[Entry]bool{}
for _, book := range s.Owned.PictorialDiscovered() {
books[Entry{GroupID: book.ID, ID: book.GroupID}] = true
}
for _, owned := range s.Owned.PictorialItems() {
if owned.Count != 0 {
items[[2]uint64{owned.Type, owned.ID}] = true
if _, found := itemOrder[owned.ID]; !found && (owned.Type == 13 || owned.Type == 17) {
itemOrder[owned.ID] = itemIndex
itemIndex++
}
if owned.Pictorialbook != nil {
books[Entry{GroupID: owned.Pictorialbook.ID, ID: owned.Pictorialbook.GroupID}] = true
}
}
}
var entries []Entry
entryOrder := map[uint64]int{}
for _, row := range d.Characters {
if allPresent(row.UniqueIDs, unique) {
entries = append(entries, Entry{gamedata.PictorialCharacter, row.ID, row.BuffID})
}
}
for _, row := range d.Talents {
if allPresent(row.TalentIDs, talents) || books[Entry{GroupID: gamedata.PictorialTalent, ID: row.ID}] {
entries = append(entries, Entry{gamedata.PictorialTalent, row.ID, 0})
}
}
for _, row := range d.Costumes {
level, found := costumes[row.CostumeID]
if !found {
continue
}
buffID := uint64(0)
for i, threshold := range row.Thresholds {
if threshold <= level {
buffID = row.BuffIDs[i]
}
}
entries = append(entries, Entry{gamedata.PictorialCostume, row.ID, buffID})
}
for _, row := range d.Equipment {
if equipment[row.EquipmentID] {
entries = append(entries, Entry{gamedata.PictorialEquipment, row.ID, row.BuffID})
}
}
for _, row := range d.Cooking {
if items[[2]uint64{14, row.RecipeID}] || books[Entry{GroupID: gamedata.PictorialCooking, ID: row.ID}] {
entries = append(entries, Entry{gamedata.PictorialCooking, row.ID, row.BuffID})
}
}
for _, row := range d.Items {
itemType := uint64(17)
if row.Category == 1 {
itemType = 13
}
if items[[2]uint64{itemType, row.ItemID}] || books[Entry{GroupID: gamedata.PictorialItem, ID: row.ID}] {
entry := Entry{gamedata.PictorialItem, row.ID, row.BuffID}
entries = append(entries, entry)
order, found := itemOrder[row.ItemID]
if !found {
order = len(itemOrder) + int(row.ID)
}
entryOrder[row.ID] = order
}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].GroupID != entries[j].GroupID {
return entries[i].GroupID < entries[j].GroupID
}
if entries[i].GroupID == gamedata.PictorialItem {
// Official PictorialBookInfo preserves discovery order for item
// collections (starter 30021, then quest 2101 and 2102).
first, second := entryOrder[entries[i].ID], entryOrder[entries[j].ID]
if first != second {
return first < second
}
}
return entries[i].ID < entries[j].ID
})
type key struct{ category, stat uint64 }
values := map[key]float64{}
for _, entry := range entries {
if entry.BuffID == 0 {
continue
}
buff, found := d.Buffs[entry.BuffID]
if !found {
return nil, nil, fmt.Errorf("pictorial: buff %d missing for entry %d/%d", entry.BuffID, entry.GroupID, entry.ID)
}
// DB StatValue is defined to four decimal places. Accumulate fixed
// ten-thousandths to prevent binary float drift across many rows.
values[key{buff.Category, buff.StatType}] += buff.Value
}
buffs := make([]gamedata.PictorialBuffStat, 0, len(values))
for k, value := range values {
buffs = append(buffs, gamedata.PictorialBuffStat{Category: k.category, StatType: k.stat, Value: math.Round(value*100000000) / 100000000})
}
sort.Slice(buffs, func(i, j int) bool {
if buffs[i].Category != buffs[j].Category {
return buffs[i].Category < buffs[j].Category
}
return buffs[i].StatType < buffs[j].StatType
})
return entries, buffs, nil
}
func allPresent(ids []uint64, available map[uint64]bool) bool {
for _, id := range ids {
if !available[id] {
return false
}
}
return len(ids) != 0
}
func (s *Service) Contributions() ([]gamedata.StatContribution, error) {
_, buffs, err := s.Snapshot()
if err != nil {
return nil, err
}
var result []gamedata.StatContribution
for _, buff := range buffs {
if buff.Category != 0 {
continue
}
contribution := gamedata.StatContribution{Percent: buff.Value}
switch buff.StatType {
case 1, 2:
contribution.Stat = gamedata.StatHealth
case 3, 4:
contribution.Stat = gamedata.StatAttack
case 5, 6:
contribution.Stat = gamedata.StatMagic
default:
continue
}
if buff.StatType%2 == 1 {
contribution.Flat, contribution.Percent = contribution.Percent, 0
}
result = append(result, contribution)
}
return result, nil
}
func (s *Service) MaxHealth(character player.Character) (uint64, error) {
if s == nil || s.Design == nil {
return 0, errors.New("pictorial: missing character stat design")
}
cacheKey := [2]uint64{character.ID, character.Level}
value, found := s.baseHealth.Load(cacheKey)
if !found {
base, err := gamedata.CharacterBaseStats(s.Design.Root, s.Design.Version, int(character.ID), int(character.Level))
if err != nil {
return 0, err
}
value, _ = s.baseHealth.LoadOrStore(cacheKey, base.Health)
}
contributions, err := s.Contributions()
if err != nil {
return 0, err
}
maxHP := gamedata.AggregateStats(gamedata.BaseStats{Health: value.(float64)}, contributions).Health
if maxHP < 1 || maxHP > float64(^uint64(0)) {
return 0, fmt.Errorf("pictorial: invalid maximum health for character %d: %v", character.ID, maxHP)
}
return uint64(maxHP), nil
}