diff --git a/go/cmd/bd2server/main.go b/go/cmd/bd2server/main.go index fcf1724..c7cfe75 100644 --- a/go/cmd/bd2server/main.go +++ b/go/cmd/bd2server/main.go @@ -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, diff --git a/go/internal/server/account/friendship_test.go b/go/internal/server/account/friendship_test.go new file mode 100644 index 0000000..d61bb99 --- /dev/null +++ b/go/internal/server/account/friendship_test.go @@ -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") + } +} diff --git a/go/internal/server/account/login.go b/go/internal/server/account/login.go index e4e8e59..5df502c 100644 --- a/go/internal/server/account/login.go +++ b/go/internal/server/account/login.go @@ -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 } diff --git a/go/internal/server/battle/battle.go b/go/internal/server/battle/battle.go index 4238edb..05fc200 100644 --- a/go/internal/server/battle/battle.go +++ b/go/internal/server/battle/battle.go @@ -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 { diff --git a/go/internal/server/battle/health_test.go b/go/internal/server/battle/health_test.go new file mode 100644 index 0000000..32aa6dc --- /dev/null +++ b/go/internal/server/battle/health_test.go @@ -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) + } +} diff --git a/go/internal/server/feature/feature.go b/go/internal/server/feature/feature.go index 565fc65..1901877 100644 --- a/go/internal/server/feature/feature.go +++ b/go/internal/server/feature/feature.go @@ -37,7 +37,6 @@ var emptyResponses = map[string]int{ "/FishingCollectionInfo": 465, "/FishingTrapInfo": 457, "/FriendInfoList": 204, - "/FriendshipInfo": 612, "/FriendshipSpecialEpisodeInfo": 623, "/GuildInitInfo": 332, "/IdCardPresetInfo": 450, diff --git a/go/internal/server/feature/feature_test.go b/go/internal/server/feature/feature_test.go index 1753c56..dd63d12 100644 --- a/go/internal/server/feature/feature_test.go +++ b/go/internal/server/feature/feature_test.go @@ -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) } diff --git a/go/internal/server/gamedata/costume_potential.go b/go/internal/server/gamedata/costume_potential.go index fa9efa7..a61b4d8 100644 --- a/go/internal/server/gamedata/costume_potential.go +++ b/go/internal/server/gamedata/costume_potential.go @@ -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 { diff --git a/go/internal/server/gamedata/equipment_stats.go b/go/internal/server/gamedata/equipment_stats.go new file mode 100644 index 0000000..18f589c --- /dev/null +++ b/go/internal/server/gamedata/equipment_stats.go @@ -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) +} diff --git a/go/internal/server/gamedata/equipment_stats_test.go b/go/internal/server/gamedata/equipment_stats_test.go new file mode 100644 index 0000000..5b40d44 --- /dev/null +++ b/go/internal/server/gamedata/equipment_stats_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/food.go b/go/internal/server/gamedata/food.go new file mode 100644 index 0000000..599650a --- /dev/null +++ b/go/internal/server/gamedata/food.go @@ -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 +} diff --git a/go/internal/server/gamedata/food_test.go b/go/internal/server/gamedata/food_test.go new file mode 100644 index 0000000..377d404 --- /dev/null +++ b/go/internal/server/gamedata/food_test.go @@ -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) + } +} diff --git a/go/internal/server/gamedata/friendship.go b/go/internal/server/gamedata/friendship.go new file mode 100644 index 0000000..c246129 --- /dev/null +++ b/go/internal/server/gamedata/friendship.go @@ -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() +} diff --git a/go/internal/server/gamedata/friendship_test.go b/go/internal/server/gamedata/friendship_test.go new file mode 100644 index 0000000..975132d --- /dev/null +++ b/go/internal/server/gamedata/friendship_test.go @@ -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 +} diff --git a/go/internal/server/gamedata/stat_design.go b/go/internal/server/gamedata/stat_design.go index 612eb78..a7d2513 100644 --- a/go/internal/server/gamedata/stat_design.go +++ b/go/internal/server/gamedata/stat_design.go @@ -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 } diff --git a/go/internal/server/pictorial/health_test.go b/go/internal/server/pictorial/health_test.go new file mode 100644 index 0000000..29f5b1e --- /dev/null +++ b/go/internal/server/pictorial/health_test.go @@ -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") + } +} diff --git a/go/internal/server/pictorial/service.go b/go/internal/server/pictorial/service.go index 81a8d59..671cfb9 100644 --- a/go/internal/server/pictorial/service.go +++ b/go/internal/server/pictorial/service.go @@ -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)) { diff --git a/go/internal/server/player/collection.go b/go/internal/server/player/collection.go index 3ab3a52..467ff38 100644 --- a/go/internal/server/player/collection.go +++ b/go/internal/server/player/collection.go @@ -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...) diff --git a/go/internal/server/player/collection_entries.go b/go/internal/server/player/collection_entries.go index 9ff57a5..d142e59 100644 --- a/go/internal/server/player/collection_entries.go +++ b/go/internal/server/player/collection_entries.go @@ -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 diff --git a/go/internal/server/player/costume_potential.go b/go/internal/server/player/costume_potential.go index 63ba880..11de7b2 100644 --- a/go/internal/server/player/costume_potential.go +++ b/go/internal/server/player/costume_potential.go @@ -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 diff --git a/go/internal/server/player/costume_potential_test.go b/go/internal/server/player/costume_potential_test.go index bcc5847..fec04d9 100644 --- a/go/internal/server/player/costume_potential_test.go +++ b/go/internal/server/player/costume_potential_test.go @@ -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"}) diff --git a/go/internal/server/player/eat_food.go b/go/internal/server/player/eat_food.go new file mode 100644 index 0000000..2a06da1 --- /dev/null +++ b/go/internal/server/player/eat_food.go @@ -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 +} diff --git a/go/internal/server/player/eat_food_test.go b/go/internal/server/player/eat_food_test.go new file mode 100644 index 0000000..2c53fcb --- /dev/null +++ b/go/internal/server/player/eat_food_test.go @@ -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) + } +} diff --git a/go/internal/server/player/equipment.go b/go/internal/server/player/equipment.go index 6b7a726..1476c9a 100644 --- a/go/internal/server/player/equipment.go +++ b/go/internal/server/player/equipment.go @@ -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 diff --git a/go/internal/server/player/equipment_stats.go b/go/internal/server/player/equipment_stats.go new file mode 100644 index 0000000..2b0e929 --- /dev/null +++ b/go/internal/server/player/equipment_stats.go @@ -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 +} diff --git a/go/internal/server/player/equipment_stats_test.go b/go/internal/server/player/equipment_stats_test.go new file mode 100644 index 0000000..b2a53ff --- /dev/null +++ b/go/internal/server/player/equipment_stats_test.go @@ -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") + } +} diff --git a/go/internal/server/player/friendship.go b/go/internal/server/player/friendship.go new file mode 100644 index 0000000..f8cecb7 --- /dev/null +++ b/go/internal/server/player/friendship.go @@ -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 +} diff --git a/go/internal/server/player/friendship_sqlite_test.go b/go/internal/server/player/friendship_sqlite_test.go new file mode 100644 index 0000000..12c460f --- /dev/null +++ b/go/internal/server/player/friendship_sqlite_test.go @@ -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") + } + }) + } +} diff --git a/go/internal/server/player/friendship_state.go b/go/internal/server/player/friendship_state.go new file mode 100644 index 0000000..551e4f9 --- /dev/null +++ b/go/internal/server/player/friendship_state.go @@ -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) +} diff --git a/go/internal/server/player/friendship_test.go b/go/internal/server/player/friendship_test.go new file mode 100644 index 0000000..bf3a9c6 --- /dev/null +++ b/go/internal/server/player/friendship_test.go @@ -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") + } + }) + } +} diff --git a/go/internal/server/player/growth.go b/go/internal/server/player/growth.go index b9f2844..47c4961 100644 --- a/go/internal/server/player/growth.go +++ b/go/internal/server/player/growth.go @@ -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 }