feat(server): implement friendship gifts, counseling and food recovery
This commit is contained in:
@@ -511,6 +511,14 @@ func serve(args []string) (serveErr error) {
|
||||
return fmt.Errorf("load pictorial GameData: %w", err)
|
||||
}
|
||||
pictorialService := &pictorial.Service{Design: pictorialDesign, Owned: worldService}
|
||||
equipmentStatDesign, err := gamedata.LoadEquipmentStatDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load equipment stat GameData: %w", err)
|
||||
}
|
||||
if err := ownedEquipment.AttachStatDesign(equipmentStatDesign); err != nil {
|
||||
return err
|
||||
}
|
||||
pictorialService.EquipmentContributions = ownedEquipment.StatContributions
|
||||
charAwakeDesign, err := gamedata.LoadCharAwakeDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load character awakening GameData: %w", err)
|
||||
@@ -537,6 +545,7 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pictorialService.PotentialContributions = costumePotentialService.Contributions
|
||||
costumeBurstDesign, err := gamedata.LoadCostumeBurstDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load costume burst GameData: %w", err)
|
||||
@@ -545,7 +554,49 @@ func serve(args []string) (serveErr error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
friendshipDesign, err := gamedata.LoadFriendshipDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load friendship GameData: %w", err)
|
||||
}
|
||||
friendshipService, err := player.NewFriendshipService(&friendshipDesign, charAwakeDesign, costumePotentialDesign, collection, ownedItems, wallet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load friendship state: %w", err)
|
||||
}
|
||||
if err := login.AttachFriendshipAP(friendshipService); err != nil {
|
||||
return err
|
||||
}
|
||||
battleService := battle.NewService(gameData, *gameDataVersion, ownedItems, worldService.CurrentPackID)
|
||||
characters := worldService.CharacterService()
|
||||
battleService.AttachCommittedHealth(func(health map[uint64]uint64) error {
|
||||
for index, hp := range health {
|
||||
maximum, err := characters.MaxHealth(index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid completed battle health for character %d: %w", index, err)
|
||||
}
|
||||
if hp > maximum {
|
||||
// Battle-only HP buffs are not persisted into field health.
|
||||
// This is our settlement policy, not an inferred provider rule.
|
||||
health[index] = maximum
|
||||
}
|
||||
}
|
||||
for index, hp := range health {
|
||||
if err := characters.SetCurrentHealth(index, hp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
foodDesign, err := gamedata.LoadFoodDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load food GameData: %w", err)
|
||||
}
|
||||
foodService, err := player.OpenFoodService(stateRepository, foodDesign, ownedItems, characters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load food state: %w", err)
|
||||
}
|
||||
if err := foodService.AttachContext(worldService.CurrentPackID, battleService.Active); err != nil {
|
||||
return err
|
||||
}
|
||||
battleService.AttachTutorialWin(func() error {
|
||||
return missionService.CompleteMission(gamedata.MissionKey{GroupType: 0, GroupID: 1, ID: 113})
|
||||
})
|
||||
@@ -565,6 +616,8 @@ func serve(args []string) (serveErr error) {
|
||||
charAwakeService,
|
||||
costumePotentialService,
|
||||
costumeBurstService,
|
||||
friendshipService,
|
||||
foodService,
|
||||
starter,
|
||||
mailService,
|
||||
gachaService,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type friendshipAPFixture struct {
|
||||
remaining uint64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *friendshipAPFixture) FriendshipAP() (uint64, error) { return f.remaining, f.err }
|
||||
|
||||
func TestLoginReadsCurrentFriendshipAPInsteadOfSeed(t *testing.T) {
|
||||
seed := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 1)}
|
||||
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 69, 3)
|
||||
seed.UserInfo = wire.AppendVarint(seed.UserInfo, 70, 99)
|
||||
provider := &friendshipAPFixture{remaining: 2}
|
||||
if err := seed.AttachFriendshipAP(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, remaining := range []uint64{2, 0, 3} {
|
||||
provider.remaining = remaining
|
||||
body, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, _, _ := wire.Bytes(body, 1)
|
||||
free, _, _ := wire.Varint(user, 69)
|
||||
stack, _, _ := wire.Varint(user, 70)
|
||||
if free != remaining || stack != 0 {
|
||||
t.Fatalf("friendship points free=%d stack=%d, want %d/0", free, stack, remaining)
|
||||
}
|
||||
}
|
||||
provider.err = errors.New("unavailable")
|
||||
if _, err := seed.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef")); err == nil {
|
||||
t.Fatal("login ignored friendship state error")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ var emptyResponses = map[string]int{
|
||||
"/FishingCollectionInfo": 465,
|
||||
"/FishingTrapInfo": 457,
|
||||
"/FriendInfoList": 204,
|
||||
"/FriendshipInfo": 612,
|
||||
"/FriendshipSpecialEpisodeInfo": 623,
|
||||
"/GuildInitInfo": 332,
|
||||
"/IdCardPresetInfo": 450,
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
)
|
||||
|
||||
func TestHandleAuditedEmptyResponses(t *testing.T) {
|
||||
if got := len(EmptyPacketCodes()); got != 32 {
|
||||
t.Fatalf("audited empty-response registry has %d paths, want 32", got)
|
||||
if got := len(EmptyPacketCodes()); got != 31 {
|
||||
t.Fatalf("audited empty-response registry has %d paths, want 31", got)
|
||||
}
|
||||
for path, wantCode := range EmptyPacketCodes() {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
@@ -28,7 +28,7 @@ func TestHandleRejectsUnknownAndInvalidRequests(t *testing.T) {
|
||||
if _, _, ok, err := Handle("/CharAwakeInfo", wire.AppendVarint(nil, 1, 1)); ok || err != nil {
|
||||
t.Fatalf("stateful CharAwakeInfo must not be handled by feature defaults: ok=%v err=%v", ok, err)
|
||||
}
|
||||
for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo"} {
|
||||
for _, path := range []string{"/PresetInfo", "/DeckCostumeSettingInfo", "/FriendshipInfo"} {
|
||||
if _, _, ok, err := Handle(path, wire.AppendVarint(nil, 1, 1)); ok || err != nil {
|
||||
t.Fatalf("stateful %s must not be handled by feature defaults: ok=%v err=%v", path, ok, err)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package gamedata
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
@@ -11,6 +12,9 @@ type CostumePotentialCost struct{ Type, ID, Count uint64 }
|
||||
|
||||
type CostumePotentialNode struct {
|
||||
ID uint64
|
||||
NodeType uint64
|
||||
StatType uint64
|
||||
StatValue float64
|
||||
ConditionGrade uint64
|
||||
Prerequisites []uint64
|
||||
Costs []CostumePotentialCost
|
||||
@@ -68,6 +72,22 @@ func loadCostumePotentialDesign(db *sql.DB) (*CostumePotentialDesign, error) {
|
||||
grade, _ := packedInts(proto, 17)
|
||||
conditions, _ := packedInts(proto, 18)
|
||||
node := CostumePotentialNode{ID: id, Prerequisites: append([]uint64(nil), conditions...)}
|
||||
nodeTypes, nodeTypeErr := packedInts(proto, 27)
|
||||
statTypes, statTypeErr := packedInts(proto, 29)
|
||||
statValue, _, statValueErr := fixed64Double(proto, 30)
|
||||
if nodeTypeErr != nil || statTypeErr != nil || statValueErr != nil || len(nodeTypes) != 1 || len(statTypes) > 1 || math.IsNaN(statValue) || math.IsInf(statValue, 0) || statValue < 0 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid costume potential stats %d/%d", groupID, id)
|
||||
}
|
||||
node.NodeType = nodeTypes[0]
|
||||
node.StatValue = statValue
|
||||
if len(statTypes) == 1 {
|
||||
node.StatType = statTypes[0]
|
||||
}
|
||||
if (node.NodeType == 1 || node.NodeType == 2) && (node.StatType == 0 || node.StatType > 20) {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: unsupported costume potential stat %d/%d option %d", groupID, id, node.StatType)
|
||||
}
|
||||
if len(grade) == 1 {
|
||||
node.ConditionGrade = grade[0]
|
||||
} else if len(grade) > 1 {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// EquipmentStatRule retains the exact option curve. Percent values are already
|
||||
// fractions in GameData: 0.0135 means 1.35%, with no conversion by 100.
|
||||
type EquipmentStatRule struct {
|
||||
Default, Growth float64
|
||||
Levels []float64
|
||||
Ranks [3][]float64
|
||||
}
|
||||
|
||||
// EquipmentStatDesign contains the HP subset used for field maximum health.
|
||||
// Loading once avoids decrypting common.db on every character lookup.
|
||||
type EquipmentStatDesign struct {
|
||||
Options map[[2]uint64]EquipmentStatRule
|
||||
}
|
||||
|
||||
func LoadEquipmentStatDesign(root, version string) (*EquipmentStatDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadEquipmentStatDesign(db)
|
||||
}
|
||||
|
||||
func loadEquipmentStatDesign(db *sql.DB) (*EquipmentStatDesign, error) {
|
||||
rows, err := db.Query("SELECT groupId,id,ProtoBuf FROM EquipmentOptionTable WHERE id IN (1,2)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{}}
|
||||
for rows.Next() {
|
||||
var group, id uint64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&group, &id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protoGroup, err := packedInts(raw, 3)
|
||||
if err != nil || len(protoGroup) != 1 || protoGroup[0] != group {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment stat group %d", group)
|
||||
}
|
||||
protoID, err := packedInts(raw, 5)
|
||||
if err != nil || len(protoID) != 1 || protoID[0] != id {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment stat id %d/%d", group, id)
|
||||
}
|
||||
var rule EquipmentStatRule
|
||||
rule.Default, _, err = fixed64Double(raw, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.Growth, _, err = fixed64Double(raw, 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.Levels, err = fixed32Floats(raw, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range rule.Ranks {
|
||||
rule.Ranks[i], err = fixed32Floats(raw, 7+i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
values := []float64{rule.Default, rule.Growth}
|
||||
values = append(values, rule.Levels...)
|
||||
for _, ranks := range rule.Ranks {
|
||||
values = append(values, ranks...)
|
||||
}
|
||||
for _, value := range values {
|
||||
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil, fmt.Errorf("gamedata: invalid equipment stat %d/%d", group, id)
|
||||
}
|
||||
}
|
||||
d.Options[[2]uint64{group, id}] = rule
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(d.Options) == 0 {
|
||||
return nil, fmt.Errorf("gamedata: empty equipment health stat design")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// HealthContribution follows EquipmentInfo: main/private options use the
|
||||
// level-and-rank curve, while suboptions use DefaultValue alone.
|
||||
func (d *EquipmentStatDesign) HealthContribution(option EquipmentOption, suboption bool) (StatContribution, error) {
|
||||
if option.ID == 0 || option.ID > 2 {
|
||||
return StatContribution{}, nil
|
||||
}
|
||||
if d == nil || option.GroupID == 0 || option.Level < 0 {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health stat option")
|
||||
}
|
||||
rule, found := d.Options[[2]uint64{option.GroupID, option.ID}]
|
||||
if !found {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: unknown equipment health option %d/%d", option.GroupID, option.ID)
|
||||
}
|
||||
return equipmentStatContribution(rule, option, suboption)
|
||||
}
|
||||
|
||||
func equipmentStatContribution(rule EquipmentStatRule, option EquipmentOption, suboption bool) (StatContribution, error) {
|
||||
value := rule.Default
|
||||
if !suboption {
|
||||
var level float64
|
||||
if len(rule.Levels) != 0 {
|
||||
if option.Level >= len(rule.Levels) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health option level %d", option.Level)
|
||||
}
|
||||
level = rule.Levels[option.Level]
|
||||
}
|
||||
var ranks float32
|
||||
for i, rank := range option.Rank {
|
||||
if rank == 0 {
|
||||
continue
|
||||
}
|
||||
if rank < 1 || rank > len(rule.Ranks[i]) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health option rank %d", rank)
|
||||
}
|
||||
ranks += float32(rule.Ranks[i][rank-1])
|
||||
}
|
||||
value += rule.Growth * (level + float64(ranks))
|
||||
}
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment health stat result")
|
||||
}
|
||||
return optionContribution(option.ID, value)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEquipmentHealthStatsUseRankCurveSubDefaultAndFractionPercent(t *testing.T) {
|
||||
d := &EquipmentStatDesign{Options: map[[2]uint64]EquipmentStatRule{
|
||||
{10, 1}: {Default: 7.9, Growth: 2, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, {1}, {1.5}}},
|
||||
{20, 2}: {Default: 0.01359, Growth: 0.01, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, {1}, {1.5}}},
|
||||
}}
|
||||
flat, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1, Rank: [3]int{1, 1, 1}}, false)
|
||||
if err != nil || flat.Flat != 15 {
|
||||
t.Fatalf("flat=%+v err=%v", flat, err)
|
||||
}
|
||||
percent, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 1, Rank: [3]int{1, 1, 1}}, false)
|
||||
if err != nil || percent.Percent != 0.0535 {
|
||||
t.Fatalf("percent=%+v err=%v", percent, err)
|
||||
}
|
||||
sub, err := d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 99, Rank: [3]int{99, 99, 99}}, true)
|
||||
if err != nil || sub.Percent != 0.0135 {
|
||||
t.Fatalf("sub=%+v err=%v", sub, err)
|
||||
}
|
||||
if got := AggregateStats(BaseStats{Health: 100}, []StatContribution{flat, percent, sub}).Health; got != 122 {
|
||||
t.Fatalf("aggregated health=%v", got)
|
||||
}
|
||||
if _, err = d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Level: 2}, false); err == nil {
|
||||
t.Fatal("accepted nonexistent level")
|
||||
}
|
||||
if _, err = d.HealthContribution(EquipmentOption{GroupID: 20, ID: 2, Rank: [3]int{2, 0, 0}}, false); err == nil {
|
||||
t.Fatal("accepted nonexistent rank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentStatLoaderReadsCompositeKeysAndFloatCurves(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err = db.Exec("CREATE TABLE EquipmentOptionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := wire.AppendDouble(nil, 1, 7.9)
|
||||
raw = wire.AppendDouble(raw, 4, 2)
|
||||
raw = wire.AppendVarint(raw, 3, 10)
|
||||
raw = wire.AppendVarint(raw, 5, 1)
|
||||
levels := binary.LittleEndian.AppendUint32(nil, math.Float32bits(0))
|
||||
levels = binary.LittleEndian.AppendUint32(levels, math.Float32bits(1))
|
||||
raw = wire.AppendBytes(raw, 6, levels)
|
||||
if _, err = db.Exec("INSERT INTO EquipmentOptionTable VALUES(10,1,?)", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := loadEquipmentStatDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := d.HealthContribution(EquipmentOption{GroupID: 10, ID: 1, Level: 1}, false)
|
||||
if err != nil || got.Flat != 9 {
|
||||
t.Fatalf("health=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentOptionContributionTruncatesRatherThanRounds(t *testing.T) {
|
||||
flat, err := optionContribution(1, 7.9)
|
||||
if err != nil || flat.Flat != 7 {
|
||||
t.Fatalf("flat=%+v err=%v", flat, err)
|
||||
}
|
||||
percent, err := optionContribution(2, 0.01359)
|
||||
if err != nil || percent.Percent != 0.0135 {
|
||||
t.Fatalf("fraction percent=%+v err=%v", percent, err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// FriendshipKey identifies a costume's level or counseling session.
|
||||
type FriendshipKey struct{ GroupID, ID uint64 }
|
||||
|
||||
type FriendshipDefaultDesign struct {
|
||||
CorrectEXP, CorrectSelectDialogIndex, IncorrectEXP uint64
|
||||
MaxCounselingAP, MaxCounselingAPByCostume uint64
|
||||
QuickCounselingUnlockCount uint64
|
||||
MaxLevels [3]uint64
|
||||
CounselingRewards []Reward
|
||||
}
|
||||
|
||||
type FriendshipGiftDesign struct {
|
||||
ID, Type, ItemID, GiftType, EXP, FavoriteEXP uint64
|
||||
FavoriteCostumeIDs []uint64
|
||||
}
|
||||
|
||||
// Experience uses the original friendship costume ID, even when the mapped
|
||||
// owned costume ID is different. GiftType 1 is favorite for every costume.
|
||||
func (g FriendshipGiftDesign) Experience(costumeID uint64) uint64 {
|
||||
if g.GiftType == 1 {
|
||||
return g.FavoriteEXP
|
||||
}
|
||||
for _, id := range g.FavoriteCostumeIDs {
|
||||
if id == costumeID {
|
||||
return g.FavoriteEXP
|
||||
}
|
||||
}
|
||||
return g.EXP
|
||||
}
|
||||
|
||||
type FriendshipLevelDesign struct {
|
||||
NextEXP uint64
|
||||
Rewards []Reward
|
||||
}
|
||||
|
||||
type FriendshipSessionDesign struct {
|
||||
DialogGroupID, ChoiceCount uint64
|
||||
}
|
||||
|
||||
// FriendshipDesign holds immutable tables, not account progress. Gift keys
|
||||
// are ItemType and item design ID; ItemType Resource differs from its subtype.
|
||||
type FriendshipDesign struct {
|
||||
Default FriendshipDefaultDesign
|
||||
Costumes map[uint64]uint64
|
||||
Gifts map[[2]uint64]FriendshipGiftDesign
|
||||
Levels map[FriendshipKey]FriendshipLevelDesign
|
||||
Sessions map[FriendshipKey]FriendshipSessionDesign
|
||||
}
|
||||
|
||||
// LoadFriendshipDesign decrypts and opens the common database once for all
|
||||
// friendship tables. The query helper is shared with other common designs.
|
||||
func LoadFriendshipDesign(root, version string) (FriendshipDesign, error) {
|
||||
db, closeDB, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return FriendshipDesign{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadFriendshipDesign(db)
|
||||
}
|
||||
|
||||
func loadFriendshipDesign(db *sql.DB) (FriendshipDesign, error) {
|
||||
d := FriendshipDesign{Costumes: map[uint64]uint64{}, Gifts: map[[2]uint64]FriendshipGiftDesign{}, Levels: map[FriendshipKey]FriendshipLevelDesign{}, Sessions: map[FriendshipKey]FriendshipSessionDesign{}}
|
||||
if db == nil {
|
||||
return d, fmt.Errorf("gamedata: nil friendship database")
|
||||
}
|
||||
var proto []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM FriendshipDefaultTable WHERE id=0").Scan(&proto); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship defaults: %w", err)
|
||||
}
|
||||
defaults := []*uint64{&d.Default.CorrectEXP, &d.Default.CorrectSelectDialogIndex, &d.Default.IncorrectEXP, &d.Default.MaxCounselingAP, &d.Default.MaxCounselingAPByCostume, &d.Default.QuickCounselingUnlockCount, &d.Default.MaxLevels[0], &d.Default.MaxLevels[1], &d.Default.MaxLevels[2]}
|
||||
for i, field := range []int{1, 2, 12, 13, 14, 18, 7, 8, 9} {
|
||||
value, err := friendshipScalar(proto, field)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship defaults: %w", err)
|
||||
}
|
||||
*defaults[i] = value
|
||||
}
|
||||
var err error
|
||||
d.Default.CounselingRewards, err = friendshipRewards(proto, 5, 4, 3, true)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship counseling rewards: %w", err)
|
||||
}
|
||||
if d.Default.CorrectEXP == 0 || d.Default.IncorrectEXP == 0 || d.Default.MaxCounselingAP == 0 || d.Default.MaxCounselingAPByCostume == 0 || d.Default.QuickCounselingUnlockCount == 0 || d.Default.MaxLevels[0] <= 1 || d.Default.MaxLevels[0] >= d.Default.MaxLevels[1] || d.Default.MaxLevels[1] >= d.Default.MaxLevels[2] {
|
||||
return d, fmt.Errorf("gamedata: invalid friendship defaults")
|
||||
}
|
||||
if err := friendshipRows(db, "SELECT id,ProtoBuf FROM FriendshipCostumeTable ORDER BY id", func(id uint64, proto []byte) error {
|
||||
protoID, err := friendshipScalar(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
costumeID, err := friendshipScalar(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 || protoID != id || costumeID == 0 {
|
||||
return fmt.Errorf("invalid costume identity %d", id)
|
||||
}
|
||||
d.Costumes[id] = costumeID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship costumes: %w", err)
|
||||
}
|
||||
if err := friendshipRows(db, "SELECT id,ProtoBuf FROM FriendshipGiftTable ORDER BY id", func(id uint64, proto []byte) error {
|
||||
g := FriendshipGiftDesign{}
|
||||
for i, dest := range []*uint64{&g.ID, &g.Type, &g.ItemID, &g.GiftType, &g.EXP, &g.FavoriteEXP} {
|
||||
value, err := friendshipScalar(proto, []int{5, 7, 6, 4, 1, 2}[i])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*dest = value
|
||||
}
|
||||
var err error
|
||||
g.FavoriteCostumeIDs, err = packedInts(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id == 0 || g.ID != id || g.Type == 0 || g.ItemID == 0 || g.GiftType > 1 || g.FavoriteEXP == 0 || (g.GiftType == 0 && g.EXP == 0) {
|
||||
return fmt.Errorf("invalid gift %d", id)
|
||||
}
|
||||
key := [2]uint64{g.Type, g.ItemID}
|
||||
if _, exists := d.Gifts[key]; exists {
|
||||
return fmt.Errorf("duplicate gift item %v", key)
|
||||
}
|
||||
for _, costumeID := range g.FavoriteCostumeIDs {
|
||||
if _, ok := d.Costumes[costumeID]; !ok {
|
||||
return fmt.Errorf("gift %d references unknown costume %d", id, costumeID)
|
||||
}
|
||||
}
|
||||
d.Gifts[key] = g
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship gifts: %w", err)
|
||||
}
|
||||
if err := friendshipGroupRows(db, "SELECT groupId,id,ProtoBuf FROM FriendshipLevelTable ORDER BY groupId,id", func(key FriendshipKey, proto []byte) error {
|
||||
if err := friendshipIdentity(proto, key); err != nil {
|
||||
return err
|
||||
}
|
||||
next, err := friendshipScalar(proto, 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rewards, err := friendshipRewards(proto, 6, 5, 4, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Costumes[key.GroupID]; !ok || key.ID == 0 || key.ID > d.Default.MaxLevels[2] || (key.ID < d.Default.MaxLevels[2] && next == 0) {
|
||||
return fmt.Errorf("invalid level %v", key)
|
||||
}
|
||||
d.Levels[key] = FriendshipLevelDesign{NextEXP: next, Rewards: rewards}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship levels: %w", err)
|
||||
}
|
||||
if err := friendshipGroupRows(db, "SELECT groupId,id,ProtoBuf FROM CounselingSessionTable ORDER BY groupId,id", func(key FriendshipKey, proto []byte) error {
|
||||
if err := friendshipIdentity(proto, key); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, err := friendshipScalar(proto, 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := d.Costumes[key.GroupID]; !ok || key.ID == 0 || groupID == 0 {
|
||||
return fmt.Errorf("invalid session %v", key)
|
||||
}
|
||||
d.Sessions[key] = FriendshipSessionDesign{DialogGroupID: groupID}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return d, fmt.Errorf("gamedata: friendship sessions: %w", err)
|
||||
}
|
||||
// Dialog type 4 is EVisualNovelDialogType.Select. Resolve option counts
|
||||
// from the same design used by TimelineVisualNovelManager.StartSelectTalk.
|
||||
for key, session := range d.Sessions {
|
||||
rows, err := db.Query("SELECT s.ProtoBuf FROM VisualNovelDialogTable v JOIN SelectDialogTable s ON s.id=v.selectDialogId WHERE v.groupId=? AND v.type=4", session.DialogGroupID)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
var matches int
|
||||
for rows.Next() {
|
||||
var selection []byte
|
||||
if err := rows.Scan(&selection); err != nil {
|
||||
rows.Close()
|
||||
return d, err
|
||||
}
|
||||
choices, err := packedInts(selection, 1)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return d, err
|
||||
}
|
||||
session.ChoiceCount = uint64(len(choices))
|
||||
matches++
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
if matches != 1 || session.ChoiceCount <= d.Default.CorrectSelectDialogIndex {
|
||||
return d, fmt.Errorf("gamedata: invalid counseling choices %v", key)
|
||||
}
|
||||
d.Sessions[key] = session
|
||||
}
|
||||
for costumeID := range d.Costumes {
|
||||
for level := uint64(1); level <= d.Default.MaxLevels[2]; level++ {
|
||||
if _, ok := d.Levels[FriendshipKey{costumeID, level}]; !ok {
|
||||
return d, fmt.Errorf("gamedata: missing friendship level %d/%d", costumeID, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(d.Costumes) == 0 || len(d.Gifts) == 0 || len(d.Sessions) == 0 {
|
||||
return d, fmt.Errorf("gamedata: empty friendship design")
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func friendshipScalar(proto []byte, field int) (uint64, error) {
|
||||
values, err := packedInts(proto, field)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(values) > 1 {
|
||||
return 0, fmt.Errorf("field %d has multiple scalar values", field)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return values[0], nil
|
||||
}
|
||||
|
||||
func friendshipIdentity(proto []byte, key FriendshipKey) error {
|
||||
group, err := friendshipScalar(proto, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := friendshipScalar(proto, 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if group != key.GroupID || id != key.ID {
|
||||
return fmt.Errorf("identity mismatch %v", key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func friendshipRewards(proto []byte, typeField, idField, countField int, scalar bool) ([]Reward, error) {
|
||||
types, err := packedInts(proto, typeField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids, err := packedInts(proto, idField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts, err := packedInts(proto, countField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The default reward is scalar, so omitted ID means protobuf's zero ID.
|
||||
if scalar && len(ids) == 0 && len(types) == 1 {
|
||||
ids = []uint64{0}
|
||||
}
|
||||
if len(types) != len(ids) || len(types) != len(counts) || (scalar && len(types) > 1) {
|
||||
return nil, fmt.Errorf("mismatched reward arrays")
|
||||
}
|
||||
rewards := make([]Reward, len(types))
|
||||
for i := range types {
|
||||
if types[i] == 0 || counts[i] == 0 {
|
||||
return nil, fmt.Errorf("invalid reward %d", i)
|
||||
}
|
||||
rewards[i] = Reward{Type: types[i], ID: ids[i], Count: counts[i]}
|
||||
}
|
||||
return rewards, nil
|
||||
}
|
||||
|
||||
func friendshipRows(db *sql.DB, query string, visit func(uint64, []byte) error) error {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var proto []byte
|
||||
if err := rows.Scan(&id, &proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visit(id, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func friendshipGroupRows(db *sql.DB, query string, visit func(FriendshipKey, []byte) error) error {
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key FriendshipKey
|
||||
var proto []byte
|
||||
if err := rows.Scan(&key.GroupID, &key.ID, &proto); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := visit(key, proto); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestFriendshipDesignLoadsProtocolDefaultsAndIndependentIdentities(t *testing.T) {
|
||||
db := friendshipTestDB(t)
|
||||
d, err := loadFriendshipDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Default.CorrectSelectDialogIndex != 0 || d.Default.IncorrectEXP != 80 || d.Default.MaxLevels != [3]uint64{2, 3, 4} || !reflect.DeepEqual(d.Default.CounselingRewards, []Reward{{Type: 3, Count: 100}}) {
|
||||
t.Fatalf("defaults=%+v", d.Default)
|
||||
}
|
||||
if d.Costumes[901] != 902 {
|
||||
t.Fatalf("costume mapping=%v", d.Costumes)
|
||||
}
|
||||
gift := d.Gifts[[2]uint64{8, 920}]
|
||||
if gift.Experience(901) != 40 || gift.Experience(902) != 20 {
|
||||
t.Fatalf("gift favorite uses wrong identity: %+v", gift)
|
||||
}
|
||||
if got := d.Gifts[[2]uint64{8, 921}].Experience(901); got != 150 {
|
||||
t.Fatalf("universal experience=%d", got)
|
||||
}
|
||||
if session := d.Sessions[FriendshipKey{901, 7}]; session.DialogGroupID != 931 || session.ChoiceCount != 2 {
|
||||
t.Fatalf("session=%+v", session)
|
||||
}
|
||||
if rewards := d.Levels[FriendshipKey{901, 3}].Rewards; !reflect.DeepEqual(rewards, []Reward{{Type: 47, ID: 950, Count: 1}}) {
|
||||
t.Fatalf("level rewards=%+v", rewards)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipDesignRejectsPartialRewardsAndBrokenReferences(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, query string
|
||||
args []any
|
||||
}{
|
||||
{"partial reward", "UPDATE FriendshipLevelTable SET ProtoBuf=? WHERE id=3", []any{friendshipTestProto(map[int][]uint64{1: {901}, 2: {3}, 3: {60}, 6: {47}})}},
|
||||
{"missing curve level", "DELETE FROM FriendshipLevelTable WHERE id=2", nil},
|
||||
{"missing choices", "DELETE FROM SelectDialogTable", nil},
|
||||
{"unknown favorite", "UPDATE FriendshipGiftTable SET ProtoBuf=? WHERE id=1", []any{friendshipTestProto(map[int][]uint64{1: {20}, 2: {40}, 3: {999}, 5: {1}, 6: {920}, 7: {8}})}},
|
||||
{"mismatched session identity", "UPDATE CounselingSessionTable SET ProtoBuf=?", []any{friendshipTestProto(map[int][]uint64{1: {901}, 2: {8}, 4: {931}})}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
db := friendshipTestDB(t)
|
||||
if _, err := db.Exec(tc.query, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadFriendshipDesign(db); err == nil {
|
||||
t.Fatal("invalid design accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func friendshipTestProto(fields map[int][]uint64) []byte {
|
||||
var proto []byte
|
||||
for field, values := range fields {
|
||||
for _, value := range values {
|
||||
proto = wire.AppendVarint(proto, field, value)
|
||||
}
|
||||
}
|
||||
return proto
|
||||
}
|
||||
|
||||
func friendshipTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { db.Close() })
|
||||
for _, query := range []string{
|
||||
"CREATE TABLE FriendshipDefaultTable(id INTEGER,ProtoBuf BLOB)",
|
||||
"CREATE TABLE FriendshipCostumeTable(id INTEGER,ProtoBuf BLOB)",
|
||||
"CREATE TABLE FriendshipGiftTable(id INTEGER,ProtoBuf BLOB)",
|
||||
"CREATE TABLE FriendshipLevelTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)",
|
||||
"CREATE TABLE CounselingSessionTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)",
|
||||
"CREATE TABLE VisualNovelDialogTable(groupId INTEGER,type INTEGER,selectDialogId INTEGER)",
|
||||
"CREATE TABLE SelectDialogTable(id INTEGER,ProtoBuf BLOB)",
|
||||
} {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insert := func(query string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(query, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
insert("INSERT INTO FriendshipDefaultTable VALUES(0,?)", friendshipTestProto(map[int][]uint64{1: {100}, 3: {100}, 5: {3}, 7: {2}, 8: {3}, 9: {4}, 12: {80}, 13: {3}, 14: {1}, 18: {5}}))
|
||||
insert("INSERT INTO FriendshipCostumeTable VALUES(901,?)", friendshipTestProto(map[int][]uint64{1: {902}, 2: {901}}))
|
||||
insert("INSERT INTO FriendshipGiftTable VALUES(1,?)", friendshipTestProto(map[int][]uint64{1: {20}, 2: {40}, 3: {901}, 5: {1}, 6: {920}, 7: {8}}))
|
||||
insert("INSERT INTO FriendshipGiftTable VALUES(2,?)", friendshipTestProto(map[int][]uint64{2: {150}, 4: {1}, 5: {2}, 6: {921}, 7: {8}}))
|
||||
for level := uint64(1); level <= 4; level++ {
|
||||
fields := map[int][]uint64{1: {901}, 2: {level}}
|
||||
if level < 4 {
|
||||
fields[3] = []uint64{60}
|
||||
}
|
||||
if level == 3 {
|
||||
fields[4] = []uint64{1}
|
||||
fields[5] = []uint64{950}
|
||||
fields[6] = []uint64{47}
|
||||
}
|
||||
insert("INSERT INTO FriendshipLevelTable VALUES(901,?,?)", level, friendshipTestProto(fields))
|
||||
}
|
||||
insert("INSERT INTO CounselingSessionTable VALUES(901,7,?)", friendshipTestProto(map[int][]uint64{1: {901}, 2: {7}, 4: {931}}))
|
||||
insert("INSERT INTO VisualNovelDialogTable VALUES(931,4,940)")
|
||||
insert("INSERT INTO SelectDialogTable VALUES(940,?)", friendshipTestProto(map[int][]uint64{1: {960, 961}, 3: {940}}))
|
||||
return db
|
||||
}
|
||||
@@ -251,41 +251,26 @@ func EquipmentOptionContribution(root, version string, option EquipmentOption) (
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM EquipmentOptionTable WHERE GroupId=? AND id=?", option.GroupID, option.ID).Scan(&proto); err != nil {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: equipment option %d/%d: %w", option.GroupID, option.ID, err)
|
||||
}
|
||||
defaultValue, _, err := fixed64Double(proto, 1)
|
||||
var rule EquipmentStatRule
|
||||
rule.Default, _, err = fixed64Double(proto, 1)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
growthValue, _, err := fixed64Double(proto, 4)
|
||||
rule.Growth, _, err = fixed64Double(proto, 4)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
levels, err := fixed32Floats(proto, 6)
|
||||
rule.Levels, err = fixed32Floats(proto, 6)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
if len(levels) != 0 && option.Level >= len(levels) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: equipment level %d outside option curve", option.Level)
|
||||
}
|
||||
levelValue := 0.0
|
||||
if len(levels) != 0 {
|
||||
levelValue = levels[option.Level]
|
||||
}
|
||||
rankValue := 0.0
|
||||
for i, field := range []int{7, 8, 9} {
|
||||
values, err := fixed32Floats(proto, field)
|
||||
for i := range rule.Ranks {
|
||||
rule.Ranks[i], err = fixed32Floats(proto, 7+i)
|
||||
if err != nil {
|
||||
return StatContribution{}, err
|
||||
}
|
||||
rank := option.Rank[i]
|
||||
if rank == 0 {
|
||||
continue
|
||||
}
|
||||
if rank < 1 || rank > len(values) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: invalid equipment rank %d", rank)
|
||||
}
|
||||
rankValue += values[rank-1]
|
||||
}
|
||||
return optionContribution(option.ID, defaultValue+growthValue*(levelValue+rankValue))
|
||||
return equipmentStatContribution(rule, option, false)
|
||||
}
|
||||
|
||||
func optionContribution(id uint64, value float64) (StatContribution, error) {
|
||||
@@ -295,8 +280,8 @@ func optionContribution(id uint64, value float64) (StatContribution, error) {
|
||||
digits = 4
|
||||
}
|
||||
factor := math.Pow10(digits)
|
||||
value = math.Round(value*factor) / factor
|
||||
contribution := StatContribution{}
|
||||
value = math.Trunc(value*factor) / factor
|
||||
contribution := StatContribution{Option: id}
|
||||
switch id {
|
||||
case 1, 2:
|
||||
contribution.Stat = StatHealth
|
||||
@@ -312,7 +297,7 @@ func optionContribution(id uint64, value float64) (StatContribution, error) {
|
||||
return StatContribution{}, fmt.Errorf("gamedata: unsupported equipment stat option %d", id)
|
||||
}
|
||||
if percentage {
|
||||
contribution.Percent = value / 100
|
||||
contribution.Percent = value
|
||||
} else {
|
||||
contribution.Flat = value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package pictorial
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
)
|
||||
|
||||
func TestMaximumHealthIncludesOwnedEquipmentPotentialAndAwakening(t *testing.T) {
|
||||
character := player.Character{InvenIndex: 77, ID: 350, Level: 1}
|
||||
s := &Service{Design: &gamedata.PictorialDesign{}, Owned: &ownedState{}}
|
||||
s.baseHealth.Store([2]uint64{350, 1}, float64(100))
|
||||
s.EquipmentContributions = func(got player.Character) ([]gamedata.StatContribution, error) {
|
||||
if got.InvenIndex != character.InvenIndex || got.ID != character.ID || got.Level != character.Level {
|
||||
t.Fatalf("wrong stat character: %+v", got)
|
||||
}
|
||||
return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Flat: 20, Percent: .1}}, nil
|
||||
}
|
||||
s.PotentialContributions = func(player.Character) ([]gamedata.StatContribution, error) {
|
||||
return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Percent: .2}}, nil
|
||||
}
|
||||
s.AwakeContributions = func(player.Character) ([]gamedata.StatContribution, error) {
|
||||
return []gamedata.StatContribution{{Stat: gamedata.StatHealth, Flat: 5}}, nil
|
||||
}
|
||||
maximum, err := s.MaxHealth(character)
|
||||
if err != nil || maximum != 162 {
|
||||
t.Fatalf("maximum=%d err=%v, want trunc((100+20+5)*1.3)=162", maximum, err)
|
||||
}
|
||||
s.EquipmentContributions = func(player.Character) ([]gamedata.StatContribution, error) {
|
||||
return nil, errors.New("equipment unavailable")
|
||||
}
|
||||
if _, err := s.MaxHealth(character); err == nil {
|
||||
t.Fatal("ignored equipment stat failure")
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -141,6 +141,7 @@ type collectionSnapshot struct {
|
||||
CostumeBurstLevels map[string]uint64 `json:"-"`
|
||||
CostumeBurstUpgrades map[string]CostumeBurstUpgradeRecord `json:"-"`
|
||||
CharAwake map[string]CharAwakeProgress `json:"char_awake,omitempty"`
|
||||
Friendships map[string]FriendshipEntry `json:"-"`
|
||||
GachaSelections map[string][]GachaSelection `json:"gacha_selections,omitempty"`
|
||||
GachaSelectionChanges map[string]uint64 `json:"gacha_selection_changes,omitempty"`
|
||||
StepUpProgress map[string]uint64 `json:"step_up_progress,omitempty"`
|
||||
@@ -178,6 +179,7 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
CostumePotential: map[string][]uint64{},
|
||||
CostumeBurstLevels: map[string]uint64{}, CostumeBurstUpgrades: map[string]CostumeBurstUpgradeRecord{},
|
||||
CharAwake: map[string]CharAwakeProgress{},
|
||||
Friendships: map[string]FriendshipEntry{},
|
||||
StepUpProgress: map[string]uint64{}, GachaUsers: map[string]GachaUserState{}, GachaFixed: map[string]GachaFixedState{},
|
||||
GachaApplied: map[string]bool{}, GachaPointExchange: map[string]GachaPointExchange{}, Grants: map[string]CollectionGrant{},
|
||||
}}
|
||||
@@ -215,6 +217,9 @@ func OpenCollectionStore(store stateio.Store, base []Costume) (*CollectionStore,
|
||||
if err := s.validateCostumeBurstStorage(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateFriendshipEntries(s.data.Friendships); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, progress := range s.data.CharAwake {
|
||||
uniqueID, parseErr := strconv.ParseUint(key, 10, 64)
|
||||
if parseErr != nil || uniqueID == 0 || (progress.ImprintLevels == [3]uint64{} && !progress.IsAwake) {
|
||||
@@ -1184,6 +1189,7 @@ func cloneGrant(grant CollectionGrant) CollectionGrant {
|
||||
|
||||
func cloneCollection(in collectionSnapshot) collectionSnapshot {
|
||||
out := in
|
||||
out.Friendships = cloneFriendshipEntries(in.Friendships)
|
||||
out.LatestPreview = append([]uint64(nil), in.LatestPreview...)
|
||||
out.Characters = append([]Character(nil), in.Characters...)
|
||||
out.Costumes = append([]Costume(nil), in.Costumes...)
|
||||
|
||||
@@ -16,7 +16,7 @@ var collectionEntryBuckets = [...]string{
|
||||
"characters", "costumes", "grants", "gacha_applied", "gacha_users",
|
||||
"gacha_fixed", "step_up_progress", "gacha_point_exchanges",
|
||||
"gacha_selections", "gacha_selection_changes", "costume_potential",
|
||||
"costume_burst_levels", "costume_burst_upgrades", "char_awake",
|
||||
"costume_burst_levels", "costume_burst_upgrades", "char_awake", "friendships",
|
||||
}
|
||||
|
||||
func rejectInlineCollectionEntries(raw []byte) error {
|
||||
@@ -56,6 +56,9 @@ func loadEntryMap[T any](store stateio.EntryStore, bucket string) (map[string]T,
|
||||
|
||||
func loadCollectionEntries(store stateio.EntryStore, data *collectionSnapshot) error {
|
||||
var err error
|
||||
if data.Friendships, err = loadEntryMap[FriendshipEntry](store, "friendships"); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.Characters, err = loadIndexedEntries[Character](store, "characters", func(c Character) uint64 { return c.InvenIndex }); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,6 +126,7 @@ func loadIndexedEntries[T any](store stateio.EntryStore, bucket string, index fu
|
||||
}
|
||||
|
||||
func collectionCore(in collectionSnapshot) collectionSnapshot {
|
||||
in.Friendships = nil
|
||||
in.Characters = nil
|
||||
in.Costumes = nil
|
||||
in.Grants = nil
|
||||
@@ -197,6 +201,9 @@ func diffIndexedEntries[T any](bucket string, before, after []T, index func(T) u
|
||||
|
||||
func diffCollectionEntries(before, after collectionSnapshot) ([]stateio.EntryMutation, error) {
|
||||
changes := make([]stateio.EntryMutation, 0)
|
||||
if err := diffEntryMap("friendships", before.Friendships, after.Friendships, &changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range []struct {
|
||||
bucket string
|
||||
old, next map[string]bool
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -27,6 +28,81 @@ func NewCostumePotentialService(design *gamedata.CostumePotentialDesign, collect
|
||||
return &CostumePotentialService{design: design, collection: collection, characters: characters, inventory: inventory, wallet: wallet}, nil
|
||||
}
|
||||
|
||||
// Contributions follows CharStatInfo.GetCostumeNodeBuffStat: type 2 nodes
|
||||
// contribute across every owned costume of the same UniqueCharId, while type
|
||||
// 1 nodes contribute only from CharDBInfo.ConnectPotentialCostume. Values are
|
||||
// already fractions for the percent options 2, 4 and 6; they are not equipment
|
||||
// option percentages and must not be divided by 100 or rounded individually.
|
||||
func (s *CostumePotentialService) Contributions(character Character) ([]gamedata.StatContribution, error) {
|
||||
if s == nil || s.design == nil || s.collection == nil {
|
||||
return nil, errors.New("player: missing costume potential contribution source")
|
||||
}
|
||||
unique, ok := s.design.CharacterUnique[character.ID]
|
||||
if !ok || unique == 0 {
|
||||
return nil, fmt.Errorf("player: unknown costume potential character %d", character.ID)
|
||||
}
|
||||
var result []gamedata.StatContribution
|
||||
connectedFound := character.ConnectPotentialCostume == 0
|
||||
for _, costume := range s.collection.Costumes() {
|
||||
if s.design.CostumeUnique[costume.ID] != unique {
|
||||
continue
|
||||
}
|
||||
if costume.ID == character.ConnectPotentialCostume {
|
||||
connectedFound = true
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range costume.PotentialIDs {
|
||||
if seen[id] {
|
||||
return nil, errors.New("player: duplicate active costume potential stat node")
|
||||
}
|
||||
seen[id] = true
|
||||
node, found := s.design.Nodes[costume.ID][id]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("player: unknown active potential node %d/%d", costume.ID, id)
|
||||
}
|
||||
if node.NodeType != 2 && (node.NodeType != 1 || costume.ID != character.ConnectPotentialCostume) {
|
||||
continue
|
||||
}
|
||||
if math.IsNaN(node.StatValue) || math.IsInf(node.StatValue, 0) || node.StatValue < 0 {
|
||||
return nil, errors.New("player: invalid costume potential stat value")
|
||||
}
|
||||
contribution := gamedata.StatContribution{Option: node.StatType}
|
||||
switch node.StatType {
|
||||
case 1, 2:
|
||||
contribution.Stat = gamedata.StatHealth
|
||||
case 3, 4:
|
||||
contribution.Stat = gamedata.StatAttack
|
||||
case 5, 6:
|
||||
contribution.Stat = gamedata.StatMagic
|
||||
case 7:
|
||||
contribution.Stat = gamedata.StatDefencePercent
|
||||
case 8:
|
||||
contribution.Stat = gamedata.StatMagicResistancePercent
|
||||
case 9:
|
||||
contribution.Stat = gamedata.StatCriticalChance
|
||||
case 10:
|
||||
contribution.Stat = gamedata.StatCriticalDamage
|
||||
case 11, 12, 13, 14, 15, 19:
|
||||
contribution.Stat = gamedata.StatElementDamage
|
||||
case 16, 17, 18, 20:
|
||||
contribution.Stat = gamedata.StatElementResistance
|
||||
default:
|
||||
return nil, fmt.Errorf("player: unsupported costume potential stat option %d", node.StatType)
|
||||
}
|
||||
if node.StatType == 2 || node.StatType == 4 || node.StatType == 6 {
|
||||
contribution.Percent = node.StatValue
|
||||
} else {
|
||||
contribution.Flat = node.StatValue
|
||||
}
|
||||
result = append(result, contribution)
|
||||
}
|
||||
}
|
||||
if !connectedFound {
|
||||
return nil, errors.New("player: connected potential costume is not owned by this character")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *CostumePotentialService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/CostumeNodeActivation" {
|
||||
return 0, nil, false, nil
|
||||
|
||||
@@ -2,13 +2,82 @@ package player
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestCostumePotentialContributionsIncludePublicAndConnectedNodes(t *testing.T) {
|
||||
storage := stateio.NewMemory()
|
||||
collection, err := OpenCollectionStore(storage, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, index := range []uint64{1, 2, 3} {
|
||||
if err := collection.ActivateCostumePotential(index, []uint64{1, 2, 3}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
design := &gamedata.CostumePotentialDesign{CharacterUnique: map[uint64]uint64{91: 9, 101: 10}, CostumeUnique: map[uint64]uint64{100: 9, 200: 9, 300: 10}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{
|
||||
100: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 7}, 2: {ID: 2, NodeType: 1, StatType: 2, StatValue: .085}, 3: {ID: 3, NodeType: 4}},
|
||||
200: {1: {ID: 1, NodeType: 2, StatType: 2, StatValue: .015}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 20}, 3: {ID: 3, NodeType: 2, StatType: 3, StatValue: 3}},
|
||||
300: {1: {ID: 1, NodeType: 2, StatType: 1, StatValue: 1000}, 2: {ID: 2, NodeType: 1, StatType: 1, StatValue: 1000}, 3: {ID: 3, NodeType: 4}},
|
||||
}}
|
||||
service := &CostumePotentialService{design: design, collection: collection}
|
||||
character := Character{ID: 91, ConnectPotentialCostume: 100}
|
||||
contributions, err := service.Contributions(character)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats := gamedata.AggregateStats(gamedata.BaseStats{Health: 100, Attack: 10}, contributions)
|
||||
if stats.Health != 117 || stats.Attack != 13 {
|
||||
t.Fatalf("public plus connected stats=%+v contributions=%+v", stats, contributions)
|
||||
}
|
||||
character.ConnectPotentialCostume = 200
|
||||
contributions, err = service.Contributions(character)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats = gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions)
|
||||
if stats.Health != 128 {
|
||||
t.Fatalf("connection switch health=%f", stats.Health)
|
||||
}
|
||||
character.ConnectPotentialCostume = 0
|
||||
contributions, err = service.Contributions(character)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats = gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions)
|
||||
if stats.Health != 108 {
|
||||
t.Fatalf("unconnected public health=%f", stats.Health)
|
||||
}
|
||||
character.ConnectPotentialCostume = 300
|
||||
if _, err := service.Contributions(character); err == nil {
|
||||
t.Fatal("connected another character's costume")
|
||||
}
|
||||
character.ConnectPotentialCostume = 100
|
||||
reopened, err := OpenCollectionStore(storage, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.collection = reopened
|
||||
contributions, err = service.Contributions(character)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions); stats.Health != 117 {
|
||||
t.Fatalf("restart contribution health=%f", stats.Health)
|
||||
}
|
||||
design.Nodes[100][1] = gamedata.CostumePotentialNode{ID: 1, NodeType: 2, StatType: 1, StatValue: math.NaN()}
|
||||
if _, err := service.Contributions(character); err == nil {
|
||||
t.Fatal("nonfinite stat accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeNodeActivationSupportsSingleAndOneClickSets(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
inventory, err := OpenInventory(testStore(filepath.Join(dir, "items.json")), &Starter{Version: "2.35.10"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type EquipmentInventory struct {
|
||||
craft *gamedata.EquipmentCraftDesign
|
||||
smelting *gamedata.EquipmentSmeltingDesign
|
||||
optionReroll *gamedata.EquipmentOptionRerollDesign
|
||||
statDesign *gamedata.EquipmentStatDesign
|
||||
wallet *Wallet
|
||||
inventory *Inventory
|
||||
sessionID string
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func (s *EquipmentInventory) AttachStatDesign(design *gamedata.EquipmentStatDesign) error {
|
||||
if design == nil {
|
||||
return errors.New("player: nil equipment stat design")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.statDesign = design
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatContributions resolves only maximum-health equipment effects. Snapshot
|
||||
// options under the equipment lock and release it before calculating; callers
|
||||
// may themselves be the CharacterStore maximum-health callback.
|
||||
func (s *EquipmentInventory) StatContributions(character Character) ([]gamedata.StatContribution, error) {
|
||||
if character.InvenIndex == 0 {
|
||||
return nil, errors.New("player: invalid equipment stat character")
|
||||
}
|
||||
type query struct {
|
||||
option gamedata.EquipmentOption
|
||||
sub bool
|
||||
}
|
||||
var queries []query
|
||||
s.mu.Lock()
|
||||
design := s.statDesign
|
||||
for _, equipment := range s.owned.Equipment {
|
||||
if equipment.UseChar != character.InvenIndex {
|
||||
continue
|
||||
}
|
||||
if equipment.Level > math.MaxInt32 {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("player: invalid equipment %d level", equipment.InvenIndex)
|
||||
}
|
||||
var rank [3]int
|
||||
for i, value := range equipment.Rank {
|
||||
if i >= 3 || value > 4 {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("player: invalid equipment %d ranks", equipment.InvenIndex)
|
||||
}
|
||||
rank[i] = int(value)
|
||||
}
|
||||
add := func(option EquipmentOption, sub bool) {
|
||||
if option.ID == 1 || option.ID == 2 {
|
||||
queries = append(queries, query{gamedata.EquipmentOption{GroupID: option.GroupID, ID: option.ID, Level: int(equipment.Level), Rank: rank}, sub})
|
||||
}
|
||||
}
|
||||
for _, option := range equipment.MainOption {
|
||||
add(option, false)
|
||||
}
|
||||
for _, option := range equipment.SubOption {
|
||||
add(option, true)
|
||||
}
|
||||
if equipment.PrivateOption != nil {
|
||||
add(*equipment.PrivateOption, false)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
if len(queries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if design == nil {
|
||||
return nil, errors.New("player: equipment stat design unavailable")
|
||||
}
|
||||
result := make([]gamedata.StatContribution, 0, len(queries))
|
||||
for _, query := range queries {
|
||||
contribution, err := design.HealthContribution(query.option, query.sub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, contribution)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEquipmentStatContributionsUseOnlyEquippedOwnerAndAllHealthOptions(t *testing.T) {
|
||||
s := &EquipmentInventory{owned: equipmentSnapshot{Equipment: []Equipment{
|
||||
{InvenIndex: 1, UseChar: 77, Level: 1, Rank: []uint64{1, 0, 0}, MainOption: []EquipmentOption{{GroupID: 10, ID: 1}, {GroupID: 999, ID: 3}}, SubOption: []EquipmentOption{{GroupID: 20, ID: 2}}, PrivateOption: &EquipmentOption{GroupID: 10, ID: 1}},
|
||||
{InvenIndex: 2, UseChar: 78, MainOption: []EquipmentOption{{GroupID: 999, ID: 1}}},
|
||||
{InvenIndex: 3, MainOption: []EquipmentOption{{GroupID: 999, ID: 1}}},
|
||||
}}}
|
||||
d := &gamedata.EquipmentStatDesign{Options: map[[2]uint64]gamedata.EquipmentStatRule{
|
||||
{10, 1}: {Default: 7.9, Growth: 2, Levels: []float64{0, 1}, Ranks: [3][]float64{{0.5}, nil, nil}},
|
||||
{20, 2}: {Default: 0.01359, Growth: 100},
|
||||
}}
|
||||
if err := s.AttachStatDesign(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.StatContributions(Character{InvenIndex: 77})
|
||||
if err != nil || len(got) != 3 {
|
||||
t.Fatalf("contributions=%+v err=%v", got, err)
|
||||
}
|
||||
if hp := gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, got).Health; hp != 121 {
|
||||
t.Fatalf("equipped health=%v", hp)
|
||||
}
|
||||
// A normal Find callback must safely enter equipment snapshot resolution.
|
||||
_, _, characters := foodTestService(t, stateio.NewMemory())
|
||||
if err := characters.AttachMaxHealth(func(c Character) (uint64, error) {
|
||||
contributions, err := s.StatContributions(c)
|
||||
return uint64(gamedata.AggregateStats(gamedata.BaseStats{Health: 100}, contributions).Health), err
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan Character, 1)
|
||||
go func() { c, _ := characters.Find(77); done <- c }()
|
||||
select {
|
||||
case c := <-done:
|
||||
if c.HP != 121 {
|
||||
t.Fatalf("Find health=%d", c.HP)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("Find deadlocked calculating equipped health")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
// FriendshipService applies inventory gifts and counseling inside the account
|
||||
// request transaction. Static IDs, thresholds and rewards come from GameData.
|
||||
type FriendshipService struct {
|
||||
mu sync.Mutex
|
||||
design *gamedata.FriendshipDesign
|
||||
awake *gamedata.CharAwakeDesign
|
||||
potential *gamedata.CostumePotentialDesign
|
||||
collection *CollectionStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
sessionID string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewFriendshipService(design *gamedata.FriendshipDesign, awake *gamedata.CharAwakeDesign, potential *gamedata.CostumePotentialDesign, collection *CollectionStore, inventory *Inventory, wallet *Wallet) (*FriendshipService, error) {
|
||||
if design == nil || awake == nil || potential == nil || collection == nil || inventory == nil || wallet == nil {
|
||||
return nil, errors.New("player: incomplete friendship service")
|
||||
}
|
||||
s := &FriendshipService{design: design, awake: awake, potential: potential, collection: collection, inventory: inventory, wallet: wallet, now: time.Now}
|
||||
for _, entry := range collection.FriendshipEntries() {
|
||||
if entry.Daily != nil && entry.Daily.Used > design.Default.MaxCounselingAP {
|
||||
return nil, errors.New("player: saved daily friendship AP exceeds maximum")
|
||||
}
|
||||
if entry.State == nil {
|
||||
continue
|
||||
}
|
||||
state := entry.State
|
||||
if _, owned := s.owned(state.CostumeID); !owned {
|
||||
return nil, errors.New("player: saved friendship costume is not owned")
|
||||
}
|
||||
level, ok := design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}]
|
||||
if !ok || (level.NextEXP != 0 && state.EXP >= level.NextEXP) || (state.Level >= design.Default.MaxLevels[2] && state.EXP != 0) {
|
||||
return nil, errors.New("player: saved friendship level is not in GameData")
|
||||
}
|
||||
if state.CounselingCount > design.Default.MaxCounselingAPByCostume && state.Level < design.Default.MaxLevels[2] {
|
||||
return nil, errors.New("player: invalid saved costume counseling count")
|
||||
}
|
||||
for _, id := range state.Sessions {
|
||||
if _, ok := design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: id}]; !ok {
|
||||
return nil, errors.New("player: saved counseling session is not in GameData")
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *FriendshipService) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessionID = id
|
||||
}
|
||||
|
||||
func (s *FriendshipService) FriendshipAP() (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
daily := s.daily(s.collection.FriendshipEntries(), s.now().UTC().Format("2006-01-02"))
|
||||
if daily.Used >= s.design.Default.MaxCounselingAP {
|
||||
return 0, nil
|
||||
}
|
||||
return s.design.Default.MaxCounselingAP - daily.Used, nil
|
||||
}
|
||||
|
||||
func (s *FriendshipService) daily(entries map[string]FriendshipEntry, day string) FriendshipDaily {
|
||||
if entry := entries["daily"]; entry.Daily != nil && entry.Daily.Day == day {
|
||||
return *entry.Daily
|
||||
}
|
||||
return FriendshipDaily{Day: day}
|
||||
}
|
||||
|
||||
func (s *FriendshipService) owned(id uint64) (Costume, bool) {
|
||||
costumeID, ok := s.design.Costumes[id]
|
||||
if !ok {
|
||||
return Costume{}, false
|
||||
}
|
||||
return s.collection.CostumeByID(costumeID)
|
||||
}
|
||||
|
||||
func (s *FriendshipService) maximum(costume Costume) uint64 {
|
||||
max := s.design.Default.MaxLevels[0]
|
||||
unique := s.potential.CostumeUnique[costume.ID]
|
||||
awake, exists := s.collection.CharAwakeState(unique)
|
||||
rule, hasRule := s.awake.Characters[unique]
|
||||
if !exists || !hasRule || !awake.IsAwake {
|
||||
return max
|
||||
}
|
||||
for i, level := range awake.ImprintLevels {
|
||||
if len(rule.ImprintGrowth[i]) == 0 || level != uint64(len(rule.ImprintGrowth[i])) {
|
||||
return max
|
||||
}
|
||||
}
|
||||
max = s.design.Default.MaxLevels[1]
|
||||
nodes := s.potential.Nodes[costume.ID]
|
||||
if len(nodes) == 0 || len(costume.PotentialIDs) != len(nodes) {
|
||||
return max
|
||||
}
|
||||
for _, id := range costume.PotentialIDs {
|
||||
if _, ok := nodes[id]; !ok {
|
||||
return max
|
||||
}
|
||||
}
|
||||
return s.design.Default.MaxLevels[2]
|
||||
}
|
||||
|
||||
func friendshipWire(state FriendshipState) []byte {
|
||||
b := wire.AppendVarint(nil, 1, state.CostumeID)
|
||||
b = wire.AppendVarint(b, 2, state.Level)
|
||||
if state.EXP != 0 {
|
||||
b = wire.AppendVarint(b, 3, state.EXP)
|
||||
}
|
||||
if state.LastCounselingDate != 0 {
|
||||
b = wire.AppendVarint(b, 4, state.LastCounselingDate)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *FriendshipService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path != "/FriendshipInfo" && path != "/FriendshipGift" && path != "/FriendshipCounseling" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seq, found, err := wire.Varint(request, 1)
|
||||
if err != nil || !found || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: friendship invalid sequence")
|
||||
}
|
||||
entries := s.collection.FriendshipEntries()
|
||||
if path == "/FriendshipInfo" {
|
||||
return 612, s.info(entries), true, nil
|
||||
}
|
||||
session := s.sessionID
|
||||
if session == "" {
|
||||
return 0, nil, true, errors.New("player: friendship mutation requires an authenticated session")
|
||||
}
|
||||
keyHash := sha256.Sum256([]byte(session + ":" + strconv.FormatUint(seq, 10)))
|
||||
key := "reply:" + hex.EncodeToString(keyHash[:])
|
||||
digestHash := sha256.Sum256(request)
|
||||
digest := hex.EncodeToString(digestHash[:])
|
||||
code := 614
|
||||
if path == "/FriendshipCounseling" {
|
||||
code = 613
|
||||
}
|
||||
if entry := entries[key]; entry.Reply != nil {
|
||||
if entry.Reply.Digest != digest || entry.Reply.Code != code {
|
||||
return 0, nil, true, errors.New("player: friendship sequence reused with different request")
|
||||
}
|
||||
return code, append([]byte(nil), entry.Reply.Body...), true, nil
|
||||
}
|
||||
id, found, err := wire.Varint(request, 2)
|
||||
if err != nil || !found || id == 0 || id > math.MaxInt32 {
|
||||
return 0, nil, true, errors.New("player: friendship invalid costume")
|
||||
}
|
||||
costume, owned := s.owned(id)
|
||||
if !owned {
|
||||
return 0, nil, true, fmt.Errorf("player: friendship costume %d is not owned", id)
|
||||
}
|
||||
state := FriendshipState{CostumeID: id, Level: 1}
|
||||
if entry := entries[friendshipStateKey(id)]; entry.State != nil {
|
||||
state = *entry.State
|
||||
state.Sessions = append([]uint64(nil), state.Sessions...)
|
||||
}
|
||||
max := s.maximum(costume)
|
||||
var body []byte
|
||||
if code == 614 {
|
||||
body, err = s.gift(request, state, max, key, digest)
|
||||
} else {
|
||||
body, err = s.counsel(request, state, max, entries, key, digest)
|
||||
}
|
||||
return code, body, true, err
|
||||
}
|
||||
|
||||
func (s *FriendshipService) info(entries map[string]FriendshipEntry) []byte {
|
||||
ids := make([]uint64, 0, len(s.design.Costumes))
|
||||
for id := range s.design.Costumes {
|
||||
if _, ok := s.owned(id); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
var body []byte
|
||||
for _, id := range ids {
|
||||
state := FriendshipState{CostumeID: id, Level: 1}
|
||||
if entry := entries[friendshipStateKey(id)]; entry.State != nil {
|
||||
state = *entry.State
|
||||
}
|
||||
body = wire.AppendBytes(body, 1, friendshipWire(state))
|
||||
if len(state.Sessions) != 0 {
|
||||
counsel := wire.AppendVarint(nil, 1, id)
|
||||
for _, session := range state.Sessions {
|
||||
counsel = wire.AppendVarint(counsel, 2, session)
|
||||
}
|
||||
body = wire.AppendBytes(body, 2, counsel)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (s *FriendshipService) gift(request []byte, state FriendshipState, max uint64, key, digest string) ([]byte, error) {
|
||||
if state.Level >= max {
|
||||
return nil, errors.New("player: friendship level is at its unlocked maximum")
|
||||
}
|
||||
items, err := equipmentRequestItems(request, 3, "FriendshipGift")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var exp uint64
|
||||
seen := map[uint64]bool{}
|
||||
for _, item := range items {
|
||||
if seen[item.InvenIndex] || item.InvenIndex > math.MaxInt64 || item.ID > math.MaxInt32 || item.Type > math.MaxInt32 || item.Count > math.MaxInt32 {
|
||||
return nil, errors.New("player: invalid friendship gift item")
|
||||
}
|
||||
seen[item.InvenIndex] = true
|
||||
rule, ok := s.design.Gifts[[2]uint64{item.Type, item.ID}]
|
||||
if !ok {
|
||||
return nil, errors.New("player: item is not a friendship gift")
|
||||
}
|
||||
unit := rule.Experience(state.CostumeID)
|
||||
if unit == 0 || item.Count > math.MaxInt32/unit || exp > math.MaxInt32-unit*item.Count {
|
||||
return nil, errors.New("player: invalid friendship gift experience")
|
||||
}
|
||||
exp += unit * item.Count
|
||||
}
|
||||
if err := s.inventory.CanConsume(items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next, rewards, err := s.advance(state, max, exp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.inventory.Consume(items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle, err := s.grant(key, rewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := wire.AppendBytes(nil, 1, bundle)
|
||||
body = wire.AppendBytes(body, 2, friendshipWire(next))
|
||||
body = wire.AppendVarint(body, 3, exp)
|
||||
if err := s.collection.ApplyFriendship(next, nil, key, FriendshipReply{Digest: digest, Code: 614, Body: body}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (s *FriendshipService) counsel(request []byte, state FriendshipState, max uint64, entries map[string]FriendshipEntry, key, digest string) ([]byte, error) {
|
||||
session, found, err := wire.Varint(request, 3)
|
||||
if err != nil || !found || session == 0 || session > math.MaxInt32 {
|
||||
return nil, errors.New("player: invalid counseling session")
|
||||
}
|
||||
rule, exists := s.design.Sessions[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: session}]
|
||||
if !exists {
|
||||
return nil, errors.New("player: counseling session does not belong to costume")
|
||||
}
|
||||
choice, _, err := wire.Varint(request, 4)
|
||||
if err != nil || choice >= rule.ChoiceCount {
|
||||
return nil, errors.New("player: invalid counseling choice")
|
||||
}
|
||||
quick, _, err := wire.Varint(request, 5)
|
||||
if err != nil || quick > 1 {
|
||||
return nil, errors.New("player: invalid counseling quick flag")
|
||||
}
|
||||
if quick == 1 && (uint64(len(state.Sessions)) < s.design.Default.QuickCounselingUnlockCount || state.Level >= max || choice != 1) {
|
||||
return nil, errors.New("player: quick counseling is not available")
|
||||
}
|
||||
now := s.now()
|
||||
day := now.UTC().Format("2006-01-02")
|
||||
daily := s.daily(entries, day)
|
||||
free := state.Level >= s.design.Default.MaxLevels[2]
|
||||
if !free {
|
||||
if daily.Used >= s.design.Default.MaxCounselingAP {
|
||||
return nil, errors.New("player: no daily friendship AP remaining")
|
||||
}
|
||||
if state.CounselingDay == day && state.CounselingCount >= s.design.Default.MaxCounselingAPByCostume {
|
||||
return nil, errors.New("player: costume daily counseling limit reached")
|
||||
}
|
||||
}
|
||||
correct := quick == 1 || choice == s.design.Default.CorrectSelectDialogIndex
|
||||
exp := s.design.Default.IncorrectEXP
|
||||
if correct {
|
||||
exp = s.design.Default.CorrectEXP
|
||||
}
|
||||
if state.Level >= max {
|
||||
exp = 0
|
||||
}
|
||||
next, rewards, err := s.advance(state, max, exp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
completed := false
|
||||
for _, id := range state.Sessions {
|
||||
if id == session {
|
||||
completed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !free {
|
||||
rewards = append(rewards, s.design.Default.CounselingRewards...)
|
||||
daily.Used++
|
||||
} else if !completed && quick == 0 {
|
||||
// At the final cap the client allows unrestricted story playback. A
|
||||
// previously unseen story earns its default reward once; repeated
|
||||
// playback does not create a source of unlimited account currency.
|
||||
rewards = append(rewards, s.design.Default.CounselingRewards...)
|
||||
}
|
||||
if next.CounselingDay != day {
|
||||
next.CounselingDay = day
|
||||
next.CounselingCount = 0
|
||||
}
|
||||
next.CounselingCount++
|
||||
if now.UnixMilli() <= 0 {
|
||||
return nil, errors.New("player: invalid counseling time")
|
||||
}
|
||||
next.LastCounselingDate = uint64(now.UnixMilli())
|
||||
if quick == 0 {
|
||||
if !completed {
|
||||
next.Sessions = append(next.Sessions, session)
|
||||
sort.Slice(next.Sessions, func(i, j int) bool { return next.Sessions[i] < next.Sessions[j] })
|
||||
}
|
||||
}
|
||||
bundle, err := s.grant(key, rewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := wire.AppendBytes(nil, 1, bundle)
|
||||
body = wire.AppendBytes(body, 2, friendshipWire(next))
|
||||
if correct {
|
||||
body = wire.AppendVarint(body, 3, 1)
|
||||
}
|
||||
if exp != 0 {
|
||||
body = wire.AppendVarint(body, 4, exp)
|
||||
}
|
||||
if err := s.collection.ApplyFriendship(next, &daily, key, FriendshipReply{Digest: digest, Code: 613, Body: body}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (s *FriendshipService) advance(state FriendshipState, max, exp uint64) (FriendshipState, []gamedata.Reward, error) {
|
||||
if state.EXP > math.MaxInt32-exp {
|
||||
return state, nil, errors.New("player: friendship experience overflow")
|
||||
}
|
||||
state.EXP += exp
|
||||
var rewards []gamedata.Reward
|
||||
for state.Level < max {
|
||||
rule, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}]
|
||||
if !ok || rule.NextEXP == 0 {
|
||||
return state, nil, errors.New("player: missing friendship level threshold")
|
||||
}
|
||||
if state.EXP < rule.NextEXP {
|
||||
break
|
||||
}
|
||||
state.EXP -= rule.NextEXP
|
||||
state.Level++
|
||||
next, ok := s.design.Levels[gamedata.FriendshipKey{GroupID: state.CostumeID, ID: state.Level}]
|
||||
if !ok {
|
||||
return state, nil, errors.New("player: missing friendship level reward")
|
||||
}
|
||||
rewards = append(rewards, next.Rewards...)
|
||||
}
|
||||
if state.Level >= max {
|
||||
state.EXP = 0
|
||||
}
|
||||
return state, rewards, nil
|
||||
}
|
||||
|
||||
func (s *FriendshipService) grant(identity string, rewards []gamedata.Reward) ([]byte, error) {
|
||||
if len(rewards) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var items []gamedata.BattleReward
|
||||
for _, reward := range rewards {
|
||||
if reward.Type == 0 || reward.Count == 0 || reward.Count > math.MaxInt32 {
|
||||
return nil, errors.New("player: invalid friendship reward")
|
||||
}
|
||||
switch reward.Type {
|
||||
case 2, 3, 4, 12, 20:
|
||||
default:
|
||||
if reward.ID == 0 || reward.ID > math.MaxInt32 {
|
||||
return nil, errors.New("player: invalid friendship item reward")
|
||||
}
|
||||
items = append(items, gamedata.BattleReward{Type: reward.Type, ID: reward.ID, Count: reward.Count})
|
||||
}
|
||||
}
|
||||
if _, err := s.wallet.GrantQuestOnce(identity+":currency", rewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
granted, err := s.inventory.GrantOnce(identity+":items", items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bundle []byte
|
||||
for _, item := range granted {
|
||||
bundle = wire.AppendBytes(bundle, 1, ItemWire(item))
|
||||
}
|
||||
for _, reward := range rewards {
|
||||
view := Item{ID: reward.ID, Type: reward.Type, Count: reward.Count}
|
||||
bundle = wire.AppendBytes(bundle, 6, ItemWire(view))
|
||||
switch reward.Type {
|
||||
case 2, 3, 4, 12, 20:
|
||||
bundle = wire.AppendBytes(bundle, 1, ItemWire(view))
|
||||
}
|
||||
}
|
||||
return bundle, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
)
|
||||
|
||||
type friendshipFailStore struct {
|
||||
stateio.AtomicEntryStore
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (s *friendshipFailStore) SaveWithEntries(domain string, core []byte, changes []stateio.EntryMutation) error {
|
||||
if s.fail && domain == "collection" {
|
||||
return errors.New("injected collection write failure")
|
||||
}
|
||||
return s.AtomicEntryStore.SaveWithEntries(domain, core, changes)
|
||||
}
|
||||
|
||||
func sqliteFriendshipService(t *testing.T, store stateio.Store, template *FriendshipService) (*FriendshipService, *Inventory, *Wallet) {
|
||||
t.Helper()
|
||||
collection, err := OpenCollectionStore(store, []Costume{{InvenIndex: 1, ID: 100}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := NewFriendshipService(template.design, template.awake, template.potential, collection, inventory, wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("durable-session")
|
||||
return s, inventory, wallet
|
||||
}
|
||||
|
||||
func TestFriendshipSQLiteAtomicFailureRestartAndExactRetry(t *testing.T) {
|
||||
for _, rollback := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "commit-replay", true: "rollback-retry"}[rollback], func(t *testing.T) {
|
||||
template := newFriendshipHarness(t).service
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := &friendshipFailStore{AtomicEntryStore: repo}
|
||||
s, inventory, wallet := sqliteFriendshipService(t, store, template)
|
||||
items, err := inventory.GrantOnce("seed", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 3}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := items[0]
|
||||
item.Count = 1
|
||||
request := giftRequest(1, 10, item)
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.fail = rollback
|
||||
_, body, _, handleErr := s.Handle("/FriendshipGift", request)
|
||||
if rollback {
|
||||
if handleErr == nil {
|
||||
t.Fatal("injected failure was ignored")
|
||||
}
|
||||
if err := op.Rollback(); err == nil {
|
||||
t.Fatal("dirty rollback must fence writer until restart")
|
||||
}
|
||||
} else {
|
||||
if handleErr != nil {
|
||||
t.Fatal(handleErr)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := repo.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
s, inventory, wallet = sqliteFriendshipService(t, repo, template)
|
||||
if rollback {
|
||||
if wallet.Snapshot().FreeJewelry != 0 || len(s.collection.FriendshipEntries()) != 0 {
|
||||
t.Fatal("failed operation left committed rewards or progress")
|
||||
}
|
||||
if err := inventory.CanConsume(items); err != nil {
|
||||
t.Fatal("failed gift consumed committed inventory")
|
||||
}
|
||||
}
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, replay, _, err := s.Handle("/FriendshipGift", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rollback && !bytes.Equal(body, replay) {
|
||||
t.Fatal("restart response changed")
|
||||
}
|
||||
if wallet.Snapshot().FreeJewelry != 7 {
|
||||
t.Fatal("reward was not applied exactly once")
|
||||
}
|
||||
if err := inventory.CanConsume([]Item{{InvenIndex: item.InvenIndex, ID: 7, Type: 8, Count: 2}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inventory.CanConsume(items); err == nil {
|
||||
t.Fatal("request was not charged exactly once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type FriendshipState struct {
|
||||
CostumeID uint64 `json:"costume_id"`
|
||||
Level uint64 `json:"level"`
|
||||
EXP uint64 `json:"exp"`
|
||||
LastCounselingDate uint64 `json:"last_counseling_date"`
|
||||
CounselingDay string `json:"counseling_day,omitempty"`
|
||||
CounselingCount uint64 `json:"counseling_count,omitempty"`
|
||||
Sessions []uint64 `json:"sessions,omitempty"`
|
||||
}
|
||||
|
||||
type FriendshipDaily struct {
|
||||
Day string `json:"day"`
|
||||
Used uint64 `json:"used"`
|
||||
}
|
||||
|
||||
type FriendshipReply struct {
|
||||
Digest string `json:"digest"`
|
||||
Code int `json:"code"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
// FriendshipEntry keeps progress and successful request replies in the existing
|
||||
// collection domain. Each record has exactly one kind, so collection commits
|
||||
// can update one costume, the daily counter and a replay together.
|
||||
type FriendshipEntry struct {
|
||||
State *FriendshipState `json:"state,omitempty"`
|
||||
Daily *FriendshipDaily `json:"daily,omitempty"`
|
||||
Reply *FriendshipReply `json:"reply,omitempty"`
|
||||
}
|
||||
|
||||
func friendshipStateKey(id uint64) string { return "costume:" + strconv.FormatUint(id, 10) }
|
||||
|
||||
func cloneFriendshipEntries(in map[string]FriendshipEntry) map[string]FriendshipEntry {
|
||||
out := make(map[string]FriendshipEntry, len(in))
|
||||
for key, entry := range in {
|
||||
if entry.State != nil {
|
||||
state := *entry.State
|
||||
state.Sessions = append([]uint64(nil), state.Sessions...)
|
||||
entry.State = &state
|
||||
}
|
||||
if entry.Daily != nil {
|
||||
daily := *entry.Daily
|
||||
entry.Daily = &daily
|
||||
}
|
||||
if entry.Reply != nil {
|
||||
reply := *entry.Reply
|
||||
reply.Body = append([]byte(nil), reply.Body...)
|
||||
entry.Reply = &reply
|
||||
}
|
||||
out[key] = entry
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validateFriendshipEntries(entries map[string]FriendshipEntry) error {
|
||||
for key, entry := range entries {
|
||||
kinds := 0
|
||||
if entry.State != nil {
|
||||
kinds++
|
||||
}
|
||||
if entry.Daily != nil {
|
||||
kinds++
|
||||
}
|
||||
if entry.Reply != nil {
|
||||
kinds++
|
||||
}
|
||||
if kinds != 1 {
|
||||
return fmt.Errorf("player: invalid friendship entry %q", key)
|
||||
}
|
||||
if state := entry.State; state != nil {
|
||||
if key != friendshipStateKey(state.CostumeID) || state.CostumeID == 0 || state.CostumeID > math.MaxInt32 || state.Level == 0 || state.Level > math.MaxInt32 || state.EXP > math.MaxInt32 || state.LastCounselingDate > math.MaxInt64 {
|
||||
return fmt.Errorf("player: invalid friendship state %q", key)
|
||||
}
|
||||
if state.LastCounselingDate == 0 {
|
||||
if state.CounselingDay != "" || state.CounselingCount != 0 || len(state.Sessions) != 0 {
|
||||
return errors.New("player: friendship counseling state has no date")
|
||||
}
|
||||
} else if state.CounselingCount == 0 || state.CounselingDay != time.UnixMilli(int64(state.LastCounselingDate)).UTC().Format("2006-01-02") {
|
||||
return errors.New("player: inconsistent friendship counseling date")
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range state.Sessions {
|
||||
if id == 0 || id > math.MaxInt32 || seen[id] {
|
||||
return errors.New("player: invalid friendship counseling sessions")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
if entry.Daily != nil && (key != "daily" || entry.Daily.Day == "") {
|
||||
return errors.New("player: invalid friendship daily entry")
|
||||
}
|
||||
if entry.Daily != nil {
|
||||
if parsed, err := time.Parse("2006-01-02", entry.Daily.Day); err != nil || parsed.Format("2006-01-02") != entry.Daily.Day {
|
||||
return errors.New("player: invalid friendship daily date")
|
||||
}
|
||||
}
|
||||
if reply := entry.Reply; reply != nil {
|
||||
if !strings.HasPrefix(key, "reply:") || len(key) != len("reply:")+64 || (reply.Code != 613 && reply.Code != 614) || len(reply.Digest) != 64 || len(reply.Body) == 0 {
|
||||
return errors.New("player: invalid friendship replay")
|
||||
}
|
||||
if _, err := hex.DecodeString(reply.Digest); err != nil {
|
||||
return errors.New("player: invalid friendship request digest")
|
||||
}
|
||||
if _, err := hex.DecodeString(strings.TrimPrefix(key, "reply:")); err != nil {
|
||||
return errors.New("player: invalid friendship replay key")
|
||||
}
|
||||
if err := validateFriendshipReply(*reply); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFriendshipReply(reply FriendshipReply) error {
|
||||
infoCount := 0
|
||||
err := wire.Walk(reply.Body, func(field wire.Field) error {
|
||||
switch field.Number {
|
||||
case 1:
|
||||
if field.Type != 2 {
|
||||
return errors.New("invalid friendship reward bundle")
|
||||
}
|
||||
return wire.Walk(field.Value, func(wire.Field) error { return nil })
|
||||
case 2:
|
||||
if field.Type != 2 {
|
||||
return errors.New("invalid friendship response info")
|
||||
}
|
||||
infoCount++
|
||||
id, found, err := wire.Varint(field.Value, 1)
|
||||
if err != nil || !found || id == 0 || id > math.MaxInt32 {
|
||||
return errors.New("invalid friendship response costume")
|
||||
}
|
||||
level, found, err := wire.Varint(field.Value, 2)
|
||||
if err != nil || !found || level == 0 || level > math.MaxInt32 {
|
||||
return errors.New("invalid friendship response level")
|
||||
}
|
||||
case 3, 4:
|
||||
if field.Type != 0 || (reply.Code == 614 && field.Number == 4) {
|
||||
return errors.New("invalid friendship response scalar")
|
||||
}
|
||||
value, _, err := wire.Varint(reply.Body, field.Number)
|
||||
if err != nil || value > math.MaxInt32 || (reply.Code == 613 && field.Number == 3 && value > 1) {
|
||||
return errors.New("invalid friendship response value")
|
||||
}
|
||||
default:
|
||||
return errors.New("unexpected friendship response field")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || infoCount != 1 {
|
||||
return errors.New("player: invalid friendship replay response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CollectionStore) FriendshipEntries() map[string]FriendshipEntry {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return cloneFriendshipEntries(s.data.Friendships)
|
||||
}
|
||||
|
||||
func (s *CollectionStore) ApplyFriendship(state FriendshipState, daily *FriendshipDaily, replayKey string, reply FriendshipReply) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := cloneCollection(s.data)
|
||||
next.Friendships[friendshipStateKey(state.CostumeID)] = FriendshipEntry{State: &state}
|
||||
if daily != nil {
|
||||
next.Friendships["daily"] = FriendshipEntry{Daily: daily}
|
||||
}
|
||||
if _, exists := next.Friendships[replayKey]; exists {
|
||||
return errors.New("player: friendship request already applied")
|
||||
}
|
||||
next.Friendships[replayKey] = FriendshipEntry{Reply: &reply}
|
||||
if err := validateFriendshipEntries(next.Friendships); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.commit(next)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
func TestFriendshipInfoValidatesSequence(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
for _, request := range [][]byte{nil, wire.AppendVarint(nil, 1, 0), wire.AppendVarint(nil, 1, math.MaxInt32+1), {0x08, 0x80}} {
|
||||
if _, _, handled, err := h.service.Handle("/FriendshipInfo", request); !handled || err == nil {
|
||||
t.Fatal("invalid information request sequence was accepted")
|
||||
}
|
||||
}
|
||||
if code, body, handled, err := h.service.Handle("/FriendshipInfo", wire.AppendVarint(nil, 1, 1)); err != nil || !handled || code != 612 || len(body) == 0 {
|
||||
t.Fatalf("valid information request: code=%d handled=%v err=%v", code, handled, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipMutationsRequireSession(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
h.service.BeginSession("")
|
||||
item := h.item
|
||||
item.Count = 1
|
||||
for path, request := range map[string][]byte{"/FriendshipGift": giftRequest(1, 10, item), "/FriendshipCounseling": counselingRequest(1, 10, 1, 0, false)} {
|
||||
if _, _, handled, err := h.service.Handle(path, request); !handled || err == nil {
|
||||
t.Fatalf("%s accepted a mutation without a session", path)
|
||||
}
|
||||
}
|
||||
if len(h.collection.FriendshipEntries()) != 0 || h.wallet.Snapshot().FreeJewelry != 0 {
|
||||
t.Fatal("missing-session request mutated account state")
|
||||
}
|
||||
if err := h.inventory.CanConsume([]Item{h.item}); err != nil {
|
||||
t.Fatal("missing-session request consumed inventory")
|
||||
}
|
||||
h.service.BeginSession("authenticated-session")
|
||||
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type friendshipHarness struct {
|
||||
service *FriendshipService
|
||||
store *stateio.Memory
|
||||
collection *CollectionStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
item Item
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func newFriendshipHarness(t *testing.T) *friendshipHarness {
|
||||
t.Helper()
|
||||
h := &friendshipHarness{store: stateio.NewMemory(), clock: time.Date(2026, 10, 4, 23, 59, 0, 0, time.UTC)}
|
||||
var err error
|
||||
h.collection, err = OpenCollectionStore(h.store, []Costume{{InvenIndex: 1, ID: 100, Level: 5}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.inventory, err = OpenInventory(h.store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := h.inventory.GrantOnce("gift-test", []gamedata.BattleReward{{Type: 8, ID: 7, Count: 50}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.item = items[0]
|
||||
h.wallet, err = OpenWallet(h.store, Currency{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &gamedata.FriendshipDesign{Default: gamedata.FriendshipDefaultDesign{CorrectEXP: 100, IncorrectEXP: 80, MaxCounselingAP: 3, MaxCounselingAPByCostume: 1, QuickCounselingUnlockCount: 2, MaxLevels: [3]uint64{3, 4, 5}, CounselingRewards: []gamedata.Reward{{Type: 3, Count: 13}}}, Costumes: map[uint64]uint64{10: 100, 20: 200, 30: 300, 40: 400, 50: 500}, Gifts: map[[2]uint64]gamedata.FriendshipGiftDesign{{8, 7}: {Type: 8, ItemID: 7, EXP: 60, FavoriteEXP: 150, FavoriteCostumeIDs: []uint64{10}}}, Levels: map[gamedata.FriendshipKey]gamedata.FriendshipLevelDesign{}, Sessions: map[gamedata.FriendshipKey]gamedata.FriendshipSessionDesign{}}
|
||||
for id := range d.Costumes {
|
||||
for level := uint64(1); level <= 5; level++ {
|
||||
d.Levels[gamedata.FriendshipKey{GroupID: id, ID: level}] = gamedata.FriendshipLevelDesign{NextEXP: 100}
|
||||
}
|
||||
for session := uint64(1); session <= 3; session++ {
|
||||
d.Sessions[gamedata.FriendshipKey{GroupID: id, ID: session}] = gamedata.FriendshipSessionDesign{ChoiceCount: 2}
|
||||
}
|
||||
}
|
||||
d.Levels[gamedata.FriendshipKey{GroupID: 10, ID: 2}] = gamedata.FriendshipLevelDesign{NextEXP: 100, Rewards: []gamedata.Reward{{Type: 8, ID: 88, Count: 2}, {Type: 3, Count: 7}}}
|
||||
awake := &gamedata.CharAwakeDesign{Characters: map[uint64]gamedata.CharAwakeCharacter{1: {ImprintGrowth: [3][]gamedata.CharAwakeGrowth{{{}}, {{}}, {{}}}}}}
|
||||
potential := &gamedata.CostumePotentialDesign{CostumeUnique: map[uint64]uint64{100: 1}, Nodes: map[uint64]map[uint64]gamedata.CostumePotentialNode{100: {71: {ID: 71}, 72: {ID: 72}}}}
|
||||
h.service, err = NewFriendshipService(d, awake, potential, h.collection, h.inventory, h.wallet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.service.now = func() time.Time { return h.clock }
|
||||
h.service.BeginSession("test-session")
|
||||
return h
|
||||
}
|
||||
|
||||
func giftRequest(seq, id uint64, item Item) []byte {
|
||||
b := wire.AppendVarint(nil, 1, seq)
|
||||
b = wire.AppendVarint(b, 2, id)
|
||||
return wire.AppendBytes(b, 3, ItemWire(item))
|
||||
}
|
||||
func counselingRequest(seq, id, session, choice uint64, quick bool) []byte {
|
||||
b := wire.AppendVarint(nil, 1, seq)
|
||||
b = wire.AppendVarint(b, 2, id)
|
||||
b = wire.AppendVarint(b, 3, session)
|
||||
if choice != 0 {
|
||||
b = wire.AppendVarint(b, 4, choice)
|
||||
}
|
||||
if quick {
|
||||
b = wire.AppendVarint(b, 5, 1)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestFriendshipGiftConsumesFavoriteAndRestoresExactReplay(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
item := h.item
|
||||
item.Count = 1
|
||||
request := giftRequest(1, 10, item)
|
||||
code, body, handled, err := h.service.Handle("/FriendshipGift", request)
|
||||
if err != nil || !handled || code != 614 {
|
||||
t.Fatalf("gift %d %v %v", code, handled, err)
|
||||
}
|
||||
exp, _, _ := wire.Varint(body, 3)
|
||||
if exp != 150 {
|
||||
t.Fatalf("favorite exp=%d", exp)
|
||||
}
|
||||
state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State
|
||||
if state.Level != 2 || state.EXP != 50 {
|
||||
t.Fatalf("state=%+v", state)
|
||||
}
|
||||
if h.wallet.Snapshot().FreeJewelry != 7 {
|
||||
t.Fatal("level currency was not granted")
|
||||
}
|
||||
if err := h.inventory.CanConsume([]Item{{InvenIndex: item.InvenIndex, ID: 7, Type: 8, Count: 50}}); err == nil {
|
||||
t.Fatal("gift stack was not consumed")
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 20, item)); err == nil {
|
||||
t.Fatal("changed request reused sequence")
|
||||
}
|
||||
var openErr error
|
||||
h.collection, openErr = OpenCollectionStore(h.store, []Costume{{InvenIndex: 1, ID: 100, Level: 5}, {InvenIndex: 2, ID: 200}, {InvenIndex: 3, ID: 300}, {InvenIndex: 4, ID: 400}})
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
h.inventory, openErr = OpenInventory(h.store, &Starter{Version: "2.35.10"})
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
h.wallet, openErr = OpenWallet(h.store, Currency{})
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
reopened, openErr := NewFriendshipService(h.service.design, h.service.awake, h.service.potential, h.collection, h.inventory, h.wallet)
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
reopened.BeginSession("test-session")
|
||||
_, replay, _, err := reopened.Handle("/FriendshipGift", request)
|
||||
if err != nil || !bytes.Equal(body, replay) {
|
||||
t.Fatalf("durable replay=%v", err)
|
||||
}
|
||||
if h.wallet.Snapshot().FreeJewelry != 7 {
|
||||
t.Fatal("retry granted currency twice")
|
||||
}
|
||||
_, info, _, err := reopened.Handle("/FriendshipInfo", wire.AppendVarint(nil, 1, 2))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var level uint64
|
||||
if err := wire.Walk(info, func(field wire.Field) error {
|
||||
if field.Number == 1 {
|
||||
id, _, _ := wire.Varint(field.Value, 1)
|
||||
if id == 10 {
|
||||
level, _, _ = wire.Varint(field.Value, 2)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if level != 2 {
|
||||
t.Fatalf("restored level=%d", level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipRejectsCounterfeitGiftsAndUnownedCostumes(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
id uint64
|
||||
change func(*Item)
|
||||
}{
|
||||
{"unowned", 50, func(*Item) {}}, {"fake-index", 10, func(i *Item) { i.InvenIndex++ }}, {"fake-type", 10, func(i *Item) { i.Type = 7 }}, {"fake-id", 10, func(i *Item) { i.ID = 8 }}, {"too-many", 10, func(i *Item) { i.Count = 51 }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
item := h.item
|
||||
item.Count = 1
|
||||
test.change(&item)
|
||||
_, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, test.id, item))
|
||||
if err == nil {
|
||||
t.Fatal("accepted counterfeit")
|
||||
}
|
||||
if len(h.collection.FriendshipEntries()) != 0 || h.wallet.Snapshot().FreeJewelry != 0 {
|
||||
t.Fatal("rejected gift mutated state")
|
||||
}
|
||||
if err := h.inventory.CanConsume([]Item{h.item}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipCounselingDailyLimitsChoicesAndUTCRollover(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
_, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 1, 0, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
correct, _, _ := wire.Varint(body, 3)
|
||||
exp, _, _ := wire.Varint(body, 4)
|
||||
if correct != 1 || exp != 100 {
|
||||
t.Fatalf("correct=%d exp=%d", correct, exp)
|
||||
}
|
||||
if h.wallet.Snapshot().FreeJewelry != 20 {
|
||||
t.Fatal("level plus counseling currency incorrect")
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 10, 2, 0, false)); err == nil {
|
||||
t.Fatal("same costume counseled twice")
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 99, 0, false)); err == nil {
|
||||
t.Fatal("unknown session accepted")
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 1, 2, false)); err == nil {
|
||||
t.Fatal("unknown choice accepted")
|
||||
}
|
||||
_, body, _, err = h.service.Handle("/FriendshipCounseling", counselingRequest(2, 20, 1, 1, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _, _ = wire.Varint(body, 4)
|
||||
correct, _, _ = wire.Varint(body, 3)
|
||||
if exp != 80 || correct != 0 {
|
||||
t.Fatal("incorrect choice got correct exp")
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(3, 30, 1, 0, false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ap, _ := h.service.FriendshipAP()
|
||||
if ap != 0 {
|
||||
t.Fatalf("ap=%d", ap)
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(4, 40, 1, 0, false)); err == nil {
|
||||
t.Fatal("fourth account AP accepted")
|
||||
}
|
||||
h.clock = h.clock.Add(2 * time.Minute)
|
||||
ap, _ = h.service.FriendshipAP()
|
||||
if ap != 3 {
|
||||
t.Fatalf("rollover ap=%d", ap)
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(4, 10, 2, 1, false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State
|
||||
if len(state.Sessions) != 2 || state.LastCounselingDate != uint64(h.clock.UnixMilli()) {
|
||||
t.Fatalf("counsel state=%+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipQuickUnlockAndAwakeningPotentialGates(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 1, 1, true)); err == nil {
|
||||
t.Fatal("locked quick accepted")
|
||||
}
|
||||
state := FriendshipState{CostumeID: 10, Level: 1, Sessions: []uint64{1, 2}, LastCounselingDate: uint64(h.clock.Add(-24 * time.Hour).UnixMilli()), CounselingDay: h.clock.Add(-24 * time.Hour).Format("2006-01-02"), CounselingCount: 1}
|
||||
if err := h.collection.ApplyFriendship(state, nil, "reply:"+string(bytes.Repeat([]byte("a"), 64)), FriendshipReply{Digest: string(bytes.Repeat([]byte("b"), 64)), Code: 613, Body: wire.AppendBytes(nil, 2, friendshipWire(state))}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(1, 10, 3, 1, true))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
correct, _, _ := wire.Varint(body, 3)
|
||||
if correct != 1 {
|
||||
t.Fatal("quick compared fixed choice1 with normal correct choice0")
|
||||
}
|
||||
state = *h.collection.FriendshipEntries()[friendshipStateKey(10)].State
|
||||
if len(state.Sessions) != 2 {
|
||||
t.Fatal("quick added a new completed story")
|
||||
}
|
||||
costume, _ := h.collection.CostumeByID(100)
|
||||
if max := h.service.maximum(costume); max != 3 {
|
||||
t.Fatalf("+5 incorrectly unlocked max=%d", max)
|
||||
}
|
||||
if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if max := h.service.maximum(costume); max != 4 {
|
||||
t.Fatalf("awake max=%d", max)
|
||||
}
|
||||
if err := h.collection.ActivateCostumePotential(costume.InvenIndex, []uint64{71, 72}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
costume, _ = h.collection.CostumeByID(100)
|
||||
if max := h.service.maximum(costume); max != 5 {
|
||||
t.Fatalf("potential max=%d", max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipGiftCapDiscardsOverflowAndPaysEachReachedLevel(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
h.service.design.Levels[gamedata.FriendshipKey{GroupID: 10, ID: 3}] = gamedata.FriendshipLevelDesign{NextEXP: 100, Rewards: []gamedata.Reward{{Type: 47, ID: 900, Count: 1}}}
|
||||
item := h.item
|
||||
item.Count = 5
|
||||
_, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := h.collection.FriendshipEntries()[friendshipStateKey(10)].State
|
||||
if state.Level != 3 || state.EXP != 0 {
|
||||
t.Fatalf("gate did not discard overflow %+v", state)
|
||||
}
|
||||
var rewards []Item
|
||||
for _, entry := range h.inventory.owned.Items {
|
||||
if entry.Type == 47 {
|
||||
rewards = append(rewards, entry)
|
||||
}
|
||||
}
|
||||
if len(rewards) != 1 || rewards[0].ID != 900 || rewards[0].Count != 1 {
|
||||
t.Fatalf("cap level ID card reward %+v", rewards)
|
||||
}
|
||||
item.Count = 1
|
||||
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(2, 10, item)); err == nil {
|
||||
t.Fatal("gift accepted at locked gate")
|
||||
}
|
||||
if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(2, 10, item)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state = h.collection.FriendshipEntries()[friendshipStateKey(10)].State
|
||||
if state.Level != 4 || state.EXP != 0 {
|
||||
t.Fatalf("awake gate state %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipFinalCapStoryPlaybackDoesNotConsumeAPOrRepeatReward(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
if err := h.collection.UpdateCharAwake(1, CharAwakeProgress{}, CharAwakeProgress{IsAwake: true, ImprintLevels: [3]uint64{1, 1, 1}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.collection.ActivateCostumePotential(1, []uint64{71, 72}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := h.item
|
||||
item.Count = 5
|
||||
if _, _, _, err := h.service.Handle("/FriendshipGift", giftRequest(1, 10, item)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, body, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(2, 10, 1, 0, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exp, _, _ := wire.Varint(body, 4)
|
||||
if exp != 0 {
|
||||
t.Fatal("final cap counseling gained exp")
|
||||
}
|
||||
before := h.wallet.Snapshot().FreeJewelry
|
||||
if _, _, _, err := h.service.Handle("/FriendshipCounseling", counselingRequest(3, 10, 1, 0, false)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 20 || h.wallet.Snapshot().FreeJewelry != before {
|
||||
t.Fatal("final cap story reward repeated")
|
||||
}
|
||||
ap, _ := h.service.FriendshipAP()
|
||||
if ap != 3 {
|
||||
t.Fatalf("final cap playback consumed ap=%d", ap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFriendshipRejectsCorruptSavedProgress(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*FriendshipState)
|
||||
}{
|
||||
{"date", func(s *FriendshipState) {
|
||||
s.LastCounselingDate = 1
|
||||
s.CounselingDay = "2026-10-01"
|
||||
s.CounselingCount = 1
|
||||
}},
|
||||
{"experience", func(s *FriendshipState) { s.EXP = 100 }},
|
||||
{"session", func(s *FriendshipState) { s.Sessions = []uint64{999} }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
h := newFriendshipHarness(t)
|
||||
state := FriendshipState{CostumeID: 10, Level: 1}
|
||||
test.mutate(&state)
|
||||
h.collection.data.Friendships[friendshipStateKey(10)] = FriendshipEntry{State: &state}
|
||||
if err := validateFriendshipEntries(h.collection.data.Friendships); err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := NewFriendshipService(h.service.design, h.service.awake, h.service.potential, h.collection, h.inventory, h.wallet); err == nil {
|
||||
t.Fatal("accepted corrupt progress")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user