fix(server): reuse promoted characters when granting new costumes

This commit is contained in:
2026-10-06 12:53:01 +08:00
parent 20be684204
commit 6aef56271f
5 changed files with 140 additions and 29 deletions
+9 -7
View File
@@ -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
}
+27 -11
View File
@@ -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")
+19 -11
View File
@@ -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")
}
}