fix(server): reuse promoted characters when granting new costumes
This commit is contained in:
@@ -37,12 +37,13 @@ type InfiniteGachaDesign struct {
|
||||
}
|
||||
|
||||
type CharacterDesign struct {
|
||||
ID uint64
|
||||
HP uint64
|
||||
CostumeMaxLevel uint64
|
||||
OverflowItemType uint64
|
||||
OverflowItemID uint64
|
||||
OverflowItemCount uint64
|
||||
ID uint64
|
||||
GrowthCharacterIDs []uint64 // Permanent CharTable IDs sharing this costume's UniqueCharId.
|
||||
HP uint64
|
||||
CostumeMaxLevel uint64
|
||||
OverflowItemType uint64
|
||||
OverflowItemID uint64
|
||||
OverflowItemCount uint64
|
||||
}
|
||||
|
||||
type WeightedCostume struct {
|
||||
@@ -1483,7 +1484,7 @@ func collectCostumeIDs(pool []WeightedCostume, result *[]uint64) {
|
||||
}
|
||||
|
||||
func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, error) {
|
||||
characterID, err := loadCostumeBaseCharacterID(db, costumeID)
|
||||
characterID, family, err := loadCostumeCharacterFamily(db, costumeID)
|
||||
if err != nil {
|
||||
return CharacterDesign{}, err
|
||||
}
|
||||
@@ -1521,6 +1522,7 @@ func loadGachaCharacterDesign(db *sql.DB, costumeID uint64) (CharacterDesign, er
|
||||
return CharacterDesign{}, err
|
||||
}
|
||||
costume.ID = characterID
|
||||
costume.GrowthCharacterIDs = family
|
||||
costume.HP = uint64(hp)
|
||||
return costume, nil
|
||||
}
|
||||
|
||||
@@ -9,47 +9,63 @@ import (
|
||||
// prefix. The initial growth grade is the base character, excluding pack-only
|
||||
// temporary copies; ambiguous rows are rejected instead of selecting an ID.
|
||||
func loadCostumeBaseCharacterID(db *sql.DB, costumeID uint64) (uint64, error) {
|
||||
base, _, err := loadCostumeCharacterFamily(db, costumeID)
|
||||
return base, err
|
||||
}
|
||||
|
||||
// The family includes every permanent promotion stage. A costume reward must
|
||||
// reuse its owned character even when promotion has changed CharTable.Id.
|
||||
func loadCostumeCharacterFamily(db *sql.DB, costumeID uint64) (uint64, []uint64, error) {
|
||||
var raw []byte
|
||||
if err := db.QueryRow("SELECT ProtoBuf FROM CostumeTable WHERE id=?", costumeID).Scan(&raw); err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
unique, err := optionalScalar(raw, 27)
|
||||
if err != nil || unique == 0 {
|
||||
return 0, fmt.Errorf("gamedata: costume %d unique character unavailable", costumeID)
|
||||
return 0, nil, fmt.Errorf("gamedata: costume %d unique character unavailable", costumeID)
|
||||
}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM CharTable WHERE uniqueCharId=?", unique)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result uint64
|
||||
var family []uint64
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var row []byte
|
||||
if err := rows.Scan(&id, &row); err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
growth, err := optionalScalar(row, 10)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
temporary, err := optionalScalar(row, 21)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
if growth != 1 || temporary != 0 {
|
||||
kind, err := optionalScalar(row, 19)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if temporary != 0 || kind != 0 {
|
||||
continue
|
||||
}
|
||||
family = append(family, id)
|
||||
if growth != 1 {
|
||||
continue
|
||||
}
|
||||
if result != 0 {
|
||||
return 0, fmt.Errorf("gamedata: costume %d has ambiguous base character", costumeID)
|
||||
return 0, nil, fmt.Errorf("gamedata: costume %d has ambiguous base character", costumeID)
|
||||
}
|
||||
result = id
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
return 0, nil, err
|
||||
}
|
||||
if result == 0 {
|
||||
return 0, fmt.Errorf("gamedata: costume %d has no base character", costumeID)
|
||||
return 0, nil, fmt.Errorf("gamedata: costume %d has no base character", costumeID)
|
||||
}
|
||||
return result, nil
|
||||
return result, family, nil
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ func TestCostumeCharacterRelationshipDoesNotUseNumericPrefix(t *testing.T) {
|
||||
if err != nil || got != 999 {
|
||||
t.Fatalf("character=%d err=%v", got, err)
|
||||
}
|
||||
base, family, err := loadCostumeCharacterFamily(db, 76543)
|
||||
if err != nil || base != 999 || len(family) != 2 {
|
||||
t.Fatalf("base=%d family=%v err=%v", base, family, err)
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range family {
|
||||
seen[id] = true
|
||||
}
|
||||
if !seen[999] || !seen[1000] || seen[222] {
|
||||
t.Fatalf("promotion family=%v", family)
|
||||
}
|
||||
db.Exec("INSERT INTO CharTable VALUES (?,?,?)", 777, 88, wire.AppendVarint(nil, 10, 1))
|
||||
if _, err := loadCostumeBaseCharacterID(db, 76543); err == nil {
|
||||
t.Fatal("ambiguous base guessed")
|
||||
|
||||
@@ -728,7 +728,10 @@ func (s *CollectionStore) grantCostumesSorted(identity string, costumeIDs, sortI
|
||||
}
|
||||
continue
|
||||
}
|
||||
characterIndex, characterExists := findCharacterByID(s.baseCharacters, next.Characters, characterDesign.ID)
|
||||
characterIndex, characterExists, err := findCharacterByDesign(s.baseCharacters, next.Characters, characterDesign)
|
||||
if err != nil {
|
||||
return CollectionGrant{}, err
|
||||
}
|
||||
costumeIndex := next.NextCostumeIndex
|
||||
next.NextCostumeIndex++
|
||||
if !characterExists {
|
||||
@@ -1160,18 +1163,23 @@ func findCostume(costumes []Costume, id uint64) (int, bool) {
|
||||
return -1, false
|
||||
}
|
||||
|
||||
func findCharacterByID(base, collection []Character, id uint64) (uint64, bool) {
|
||||
for _, character := range base {
|
||||
if character.ID == id {
|
||||
return character.InvenIndex, true
|
||||
func findCharacterByDesign(base, collection []Character, design gamedata.CharacterDesign) (uint64, bool, error) {
|
||||
var result uint64
|
||||
for _, characters := range [][]Character{base, collection} {
|
||||
for _, character := range characters {
|
||||
matches := character.ID == design.ID
|
||||
for _, id := range design.GrowthCharacterIDs {
|
||||
matches = matches || character.ID == id
|
||||
}
|
||||
if matches {
|
||||
if result != 0 && result != character.InvenIndex {
|
||||
return 0, false, fmt.Errorf("player: duplicate owned character family for design %d; repair the save", design.ID)
|
||||
}
|
||||
result = character.InvenIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, character := range collection {
|
||||
if character.ID == id {
|
||||
return character.InvenIndex, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
return result, result != 0, nil
|
||||
}
|
||||
|
||||
func cloneGrant(grant CollectionGrant) CollectionGrant {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
)
|
||||
|
||||
func TestNewCostumeReusesPromotedCharacter(t *testing.T) {
|
||||
for _, inBase := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "collection", true: "base"}[inBase], func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "collection.json")
|
||||
store, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned := Character{InvenIndex: 77, ID: 888, HP: 4000, Level: 100, CostumeID: 76541, UseCostume: 123, ConnectPotentialCostume: 76541, TalentLevel: 4}
|
||||
if inBase {
|
||||
if err := store.BindBaseCharacters([]Character{owned}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
next := cloneCollection(store.data)
|
||||
next.Characters = []Character{owned}
|
||||
if err := store.commit(next); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
catalog, err := gamedata.NewRegularGachaCatalog(
|
||||
map[uint64]gamedata.RegularGacha{1: {ID: 1, Count: 1, PriceType: 3, Price: 1, Pool: []gamedata.WeightedCostume{{ID: 76543, Weight: 1}}}},
|
||||
map[uint64]gamedata.CharacterDesign{76543: {ID: 999, GrowthCharacterIDs: []uint64{999, 888}, HP: 100, CostumeMaxLevel: 5, OverflowItemType: 20, OverflowItemCount: 2}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := store.data.NextCharacterIndex
|
||||
grant, err := store.GrantCostumes("new-costume", []uint64{76543}, catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grant.CharacterIndices) != 0 || len(grant.CostumeIndices) != 1 || store.data.NextCharacterIndex != before {
|
||||
t.Fatalf("new character created after promotion: %+v", grant)
|
||||
}
|
||||
costume, ok := store.CostumeByIndex(grant.CostumeIndices[0])
|
||||
if !ok || costume.UseChar != owned.InvenIndex {
|
||||
t.Fatalf("costume=%+v", costume)
|
||||
}
|
||||
loaded, err := OpenCollectionStore(testStore(path), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inBase {
|
||||
if err := loaded.BindBaseCharacters([]Character{owned}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if got, ok := loaded.FindCharacter(owned.InvenIndex); !ok || !reflect.DeepEqual(got, owned) {
|
||||
t.Fatalf("original character changed: %+v", got)
|
||||
}
|
||||
retry, err := loaded.GrantCostumes("new-costume", []uint64{76543}, catalog)
|
||||
if err != nil || !reflect.DeepEqual(retry, grant) || len(loaded.Costumes()) != 1 {
|
||||
t.Fatalf("retry=%+v err=%v", retry, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCostumeRejectsAmbiguousPromotedOwnership(t *testing.T) {
|
||||
_, _, err := findCharacterByDesign(nil, []Character{{InvenIndex: 77, ID: 999}, {InvenIndex: 88, ID: 888}}, gamedata.CharacterDesign{ID: 999, GrowthCharacterIDs: []uint64{999, 888}})
|
||||
if err == nil {
|
||||
t.Fatal("duplicate family silently selected")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user