feat(server): implement friendship gifts, counseling and food recovery

This commit is contained in:
2026-10-04 03:05:23 +08:00
parent 3658882bc6
commit 0ca19f9244
31 changed files with 3426 additions and 36 deletions
+53
View File
@@ -511,6 +511,14 @@ func serve(args []string) (serveErr error) {
return fmt.Errorf("load pictorial GameData: %w", err)
}
pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService}
equipmentStatDesign, err := gamedata.LoadEquipmentStatDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load equipment stat GameData: %w", err)
}
if err := ownedEquipment.AttachStatDesign(equipmentStatDesign); err != nil {
return err
}
pictorialService.EquipmentContributions = ownedEquipment.StatContributions
charAwakeDesign, err := gamedata.LoadCharAwakeDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load character awakening GameData: %w", err)
@@ -537,6 +545,7 @@ func serve(args []string) (serveErr error) {
if err != nil {
return err
}
pictorialService.PotentialContributions = costumePotentialService.Contributions
costumeBurstDesign, err := gamedata.LoadCostumeBurstDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load costume burst GameData: %w", err)
@@ -545,7 +554,49 @@ func serve(args []string) (serveErr error) {
if err != nil {
return err
}
friendshipDesign, err := gamedata.LoadFriendshipDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load friendship GameData: %w", err)
}
friendshipService, err := player.NewFriendshipService(&friendshipDesign, charAwakeDesign, costumePotentialDesign, collection, ownedItems, wallet)
if err != nil {
return fmt.Errorf("load friendship state: %w", err)
}
if err := login.AttachFriendshipAP(friendshipService); err != nil {
return err
}
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
characters := worldService.CharacterService()
battleService.AttachCommittedHealth(func(health map[uint64]uint64) error {
for index, hp := range health {
maximum, err := characters.MaxHealth(index)
if err != nil {
return fmt.Errorf("invalid completed battle health for character %d: %w", index, err)
}
if hp > maximum {
// Battle-only HP buffs are not persisted into field health.
// This is our settlement policy, not an inferred provider rule.
health[index] = maximum
}
}
for index, hp := range health {
if err := characters.SetCurrentHealth(index, hp); err != nil {
return err
}
}
return nil
})
foodDesign, err := gamedata.LoadFoodDesign(gameData, *gameDataVersion)
if err != nil {
return fmt.Errorf("load food GameData: %w", err)
}
foodService, err := player.OpenFoodService(stateRepository, foodDesign, ownedItems, characters)
if err != nil {
return fmt.Errorf("load food state: %w", err)
}
if err := foodService.AttachContext(worldService.CurrentPackID, battleService.Active); err != nil {
return err
}
battleService.AttachTutorialWin(func() error {
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
})
@@ -565,6 +616,8 @@ func serve(args []string) (serveErr error) {
charAwakeService,
costumePotentialService,
costumeBurstService,
friendshipService,
foodService,
starter,
mailService,
gachaService,
@@ -0,0 +1,42 @@
package account
import (
"errors"
"testing"
"bd2server/internal/server/wire"
)
type friendshipAPFixture struct {
remaining uint64
err error
}
func (f *friendshipAPFixture) FriendshipAP() (uint64, error) { return f.remaining, f.err }
func TestLoginReadsCurrentFriendshipAPInsteadOfSeed(t *testing.T) {
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 69, 3)
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 70, 99)
provider := &friendshipAPFixture{remaining: 2}
if err := seed.AttachFriendshipAP(provider); err != nil {
t.Fatal(err)
}
for _, remaining := range []uint64{2, 0, 3} {
provider.remaining = remaining
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatal(err)
}
user, _, _ := wire.Bytes(body, 1)
free, _, _ := wire.Varint(user, 69)
stack, _, _ := wire.Varint(user, 70)
if free != remaining || stack != 0 {
t.Fatalf("friendship points free=%d stack=%d, want %d/0", free, stack, remaining)
}
}
provider.err = errors.New("unavailable")
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
t.Fatal("login ignored friendship state error")
}
}
+27
View File
@@ -37,6 +37,7 @@ type LoginSeed struct {
presetSlots PresetSlotProvider
inventorySlots InventorySlotProvider
firstGacha FirstGachaProvider
friendshipAP FriendshipAPProvider
}
// FirstGachaProvider reads the mutable account flag for every login, including
@@ -91,6 +92,20 @@ type InventorySlotProvider interface {
UserInventorySlots() (items, storage, equipment, equipmentStorage uint64, err error)
}
// FriendshipAPProvider supplies the account's remaining daily counseling
// points; LoginUser must not restore points from its immutable seed.
type FriendshipAPProvider interface {
FriendshipAP() (uint64, error)
}
func (s *LoginSeed) AttachFriendshipAP(provider FriendshipAPProvider) error {
if provider == nil {
return errors.New("account: nil friendship AP provider")
}
s.friendshipAP = provider
return nil
}
func (s *LoginSeed) AttachCurrencies(provider CurrencyProvider) error {
if provider == nil {
return errors.New("account: nil currency provider")
@@ -392,6 +407,18 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
}
}
}
if s.friendshipAP != nil {
remaining, err := s.friendshipAP.FriendshipAP()
if err != nil {
return nil, fmt.Errorf("account: friendship AP: %w", err)
}
if user, _, err = wire.ReplaceVarint(user, 69, remaining); err != nil {
return nil, err
}
if user, _, err = wire.ReplaceVarint(user, 70, 0); err != nil {
return nil, err
}
}
user = wire.AppendBytes(user, 3, sessionKey)
return append(wire.AppendBytes(nil, 1, user), s.ResponseFields...), nil
}
+60 -1
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"sync"
"bd2server/internal/server/gamedata"
@@ -25,6 +26,7 @@ type Service struct {
loadRewards func(string, string, int, uint64) ([]gamedata.BattleReward, error)
buffs func() ([]gamedata.PictorialBuffStat, error)
onTutorialWin func() error
commitHealth func(map[uint64]uint64) error
}
type battleState struct {
@@ -65,6 +67,20 @@ func (s *Service) BeginSession(id string) {
func (s *Service) AttachTutorialWin(callback func() error) { s.onTutorialWin = callback }
// AttachCommittedHealth persists only completed battle results. Round state
// remains transient, so reconnect rolls back an unfinished battle.
// The callback may normalize values to field HP; the response uses those
// committed values rather than echoing battle-only HP buffs.
func (s *Service) AttachCommittedHealth(callback func(map[uint64]uint64) error) {
s.commitHealth = callback
}
func (s *Service) Active() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.stateLocked().entered
}
func NewService(gameDataRoot, gameDataVersion string, inventory *player.Inventory, currentPack func() (int, error)) *Service {
return &Service{
gameDataRoot: gameDataRoot, gameDataVersion: gameDataVersion,
@@ -210,15 +226,58 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
return 0, nil, true, errors.New("battle: invalid result")
}
response := wire.AppendVarint(nil, 1, result)
var resultCharacters [][]byte
finishedHealth := make(map[uint64]uint64)
participants := make(map[uint64]bool)
if s.commitHealth != nil {
for _, character := range state.initialBlue {
index, _, err := wire.Varint(character, 2)
if err != nil {
return 0, nil, true, err
}
if index != 0 {
participants[index] = true
}
}
}
err = wire.Walk(request, func(field wire.Field) error {
if field.Number == 3 && field.Type == 2 {
response = wire.AppendBytes(response, 3, field.Value)
if s.commitHealth != nil {
index, present, err := wire.Varint(field.Value, 1)
if err != nil || !present || !participants[index] {
return errors.New("battle: result character was not a battle participant")
}
if _, duplicate := finishedHealth[index]; duplicate {
return errors.New("battle: duplicate result character")
}
hp, _, err := wire.Varint(field.Value, 3)
if err != nil || hp > math.MaxInt64 {
return errors.New("battle: invalid result health")
}
finishedHealth[index] = hp
}
resultCharacters = append(resultCharacters, field.Value)
}
return nil
})
if err != nil {
return 0, nil, true, err
}
if s.commitHealth != nil && len(finishedHealth) != 0 {
if err := s.commitHealth(finishedHealth); err != nil {
return 0, nil, true, fmt.Errorf("battle: persist completed character health: %w", err)
}
}
for _, character := range resultCharacters {
if s.commitHealth != nil {
index, _, _ := wire.Varint(character, 1)
character, _, err = wire.ReplaceVarint(character, 3, finishedHealth[index])
if err != nil {
return 0, nil, true, err
}
}
response = wire.AppendBytes(response, 3, character)
}
rewardBundle := false
if result == 1 && s.inventory != nil && state.monster != 0 && s.gameDataRoot != "" {
if state.pack <= 0 {
+78
View File
@@ -0,0 +1,78 @@
package battle
import (
"testing"
"bd2server/internal/server/wire"
)
func TestBattleHealthCommitsOnlyAtEndAndChecksParticipants(t *testing.T) {
s := &Service{}
calls := 0
var health map[uint64]uint64
s.AttachCommittedHealth(func(values map[uint64]uint64) error {
calls++
for index, hp := range values {
values[index] = min(hp, 50)
}
health = values
return nil
})
s.BeginSession("first")
enter := wire.AppendVarint(wire.AppendVarint(request(1), 4, 1), 5, 1)
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
start := wire.AppendVarint(request(2), 2, 11)
blue := wire.AppendVarint(wire.AppendVarint(nil, 2, 100), 4, 60)
start = wire.AppendBytes(start, 5, blue)
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
if calls != 0 || !s.Active() {
t.Fatal("round state was committed or battle was not active")
}
invalid := wire.AppendVarint(request(3), 2, 1)
invalid = wire.AppendBytes(invalid, 3, wire.AppendVarint(nil, 1, 999))
if _, _, _, err := s.Handle("/BattleEnd", invalid); err == nil || calls != 0 {
t.Fatal("accepted health for a character outside the blue team")
}
result := wire.AppendVarint(request(4), 2, 1)
character := wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 17)
result = wire.AppendBytes(result, 3, character)
if _, _, _, err := s.Handle("/BattleEnd", result); err != nil {
t.Fatal(err)
}
if calls != 1 || health[100] != 17 || s.Active() {
t.Fatalf("health=%v calls=%d active=%t", health, calls, s.Active())
}
// A second battle interrupted by a login never commits its round health.
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
s.BeginSession("reconnected")
if calls != 1 || s.Active() {
t.Fatal("reconnect persisted unfinished battle health")
}
if _, _, _, err := s.Handle("/BattleEnter", enter); err != nil {
t.Fatal(err)
}
if _, _, _, err := s.Handle("/BattleStart", start); err != nil {
t.Fatal(err)
}
result = wire.AppendVarint(request(5), 2, 1)
character = wire.AppendVarint(wire.AppendVarint(nil, 1, 100), 3, 999)
result = wire.AppendBytes(result, 3, character)
_, body, _, err := s.Handle("/BattleEnd", result)
if err != nil {
t.Fatal(err)
}
returned, _, _ := wire.Bytes(body, 3)
hp, _, _ := wire.Varint(returned, 3)
if health[100] != 50 || hp != 50 {
t.Fatalf("committed and returned HP differ: saved=%v response=%d", health, hp)
}
}
-1
View File
@@ -37,7 +37,6 @@ var emptyResponses = map[string]int{
"/FishingCollectionInfo": 465,
"/FishingTrapInfo": 457,
"/FriendInfoList": 204,
"/FriendshipInfo": 612,
"/FriendshipSpecialEpisodeInfo": 623,
"/GuildInitInfo": 332,
"/IdCardPresetInfo": 450,
+3 -3
View File
@@ -8,8 +8,8 @@ import (
)
func TestHandleAuditedEmptyResponses(t *testing.T) {
if got := len(EmptyPacketCodes()); got != 32 {
t.Fatalf("audited empty-response registry has %d paths, want 32", got)
if got := len(EmptyPacketCodes()); got != 31 {
t.Fatalf("audited empty-response registry has %d paths, want 31", got)
}
for path, wantCode := range EmptyPacketCodes() {
t.Run(path, func(t *testing.T) {
@@ -28,7 +28,7 @@ func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) {
if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil {
t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err)
}
for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo"} {
for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo"} {
if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil {
t.Fatalf("stateful %s must not be handled by feature defaults: ok=%v err=%v", path, ok, err)
}
@@ -3,6 +3,7 @@ package gamedata
import (
"database/sql"
"fmt"
"math"
"os"
"path/filepath"
)
@@ -11,6 +12,9 @@ type CostumePotentialCost struct{ Type, ID, Count uint64 }
type CostumePotentialNode struct {
ID uint64
NodeType uint64
StatType uint64
StatValue float64
ConditionGrade uint64
Prerequisites []uint64
Costs []CostumePotentialCost
@@ -68,6 +72,22 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
grade, _ := packedInts(proto, 17)
conditions, _ := packedInts(proto, 18)
node := CostumePotentialNode{ID: id, Prerequisites: append([]uint64(nil), conditions...)}
nodeTypes, nodeTypeErr := packedInts(proto, 27)
statTypes, statTypeErr := packedInts(proto, 29)
statValue, _, statValueErr := fixed64Double(proto, 30)
if nodeTypeErr != nil || statTypeErr != nil || statValueErr != nil || len(nodeTypes) != 1 || len(statTypes) > 1 || math.IsNaN(statValue) || math.IsInf(statValue, 0) || statValue < 0 {
rows.Close()
return nil, fmt.Errorf("gamedata: invalid costume potential stats %d/%d", groupID, id)
}
node.NodeType = nodeTypes[0]
node.StatValue = statValue
if len(statTypes) == 1 {
node.StatType = statTypes[0]
}
if (node.NodeType == 1 || node.NodeType == 2) && (node.StatType == 0 || node.StatType > 20) {
rows.Close()
return nil, fmt.Errorf("gamedata: unsupported costume potential stat %d/%d option %d", groupID, id, node.StatType)
}
if len(grade) == 1 {
node.ConditionGrade = grade[0]
} else if len(grade) > 1 {
@@ -0,0 +1,135 @@
package gamedata
import (
"database/sql"
"fmt"
"math"
)
// EquipmentStatRule retains the exact option curve. Percent values are already
// fractions in GameData: 0.0135 means 1.35%, with no conversion by 100.
type EquipmentStatRule struct {
Default, Growth float64
Levels []float64
Ranks [3][]float64
}
// EquipmentStatDesign contains the HP subset used for field maximum health.
// Loading once avoids decrypting common.db on every character lookup.
type EquipmentStatDesign struct {
Options map[[2]uint64]EquipmentStatRule
}
func LoadEquipmentStatDesign(root, version string) (*EquipmentStatDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer closeDB()
return loadEquipmentStatDesign(db)
}
func loadEquipmentStatDesign(db *sql.DB) (*EquipmentStatDesign, error) {
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentOptionTable WHERE id IN (1,2)")
if err != nil {
return nil, err
}
defer rows.Close()
d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{}}
for rows.Next() {
var group, id uint64
var raw []byte
if err := rows.Scan(&group, &id, &raw); err != nil {
return nil, err
}
protoGroup, err := packedInts(raw, 3)
if err != nil || len(protoGroup) != 1 || protoGroup[0] != group {
return nil, fmt.Errorf("gamedata: invalid equipment stat group %d", group)
}
protoID, err := packedInts(raw, 5)
if err != nil || len(protoID) != 1 || protoID[0] != id {
return nil, fmt.Errorf("gamedata: invalid equipment stat id %d/%d", group, id)
}
var rule EquipmentStatRule
rule.Default, _, err = fixed64Double(raw, 1)
if err != nil {
return nil, err
}
rule.Growth, _, err = fixed64Double(raw, 4)
if err != nil {
return nil, err
}
rule.Levels, err = fixed32Floats(raw, 6)
if err != nil {
return nil, err
}
for i := range rule.Ranks {
rule.Ranks[i], err = fixed32Floats(raw, 7+i)
if err != nil {
return nil, err
}
}
values := []float64{rule.Default, rule.Growth}
values = append(values, rule.Levels...)
for _, ranks := range rule.Ranks {
values = append(values, ranks...)
}
for _, value := range values {
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return nil, fmt.Errorf("gamedata: invalid equipment stat %d/%d", group, id)
}
}
d.Options[[2]uint64{group, id}] = rule
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(d.Options) == 0 {
return nil, fmt.Errorf("gamedata: empty equipment health stat design")
}
return d, nil
}
// HealthContribution follows EquipmentInfo: main/private options use the
// level-and-rank curve, while suboptions use DefaultValue alone.
func (d *EquipmentStatDesign) HealthContribution(option EquipmentOption, suboption bool) (StatContribution, error) {
if option.ID == 0 || option.ID > 2 {
return StatContribution{}, nil
}
if d == nil || option.GroupID == 0 || option.Level < 0 {
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health stat option")
}
rule, found := d.Options[[2]uint64{option.GroupID, option.ID}]
if !found {
return StatContribution{}, fmt.Errorf("gamedata: unknown equipment health option %d/%d", option.GroupID, option.ID)
}
return equipmentStatContribution(rule, option, suboption)
}
func equipmentStatContribution(rule EquipmentStatRule, option EquipmentOption, suboption bool) (StatContribution, error) {
value := rule.Default
if !suboption {
var level float64
if len(rule.Levels) != 0 {
if option.Level >= len(rule.Levels) {
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health option level %d", option.Level)
}
level = rule.Levels[option.Level]
}
var ranks float32
for i, rank := range option.Rank {
if rank == 0 {
continue
}
if rank < 1 || rank > len(rule.Ranks[i]) {
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health option rank %d", rank)
}
ranks += float32(rule.Ranks[i][rank-1])
}
value += rule.Growth * (level + float64(ranks))
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health stat result")
}
return optionContribution(option.ID, value)
}
@@ -0,0 +1,77 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"encoding/binary"
"math"
"testing"
)
func TestEquipmentHealthStatsUseRankCurveSubDefaultAndFractionPercent(t *testing.T) {
d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{
{10, 1}: {Default: 7.9, Growth: 2, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, {1}, {1.5}}},
{20, 2}: {Default: 0.01359, Growth: 0.01, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, {1}, {1.5}}},
}}
flat, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1, Rank: [3]int{1, 1, 1}}, false)
if err != nil || flat.Flat != 15 {
t.Fatalf("flat=%+v err=%v", flat, err)
}
percent, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 1, Rank: [3]int{1, 1, 1}}, false)
if err != nil || percent.Percent != 0.0535 {
t.Fatalf("percent=%+v err=%v", percent, err)
}
sub, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 99, Rank: [3]int{99, 99, 99}}, true)
if err != nil || sub.Percent != 0.0135 {
t.Fatalf("sub=%+v err=%v", sub, err)
}
if got := AggregateStats(BaseStats{Health: 100}, []StatContribution{flat, percent, sub}).Health; got != 122 {
t.Fatalf("aggregated health=%v", got)
}
if _, err = d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 2}, false); err == nil {
t.Fatal("accepted nonexistent level")
}
if _, err = d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Rank: [3]int{2, 0, 0}}, false); err == nil {
t.Fatal("accepted nonexistent rank")
}
}
func TestEquipmentStatLoaderReadsCompositeKeysAndFloatCurves(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec("CREATE TABLE EquipmentOptionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
raw := wire.AppendDouble(nil, 1, 7.9)
raw = wire.AppendDouble(raw, 4, 2)
raw = wire.AppendVarint(raw, 3, 10)
raw = wire.AppendVarint(raw, 5, 1)
levels := binary.LittleEndian.AppendUint32(nil, math.Float32bits(0))
levels = binary.LittleEndian.AppendUint32(levels, math.Float32bits(1))
raw = wire.AppendBytes(raw, 6, levels)
if _, err = db.Exec("INSERT INTO EquipmentOptionTable VALUES(10,1,?)", raw); err != nil {
t.Fatal(err)
}
d, err := loadEquipmentStatDesign(db)
if err != nil {
t.Fatal(err)
}
got, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1}, false)
if err != nil || got.Flat != 9 {
t.Fatalf("health=%+v err=%v", got, err)
}
}
func TestEquipmentOptionContributionTruncatesRatherThanRounds(t *testing.T) {
flat, err := optionContribution(1, 7.9)
if err != nil || flat.Flat != 7 {
t.Fatalf("flat=%+v err=%v", flat, err)
}
percent, err := optionContribution(2, 0.01359)
if err != nil || percent.Percent != 0.0135 {
t.Fatalf("fraction percent=%+v err=%v", percent, err)
}
}
+131
View File
@@ -0,0 +1,131 @@
package gamedata
import (
"database/sql"
"errors"
"fmt"
"math"
"os"
"path/filepath"
)
// Food is decoded from FoodTable, including materials so that the server can
// reject them explicitly. FavoritePoint replaces Point for matching characters.
type Food struct {
ID, Type, Point, RecoveryType, FavoritePoint, FoodBuffID uint64
FavoriteUniqueCharIDs []uint64
}
type FoodDesign struct{ Foods map[uint64]Food }
func LoadFoodDesign(root, version string) (*FoodDesign, error) {
plain, err := ReadQuestDatabase(root, version)
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "bd2-food-")
if err != nil {
return nil, err
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, "common.db")
if err := os.WriteFile(path, plain, 0o600); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path)+"?mode=ro")
if err != nil {
return nil, err
}
defer db.Close()
return loadFoodDesign(db)
}
func loadFoodDesign(db *sql.DB) (*FoodDesign, error) {
rows, err := db.Query("SELECT id,ProtoBuf FROM FoodTable ORDER BY id")
if err != nil {
return nil, err
}
defer rows.Close()
design := &FoodDesign{Foods: map[uint64]Food{}}
for rows.Next() {
var id uint64
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
return nil, err
}
read := func(field int) (uint64, error) {
values, err := packedInts(raw, field)
if err != nil || len(values) > 1 {
return 0, fmt.Errorf("gamedata: invalid food %d field %d", id, field)
}
if len(values) == 0 {
return 0, nil
}
if values[0] > math.MaxInt32 {
return 0, fmt.Errorf("gamedata: invalid food %d integer", id)
}
return values[0], nil
}
food := Food{ID: id}
for field, target := range map[int]*uint64{1: &food.FavoritePoint, 3: &food.FoodBuffID, 4: &food.Type, 13: &food.Point, 14: &food.RecoveryType} {
if *target, err = read(field); err != nil {
return nil, err
}
}
protoID, err := read(7)
if err != nil || id == 0 || protoID != id || food.Type > 2 || food.RecoveryType > 1 {
return nil, fmt.Errorf("gamedata: invalid food row %d", id)
}
food.FavoriteUniqueCharIDs, err = packedInts(raw, 2)
if err != nil {
return nil, err
}
for _, charID := range food.FavoriteUniqueCharIDs {
if charID == 0 || charID > math.MaxInt32 {
return nil, fmt.Errorf("gamedata: invalid food %d favorite", id)
}
}
if food.Type != 2 && food.Point == 0 && food.FoodBuffID == 0 {
return nil, fmt.Errorf("gamedata: edible food %d has no effect", id)
}
design.Foods[id] = food
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(design.Foods) == 0 {
return nil, errors.New("gamedata: empty FoodTable")
}
return design, nil
}
// Recovery uses the client's float multiplication and Math.Round midpoint to
// even semantics once per item, then multiplies by the requested stack count.
func (f Food) Recovery(characterID, maximum, count uint64) (uint64, error) {
if f.Type > 1 || f.RecoveryType > 1 || f.FoodBuffID != 0 || count == 0 || count > math.MaxInt32 || maximum == 0 {
return 0, errors.New("gamedata: food is not a supported recovery dish")
}
point := f.Point
if f.Type == 1 {
for _, favorite := range f.FavoriteUniqueCharIDs {
if favorite == characterID/10 {
point = f.FavoritePoint
break
}
}
}
if point == 0 {
return 0, errors.New("gamedata: food has no recovery effect")
}
if f.RecoveryType == 1 {
value := math.RoundToEven(float64(float32(maximum) * (float32(point) / float32(100))))
if value < 0 || value >= float64(math.MaxUint64) {
return 0, errors.New("gamedata: food recovery overflow")
}
point = uint64(value)
}
if point != 0 && count > math.MaxUint64/point {
return 0, errors.New("gamedata: food recovery overflow")
}
return point * count, nil
}
+60
View File
@@ -0,0 +1,60 @@
package gamedata
import (
"bd2server/internal/server/wire"
"database/sql"
"testing"
)
func TestFoodRecoveryUsesFavoriteReplacementAndFloatMidpointEven(t *testing.T) {
food := Food{Type: 1, Point: 10, FavoritePoint: 15, FavoriteUniqueCharIDs: []uint64{35}}
got, err := food.Recovery(350, 100, 2)
if err != nil || got != 30 {
t.Fatalf("favorite=%d err=%v", got, err)
}
got, err = food.Recovery(360, 100, 2)
if err != nil || got != 20 {
t.Fatalf("ordinary=%d err=%v", got, err)
}
for _, test := range []struct{ maximum, want uint64 }{{5, 2}, {7, 4}} {
got, err = (Food{Point: 50, RecoveryType: 1}).Recovery(350, test.maximum, 1)
if err != nil || got != test.want {
t.Fatalf("percentage max=%d got=%d err=%v", test.maximum, got, err)
}
}
for _, food := range []Food{{Type: 2, Point: 100}, {FoodBuffID: 101, Point: 100}, {RecoveryType: 2, Point: 100}, {}} {
if _, err := food.Recovery(350, 100, 1); err == nil {
t.Fatalf("accepted invalid food %+v", food)
}
}
}
func TestFoodLoaderReadsFavoriteIDsAndProtoDefaults(t *testing.T) {
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err = db.Exec("CREATE TABLE FoodTable(id INTEGER PRIMARY KEY,ProtoBuf BLOB)"); err != nil {
t.Fatal(err)
}
raw := wire.AppendVarint(nil, 7, 101)
raw = wire.AppendVarint(raw, 13, 10)
if _, err = db.Exec("INSERT INTO FoodTable VALUES(101,?)", raw); err != nil {
t.Fatal(err)
}
raw = wire.AppendVarint(nil, 7, 201)
for field, value := range map[int]uint64{1: 15, 2: 35, 4: 1, 13: 10} {
raw = wire.AppendVarint(raw, field, value)
}
if _, err = db.Exec("INSERT INTO FoodTable VALUES(201,?)", raw); err != nil {
t.Fatal(err)
}
design, err := loadFoodDesign(db)
if err != nil {
t.Fatal(err)
}
if design.Foods[101].Type != 0 || design.Foods[201].FavoritePoint != 15 || design.Foods[201].FavoriteUniqueCharIDs[0] != 35 {
t.Fatalf("design=%+v", design)
}
}
+317
View File
@@ -0,0 +1,317 @@
package gamedata
import (
"database/sql"
"fmt"
)
// FriendshipKey identifies a costume's level or counseling session.
type FriendshipKey struct{ GroupID, ID uint64 }
type FriendshipDefaultDesign struct {
CorrectEXP, CorrectSelectDialogIndex, IncorrectEXP uint64
MaxCounselingAP, MaxCounselingAPByCostume uint64
QuickCounselingUnlockCount uint64
MaxLevels [3]uint64
CounselingRewards []Reward
}
type FriendshipGiftDesign struct {
ID, Type, ItemID, GiftType, EXP, FavoriteEXP uint64
FavoriteCostumeIDs []uint64
}
// Experience uses the original friendship costume ID, even when the mapped
// owned costume ID is different. GiftType 1 is favorite for every costume.
func (g FriendshipGiftDesign) Experience(costumeID uint64) uint64 {
if g.GiftType == 1 {
return g.FavoriteEXP
}
for _, id := range g.FavoriteCostumeIDs {
if id == costumeID {
return g.FavoriteEXP
}
}
return g.EXP
}
type FriendshipLevelDesign struct {
NextEXP uint64
Rewards []Reward
}
type FriendshipSessionDesign struct {
DialogGroupID, ChoiceCount uint64
}
// FriendshipDesign holds immutable tables, not account progress. Gift keys
// are ItemType and item design ID; ItemType Resource differs from its subtype.
type FriendshipDesign struct {
Default FriendshipDefaultDesign
Costumes map[uint64]uint64
Gifts map[[2]uint64]FriendshipGiftDesign
Levels map[FriendshipKey]FriendshipLevelDesign
Sessions map[FriendshipKey]FriendshipSessionDesign
}
// LoadFriendshipDesign decrypts and opens the common database once for all
// friendship tables. The query helper is shared with other common designs.
func LoadFriendshipDesign(root, version string) (FriendshipDesign, error) {
db, closeDB, err := openStatDatabase(root, version)
if err != nil {
return FriendshipDesign{}, err
}
defer closeDB()
return loadFriendshipDesign(db)
}
func loadFriendshipDesign(db *sql.DB) (FriendshipDesign, error) {
d := FriendshipDesign{Costumes: map[uint64]uint64{}, Gifts: map[[2]uint64]FriendshipGiftDesign{}, Levels: map[FriendshipKey]FriendshipLevelDesign{}, Sessions: map[FriendshipKey]FriendshipSessionDesign{}}
if db == nil {
return d, fmt.Errorf("gamedata: nil friendship database")
}
var proto []byte
if err := db.QueryRow("SELECT ProtoBuf FROM FriendshipDefaultTable WHERE id=0").Scan(&proto); err != nil {
return d, fmt.Errorf("gamedata: friendship defaults: %w", err)
}
defaults := []*uint64{&d.Default.CorrectEXP, &d.Default.CorrectSelectDialogIndex, &d.Default.IncorrectEXP, &d.Default.MaxCounselingAP, &d.Default.MaxCounselingAPByCostume, &d.Default.QuickCounselingUnlockCount, &d.Default.MaxLevels[0], &d.Default.MaxLevels[1], &d.Default.MaxLevels[2]}
for i, field := range []int{1, 2, 12, 13, 14, 18, 7, 8, 9} {
value, err := friendshipScalar(proto, field)
if err != nil {
return d, fmt.Errorf("gamedata: friendship defaults: %w", err)
}
*defaults[i] = value
}
var err error
d.Default.CounselingRewards, err = friendshipRewards(proto, 5, 4, 3, true)
if err != nil {
return d, fmt.Errorf("gamedata: friendship counseling rewards: %w", err)
}
if d.Default.CorrectEXP == 0 || d.Default.IncorrectEXP == 0 || d.Default.MaxCounselingAP == 0 || d.Default.MaxCounselingAPByCostume == 0 || d.Default.QuickCounselingUnlockCount == 0 || d.Default.MaxLevels[0] <= 1 || d.Default.MaxLevels[0] >= d.Default.MaxLevels[1] || d.Default.MaxLevels[1] >= d.Default.MaxLevels[2] {
return d, fmt.Errorf("gamedata: invalid friendship defaults")
}
if err := friendshipRows(db, "SELECT id,ProtoBuf FROM FriendshipCostumeTable ORDER BY id", func(id uint64, proto []byte) error {
protoID, err := friendshipScalar(proto, 2)
if err != nil {
return err
}
costumeID, err := friendshipScalar(proto, 1)
if err != nil {
return err
}
if id == 0 || protoID != id || costumeID == 0 {
return fmt.Errorf("invalid costume identity %d", id)
}
d.Costumes[id] = costumeID
return nil
}); err != nil {
return d, fmt.Errorf("gamedata: friendship costumes: %w", err)
}
if err := friendshipRows(db, "SELECT id,ProtoBuf FROM FriendshipGiftTable ORDER BY id", func(id uint64, proto []byte) error {
g := FriendshipGiftDesign{}
for i, dest := range []*uint64{&g.ID, &g.Type, &g.ItemID, &g.GiftType, &g.EXP, &g.FavoriteEXP} {
value, err := friendshipScalar(proto, []int{5, 7, 6, 4, 1, 2}[i])
if err != nil {
return err
}
*dest = value
}
var err error
g.FavoriteCostumeIDs, err = packedInts(proto, 3)
if err != nil {
return err
}
if id == 0 || g.ID != id || g.Type == 0 || g.ItemID == 0 || g.GiftType > 1 || g.FavoriteEXP == 0 || (g.GiftType == 0 && g.EXP == 0) {
return fmt.Errorf("invalid gift %d", id)
}
key := [2]uint64{g.Type, g.ItemID}
if _, exists := d.Gifts[key]; exists {
return fmt.Errorf("duplicate gift item %v", key)
}
for _, costumeID := range g.FavoriteCostumeIDs {
if _, ok := d.Costumes[costumeID]; !ok {
return fmt.Errorf("gift %d references unknown costume %d", id, costumeID)
}
}
d.Gifts[key] = g
return nil
}); err != nil {
return d, fmt.Errorf("gamedata: friendship gifts: %w", err)
}
if err := friendshipGroupRows(db, "SELECT groupId,id,ProtoBuf FROM FriendshipLevelTable ORDER BY groupId,id", func(key FriendshipKey, proto []byte) error {
if err := friendshipIdentity(proto, key); err != nil {
return err
}
next, err := friendshipScalar(proto, 3)
if err != nil {
return err
}
rewards, err := friendshipRewards(proto, 6, 5, 4, false)
if err != nil {
return err
}
if _, ok := d.Costumes[key.GroupID]; !ok || key.ID == 0 || key.ID > d.Default.MaxLevels[2] || (key.ID < d.Default.MaxLevels[2] && next == 0) {
return fmt.Errorf("invalid level %v", key)
}
d.Levels[key] = FriendshipLevelDesign{NextEXP: next, Rewards: rewards}
return nil
}); err != nil {
return d, fmt.Errorf("gamedata: friendship levels: %w", err)
}
if err := friendshipGroupRows(db, "SELECT groupId,id,ProtoBuf FROM CounselingSessionTable ORDER BY groupId,id", func(key FriendshipKey, proto []byte) error {
if err := friendshipIdentity(proto, key); err != nil {
return err
}
groupID, err := friendshipScalar(proto, 4)
if err != nil {
return err
}
if _, ok := d.Costumes[key.GroupID]; !ok || key.ID == 0 || groupID == 0 {
return fmt.Errorf("invalid session %v", key)
}
d.Sessions[key] = FriendshipSessionDesign{DialogGroupID: groupID}
return nil
}); err != nil {
return d, fmt.Errorf("gamedata: friendship sessions: %w", err)
}
// Dialog type 4 is EVisualNovelDialogType.Select. Resolve option counts
// from the same design used by TimelineVisualNovelManager.StartSelectTalk.
for key, session := range d.Sessions {
rows, err := db.Query("SELECT s.ProtoBuf FROM VisualNovelDialogTable v JOIN SelectDialogTable s ON s.id=v.selectDialogId WHERE v.groupId=? AND v.type=4", session.DialogGroupID)
if err != nil {
return d, err
}
var matches int
for rows.Next() {
var selection []byte
if err := rows.Scan(&selection); err != nil {
rows.Close()
return d, err
}
choices, err := packedInts(selection, 1)
if err != nil {
rows.Close()
return d, err
}
session.ChoiceCount = uint64(len(choices))
matches++
}
err = rows.Err()
rows.Close()
if err != nil {
return d, err
}
if matches != 1 || session.ChoiceCount <= d.Default.CorrectSelectDialogIndex {
return d, fmt.Errorf("gamedata: invalid counseling choices %v", key)
}
d.Sessions[key] = session
}
for costumeID := range d.Costumes {
for level := uint64(1); level <= d.Default.MaxLevels[2]; level++ {
if _, ok := d.Levels[FriendshipKey{costumeID, level}]; !ok {
return d, fmt.Errorf("gamedata: missing friendship level %d/%d", costumeID, level)
}
}
}
if len(d.Costumes) == 0 || len(d.Gifts) == 0 || len(d.Sessions) == 0 {
return d, fmt.Errorf("gamedata: empty friendship design")
}
return d, nil
}
func friendshipScalar(proto []byte, field int) (uint64, error) {
values, err := packedInts(proto, field)
if err != nil {
return 0, err
}
if len(values) > 1 {
return 0, fmt.Errorf("field %d has multiple scalar values", field)
}
if len(values) == 0 {
return 0, nil
}
return values[0], nil
}
func friendshipIdentity(proto []byte, key FriendshipKey) error {
group, err := friendshipScalar(proto, 1)
if err != nil {
return err
}
id, err := friendshipScalar(proto, 2)
if err != nil {
return err
}
if group != key.GroupID || id != key.ID {
return fmt.Errorf("identity mismatch %v", key)
}
return nil
}
func friendshipRewards(proto []byte, typeField, idField, countField int, scalar bool) ([]Reward, error) {
types, err := packedInts(proto, typeField)
if err != nil {
return nil, err
}
ids, err := packedInts(proto, idField)
if err != nil {
return nil, err
}
counts, err := packedInts(proto, countField)
if err != nil {
return nil, err
}
// The default reward is scalar, so omitted ID means protobuf's zero ID.
if scalar && len(ids) == 0 && len(types) == 1 {
ids = []uint64{0}
}
if len(types) != len(ids) || len(types) != len(counts) || (scalar && len(types) > 1) {
return nil, fmt.Errorf("mismatched reward arrays")
}
rewards := make([]Reward, len(types))
for i := range types {
if types[i] == 0 || counts[i] == 0 {
return nil, fmt.Errorf("invalid reward %d", i)
}
rewards[i] = Reward{Type: types[i], ID: ids[i], Count: counts[i]}
}
return rewards, nil
}
func friendshipRows(db *sql.DB, query string, visit func(uint64, []byte) error) error {
rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id uint64
var proto []byte
if err := rows.Scan(&id, &proto); err != nil {
return err
}
if err := visit(id, proto); err != nil {
return err
}
}
return rows.Err()
}
func friendshipGroupRows(db *sql.DB, query string, visit func(FriendshipKey, []byte) error) error {
rows, err := db.Query(query)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var key FriendshipKey
var proto []byte
if err := rows.Scan(&key.GroupID, &key.ID, &proto); err != nil {
return err
}
if err := visit(key, proto); err != nil {
return err
}
}
return rows.Err()
}
@@ -0,0 +1,118 @@
package gamedata
import (
"database/sql"
"reflect"
"testing"
"bd2server/internal/server/wire"
)
func TestFriendshipDesignLoadsProtocolDefaultsAndIndependentIdentities(t *testing.T) {
db := friendshipTestDB(t)
d, err := loadFriendshipDesign(db)
if err != nil {
t.Fatal(err)
}
if d.Default.CorrectSelectDialogIndex != 0 || d.Default.IncorrectEXP != 80 || d.Default.MaxLevels != [3]uint64{2, 3, 4} || !reflect.DeepEqual(d.Default.CounselingRewards, []Reward{{Type: 3, Count: 100}}) {
t.Fatalf("defaults=%+v", d.Default)
}
if d.Costumes[901] != 902 {
t.Fatalf("costume mapping=%v", d.Costumes)
}
gift := d.Gifts[[2]uint64{8, 920}]
if gift.Experience(901) != 40 || gift.Experience(902) != 20 {
t.Fatalf("gift favorite uses wrong identity: %+v", gift)
}
if got := d.Gifts[[2]uint64{8, 921}].Experience(901); got != 150 {
t.Fatalf("universal experience=%d", got)
}
if session := d.Sessions[FriendshipKey{901, 7}]; session.DialogGroupID != 931 || session.ChoiceCount != 2 {
t.Fatalf("session=%+v", session)
}
if rewards := d.Levels[FriendshipKey{901, 3}].Rewards; !reflect.DeepEqual(rewards, []Reward{{Type: 47, ID: 950, Count: 1}}) {
t.Fatalf("level rewards=%+v", rewards)
}
}
func TestFriendshipDesignRejectsPartialRewardsAndBrokenReferences(t *testing.T) {
for _, tc := range []struct {
name, query string
args []any
}{
{"partial reward", "UPDATE FriendshipLevelTable SET ProtoBuf=? WHERE id=3", []any{friendshipTestProto(map[int][]uint64{1: {901}, 2: {3}, 3: {60}, 6: {47}})}},
{"missing curve level", "DELETE FROM FriendshipLevelTable WHERE id=2", nil},
{"missing choices", "DELETE FROM SelectDialogTable", nil},
{"unknown favorite", "UPDATE FriendshipGiftTable SET ProtoBuf=? WHERE id=1", []any{friendshipTestProto(map[int][]uint64{1: {20}, 2: {40}, 3: {999}, 5: {1}, 6: {920}, 7: {8}})}},
{"mismatched session identity", "UPDATE CounselingSessionTable SET ProtoBuf=?", []any{friendshipTestProto(map[int][]uint64{1: {901}, 2: {8}, 4: {931}})}},
} {
t.Run(tc.name, func(t *testing.T) {
db := friendshipTestDB(t)
if _, err := db.Exec(tc.query, tc.args...); err != nil {
t.Fatal(err)
}
if _, err := loadFriendshipDesign(db); err == nil {
t.Fatal("invalid design accepted")
}
})
}
}
func friendshipTestProto(fields map[int][]uint64) []byte {
var proto []byte
for field, values := range fields {
for _, value := range values {
proto = wire.AppendVarint(proto, field, value)
}
}
return proto
}
func friendshipTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() { db.Close() })
for _, query := range []string{
"CREATE TABLE FriendshipDefaultTable(id INTEGER,ProtoBuf BLOB)",
"CREATE TABLE FriendshipCostumeTable(id INTEGER,ProtoBuf BLOB)",
"CREATE TABLE FriendshipGiftTable(id INTEGER,ProtoBuf BLOB)",
"CREATE TABLE FriendshipLevelTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)",
"CREATE TABLE CounselingSessionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)",
"CREATE TABLE VisualNovelDialogTable(groupId INTEGER,type INTEGER,selectDialogId INTEGER)",
"CREATE TABLE SelectDialogTable(id INTEGER,ProtoBuf BLOB)",
} {
if _, err := db.Exec(query); err != nil {
t.Fatal(err)
}
}
insert := func(query string, args ...any) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatal(err)
}
}
insert("INSERT INTO FriendshipDefaultTable VALUES(0,?)", friendshipTestProto(map[int][]uint64{1: {100}, 3: {100}, 5: {3}, 7: {2}, 8: {3}, 9: {4}, 12: {80}, 13: {3}, 14: {1}, 18: {5}}))
insert("INSERT INTO FriendshipCostumeTable VALUES(901,?)", friendshipTestProto(map[int][]uint64{1: {902}, 2: {901}}))
insert("INSERT INTO FriendshipGiftTable VALUES(1,?)", friendshipTestProto(map[int][]uint64{1: {20}, 2: {40}, 3: {901}, 5: {1}, 6: {920}, 7: {8}}))
insert("INSERT INTO FriendshipGiftTable VALUES(2,?)", friendshipTestProto(map[int][]uint64{2: {150}, 4: {1}, 5: {2}, 6: {921}, 7: {8}}))
for level := uint64(1); level <= 4; level++ {
fields := map[int][]uint64{1: {901}, 2: {level}}
if level < 4 {
fields[3] = []uint64{60}
}
if level == 3 {
fields[4] = []uint64{1}
fields[5] = []uint64{950}
fields[6] = []uint64{47}
}
insert("INSERT INTO FriendshipLevelTable VALUES(901,?,?)", level, friendshipTestProto(fields))
}
insert("INSERT INTO CounselingSessionTable VALUES(901,7,?)", friendshipTestProto(map[int][]uint64{1: {901}, 2: {7}, 4: {931}}))
insert("INSERT INTO VisualNovelDialogTable VALUES(931,4,940)")
insert("INSERT INTO SelectDialogTable VALUES(940,?)", friendshipTestProto(map[int][]uint64{1: {960, 961}, 3: {940}}))
return db
}
+10 -25
View File
@@ -251,41 +251,26 @@ func EquipmentOptionContribution(root, version string, option EquipmentOption) (
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? AND id=?", option.GroupID, option.ID).Scan(&proto); err != nil {
return StatContribution{}, fmt.Errorf("gamedata: equipment option %d/%d: %w", option.GroupID, option.ID, err)
}
defaultValue, _, err := fixed64Double(proto, 1)
var rule EquipmentStatRule
rule.Default, _, err = fixed64Double(proto, 1)
if err != nil {
return StatContribution{}, err
}
growthValue, _, err := fixed64Double(proto, 4)
rule.Growth, _, err = fixed64Double(proto, 4)
if err != nil {
return StatContribution{}, err
}
levels, err := fixed32Floats(proto, 6)
rule.Levels, err = fixed32Floats(proto, 6)
if err != nil {
return StatContribution{}, err
}
if len(levels) != 0 && option.Level >= len(levels) {
return StatContribution{}, fmt.Errorf("gamedata: equipment level %d outside option curve", option.Level)
}
levelValue := 0.0
if len(levels) != 0 {
levelValue = levels[option.Level]
}
rankValue := 0.0
for i, field := range []int{7, 8, 9} {
values, err := fixed32Floats(proto, field)
for i := range rule.Ranks {
rule.Ranks[i], err = fixed32Floats(proto, 7+i)
if err != nil {
return StatContribution{}, err
}
rank := option.Rank[i]
if rank == 0 {
continue
}
if rank < 1 || rank > len(values) {
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment rank %d", rank)
}
rankValue += values[rank-1]
}
return optionContribution(option.ID, defaultValue+growthValue*(levelValue+rankValue))
return equipmentStatContribution(rule, option, false)
}
func optionContribution(id uint64, value float64) (StatContribution, error) {
@@ -295,8 +280,8 @@ func optionContribution(id uint64, value float64) (StatContribution, error) {
digits = 4
}
factor := math.Pow10(digits)
value = math.Round(value*factor) / factor
contribution := StatContribution{}
value = math.Trunc(value*factor) / factor
contribution := StatContribution{Option: id}
switch id {
case 1, 2:
contribution.Stat = StatHealth
@@ -312,7 +297,7 @@ func optionContribution(id uint64, value float64) (StatContribution, error) {
return StatContribution{}, fmt.Errorf("gamedata: unsupported equipment stat option %d", id)
}
if percentage {
contribution.Percent = value / 100
contribution.Percent = value
} else {
contribution.Flat = value
}
@@ -0,0 +1,37 @@
package pictorial
import (
"errors"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
)
func TestMaximumHealthIncludesOwnedEquipmentPotentialAndAwakening(t *testing.T) {
character := player.Character{InvenIndex: 77, ID: 350, Level: 1}
s := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}}
s.baseHealth.Store([2]uint64{350, 1}, float64(100))
s.EquipmentContributions = func(got player.Character) ([]gamedata.StatContribution, error) {
if got.InvenIndex != character.InvenIndex || got.ID != character.ID || got.Level != character.Level {
t.Fatalf("wrong stat character: %+v", got)
}
return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Flat: 20, Percent: .1}}, nil
}
s.PotentialContributions = func(player.Character) ([]gamedata.StatContribution, error) {
return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Percent: .2}}, nil
}
s.AwakeContributions = func(player.Character) ([]gamedata.StatContribution, error) {
return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Flat: 5}}, nil
}
maximum, err := s.MaxHealth(character)
if err != nil || maximum != 162 {
t.Fatalf("maximum=%d err=%v, want trunc((100+20+5)*1.3)=162", maximum, err)
}
s.EquipmentContributions = func(player.Character) ([]gamedata.StatContribution, error) {
return nil, errors.New("equipment unavailable")
}
if _, err := s.MaxHealth(character); err == nil {
t.Fatal("ignored equipment stat failure")
}
}
+10 -5
View File
@@ -27,8 +27,10 @@ type Service struct {
Owned Owned
// AwakeContributions is injected by the character-awakening domain so all
// server-side maximum-HP consumers use the same derived account state.
AwakeContributions func(player.Character) ([]gamedata.StatContribution, error)
baseHealth sync.Map // [2]uint64 (design character ID, level) -> design-only base HP
AwakeContributions func(player.Character) ([]gamedata.StatContribution, error)
EquipmentContributions func(player.Character) ([]gamedata.StatContribution, error)
PotentialContributions func(player.Character) ([]gamedata.StatContribution, error)
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) {
@@ -273,12 +275,15 @@ func (s *Service) MaxHealth(character player.Character) (uint64, error) {
if err != nil {
return 0, err
}
if s.AwakeContributions != nil {
awake, err := s.AwakeContributions(character)
for _, calculate := range []func(player.Character) ([]gamedata.StatContribution, error){s.AwakeContributions, s.EquipmentContributions, s.PotentialContributions} {
if calculate == nil {
continue
}
additional, err := calculate(character)
if err != nil {
return 0, err
}
contributions = append(contributions, awake...)
contributions = append(contributions, additional...)
}
maxHP := gamedata.AggregateStats(gamedata.BaseStats{Health: value.(float64)}, contributions).Health
if maxHP < 1 || maxHP > float64(^uint64(0)) {
+6
View File
@@ -141,6 +141,7 @@ type collectionSnapshot struct {
CostumeBurstLevels map[string]uint64 `json:"-"`
CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
Friendships map[string]FriendshipEntry `json:"-"`
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
@@ -178,6 +179,7 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
CostumePotential: map[string][]uint64{},
CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
CharAwake: map[string]CharAwakeProgress{},
Friendships: map[string]FriendshipEntry{},
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
}}
@@ -215,6 +217,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
if err := s.validateCostumeBurstStorage(); err != nil {
return nil, err
}
if err := validateFriendshipEntries(s.data.Friendships); err != nil {
return nil, err
}
for key, progress := range s.data.CharAwake {
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
@@ -1184,6 +1189,7 @@ func cloneGrant(grant CollectionGrant) CollectionGrant {
func cloneCollection(in collectionSnapshot) collectionSnapshot {
out := in
out.Friendships = cloneFriendshipEntries(in.Friendships)
out.LatestPreview = append([]uint64(nil), in.LatestPreview...)
out.Characters = append([]Character(nil), in.Characters...)
out.Costumes = append([]Costume(nil), in.Costumes...)
@@ -16,7 +16,7 @@ var collectionEntryBuckets = [...]string{
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
"gacha_selections", "gacha_selection_changes", "costume_potential",
"costume_burst_levels", "costume_burst_upgrades", "char_awake",
"costume_burst_levels", "costume_burst_upgrades", "char_awake", "friendships",
}
func rejectInlineCollectionEntries(raw []byte) error {
@@ -56,6 +56,9 @@ func loadEntryMap[T any](store stateio.EntryStore, bucket string) (map[string]T,
func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) error {
var err error
if data.Friendships, err = loadEntryMap[FriendshipEntry](store, "friendships"); err != nil {
return err
}
if data.Characters, err = loadIndexedEntries[Character](store, "characters", func(c Character) uint64 { return c.InvenIndex }); err != nil {
return err
}
@@ -123,6 +126,7 @@ func loadIndexedEntries[T any](store stateio.EntryStore, bucket string, index fu
}
func collectionCore(in collectionSnapshot) collectionSnapshot {
in.Friendships = nil
in.Characters = nil
in.Costumes = nil
in.Grants = nil
@@ -197,6 +201,9 @@ func diffIndexedEntries[T any](bucket string, before, after []T, index func(T) u
func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMutation, error) {
changes := make([]stateio.EntryMutation, 0)
if err := diffEntryMap("friendships", before.Friendships, after.Friendships, &changes); err != nil {
return nil, err
}
for _, item := range []struct {
bucket string
old, next map[string]bool
@@ -4,6 +4,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
@@ -27,6 +28,81 @@ func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collect
return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
}
// Contributions follows CharStatInfo.GetCostumeNodeBuffStat: type 2 nodes
// contribute across every owned costume of the same UniqueCharId, while type
// 1 nodes contribute only from CharDBInfo.ConnectPotentialCostume. Values are
// already fractions for the percent options 2, 4 and 6; they are not equipment
// option percentages and must not be divided by 100 or rounded individually.
func (s *CostumePotentialService) Contributions(character Character) ([]gamedata.StatContribution, error) {
if s == nil || s.design == nil || s.collection == nil {
return nil, errors.New("player: missing costume potential contribution source")
}
unique, ok := s.design.CharacterUnique[character.ID]
if !ok || unique == 0 {
return nil, fmt.Errorf("player: unknown costume potential character %d", character.ID)
}
var result []gamedata.StatContribution
connectedFound := character.ConnectPotentialCostume == 0
for _, costume := range s.collection.Costumes() {
if s.design.CostumeUnique[costume.ID] != unique {
continue
}
if costume.ID == character.ConnectPotentialCostume {
connectedFound = true
}
seen := map[uint64]bool{}
for _, id := range costume.PotentialIDs {
if seen[id] {
return nil, errors.New("player: duplicate active costume potential stat node")
}
seen[id] = true
node, found := s.design.Nodes[costume.ID][id]
if !found {
return nil, fmt.Errorf("player: unknown active potential node %d/%d", costume.ID, id)
}
if node.NodeType != 2 && (node.NodeType != 1 || costume.ID != character.ConnectPotentialCostume) {
continue
}
if math.IsNaN(node.StatValue) || math.IsInf(node.StatValue, 0) || node.StatValue < 0 {
return nil, errors.New("player: invalid costume potential stat value")
}
contribution := gamedata.StatContribution{Option: node.StatType}
switch node.StatType {
case 1, 2:
contribution.Stat = gamedata.StatHealth
case 3, 4:
contribution.Stat = gamedata.StatAttack
case 5, 6:
contribution.Stat = gamedata.StatMagic
case 7:
contribution.Stat = gamedata.StatDefencePercent
case 8:
contribution.Stat = gamedata.StatMagicResistancePercent
case 9:
contribution.Stat = gamedata.StatCriticalChance
case 10:
contribution.Stat = gamedata.StatCriticalDamage
case 11, 12, 13, 14, 15, 19:
contribution.Stat = gamedata.StatElementDamage
case 16, 17, 18, 20:
contribution.Stat = gamedata.StatElementResistance
default:
return nil, fmt.Errorf("player: unsupported costume potential stat option %d", node.StatType)
}
if node.StatType == 2 || node.StatType == 4 || node.StatType == 6 {
contribution.Percent = node.StatValue
} else {
contribution.Flat = node.StatValue
}
result = append(result, contribution)
}
}
if !connectedFound {
return nil, errors.New("player: connected potential costume is not owned by this character")
}
return result, nil
}
func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/CostumeNodeActivation" {
return 0, nil, false, nil
@@ -2,13 +2,82 @@ package player
import (
"encoding/binary"
"math"
"path/filepath"
"testing"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestCostumePotentialContributionsIncludePublicAndConnectedNodes(t *testing.T) {
storage := stateio.NewMemory()
collection, err := OpenCollectionStore(storage, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}})
if err != nil {
t.Fatal(err)
}
for _, index := range []uint64{1, 2, 3} {
if err := collection.ActivateCostumePotential(index, []uint64{1, 2, 3}); err != nil {
t.Fatal(err)
}
}
design := &gamedata.CostumePotentialDesign{CharacterUnique: map[uint64]uint64{91: 9, 101: 10}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9, 300: 10}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{
100: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 7}, 2: {ID: 2, NodeType: 1, StatType: 2, StatValue: .085}, 3: {ID: 3, NodeType: 4}},
200: {1: {ID: 1, NodeType: 2, StatType: 2, StatValue: .015}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 20}, 3: {ID: 3, NodeType: 2, StatType: 3, StatValue: 3}},
300: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 1000}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 1000}, 3: {ID: 3, NodeType: 4}},
}}
service := &CostumePotentialService{design: design, collection: collection}
character := Character{ID: 91, ConnectPotentialCostume: 100}
contributions, err := service.Contributions(character)
if err != nil {
t.Fatal(err)
}
stats := gamedata.AggregateStats(gamedata.BaseStats{Health: 100, Attack: 10}, contributions)
if stats.Health != 117 || stats.Attack != 13 {
t.Fatalf("public plus connected stats=%+v contributions=%+v", stats, contributions)
}
character.ConnectPotentialCostume = 200
contributions, err = service.Contributions(character)
if err != nil {
t.Fatal(err)
}
stats = gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions)
if stats.Health != 128 {
t.Fatalf("connection switch health=%f", stats.Health)
}
character.ConnectPotentialCostume = 0
contributions, err = service.Contributions(character)
if err != nil {
t.Fatal(err)
}
stats = gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions)
if stats.Health != 108 {
t.Fatalf("unconnected public health=%f", stats.Health)
}
character.ConnectPotentialCostume = 300
if _, err := service.Contributions(character); err == nil {
t.Fatal("connected another character's costume")
}
character.ConnectPotentialCostume = 100
reopened, err := OpenCollectionStore(storage, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}})
if err != nil {
t.Fatal(err)
}
service.collection = reopened
contributions, err = service.Contributions(character)
if err != nil {
t.Fatal(err)
}
if stats := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions); stats.Health != 117 {
t.Fatalf("restart contribution health=%f", stats.Health)
}
design.Nodes[100][1] = gamedata.CostumePotentialNode{ID: 1, NodeType: 2, StatType: 1, StatValue: math.NaN()}
if _, err := service.Contributions(character); err == nil {
t.Fatal("nonfinite stat accepted")
}
}
func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) {
dir := t.TempDir()
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
+390
View File
@@ -0,0 +1,390 @@
package player
import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"sync"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
// FoodService participates in the account request transaction: consuming the
// stack, saving health and recording the replay response commit together.
type FoodService struct {
mu sync.Mutex
store stateio.AtomicEntryStore
design *gamedata.FoodDesign
inventory *Inventory
characters *CharacterStore
session string
currentPack func() (int, error)
battleActive func() bool
}
type foodReply struct {
Digest string `json:"digest"`
Body []byte `json:"body"`
}
func OpenFoodService(store stateio.Store, design *gamedata.FoodDesign, inventory *Inventory, characters *CharacterStore) (*FoodService, error) {
entries, ok := store.(stateio.AtomicEntryStore)
if !ok || design == nil || inventory == nil || characters == nil {
return nil, errors.New("player: incomplete food configuration")
}
s := &FoodService{store: entries, design: design, inventory: inventory, characters: characters}
health, err := entries.ListEntries("characters", "current_hp")
if err != nil {
return nil, err
}
for key, raw := range health {
index, err := strconv.ParseUint(key, 10, 64)
var hp uint64
if err != nil || index == 0 || json.Unmarshal(raw, &hp) != nil {
return nil, errors.New("player: malformed saved current health")
}
if _, err := characters.MaxHealth(index); err != nil {
return nil, err
}
}
replies, err := entries.ListEntries("characters", "food_requests")
if err != nil {
return nil, err
}
for key, raw := range replies {
var reply foodReply
decodedKey, keyErr := hex.DecodeString(key)
if keyErr != nil || len(decodedKey) != sha256.Size || json.Unmarshal(raw, &reply) != nil || len(reply.Body) == 0 {
return nil, errors.New("player: malformed saved food replay")
}
digest, err := hex.DecodeString(reply.Digest)
if err != nil || len(digest) != sha256.Size {
return nil, errors.New("player: malformed saved food replay digest")
}
if err := wire.Walk(reply.Body, func(field wire.Field) error { return nil }); err != nil {
return nil, errors.New("player: malformed saved food replay body")
}
}
return s, nil
}
func (s *FoodService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
// AttachContext supplies authoritative pack and active-battle state; the client
// omits PackId in ordinary recovery requests, which means the current pack.
func (s *FoodService) AttachContext(currentPack func() (int, error), battleActive func() bool) error {
if currentPack == nil || battleActive == nil {
return errors.New("player: incomplete food context")
}
s.mu.Lock()
defer s.mu.Unlock()
s.currentPack = currentPack
s.battleActive = battleActive
return nil
}
func (s *FoodService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path == "/EatFoodAuto" {
return s.eatFoodAuto(request)
}
if path != "/EatFood" {
return 0, nil, false, nil
}
var seq, pack, index uint64
seenHeaders := map[int]bool{}
parseErr := wire.Walk(request, func(field wire.Field) error {
if field.Number == 4 {
return nil
}
target := map[int]*uint64{1: &seq, 2: &pack, 3: &index}[field.Number]
if target == nil || field.Type != 0 || seenHeaders[field.Number] {
return errors.New("player: invalid EatFood header")
}
seenHeaders[field.Number] = true
*target, _ = binary.Uvarint(field.Value)
return nil
})
if parseErr != nil || seq == 0 || seq > math.MaxInt32 || pack > math.MaxInt32 || index == 0 || index > math.MaxInt64 {
return 0, nil, true, errors.New("player: invalid EatFood request")
}
items, err := equipmentRequestItems(request, 4, "EatFood")
if err != nil || len(items) == 0 {
return 0, nil, true, errors.New("player: EatFood requires food stacks")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.session == "" || s.currentPack == nil || s.battleActive == nil {
return 0, nil, true, errors.New("player: EatFood session or context unavailable")
}
keyDigest := sha256.Sum256([]byte(s.session + ":EatFood:" + strconv.FormatUint(seq, 10)))
key := hex.EncodeToString(keyDigest[:])
digest := sha256.Sum256(request)
digestString := hex.EncodeToString(digest[:])
prior, found, err := s.store.LoadEntry("characters", "food_requests", key)
if err != nil {
return 0, nil, true, err
}
if found {
var reply foodReply
if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString {
return 0, nil, true, errors.New("player: EatFood sequence reused with different request")
}
return 22, append([]byte(nil), reply.Body...), true, nil
}
currentPack, err := s.currentPack()
if err != nil || currentPack <= 0 || (pack != 0 && pack != uint64(currentPack)) {
return 0, nil, true, errors.New("player: EatFood pack unavailable")
}
if s.battleActive() {
return 0, nil, true, errors.New("player: cannot eat food during battle")
}
character, err := s.recoverCharacter(index, items)
if err != nil {
return 0, nil, true, err
}
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, err
}
body := wire.AppendBytes(nil, 1, CharacterWire(character))
if err := s.inventory.Consume(items); err != nil {
return 0, nil, true, err
}
healthRaw, _ := json.Marshal(character.HP)
replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body})
if err := s.store.SaveWithEntries("characters", nil, []stateio.EntryMutation{{Bucket: "current_hp", Key: strconv.FormatUint(index, 10), Payload: healthRaw}, {Bucket: "food_requests", Key: key, Payload: replyRaw}}); err != nil {
return 0, nil, true, err
}
return 22, body, true, nil
}
// MaxHealth reads the shared stat calculator without replacing it with field HP.
func (s *CharacterStore) MaxHealth(index uint64) (uint64, error) {
var character Character
for _, candidate := range s.RawAll() {
if candidate.InvenIndex == index {
character = candidate
break
}
}
if character.InvenIndex == 0 {
return 0, fmt.Errorf("player: unknown health character %d", index)
}
s.mu.Lock()
calculate := s.maxHealth
s.mu.Unlock()
if calculate != nil {
return calculate(character)
}
if character.HP == 0 {
return 0, errors.New("player: maximum health unavailable")
}
return character.HP, nil
}
func (s *CharacterStore) CurrentHealth(index uint64) (uint64, error) {
maximum, err := s.MaxHealth(index)
if err != nil {
return 0, err
}
return s.currentHealthAtMaximum(index, maximum)
}
func (s *CharacterStore) currentHealthAtMaximum(index, maximum uint64) (uint64, error) {
if s.store == nil {
return maximum, nil
}
data, found, err := s.store.LoadEntry("characters", "current_hp", strconv.FormatUint(index, 10))
if err != nil || !found {
return maximum, err
}
var hp uint64
if json.Unmarshal(data, &hp) != nil {
return 0, errors.New("player: invalid saved current health")
}
if hp > maximum {
hp = maximum
}
return hp, nil
}
// SetCurrentHealth accepts server-validated battle results and recovery only.
// Food requests never accept health from the caller.
func (s *CharacterStore) SetCurrentHealth(index, hp uint64) error {
maximum, err := s.MaxHealth(index)
if err != nil {
return err
}
if hp > maximum {
return errors.New("player: current health exceeds maximum")
}
raw, _ := json.Marshal(hp)
return s.store.PutEntry("characters", "current_hp", strconv.FormatUint(index, 10), raw)
}
func (s *CharacterStore) resetCurrentHealth(index uint64) error {
_, err := s.store.DeleteEntry("characters", "current_hp", strconv.FormatUint(index, 10))
return err
}
func (s *FoodService) eatFoodAuto(request []byte) (int, []byte, bool, error) {
var seq uint64
var targets []struct {
index uint64
items []Item
}
seenSequence := false
err := wire.Walk(request, func(field wire.Field) error {
switch field.Number {
case 1:
if field.Type != 0 || seenSequence {
return errors.New("player: invalid EatFoodAuto sequence")
}
seenSequence = true
seq, _ = binary.Uvarint(field.Value)
case 2:
if field.Type != 2 {
return errors.New("player: invalid EatFoodAuto target")
}
var index uint64
seenIndex := false
if err := wire.Walk(field.Value, func(inner wire.Field) error {
if inner.Number == 2 {
return nil
}
if inner.Number != 1 || inner.Type != 0 || seenIndex {
return errors.New("player: invalid EatFoodAuto character")
}
seenIndex = true
index, _ = binary.Uvarint(inner.Value)
return nil
}); err != nil {
return err
}
if index == 0 || index > math.MaxInt64 {
return errors.New("player: invalid EatFoodAuto character index")
}
items, err := equipmentRequestItems(field.Value, 2, "EatFoodAuto")
if err != nil {
return err
}
if len(items) == 0 {
return errors.New("player: EatFoodAuto character requires food stacks")
}
targets = append(targets, struct {
index uint64
items []Item
}{index, items})
default:
return errors.New("player: unknown EatFoodAuto field")
}
return nil
})
if err != nil || seq == 0 || seq > math.MaxInt32 || len(targets) == 0 {
return 0, nil, true, errors.New("player: invalid EatFoodAuto request")
}
s.mu.Lock()
defer s.mu.Unlock()
if s.session == "" || s.currentPack == nil || s.battleActive == nil {
return 0, nil, true, errors.New("player: EatFoodAuto context unavailable")
}
keyDigest := sha256.Sum256([]byte(s.session + ":EatFoodAuto:" + strconv.FormatUint(seq, 10)))
key := hex.EncodeToString(keyDigest[:])
digest := sha256.Sum256(request)
digestString := hex.EncodeToString(digest[:])
prior, found, err := s.store.LoadEntry("characters", "food_requests", key)
if err != nil {
return 0, nil, true, err
}
if found {
var reply foodReply
if json.Unmarshal(prior, &reply) != nil || reply.Digest != digestString {
return 0, nil, true, errors.New("player: EatFoodAuto sequence reused with different request")
}
return 27, append([]byte(nil), reply.Body...), true, nil
}
pack, err := s.currentPack()
if err != nil || pack <= 0 || s.battleActive() {
return 0, nil, true, errors.New("player: EatFoodAuto unavailable during battle or outside pack")
}
seenCharacters := map[uint64]bool{}
var items []Item
var body []byte
var changes []stateio.EntryMutation
for _, target := range targets {
if seenCharacters[target.index] {
return 0, nil, true, errors.New("player: duplicate EatFoodAuto character")
}
seenCharacters[target.index] = true
character, err := s.recoverCharacter(target.index, target.items)
if err != nil {
return 0, nil, true, err
}
items = append(items, target.items...)
raw, _ := json.Marshal(character.HP)
changes = append(changes, stateio.EntryMutation{Bucket: "current_hp", Key: strconv.FormatUint(target.index, 10), Payload: raw})
info := wire.AppendVarint(nil, 1, target.index)
info = wire.AppendVarint(info, 2, character.HP)
body = wire.AppendBytes(body, 1, info)
}
// CanConsume accounts for a shared stack requested by several characters.
if err := s.inventory.CanConsume(items); err != nil {
return 0, nil, true, err
}
if err := s.inventory.Consume(items); err != nil {
return 0, nil, true, err
}
replyRaw, _ := json.Marshal(foodReply{Digest: digestString, Body: body})
changes = append(changes, stateio.EntryMutation{Bucket: "food_requests", Key: key, Payload: replyRaw})
if err := s.store.SaveWithEntries("characters", nil, changes); err != nil {
return 0, nil, true, err
}
return 27, body, true, nil
}
func (s *FoodService) recoverCharacter(index uint64, items []Item) (Character, error) {
character, found := s.characters.Find(index)
if !found {
return Character{}, errors.New("player: EatFood character is not owned")
}
maximum, err := s.characters.MaxHealth(index)
if err != nil {
return Character{}, err
}
current, err := s.characters.CurrentHealth(index)
if err != nil {
return Character{}, err
}
var total uint64
seen := map[uint64]bool{}
for _, item := range items {
if item.Type != 5 || item.InvenIndex == 0 || item.Count == 0 || item.Count > math.MaxInt32 || seen[item.InvenIndex] {
return Character{}, errors.New("player: invalid EatFood inventory stack")
}
seen[item.InvenIndex] = true
food, exists := s.design.Foods[item.ID]
if !exists {
return Character{}, errors.New("player: unknown EatFood dish")
}
value, err := food.Recovery(character.ID, maximum, item.Count)
if err != nil {
return Character{}, err
}
if value > math.MaxUint64-total {
return Character{}, errors.New("player: EatFood recovery overflow")
}
total += value
}
if total >= maximum-current {
character.HP = maximum
} else {
character.HP = current + total
}
return character, nil
}
+387
View File
@@ -0,0 +1,387 @@
package player
import (
"bytes"
"errors"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func foodTestService(t *testing.T, store stateio.Store) (*FoodService, *Inventory, *CharacterStore) {
t.Helper()
starter := &Starter{Version: "2.35.10"}
inventory, err := OpenInventory(store, starter)
if err != nil {
t.Fatal(err)
}
characters, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 7}, {InvenIndex: 78, ID: 360, Level: 1, HP: 7}}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
if err = characters.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil }); err != nil {
t.Fatal(err)
}
if err = characters.EnsurePersisted(); err != nil {
t.Fatal(err)
}
design := &gamedata.FoodDesign{Foods: map[uint64]gamedata.Food{101: {ID: 101, Point: 15}, 102: {ID: 102, Type: 1, Point: 10, FavoritePoint: 25, FavoriteUniqueCharIDs: []uint64{35}}, 103: {ID: 103, Type: 2}, 104: {ID: 104, Point: 100, FoodBuffID: 101}, 105: {ID: 105, Point: 50, RecoveryType: 1}}}
s, err := OpenFoodService(store, design, inventory, characters)
if err != nil {
t.Fatal(err)
}
s.BeginSession("food-session")
if err = s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return false }); err != nil {
t.Fatal(err)
}
return s, inventory, characters
}
func autoFoodRequest(seq uint64, indices []uint64, items []Item) []byte {
out := wire.AppendVarint(nil, 1, seq)
for i, index := range indices {
target := wire.AppendVarint(nil, 1, index)
target = wire.AppendBytes(target, 2, ItemWire(items[i]))
out = wire.AppendBytes(out, 2, target)
}
return out
}
func TestEatFoodAutoSharesStacksValidatesWholeRequestAndReplaysSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
s, inventory, characters := foodTestService(t, repo)
stacks, err := inventory.GrantOnce("auto-food", []gamedata.BattleReward{{Type: 5, ID: 102, Count: 3}})
if err != nil {
t.Fatal(err)
}
for _, index := range []uint64{77, 78} {
if err = characters.SetCurrentHealth(index, 0); err != nil {
t.Fatal(err)
}
}
stack := stacks[0]
stack.Count = 2
overspend := autoFoodRequest(1, []uint64{77, 78}, []Item{stack, stack})
if _, _, _, err = s.Handle("/EatFoodAuto", overspend); err == nil {
t.Fatal("accepted aggregate stack overspend")
}
if hp, _ := characters.CurrentHealth(77); hp != 0 {
t.Fatal("partially healed rejected request")
}
if inventory.All()[0].Count != 3 {
t.Fatal("partially consumed rejected request")
}
stack.Count = 1
request := autoFoodRequest(2, []uint64{77, 78}, []Item{stack, stack})
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
code, body, _, err := s.Handle("/EatFoodAuto", request)
if err != nil || code != 27 {
t.Fatalf("auto code=%d err=%v", code, err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 25 {
t.Fatalf("favorite hp=%d", hp)
}
if hp, _ := characters.CurrentHealth(78); hp != 10 {
t.Fatalf("ordinary hp=%d", hp)
}
var resultIndices []uint64
if err = wire.Walk(body, func(field wire.Field) error {
index, _, err := wire.Varint(field.Value, 1)
resultIndices = append(resultIndices, index)
return err
}); err != nil || len(resultIndices) != 2 || resultIndices[0] != 77 || resultIndices[1] != 78 {
t.Fatalf("auto response=%v err=%v", resultIndices, err)
}
repo.Close()
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
s, inventory, characters = foodTestService(t, repo)
code, replay, _, err := s.Handle("/EatFoodAuto", request)
if err != nil || code != 27 || !bytes.Equal(replay, body) || inventory.All()[0].Count != 1 {
t.Fatalf("auto replay code=%d err=%v", code, err)
}
// Equal sequences on the two endpoints refer to separate requests.
if _, _, _, err = s.Handle("/EatFood", foodRequest(2, 77, 0, stack)); err != nil {
t.Fatalf("normal/auto sequence collision: %v", err)
}
}
func TestEatFoodAutoRejectsEmptyTargetWithoutConsumingValidTarget(t *testing.T) {
s, inventory, characters := foodTestService(t, stateio.NewMemory())
stacks, err := inventory.GrantOnce("auto-empty", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}})
if err != nil {
t.Fatal(err)
}
if err = characters.SetCurrentHealth(77, 0); err != nil {
t.Fatal(err)
}
request := autoFoodRequest(1, []uint64{77}, stacks)
request = wire.AppendBytes(request, 2, wire.AppendVarint(nil, 1, 78))
if _, _, _, err = s.Handle("/EatFoodAuto", request); err == nil {
t.Fatal("accepted empty recovery target")
}
if inventory.All()[0].Count != 1 {
t.Fatal("empty target consumed valid target food")
}
if hp, _ := characters.CurrentHealth(77); hp != 0 {
t.Fatal("empty target partially healed valid target")
}
}
func foodRequest(seq, index, pack uint64, items ...Item) []byte {
out := wire.AppendVarint(nil, 1, seq)
if pack != 0 {
out = wire.AppendVarint(out, 2, pack)
}
out = wire.AppendVarint(out, 3, index)
for _, item := range items {
out = wire.AppendBytes(out, 4, ItemWire(item))
}
return out
}
func TestEatFoodSQLitePersistsRecoveryAndSequenceReplay(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
s, inventory, characters := foodTestService(t, repo)
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 4}, {Type: 5, ID: 102, Count: 2}})
if err != nil {
t.Fatal(err)
}
if err = characters.SetCurrentHealth(77, 20); err != nil {
t.Fatal(err)
}
stacks[0].Count = 2
stacks[1].Count = 1
request := foodRequest(10, 77, 0, stacks...)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
code, body, handled, err := s.Handle("/EatFood", request)
if err != nil || code != 22 || !handled {
t.Fatalf("code=%d handled=%v err=%v", code, handled, err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if hp, err := characters.CurrentHealth(77); err != nil || hp != 75 {
t.Fatalf("hp=%d err=%v", hp, err)
}
if c, _ := characters.Find(77); c.HP != 75 || characters.All()[0].HP != 75 {
t.Fatal("current HP overwritten by maximum")
}
if err = repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
s, inventory, characters = foodTestService(t, repo)
code, replayed, _, err := s.Handle("/EatFood", request)
if err != nil || code != 22 || !bytes.Equal(body, replayed) {
t.Fatalf("replay err=%v", err)
}
if items := inventory.All(); len(items) != 2 || items[0].Count != 2 || items[1].Count != 1 {
t.Fatalf("retry consumed items %+v", items)
}
if hp, _ := characters.CurrentHealth(77); hp != 75 {
t.Fatalf("reopened hp=%d", hp)
}
request = foodRequest(10, 77, 0, stacks[0])
if _, _, _, err = s.Handle("/EatFood", request); err == nil {
t.Fatal("accepted changed request with reused sequence")
}
}
func TestEatFoodRejectsInvalidStacksAndContextWithoutMutation(t *testing.T) {
s, inventory, characters := foodTestService(t, stateio.NewMemory())
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 2}, {Type: 5, ID: 103, Count: 2}, {Type: 5, ID: 104, Count: 2}, {Type: 5, ID: 999, Count: 2}})
if err != nil {
t.Fatal(err)
}
if err = characters.SetCurrentHealth(77, 0); err != nil {
t.Fatal(err)
}
wrongID := stacks[0]
wrongID.ID = 102
wrongType := stacks[0]
wrongType.Type = 8
over := stacks[0]
over.Count = 3
missing := stacks[0]
missing.InvenIndex++
requests := [][]byte{foodRequest(1, 999, 0, stacks[0]), foodRequest(2, 77, 22, stacks[0]), foodRequest(3, 77, 0, wrongID), foodRequest(4, 77, 0, wrongType), foodRequest(5, 77, 0, over), foodRequest(6, 77, 0, missing), foodRequest(7, 77, 0, stacks[1]), foodRequest(8, 77, 0, stacks[2]), foodRequest(9, 77, 0, stacks[3]), foodRequest(10, 77, 0, stacks[0], stacks[0]), foodRequest(11, 77, 0)}
for i, request := range requests {
if _, _, _, err := s.Handle("/EatFood", request); err == nil {
t.Fatalf("invalid request %d accepted", i)
}
}
s.AttachContext(func() (int, error) { return 21, nil }, func() bool { return true })
if _, _, _, err = s.Handle("/EatFood", foodRequest(12, 77, 0, stacks[0])); err == nil {
t.Fatal("accepted food during battle")
}
if hp, _ := characters.CurrentHealth(77); hp != 0 {
t.Fatalf("rejected request changed HP=%d", hp)
}
if got := inventory.All(); len(got) != 4 || got[0].Count != 2 {
t.Fatalf("rejected request consumed inventory %+v", got)
}
}
func TestEatFoodPercentageRecoversDeadCharacterAndClampsToMaximum(t *testing.T) {
s, inventory, characters := foodTestService(t, stateio.NewMemory())
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 105, Count: 3}})
if err != nil {
t.Fatal(err)
}
if err = characters.SetCurrentHealth(77, 0); err != nil {
t.Fatal(err)
}
stack := stacks[0]
stack.Count = 1
if _, _, _, err = s.Handle("/EatFood", foodRequest(1, 77, 21, stack)); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 50 {
t.Fatalf("percentage hp=%d", hp)
}
stack.Count = 2
if _, _, _, err = s.Handle("/EatFood", foodRequest(2, 77, 21, stack)); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 100 {
t.Fatalf("clamped hp=%d", hp)
}
if len(inventory.All()) != 0 {
t.Fatal("empty food stack remains")
}
if err = characters.SetCurrentHealth(77, 101); err == nil {
t.Fatal("accepted health above maximum")
}
}
func TestCurrentHealthGrowthAndImmortalClearPersistedInjury(t *testing.T) {
_, inventory, characters := foodTestService(t, stateio.NewMemory())
stacks, err := inventory.GrantOnce("growth-food-health", []gamedata.BattleReward{{Type: 8, ID: 1, Count: 1}})
if err != nil {
t.Fatal(err)
}
characters.grow = func(Character, []gamedata.GrowthMaterial) (uint64, uint64, []gamedata.GrowthMaterial, error) {
return 2, 0, nil, nil
}
if err = characters.SetCurrentHealth(77, 3); err != nil {
t.Fatal(err)
}
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77)
request = wire.AppendBytes(request, 3, ItemWire(stacks[0]))
if _, _, _, err = characters.Handle("/CharGrowth", request); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 100 {
t.Fatalf("grown current HP=%d", hp)
}
if err = characters.SetCurrentHealth(77, 0); err != nil {
t.Fatal(err)
}
request = wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77)
if _, _, _, err = characters.Handle("/CharImmortal", request); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 100 {
t.Fatalf("revived current HP=%d", hp)
}
}
type failFoodStore struct {
stateio.AtomicEntryStore
fail bool
}
func (s *failFoodStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
if s.fail && domain == "characters" {
return errors.New("injected food save failure")
}
return s.AtomicEntryStore.SaveWithEntries(domain, core, changes)
}
func TestEatFoodSQLiteRollbackRestoresInventoryHealthAndReplayLedger(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store := &failFoodStore{AtomicEntryStore: repo}
s, inventory, characters := foodTestService(t, store)
stacks, err := inventory.GrantOnce("food", []gamedata.BattleReward{{Type: 5, ID: 101, Count: 1}})
if err != nil {
t.Fatal(err)
}
if err = characters.SetCurrentHealth(77, 1); err != nil {
t.Fatal(err)
}
store.fail = true
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
request := foodRequest(1, 77, 21, stacks[0])
if _, _, _, err = s.Handle("/EatFood", request); err == nil {
t.Fatal("injected failure was ignored")
}
if err = op.Rollback(); !errors.Is(err, stateio.ErrStateRecoveryRequired) {
t.Fatalf("expected recovery fencing, got %v", err)
}
repo.Close()
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
s, inventory, characters = foodTestService(t, repo)
if inventory.All()[0].Count != 1 {
t.Fatal("rollback lost inventory")
}
if hp, _ := characters.CurrentHealth(77); hp != 1 {
t.Fatalf("rollback HP=%d", hp)
}
ledger, err := repo.ListEntries("characters", "food_requests")
if err != nil || len(ledger) != 0 {
t.Fatalf("rollback ledger=%v err=%v", ledger, err)
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
if _, _, _, err = s.Handle("/EatFood", request); err != nil {
t.Fatal(err)
}
if err = op.Commit(); err != nil {
t.Fatal(err)
}
if hp, _ := characters.CurrentHealth(77); hp != 16 {
t.Fatalf("recovery after rollback HP=%d", hp)
}
}
+1
View File
@@ -65,6 +65,7 @@ type EquipmentInventory struct {
craft *gamedata.EquipmentCraftDesign
smelting *gamedata.EquipmentSmeltingDesign
optionReroll *gamedata.EquipmentOptionRerollDesign
statDesign *gamedata.EquipmentStatDesign
wallet *Wallet
inventory *Inventory
sessionID string
@@ -0,0 +1,81 @@
package player
import (
"bd2server/internal/server/gamedata"
"errors"
"fmt"
"math"
)
func (s *EquipmentInventory) AttachStatDesign(design *gamedata.EquipmentStatDesign) error {
if design == nil {
return errors.New("player: nil equipment stat design")
}
s.mu.Lock()
defer s.mu.Unlock()
s.statDesign = design
return nil
}
// StatContributions resolves only maximum-health equipment effects. Snapshot
// options under the equipment lock and release it before calculating; callers
// may themselves be the CharacterStore maximum-health callback.
func (s *EquipmentInventory) StatContributions(character Character) ([]gamedata.StatContribution, error) {
if character.InvenIndex == 0 {
return nil, errors.New("player: invalid equipment stat character")
}
type query struct {
option gamedata.EquipmentOption
sub bool
}
var queries []query
s.mu.Lock()
design := s.statDesign
for _, equipment := range s.owned.Equipment {
if equipment.UseChar != character.InvenIndex {
continue
}
if equipment.Level > math.MaxInt32 {
s.mu.Unlock()
return nil, fmt.Errorf("player: invalid equipment %d level", equipment.InvenIndex)
}
var rank [3]int
for i, value := range equipment.Rank {
if i >= 3 || value > 4 {
s.mu.Unlock()
return nil, fmt.Errorf("player: invalid equipment %d ranks", equipment.InvenIndex)
}
rank[i] = int(value)
}
add := func(option EquipmentOption, sub bool) {
if option.ID == 1 || option.ID == 2 {
queries = append(queries, query{gamedata.EquipmentOption{GroupID: option.GroupID, ID: option.ID, Level: int(equipment.Level), Rank: rank}, sub})
}
}
for _, option := range equipment.MainOption {
add(option, false)
}
for _, option := range equipment.SubOption {
add(option, true)
}
if equipment.PrivateOption != nil {
add(*equipment.PrivateOption, false)
}
}
s.mu.Unlock()
if len(queries) == 0 {
return nil, nil
}
if design == nil {
return nil, errors.New("player: equipment stat design unavailable")
}
result := make([]gamedata.StatContribution, 0, len(queries))
for _, query := range queries {
contribution, err := design.HealthContribution(query.option, query.sub)
if err != nil {
return nil, err
}
result = append(result, contribution)
}
return result, nil
}
@@ -0,0 +1,48 @@
package player
import (
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"testing"
"time"
)
func TestEquipmentStatContributionsUseOnlyEquippedOwnerAndAllHealthOptions(t *testing.T) {
s := &EquipmentInventory{owned: equipmentSnapshot{Equipment: []Equipment{
{InvenIndex: 1, UseChar: 77, Level: 1, Rank: []uint64{1, 0, 0}, MainOption: []EquipmentOption{{GroupID: 10, ID: 1}, {GroupID: 999, ID: 3}}, SubOption: []EquipmentOption{{GroupID: 20, ID: 2}}, PrivateOption: &EquipmentOption{GroupID: 10, ID: 1}},
{InvenIndex: 2, UseChar: 78, MainOption: []EquipmentOption{{GroupID: 999, ID: 1}}},
{InvenIndex: 3, MainOption: []EquipmentOption{{GroupID: 999, ID: 1}}},
}}}
d := &gamedata.EquipmentStatDesign{Options: map[[2]uint64]gamedata.EquipmentStatRule{
{10, 1}: {Default: 7.9, Growth: 2, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, nil, nil}},
{20, 2}: {Default: 0.01359, Growth: 100},
}}
if err := s.AttachStatDesign(d); err != nil {
t.Fatal(err)
}
got, err := s.StatContributions(Character{InvenIndex: 77})
if err != nil || len(got) != 3 {
t.Fatalf("contributions=%+v err=%v", got, err)
}
if hp := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, got).Health; hp != 121 {
t.Fatalf("equipped health=%v", hp)
}
// A normal Find callback must safely enter equipment snapshot resolution.
_, _, characters := foodTestService(t, stateio.NewMemory())
if err := characters.AttachMaxHealth(func(c Character) (uint64, error) {
contributions, err := s.StatContributions(c)
return uint64(gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions).Health), err
}); err != nil {
t.Fatal(err)
}
done := make(chan Character, 1)
go func() { c, _ := characters.Find(77); done <- c }()
select {
case c := <-done:
if c.HP != 121 {
t.Fatalf("Find health=%d", c.HP)
}
case <-time.After(time.Second):
t.Fatal("Find deadlocked calculating equipped health")
}
}
+421
View File
@@ -0,0 +1,421 @@
package player
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"sort"
"strconv"
"sync"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/wire"
)
// FriendshipService applies inventory gifts and counseling inside the account
// request transaction. Static IDs, thresholds and rewards come from GameData.
type FriendshipService struct {
mu sync.Mutex
design *gamedata.FriendshipDesign
awake *gamedata.CharAwakeDesign
potential *gamedata.CostumePotentialDesign
collection *CollectionStore
inventory *Inventory
wallet *Wallet
sessionID string
now func() time.Time
}
func NewFriendshipService(design *gamedata.FriendshipDesign, awake *gamedata.CharAwakeDesign, potential *gamedata.CostumePotentialDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*FriendshipService, error) {
if design == nil || awake == nil || potential == nil || collection == nil || inventory == nil || wallet == nil {
return nil, errors.New("player: incomplete friendship service")
}
s := &FriendshipService{design: design, awake: awake, potential: potential, collection: collection, inventory: inventory, wallet: wallet, now: time.Now}
for _, entry := range collection.FriendshipEntries() {
if entry.Daily != nil && entry.Daily.Used > design.Default.MaxCounselingAP {
return nil, errors.New("player: saved daily friendship AP exceeds maximum")
}
if entry.State == nil {
continue
}
state := entry.State
if _, owned := s.owned(state.CostumeID); !owned {
return nil, errors.New("player: saved friendship costume is not owned")
}
level, ok := design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}]
if !ok || (level.NextEXP != 0 && state.EXP >= level.NextEXP) || (state.Level >= design.Default.MaxLevels[2] && state.EXP != 0) {
return nil, errors.New("player: saved friendship level is not in GameData")
}
if state.CounselingCount > design.Default.MaxCounselingAPByCostume && state.Level < design.Default.MaxLevels[2] {
return nil, errors.New("player: invalid saved costume counseling count")
}
for _, id := range state.Sessions {
if _, ok := design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: id}]; !ok {
return nil, errors.New("player: saved counseling session is not in GameData")
}
}
}
return s, nil
}
func (s *FriendshipService) BeginSession(id string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessionID = id
}
func (s *FriendshipService) FriendshipAP() (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
daily := s.daily(s.collection.FriendshipEntries(), s.now().UTC().Format("2006-01-02"))
if daily.Used >= s.design.Default.MaxCounselingAP {
return 0, nil
}
return s.design.Default.MaxCounselingAP - daily.Used, nil
}
func (s *FriendshipService) daily(entries map[string]FriendshipEntry, day string) FriendshipDaily {
if entry := entries["daily"]; entry.Daily != nil && entry.Daily.Day == day {
return *entry.Daily
}
return FriendshipDaily{Day: day}
}
func (s *FriendshipService) owned(id uint64) (Costume, bool) {
costumeID, ok := s.design.Costumes[id]
if !ok {
return Costume{}, false
}
return s.collection.CostumeByID(costumeID)
}
func (s *FriendshipService) maximum(costume Costume) uint64 {
max := s.design.Default.MaxLevels[0]
unique := s.potential.CostumeUnique[costume.ID]
awake, exists := s.collection.CharAwakeState(unique)
rule, hasRule := s.awake.Characters[unique]
if !exists || !hasRule || !awake.IsAwake {
return max
}
for i, level := range awake.ImprintLevels {
if len(rule.ImprintGrowth[i]) == 0 || level != uint64(len(rule.ImprintGrowth[i])) {
return max
}
}
max = s.design.Default.MaxLevels[1]
nodes := s.potential.Nodes[costume.ID]
if len(nodes) == 0 || len(costume.PotentialIDs) != len(nodes) {
return max
}
for _, id := range costume.PotentialIDs {
if _, ok := nodes[id]; !ok {
return max
}
}
return s.design.Default.MaxLevels[2]
}
func friendshipWire(state FriendshipState) []byte {
b := wire.AppendVarint(nil, 1, state.CostumeID)
b = wire.AppendVarint(b, 2, state.Level)
if state.EXP != 0 {
b = wire.AppendVarint(b, 3, state.EXP)
}
if state.LastCounselingDate != 0 {
b = wire.AppendVarint(b, 4, state.LastCounselingDate)
}
return b
}
func (s *FriendshipService) Handle(path string, request []byte) (int, []byte, bool, error) {
if path != "/FriendshipInfo" && path != "/FriendshipGift" && path != "/FriendshipCounseling" {
return 0, nil, false, nil
}
s.mu.Lock()
defer s.mu.Unlock()
seq, found, err := wire.Varint(request, 1)
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
return 0, nil, true, errors.New("player: friendship invalid sequence")
}
entries := s.collection.FriendshipEntries()
if path == "/FriendshipInfo" {
return 612, s.info(entries), true, nil
}
session := s.sessionID
if session == "" {
return 0, nil, true, errors.New("player: friendship mutation requires an authenticated session")
}
keyHash := sha256.Sum256([]byte(session + ":" + strconv.FormatUint(seq, 10)))
key := "reply:" + hex.EncodeToString(keyHash[:])
digestHash := sha256.Sum256(request)
digest := hex.EncodeToString(digestHash[:])
code := 614
if path == "/FriendshipCounseling" {
code = 613
}
if entry := entries[key]; entry.Reply != nil {
if entry.Reply.Digest != digest || entry.Reply.Code != code {
return 0, nil, true, errors.New("player: friendship sequence reused with different request")
}
return code, append([]byte(nil), entry.Reply.Body...), true, nil
}
id, found, err := wire.Varint(request, 2)
if err != nil || !found || id == 0 || id > math.MaxInt32 {
return 0, nil, true, errors.New("player: friendship invalid costume")
}
costume, owned := s.owned(id)
if !owned {
return 0, nil, true, fmt.Errorf("player: friendship costume %d is not owned", id)
}
state := FriendshipState{CostumeID: id, Level: 1}
if entry := entries[friendshipStateKey(id)]; entry.State != nil {
state = *entry.State
state.Sessions = append([]uint64(nil), state.Sessions...)
}
max := s.maximum(costume)
var body []byte
if code == 614 {
body, err = s.gift(request, state, max, key, digest)
} else {
body, err = s.counsel(request, state, max, entries, key, digest)
}
return code, body, true, err
}
func (s *FriendshipService) info(entries map[string]FriendshipEntry) []byte {
ids := make([]uint64, 0, len(s.design.Costumes))
for id := range s.design.Costumes {
if _, ok := s.owned(id); ok {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
var body []byte
for _, id := range ids {
state := FriendshipState{CostumeID: id, Level: 1}
if entry := entries[friendshipStateKey(id)]; entry.State != nil {
state = *entry.State
}
body = wire.AppendBytes(body, 1, friendshipWire(state))
if len(state.Sessions) != 0 {
counsel := wire.AppendVarint(nil, 1, id)
for _, session := range state.Sessions {
counsel = wire.AppendVarint(counsel, 2, session)
}
body = wire.AppendBytes(body, 2, counsel)
}
}
return body
}
func (s *FriendshipService) gift(request []byte, state FriendshipState, max uint64, key, digest string) ([]byte, error) {
if state.Level >= max {
return nil, errors.New("player: friendship level is at its unlocked maximum")
}
items, err := equipmentRequestItems(request, 3, "FriendshipGift")
if err != nil {
return nil, err
}
var exp uint64
seen := map[uint64]bool{}
for _, item := range items {
if seen[item.InvenIndex] || item.InvenIndex > math.MaxInt64 || item.ID > math.MaxInt32 || item.Type > math.MaxInt32 || item.Count > math.MaxInt32 {
return nil, errors.New("player: invalid friendship gift item")
}
seen[item.InvenIndex] = true
rule, ok := s.design.Gifts[[2]uint64{item.Type, item.ID}]
if !ok {
return nil, errors.New("player: item is not a friendship gift")
}
unit := rule.Experience(state.CostumeID)
if unit == 0 || item.Count > math.MaxInt32/unit || exp > math.MaxInt32-unit*item.Count {
return nil, errors.New("player: invalid friendship gift experience")
}
exp += unit * item.Count
}
if err := s.inventory.CanConsume(items); err != nil {
return nil, err
}
next, rewards, err := s.advance(state, max, exp)
if err != nil {
return nil, err
}
if err := s.inventory.Consume(items); err != nil {
return nil, err
}
bundle, err := s.grant(key, rewards)
if err != nil {
return nil, err
}
body := wire.AppendBytes(nil, 1, bundle)
body = wire.AppendBytes(body, 2, friendshipWire(next))
body = wire.AppendVarint(body, 3, exp)
if err := s.collection.ApplyFriendship(next, nil, key, FriendshipReply{Digest: digest, Code: 614, Body: body}); err != nil {
return nil, err
}
return body, nil
}
func (s *FriendshipService) counsel(request []byte, state FriendshipState, max uint64, entries map[string]FriendshipEntry, key, digest string) ([]byte, error) {
session, found, err := wire.Varint(request, 3)
if err != nil || !found || session == 0 || session > math.MaxInt32 {
return nil, errors.New("player: invalid counseling session")
}
rule, exists := s.design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: session}]
if !exists {
return nil, errors.New("player: counseling session does not belong to costume")
}
choice, _, err := wire.Varint(request, 4)
if err != nil || choice >= rule.ChoiceCount {
return nil, errors.New("player: invalid counseling choice")
}
quick, _, err := wire.Varint(request, 5)
if err != nil || quick > 1 {
return nil, errors.New("player: invalid counseling quick flag")
}
if quick == 1 && (uint64(len(state.Sessions)) < s.design.Default.QuickCounselingUnlockCount || state.Level >= max || choice != 1) {
return nil, errors.New("player: quick counseling is not available")
}
now := s.now()
day := now.UTC().Format("2006-01-02")
daily := s.daily(entries, day)
free := state.Level >= s.design.Default.MaxLevels[2]
if !free {
if daily.Used >= s.design.Default.MaxCounselingAP {
return nil, errors.New("player: no daily friendship AP remaining")
}
if state.CounselingDay == day && state.CounselingCount >= s.design.Default.MaxCounselingAPByCostume {
return nil, errors.New("player: costume daily counseling limit reached")
}
}
correct := quick == 1 || choice == s.design.Default.CorrectSelectDialogIndex
exp := s.design.Default.IncorrectEXP
if correct {
exp = s.design.Default.CorrectEXP
}
if state.Level >= max {
exp = 0
}
next, rewards, err := s.advance(state, max, exp)
if err != nil {
return nil, err
}
completed := false
for _, id := range state.Sessions {
if id == session {
completed = true
break
}
}
if !free {
rewards = append(rewards, s.design.Default.CounselingRewards...)
daily.Used++
} else if !completed && quick == 0 {
// At the final cap the client allows unrestricted story playback. A
// previously unseen story earns its default reward once; repeated
// playback does not create a source of unlimited account currency.
rewards = append(rewards, s.design.Default.CounselingRewards...)
}
if next.CounselingDay != day {
next.CounselingDay = day
next.CounselingCount = 0
}
next.CounselingCount++
if now.UnixMilli() <= 0 {
return nil, errors.New("player: invalid counseling time")
}
next.LastCounselingDate = uint64(now.UnixMilli())
if quick == 0 {
if !completed {
next.Sessions = append(next.Sessions, session)
sort.Slice(next.Sessions, func(i, j int) bool { return next.Sessions[i] < next.Sessions[j] })
}
}
bundle, err := s.grant(key, rewards)
if err != nil {
return nil, err
}
body := wire.AppendBytes(nil, 1, bundle)
body = wire.AppendBytes(body, 2, friendshipWire(next))
if correct {
body = wire.AppendVarint(body, 3, 1)
}
if exp != 0 {
body = wire.AppendVarint(body, 4, exp)
}
if err := s.collection.ApplyFriendship(next, &daily, key, FriendshipReply{Digest: digest, Code: 613, Body: body}); err != nil {
return nil, err
}
return body, nil
}
func (s *FriendshipService) advance(state FriendshipState, max, exp uint64) (FriendshipState, []gamedata.Reward, error) {
if state.EXP > math.MaxInt32-exp {
return state, nil, errors.New("player: friendship experience overflow")
}
state.EXP += exp
var rewards []gamedata.Reward
for state.Level < max {
rule, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}]
if !ok || rule.NextEXP == 0 {
return state, nil, errors.New("player: missing friendship level threshold")
}
if state.EXP < rule.NextEXP {
break
}
state.EXP -= rule.NextEXP
state.Level++
next, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}]
if !ok {
return state, nil, errors.New("player: missing friendship level reward")
}
rewards = append(rewards, next.Rewards...)
}
if state.Level >= max {
state.EXP = 0
}
return state, rewards, nil
}
func (s *FriendshipService) grant(identity string, rewards []gamedata.Reward) ([]byte, error) {
if len(rewards) == 0 {
return nil, nil
}
var items []gamedata.BattleReward
for _, reward := range rewards {
if reward.Type == 0 || reward.Count == 0 || reward.Count > math.MaxInt32 {
return nil, errors.New("player: invalid friendship reward")
}
switch reward.Type {
case 2, 3, 4, 12, 20:
default:
if reward.ID == 0 || reward.ID > math.MaxInt32 {
return nil, errors.New("player: invalid friendship item reward")
}
items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
}
}
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
return nil, err
}
granted, err := s.inventory.GrantOnce(identity+":items", items)
if err != nil {
return nil, err
}
var bundle []byte
for _, item := range granted {
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
}
for _, reward := range rewards {
view := Item{ID: reward.ID, Type: reward.Type, Count: reward.Count}
bundle = wire.AppendBytes(bundle, 6, ItemWire(view))
switch reward.Type {
case 2, 3, 4, 12, 20:
bundle = wire.AppendBytes(bundle, 1, ItemWire(view))
}
}
return bundle, nil
}
@@ -0,0 +1,129 @@
package player
import (
"bytes"
"errors"
"path/filepath"
"testing"
"bd2server/internal/server/accountstate"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
)
type friendshipFailStore struct {
stateio.AtomicEntryStore
fail bool
}
func (s *friendshipFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
if s.fail && domain == "collection" {
return errors.New("injected collection write failure")
}
return s.AtomicEntryStore.SaveWithEntries(domain, core, changes)
}
func sqliteFriendshipService(t *testing.T, store stateio.Store, template *FriendshipService) (*FriendshipService, *Inventory, *Wallet) {
t.Helper()
collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}})
if err != nil {
t.Fatal(err)
}
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
wallet, err := OpenWallet(store, Currency{})
if err != nil {
t.Fatal(err)
}
s, err := NewFriendshipService(template.design, template.awake, template.potential, collection, inventory, wallet)
if err != nil {
t.Fatal(err)
}
s.BeginSession("durable-session")
return s, inventory, wallet
}
func TestFriendshipSQLiteAtomicFailureRestartAndExactRetry(t *testing.T) {
for _, rollback := range []bool{false, true} {
t.Run(map[bool]string{false: "commit-replay", true: "rollback-retry"}[rollback], func(t *testing.T) {
template := newFriendshipHarness(t).service
path := filepath.Join(t.TempDir(), "state.db")
repo, err := accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
store := &friendshipFailStore{AtomicEntryStore: repo}
s, inventory, wallet := sqliteFriendshipService(t, store, template)
items, err := inventory.GrantOnce("seed", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 3}})
if err != nil {
t.Fatal(err)
}
item := items[0]
item.Count = 1
request := giftRequest(1, 10, item)
op, err := repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
store.fail = rollback
_, body, _, handleErr := s.Handle("/FriendshipGift", request)
if rollback {
if handleErr == nil {
t.Fatal("injected failure was ignored")
}
if err := op.Rollback(); err == nil {
t.Fatal("dirty rollback must fence writer until restart")
}
} else {
if handleErr != nil {
t.Fatal(handleErr)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
}
if err := repo.Close(); err != nil {
t.Fatal(err)
}
repo, err = accountstate.Open(path)
if err != nil {
t.Fatal(err)
}
defer repo.Close()
s, inventory, wallet = sqliteFriendshipService(t, repo, template)
if rollback {
if wallet.Snapshot().FreeJewelry != 0 || len(s.collection.FriendshipEntries()) != 0 {
t.Fatal("failed operation left committed rewards or progress")
}
if err := inventory.CanConsume(items); err != nil {
t.Fatal("failed gift consumed committed inventory")
}
}
op, err = repo.BeginOperation()
if err != nil {
t.Fatal(err)
}
_, replay, _, err := s.Handle("/FriendshipGift", request)
if err != nil {
t.Fatal(err)
}
if err := op.Commit(); err != nil {
t.Fatal(err)
}
if !rollback && !bytes.Equal(body, replay) {
t.Fatal("restart response changed")
}
if wallet.Snapshot().FreeJewelry != 7 {
t.Fatal("reward was not applied exactly once")
}
if err := inventory.CanConsume([]Item{{InvenIndex: item.InvenIndex, ID: 7, Type: 8, Count: 2}}); err != nil {
t.Fatal(err)
}
if err := inventory.CanConsume(items); err == nil {
t.Fatal("request was not charged exactly once")
}
})
}
}
@@ -0,0 +1,192 @@
package player
import (
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"bd2server/internal/server/wire"
)
type FriendshipState struct {
CostumeID uint64 `json:"costume_id"`
Level uint64 `json:"level"`
EXP uint64 `json:"exp"`
LastCounselingDate uint64 `json:"last_counseling_date"`
CounselingDay string `json:"counseling_day,omitempty"`
CounselingCount uint64 `json:"counseling_count,omitempty"`
Sessions []uint64 `json:"sessions,omitempty"`
}
type FriendshipDaily struct {
Day string `json:"day"`
Used uint64 `json:"used"`
}
type FriendshipReply struct {
Digest string `json:"digest"`
Code int `json:"code"`
Body []byte `json:"body"`
}
// FriendshipEntry keeps progress and successful request replies in the existing
// collection domain. Each record has exactly one kind, so collection commits
// can update one costume, the daily counter and a replay together.
type FriendshipEntry struct {
State *FriendshipState `json:"state,omitempty"`
Daily *FriendshipDaily `json:"daily,omitempty"`
Reply *FriendshipReply `json:"reply,omitempty"`
}
func friendshipStateKey(id uint64) string { return "costume:" + strconv.FormatUint(id, 10) }
func cloneFriendshipEntries(in map[string]FriendshipEntry) map[string]FriendshipEntry {
out := make(map[string]FriendshipEntry, len(in))
for key, entry := range in {
if entry.State != nil {
state := *entry.State
state.Sessions = append([]uint64(nil), state.Sessions...)
entry.State = &state
}
if entry.Daily != nil {
daily := *entry.Daily
entry.Daily = &daily
}
if entry.Reply != nil {
reply := *entry.Reply
reply.Body = append([]byte(nil), reply.Body...)
entry.Reply = &reply
}
out[key] = entry
}
return out
}
func validateFriendshipEntries(entries map[string]FriendshipEntry) error {
for key, entry := range entries {
kinds := 0
if entry.State != nil {
kinds++
}
if entry.Daily != nil {
kinds++
}
if entry.Reply != nil {
kinds++
}
if kinds != 1 {
return fmt.Errorf("player: invalid friendship entry %q", key)
}
if state := entry.State; state != nil {
if key != friendshipStateKey(state.CostumeID) || state.CostumeID == 0 || state.CostumeID > math.MaxInt32 || state.Level == 0 || state.Level > math.MaxInt32 || state.EXP > math.MaxInt32 || state.LastCounselingDate > math.MaxInt64 {
return fmt.Errorf("player: invalid friendship state %q", key)
}
if state.LastCounselingDate == 0 {
if state.CounselingDay != "" || state.CounselingCount != 0 || len(state.Sessions) != 0 {
return errors.New("player: friendship counseling state has no date")
}
} else if state.CounselingCount == 0 || state.CounselingDay != time.UnixMilli(int64(state.LastCounselingDate)).UTC().Format("2006-01-02") {
return errors.New("player: inconsistent friendship counseling date")
}
seen := map[uint64]bool{}
for _, id := range state.Sessions {
if id == 0 || id > math.MaxInt32 || seen[id] {
return errors.New("player: invalid friendship counseling sessions")
}
seen[id] = true
}
}
if entry.Daily != nil && (key != "daily" || entry.Daily.Day == "") {
return errors.New("player: invalid friendship daily entry")
}
if entry.Daily != nil {
if parsed, err := time.Parse("2006-01-02", entry.Daily.Day); err != nil || parsed.Format("2006-01-02") != entry.Daily.Day {
return errors.New("player: invalid friendship daily date")
}
}
if reply := entry.Reply; reply != nil {
if !strings.HasPrefix(key, "reply:") || len(key) != len("reply:")+64 || (reply.Code != 613 && reply.Code != 614) || len(reply.Digest) != 64 || len(reply.Body) == 0 {
return errors.New("player: invalid friendship replay")
}
if _, err := hex.DecodeString(reply.Digest); err != nil {
return errors.New("player: invalid friendship request digest")
}
if _, err := hex.DecodeString(strings.TrimPrefix(key, "reply:")); err != nil {
return errors.New("player: invalid friendship replay key")
}
if err := validateFriendshipReply(*reply); err != nil {
return err
}
}
}
return nil
}
func validateFriendshipReply(reply FriendshipReply) error {
infoCount := 0
err := wire.Walk(reply.Body, func(field wire.Field) error {
switch field.Number {
case 1:
if field.Type != 2 {
return errors.New("invalid friendship reward bundle")
}
return wire.Walk(field.Value, func(wire.Field) error { return nil })
case 2:
if field.Type != 2 {
return errors.New("invalid friendship response info")
}
infoCount++
id, found, err := wire.Varint(field.Value, 1)
if err != nil || !found || id == 0 || id > math.MaxInt32 {
return errors.New("invalid friendship response costume")
}
level, found, err := wire.Varint(field.Value, 2)
if err != nil || !found || level == 0 || level > math.MaxInt32 {
return errors.New("invalid friendship response level")
}
case 3, 4:
if field.Type != 0 || (reply.Code == 614 && field.Number == 4) {
return errors.New("invalid friendship response scalar")
}
value, _, err := wire.Varint(reply.Body, field.Number)
if err != nil || value > math.MaxInt32 || (reply.Code == 613 && field.Number == 3 && value > 1) {
return errors.New("invalid friendship response value")
}
default:
return errors.New("unexpected friendship response field")
}
return nil
})
if err != nil || infoCount != 1 {
return errors.New("player: invalid friendship replay response")
}
return nil
}
func (s *CollectionStore) FriendshipEntries() map[string]FriendshipEntry {
s.mu.Lock()
defer s.mu.Unlock()
return cloneFriendshipEntries(s.data.Friendships)
}
func (s *CollectionStore) ApplyFriendship(state FriendshipState, daily *FriendshipDaily, replayKey string, reply FriendshipReply) error {
s.mu.Lock()
defer s.mu.Unlock()
next := cloneCollection(s.data)
next.Friendships[friendshipStateKey(state.CostumeID)] = FriendshipEntry{State: &state}
if daily != nil {
next.Friendships["daily"] = FriendshipEntry{Daily: daily}
}
if _, exists := next.Friendships[replayKey]; exists {
return errors.New("player: friendship request already applied")
}
next.Friendships[replayKey] = FriendshipEntry{Reply: &reply}
if err := validateFriendshipEntries(next.Friendships); err != nil {
return err
}
return s.commit(next)
}
@@ -0,0 +1,411 @@
package player
import (
"bytes"
"math"
"testing"
"time"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
func TestFriendshipInfoValidatesSequence(t *testing.T) {
h := newFriendshipHarness(t)
for _, request := range [][]byte{nil, wire.AppendVarint(nil, 1, 0), wire.AppendVarint(nil, 1, math.MaxInt32+1), {0x08, 0x80}} {
if _, _, handled, err := h.service.Handle("/FriendshipInfo", request); !handled || err == nil {
t.Fatal("invalid information request sequence was accepted")
}
}
if code, body, handled, err := h.service.Handle("/FriendshipInfo", wire.AppendVarint(nil, 1, 1)); err != nil || !handled || code != 612 || len(body) == 0 {
t.Fatalf("valid information request: code=%d handled=%v err=%v", code, handled, err)
}
}
func TestFriendshipMutationsRequireSession(t *testing.T) {
h := newFriendshipHarness(t)
h.service.BeginSession("")
item := h.item
item.Count = 1
for path, request := range map[string][]byte{"/FriendshipGift": giftRequest(1, 10, item), "/FriendshipCounseling": counselingRequest(1, 10, 1, 0, false)} {
if _, _, handled, err := h.service.Handle(path, request); !handled || err == nil {
t.Fatalf("%s accepted a mutation without a session", path)
}
}
if len(h.collection.FriendshipEntries()) != 0 || h.wallet.Snapshot().FreeJewelry != 0 {
t.Fatal("missing-session request mutated account state")
}
if err := h.inventory.CanConsume([]Item{h.item}); err != nil {
t.Fatal("missing-session request consumed inventory")
}
h.service.BeginSession("authenticated-session")
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)); err != nil {
t.Fatal(err)
}
}
type friendshipHarness struct {
service *FriendshipService
store *stateio.Memory
collection *CollectionStore
inventory *Inventory
wallet *Wallet
item Item
clock time.Time
}
func newFriendshipHarness(t *testing.T) *friendshipHarness {
t.Helper()
h := &friendshipHarness{store: stateio.NewMemory(), clock: time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC)}
var err error
h.collection, err = OpenCollectionStore(h.store, []Costume{{InvenIndex: 1, ID: 100, Level: 5}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}})
if err != nil {
t.Fatal(err)
}
h.inventory, err = OpenInventory(h.store, &Starter{Version: "2.35.10"})
if err != nil {
t.Fatal(err)
}
items, err := h.inventory.GrantOnce("gift-test", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 50}})
if err != nil {
t.Fatal(err)
}
h.item = items[0]
h.wallet, err = OpenWallet(h.store, Currency{})
if err != nil {
t.Fatal(err)
}
d := &gamedata.FriendshipDesign{Default: gamedata.FriendshipDefaultDesign{CorrectEXP: 100, IncorrectEXP: 80, MaxCounselingAP: 3, MaxCounselingAPByCostume: 1, QuickCounselingUnlockCount: 2, MaxLevels: [3]uint64{3, 4, 5}, CounselingRewards: []gamedata.Reward{{Type: 3, Count: 13}}}, Costumes: map[uint64]uint64{10: 100, 20: 200, 30: 300, 40: 400, 50: 500}, Gifts: map[[2]uint64]gamedata.FriendshipGiftDesign{{8, 7}: {Type: 8, ItemID: 7, EXP: 60, FavoriteEXP: 150, FavoriteCostumeIDs: []uint64{10}}}, Levels: map[gamedata.FriendshipKey]gamedata.FriendshipLevelDesign{}, Sessions: map[gamedata.FriendshipKey]gamedata.FriendshipSessionDesign{}}
for id := range d.Costumes {
for level := uint64(1); level <= 5; level++ {
d.Levels[gamedata.FriendshipKey{GroupID: id, ID: level}] = gamedata.FriendshipLevelDesign{NextEXP: 100}
}
for session := uint64(1); session <= 3; session++ {
d.Sessions[gamedata.FriendshipKey{GroupID: id, ID: session}] = gamedata.FriendshipSessionDesign{ChoiceCount: 2}
}
}
d.Levels[gamedata.FriendshipKey{GroupID: 10, ID: 2}] = gamedata.FriendshipLevelDesign{NextEXP: 100, Rewards: []gamedata.Reward{{Type: 8, ID: 88, Count: 2}, {Type: 3, Count: 7}}}
awake := &gamedata.CharAwakeDesign{Characters: map[uint64]gamedata.CharAwakeCharacter{1: {ImprintGrowth: [3][]gamedata.CharAwakeGrowth{{{}}, {{}}, {{}}}}}}
potential := &gamedata.CostumePotentialDesign{CostumeUnique: map[uint64]uint64{100: 1}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{100: {71: {ID: 71}, 72: {ID: 72}}}}
h.service, err = NewFriendshipService(d, awake, potential, h.collection, h.inventory, h.wallet)
if err != nil {
t.Fatal(err)
}
h.service.now = func() time.Time { return h.clock }
h.service.BeginSession("test-session")
return h
}
func giftRequest(seq, id uint64, item Item) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 2, id)
return wire.AppendBytes(b, 3, ItemWire(item))
}
func counselingRequest(seq, id, session, choice uint64, quick bool) []byte {
b := wire.AppendVarint(nil, 1, seq)
b = wire.AppendVarint(b, 2, id)
b = wire.AppendVarint(b, 3, session)
if choice != 0 {
b = wire.AppendVarint(b, 4, choice)
}
if quick {
b = wire.AppendVarint(b, 5, 1)
}
return b
}
func TestFriendshipGiftConsumesFavoriteAndRestoresExactReplay(t *testing.T) {
h := newFriendshipHarness(t)
item := h.item
item.Count = 1
request := giftRequest(1, 10, item)
code, body, handled, err := h.service.Handle("/FriendshipGift", request)
if err != nil || !handled || code != 614 {
t.Fatalf("gift %d %v %v", code, handled, err)
}
exp, _, _ := wire.Varint(body, 3)
if exp != 150 {
t.Fatalf("favorite exp=%d", exp)
}
state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State
if state.Level != 2 || state.EXP != 50 {
t.Fatalf("state=%+v", state)
}
if h.wallet.Snapshot().FreeJewelry != 7 {
t.Fatal("level currency was not granted")
}
if err := h.inventory.CanConsume([]Item{{InvenIndex: item.InvenIndex, ID: 7, Type: 8, Count: 50}}); err == nil {
t.Fatal("gift stack was not consumed")
}
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 20, item)); err == nil {
t.Fatal("changed request reused sequence")
}
var openErr error
h.collection, openErr = OpenCollectionStore(h.store, []Costume{{InvenIndex: 1, ID: 100, Level: 5}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}})
if openErr != nil {
t.Fatal(openErr)
}
h.inventory, openErr = OpenInventory(h.store, &Starter{Version: "2.35.10"})
if openErr != nil {
t.Fatal(openErr)
}
h.wallet, openErr = OpenWallet(h.store, Currency{})
if openErr != nil {
t.Fatal(openErr)
}
reopened, openErr := NewFriendshipService(h.service.design, h.service.awake, h.service.potential, h.collection, h.inventory, h.wallet)
if openErr != nil {
t.Fatal(openErr)
}
reopened.BeginSession("test-session")
_, replay, _, err := reopened.Handle("/FriendshipGift", request)
if err != nil || !bytes.Equal(body, replay) {
t.Fatalf("durable replay=%v", err)
}
if h.wallet.Snapshot().FreeJewelry != 7 {
t.Fatal("retry granted currency twice")
}
_, info, _, err := reopened.Handle("/FriendshipInfo", wire.AppendVarint(nil, 1, 2))
if err != nil {
t.Fatal(err)
}
var level uint64
if err := wire.Walk(info, func(field wire.Field) error {
if field.Number == 1 {
id, _, _ := wire.Varint(field.Value, 1)
if id == 10 {
level, _, _ = wire.Varint(field.Value, 2)
}
}
return nil
}); err != nil {
t.Fatal(err)
}
if level != 2 {
t.Fatalf("restored level=%d", level)
}
}
func TestFriendshipRejectsCounterfeitGiftsAndUnownedCostumes(t *testing.T) {
for _, test := range []struct {
name string
id uint64
change func(*Item)
}{
{"unowned", 50, func(*Item) {}}, {"fake-index", 10, func(i *Item) { i.InvenIndex++ }}, {"fake-type", 10, func(i *Item) { i.Type = 7 }}, {"fake-id", 10, func(i *Item) { i.ID = 8 }}, {"too-many", 10, func(i *Item) { i.Count = 51 }},
} {
t.Run(test.name, func(t *testing.T) {
h := newFriendshipHarness(t)
item := h.item
item.Count = 1
test.change(&item)
_, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, test.id, item))
if err == nil {
t.Fatal("accepted counterfeit")
}
if len(h.collection.FriendshipEntries()) != 0 || h.wallet.Snapshot().FreeJewelry != 0 {
t.Fatal("rejected gift mutated state")
}
if err := h.inventory.CanConsume([]Item{h.item}); err != nil {
t.Fatal(err)
}
})
}
}
func TestFriendshipCounselingDailyLimitsChoicesAndUTCRollover(t *testing.T) {
h := newFriendshipHarness(t)
_, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 1, 0, false))
if err != nil {
t.Fatal(err)
}
correct, _, _ := wire.Varint(body, 3)
exp, _, _ := wire.Varint(body, 4)
if correct != 1 || exp != 100 {
t.Fatalf("correct=%d exp=%d", correct, exp)
}
if h.wallet.Snapshot().FreeJewelry != 20 {
t.Fatal("level plus counseling currency incorrect")
}
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 10, 2, 0, false)); err == nil {
t.Fatal("same costume counseled twice")
}
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 99, 0, false)); err == nil {
t.Fatal("unknown session accepted")
}
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 1, 2, false)); err == nil {
t.Fatal("unknown choice accepted")
}
_, body, _, err = h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 1, 1, false))
if err != nil {
t.Fatal(err)
}
exp, _, _ = wire.Varint(body, 4)
correct, _, _ = wire.Varint(body, 3)
if exp != 80 || correct != 0 {
t.Fatal("incorrect choice got correct exp")
}
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(3, 30, 1, 0, false)); err != nil {
t.Fatal(err)
}
ap, _ := h.service.FriendshipAP()
if ap != 0 {
t.Fatalf("ap=%d", ap)
}
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(4, 40, 1, 0, false)); err == nil {
t.Fatal("fourth account AP accepted")
}
h.clock = h.clock.Add(2 * time.Minute)
ap, _ = h.service.FriendshipAP()
if ap != 3 {
t.Fatalf("rollover ap=%d", ap)
}
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(4, 10, 2, 1, false)); err != nil {
t.Fatal(err)
}
state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State
if len(state.Sessions) != 2 || state.LastCounselingDate != uint64(h.clock.UnixMilli()) {
t.Fatalf("counsel state=%+v", state)
}
}
func TestFriendshipQuickUnlockAndAwakeningPotentialGates(t *testing.T) {
h := newFriendshipHarness(t)
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 1, 1, true)); err == nil {
t.Fatal("locked quick accepted")
}
state := FriendshipState{CostumeID: 10, Level: 1, Sessions: []uint64{1, 2}, LastCounselingDate: uint64(h.clock.Add(-24 * time.Hour).UnixMilli()), CounselingDay: h.clock.Add(-24 * time.Hour).Format("2006-01-02"), CounselingCount: 1}
if err := h.collection.ApplyFriendship(state, nil, "reply:"+string(bytes.Repeat([]byte("a"), 64)), FriendshipReply{Digest: string(bytes.Repeat([]byte("b"), 64)), Code: 613, Body: wire.AppendBytes(nil, 2, friendshipWire(state))}); err != nil {
t.Fatal(err)
}
_, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 3, 1, true))
if err != nil {
t.Fatal(err)
}
correct, _, _ := wire.Varint(body, 3)
if correct != 1 {
t.Fatal("quick compared fixed choice1 with normal correct choice0")
}
state = *h.collection.FriendshipEntries()[friendshipStateKey(10)].State
if len(state.Sessions) != 2 {
t.Fatal("quick added a new completed story")
}
costume, _ := h.collection.CostumeByID(100)
if max := h.service.maximum(costume); max != 3 {
t.Fatalf("+5 incorrectly unlocked max=%d", max)
}
if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil {
t.Fatal(err)
}
if max := h.service.maximum(costume); max != 4 {
t.Fatalf("awake max=%d", max)
}
if err := h.collection.ActivateCostumePotential(costume.InvenIndex, []uint64{71, 72}); err != nil {
t.Fatal(err)
}
costume, _ = h.collection.CostumeByID(100)
if max := h.service.maximum(costume); max != 5 {
t.Fatalf("potential max=%d", max)
}
}
func TestFriendshipGiftCapDiscardsOverflowAndPaysEachReachedLevel(t *testing.T) {
h := newFriendshipHarness(t)
h.service.design.Levels[gamedata.FriendshipKey{GroupID: 10, ID: 3}] = gamedata.FriendshipLevelDesign{NextEXP: 100, Rewards: []gamedata.Reward{{Type: 47, ID: 900, Count: 1}}}
item := h.item
item.Count = 5
_, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item))
if err != nil {
t.Fatal(err)
}
state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State
if state.Level != 3 || state.EXP != 0 {
t.Fatalf("gate did not discard overflow %+v", state)
}
var rewards []Item
for _, entry := range h.inventory.owned.Items {
if entry.Type == 47 {
rewards = append(rewards, entry)
}
}
if len(rewards) != 1 || rewards[0].ID != 900 || rewards[0].Count != 1 {
t.Fatalf("cap level ID card reward %+v", rewards)
}
item.Count = 1
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(2, 10, item)); err == nil {
t.Fatal("gift accepted at locked gate")
}
if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil {
t.Fatal(err)
}
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(2, 10, item)); err != nil {
t.Fatal(err)
}
state = h.collection.FriendshipEntries()[friendshipStateKey(10)].State
if state.Level != 4 || state.EXP != 0 {
t.Fatalf("awake gate state %+v", state)
}
}
func TestFriendshipFinalCapStoryPlaybackDoesNotConsumeAPOrRepeatReward(t *testing.T) {
h := newFriendshipHarness(t)
if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil {
t.Fatal(err)
}
if err := h.collection.ActivateCostumePotential(1, []uint64{71, 72}); err != nil {
t.Fatal(err)
}
item := h.item
item.Count = 5
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)); err != nil {
t.Fatal(err)
}
_, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 10, 1, 0, false))
if err != nil {
t.Fatal(err)
}
exp, _, _ := wire.Varint(body, 4)
if exp != 0 {
t.Fatal("final cap counseling gained exp")
}
before := h.wallet.Snapshot().FreeJewelry
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(3, 10, 1, 0, false)); err != nil {
t.Fatal(err)
}
if before != 20 || h.wallet.Snapshot().FreeJewelry != before {
t.Fatal("final cap story reward repeated")
}
ap, _ := h.service.FriendshipAP()
if ap != 3 {
t.Fatalf("final cap playback consumed ap=%d", ap)
}
}
func TestFriendshipRejectsCorruptSavedProgress(t *testing.T) {
for _, test := range []struct {
name string
mutate func(*FriendshipState)
}{
{"date", func(s *FriendshipState) {
s.LastCounselingDate = 1
s.CounselingDay = "2026-10-01"
s.CounselingCount = 1
}},
{"experience", func(s *FriendshipState) { s.EXP = 100 }},
{"session", func(s *FriendshipState) { s.Sessions = []uint64{999} }},
} {
t.Run(test.name, func(t *testing.T) {
h := newFriendshipHarness(t)
state := FriendshipState{CostumeID: 10, Level: 1}
test.mutate(&state)
h.collection.data.Friendships[friendshipStateKey(10)] = FriendshipEntry{State: &state}
if err := validateFriendshipEntries(h.collection.data.Friendships); err != nil {
return
}
if _, err := NewFriendshipService(h.service.design, h.service.awake, h.service.potential, h.collection, h.inventory, h.wallet); err == nil {
t.Fatal("accepted corrupt progress")
}
})
}
}
+29
View File
@@ -233,6 +233,11 @@ func (s *CharacterStore) All() []Character {
}
}
}
for i := range characters {
if hp, err := s.currentHealthAtMaximum(characters[i].InvenIndex, characters[i].HP); err == nil {
characters[i].HP = hp
}
}
return characters
}
@@ -260,6 +265,9 @@ func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) {
character.HP = hp
}
}
if hp, err := s.currentHealthAtMaximum(character.InvenIndex, character.HP); err == nil {
character.HP = hp
}
return character, true
}
}
@@ -272,6 +280,11 @@ func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) {
character.HP = hp
}
}
if found {
if hp, err := s.currentHealthAtMaximum(character.InvenIndex, character.HP); err == nil {
character.HP = hp
}
}
return character, found
}
return Character{}, false
@@ -383,6 +396,9 @@ func (s *CharacterStore) Handle(path string, request []byte) (int, []byte, bool,
}
s.characters = next
}
if err := s.resetCurrentHealth(current.InvenIndex); err != nil {
return 0, nil, true, err
}
response := wire.AppendBytes(nil, 1, CharacterWire(current))
var bundle []byte
for _, item := range returned {
@@ -475,6 +491,9 @@ func (s *CharacterStore) promoteCharacter(current Character, position int, fromC
}
s.characters = next
}
if err := s.resetCurrentHealth(current.InvenIndex); err != nil {
return 0, nil, true, err
}
response := wire.AppendBytes(nil, 1, CharacterWire(current))
var bundle []byte
for _, item := range returned {
@@ -530,8 +549,18 @@ func (s *CharacterStore) charImmortal(request []byte) (int, []byte, bool, error)
if !found {
return 0, nil, true, fmt.Errorf("player: CharImmortal unknown character %d", index)
}
maximum, err := s.MaxHealth(index)
if err != nil {
return 0, nil, true, err
}
character.HP = maximum
response = wire.AppendBytes(response, 1, CharacterWire(character))
}
for _, index := range indices {
if err := s.resetCurrentHealth(index); err != nil {
return 0, nil, true, err
}
}
return 96, response, true, nil
}