Files
bd2/go/internal/server/world/world_test.go
T
Flechazo 5b5a219dc2 feat(server): add special loot and data-driven gameplay rules
Generate equipment loot atomically, grant random boxes and prevent repeat claims.
Read gacha pools, pity, products, tickets, missions, presets, chapter rewards
and inventory costs from GameData; extend both collaboration UR ticket pools.
Sync owned recipes, configure startup state and calendars in versioned seeds,
and validate unlock tickets and revival talents.
2026-10-04 19:15:59 +08:00

466 lines
15 KiB
Go

package world
import (
"bytes"
"encoding/binary"
"sort"
"testing"
"bd2server/internal/server/deck"
"bd2server/internal/server/gamedata"
"bd2server/internal/server/player"
"bd2server/internal/server/progress"
"bd2server/internal/server/stateio"
"bd2server/internal/server/wire"
)
// Legacy regression fixtures define their own linear IDs. Production uses
// GameData links; this helper gives those small test fixtures explicit links.
func attachTestStoryCatalog(s *Service) {
packs := s.packs
if packs == nil {
packs = map[int]map[int]gamedata.QuestDesign{s.seed.PackID: s.quests}
}
s.storyCatalog = &gamedata.StoryCatalog{Packs: map[int]gamedata.StoryPack{}}
for id, quests := range packs {
var ids []int
for qid := range quests {
ids = append(ids, qid)
}
sort.Ints(ids)
for i, qid := range ids {
q := quests[qid]
q.Type = 0
if i > 0 {
q.PriorQuestID = ids[i-1]
}
if i+1 < len(ids) {
q.NextQuestID = ids[i+1]
}
quests[qid] = q
}
s.storyCatalog.Packs[id] = gamedata.StoryPack{ID: id, Quests: quests, MainQuestIDs: ids}
}
if s.transition.NextPackID != 0 {
if _, exists := s.storyCatalog.Packs[s.transition.NextPackID]; !exists {
s.storyCatalog.Packs[s.transition.NextPackID] = gamedata.StoryPack{ID: s.transition.NextPackID}
}
}
}
func testService() *Service {
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: progress.NewStore(), starter: &player.Starter{Version: "2.35.10"}, quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}, 3: {ID: 3}}}
s.seed.SquareSceneID = 3
s.seed.InitialReputations = []InitialReputation{{GroupID: 1, State: 1}}
s.seed.InitialRankStatues = []InitialRankStatue{{ID: 3, Season: 77, Error: true}}
s.packJamDesign = &gamedata.PackJamDesign{Reward: gamedata.Reward{Type: 3, Count: 150}}
attachTestStoryCatalog(s)
return s
}
func TestQuest29EchoesCurrentStoryDeck(t *testing.T) {
state := progress.NewStore()
quests := make(map[int]gamedata.QuestDesign)
for id := 1; id <= 30; id++ {
quests[id] = gamedata.QuestDesign{ID: id}
if id < 29 {
if err := state.ClearQuest(id, 21); err != nil {
t.Fatal(err)
}
}
}
decks, err := deck.NewStore(deck.Seed{Version: "2.35.10", FieldDeck: []deck.FieldEntry{
{Slot: 1, CharacterInvenIndex: 535607162},
}})
if err != nil {
t.Fatal(err)
}
save := wire.AppendVarint(nil, 1, 1)
want := []uint64{535607162, 535607159, 535607160, 535607161, 535604120}
for i, index := range want {
entry := wire.AppendVarint(nil, 1, index)
entry = wire.AppendVarint(entry, 2, uint64(i+1))
entry = wire.AppendVarint(entry, 3, uint64(i+1))
save = wire.AppendBytes(save, 2, entry)
}
if _, _, ok, err := decks.Handle("/DeckSave", save); err != nil || !ok {
t.Fatalf("deck save ok=%v err=%v", ok, err)
}
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state,
starter: &player.Starter{Version: "2.35.10"}, quests: quests, decks: decks}
attachTestStoryCatalog(s)
request := wire.AppendVarint(nil, 1, 2)
request = wire.AppendVarint(request, 2, 29)
request = wire.AppendVarint(request, 3, 21)
_, response, ok, err := s.Handle("/QuestClear", request)
if err != nil || !ok {
t.Fatalf("quest clear ok=%v err=%v", ok, err)
}
var got []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 4 {
index, _, _ := wire.Varint(field.Value, 1)
got = append(got, index)
}
return nil
}); err != nil {
t.Fatal(err)
}
if len(got) != len(want) {
t.Fatalf("deck rows=%v want=%v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("deck=%v want=%v", got, want)
}
}
}
func TestInitialPackInfoMatchesVersionedProtocol(t *testing.T) {
s := testService()
request := wire.AppendVarint(nil, 1, 89)
request = wire.AppendVarint(request, 2, 21)
code, got, ok, err := s.Handle("/PackInGameInfo", request)
if err != nil || !ok || code != 5 {
t.Fatalf("Handle: code=%d ok=%v err=%v", code, ok, err)
}
want := []byte{0x12, 4, 8, 1, 0x30, 0x15, 0x22, 2, '{', '}', 0x4a, 4, 8, 1, 0x10, 1, 0x62, 2, 0x28, 0x15, 0x72, 6, 8, 3, 0x10, 0x4d, 0x18, 1, 0x82, 1, 0x0e, 0x0a, 5, 0x18, 3, 0x20, 0x96, 1, 0x32, 5, 0x18, 3, 0x20, 0x96, 1}
if !bytes.Equal(got, want) {
t.Fatalf("pack proto mismatch\ngot %x\nwant %x", got, want)
}
}
func TestQuestClearAdvancesAndPersists(t *testing.T) {
s := testService()
request := wire.AppendVarint(nil, 1, 107)
request = wire.AppendVarint(request, 2, 1)
request = wire.AppendVarint(request, 3, 21)
code, response, ok, err := s.Handle("/QuestClear", request)
if err != nil || !ok || code != 18 {
t.Fatalf("first clear: code=%d ok=%v err=%v", code, ok, err)
}
cleared, found, err := wire.Varint(response, 3)
if err != nil || !found || cleared != 1 {
t.Fatalf("clear echo: %d %v %v", cleared, found, err)
}
next, found, err := wire.Bytes(response, 2)
if err != nil || !found {
t.Fatalf("next: %v", err)
}
id, found, err := wire.Varint(next, 1)
if err != nil || !found || id != 2 {
t.Fatalf("next id=%d found=%v err=%v", id, found, err)
}
if !s.state.QuestCleared(1, 21) {
t.Fatal("clear was not stored")
}
var deckRows int
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 4 {
deckRows++
}
return nil
}); err != nil {
t.Fatal(err)
}
if deckRows != 0 {
t.Fatalf("ordinary quest clear unexpectedly replaced deck with %d rows", deckRows)
}
packRequest := wire.AppendVarint(nil, 1, 200)
packRequest = wire.AppendVarint(packRequest, 2, 21)
_, restored, _, err := s.Handle("/PackInGameInfo", packRequest)
if err != nil {
t.Fatal(err)
}
active, found, err := wire.Bytes(restored, 2)
if err != nil || !found {
t.Fatalf("restored active quest missing: %v", err)
}
activeID, found, err := wire.Varint(active, 1)
if err != nil || !found || activeID != 2 {
t.Fatalf("restored active quest=%d found=%v err=%v", activeID, found, err)
}
clearedIDs, found, err := wire.Bytes(restored, 3)
clearedID, count := binary.Uvarint(clearedIDs)
if err != nil || !found || count <= 0 || clearedID != 1 {
t.Fatalf("restored cleared quests=%x found=%v err=%v", clearedIDs, found, err)
}
request = wire.AppendVarint(nil, 1, 108)
request = wire.AppendVarint(request, 2, 3)
request = wire.AppendVarint(request, 3, 21)
if _, _, _, err := s.Handle("/QuestClear", request); err == nil {
t.Fatal("out-of-order clear succeeded")
}
}
func TestFinalQuestClearIncludesEmptyNextQuestInfo(t *testing.T) {
// The client completion coroutine unconditionally evaluates
// QuestClearResponse.QuestInfo.Id. An absent protobuf field becomes a
// null C# reference; an explicitly present zero-length message becomes a
// non-null QuestDBInfo with ID 0, satisfying the client's final-quest
// sentinel path. Exact official final-pack wire parity is still unverified.
s := &Service{
seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 38},
state: progress.NewStore(),
starter: &player.Starter{Version: "2.35.10"},
quests: map[int]gamedata.QuestDesign{38: {ID: 38}},
transition: gamedata.PackTransition{PackID: 21, NextPackID: 22},
}
attachTestStoryCatalog(s)
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 38)
request = wire.AppendVarint(request, 3, 21)
_, response, ok, err := s.Handle("/QuestClear", request)
if err != nil || !ok {
t.Fatalf("final clear ok=%v err=%v", ok, err)
}
var nextCount int
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 2 {
return nil
}
nextCount++
if len(field.Value) != 0 {
t.Fatalf("final next QuestInfo=%x, want explicitly empty message", field.Value)
}
return nil
}); err != nil {
t.Fatal(err)
}
if nextCount != 1 {
t.Fatalf("final clear has %d QuestInfo fields, want one empty message", nextCount)
}
var packs []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 11 {
id, _, _ := wire.Varint(field.Value, 1)
packs = append(packs, id)
}
return nil
}); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 {
t.Fatalf("final pack transition=%v err=%v", packs, err)
}
}
func TestPackInfoRestoresCompletedPackAndUnlockedNextPack(t *testing.T) {
state := progress.NewStore()
for _, id := range []int{1, 2} {
if err := state.ClearQuest(id, 21); err != nil {
t.Fatal(err)
}
}
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21}, state: state,
quests: map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}, transition: gamedata.PackTransition{PackID: 21, NextPackID: 22}}
attachTestStoryCatalog(s)
code, response, handled, err := s.Handle("/PackInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled || code != 4 {
t.Fatalf("PackInfo code=%d handled=%v err=%v", code, handled, err)
}
var packs []uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 {
id, _, _ := wire.Varint(field.Value, 1)
packs = append(packs, id)
if id == 22 {
bought, _, _ := wire.Varint(field.Value, 8)
if bought != 0 {
t.Fatalf("newly unlocked pack22 is already marked bought")
}
}
}
return nil
}); err != nil || len(packs) != 2 || packs[0] != 21 || packs[1] != 22 {
t.Fatalf("PackInfo packs=%v err=%v", packs, err)
}
}
func TestPack22InitializesWithIndependentQuestIdentity(t *testing.T) {
state := progress.NewStore()
if err := state.ClearQuest(1, 21); err != nil {
t.Fatal(err)
}
pack21 := map[int]gamedata.QuestDesign{1: {ID: 1}}
pack22 := map[int]gamedata.QuestDesign{1: {ID: 1}, 2: {ID: 2}}
s := &Service{
seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1},
state: state,
starter: &player.Starter{Version: "2.35.10"},
quests: pack21,
packs: map[int]map[int]gamedata.QuestDesign{21: pack21, 22: pack22},
transitions: map[int]gamedata.PackTransition{
21: {PackID: 21, NextPackID: 22},
22: {PackID: 22},
},
}
attachTestStoryCatalog(s)
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 22)
code, response, handled, err := s.Handle("/PackInGameInfo", request)
if err != nil || !handled || code != 5 {
t.Fatalf("pack22 init code=%d handled=%v err=%v", code, handled, err)
}
currentPack, err := s.CurrentPackID()
if err != nil || currentPack != 22 {
t.Fatalf("current pack after pack22 init=%d err=%v", currentPack, err)
}
active, found, err := wire.Bytes(response, 2)
if err != nil || !found {
t.Fatalf("pack22 active quest missing: %v", err)
}
id, _, _ := wire.Varint(active, 1)
packID, _, _ := wire.Varint(active, 6)
if id != 1 || packID != 22 {
t.Fatalf("pack22 active quest id=%d pack=%d", id, packID)
}
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number == 1 || field.Number == 9 || field.Number == 14 || field.Number == 16 {
t.Fatalf("pack22 inherited pack21-only field %d", field.Number)
}
return nil
}); err != nil {
t.Fatal(err)
}
clear := wire.AppendVarint(nil, 1, 2)
clear = wire.AppendVarint(clear, 2, 1)
clear = wire.AppendVarint(clear, 3, 22)
if _, _, _, err := s.Handle("/QuestClear", clear); err != nil {
t.Fatal(err)
}
if !state.QuestCleared(1, 21) || !state.QuestCleared(1, 22) {
t.Fatal("quest 1 did not remain independently cleared in pack21 and pack22")
}
}
func TestQuest28GrantsEquipmentInRewardBundle(t *testing.T) {
state := progress.NewStore()
quests := make(map[int]gamedata.QuestDesign)
for id := 1; id <= 29; id++ {
quests[id] = gamedata.QuestDesign{ID: id}
if id < 28 {
if err := state.ClearQuest(id, 21); err != nil {
t.Fatal(err)
}
}
}
storage := stateio.NewMemory()
equipment, err := player.OpenEquipmentInventory(storage)
if err != nil {
t.Fatal(err)
}
starter := &player.Starter{Version: "2.35.10"}
inventory, err := player.OpenInventory(storage, starter)
if err != nil {
t.Fatal(err)
}
entry := quests[28]
entry.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}, {Type: 10, ID: 10010}}
quests[28] = entry
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state,
starter: starter, equipment: equipment, inventory: inventory, quests: quests}
attachTestStoryCatalog(s)
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 28)
request = wire.AppendVarint(request, 3, 21)
code, response, ok, err := s.Handle("/QuestClear", request)
if err != nil || !ok || code != 18 {
t.Fatalf("clear: code=%d ok=%v err=%v", code, ok, err)
}
bundle, found, err := wire.Bytes(response, 1)
if err != nil || !found {
t.Fatalf("reward bundle: %v", err)
}
encoded, found, err := wire.Bytes(bundle, 4)
if err != nil || !found {
t.Fatalf("equipment reward: %v", err)
}
base, found, err := wire.Bytes(encoded, 5)
if err != nil || !found {
t.Fatalf("equipment base: %v", err)
}
id, found, err := wire.Varint(base, 1)
if err != nil || !found || id != 10010 {
t.Fatalf("equipment id=%d found=%v err=%v", id, found, err)
}
}
func TestQuest27UsesGameDataFreeJewelryReward(t *testing.T) {
storage := stateio.NewMemory()
state := progress.NewStore()
quests := make(map[int]gamedata.QuestDesign)
for id := 1; id <= 27; id++ {
quests[id] = gamedata.QuestDesign{ID: id}
if id < 27 {
if err := state.ClearQuest(id, 21); err != nil {
t.Fatal(err)
}
}
}
design := quests[27]
design.Rewards[0] = []gamedata.Reward{{Type: 3, Count: 70}}
quests[27] = design
wallet, err := player.OpenWallet(storage, player.Currency{})
if err != nil {
t.Fatal(err)
}
s := &Service{seed: Seed{Version: "2.35.10", PackID: 21, StartQuestID: 1, PlaceholderCostumeID: 996000}, state: state,
starter: &player.Starter{Version: "2.35.10"}, wallet: wallet, quests: quests}
attachTestStoryCatalog(s)
request := wire.AppendVarint(nil, 1, 1)
request = wire.AppendVarint(request, 2, 27)
request = wire.AppendVarint(request, 3, 21)
_, response, _, err := s.Handle("/QuestClear", request)
if err != nil {
t.Fatal(err)
}
if got := wallet.Snapshot().FreeJewelry; got != 70 {
t.Fatalf("free jewelry=%d", got)
}
bundle, _, _ := wire.Bytes(response, 1)
item, _, _ := wire.Bytes(bundle, 1)
typ, _, _ := wire.Varint(item, 3)
count, _, _ := wire.Varint(item, 4)
if typ != 3 || count != 70 {
t.Fatalf("currency type=%d count=%d", typ, count)
}
}
func TestPackInfoDoesNotEmitFixedRewardRosterWithoutActiveFormation(t *testing.T) {
storage := stateio.NewMemory()
starter := &player.Starter{Version: "2.35.10"}
inventory, err := player.OpenInventory(storage, starter)
if err != nil {
t.Fatal(err)
}
characters, err := player.OpenCharacterStore(storage,
[]player.Character{{InvenIndex: 77, ID: 350, Level: 20}}, inventory, "", "")
if err != nil {
t.Fatal(err)
}
state := progress.NewStore()
if err := state.ClearQuest(26, 21); err != nil {
t.Fatal(err)
}
s := &Service{seed: Seed{PackID: 21, BattleUnlockQuestID: 26, RewardCharacter: player.Character{InvenIndex: 77, ID: 350, Level: 1}}, state: state, starter: starter, characters: characters}
attachTestStoryCatalog(s)
response, err := s.packInfo()
if err != nil {
t.Fatal(err)
}
var level uint64
if err := wire.Walk(response, func(field wire.Field) error {
if field.Number != 1 {
return nil
}
index, _, _ := wire.Varint(field.Value, 1)
if index == 77 {
level, _, _ = wire.Varint(field.Value, 4)
}
return nil
}); err != nil {
t.Fatal(err)
}
if level != 0 {
t.Fatalf("fixed reward character leaked without an authored active formation: level=%d", level)
}
}