feat(server): complete field talents, crafting, party recovery and pack monster lifecycle
This commit is contained in:
@@ -569,7 +569,13 @@ func serve(args []string) (serveErr error) {
|
||||
gachaService.AttachDrawMission(func(count uint64) error {
|
||||
return missionService.RecordEvent(missions.ConditionGachaBuy, 0, count, missionUnlocked)
|
||||
})
|
||||
if err := collection.BindBaseCharacters(worldService.CharacterService().RawAll()); err != nil {
|
||||
var permanentBaseCharacters []player.Character
|
||||
for _, c := range worldService.CharacterService().RawAll() {
|
||||
if !player.IsCharmCharacter(c) {
|
||||
permanentBaseCharacters = append(permanentBaseCharacters, c)
|
||||
}
|
||||
}
|
||||
if err := collection.BindBaseCharacters(permanentBaseCharacters); err != nil {
|
||||
return fmt.Errorf("bind base collection characters: %w", err)
|
||||
}
|
||||
if err := mailService.AttachCostumeRewards(collection, limitedCostumes); err != nil {
|
||||
@@ -593,6 +599,19 @@ func serve(args []string) (serveErr error) {
|
||||
if err := deckStateStore.AttachPresetRuntime(wallet, worldService.CharacterService(), ownedEquipment, collection); err != nil {
|
||||
return fmt.Errorf("attach ordinary preset runtime: %w", err)
|
||||
}
|
||||
fieldSettingsDesign, err := gamedata.LoadFieldSettingsDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load field character settings: %w", err)
|
||||
}
|
||||
if err := deckStateStore.AttachFieldSettingsPack(worldService.CurrentPackID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deckStateStore.AttachFieldSettings(fieldSettingsDesign); err != nil {
|
||||
return fmt.Errorf("attach field character settings: %w", err)
|
||||
}
|
||||
if err := login.AttachAutoReviveSettings(deckStateStore); err != nil {
|
||||
return fmt.Errorf("attach automatic revival settings: %w", err)
|
||||
}
|
||||
pictorialDesign, err := gamedata.LoadPictorialDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load pictorial GameData: %w", err)
|
||||
@@ -676,6 +695,10 @@ func serve(args []string) (serveErr error) {
|
||||
if err := worldService.AttachFieldMonsterState(gameplayStore); err != nil {
|
||||
return fmt.Errorf("attach field monster state: %w", err)
|
||||
}
|
||||
battleService.AttachFieldMonsters(worldService)
|
||||
if err := worldService.AttachFieldBuffRuntime(gameData, *gameDataVersion); err != nil {
|
||||
return fmt.Errorf("attach field monster damage: %w", err)
|
||||
}
|
||||
contentOpeningDesign, err := gamedata.LoadContentOpeningDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load content opening GameData: %w", err)
|
||||
@@ -739,6 +762,49 @@ func serve(args []string) (serveErr error) {
|
||||
return fmt.Errorf("load event economy: %w", err)
|
||||
}
|
||||
eventEconomy.AttachHuntingAP(huntingService)
|
||||
talentUseDesign, err := gamedata.LoadTalentUseDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load field talent skills: %w", err)
|
||||
}
|
||||
talentUseService, err := player.NewTalentUseService(talentUseDesign, gameplayStore, worldService.CharacterService(), ownedItems, wallet, eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load field talent state: %w", err)
|
||||
}
|
||||
talentUseService.AttachContext(worldService.TalentFieldContext)
|
||||
if err := worldService.AttachAutoRecoveryPolicy(gameData, *gameDataVersion); err != nil {
|
||||
return fmt.Errorf("attach automatic recovery policy: %w", err)
|
||||
}
|
||||
deckStateStore.AttachAutoRecoveryAllowed(worldService.AutoRecoveryAllowed)
|
||||
deckStateStore.AttachAutoRecovery(talentUseService.AutoRecover)
|
||||
worldService.AttachTalentPackInfo(talentUseService.PackInfo)
|
||||
worldService.AttachOverwhelmAuthorization(talentUseService.ConsumeOverwhelm)
|
||||
worldService.AttachOverwhelmHunting(huntingService)
|
||||
if err := worldService.AttachOverwhelmDesign(gameData, *gameDataVersion); err != nil {
|
||||
return fmt.Errorf("attach overwhelm design: %w", err)
|
||||
}
|
||||
talentUseService.AttachEffect(4, worldService.ApplyTalentFieldAbsorb)
|
||||
talentUseService.AttachEffect(20, worldService.ApplyTalentMonsterSummon)
|
||||
dispatchDesign, err := gamedata.LoadTalentDispatchDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load talent dispatch design: %w", err)
|
||||
}
|
||||
dispatchService, err := player.OpenTalentDispatch(gameplayStore, dispatchDesign, eventEconomy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load talent dispatch state: %w", err)
|
||||
}
|
||||
talentUseService.AttachEffect(18, dispatchService.Start)
|
||||
itemCraftDesign, err := gamedata.LoadItemCraftDesign(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load item crafting design: %w", err)
|
||||
}
|
||||
itemCraftService, err := player.NewItemCraftService(itemCraftDesign, talentUseDesign, gameplayStore, ownedItems, worldService.CharacterService(), wallet, recipeService.Knows)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load item crafting state: %w", err)
|
||||
}
|
||||
itemCraftService.AttachContext(func() (int, bool, error) {
|
||||
pack, err := worldService.CurrentPackID()
|
||||
return pack, battleService.Active(), err
|
||||
})
|
||||
if err := worldService.ConfigureNPCRuntime(gameData, *gameDataVersion, gameplayStore); err != nil {
|
||||
return fmt.Errorf("configure NPC world runtime: %w", err)
|
||||
}
|
||||
@@ -762,6 +828,7 @@ func serve(args []string) (serveErr error) {
|
||||
return fmt.Errorf("load NPC shop state: %w", err)
|
||||
}
|
||||
npcShopService.SetReputationSource(worldService.NPCShopReputation)
|
||||
npcShopService.SetTalentDiscountSource(talentUseService.ShopDiscount)
|
||||
commissionDesign, err := gamedata.LoadTodayQuests(gameData, *gameDataVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load NPC commission design: %w", err)
|
||||
@@ -1193,6 +1260,9 @@ func serve(args []string) (serveErr error) {
|
||||
masterTitleService,
|
||||
recruitService,
|
||||
foodService,
|
||||
talentUseService,
|
||||
dispatchService,
|
||||
itemCraftService,
|
||||
recipeService,
|
||||
starter,
|
||||
mailService,
|
||||
|
||||
@@ -147,6 +147,30 @@ func preflight(args []string) error {
|
||||
if _, err := gamedata.LoadCostumeBurstDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := gamedata.LoadTalentUseDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight talent skill design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadFieldSettingsDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight field character settings: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadItemCraftDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight item craft design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadTalentDispatchDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight talent dispatch design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadFieldBuffDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight field damage design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadPackRecoveryPolicy(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight automatic recovery policy: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadSkyWayOverwhelm(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight overwhelm design: %w", err)
|
||||
}
|
||||
if _, err := gamedata.LoadNPCShopDesign(gameData, versions.GameDataVersion); err != nil {
|
||||
return fmt.Errorf("preflight NPC shop design: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type AutoReviveSettingsProvider interface{ AutoReviveSettings() (bool, uint64, error) }
|
||||
|
||||
func (s *LoginSeed) AttachAutoReviveSettings(p AutoReviveSettingsProvider) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("account: nil automatic recovery settings")
|
||||
}
|
||||
s.autoReviveSettings = p
|
||||
return nil
|
||||
}
|
||||
func (s *LoginSeed) projectAutoRevive(user []byte) ([]byte, error) {
|
||||
if s.autoReviveSettings == nil {
|
||||
return user, nil
|
||||
}
|
||||
on, index, e := s.autoReviveSettings.AutoReviveSettings()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if index > 9223372036854775807 {
|
||||
return nil, fmt.Errorf("account: automatic recovery caster overflow")
|
||||
}
|
||||
n := uint64(0)
|
||||
if on {
|
||||
n = 1
|
||||
}
|
||||
user, _, e = wire.ReplaceVarint(user, 49, n)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
user, _, e = wire.ReplaceVarint(user, 50, index)
|
||||
return user, e
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type autoSettingsFake struct {
|
||||
on bool
|
||||
index uint64
|
||||
}
|
||||
|
||||
func (p *autoSettingsFake) AutoReviveSettings() (bool, uint64, error) { return p.on, p.index, nil }
|
||||
func TestLoginProjectsCurrentRecoverySettingsEachTime(t *testing.T) {
|
||||
s := &LoginSeed{Version: StateVersion(), PacketCode: 3, UserInfo: wire.AppendVarint(nil, 1, 42)}
|
||||
p := &autoSettingsFake{true, 199}
|
||||
if e := s.AttachAutoReviveSettings(p); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, on := range []bool{true, false} {
|
||||
p.on = on
|
||||
if !on {
|
||||
p.index = 0
|
||||
}
|
||||
b, e := s.Login(wire.AppendVarint(nil, 1, 1), []byte("0123456789abcdef0123456789abcdef"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
user, _, _ := wire.Bytes(b, 1)
|
||||
v, _, _ := wire.Varint(user, 49)
|
||||
index, _, _ := wire.Varint(user, 50)
|
||||
if (v != 0) != on || index != p.index {
|
||||
t.Fatal("seed value leaked", v, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type LoginSeed struct {
|
||||
friendshipAP FriendshipAPProvider
|
||||
lastPlayedPack LastPlayedPackProvider
|
||||
achievementExp AchievementExperienceProvider
|
||||
autoReviveSettings interface{ AutoReviveSettings() (bool, uint64, error) }
|
||||
levelReward LevelRewardProvider
|
||||
huntingAP HuntingAPProvider
|
||||
monsterHuntSlots PresetSlotProvider
|
||||
@@ -606,7 +607,11 @@ func (s *LoginSeed) Login(request, sessionKey []byte) ([]byte, error) {
|
||||
}
|
||||
}
|
||||
user = wire.AppendBytes(user, 3, sessionKey)
|
||||
return append(wire.AppendBytes(nil, 1, user), s.ResponseFields...), nil
|
||||
projected, projectionErr := s.projectAutoRevive(user)
|
||||
if projectionErr != nil {
|
||||
return nil, projectionErr
|
||||
}
|
||||
return append(wire.AppendBytes(nil, 1, projected), s.ResponseFields...), nil
|
||||
}
|
||||
|
||||
func replaceRepeatedBytes(data []byte, number int, values [][]byte) ([]byte, error) {
|
||||
|
||||
@@ -22,23 +22,24 @@ type Problem struct {
|
||||
}
|
||||
|
||||
type validationSnapshot struct {
|
||||
formatVersion int
|
||||
clientVersion string
|
||||
gameDataVersion string
|
||||
itemIndices []uint64
|
||||
equipmentIndices []uint64
|
||||
characterIndices []uint64
|
||||
costumeIndices []uint64
|
||||
itemNextIndex uint64
|
||||
equipmentNextIndex uint64
|
||||
characterNextIndex uint64
|
||||
costumeNextIndex uint64
|
||||
equipmentGrants []namedIndex
|
||||
equipmentUsers []namedIndex
|
||||
quests []questKey
|
||||
cleared []questKey
|
||||
granted map[string]bool
|
||||
itemGrants []indexedGrant
|
||||
formatVersion int
|
||||
clientVersion string
|
||||
gameDataVersion string
|
||||
itemIndices []uint64
|
||||
equipmentIndices []uint64
|
||||
characterIndices []uint64
|
||||
collectionCharacterIndices []uint64
|
||||
costumeIndices []uint64
|
||||
itemNextIndex uint64
|
||||
equipmentNextIndex uint64
|
||||
characterNextIndex uint64
|
||||
costumeNextIndex uint64
|
||||
equipmentGrants []namedIndex
|
||||
equipmentUsers []namedIndex
|
||||
quests []questKey
|
||||
cleared []questKey
|
||||
granted map[string]bool
|
||||
itemGrants []indexedGrant
|
||||
}
|
||||
|
||||
type namedIndex struct {
|
||||
@@ -73,7 +74,7 @@ func validateSnapshot(snapshot validationSnapshot) []Problem {
|
||||
problems = append(problems, duplicateIndexProblems("costumes.duplicate_index", []string{"collection", "costumes"}, snapshot.costumeIndices)...)
|
||||
problems = append(problems, nextIndexProblems("inventory.next_index", []string{"inventory", "next_index"}, snapshot.itemNextIndex, snapshot.itemIndices)...)
|
||||
problems = append(problems, nextIndexProblems("equipment.next_index", []string{"equipment", "next_index"}, snapshot.equipmentNextIndex, snapshot.equipmentIndices)...)
|
||||
problems = append(problems, nextIndexProblems("collection.next_character_index", []string{"collection", "next_character_index"}, snapshot.characterNextIndex, snapshot.characterIndices)...)
|
||||
problems = append(problems, nextIndexProblems("collection.next_character_index", []string{"collection", "next_character_index"}, snapshot.characterNextIndex, snapshot.collectionCharacterIndices)...)
|
||||
problems = append(problems, nextIndexProblems("collection.next_costume_index", []string{"collection", "next_costume_index"}, snapshot.costumeNextIndex, snapshot.costumeIndices)...)
|
||||
|
||||
for _, grant := range snapshot.equipmentGrants {
|
||||
@@ -245,6 +246,10 @@ func validateState(tx *sql.Tx) ([]Problem, error) {
|
||||
return nil, err
|
||||
}
|
||||
snapshot.characterIndices = append(baseCharacters, acquiredCharacters...)
|
||||
// Collection allocation owns acquired characters. Base, story and timed
|
||||
// companions have separate instance namespaces; they still participate in
|
||||
// duplicate identity and equipment owner validation above.
|
||||
snapshot.collectionCharacterIndices = acquiredCharacters
|
||||
if snapshot.costumeIndices, _, err = indexedEntries(tx, "collection", "costumes", false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -10,14 +10,29 @@ func validValidationSnapshot() validationSnapshot {
|
||||
formatVersion: 1, clientVersion: "2.35.10", gameDataVersion: "20260924000000",
|
||||
itemNextIndex: 11, equipmentNextIndex: 21, characterNextIndex: 31, costumeNextIndex: 41,
|
||||
itemIndices: []uint64{10}, equipmentIndices: []uint64{20}, characterIndices: []uint64{30}, costumeIndices: []uint64{40},
|
||||
equipmentGrants: []namedIndex{{identity: "quest:test", index: 20}},
|
||||
equipmentUsers: []namedIndex{{identity: "20", index: 30}},
|
||||
quests: []questKey{{pack: 1, quest: 2}}, cleared: []questKey{{pack: 1, quest: 2}},
|
||||
collectionCharacterIndices: []uint64{30},
|
||||
equipmentGrants: []namedIndex{{identity: "quest:test", index: 20}},
|
||||
equipmentUsers: []namedIndex{{identity: "20", index: 30}},
|
||||
quests: []questKey{{pack: 1, quest: 2}}, cleared: []questKey{{pack: 1, quest: 2}},
|
||||
granted: map[string]bool{"mail:test": true},
|
||||
itemGrants: []indexedGrant{{identity: "mail:test", indices: []uint64{9}}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimedCompanionIdentityIsSeparateFromCollectionAllocator(t *testing.T) {
|
||||
s := validValidationSnapshot()
|
||||
companion := uint64(1<<59) + 100
|
||||
s.characterIndices = append(s.characterIndices, companion)
|
||||
s.equipmentUsers[0].index = companion
|
||||
if p := validateSnapshot(s); len(p) != 0 {
|
||||
t.Fatalf("temporary owner rejected: %+v", p)
|
||||
}
|
||||
s.collectionCharacterIndices = append(s.collectionCharacterIndices, s.characterNextIndex)
|
||||
if !slices.Contains(problemCodes(validateSnapshot(s)), "collection.next_character_index") {
|
||||
t.Fatal("permanent allocator invariant lost")
|
||||
}
|
||||
}
|
||||
|
||||
func problemCodes(problems []Problem) []string {
|
||||
codes := make([]string, 0, len(problems))
|
||||
for _, problem := range problems {
|
||||
|
||||
@@ -35,7 +35,14 @@ type Service struct {
|
||||
hunting HuntingRuntime
|
||||
monsterHunt MonsterHuntRuntime
|
||||
eventBattles []EventBattleRuntime
|
||||
fieldMonsters FieldMonsterRuntime
|
||||
}
|
||||
type FieldMonsterRuntime interface {
|
||||
BeginFieldMonsterBattle(int, uint64, uint64) (string, bool, error)
|
||||
CompleteFieldMonsterBattle(int, uint64, string) ([]byte, error)
|
||||
}
|
||||
|
||||
func (s *Service) AttachFieldMonsters(runtime FieldMonsterRuntime) { s.fieldMonsters = runtime }
|
||||
|
||||
// EventBattleRuntime owns event stage eligibility, costs and settlement while
|
||||
// the normal battle service transports the client's turn simulation.
|
||||
@@ -85,23 +92,24 @@ func (s *Service) AttachHunting(runtime HuntingRuntime) {
|
||||
}
|
||||
|
||||
type battleState struct {
|
||||
entered bool
|
||||
index uint64
|
||||
round uint64
|
||||
monster uint64
|
||||
deck uint64
|
||||
pack int
|
||||
mode uint64
|
||||
enterReceipt string
|
||||
initialBlue [][]byte
|
||||
phases []gamedata.BattlePhase
|
||||
phase int
|
||||
phaseStarted bool
|
||||
phaseSeq uint64
|
||||
phaseReply []byte
|
||||
endSeq uint64
|
||||
endRequest []byte
|
||||
endReply []byte
|
||||
entered bool
|
||||
index uint64
|
||||
round uint64
|
||||
monster uint64
|
||||
deck uint64
|
||||
pack int
|
||||
mode uint64
|
||||
enterReceipt string
|
||||
fieldInstance string
|
||||
initialBlue [][]byte
|
||||
phases []gamedata.BattlePhase
|
||||
phase int
|
||||
phaseStarted bool
|
||||
phaseSeq uint64
|
||||
phaseReply []byte
|
||||
endSeq uint64
|
||||
endRequest []byte
|
||||
endReply []byte
|
||||
}
|
||||
|
||||
// BeginSession discards an unfinished battle when LoginUser creates a new
|
||||
@@ -292,6 +300,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
}
|
||||
}
|
||||
var phases []gamedata.BattlePhase
|
||||
fieldInstance := ""
|
||||
if mode == 2 && eventRuntime == nil && s.fieldMonsters != nil && monster != 0 {
|
||||
instance, _, e := s.fieldMonsters.BeginFieldMonsterBattle(packID, monster, deck)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
fieldInstance = instance
|
||||
}
|
||||
if !isMonsterHunt(mode) && eventRuntime == nil && (s.loadPhases != nil || (s.gameDataRoot != "" && packID > 0 && monster != 0)) {
|
||||
loader := s.loadPhases
|
||||
if loader == nil {
|
||||
@@ -328,6 +344,7 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
state.mode = mode
|
||||
seq, _, _ := wire.Varint(request, 1)
|
||||
state.enterReceipt = fmt.Sprintf("%s:%d", s.activeSession, seq)
|
||||
state.fieldInstance = fieldInstance
|
||||
state.phases, state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = phases, 0, false, 0, nil
|
||||
slog.Info("team trace: battle entered", "pack", packID, "monster", monster, "enemyDeck", deck, "mode", mode)
|
||||
return 52, response, true, nil
|
||||
@@ -522,7 +539,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
if rewardErr != nil {
|
||||
return 0, nil, true, fmt.Errorf("battle: pack %d monster %d deck %d rewards: %w", state.pack, state.monster, state.deck, rewardErr)
|
||||
}
|
||||
items, grantErr := s.inventory.GrantOnce(fmt.Sprintf("pack%d:monster%d:deck%d", state.pack, state.monster, state.deck), rewards)
|
||||
rewardIdentity := fmt.Sprintf("pack%d:monster%d:deck%d", state.pack, state.monster, state.deck)
|
||||
if state.fieldInstance != "" {
|
||||
rewardIdentity = state.fieldInstance
|
||||
}
|
||||
items, grantErr := s.inventory.GrantOnce(rewardIdentity, rewards)
|
||||
if grantErr != nil {
|
||||
return 0, nil, true, grantErr
|
||||
}
|
||||
@@ -534,6 +555,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
response = wire.AppendBytes(response, 5, bundle)
|
||||
rewardBundle = true
|
||||
}
|
||||
if state.fieldInstance != "" {
|
||||
monsterRow, e := s.fieldMonsters.CompleteFieldMonsterBattle(state.pack, state.monster, state.fieldInstance)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
response = wire.AppendBytes(response, 4, monsterRow)
|
||||
}
|
||||
}
|
||||
if result == 1 && state.monster != 0 && s.onMonsterWin != nil {
|
||||
if err := s.onMonsterWin(); err != nil {
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package deck
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func (s *Store) AttachAutoRecovery(f func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.autoRecovery = f
|
||||
}
|
||||
func (s *Store) AttachAutoRecoveryAllowed(f func() (bool, error)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.autoRecoveryAllowed = f
|
||||
}
|
||||
|
||||
type autoRecoveryReceipt struct {
|
||||
Digest string
|
||||
Body []byte
|
||||
}
|
||||
|
||||
func (s *Store) handleAutoRecovery(req []byte) (int, []byte, bool, error) {
|
||||
fail := func(e error) (int, []byte, bool, error) { return 373, nil, true, e }
|
||||
if e := checkSeq(req); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
caster, _, e := wire.Varint(req, 2)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
seq, _, _ := wire.Varint(req, 1)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := s.sessionID + ":" + fmt.Sprint(seq)
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(req))
|
||||
if s.storage != nil {
|
||||
raw, ok, err := s.storage.LoadEntry("deck", "auto_recovery", key)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if ok {
|
||||
var r autoRecoveryReceipt
|
||||
if err = json.Unmarshal(raw, &r); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if r.Digest != digest {
|
||||
return fail(fmt.Errorf("deck: changed automatic recovery replay"))
|
||||
}
|
||||
return 373, r.Body, true, nil
|
||||
}
|
||||
}
|
||||
n := clone(s.state)
|
||||
if len(n.Deck) == 0 {
|
||||
return fail(fmt.Errorf("deck: automatic recovery has no battle deck"))
|
||||
}
|
||||
result := player.AutoRecoveryResult{Caster: caster, Catalyst: n.AutoReviveCatalyst}
|
||||
if s.wallet != nil {
|
||||
result.Catalyst = s.wallet.CatalystBalance()
|
||||
}
|
||||
mode := uint64(2)
|
||||
var settings fieldSettings
|
||||
if s.characters != nil {
|
||||
settings, e = s.loadFieldSettings()
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
if caster != settings.Caster {
|
||||
return fail(fmt.Errorf("deck: automatic recovery caster differs from saved setting"))
|
||||
}
|
||||
if n.FieldCharControlDeckType == 2 {
|
||||
allExhausted := true
|
||||
for _, v := range n.Deck {
|
||||
hp, err := s.characters.CurrentHealth(v.CharacterInvenIndex)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if hp > 0 {
|
||||
allExhausted = false
|
||||
}
|
||||
}
|
||||
if allExhausted {
|
||||
mode = 3
|
||||
}
|
||||
}
|
||||
field := s.visibleFieldDeckLocked()
|
||||
var targets []uint64
|
||||
seen := map[uint64]bool{}
|
||||
add := func(id uint64) error {
|
||||
if seen[id] {
|
||||
return nil
|
||||
}
|
||||
seen[id] = true
|
||||
c, ok := s.characters.Find(id)
|
||||
if !ok || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) {
|
||||
return nil
|
||||
}
|
||||
hp, err := s.characters.CurrentHealth(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hp == 0 {
|
||||
targets = append(targets, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if n.FieldCharControlDeckType == 0 {
|
||||
for _, v := range n.Deck {
|
||||
if e = add(v.CharacterInvenIndex); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
} else if n.FieldCharControlDeckType == 1 {
|
||||
for _, v := range field {
|
||||
if e = add(v.CharacterInvenIndex); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
allowed := true
|
||||
if s.autoRecoveryAllowed != nil {
|
||||
allowed, e = s.autoRecoveryAllowed()
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
if settings.AutoRevive && allowed && len(targets) > 0 {
|
||||
if s.autoRecovery == nil {
|
||||
return fail(fmt.Errorf("deck: automatic recovery executor unavailable"))
|
||||
}
|
||||
result, e = s.autoRecovery(seq, caster, targets)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
settings.Caster = result.Caster
|
||||
if len(result.Characters) > 0 {
|
||||
mode = 1
|
||||
}
|
||||
}
|
||||
// On recovery failure, replace fatigued party members with living,
|
||||
// permanent owned characters in inventory order. Keep the fatigued
|
||||
// member when no replacement exists so the client can show exhaustion.
|
||||
if mode != 1 && len(targets) > 0 {
|
||||
all := s.characters.RawAll()
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].InvenIndex < all[j].InvenIndex })
|
||||
used := map[uint64]bool{}
|
||||
for _, v := range n.Deck {
|
||||
used[v.CharacterInvenIndex] = true
|
||||
}
|
||||
for _, v := range field {
|
||||
used[v.CharacterInvenIndex] = true
|
||||
}
|
||||
replacements := map[uint64]player.Character{}
|
||||
for _, id := range targets {
|
||||
for _, c := range all {
|
||||
if used[c.InvenIndex] || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) {
|
||||
continue
|
||||
}
|
||||
hp, err := s.characters.CurrentHealth(c.InvenIndex)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if hp == 0 {
|
||||
continue
|
||||
}
|
||||
replacements[id] = c
|
||||
used[c.InvenIndex] = true
|
||||
break
|
||||
}
|
||||
if _, ok := replacements[id]; !ok {
|
||||
mode = 3
|
||||
}
|
||||
}
|
||||
for i, v := range n.Deck {
|
||||
if c, ok := replacements[v.CharacterInvenIndex]; ok {
|
||||
n.Deck[i].CharacterInvenIndex = c.InvenIndex
|
||||
}
|
||||
}
|
||||
for i, v := range n.FieldDeck {
|
||||
if c, ok := replacements[v.CharacterInvenIndex]; ok {
|
||||
n.FieldDeck[i].CharacterInvenIndex = c.InvenIndex
|
||||
n.FieldDeck[i].CostumeInvenIndex = c.UseCostume
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if caster != 0 {
|
||||
return fail(fmt.Errorf("deck: automatic recovery character provider unavailable"))
|
||||
}
|
||||
var out []byte
|
||||
for _, v := range n.Deck {
|
||||
b := wire.AppendVarint(nil, 1, v.CharacterInvenIndex)
|
||||
b = wire.AppendVarint(b, 2, v.CostumeInvenIndex)
|
||||
b = wire.AppendVarint(b, 3, v.Slot)
|
||||
out = wire.AppendBytes(out, 1, b)
|
||||
}
|
||||
for _, v := range n.FieldDeck {
|
||||
b := wire.AppendVarint(nil, 1, v.Slot)
|
||||
b = wire.AppendVarint(b, 2, v.CharacterInvenIndex)
|
||||
b = wire.AppendVarint(b, 3, v.CostumeInvenIndex)
|
||||
out = wire.AppendBytes(out, 2, b)
|
||||
}
|
||||
for _, c := range result.Characters {
|
||||
out = wire.AppendVarint(out, 3, c.InvenIndex)
|
||||
out = wire.AppendBytes(out, 5, player.CharacterWire(c))
|
||||
}
|
||||
out = wire.AppendVarint(out, 4, mode)
|
||||
if result.Caster != 0 {
|
||||
out = wire.AppendVarint(out, 9, result.Caster)
|
||||
}
|
||||
out = wire.AppendVarint(out, 6, result.Experience)
|
||||
out = wire.AppendVarint(out, 7, result.Catalyst)
|
||||
out = wire.AppendVarint(out, 8, result.Disabled)
|
||||
if s.storage != nil {
|
||||
core, err := json.Marshal(n)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
raw, err := json.Marshal(autoRecoveryReceipt{digest, out})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
changes := []stateio.EntryMutation{{Bucket: "auto_recovery", Key: key, Payload: raw}}
|
||||
if s.characters != nil {
|
||||
raw, err = json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
changes = append(changes, stateio.EntryMutation{Bucket: "field_settings", Key: "state", Payload: raw})
|
||||
}
|
||||
if err = s.storage.SaveWithEntries("deck", core, changes); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
s.state = n
|
||||
return 373, out, true, nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package deck
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func recoveryFixture(t *testing.T) *presetFixture {
|
||||
f := newPresetFixture(t)
|
||||
f.deck.state.FieldCharControlDeckType = 0
|
||||
if e := f.deck.AttachFieldSettings(&gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 14}}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
f.characters.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil })
|
||||
f.characters.SetCurrentHealth(100, 100)
|
||||
f.characters.SetCurrentHealth(200, 0)
|
||||
if _, _, _, e := f.deck.Handle("/DeckSave", req(1, triple(200, 1, 1))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(2, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 100))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestAutoRecoveryPersistsResponseAndDisabledSettingNeverExecutes(t *testing.T) {
|
||||
f := recoveryFixture(t)
|
||||
calls := 0
|
||||
f.deck.AttachAutoRecovery(func(seq, caster uint64, targets []uint64) (player.AutoRecoveryResult, error) {
|
||||
calls++
|
||||
if caster != 100 || len(targets) != 1 || targets[0] != 200 {
|
||||
t.Fatal("wrong actual fatigue targets")
|
||||
}
|
||||
f.characters.SetCurrentHealth(200, 25)
|
||||
c, _ := f.characters.Find(200)
|
||||
c.HP = 25
|
||||
return player.AutoRecoveryResult{Caster: 100, Characters: []player.Character{c}, Experience: 2, Catalyst: 10}, nil
|
||||
})
|
||||
b := req(3, wire.AppendVarint(nil, 2, 100))
|
||||
code, out, _, e := f.deck.Handle("/DeckCharAutoRevive", b)
|
||||
if e != nil || code != 373 {
|
||||
t.Fatal(e)
|
||||
}
|
||||
mode, _, _ := wire.Varint(out, 4)
|
||||
caster, _, _ := wire.Varint(out, 9)
|
||||
if mode != 1 || caster != 100 {
|
||||
t.Fatalf("response%x", out)
|
||||
}
|
||||
reopened, e := OpenStore(f.storage, f.seed, testPresetDesign)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
reopened.BeginSession("preset-test")
|
||||
_, again, _, e := reopened.Handle("/DeckCharAutoRevive", b)
|
||||
if e != nil || !bytes.Equal(out, again) || calls != 1 {
|
||||
t.Fatal("automatic recovery receipt replay")
|
||||
}
|
||||
if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 3, 100))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
f.characters.SetCurrentHealth(200, 0)
|
||||
_, out, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100)))
|
||||
if e != nil || calls != 1 {
|
||||
t.Fatal("disabled automatic recovery executed", e)
|
||||
}
|
||||
if got := f.deck.CurrentDeck()[0].CharacterInvenIndex; got != 100 {
|
||||
t.Fatalf("fatigued member not replaced: %d", got)
|
||||
}
|
||||
if _, _, _, e = f.deck.Handle("/CharAutoReviveSet", req(6, wire.AppendVarint(nil, 3, 200))); e == nil {
|
||||
t.Fatal("disabled setting accepted Immortal class14 caster")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoRecoveryFailureReportsExhaustionAndStoryCannotRecover(t *testing.T) {
|
||||
f := recoveryFixture(t)
|
||||
if _, _, _, e := f.deck.Handle("/DeckSave", req(3, triple(100, 1, 1), triple(200, 2, 2))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) {
|
||||
return player.AutoRecoveryResult{Caster: 100, Catalyst: 0, Disabled: 2}, nil
|
||||
})
|
||||
_, out, _, e := f.deck.Handle("/DeckCharAutoRevive", req(4, wire.AppendVarint(nil, 2, 100)))
|
||||
mode, _, _ := wire.Varint(out, 4)
|
||||
disabled, _, _ := wire.Varint(out, 8)
|
||||
if e != nil || mode != 3 || disabled != 2 {
|
||||
t.Fatalf("failure mode%d disabled%d %v", mode, disabled, e)
|
||||
}
|
||||
f.characters.SetCurrentHealth(100, 0)
|
||||
f.deck.state.FieldCharControlDeckType = 2
|
||||
f.deck.AttachAutoRecovery(func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error) {
|
||||
t.Fatal("story mode must not recover temporary party")
|
||||
return player.AutoRecoveryResult{}, nil
|
||||
})
|
||||
_, out, _, e = f.deck.Handle("/DeckCharAutoRevive", req(5, wire.AppendVarint(nil, 2, 100)))
|
||||
mode, _, _ = wire.Varint(out, 4)
|
||||
if e != nil || mode != 3 {
|
||||
t.Fatalf("story fatigue mode%d %v", mode, e)
|
||||
}
|
||||
}
|
||||
@@ -58,21 +58,25 @@ func (s *Store) PortraitCostume() uint64 {
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
storage stateio.AtomicEntryStore
|
||||
state state
|
||||
presets map[uint64]Preset
|
||||
presetSlots uint64
|
||||
presetDesign gamedata.PresetDesign
|
||||
costumeSettings map[uint64]CostumeSetting
|
||||
wallet *player.Wallet
|
||||
characters *player.CharacterStore
|
||||
equipment *player.EquipmentInventory
|
||||
collection *player.CollectionStore
|
||||
sessionID string
|
||||
replies map[string]deckReply
|
||||
waypointDesign func(uint64) (gamedata.WaypointPack, error)
|
||||
waypointPack func(uint64, bool) error
|
||||
autoRecovery func(uint64, uint64, []uint64) (player.AutoRecoveryResult, error)
|
||||
autoRecoveryAllowed func() (bool, error)
|
||||
fieldSettingsDesign *gamedata.FieldSettingsDesign
|
||||
fieldSettingsPack func() (int, error)
|
||||
mu sync.RWMutex
|
||||
storage stateio.AtomicEntryStore
|
||||
state state
|
||||
presets map[uint64]Preset
|
||||
presetSlots uint64
|
||||
presetDesign gamedata.PresetDesign
|
||||
costumeSettings map[uint64]CostumeSetting
|
||||
wallet *player.Wallet
|
||||
characters *player.CharacterStore
|
||||
equipment *player.EquipmentInventory
|
||||
collection *player.CollectionStore
|
||||
sessionID string
|
||||
replies map[string]deckReply
|
||||
waypointDesign func(uint64) (gamedata.WaypointPack, error)
|
||||
waypointPack func(uint64, bool) error
|
||||
}
|
||||
|
||||
type deckReply struct {
|
||||
@@ -125,6 +129,11 @@ func validField(entries []FieldEntry) error {
|
||||
costumes[e.CostumeInvenIndex] = true
|
||||
}
|
||||
}
|
||||
for slot := uint64(1); slot <= uint64(len(entries)); slot++ {
|
||||
if !sequences[slot] {
|
||||
return errors.New("deck: field deck has a missing sequence")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -181,7 +190,7 @@ func OpenStore(storage stateio.Store, seed Seed, designs ...gamedata.PresetDesig
|
||||
return nil, fmt.Errorf("deck: load state: %w", e)
|
||||
}
|
||||
if b == nil {
|
||||
if e = stateio.RequireNoEntries(entries, "deck", "presets", "preset_config", "costume_settings"); e != nil {
|
||||
if e = stateio.RequireNoEntries(entries, "deck", "presets", "preset_config", "costume_settings", "field_settings"); e != nil {
|
||||
return nil, fmt.Errorf("deck: invalid entry storage: %w", e)
|
||||
}
|
||||
return s, nil
|
||||
@@ -250,7 +259,7 @@ func clone(x state) state {
|
||||
}
|
||||
func checkSeq(req []byte) error {
|
||||
v, ok, e := wire.Varint(req, 1)
|
||||
if e != nil || !ok || v == 0 {
|
||||
if e != nil || !ok || v == 0 || v > 2147483647 {
|
||||
return errors.New("deck: invalid request sequence")
|
||||
}
|
||||
return nil
|
||||
@@ -340,12 +349,19 @@ func (s *Store) validateOwnedFieldDeckLocked(entries []FieldEntry) error {
|
||||
return nil
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if _, found := s.characters.Find(entry.CharacterInvenIndex); !found {
|
||||
character, found := s.characters.Find(entry.CharacterInvenIndex)
|
||||
if !found {
|
||||
return fmt.Errorf("deck: field deck references unknown character %d", entry.CharacterInvenIndex)
|
||||
}
|
||||
if player.IsStoryCharacter(character) && !s.temporaryAllowed(character) {
|
||||
return fmt.Errorf("deck: field character unavailable in this pack")
|
||||
}
|
||||
if entry.CostumeInvenIndex == 0 {
|
||||
continue
|
||||
}
|
||||
if (player.IsStoryCharacter(character) || player.IsCharmCharacter(character)) && character.UseCostume == entry.CostumeInvenIndex {
|
||||
continue
|
||||
}
|
||||
costume, found := s.collection.CostumeByIndex(entry.CostumeInvenIndex)
|
||||
if !found {
|
||||
return fmt.Errorf("deck: field deck references unknown costume %d", entry.CostumeInvenIndex)
|
||||
@@ -361,6 +377,8 @@ func (s *Store) validateOwnedFieldDeckLocked(entries []FieldEntry) error {
|
||||
// typed request before committing a replacement JSON state.
|
||||
func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
|
||||
switch path {
|
||||
case "/TalentSlotSave", "/CharAutoReviveSet":
|
||||
return s.handleFieldSettings(path, req)
|
||||
case "/PresetInfo":
|
||||
return s.handlePresetInfo(req)
|
||||
case "/PresetSave":
|
||||
@@ -384,55 +402,26 @@ func (s *Store) Handle(path string, req []byte) (int, []byte, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
slog.Info("team trace: deliver saved battle deck", "deck", s.state.Deck)
|
||||
return 8, encodeDeck(s.state.Deck), true, nil
|
||||
out := encodeDeck(s.state.Deck)
|
||||
if s.fieldSettingsDesign != nil {
|
||||
v, e := s.loadFieldSettings()
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
for _, id := range s.projectTalentSlots(v.TalentIDs) {
|
||||
out = wire.AppendVarint(out, 2, id)
|
||||
}
|
||||
}
|
||||
return 8, out, true, nil
|
||||
case "/FieldDeckInfo":
|
||||
if e := checkSeq(req); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return 273, encodeField(s.state.FieldDeck), true, nil
|
||||
return 273, encodeField(s.visibleFieldDeckLocked()), true, nil
|
||||
case "/DeckCharAutoRevive":
|
||||
if e := checkSeq(req); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
caster, _, e := wire.Varint(req, 2)
|
||||
if e != nil {
|
||||
return 0, nil, true, fmt.Errorf("deck: invalid auto-revive caster: %w", e)
|
||||
}
|
||||
if caster != 0 {
|
||||
// The observed story flow has no caster and only refreshes the
|
||||
// formation. A nonzero caster would require authoritative revive
|
||||
// targets, talent experience, and catalyst consumption.
|
||||
return 0, nil, true, fmt.Errorf("deck: auto-revive caster %d is not verified", caster)
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if len(s.state.Deck) == 0 {
|
||||
return 0, nil, true, errors.New("deck: auto-revive has no current battle deck")
|
||||
}
|
||||
var response []byte
|
||||
for _, entry := range s.state.Deck {
|
||||
deck := wire.AppendVarint(nil, 1, entry.CharacterInvenIndex)
|
||||
if entry.CostumeInvenIndex != 0 {
|
||||
deck = wire.AppendVarint(deck, 2, entry.CostumeInvenIndex)
|
||||
}
|
||||
deck = wire.AppendVarint(deck, 3, entry.Slot)
|
||||
response = wire.AppendBytes(response, 1, deck)
|
||||
}
|
||||
for _, entry := range s.state.FieldDeck {
|
||||
field := wire.AppendVarint(nil, 1, entry.Slot)
|
||||
field = wire.AppendVarint(field, 2, entry.CharacterInvenIndex)
|
||||
if entry.CostumeInvenIndex != 0 {
|
||||
field = wire.AppendVarint(field, 3, entry.CostumeInvenIndex)
|
||||
}
|
||||
response = wire.AppendBytes(response, 2, field)
|
||||
}
|
||||
response = wire.AppendVarint(response, 4, 2) // Define_AutoReviveCharType.CHANGE.
|
||||
if s.state.AutoReviveCatalyst != 0 {
|
||||
response = wire.AppendVarint(response, 7, s.state.AutoReviveCatalyst)
|
||||
}
|
||||
return 373, response, true, nil
|
||||
return s.handleAutoRecovery(req)
|
||||
case "/WaypointInfo":
|
||||
return s.handleWaypoint(path, req)
|
||||
case "/DeckSave":
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package deck
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fieldSettings struct {
|
||||
TalentIDs []uint64 `json:"talent_ids"`
|
||||
AutoRevive bool `json:"auto_revive"`
|
||||
Caster uint64 `json:"caster"`
|
||||
}
|
||||
|
||||
// A new formal entry owns quick slots and automatic recovery preferences; the
|
||||
// existing deck core and historical protobuf schema keep their current layout.
|
||||
func (s *Store) AttachFieldSettings(d *gamedata.FieldSettingsDesign) error {
|
||||
if d == nil || d.TalentSlots <= 0 {
|
||||
return fmt.Errorf("deck: invalid field settings design")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.fieldSettingsDesign = d
|
||||
_, e := s.loadFieldSettings()
|
||||
return e
|
||||
}
|
||||
func (s *Store) AttachFieldSettingsPack(source func() (int, error)) error {
|
||||
if source == nil {
|
||||
return fmt.Errorf("deck: nil field pack provider")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.fieldSettingsPack = source
|
||||
return nil
|
||||
}
|
||||
func (s *Store) temporaryAllowed(c player.Character) bool {
|
||||
if s.fieldSettingsDesign == nil || s.fieldSettingsPack == nil {
|
||||
return false
|
||||
}
|
||||
p, e := s.fieldSettingsPack()
|
||||
return e == nil && s.fieldSettingsDesign.CharacterTemporaryPack[c.ID] == p && p != 0
|
||||
}
|
||||
func (s *Store) loadFieldSettings() (fieldSettings, error) {
|
||||
d := s.fieldSettingsDesign
|
||||
if d == nil {
|
||||
return fieldSettings{}, fmt.Errorf("deck: field settings unavailable")
|
||||
}
|
||||
v := fieldSettings{TalentIDs: make([]uint64, d.TalentSlots)}
|
||||
raw, ok, e := s.storage.LoadEntry("deck", "field_settings", "state")
|
||||
if e != nil || !ok {
|
||||
return v, e
|
||||
}
|
||||
if e = stateExactFieldSettings(raw, &v); e != nil {
|
||||
return v, e
|
||||
}
|
||||
if e = s.validateFieldSettings(v, false); e != nil {
|
||||
return v, e
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func stateExactFieldSettings(raw []byte, v *fieldSettings) error {
|
||||
var fields map[string]json.RawMessage
|
||||
if e := json.Unmarshal(raw, &fields); e != nil {
|
||||
return e
|
||||
}
|
||||
if len(fields) != 3 || fields["talent_ids"] == nil || fields["auto_revive"] == nil || fields["caster"] == nil {
|
||||
return fmt.Errorf("deck: invalid field settings layout")
|
||||
}
|
||||
return json.Unmarshal(raw, v)
|
||||
}
|
||||
func (s *Store) validateFieldSettings(v fieldSettings, live bool) error {
|
||||
if len(v.TalentIDs) != s.fieldSettingsDesign.TalentSlots {
|
||||
return fmt.Errorf("deck: talent slot count")
|
||||
}
|
||||
owned := map[uint64]bool{}
|
||||
if !live {
|
||||
for id := range s.fieldSettingsDesign.CharacterTalentClass {
|
||||
owned[id] = true
|
||||
}
|
||||
}
|
||||
if s.characters != nil {
|
||||
for _, c := range s.characters.RawAll() {
|
||||
if live && player.CharacterExpired(c, time.Now()) {
|
||||
continue
|
||||
}
|
||||
if !player.IsStoryCharacter(c) || s.temporaryAllowed(c) || !live && s.fieldSettingsDesign.CharacterTemporaryPack[c.ID] != 0 {
|
||||
owned[c.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range v.TalentIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if id > 2147483647 || seen[id] || !owned[id] {
|
||||
return fmt.Errorf("deck: talent slot character unavailable %d", id)
|
||||
}
|
||||
if _, ok := s.fieldSettingsDesign.CharacterTalentClass[id]; !ok {
|
||||
return fmt.Errorf("deck: character has no talent")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
if v.Caster != 0 {
|
||||
if s.characters == nil {
|
||||
return fmt.Errorf("deck: character provider unavailable")
|
||||
}
|
||||
c, ok := s.characters.Find(v.Caster)
|
||||
if !ok || player.IsStoryCharacter(c) || player.IsCharmCharacter(c) || s.fieldSettingsDesign.CharacterTalentClass[c.ID] != 10 {
|
||||
return fmt.Errorf("deck: invalid automatic recovery caster")
|
||||
}
|
||||
}
|
||||
if v.AutoRevive && v.Caster == 0 {
|
||||
return fmt.Errorf("deck: automatic recovery needs caster")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *Store) TalentCharacterIDs() ([]uint64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
v, e := s.loadFieldSettings()
|
||||
return s.projectTalentSlots(v.TalentIDs), e
|
||||
}
|
||||
func (s *Store) projectTalentSlots(ids []uint64) []uint64 {
|
||||
out := append([]uint64(nil), ids...)
|
||||
owned := map[uint64]bool{}
|
||||
charm := map[uint64]bool{}
|
||||
if s.characters != nil {
|
||||
for _, c := range s.characters.RawAll() {
|
||||
if player.CharacterExpired(c, time.Now()) {
|
||||
continue
|
||||
}
|
||||
if player.IsCharmCharacter(c) {
|
||||
owned[c.ID] = true
|
||||
charm[c.ID] = true
|
||||
continue
|
||||
}
|
||||
if !player.IsStoryCharacter(c) || s.temporaryAllowed(c) {
|
||||
owned[c.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, id := range out {
|
||||
if id != 0 && !owned[id] {
|
||||
out[i] = 0
|
||||
continue
|
||||
}
|
||||
if s.fieldSettingsDesign.CharacterTemporaryPack[id] != 0 && !charm[id] {
|
||||
p := 0
|
||||
if s.fieldSettingsPack != nil {
|
||||
p, _ = s.fieldSettingsPack()
|
||||
}
|
||||
if p != s.fieldSettingsDesign.CharacterTemporaryPack[id] {
|
||||
out[i] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (s *Store) AutoReviveSettings() (bool, uint64, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
v, e := s.loadFieldSettings()
|
||||
return v.AutoRevive, v.Caster, e
|
||||
}
|
||||
func (s *Store) FieldControlType() uint64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.state.FieldCharControlDeckType
|
||||
}
|
||||
func (s *Store) handleFieldSettings(path string, req []byte) (int, []byte, bool, error) {
|
||||
if e := checkSeq(req); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, e := s.loadFieldSettings()
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
code := 71
|
||||
var body []byte
|
||||
if path == "/TalentSlotSave" {
|
||||
var ids []uint64
|
||||
e = wire.Walk(req, func(f wire.Field) error {
|
||||
if f.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 && f.Type != 2 {
|
||||
return fmt.Errorf("deck: invalid talent slots")
|
||||
}
|
||||
b := f.Value
|
||||
for len(b) > 0 {
|
||||
n, k := binary.Uvarint(b)
|
||||
if k <= 0 {
|
||||
return fmt.Errorf("deck: invalid packed talent slots")
|
||||
}
|
||||
ids = append(ids, n)
|
||||
if len(ids) > s.fieldSettingsDesign.TalentSlots {
|
||||
return fmt.Errorf("deck: too many talent slots")
|
||||
}
|
||||
b = b[k:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
v.TalentIDs = ids
|
||||
} else {
|
||||
code = 372
|
||||
on, _, e := wire.Varint(req, 2)
|
||||
if e != nil || on > 1 {
|
||||
return 0, nil, true, fmt.Errorf("deck: invalid auto-recovery switch")
|
||||
}
|
||||
caster, _, e := wire.Varint(req, 3)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
v.AutoRevive = on != 0
|
||||
v.Caster = caster
|
||||
body = wire.AppendVarint(nil, 1, on)
|
||||
body = wire.AppendVarint(body, 2, caster)
|
||||
}
|
||||
if e = s.validateFieldSettings(v, path == "/TalentSlotSave"); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
raw, e := json.Marshal(v)
|
||||
if e == nil {
|
||||
core, err := json.Marshal(s.state)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
e = s.storage.SaveWithEntries("deck", core, []stateio.EntryMutation{{Bucket: "field_settings", Key: "state", Payload: raw}})
|
||||
}
|
||||
return code, body, true, e
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package deck
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTalentSlotsRestoreOwnershipAndClearSlots(t *testing.T) {
|
||||
f := newPresetFixture(t)
|
||||
design := &gamedata.FieldSettingsDesign{TalentSlots: 3, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 9}}
|
||||
if e := f.deck.AttachFieldSettings(design); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
save := req(1, wire.AppendVarint(nil, 2, 360), wire.AppendVarint(nil, 2, 0), wire.AppendVarint(nil, 2, 350))
|
||||
if _, _, _, e := f.deck.Handle("/TalentSlotSave", save); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, bad := range [][]uint64{{350, 350, 0}, {999, 0, 0}, {100, 0, 0}, {350}} {
|
||||
var r []byte
|
||||
for _, id := range bad {
|
||||
r = wire.AppendVarint(r, 2, id)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(2, r)); e == nil {
|
||||
t.Fatal("invalid slots accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(3, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 200))); e == nil {
|
||||
t.Fatal("wrong talent caster accepted")
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, 100))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
reopened, e := OpenStore(f.storage, f.seed, f.deck.presetDesign)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
reopened.characters = f.characters
|
||||
if e = reopened.AttachFieldSettings(design); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ids, e := reopened.TalentCharacterIDs()
|
||||
if e != nil || ids[0] != 360 || ids[1] != 0 || ids[2] != 350 {
|
||||
t.Fatal(ids, e)
|
||||
}
|
||||
on, caster, e := reopened.AutoReviveSettings()
|
||||
if e != nil || !on || caster != 100 {
|
||||
t.Fatal(on, caster, e)
|
||||
}
|
||||
_, b, _, e := reopened.Handle("/DeckInfo", req(5))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
n := 0
|
||||
wire.Walk(b, func(f wire.Field) error {
|
||||
if f.Number == 2 {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if n != 3 {
|
||||
t.Fatal("DeckInfo missing empty quick slot", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharmFieldAndTalentSlotsExpireWithoutPermanentOwnership(t *testing.T) {
|
||||
f := newPresetFixture(t)
|
||||
base := f.characters.RawAll()
|
||||
index := player.CharmCharacterIndexBase + 11
|
||||
install := func(expiry uint64) {
|
||||
m := stateio.NewMemory()
|
||||
inv, e := player.OpenInventory(m, &player.Starter{Version: f.seed.Version})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
chars := append(append([]player.Character(nil), base...), player.Character{InvenIndex: index, ID: 9010, Level: 1, CostumeID: 90101, UseCostume: 0, ExpiryTime: expiry})
|
||||
s, e := player.OpenCharacterStore(m, chars, inv, "", "")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
f.deck.characters = s
|
||||
}
|
||||
install(uint64(time.Now().Add(time.Hour).UnixMilli()))
|
||||
d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9010: 10}, CharacterTemporaryPack: map[uint64]int{9010: 99}}
|
||||
f.deck.AttachFieldSettingsPack(func() (int, error) { return 4, nil })
|
||||
if e := f.deck.AttachFieldSettings(d); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(1, wire.AppendVarint(nil, 2, 9010))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(2, triple(1, index, 0))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(3, wire.AppendVarint(nil, 2, 1), wire.AppendVarint(nil, 3, index))); e == nil {
|
||||
t.Fatal("charm recovery caster accepted")
|
||||
}
|
||||
install(uint64(time.Now().Add(-time.Second).UnixMilli()))
|
||||
ids, e := f.deck.TalentCharacterIDs()
|
||||
if e != nil || ids[0] != 0 {
|
||||
t.Fatal("expired charm quick slot leaked", ids, e)
|
||||
}
|
||||
if len(f.deck.CurrentFieldDeck()) != 0 {
|
||||
t.Fatal("expired charm field party leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryFieldAndTalentSlotsFollowPack(t *testing.T) {
|
||||
f := newPresetFixture(t)
|
||||
c := player.Character{InvenIndex: player.StoryCharacterIndexBase + 99, ID: 9001, Level: 1, CostumeID: 90011, UseCostume: 9999}
|
||||
if e := f.characters.EnsureStoryCharacters([]player.Character{c}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
pack := 4
|
||||
d := &gamedata.FieldSettingsDesign{TalentSlots: 1, CharacterTalentClass: map[uint64]uint64{9001: 9}, CharacterTemporaryPack: map[uint64]int{9001: 4}}
|
||||
f.deck.AttachFieldSettingsPack(func() (int, error) { return pack, nil })
|
||||
if e := f.deck.AttachFieldSettings(d); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/TalentSlotSave", req(1, wire.AppendVarint(nil, 2, c.ID))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(2, triple(1, c.InvenIndex, c.UseCostume))); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
pack = 5
|
||||
ids, e := f.deck.TalentCharacterIDs()
|
||||
if e != nil || ids[0] != 0 {
|
||||
t.Fatal("temporary quick slot leaked", ids, e)
|
||||
}
|
||||
if len(f.deck.CurrentFieldDeck()) != 0 {
|
||||
t.Fatal("temporary field party leaked")
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(3, triple(1, c.InvenIndex, c.UseCostume))); e == nil {
|
||||
t.Fatal("foreign-pack temporary party accepted")
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4)); e != nil {
|
||||
t.Fatal("inactive quick slots blocked unrelated setting", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedSettingsDoNotPersist(t *testing.T) {
|
||||
f := newPresetFixture(t)
|
||||
d := &gamedata.FieldSettingsDesign{TalentSlots: 2, CharacterTalentClass: map[uint64]uint64{350: 10, 360: 9}}
|
||||
if e := f.deck.AttachFieldSettings(d); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, bad := range [][]byte{req(0, wire.AppendVarint(nil, 2, 350)), req(1, wire.AppendBytes(nil, 2, []byte{0x80})), req(2, wire.AppendFixed64(nil, 2, 350)), req(^uint64(0), wire.AppendVarint(nil, 2, 350)), req(3, wire.AppendVarint(nil, 2, ^uint64(0)), wire.AppendVarint(nil, 2, 0))} {
|
||||
if _, _, _, e := f.deck.Handle("/TalentSlotSave", bad); e == nil {
|
||||
t.Fatal("malformed talent request accepted")
|
||||
}
|
||||
if _, ok, e := f.storage.LoadEntry("deck", "field_settings", "state"); e != nil || ok {
|
||||
t.Fatal("invalid talent request wrote settings", e)
|
||||
}
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/CharAutoReviveSet", req(4, wire.AppendVarint(nil, 2, 2))); e == nil {
|
||||
t.Fatal("invalid protobuf bool accepted")
|
||||
}
|
||||
if _, _, _, e := f.deck.Handle("/FieldDeckSave", req(5, triple(2, 100, 1001))); e == nil {
|
||||
t.Fatal("field gap accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
package deck
|
||||
|
||||
import "bd2server/internal/server/player"
|
||||
|
||||
func (s *Store) CurrentFieldDeck() []FieldEntry {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return append([]FieldEntry(nil), s.state.FieldDeck...)
|
||||
return s.visibleFieldDeckLocked()
|
||||
}
|
||||
|
||||
func (s *Store) visibleFieldDeckLocked() []FieldEntry {
|
||||
var out []FieldEntry
|
||||
for _, entry := range s.state.FieldDeck {
|
||||
if s.characters != nil {
|
||||
c, ok := s.characters.Find(entry.CharacterInvenIndex)
|
||||
if !ok || player.IsStoryCharacter(c) && !s.temporaryAllowed(c) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
entry.Slot = uint64(len(out) + 1)
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetStoryParty stores the authored active quest formation using the same
|
||||
|
||||
@@ -26,7 +26,6 @@ var emptyResponses = map[string]int{
|
||||
"/CommunityRewardInfo": 289,
|
||||
"/DailyStoryInfo": 538,
|
||||
"/DatingInfo": 361,
|
||||
"/DispatchInfo": 0,
|
||||
"/EquipPresetInfo": 253,
|
||||
"/EvilCastleDailyRewardState": 441,
|
||||
"/EvilCastleTowerInfo": 200,
|
||||
|
||||
@@ -19,6 +19,23 @@ type RecipeService struct {
|
||||
items RecipeItems
|
||||
}
|
||||
|
||||
func (s *RecipeService) Knows(id uint64) bool {
|
||||
if !s.design.IDs[id] {
|
||||
return false
|
||||
}
|
||||
for _, known := range s.initial {
|
||||
if known == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, item := range s.items.All() {
|
||||
if item.Type == 7 && item.ID == id && item.Count > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewRecipeService(design *gamedata.CookingRecipeDesign, initial []uint64, items RecipeItems) (*RecipeService, error) {
|
||||
if design == nil || len(design.IDs) == 0 || items == nil {
|
||||
return nil, fmt.Errorf("recipes: missing design or inventory")
|
||||
|
||||
@@ -14,7 +14,6 @@ var standaloneDefaults = map[string]int{
|
||||
"/PvpBattleDeckInfo": 87,
|
||||
"/PvpBattleHistory": 97,
|
||||
"/PvpBattleOnceRewardInfo": 277,
|
||||
"/FieldDeckInfo": 273,
|
||||
"/FriendRecommend": 211,
|
||||
"/SupporterStatus": 438,
|
||||
"/SupporterBattleInfo": 439,
|
||||
@@ -24,10 +23,8 @@ var standaloneDefaults = map[string]int{
|
||||
// canonically empty. Domain persistence can be added without changing their
|
||||
// protocol contract. Core progress commands are handled by session first.
|
||||
var commandSuccess = map[string]int{
|
||||
"/UpdateAgeGate": 0,
|
||||
"/SaveFieldCharControlDeckType": 288,
|
||||
"/ActiveMap": 0,
|
||||
"/FieldDeckSave": 274,
|
||||
"/CostumeUse": 41,
|
||||
"/BattleExit": 388,
|
||||
"/UpdateAgeGate": 0,
|
||||
"/ActiveMap": 0,
|
||||
"/CostumeUse": 41,
|
||||
"/BattleExit": 388,
|
||||
}
|
||||
|
||||
@@ -125,7 +125,12 @@ func loadDispatchBox(db *sql.DB, id uint64, seen map[uint64]bool) (*dispatchGrou
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw); err != nil {
|
||||
return loadDispatchRewardGroup(db, gid, seen)
|
||||
}
|
||||
func loadDispatchRewardGroup(db *sql.DB, gid uint64, seen map[uint64]bool) (*dispatchGroup, error) {
|
||||
var raw []byte
|
||||
err := db.QueryRow("SELECT ProtoBuf FROM RewardGroupTable WHERE id=?", gid).Scan(&raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g := &dispatchGroup{}
|
||||
|
||||
@@ -205,7 +205,12 @@ func loadEquipmentCraftDesign(db *sql.DB) (*EquipmentCraftDesign, error) {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
need, _ := packedInts(raw, 6)
|
||||
need, err := packedInts(raw, 6)
|
||||
if err != nil || len(need) > 1 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: malformed equipment talent growth %d/%d", group, level)
|
||||
}
|
||||
design.TalentNeed[[2]uint64{group, level}] = 0
|
||||
if len(need) == 1 {
|
||||
design.TalentNeed[[2]uint64{group, level}] = need[0]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type FieldBuffDesign struct {
|
||||
ID, Type, TargetType uint64
|
||||
Value float64
|
||||
}
|
||||
|
||||
func optionalDouble(raw []byte, field int) (float64, error) {
|
||||
var value float64
|
||||
e := wire.Walk(raw, func(f wire.Field) error {
|
||||
if f.Number == field {
|
||||
if f.Type != 1 {
|
||||
return fmt.Errorf("gamedata: invalid double field")
|
||||
}
|
||||
value = math.Float64frombits(binary.LittleEndian.Uint64(f.Value))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return value, e
|
||||
}
|
||||
|
||||
func LoadFieldBuffDesign(root, version string) (map[uint64]FieldBuffDesign, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM FieldBuffTable ORDER BY id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uint64]FieldBuffDesign{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r := FieldBuffDesign{ID: id}
|
||||
for f, dst := range map[int]*uint64{6: &r.Type, 13: &r.TargetType} {
|
||||
v, x := optionalScalar(raw, f)
|
||||
if x != nil {
|
||||
return nil, x
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
v, x := optionalDouble(raw, 14)
|
||||
if x != nil {
|
||||
return nil, x
|
||||
}
|
||||
r.Value = v
|
||||
if id == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid field buff")
|
||||
}
|
||||
out[id] = r
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// LoadFieldMonsterMaps resolves actual scene placements, not optional map ids
|
||||
// guessed from the monster definition or arithmetic on its id.
|
||||
func LoadFieldMonsterMaps(root, version string, pack int) (map[int][]int, error) {
|
||||
common, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
maps := map[string]int{}
|
||||
rows, e := common.Query("SELECT id,mapScenePath FROM MapTable WHERE packId=?", pack)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var scene string
|
||||
if e = rows.Scan(&id, &scene); e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
maps[scene] = id
|
||||
}
|
||||
e = rows.Err()
|
||||
rows.Close()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
scenes, closeScenes, e := openLogicalReadDB(root, version, "FieldObjectSceneData")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer closeScenes()
|
||||
rows, e = scenes.Query(fmt.Sprintf("SELECT ProtoBuf FROM FieldObjectSceneData%d WHERE objectType=8", pack))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[int][]int{}
|
||||
for rows.Next() {
|
||||
var b []byte
|
||||
if e = rows.Scan(&b); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
id, e := optionalScalar(b, 8)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
scene, _, e := wire.Bytes(b, 7)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
mapID, ok := maps[string(scene)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: monster scene without map")
|
||||
}
|
||||
out[int(id)] = append(out[int(id)], mapID)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -9,9 +9,12 @@ import (
|
||||
// FieldMonsterDesign keeps the regeneration group requested by MonsterInfo,
|
||||
// not the unrelated event monster group used by FieldEventSpawnReward.
|
||||
type FieldMonsterDesign struct {
|
||||
ID, GroupID, QuestID int
|
||||
BattleDeck uint64
|
||||
LifeSeconds uint64
|
||||
ID, GroupID, QuestID int
|
||||
BattleDeck uint64
|
||||
BattleDecks []uint64
|
||||
LifeSeconds uint64
|
||||
RegenSeconds, ResetType, Type, FieldBuff, UseBattleSkip uint64
|
||||
Reward Reward
|
||||
}
|
||||
|
||||
func LoadFieldMonsters(root, version string, pack int) ([]FieldMonsterDesign, error) {
|
||||
@@ -43,6 +46,17 @@ func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) {
|
||||
return nil, fmt.Errorf("gamedata: malformed monster regeneration %d", id)
|
||||
}
|
||||
g := FieldMonsterDesign{GroupID: id}
|
||||
for f, dst := range map[int]*uint64{7: &g.RegenSeconds, 9: &g.ResetType} {
|
||||
v, e := optionalScalar(raw, f)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if g.ResetType > 2 {
|
||||
return nil, fmt.Errorf("gamedata: unknown monster reset type")
|
||||
}
|
||||
if len(q) > 0 {
|
||||
g.QuestID = int(q[0])
|
||||
}
|
||||
@@ -77,17 +91,26 @@ func loadFieldMonsters(db *sql.DB) ([]FieldMonsterDesign, error) {
|
||||
if e1 != nil || e2 != nil || len(group) > 1 {
|
||||
return nil, fmt.Errorf("gamedata: malformed field monster %d", id)
|
||||
}
|
||||
if len(group) == 0 || group[0] == 0 {
|
||||
continue
|
||||
}
|
||||
g, ok := groups[int(group[0])]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: monster %d references missing regeneration %d", id, group[0])
|
||||
g := FieldMonsterDesign{}
|
||||
if len(group) > 0 && group[0] > 0 {
|
||||
var ok bool
|
||||
g, ok = groups[int(group[0])]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("gamedata: monster %d references missing regeneration %d", id, group[0])
|
||||
}
|
||||
}
|
||||
g.ID = id
|
||||
for f, dst := range map[int]*uint64{30: &g.Type, 6: &g.FieldBuff, 31: &g.UseBattleSkip, 27: &g.Reward.Count, 28: &g.Reward.ID, 29: &g.Reward.Type} {
|
||||
v, e := optionalScalar(raw, f)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
*dst = v
|
||||
}
|
||||
if len(decks) > 0 {
|
||||
g.BattleDeck = decks[0]
|
||||
}
|
||||
g.BattleDecks = append([]uint64(nil), decks...)
|
||||
result = append(result, g)
|
||||
}
|
||||
return result, rows.Err()
|
||||
|
||||
@@ -3,9 +3,41 @@ package gamedata
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstalledOverwhelmCatalogIncludesScriptedAndRegeneratingMonsters(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("BD2_REAL_GAMEDATA not configured")
|
||||
}
|
||||
rows, e := LoadFieldMonsters(root, "20260923193640", 1)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
script, regen := 0, 0
|
||||
for _, m := range rows {
|
||||
if m.GroupID == 0 {
|
||||
script++
|
||||
} else {
|
||||
regen++
|
||||
}
|
||||
}
|
||||
if script == 0 || regen == 0 {
|
||||
t.Fatal("catalog lost scripted or regenerating monsters", script, regen)
|
||||
}
|
||||
rules, e := LoadSkyWayOverwhelm(root, "20260923193640")
|
||||
if e != nil || len(rules) == 0 {
|
||||
t.Fatal("skyway catalog unavailable", e)
|
||||
}
|
||||
rule, e := LoadOverwhelmQuest(root, "20260923193640", 1, 1)
|
||||
if e != nil || rule.Enemies == nil {
|
||||
t.Fatal("quest battle rule catalog unavailable", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldMonsterCatalogJoinsRegenerationAndRejectsBrokenReference(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
@@ -26,7 +58,7 @@ func TestFieldMonsterCatalogJoinsRegenerationAndRejectsBrokenReference(t *testin
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := loadFieldMonsters(db)
|
||||
if err != nil || len(rows) != 1 || rows[0] != (FieldMonsterDesign{ID: 3, GroupID: 17, QuestID: 91, BattleDeck: 78, LifeSeconds: 30}) {
|
||||
if err != nil || len(rows) != 1 || !reflect.DeepEqual(rows[0], FieldMonsterDesign{ID: 3, GroupID: 17, QuestID: 91, BattleDeck: 78, LifeSeconds: 30, BattleDecks: []uint64{78}}) {
|
||||
t.Fatalf("catalog %+v: %v", rows, err)
|
||||
}
|
||||
if _, err = db.Exec("UPDATE FieldMonsterTable SET ProtoBuf=?", wire.AppendVarint(nil, 24, 18)); err != nil {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package gamedata
|
||||
|
||||
import "fmt"
|
||||
|
||||
type FieldSettingsDesign struct {
|
||||
TalentSlots int
|
||||
CharacterTalentClass map[uint64]uint64
|
||||
CharacterTemporaryPack map[uint64]int
|
||||
}
|
||||
|
||||
func LoadFieldSettingsDesign(root, version string) (*FieldSettingsDesign, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
var raw []byte
|
||||
if e = db.QueryRow("SELECT ProtoBuf FROM GameDefaultTable WHERE id=0").Scan(&raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
n, e := optionalScalar(raw, 109)
|
||||
if e != nil || n == 0 || n > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: invalid talent slot capacity")
|
||||
}
|
||||
d := &FieldSettingsDesign{TalentSlots: int(n), CharacterTalentClass: map[uint64]uint64{}, CharacterTemporaryPack: map[uint64]int{}}
|
||||
classes := map[uint64]uint64{}
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if id == 0 || id > 2147483647 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid talent identity")
|
||||
}
|
||||
classes[id], e = optionalScalar(raw, 4)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
if e = rows.Err(); e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
rows.Close()
|
||||
rows, e = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if id == 0 || id > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: invalid character identity")
|
||||
}
|
||||
talent, e := optionalScalar(raw, 18)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if class, ok := classes[talent]; ok {
|
||||
d.CharacterTalentClass[id] = class
|
||||
}
|
||||
pack, e := optionalScalar(raw, 21)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if pack > 2147483647 {
|
||||
return nil, fmt.Errorf("gamedata: temporary pack overflows protocol")
|
||||
}
|
||||
if pack != 0 {
|
||||
d.CharacterTemporaryPack[id] = int(pack)
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstalledFieldSettingsCapacityAndDispatchReset(t *testing.T) {
|
||||
root := "../../../../data/resources/GameData"
|
||||
if _, e := os.Stat(root); e != nil {
|
||||
t.Skip("installed data missing")
|
||||
}
|
||||
s, e := LoadFieldSettingsDesign(root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if s.TalentSlots != 5 || len(s.CharacterTalentClass) == 0 || len(s.CharacterTemporaryPack) == 0 {
|
||||
t.Fatalf("unexpected field settings design %+v", s)
|
||||
}
|
||||
shops, e := LoadNPCShopDesign(root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(shops.ShopNPCs[1]) != 1 || shops.ShopNPCs[1][0] != 4 {
|
||||
t.Fatal("pack1 interaction-3 binding changed", shops.ShopNPCs[1])
|
||||
}
|
||||
d, e := LoadTalentDispatchDesign(root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, row := range d {
|
||||
if row.Seconds != 79200 {
|
||||
t.Fatal("changed dispatch slider reference", row.ID, row.Seconds)
|
||||
}
|
||||
for _, now := range []time.Time{time.Date(2026, 10, 5, 23, 59, 59, 0, time.UTC), time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)} {
|
||||
end := row.EndTime(now)
|
||||
if !end.After(now) || end.Hour() != 0 || end.Minute() != 0 || end.Second() != 0 {
|
||||
t.Fatalf("invalid reset deadline %s %s", now, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type ItemCraftRecipe struct {
|
||||
ID, Class, TalentLevel, Category uint64
|
||||
Result BattleReward
|
||||
Costs []PromotionCost
|
||||
}
|
||||
type ItemCraftDesign struct{ Cooking, Alchemy map[uint64]ItemCraftRecipe }
|
||||
|
||||
func LoadItemCraftDesign(root, version string) (*ItemCraftDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
return loadItemCraftDesign(db)
|
||||
}
|
||||
func loadItemCraftDesign(db *sql.DB) (*ItemCraftDesign, error) {
|
||||
d := &ItemCraftDesign{Cooking: map[uint64]ItemCraftRecipe{}, Alchemy: map[uint64]ItemCraftRecipe{}}
|
||||
for _, name := range []string{"CookingTable", "AlchemyTable"} {
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM " + name + " ORDER BY id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
r := ItemCraftRecipe{ID: id, Class: 8}
|
||||
if name == "AlchemyTable" {
|
||||
r.Category, err = optionalScalar(raw, 1)
|
||||
if err != nil || (r.Category != 1 && r.Category != 2) {
|
||||
return nil, fmt.Errorf("gamedata: invalid alchemy category %d", id)
|
||||
}
|
||||
}
|
||||
countField, idField, typeField, levelField, materialCount, materialID, materialType := 9, 10, 11, 12, 5, 6, 7
|
||||
if name == "CookingTable" {
|
||||
r.Class = 7
|
||||
countField, idField, typeField, levelField, materialCount, materialID, materialType = 12, 13, 0, 15, 7, 8, 0
|
||||
r.Result.Type = 5
|
||||
}
|
||||
for f, p := range map[int]*uint64{countField: &r.Result.Count, idField: &r.Result.ID, levelField: &r.TalentLevel} {
|
||||
*p, err = optionalScalar(raw, f)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if typeField != 0 {
|
||||
r.Result.Type, err = optionalScalar(raw, typeField)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ns, e1 := packedInts(raw, materialCount)
|
||||
ids, e2 := packedInts(raw, materialID)
|
||||
var ts []uint64
|
||||
var e3 error
|
||||
if materialType != 0 {
|
||||
ts, e3 = packedInts(raw, materialType)
|
||||
} else {
|
||||
for range ids {
|
||||
ts = append(ts, 5)
|
||||
}
|
||||
}
|
||||
if e1 != nil || e2 != nil || e3 != nil || len(ns) == 0 || len(ns) != len(ids) || len(ts) != len(ids) || id == 0 || id > math.MaxInt32 || r.Result.ID == 0 || r.Result.Count == 0 || r.Result.Count > math.MaxInt32 || r.TalentLevel == 0 {
|
||||
return nil, fmt.Errorf("gamedata: malformed %s recipe %d", name, id)
|
||||
}
|
||||
for i, n := range ns {
|
||||
if n == 0 || n > math.MaxInt32 || ids[i] == 0 || ids[i] > math.MaxInt32 || ts[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid recipe materials")
|
||||
}
|
||||
r.Costs = append(r.Costs, PromotionCost{Type: ts[i], ID: ids[i], Count: n})
|
||||
}
|
||||
if r.Class == 7 {
|
||||
d.Cooking[id] = r
|
||||
} else {
|
||||
d.Alchemy[id] = r
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// CraftTalent validates the producer and recipe against the same talent and
|
||||
// growth catalog used by ordinary field skills. Recipe level sets cost; the
|
||||
// producer's current level sets experience, as in the client craft UI.
|
||||
func (d *TalentUseDesign) CraftTalent(characterID, level, class, recipeLevel, count, experience uint64) (gain, catalyst, maximum uint64, err error) {
|
||||
c, ok := d.Characters[characterID]
|
||||
if !ok || level == 0 || level > c.MaxLevel || recipeLevel == 0 || level < recipeLevel || count == 0 || count > math.MaxInt32 {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: unavailable crafting talent")
|
||||
}
|
||||
current, ok := d.Rules[[2]uint64{c.Group, level}]
|
||||
if !ok || current.Class != class {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: wrong crafting talent class")
|
||||
}
|
||||
recipe, ok := d.Rules[[2]uint64{c.Group, recipeLevel}]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing recipe talent level")
|
||||
}
|
||||
if d.Growth == nil {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing talent growth")
|
||||
}
|
||||
g, ok := d.Growth.Characters[characterID]
|
||||
if !ok {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing craft growth group")
|
||||
}
|
||||
for l := uint64(1); l <= level; l++ {
|
||||
entry, exists := d.Growth.Levels[[2]uint64{g.GrowthGroup, l}]
|
||||
if !exists {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: missing growth level")
|
||||
}
|
||||
if entry.NeedExp > math.MaxUint64-maximum {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: craft growth overflow")
|
||||
}
|
||||
maximum += entry.NeedExp
|
||||
}
|
||||
if recipe.Catalyst > math.MaxInt32/count || current.Experience > math.MaxInt32/count {
|
||||
return 0, 0, 0, fmt.Errorf("gamedata: crafting quantity overflow")
|
||||
}
|
||||
gain = current.Experience * count
|
||||
if experience >= maximum {
|
||||
gain = 0
|
||||
} else if gain > maximum-experience {
|
||||
gain = maximum - experience
|
||||
}
|
||||
return gain, recipe.Catalyst * count, maximum, nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package gamedata
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// NPCShopDesign is the ordinary in-game shop, distinct from CashShopTable.
|
||||
@@ -10,6 +11,7 @@ type NPCShopDesign struct {
|
||||
Shops map[uint64]NPCShop
|
||||
Products map[uint64]map[uint64]NPCProduct
|
||||
Sell map[uint64]NPCProduct
|
||||
ShopNPCs map[uint64][]uint64
|
||||
}
|
||||
type NPCShop struct{ ID, PackID, ResetType, ResetCount, StartDay uint64 }
|
||||
type NPCProduct struct {
|
||||
@@ -24,7 +26,74 @@ func LoadNPCShopDesign(root, version string) (NPCShopDesign, error) {
|
||||
return NPCShopDesign{}, err
|
||||
}
|
||||
defer closeDB()
|
||||
return loadNPCShopDesign(db)
|
||||
d, e := loadNPCShopDesign(db)
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
d.ShopNPCs = map[uint64][]uint64{}
|
||||
packs := map[uint64]bool{}
|
||||
for _, shop := range d.Shops {
|
||||
packs[shop.PackID] = true
|
||||
}
|
||||
ids := []uint64{}
|
||||
for pack := range packs {
|
||||
ids = append(ids, pack)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for _, pack := range ids {
|
||||
bindings, e := LoadNPCShopActors(root, version, int(pack))
|
||||
if e != nil {
|
||||
return d, e
|
||||
}
|
||||
for shop, actors := range bindings {
|
||||
if def, ok := d.Shops[shop]; ok && def.PackID == pack {
|
||||
d.ShopNPCs[shop] = actors
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// NPCInfo pairs FieldNpcTable.InteractionList (9) and InteractionValue (12).
|
||||
// EInteractionType.Shop=3 identifies the real shop ID, rather than deriving an
|
||||
// NPC identity from shop numbering or using one global bargaining discount.
|
||||
func LoadNPCShopActors(root, version string, pack int) (map[uint64][]uint64, error) {
|
||||
db, done, e := openPackDatabase(root, version, pack)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM FieldNpcTable ORDER BY id")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[uint64][]uint64{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
ts, e := packedInts(raw, 9)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
vs, e := packedInts(raw, 12)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for i, t := range ts {
|
||||
if t != 3 {
|
||||
continue
|
||||
}
|
||||
if i >= len(vs) || vs[i] == 0 {
|
||||
return nil, fmt.Errorf("gamedata: invalid NPC shop interaction")
|
||||
}
|
||||
out[vs[i]] = append(out[vs[i]], id)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func loadNPCShopDesign(db *sql.DB) (NPCShopDesign, error) {
|
||||
d := NPCShopDesign{Shops: map[uint64]NPCShop{}, Products: map[uint64]map[uint64]NPCProduct{}, Sell: map[uint64]NPCProduct{}}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SkyWayOverwhelmRule struct {
|
||||
Group, Map, APType, Boss, BossAP uint64
|
||||
Monsters, AP []uint64
|
||||
}
|
||||
|
||||
func LoadSkyWayOverwhelm(root, version string) ([]SkyWayOverwhelmRule, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT ProtoBuf FROM SkyWayFieldTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []SkyWayOverwhelmRule
|
||||
for rows.Next() {
|
||||
var b []byte
|
||||
if e = rows.Scan(&b); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r := SkyWayOverwhelmRule{}
|
||||
for f, p := range map[int]*uint64{10: &r.Group, 13: &r.Map, 1: &r.APType, 4: &r.Boss, 3: &r.BossAP} {
|
||||
*p, e = optionalScalar(b, f)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
r.Monsters, e = packedInts(b, 15)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r.AP, e = packedInts(b, 14)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(r.Monsters) != len(r.AP) {
|
||||
return nil, fmt.Errorf("gamedata: malformed skyway AP mapping")
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type OverwhelmQuestRule struct {
|
||||
Type, Count uint64
|
||||
Targets []uint64
|
||||
Enemies map[uint64][]uint64
|
||||
}
|
||||
|
||||
func LoadOverwhelmQuest(root, version string, pack, quest int) (OverwhelmQuestRule, error) {
|
||||
r := OverwhelmQuestRule{Enemies: map[uint64][]uint64{}}
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
defer done()
|
||||
var b []byte
|
||||
if e = db.QueryRow(fmt.Sprintf("SELECT ProtoBuf FROM QuestTable%d WHERE id=?", pack), quest).Scan(&b); e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Type, e = optionalScalar(b, 11)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Count, e = optionalScalar(b, 10)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Targets, e = packedInts(b, 33)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
packdb, release, e := openPackDatabase(root, version, pack)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
defer release()
|
||||
rows, e := packdb.Query("SELECT id,ProtoBuf FROM BattleDeckTable")
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var raw []byte
|
||||
if e = rows.Scan(&id, &raw); e != nil {
|
||||
return r, e
|
||||
}
|
||||
ids, e := packedInts(raw, 14)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
r.Enemies[id] = ids
|
||||
}
|
||||
return r, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package gamedata
|
||||
|
||||
// PackRecoveryPolicy keeps the actual PackTable type, including packs outside
|
||||
// the currently playable story catalog.
|
||||
type PackRecoveryPolicy struct{ Types map[int]uint64 }
|
||||
|
||||
func LoadPackRecoveryPolicy(root, version string) (*PackRecoveryPolicy, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM PackTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
p := &PackRecoveryPolicy{Types: map[int]uint64{}}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var raw []byte
|
||||
if err = rows.Scan(&id, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typ, e := optionalScalar(raw, 55)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p.Types[id] = typ
|
||||
}
|
||||
return p, rows.Err()
|
||||
}
|
||||
func (p *PackRecoveryPolicy) Allowed(pack int, complete bool) bool {
|
||||
typ, ok := p.Types[pack]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch typ {
|
||||
case 4:
|
||||
return false
|
||||
case 1, 6:
|
||||
return complete
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package gamedata
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPackRecoveryUsesPackTypesAndCompletion(t *testing.T) {
|
||||
p := &PackRecoveryPolicy{Types: map[int]uint64{99: 0, 101: 1, 303: 4, 909: 6, 707: 1000}}
|
||||
for _, v := range []struct {
|
||||
pack int
|
||||
complete, want bool
|
||||
}{{99, false, true}, {101, false, false}, {101, true, true}, {303, true, false}, {909, false, false}, {909, true, true}, {707, false, true}, {123, true, false}} {
|
||||
if got := p.Allowed(v.pack, v.complete); got != v.want {
|
||||
t.Fatalf("pack%d complete%v got%v", v.pack, v.complete, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TalentDispatchDesign struct {
|
||||
ID, Level, Seconds, RewardGroup uint64
|
||||
Prefab string
|
||||
Reset FieldResetSchedule
|
||||
reward *DispatchDesign
|
||||
}
|
||||
|
||||
func (d TalentDispatchDesign) Roll(draw func(uint64) (uint64, error)) ([]BattleReward, error) {
|
||||
return d.reward.Roll(1, draw)
|
||||
}
|
||||
|
||||
// Current skill descriptions 27151-27155/27176-27180 say dispatch returns at
|
||||
// 09:00. DispatchTime is still used by the client's progress slider; completion
|
||||
// follows GameDefault's reset clock rather than start + that legacy duration.
|
||||
func (d TalentDispatchDesign) EndTime(now time.Time) time.Time {
|
||||
offset := 9*time.Hour - d.Reset.DailyReset
|
||||
shifted := now.UTC().Add(offset)
|
||||
return time.Date(shifted.Year(), shifted.Month(), shifted.Day()+1, 0, 0, 0, 0, time.UTC).Add(-offset)
|
||||
}
|
||||
func LoadTalentDispatchDesign(root, version string) (map[uint64]TalentDispatchDesign, error) {
|
||||
reset, e := LoadFieldResetSchedule(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
rows, e := db.Query("SELECT id,ProtoBuf FROM DispatchTable")
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
type row struct {
|
||||
id uint64
|
||||
raw []byte
|
||||
}
|
||||
var rs []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if e = rows.Scan(&r.id, &r.raw); e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
rs = append(rs, r)
|
||||
}
|
||||
if e = rows.Err(); e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
rows.Close()
|
||||
out := map[uint64]TalentDispatchDesign{}
|
||||
for _, r := range rs {
|
||||
d := TalentDispatchDesign{ID: r.id, Reset: reset}
|
||||
d.Level, e = optionalScalar(r.raw, 2)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d.Seconds, e = optionalScalar(r.raw, 4)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d.RewardGroup, e = optionalScalar(r.raw, 8)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
p, _, e := wire.Bytes(r.raw, 7)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d.Prefab = string(p)
|
||||
if d.ID == 0 || d.Seconds == 0 || d.Prefab == "" {
|
||||
return nil, fmt.Errorf("gamedata: invalid talent dispatch")
|
||||
}
|
||||
g, e := loadDispatchRewardGroup(db, d.RewardGroup, map[uint64]bool{})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
d.reward = &DispatchDesign{Rewards: []BattleReward{{Type: 9, ID: d.RewardGroup, Count: 1}}, boxes: map[uint64]*dispatchGroup{d.RewardGroup: g}}
|
||||
out[d.ID] = d
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
type TalentUseRule struct {
|
||||
Group, Level, Class, Catalyst, Experience, Reset, Target, Reputation uint64
|
||||
Values []float64
|
||||
}
|
||||
type TalentUseCharacter struct {
|
||||
Group, MaxLevel uint64
|
||||
BannedPacks map[int]bool
|
||||
}
|
||||
type TalentNPC struct {
|
||||
ID, MapID uint64
|
||||
Groups, Rewards, CharmCharacters []uint64
|
||||
}
|
||||
type TalentUseDesign struct {
|
||||
Characters map[uint64]TalentUseCharacter
|
||||
Rules map[[2]uint64]TalentUseRule
|
||||
Rewards map[uint64][]Reward
|
||||
Growth *TalentGrowthDesign
|
||||
NPCs func(int) (map[uint64]TalentNPC, error)
|
||||
Foods map[uint64]uint64
|
||||
ResetSchedule FieldResetSchedule
|
||||
CharmCharacter func(uint64) (StoryCharacterDesign, error)
|
||||
}
|
||||
|
||||
func LoadTalentUseDesign(root, version string) (*TalentUseDesign, error) {
|
||||
db, done, err := openStatDatabase(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer done()
|
||||
d, err := loadTalentUseDesign(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Growth, err = loadTalentGrowthDesign(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.ResetSchedule, err = LoadFieldResetSchedule(root, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.NPCs = func(pack int) (map[uint64]TalentNPC, error) {
|
||||
db, done, e := openPackDatabase(root, version, pack)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer done()
|
||||
return loadTalentNPCs(db)
|
||||
}
|
||||
d.CharmCharacter = func(id uint64) (StoryCharacterDesign, error) {
|
||||
db, done, e := openStatDatabase(root, version)
|
||||
if e != nil {
|
||||
return StoryCharacterDesign{}, e
|
||||
}
|
||||
defer done()
|
||||
var raw []byte
|
||||
if e = db.QueryRow("SELECT ProtoBuf FROM CharTable WHERE id=?", id).Scan(&raw); e != nil {
|
||||
return StoryCharacterDesign{}, e
|
||||
}
|
||||
costume, e := requiredScalar(raw, 5)
|
||||
if e != nil {
|
||||
return StoryCharacterDesign{}, e
|
||||
}
|
||||
base, e := CharacterBaseStats(root, version, int(id), 1)
|
||||
if e != nil {
|
||||
return StoryCharacterDesign{}, e
|
||||
}
|
||||
return StoryCharacterDesign{CharacterID: id, Level: 1, CostumeID: costume, HP: uint64(base.Health)}, nil
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
func loadTalentUseDesign(db *sql.DB) (*TalentUseDesign, error) {
|
||||
d := &TalentUseDesign{Characters: map[uint64]TalentUseCharacter{}, Rules: map[[2]uint64]TalentUseRule{}, Rewards: map[uint64][]Reward{}}
|
||||
d.Foods = map[uint64]uint64{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM TalentTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
talents := map[uint64]TalentUseCharacter{}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var b []byte
|
||||
if err = rows.Scan(&id, &b); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
g, e := optionalScalar(b, 18)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
max, e := optionalScalar(b, 11)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
bans, e := packedInts(b, 1)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
v := TalentUseCharacter{Group: g, MaxLevel: max, BannedPacks: map[int]bool{}}
|
||||
for _, p := range bans {
|
||||
v.BannedPacks[int(p)] = true
|
||||
}
|
||||
talents[id] = v
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM CharTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var b []byte
|
||||
if err = rows.Scan(&id, &b); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
t, e := optionalScalar(b, 18)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if t > 0 {
|
||||
v, ok := talents[t]
|
||||
if !ok {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: unknown character talent %d", t)
|
||||
}
|
||||
d.Characters[id] = v
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = db.Query("SELECT groupId,id,ProtoBuf FROM TalentSkillTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var r TalentUseRule
|
||||
var b []byte
|
||||
if err = rows.Scan(&r.Group, &r.Level, &b); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
for f, p := range map[int]*uint64{1: &r.Catalyst, 2: &r.Class, 5: &r.Experience, 8: &r.Reset, 12: &r.Reputation, 13: &r.Target} {
|
||||
*p, err = optionalScalar(b, f)
|
||||
if err != nil || *p > math.MaxInt32 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid talent skill scalar")
|
||||
}
|
||||
}
|
||||
err = wire.Walk(b, func(f wire.Field) error {
|
||||
if f.Number != 14 {
|
||||
return nil
|
||||
}
|
||||
if f.Type == 5 {
|
||||
v := float64(math.Float32frombits(binary.LittleEndian.Uint32(f.Value)))
|
||||
r.Values = append(r.Values, v)
|
||||
} else if f.Type == 2 && len(f.Value)%4 == 0 {
|
||||
for raw := f.Value; len(raw) > 0; raw = raw[4:] {
|
||||
r.Values = append(r.Values, float64(math.Float32frombits(binary.LittleEndian.Uint32(raw))))
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("gamedata: malformed talent values")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range r.Values {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 || v > math.MaxInt32 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid talent value")
|
||||
}
|
||||
}
|
||||
if r.Group == 0 || r.Level == 0 || r.Class == 0 || r.Class > 20 {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("gamedata: invalid talent identity")
|
||||
}
|
||||
d.Rules[[2]uint64{r.Group, r.Level}] = r
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM TalentRewardTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var b []byte
|
||||
if err = rows.Scan(&id, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewards, e := monsterHuntRewardArrays(b, 4, 3, 2)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, r := range rewards {
|
||||
d.Rewards[id] = append(d.Rewards[id], Reward{r.Type, r.ID, r.Count})
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
buffs := map[uint64]uint64{}
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM FoodBuffTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var b []byte
|
||||
if err = rows.Scan(&id, &b); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
t, e := optionalScalar(b, 2)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
g, e := optionalScalar(b, 3)
|
||||
if e != nil {
|
||||
rows.Close()
|
||||
return nil, e
|
||||
}
|
||||
if t == 1 {
|
||||
buffs[id] = g
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
rows.Close()
|
||||
rows, err = db.Query("SELECT id,ProtoBuf FROM FoodTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id uint64
|
||||
var b []byte
|
||||
if err = rows.Scan(&id, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buff, e := optionalScalar(b, 3)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if g, ok := buffs[buff]; ok {
|
||||
d.Foods[id] = g
|
||||
}
|
||||
}
|
||||
return d, rows.Err()
|
||||
}
|
||||
func loadTalentNPCs(db *sql.DB) (map[uint64]TalentNPC, error) {
|
||||
result := map[uint64]TalentNPC{}
|
||||
rows, err := db.Query("SELECT id,ProtoBuf FROM FieldNpcTable")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var v TalentNPC
|
||||
var b []byte
|
||||
if err = rows.Scan(&v.ID, &b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.MapID, err = optionalScalar(b, 14)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Groups, err = packedInts(b, 28)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Rewards, err = packedInts(b, 29)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.CharmCharacters, err = packedInts(b, 26)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(v.Groups) != len(v.Rewards) {
|
||||
return nil, fmt.Errorf("gamedata: mismatched NPC talent rewards")
|
||||
}
|
||||
result[v.ID] = v
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package gamedata
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTalentUseLoaderJoinsCharacterLevelRewardsAndFood(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for _, table := range []string{"TalentTable", "CharTable", "TalentRewardTable", "FoodBuffTable", "FoodTable"} {
|
||||
if _, err = db.Exec("CREATE TABLE " + table + "(id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err = db.Exec("CREATE TABLE TalentSkillTable(groupId INTEGER,id INTEGER,ProtoBuf BLOB)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
talent := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 18, 42), 11, 5), 1, 22)
|
||||
for _, v := range []struct {
|
||||
table string
|
||||
id uint64
|
||||
b []byte
|
||||
}{{"TalentTable", 8, talent}, {"CharTable", 350, wire.AppendVarint(nil, 18, 8)}, {"TalentRewardTable", 9, wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 2, 3), 3, 11), 4, 8)}, {"FoodBuffTable", 7, wire.AppendVarint(wire.AppendVarint(nil, 2, 1), 3, 42)}, {"FoodTable", 22, wire.AppendVarint(nil, 3, 7)}} {
|
||||
if _, err = db.Exec("INSERT INTO "+v.table+" VALUES(?,?)", v.id, v.b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var values []byte
|
||||
values = binary.LittleEndian.AppendUint32(values, math.Float32bits(0.75))
|
||||
values = binary.LittleEndian.AppendUint32(values, math.Float32bits(5))
|
||||
skill := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 10), 2, 13), 5, 2)
|
||||
skill = wire.AppendBytes(skill, 14, values)
|
||||
if _, err = db.Exec("INSERT INTO TalentSkillTable VALUES(?,?,?)", 42, 1, skill); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, err := loadTalentUseDesign(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := d.Characters[350]
|
||||
r := d.Rules[[2]uint64{42, 1}]
|
||||
if meta.Group != 42 || !meta.BannedPacks[22] || r.Class != 13 || r.Catalyst != 10 || r.Values[0] != 0.75 || r.Values[1] != 5 || d.Foods[22] != 42 || d.Rewards[9][0].Count != 3 {
|
||||
t.Fatal("talent loader lost static rule joins")
|
||||
}
|
||||
if _, err = db.Exec("UPDATE TalentSkillTable SET ProtoBuf=?", wire.AppendBytes(wire.AppendVarint(nil, 2, 13), 14, []byte{1, 2, 3})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = loadTalentUseDesign(db); err == nil {
|
||||
t.Fatal("malformed float rule accepted")
|
||||
}
|
||||
}
|
||||
@@ -34,15 +34,16 @@ type snapshot struct {
|
||||
Receipts map[string]receipt `json:"receipts"`
|
||||
}
|
||||
type Service struct {
|
||||
mu sync.Mutex
|
||||
design gamedata.NPCShopDesign
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
items *player.Inventory
|
||||
available func(uint64) bool
|
||||
now func() time.Time
|
||||
session func() string
|
||||
reputation func(uint64) (uint64, uint64, error)
|
||||
mu sync.Mutex
|
||||
design gamedata.NPCShopDesign
|
||||
store stateio.Store
|
||||
economy Economy
|
||||
items *player.Inventory
|
||||
available func(uint64) bool
|
||||
now func() time.Time
|
||||
session func() string
|
||||
reputation func(uint64) (uint64, uint64, error)
|
||||
talentDiscount func(uint64, uint64) (uint64, error)
|
||||
}
|
||||
|
||||
func New(d gamedata.NPCShopDesign, store stateio.Store, e Economy, items *player.Inventory, available func(uint64) bool) (*Service, error) {
|
||||
@@ -65,6 +66,28 @@ func (s *Service) BeginSession(id string) {
|
||||
func (s *Service) SetReputationSource(source func(uint64) (uint64, uint64, error)) {
|
||||
s.reputation = source
|
||||
}
|
||||
func (s *Service) SetTalentDiscountSource(source func(uint64, uint64) (uint64, error)) {
|
||||
s.talentDiscount = source
|
||||
}
|
||||
func (s *Service) talentDiscountFor(shop gamedata.NPCShop) (uint64, error) {
|
||||
if s.talentDiscount == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var best uint64
|
||||
for _, npc := range s.design.ShopNPCs[shop.ID] {
|
||||
n, e := s.talentDiscount(shop.PackID, npc)
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
if n > 100 {
|
||||
return 0, fmt.Errorf("npcshop: invalid talent discount")
|
||||
}
|
||||
if n > best {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
func (s *Service) reputationFor(pack uint64) (uint64, uint64, error) {
|
||||
if s.reputation != nil {
|
||||
return s.reputation(pack)
|
||||
@@ -191,7 +214,15 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
if path == "/ShopOpen" {
|
||||
var b []byte
|
||||
packs := map[uint64]bool{}
|
||||
isTalent := false
|
||||
for _, r := range s.design.Shops {
|
||||
if s.available(r.PackID) {
|
||||
discount, e := s.talentDiscountFor(r)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
isTalent = isTalent || discount > 0
|
||||
}
|
||||
if !packs[r.PackID] && s.available(r.PackID) {
|
||||
packs[r.PackID] = true
|
||||
state, _, err := s.reputationFor(r.PackID)
|
||||
@@ -203,6 +234,9 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
b = wire.AppendBytes(b, 1, p)
|
||||
}
|
||||
}
|
||||
if isTalent {
|
||||
b = wire.AppendVarint(b, 2, 1)
|
||||
}
|
||||
return code, b, true, nil
|
||||
}
|
||||
if path == "/ShopReputationInfo" {
|
||||
@@ -294,6 +328,10 @@ func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedat
|
||||
if state == 0 || discount > 100 {
|
||||
return nil, nil, nil, fmt.Errorf("npcshop: unavailable reputation")
|
||||
}
|
||||
talentDiscount, e := s.talentDiscountFor(r)
|
||||
if e != nil {
|
||||
return nil, nil, nil, e
|
||||
}
|
||||
rows, e := messages(g, 2)
|
||||
if e != nil || len(rows) == 0 {
|
||||
return nil, nil, nil, fmt.Errorf("npcshop: empty product list")
|
||||
@@ -321,7 +359,14 @@ func (s *Service) buy(request []byte, v *snapshot) ([]gamedata.Reward, []gamedat
|
||||
return nil, nil, nil, fmt.Errorf("npcshop: sold out product %d", id)
|
||||
}
|
||||
price := uint64(float32(p.Price.Count*expected) / 100)
|
||||
if discount > 0 {
|
||||
if talentDiscount > 0 {
|
||||
if p.NoBargain == 1 {
|
||||
return nil, nil, nil, fmt.Errorf("npcshop: product excluded during bargaining")
|
||||
}
|
||||
// ShopUI.RefreshProductsPrice uses the base price for talent
|
||||
// bargaining, replacing both market variation and reputation.
|
||||
price = uint64(float32(p.Price.Count) * (float32(100-talentDiscount) / 100))
|
||||
} else if discount > 0 {
|
||||
price = uint64(float32(price*(100-discount)) / 100)
|
||||
}
|
||||
if price > math.MaxInt32/n || p.Reward.Count > math.MaxInt32/n {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package npcshop
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTalentBargainingUsesBoundNPCBasePriceAndSuppressesReputation(t *testing.T) {
|
||||
s, e, _ := fixture(t)
|
||||
s.design.ShopNPCs = map[uint64][]uint64{71: {8801}}
|
||||
calls := 0
|
||||
s.SetTalentDiscountSource(func(pack, npc uint64) (uint64, error) {
|
||||
if pack != 91 || npc != 8801 {
|
||||
t.Fatal("wrong NPC binding", pack, npc)
|
||||
}
|
||||
calls++
|
||||
return 20, nil
|
||||
})
|
||||
s.SetReputationSource(func(uint64) (uint64, uint64, error) { return 2, 10, nil })
|
||||
p := s.design.Products[71][3]
|
||||
p.Discount = 40
|
||||
p.Premium = 40
|
||||
s.design.Products[71][3] = p
|
||||
rate := s.rate(p, 71, 1)
|
||||
_, b, _, err := s.Handle("/ShopOpen", wire.AppendVarint(nil, 1, 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
on, _, _ := wire.Varint(b, 2)
|
||||
if on != 1 {
|
||||
t.Fatal("client bargaining mode not enabled")
|
||||
}
|
||||
if _, _, _, err = s.Handle("/ShopBuy", buy(2, 1, rate)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls == 0 || len(e.costs) != 1 || e.costs[0].Count != 20 {
|
||||
t.Fatal("talent price combined with reputation/market", e.costs)
|
||||
}
|
||||
s.SetTalentDiscountSource(func(uint64, uint64) (uint64, error) { return 0, nil })
|
||||
if _, _, _, err = s.Handle("/ShopBuy", buy(2, 1, rate)); err != nil || e.calls != 1 {
|
||||
t.Fatal("discount state altered replay price", err, e.calls)
|
||||
}
|
||||
}
|
||||
func TestBargainingNoBargainAndInvalidDiscountRejectBeforeEconomy(t *testing.T) {
|
||||
s, e, _ := fixture(t)
|
||||
s.design.ShopNPCs = map[uint64][]uint64{71: {8801}}
|
||||
s.SetTalentDiscountSource(func(uint64, uint64) (uint64, error) { return 20, nil })
|
||||
p := s.design.Products[71][3]
|
||||
p.NoBargain = 1
|
||||
s.design.Products[71][3] = p
|
||||
if _, _, _, err := s.Handle("/ShopBuy", buy(1, 1, 100)); err == nil || e.calls != 0 {
|
||||
t.Fatal("NoBargain bought during talent view")
|
||||
}
|
||||
s.SetTalentDiscountSource(func(uint64, uint64) (uint64, error) { return 101, nil })
|
||||
if _, _, _, err := s.Handle("/ShopBuy", buy(2, 1, 100)); err == nil || e.calls != 0 {
|
||||
t.Fatal("invalid discount charged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// AutoRecoveryResult uses Define_AutoReviveDisabledType's protocol values.
|
||||
type AutoRecoveryResult struct {
|
||||
Caster, Experience, Catalyst, Disabled uint64
|
||||
Characters []Character
|
||||
}
|
||||
|
||||
// AutoRecover selects a living permanent fatigue-recovery caster. The selected
|
||||
// caster is preferred; another owned caster can take over when it is fatigued.
|
||||
func (s *TalentUseService) AutoRecover(seq, caster uint64, targets []uint64) (AutoRecoveryResult, error) {
|
||||
r := AutoRecoveryResult{Caster: caster, Catalyst: s.wallet.CatalystBalance()}
|
||||
if s.context == nil {
|
||||
return r, fmt.Errorf("player: automatic recovery context unavailable")
|
||||
}
|
||||
pack, _, battle, err := s.context()
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
if battle {
|
||||
return r, fmt.Errorf("player: automatic recovery during battle")
|
||||
}
|
||||
all := s.characters.RawAll()
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
left, right := all[i].InvenIndex == caster, all[j].InvenIndex == caster
|
||||
if left != right {
|
||||
return left
|
||||
}
|
||||
return all[i].InvenIndex < all[j].InvenIndex
|
||||
})
|
||||
var selected Character
|
||||
var cost uint64
|
||||
for _, c := range all {
|
||||
if IsStoryCharacter(c) || IsCharmCharacter(c) {
|
||||
continue
|
||||
}
|
||||
meta, ok := s.design.Characters[c.ID]
|
||||
if !ok || meta.BannedPacks[pack] {
|
||||
continue
|
||||
}
|
||||
rule, ok := s.design.Rules[[2]uint64{meta.Group, c.TalentLevel}]
|
||||
if !ok || rule.Class != 10 {
|
||||
continue
|
||||
}
|
||||
hp, e := s.characters.CurrentHealth(c.InvenIndex)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
if hp == 0 {
|
||||
continue
|
||||
}
|
||||
selected = c
|
||||
if len(targets) > 0 && rule.Catalyst > math.MaxUint64/uint64(len(targets)) {
|
||||
return r, fmt.Errorf("player: automatic recovery cost overflow")
|
||||
}
|
||||
cost = rule.Catalyst * uint64(len(targets))
|
||||
break
|
||||
}
|
||||
if selected.InvenIndex == 0 {
|
||||
r.Disabled = 1
|
||||
return r, nil
|
||||
}
|
||||
r.Caster = selected.InvenIndex
|
||||
if len(targets) == 0 {
|
||||
return r, nil
|
||||
}
|
||||
if cost > r.Catalyst {
|
||||
r.Disabled = 2
|
||||
return r, nil
|
||||
}
|
||||
request := wire.AppendVarint(nil, 1, seq)
|
||||
request = wire.AppendVarint(request, 2, r.Caster)
|
||||
for _, id := range targets {
|
||||
request = wire.AppendVarint(request, 3, id)
|
||||
}
|
||||
_, body, _, err := s.healing(request, "auto-recovery")
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.Experience, _, err = wire.Varint(body, 2)
|
||||
if err != nil {
|
||||
return r, err
|
||||
}
|
||||
r.Catalyst = s.wallet.CatalystBalance()
|
||||
for _, id := range targets {
|
||||
c, ok := s.characters.Find(id)
|
||||
if !ok {
|
||||
return r, fmt.Errorf("player: recovered character unavailable")
|
||||
}
|
||||
hp, e := s.characters.CurrentHealth(id)
|
||||
if e != nil {
|
||||
return r, e
|
||||
}
|
||||
c.HP = hp
|
||||
r.Characters = append(r.Characters, c)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/stateio"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutoRecoveryRequiresLivingCasterAndChargesActualTargets(t *testing.T) {
|
||||
s, chars, wallet, _ := talentTest(t, stateio.NewMemory(), 10)
|
||||
chars.characters = append(chars.characters, Character{InvenIndex: 88, ID: 350, HP: 100, Level: 1, TalentLevel: 1})
|
||||
if e := chars.SetCurrentHealth(77, 0); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
r, e := s.AutoRecover(1, 77, []uint64{77})
|
||||
if e != nil || r.Caster != 88 || r.Disabled != 0 || len(r.Characters) != 1 || r.Characters[0].HP != 100 || r.Experience != 3 || r.Catalyst != 45 {
|
||||
t.Fatalf("result%+v err%v", r, e)
|
||||
}
|
||||
if wallet.CatalystBalance() != 45 {
|
||||
t.Fatal("wrong catalyst")
|
||||
}
|
||||
if e := chars.SetCurrentHealth(77, 0); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := chars.SetCurrentHealth(88, 0); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
r, e = s.AutoRecover(2, 88, []uint64{77})
|
||||
if e != nil || r.Disabled != 1 || len(r.Characters) != 0 || wallet.CatalystBalance() != 45 {
|
||||
t.Fatalf("exhaustion%+v %v", r, e)
|
||||
}
|
||||
if e := chars.SetCurrentHealth(88, 100); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
rule := s.design.Rules[[2]uint64{42, 1}]
|
||||
rule.Catalyst = 46
|
||||
s.design.Rules[[2]uint64{42, 1}] = rule
|
||||
r, e = s.AutoRecover(3, 88, []uint64{77})
|
||||
if e != nil || r.Disabled != 2 || len(r.Characters) != 0 || wallet.CatalystBalance() != 45 {
|
||||
t.Fatalf("insufficient catalyst%+v %v", r, e)
|
||||
}
|
||||
}
|
||||
@@ -62,8 +62,10 @@ func (s *EquipmentInventory) makeEquipment(request []byte) (int, []byte, bool, e
|
||||
if err != nil {
|
||||
return 0, nil, true, fmt.Errorf("player: persist made equipment: %w", err)
|
||||
}
|
||||
if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil {
|
||||
return 0, nil, true, err
|
||||
if gain > 0 {
|
||||
if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
}
|
||||
var response []byte
|
||||
for _, entry := range created {
|
||||
@@ -290,8 +292,10 @@ func (s *EquipmentInventory) makeToBreakAuto(request []byte) (int, []byte, bool,
|
||||
for _, entry := range created {
|
||||
indices = append(indices, entry.InvenIndex)
|
||||
}
|
||||
if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil {
|
||||
return 0, nil, true, err
|
||||
if gain > 0 {
|
||||
if _, err := s.characters.AddTalentExperience(characterIndex, gain, maximum); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
}
|
||||
result, err := s.runUpgradeToBreak(indices, target, "equip-making-break-reward:"+s.sessionID+":"+strconv.FormatUint(seq, 10))
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,16 @@ import (
|
||||
)
|
||||
|
||||
func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
testEquipMakingInstalled(t, 140, 14)
|
||||
}
|
||||
|
||||
// Char10140 has Talent901: maximum level1, no experience in Skill901/1,
|
||||
// and cumulative Growth901/1 threshold0 in current GameData.
|
||||
func TestEquipMakingNonGrowingTalentAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
testEquipMakingInstalled(t, 10140, 0)
|
||||
}
|
||||
|
||||
func testEquipMakingInstalled(t *testing.T, characterID, expectedExperience uint64) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for installed GameData integration test")
|
||||
@@ -28,7 +38,7 @@ func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: 140, Level: 1, TalentLevel: 1}}, inventory, root, "20260923193640")
|
||||
characters, err := OpenCharacterStore(testStore(filepath.Join(dir, "characters.json")), []Character{{InvenIndex: 77, ID: characterID, Level: 1, TalentLevel: 1}}, inventory, root, "20260923193640")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -71,7 +81,7 @@ func TestEquipMakingAgainstInstalledCurrentVersion(t *testing.T) {
|
||||
if wallet.Snapshot().Catalyst != 7 {
|
||||
t.Fatalf("making catalyst=%d", wallet.Snapshot().Catalyst)
|
||||
}
|
||||
if character, ok := characters.Find(77); !ok || character.TalentExp != 14 {
|
||||
if character, ok := characters.Find(77); !ok || character.TalentExp != expectedExperience {
|
||||
t.Fatalf("making character=%+v found=%t", character, ok)
|
||||
}
|
||||
replayCode, replay, _, err := equipment.Handle("/EquipMaking", request)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
@@ -234,6 +235,13 @@ func validateCharacters(characters []Character) error {
|
||||
|
||||
func (s *CharacterStore) All() []Character {
|
||||
characters := s.RawAll()
|
||||
active := characters[:0]
|
||||
for _, c := range characters {
|
||||
if !CharacterExpired(c, time.Now()) {
|
||||
active = append(active, c)
|
||||
}
|
||||
}
|
||||
characters = active
|
||||
for i := range characters {
|
||||
if hp, err := s.savedCurrentHealth(characters[i]); err == nil {
|
||||
characters[i].HP = hp
|
||||
@@ -258,6 +266,9 @@ func (s *CharacterStore) RawAll() []Character {
|
||||
func (s *CharacterStore) Find(inventoryIndex uint64) (Character, bool) {
|
||||
for _, character := range s.RawAll() {
|
||||
if character.InvenIndex == inventoryIndex {
|
||||
if CharacterExpired(character, time.Now()) {
|
||||
return Character{}, false
|
||||
}
|
||||
if hp, err := s.savedCurrentHealth(character); err == nil {
|
||||
character.HP = hp
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
)
|
||||
|
||||
type itemCraftReceipt struct {
|
||||
Digest string
|
||||
Body []byte
|
||||
}
|
||||
type ItemCraftService struct {
|
||||
mu sync.Mutex
|
||||
design *gamedata.ItemCraftDesign
|
||||
talents *gamedata.TalentUseDesign
|
||||
store stateio.Store
|
||||
items *Inventory
|
||||
characters *CharacterStore
|
||||
wallet *Wallet
|
||||
known func(uint64) bool
|
||||
context func() (int, bool, error)
|
||||
session string
|
||||
}
|
||||
|
||||
func NewItemCraftService(d *gamedata.ItemCraftDesign, talents *gamedata.TalentUseDesign, store stateio.Store, items *Inventory, characters *CharacterStore, wallet *Wallet, known func(uint64) bool) (*ItemCraftService, error) {
|
||||
if d == nil || talents == nil || store == nil || items == nil || characters == nil || wallet == nil || known == nil {
|
||||
return nil, fmt.Errorf("craft: missing dependencies")
|
||||
}
|
||||
return &ItemCraftService{design: d, talents: talents, store: store, items: items, characters: characters, wallet: wallet, known: known}, nil
|
||||
}
|
||||
func (s *ItemCraftService) AttachContext(context func() (int, bool, error)) { s.context = context }
|
||||
func (s *ItemCraftService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
|
||||
func (s *ItemCraftService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
var recipes map[uint64]gamedata.ItemCraftRecipe
|
||||
var code int
|
||||
switch path {
|
||||
case "/Cooking":
|
||||
recipes = s.design.Cooking
|
||||
code = 48
|
||||
case "/Alchemy":
|
||||
recipes = s.design.Alchemy
|
||||
code = 49
|
||||
case "/AlchemyBatch":
|
||||
recipes = s.design.Alchemy
|
||||
code = 262
|
||||
default:
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fail := func(err error) (int, []byte, bool, error) { return code, nil, true, err }
|
||||
if s.session == "" || s.context == nil {
|
||||
return fail(fmt.Errorf("craft: session/context unavailable"))
|
||||
}
|
||||
values := map[int]uint64{}
|
||||
err := wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number >= 1 && f.Number <= 4 {
|
||||
if f.Type != 0 {
|
||||
return fmt.Errorf("craft: invalid scalar")
|
||||
}
|
||||
if _, exists := values[f.Number]; exists {
|
||||
return fmt.Errorf("craft: duplicate scalar")
|
||||
}
|
||||
v, _, err := wire.Varint(request, f.Number)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values[f.Number] = v
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
seq, index, recipeID, count := values[1], values[2], values[3], values[4]
|
||||
if seq == 0 || seq > math.MaxInt32 || index == 0 || index > math.MaxInt64 || recipeID == 0 || recipeID > math.MaxInt32 || count == 0 || count > math.MaxInt32 {
|
||||
return fail(fmt.Errorf("craft: invalid request"))
|
||||
}
|
||||
keyHash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d", s.session, path, seq)))
|
||||
key := hex.EncodeToString(keyHash[:])
|
||||
digestHash := sha256.Sum256(request)
|
||||
digest := hex.EncodeToString(digestHash[:])
|
||||
var receipts map[string]itemCraftReceipt
|
||||
raw, err := s.store.Load("itemcraft")
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if raw != nil && json.Unmarshal(raw, &receipts) != nil {
|
||||
return fail(fmt.Errorf("craft: invalid receipt state"))
|
||||
}
|
||||
if receipts == nil {
|
||||
receipts = map[string]itemCraftReceipt{}
|
||||
}
|
||||
if r, exists := receipts[key]; exists {
|
||||
if r.Digest != digest {
|
||||
return fail(fmt.Errorf("craft: changed request replay"))
|
||||
}
|
||||
return code, r.Body, true, nil
|
||||
}
|
||||
pack, battle, err := s.context()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if battle || pack <= 0 {
|
||||
return fail(fmt.Errorf("craft: unavailable in current scene"))
|
||||
}
|
||||
recipe, exists := recipes[recipeID]
|
||||
if !exists {
|
||||
return fail(fmt.Errorf("craft: unknown recipe"))
|
||||
}
|
||||
if recipe.Class == 7 && !s.known(recipeID) {
|
||||
return fail(fmt.Errorf("craft: recipe not learned"))
|
||||
}
|
||||
character, owned := s.characters.Find(index)
|
||||
if !owned || IsStoryCharacter(character) {
|
||||
return fail(fmt.Errorf("craft: unavailable producer"))
|
||||
}
|
||||
talent, exists := s.talents.Characters[character.ID]
|
||||
if !exists || talent.BannedPacks[pack] {
|
||||
return fail(fmt.Errorf("craft: producer talent blocked in pack"))
|
||||
}
|
||||
current := s.talents.Rules[[2]uint64{talent.Group, character.TalentLevel}]
|
||||
limitIndex := 0
|
||||
if recipe.Class == 7 {
|
||||
limitIndex = 1
|
||||
}
|
||||
if len(current.Values) <= limitIndex || count > uint64(current.Values[limitIndex]) {
|
||||
return fail(fmt.Errorf("craft: count exceeds talent limit"))
|
||||
}
|
||||
gain, catalyst, maximum, err := s.talents.CraftTalent(character.ID, character.TalentLevel, recipe.Class, recipe.TalentLevel, count, character.TalentExp)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
materials, err := equipmentRequestItems(request, 5, path)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if path == "/AlchemyBatch" {
|
||||
gain, catalyst, maximum, err = s.prepareAlchemyBatch(character, recipe, count, materials)
|
||||
} else {
|
||||
err = validateMakingMaterials(recipe.Costs, count, materials)
|
||||
// Conversion recipes charge per produced resource, as AlchemyUI.GetNeededCurrency does.
|
||||
if err == nil && recipe.Class == 8 && recipe.Category == 2 {
|
||||
if recipe.Result.Count == 0 || catalyst > math.MaxInt32/recipe.Result.Count {
|
||||
err = fmt.Errorf("craft: catalyst overflow")
|
||||
} else {
|
||||
catalyst *= recipe.Result.Count
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = s.items.CanConsume(materials); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if catalyst > 0 && !s.wallet.CanSpendCatalyst(catalyst) {
|
||||
return fail(fmt.Errorf("craft: insufficient catalyst"))
|
||||
}
|
||||
if recipe.Result.Count > math.MaxInt32/count {
|
||||
return fail(fmt.Errorf("craft: result quantity overflow"))
|
||||
}
|
||||
result := recipe.Result
|
||||
result.Count *= count
|
||||
if path == "/AlchemyBatch" {
|
||||
result.Count = count
|
||||
}
|
||||
if err = s.items.Consume(materials); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if catalyst > 0 {
|
||||
if _, err = s.wallet.SpendCatalystOnce("itemcraft:"+key, catalyst); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
granted, err := s.items.GrantOnce("itemcraft:"+key, []gamedata.BattleReward{result})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if gain > 0 {
|
||||
if _, err = s.characters.AddTalentExperience(index, gain, maximum); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
var response []byte
|
||||
for _, item := range granted {
|
||||
response = wire.AppendBytes(response, 1, ItemWire(item))
|
||||
}
|
||||
if gain > 0 {
|
||||
response = wire.AppendVarint(response, 2, gain)
|
||||
}
|
||||
receipts[key] = itemCraftReceipt{Digest: digest, Body: response}
|
||||
raw, err = json.Marshal(receipts)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = s.store.Save("itemcraft", raw); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return code, response, true, nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Batch requests specify the missing final quantity and can supply existing
|
||||
// intermediate resources plus raw resources. Only EquipmentResource recipes
|
||||
// participate in this graph; conversion recipes would introduce cycles.
|
||||
func (s *ItemCraftService) prepareAlchemyBatch(c Character, recipe gamedata.ItemCraftRecipe, count uint64, materials []Item) (gain, catalyst, maximum uint64, err error) {
|
||||
if recipe.Category != 1 || recipe.Result.Count == 0 || count%recipe.Result.Count != 0 {
|
||||
return 0, 0, 0, fmt.Errorf("craft: invalid batch output")
|
||||
}
|
||||
available := map[[2]uint64]uint64{}
|
||||
for _, item := range materials {
|
||||
key := [2]uint64{item.Type, item.ID}
|
||||
if item.Count > math.MaxInt32-available[key] {
|
||||
return 0, 0, 0, fmt.Errorf("craft: batch material overflow")
|
||||
}
|
||||
available[key] += item.Count
|
||||
}
|
||||
producers := map[[2]uint64]gamedata.ItemCraftRecipe{}
|
||||
for _, r := range s.design.Alchemy {
|
||||
if r.Category == 1 {
|
||||
key := [2]uint64{r.Result.Type, r.Result.ID}
|
||||
if _, ok := producers[key]; ok {
|
||||
return 0, 0, 0, fmt.Errorf("craft: ambiguous batch producer")
|
||||
}
|
||||
producers[key] = r
|
||||
}
|
||||
}
|
||||
visiting := map[uint64]bool{}
|
||||
var makeRecipe func(gamedata.ItemCraftRecipe, uint64) error
|
||||
makeRecipe = func(r gamedata.ItemCraftRecipe, n uint64) error {
|
||||
if visiting[r.ID] {
|
||||
return fmt.Errorf("craft: cyclic batch recipe")
|
||||
}
|
||||
visiting[r.ID] = true
|
||||
defer delete(visiting, r.ID)
|
||||
g, cost, max, e := s.talents.CraftTalent(c.ID, c.TalentLevel, 8, r.TalentLevel, n, c.TalentExp)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if gain > math.MaxInt32-g || catalyst > math.MaxInt32-cost {
|
||||
return fmt.Errorf("craft: batch growth overflow")
|
||||
}
|
||||
gain += g
|
||||
catalyst += cost
|
||||
maximum = max
|
||||
for _, material := range r.Costs {
|
||||
if material.Count == 0 || n > math.MaxInt32/material.Count {
|
||||
return fmt.Errorf("craft: batch quantity overflow")
|
||||
}
|
||||
need := material.Count * n
|
||||
key := [2]uint64{material.Type, material.ID}
|
||||
supplied := min(available[key], need)
|
||||
available[key] -= supplied
|
||||
need -= supplied
|
||||
if need == 0 {
|
||||
continue
|
||||
}
|
||||
lower, ok := producers[key]
|
||||
if !ok || lower.Result.Count == 0 || need%lower.Result.Count != 0 {
|
||||
return fmt.Errorf("craft: missing batch material %d/%d", key[0], key[1])
|
||||
}
|
||||
if e = makeRecipe(lower, need/lower.Result.Count); e != nil {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err = makeRecipe(recipe, count/recipe.Result.Count); err != nil {
|
||||
return 0, 0, 0, err
|
||||
}
|
||||
for _, n := range available {
|
||||
if n != 0 {
|
||||
return 0, 0, 0, fmt.Errorf("craft: extra batch materials")
|
||||
}
|
||||
}
|
||||
if c.TalentExp >= maximum {
|
||||
gain = 0
|
||||
} else {
|
||||
gain = min(gain, maximum-c.TalentExp)
|
||||
}
|
||||
return gain, catalyst, maximum, nil
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func craftFixture(t *testing.T, store stateio.Store, class uint64) (*ItemCraftService, *Inventory, *CharacterStore, *Wallet) {
|
||||
t.Helper()
|
||||
items, e := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = items.GrantOnce("materials", []gamedata.BattleReward{{Type: 8, ID: 1, Count: 200}, {Type: 5, ID: 1, Count: 20}, {Type: 8, ID: 2, Count: 2}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
chars, e := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, TalentLevel: 2}}, items, "", "")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = chars.EnsurePersisted(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
wallet, e := OpenWallet(store, Currency{Catalyst: 100})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = wallet.EnsurePersisted(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
talents := &gamedata.TalentUseDesign{Characters: map[uint64]gamedata.TalentUseCharacter{350: {Group: 42, MaxLevel: 2}}, Rules: map[[2]uint64]gamedata.TalentUseRule{{42, 1}: {Class: class, Catalyst: 2, Experience: 3, Values: []float64{100, 100}}, {42, 2}: {Class: class, Catalyst: 4, Experience: 6, Values: []float64{100, 100}}}, Growth: &gamedata.TalentGrowthDesign{Characters: map[uint64]gamedata.CharacterTalent{350: {GrowthGroup: 6, MaxLevel: 2}}, Levels: map[[2]uint64]gamedata.TalentGrowthLevel{{6, 1}: {NeedExp: 10}, {6, 2}: {NeedExp: 10}}}}
|
||||
d := &gamedata.ItemCraftDesign{Cooking: map[uint64]gamedata.ItemCraftRecipe{101: {ID: 101, Class: 7, TalentLevel: 1, Result: gamedata.BattleReward{Type: 5, ID: 2, Count: 1}, Costs: []gamedata.PromotionCost{{Type: 5, ID: 1, Count: 2}}}}, Alchemy: map[uint64]gamedata.ItemCraftRecipe{101: {ID: 101, Class: 8, Category: 1, TalentLevel: 1, Result: gamedata.BattleReward{Type: 8, ID: 2, Count: 1}, Costs: []gamedata.PromotionCost{{Type: 8, ID: 1, Count: 5}}}, 102: {ID: 102, Class: 8, Category: 1, TalentLevel: 2, Result: gamedata.BattleReward{Type: 8, ID: 3, Count: 1}, Costs: []gamedata.PromotionCost{{Type: 8, ID: 2, Count: 3}}}, 103: {ID: 103, Class: 8, Category: 2, TalentLevel: 1, Result: gamedata.BattleReward{Type: 8, ID: 4, Count: 10}, Costs: []gamedata.PromotionCost{{Type: 8, ID: 1, Count: 10}}}}}
|
||||
s, e := NewItemCraftService(d, talents, store, items, chars, wallet, func(id uint64) bool { return id == 101 })
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s.BeginSession("craft")
|
||||
s.AttachContext(func() (int, bool, error) { return 21, false, nil })
|
||||
return s, items, chars, wallet
|
||||
}
|
||||
|
||||
func TestItemCraftAgainstInstalledGameData(t *testing.T) {
|
||||
root := os.Getenv("BD2_REAL_GAMEDATA")
|
||||
if root == "" {
|
||||
t.Skip("set BD2_REAL_GAMEDATA for current GameData integration")
|
||||
}
|
||||
d, e := gamedata.LoadItemCraftDesign(root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
talents, e := gamedata.LoadTalentUseDesign(root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for _, class := range []uint64{7, 8} {
|
||||
t.Run(map[uint64]string{7: "Cooking", 8: "Alchemy"}[class], func(t *testing.T) {
|
||||
var producers []uint64
|
||||
for id, c := range talents.Characters {
|
||||
r, ok := talents.Rules[[2]uint64{c.Group, 1}]
|
||||
if ok && r.Class == class {
|
||||
producers = append(producers, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(producers, func(i, j int) bool { return producers[i] < producers[j] })
|
||||
if len(producers) == 0 {
|
||||
t.Fatal("missing actual producers")
|
||||
}
|
||||
for _, id := range producers {
|
||||
store := stateio.NewMemory()
|
||||
items, e := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
chars, e := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: id, Level: 1, TalentLevel: 1}}, items, root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
wallet, e := OpenWallet(store, Currency{Catalyst: 1000})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
recipes := d.Cooking
|
||||
path := "/Cooking"
|
||||
if class == 8 {
|
||||
recipes = d.Alchemy
|
||||
path = "/Alchemy"
|
||||
}
|
||||
var ids []uint64
|
||||
for rid, r := range recipes {
|
||||
if r.TalentLevel == 1 {
|
||||
ids = append(ids, rid)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
if len(ids) == 0 {
|
||||
t.Fatal("missing level1 recipe")
|
||||
}
|
||||
r := recipes[ids[0]]
|
||||
var materials []gamedata.BattleReward
|
||||
for _, c := range r.Costs {
|
||||
materials = append(materials, gamedata.BattleReward{Type: c.Type, ID: c.ID, Count: c.Count})
|
||||
}
|
||||
if _, e = items.GrantOnce("materials", materials); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e := NewItemCraftService(d, talents, store, items, chars, wallet, func(uint64) bool { return true })
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s.BeginSession("real")
|
||||
s.AttachContext(func() (int, bool, error) { return 1, false, nil })
|
||||
_, _, _, e = s.Handle(path, craftRequest(items, r.ID, 1, materials...))
|
||||
if e != nil {
|
||||
t.Fatalf("actual producer %d recipe %d: %v", id, r.ID, e)
|
||||
}
|
||||
if craftCount(items, r.Result.Type, r.Result.ID) != r.Result.Count {
|
||||
t.Fatal("actual result missing")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func craftRequest(items *Inventory, recipe, count uint64, costs ...gamedata.BattleReward) []byte {
|
||||
b := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, recipe), 4, count)
|
||||
for _, cost := range costs {
|
||||
for _, item := range items.All() {
|
||||
if item.Type == cost.Type && item.ID == cost.ID {
|
||||
item.Count = cost.Count
|
||||
b = wire.AppendBytes(b, 5, ItemWire(item))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
func craftCount(items *Inventory, kind, id uint64) uint64 {
|
||||
var n uint64
|
||||
for _, v := range items.All() {
|
||||
if v.Type == kind && v.ID == id {
|
||||
n += v.Count
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestItemCraftChargesRecipeLevelClampsExperienceAndReplaysAfterRestart(t *testing.T) {
|
||||
for _, class := range []uint64{7, 8} {
|
||||
t.Run(map[uint64]string{7: "Cooking", 8: "Alchemy"}[class], func(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
s, items, chars, wallet := craftFixture(t, store, class)
|
||||
kind := uint64(8)
|
||||
path := "/Alchemy"
|
||||
if class == 7 {
|
||||
kind = 5
|
||||
path = "/Cooking"
|
||||
}
|
||||
input := uint64(10)
|
||||
if class == 7 {
|
||||
input = 4
|
||||
}
|
||||
b := craftRequest(items, 101, 2, gamedata.BattleReward{Type: kind, ID: 1, Count: input})
|
||||
_, out, _, e := s.Handle(path, b)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c, _ := chars.Find(77)
|
||||
if wallet.Snapshot().Catalyst != 96 || c.TalentExp != 12 || craftCount(items, kind, 2) != map[uint64]uint64{7: 2, 8: 4}[class] {
|
||||
t.Fatalf("bad craft settlement: wallet=%+v character=%+v items=%+v", wallet.Snapshot(), c, items.All())
|
||||
}
|
||||
next, _, _, againWallet := craftFixture(t, store, class)
|
||||
_, again, _, e := next.Handle(path, b)
|
||||
if e != nil || !bytes.Equal(out, again) || againWallet.Snapshot().Catalyst != 96 {
|
||||
t.Fatalf("craft replay changed: %v", e)
|
||||
}
|
||||
if _, _, _, e = next.Handle(path, wire.AppendVarint(b, 4, 3)); e == nil {
|
||||
t.Fatal("changed/duplicate scalar accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAlchemyBatchUsesIntermediateInventoryAndValidatesWholeGraph(t *testing.T) {
|
||||
s, items, chars, wallet := craftFixture(t, stateio.NewMemory(), 8)
|
||||
b := craftRequest(items, 102, 2, gamedata.BattleReward{Type: 8, ID: 2, Count: 2}, gamedata.BattleReward{Type: 8, ID: 1, Count: 20})
|
||||
_, out, _, e := s.Handle("/AlchemyBatch", b)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
c, _ := chars.Find(77)
|
||||
if craftCount(items, 8, 3) != 2 || craftCount(items, 8, 2) != 0 || craftCount(items, 8, 1) != 180 || wallet.Snapshot().Catalyst != 84 || c.TalentExp != 20 {
|
||||
t.Fatalf("batch wrong settlement: %+v %+v %+v", items.All(), wallet.Snapshot(), c)
|
||||
}
|
||||
_, again, _, e := s.Handle("/AlchemyBatch", b)
|
||||
if e != nil || !bytes.Equal(out, again) || craftCount(items, 8, 3) != 2 {
|
||||
t.Fatal("batch replay changed")
|
||||
}
|
||||
for _, path := range []string{"/Alchemy", "/AlchemyBatch"} {
|
||||
s, items, _, wallet = craftFixture(t, stateio.NewMemory(), 8)
|
||||
bad := craftRequest(items, 102, 2, gamedata.BattleReward{Type: 8, ID: 1, Count: 1})
|
||||
if _, _, _, e = s.Handle(path, bad); e == nil || wallet.Snapshot().Catalyst != 100 || craftCount(items, 8, 1) != 200 {
|
||||
t.Fatalf("underpaid craft accepted by %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestAlchemyConversionChargesProducedQuantity(t *testing.T) {
|
||||
s, items, _, wallet := craftFixture(t, stateio.NewMemory(), 8)
|
||||
b := craftRequest(items, 103, 2, gamedata.BattleReward{Type: 8, ID: 1, Count: 20})
|
||||
if _, _, _, e := s.Handle("/Alchemy", b); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if craftCount(items, 8, 4) != 20 || wallet.Snapshot().Catalyst != 60 {
|
||||
t.Fatal("conversion output/catalyst mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
type failCraftReceipt struct{ stateio.Store }
|
||||
|
||||
func (s failCraftReceipt) Save(name string, b []byte) error {
|
||||
if name == "itemcraft" {
|
||||
return errors.New("receipt unavailable")
|
||||
}
|
||||
return s.Store.Save(name, b)
|
||||
}
|
||||
func TestItemCraftFinalReceiptFailureRollsBackEveryDomain(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, e := accountstate.Open(path)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, items, _, _ := craftFixture(t, repo, 8)
|
||||
s.store = failCraftReceipt{repo}
|
||||
b := craftRequest(items, 101, 2, gamedata.BattleReward{Type: 8, ID: 1, Count: 10})
|
||||
op, e := repo.BeginOperation()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e = s.Handle("/Alchemy", b); e == nil {
|
||||
t.Fatal("failed receipt accepted")
|
||||
}
|
||||
if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
repo.Close()
|
||||
repo, e = accountstate.Open(path)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer repo.Close()
|
||||
next, items, chars, wallet := craftFixture(t, repo, 8)
|
||||
c, _ := chars.Find(77)
|
||||
if c.TalentExp != 0 || wallet.Snapshot().Catalyst != 100 || craftCount(items, 8, 1) != 200 || craftCount(items, 8, 2) != 2 {
|
||||
t.Fatal("partial craft survived rollback")
|
||||
}
|
||||
op, e = repo.BeginOperation()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e = next.Handle("/Alchemy", b); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e = op.Commit(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const CharmCharacterIndexBase uint64 = 1 << 59
|
||||
|
||||
func IsCharmCharacter(c Character) bool {
|
||||
return c.InvenIndex >= CharmCharacterIndexBase && c.InvenIndex < StoryCharacterIndexBase
|
||||
}
|
||||
func CharacterExpired(c Character, now time.Time) bool {
|
||||
return c.ExpiryTime > 0 && c.ExpiryTime <= uint64(now.UnixMilli())
|
||||
}
|
||||
|
||||
// Charm instances are field companions. They live only in the character domain
|
||||
// with an expiry, never grant a permanent collection character or costume.
|
||||
func (s *TalentUseService) applyCharm(_ string, _ Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) {
|
||||
pack, _, _, err := s.context()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
npcs, err := s.design.NPCs(pack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
npc := npcs[targets[0]]
|
||||
if s.design.CharmCharacter == nil || len(rule.Values) < 2 {
|
||||
return nil, fmt.Errorf("player: charm character design unavailable")
|
||||
}
|
||||
var chars []Character
|
||||
expiry := uint64(s.now().UnixMilli() + int64(rule.Values[1])*1000)
|
||||
for _, id := range npc.CharmCharacters {
|
||||
d, e := s.design.CharmCharacter(id)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if d.CharacterID == 0 || d.CostumeID == 0 || d.HP == 0 {
|
||||
return nil, fmt.Errorf("player: invalid charm companion design")
|
||||
}
|
||||
if pack <= 0 || pack >= 65536 || d.CharacterID >= 1<<32 {
|
||||
return nil, fmt.Errorf("player: charm instance namespace overflow")
|
||||
}
|
||||
index := CharmCharacterIndexBase | uint64(pack)<<32 | d.CharacterID
|
||||
chars = append(chars, Character{InvenIndex: index, ID: d.CharacterID, Level: d.Level, HP: d.HP, CostumeID: d.CostumeID, TalentLevel: 1, ExpiryTime: expiry})
|
||||
}
|
||||
if err = s.characters.ensureCharmCharacters(chars); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []byte
|
||||
for _, c := range chars {
|
||||
out = wire.AppendBytes(out, 5, CharacterWire(c))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (s *CharacterStore) ensureCharmCharacters(chars []Character) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := append([]Character(nil), s.characters...)
|
||||
for _, c := range chars {
|
||||
if !IsCharmCharacter(c) || c.ExpiryTime == 0 {
|
||||
return fmt.Errorf("player: invalid charm instance")
|
||||
}
|
||||
found := false
|
||||
for i, old := range next {
|
||||
if old.InvenIndex == c.InvenIndex {
|
||||
if old.ID != c.ID {
|
||||
return fmt.Errorf("player: charm instance collision")
|
||||
}
|
||||
next[i] = c
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
next = append(next, c)
|
||||
}
|
||||
}
|
||||
if err := s.persist(next); err != nil {
|
||||
return err
|
||||
}
|
||||
s.characters = next
|
||||
for _, c := range chars {
|
||||
raw, err := json.Marshal(c.HP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.store.PutEntry("characters", "current_hp", strconv.FormatUint(c.InvenIndex, 10), raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type talentDispatchRow struct {
|
||||
ID uint64 `json:"id"`
|
||||
Start int64 `json:"start"`
|
||||
End int64 `json:"end"`
|
||||
Identity string `json:"identity"`
|
||||
Rewards []gamedata.Reward `json:"rewards"`
|
||||
Claimed bool `json:"claimed"`
|
||||
Bundle []byte `json:"bundle"`
|
||||
}
|
||||
type talentDispatchState struct {
|
||||
Claims map[string]talentDispatchClaim `json:"claims"`
|
||||
Rows map[uint64]talentDispatchRow `json:"rows"`
|
||||
Starts map[string][]byte `json:"starts"`
|
||||
}
|
||||
type talentDispatchClaim struct {
|
||||
Digest string `json:"digest"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
|
||||
type TalentDispatchService struct {
|
||||
session string
|
||||
mu sync.Mutex
|
||||
store stateio.Store
|
||||
design map[uint64]gamedata.TalentDispatchDesign
|
||||
now func() time.Time
|
||||
draw func(uint64) (uint64, error)
|
||||
economy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}
|
||||
}
|
||||
|
||||
func OpenTalentDispatch(store stateio.Store, d map[uint64]gamedata.TalentDispatchDesign, economy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}) (*TalentDispatchService, error) {
|
||||
if store == nil || len(d) == 0 || economy == nil {
|
||||
return nil, fmt.Errorf("dispatch: invalid configuration")
|
||||
}
|
||||
return &TalentDispatchService{store: store, design: d, economy: economy, now: time.Now, draw: func(n uint64) (uint64, error) {
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("dispatch: empty pool")
|
||||
}
|
||||
v, e := rand.Int(rand.Reader, new(big.Int).SetUint64(n))
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
return v.Uint64(), nil
|
||||
}}, nil
|
||||
}
|
||||
func (s *TalentDispatchService) load() (talentDispatchState, error) {
|
||||
st := talentDispatchState{Rows: map[uint64]talentDispatchRow{}, Starts: map[string][]byte{}, Claims: map[string]talentDispatchClaim{}}
|
||||
b, e := s.store.Load("talent_dispatch")
|
||||
if e != nil || b == nil {
|
||||
return st, e
|
||||
}
|
||||
if e = stateio.RequireExactJSONObject(b, "rows", "starts", "claims"); e != nil {
|
||||
return st, e
|
||||
}
|
||||
if e = json.Unmarshal(b, &st); e != nil {
|
||||
return st, e
|
||||
}
|
||||
if st.Rows == nil || st.Starts == nil || st.Claims == nil {
|
||||
return st, fmt.Errorf("dispatch: invalid state")
|
||||
}
|
||||
for id, r := range st.Rows {
|
||||
if _, ok := s.design[id]; !ok || r.ID != id || r.Start <= 0 || r.End <= r.Start || r.Identity == "" {
|
||||
return st, fmt.Errorf("dispatch: invalid row")
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
func (s *TalentDispatchService) BeginSession(id string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.session = id
|
||||
}
|
||||
func (s *TalentDispatchService) save(st talentDispatchState) error {
|
||||
b, e := json.Marshal(st)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.store.Save("talent_dispatch", b)
|
||||
}
|
||||
func (s *TalentDispatchService) rowWire(r talentDispatchRow) []byte {
|
||||
b := wire.AppendVarint(nil, 1, r.ID)
|
||||
b = wire.AppendVarint(b, 2, uint64(s.now().UnixMilli()))
|
||||
return wire.AppendVarint(b, 3, uint64(r.End))
|
||||
}
|
||||
|
||||
// Start returns extra TalentSkillUseResponse fields. Its caller owns talent
|
||||
// cost/experience/cooldown and the encompassing account transaction.
|
||||
func (s *TalentDispatchService) Start(identity string, character Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if identity == "" || rule.Class != 18 || character.InvenIndex == 0 || len(targets) == 0 {
|
||||
return nil, fmt.Errorf("dispatch: invalid start")
|
||||
}
|
||||
st, e := s.load()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if b, ok := st.Starts[identity]; ok {
|
||||
return append([]byte(nil), b...), nil
|
||||
}
|
||||
allowed := map[uint64]bool{}
|
||||
for _, v := range rule.Values {
|
||||
if v > 0 {
|
||||
allowed[uint64(v)] = true
|
||||
}
|
||||
}
|
||||
prefabs := map[string]bool{}
|
||||
for id, r := range st.Rows {
|
||||
if !r.Claimed {
|
||||
prefabs[s.design[id].Prefab] = true
|
||||
}
|
||||
}
|
||||
var rows []talentDispatchRow
|
||||
for _, id := range targets {
|
||||
d, ok := s.design[id]
|
||||
if !ok || !allowed[id] || prefabs[d.Prefab] {
|
||||
return nil, fmt.Errorf("dispatch: unavailable dispatch %d", id)
|
||||
}
|
||||
prefabs[d.Prefab] = true
|
||||
rs, e := d.Roll(s.draw)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
r := talentDispatchRow{ID: id, Start: s.now().UnixMilli(), Identity: identity}
|
||||
r.End = d.EndTime(time.UnixMilli(r.Start)).UnixMilli()
|
||||
for _, reward := range rs {
|
||||
r.Rewards = append(r.Rewards, gamedata.Reward(reward))
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
var b []byte
|
||||
for _, r := range rows {
|
||||
st.Rows[r.ID] = r
|
||||
b = wire.AppendBytes(b, 8, s.rowWire(r))
|
||||
}
|
||||
st.Starts[identity] = b
|
||||
return b, s.save(st)
|
||||
}
|
||||
func (s *TalentDispatchService) Handle(path string, req []byte) (int, []byte, bool, error) {
|
||||
if path != "/DispatchInfo" && path != "/DispatchReward" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
seq, ok, e := wire.Varint(req, 1)
|
||||
if e != nil || !ok || seq == 0 || seq > math.MaxInt32 {
|
||||
return 0, nil, true, fmt.Errorf("dispatch: missing sequence")
|
||||
}
|
||||
st, e := s.load()
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
if path == "/DispatchInfo" {
|
||||
var b []byte
|
||||
ids := []uint64{}
|
||||
for id, r := range st.Rows {
|
||||
if !r.Claimed {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
for _, id := range ids {
|
||||
b = wire.AppendBytes(b, 1, s.rowWire(st.Rows[id]))
|
||||
}
|
||||
return 0, b, true, nil
|
||||
}
|
||||
if s.session == "" {
|
||||
return 108, nil, true, fmt.Errorf("dispatch: claim session unavailable")
|
||||
}
|
||||
var ids []uint64
|
||||
e = wire.Walk(req, func(f wire.Field) error {
|
||||
if f.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 && f.Type != 2 {
|
||||
return fmt.Errorf("dispatch: invalid ids")
|
||||
}
|
||||
b := f.Value
|
||||
for len(b) > 0 {
|
||||
n, k := binary.Uvarint(b)
|
||||
if k <= 0 || n == 0 || n > math.MaxInt32 || len(ids) >= len(s.design) {
|
||||
return fmt.Errorf("dispatch: invalid packed ids")
|
||||
}
|
||||
ids = append(ids, n)
|
||||
b = b[k:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil || len(ids) == 0 {
|
||||
return 0, nil, true, fmt.Errorf("dispatch: missing reward ids")
|
||||
}
|
||||
digest := sha256.Sum256(req)
|
||||
key := fmt.Sprintf("%s:%d", s.session, seq)
|
||||
if receipt, ok := st.Claims[key]; ok {
|
||||
if receipt.Digest != fmt.Sprintf("%x", digest) {
|
||||
return 0, nil, true, fmt.Errorf("dispatch: conflicting claim sequence")
|
||||
}
|
||||
return 108, append([]byte(nil), receipt.Body...), true, nil
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, id := range ids {
|
||||
r, ok := st.Rows[id]
|
||||
if !ok || seen[id] || s.now().UnixMilli() < r.End {
|
||||
return 0, nil, true, fmt.Errorf("dispatch: reward not ready")
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
var body []byte
|
||||
for _, id := range ids {
|
||||
r := st.Rows[id]
|
||||
if r.Claimed {
|
||||
continue
|
||||
}
|
||||
if !r.Claimed {
|
||||
bundle, e := s.economy.Apply(r.Identity+fmt.Sprintf(":dispatch:%d", id), nil, r.Rewards)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
r.Bundle = bundle
|
||||
r.Claimed = true
|
||||
st.Rows[id] = r
|
||||
}
|
||||
if e = wire.Walk(r.Bundle, func(f wire.Field) error {
|
||||
if f.Number == 1 && f.Type == 2 {
|
||||
body = wire.AppendBytes(body, 1, f.Value)
|
||||
}
|
||||
return nil
|
||||
}); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
}
|
||||
st.Claims[key] = talentDispatchClaim{Digest: fmt.Sprintf("%x", digest), Body: body}
|
||||
return 108, body, true, s.save(st)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type dispatchEconomyFake struct {
|
||||
seen map[string]bool
|
||||
grants int
|
||||
}
|
||||
|
||||
func (e *dispatchEconomyFake) Apply(id string, c, r []gamedata.Reward) ([]byte, error) {
|
||||
if !e.seen[id] {
|
||||
e.seen[id] = true
|
||||
e.grants++
|
||||
}
|
||||
return wire.AppendBytes(nil, 1, wire.AppendVarint(nil, 3, 4)), nil
|
||||
}
|
||||
func TestTalentDispatchReadyRestartRewardReplayAndPrefabExclusion(t *testing.T) {
|
||||
root := "../../../../data/resources/GameData"
|
||||
if _, e := os.Stat(root); e != nil {
|
||||
t.Skip("installed GameData missing")
|
||||
}
|
||||
d, e := gamedata.LoadTalentDispatchDesign(root, "20260923193640")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
st := stateio.NewMemory()
|
||||
eco := &dispatchEconomyFake{seen: map[string]bool{}}
|
||||
s, e := OpenTalentDispatch(st, d, eco)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
now := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
s.now = func() time.Time { return now }
|
||||
s.BeginSession("dispatch")
|
||||
s.draw = func(uint64) (uint64, error) { return 0, nil }
|
||||
rule := gamedata.TalentUseRule{Class: 18, Values: []float64{1, 2}}
|
||||
char := Character{InvenIndex: 99}
|
||||
if _, e = s.Start("foreign", char, rule, []uint64{11}); e == nil {
|
||||
t.Fatal("foreign dispatch accepted")
|
||||
}
|
||||
if _, e = s.Start("duplicate-prefab", char, rule, []uint64{1, 2}); e == nil {
|
||||
t.Fatal("duplicate prefab accepted")
|
||||
}
|
||||
b, e := s.Start("start1", char, rule, []uint64{1})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
b2, e := s.Start("start1", char, rule, []uint64{1})
|
||||
if e != nil || !bytes.Equal(b, b2) {
|
||||
t.Fatal("start replay", e)
|
||||
}
|
||||
if _, e = s.Start("start2", char, rule, []uint64{2}); e == nil {
|
||||
t.Fatal("active prefab reused")
|
||||
}
|
||||
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 1)
|
||||
if _, _, _, e = s.Handle("/DispatchReward", req); e == nil {
|
||||
t.Fatal("claimed before end")
|
||||
}
|
||||
now = d[1].EndTime(now)
|
||||
reopened, e := OpenTalentDispatch(st, d, eco)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
reopened.now = s.now
|
||||
reopened.BeginSession("dispatch")
|
||||
_, reward, _, e := reopened.Handle("/DispatchReward", req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, replay, _, e := reopened.Handle("/DispatchReward", req)
|
||||
if e != nil || !bytes.Equal(reward, replay) || eco.grants != 1 {
|
||||
t.Fatal("reward replay", e, eco.grants)
|
||||
}
|
||||
newClaim := wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 1)
|
||||
_, again, _, e := reopened.Handle("/DispatchReward", newClaim)
|
||||
if e != nil || len(again) != 0 || eco.grants != 1 {
|
||||
t.Fatal("new sequence emitted already claimed item deltas")
|
||||
}
|
||||
_, info, _, e := reopened.Handle("/DispatchInfo", wire.AppendVarint(nil, 1, 2))
|
||||
if e != nil || len(info) != 0 {
|
||||
t.Fatal("claimed dispatch still active", e)
|
||||
}
|
||||
if _, e = reopened.Start("start3", char, rule, []uint64{2}); e != nil {
|
||||
t.Fatal("claimed prefab cannot restart", e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func (s *TalentUseService) charHealing(request []byte) (int, []byte, bool, error) {
|
||||
return s.healing(request, "healing")
|
||||
}
|
||||
|
||||
func (s *TalentUseService) healing(request []byte, operation string) (int, []byte, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fail := func(e error) (int, []byte, bool, error) { return 62, nil, true, e }
|
||||
seq, ok, err := wire.Varint(request, 1)
|
||||
if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" {
|
||||
return fail(fmt.Errorf("player: invalid healing sequence"))
|
||||
}
|
||||
index, _, err := wire.Varint(request, 2)
|
||||
if err != nil || index > math.MaxInt64 {
|
||||
return fail(fmt.Errorf("player: invalid healing caster"))
|
||||
}
|
||||
food, _, err := wire.Varint(request, 4)
|
||||
if err != nil || food > math.MaxInt64 || (index == 0) == (food == 0) {
|
||||
return fail(fmt.Errorf("player: healing requires caster or food"))
|
||||
}
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
key := s.session + ":" + operation + ":" + fmt.Sprint(seq)
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
if prior, ok := state.Receipts[key]; ok {
|
||||
if prior.Digest != digest {
|
||||
return fail(fmt.Errorf("player: changed healing replay"))
|
||||
}
|
||||
return 62, prior.Body, true, nil
|
||||
}
|
||||
var ids []uint64
|
||||
seen := map[uint64]bool{}
|
||||
err = wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number != 3 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 && f.Type != 2 {
|
||||
return fmt.Errorf("player: invalid healing targets")
|
||||
}
|
||||
for raw := f.Value; len(raw) > 0; {
|
||||
id, n := binary.Uvarint(raw)
|
||||
if n <= 0 || id == 0 || id > math.MaxInt64 || seen[id] || len(ids) >= 4096 {
|
||||
return fmt.Errorf("player: invalid healing target")
|
||||
}
|
||||
raw = raw[n:]
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil || len(ids) == 0 {
|
||||
return fail(fmt.Errorf("player: missing healing targets"))
|
||||
}
|
||||
if s.context == nil {
|
||||
return fail(fmt.Errorf("player: healing field context unavailable"))
|
||||
}
|
||||
pack, _, battle, err := s.context()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if battle {
|
||||
return fail(fmt.Errorf("player: healing unavailable during battle"))
|
||||
}
|
||||
var character Character
|
||||
var rule gamedata.TalentUseRule
|
||||
var item Item
|
||||
if food > 0 {
|
||||
for _, v := range s.inventory.All() {
|
||||
if v.InvenIndex == food && v.Type == 5 {
|
||||
item = v
|
||||
break
|
||||
}
|
||||
}
|
||||
g, ok := s.design.Foods[item.ID]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("player: invalid revival food"))
|
||||
}
|
||||
rule, ok = s.design.Rules[[2]uint64{g, 1}]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("player: missing revival food talent"))
|
||||
}
|
||||
item.Count = uint64(len(ids))
|
||||
if err = s.inventory.CanConsume([]Item{item}); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
} else {
|
||||
var owned bool
|
||||
character, owned = s.characters.Find(index)
|
||||
meta, ok := s.design.Characters[character.ID]
|
||||
if !owned || !ok || meta.BannedPacks[pack] {
|
||||
return fail(fmt.Errorf("player: invalid healing caster"))
|
||||
}
|
||||
rule, ok = s.design.Rules[[2]uint64{meta.Group, character.TalentLevel}]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("player: missing recovery level"))
|
||||
}
|
||||
}
|
||||
if rule.Class != 10 || len(rule.Values) == 0 || rule.Values[0] > 100 {
|
||||
return fail(fmt.Errorf("player: invalid fatigue recovery skill"))
|
||||
}
|
||||
var targets []Character
|
||||
for _, id := range ids {
|
||||
c, owned := s.characters.Find(id)
|
||||
if !owned {
|
||||
return fail(fmt.Errorf("player: revival target not owned"))
|
||||
}
|
||||
hp, e := s.characters.CurrentHealth(id)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
if hp != 0 {
|
||||
return fail(fmt.Errorf("player: revival target is not fatigued"))
|
||||
}
|
||||
maximum, e := s.characters.MaxHealth(id)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
c.HP = 1
|
||||
if rule.Values[0] > 0 {
|
||||
c.HP = uint64(float64(maximum) * rule.Values[0] / 100)
|
||||
if c.HP == 0 {
|
||||
c.HP = 1
|
||||
}
|
||||
}
|
||||
targets = append(targets, c)
|
||||
}
|
||||
identity := "talent-healing:" + key
|
||||
if food > 0 {
|
||||
if err = s.inventory.Consume([]Item{item}); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
} else {
|
||||
if rule.Catalyst > math.MaxUint64/uint64(len(ids)) {
|
||||
return fail(fmt.Errorf("player: recovery cost overflow"))
|
||||
}
|
||||
if rule.Catalyst > 0 {
|
||||
if rule.Catalyst > 0 {
|
||||
if _, err = s.wallet.SpendCatalystOnce(identity, rule.Catalyst*uint64(len(ids))); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []byte
|
||||
for _, c := range targets {
|
||||
if err = s.characters.SetCurrentHealth(c.InvenIndex, c.HP); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
out = wire.AppendBytes(out, 1, CharacterWire(c))
|
||||
}
|
||||
if food == 0 && rule.Experience > 0 {
|
||||
maximum, e := s.experienceMaximum(character)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
gain := rule.Experience * uint64(len(ids))
|
||||
if character.TalentExp >= maximum {
|
||||
gain = 0
|
||||
} else if gain > maximum-character.TalentExp {
|
||||
gain = maximum - character.TalentExp
|
||||
}
|
||||
if gain > 0 {
|
||||
if _, e = s.characters.AddTalentExperience(index, gain, maximum); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
out = wire.AppendVarint(out, 2, gain)
|
||||
}
|
||||
}
|
||||
state.Receipts[key] = talentUseReceipt{Digest: digest, Body: out}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = s.store.Save("talentuse", b); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 62, out, true, nil
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/big"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TalentUseContext func() (pack int, mapID uint64, battle bool, err error)
|
||||
type TalentUseEffect func(identity string, character Character, rule gamedata.TalentUseRule, targets []uint64) ([]byte, error)
|
||||
type TalentUseEconomy interface {
|
||||
Apply(string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
|
||||
}
|
||||
type talentUseState struct {
|
||||
End int64
|
||||
Count uint64
|
||||
Day string
|
||||
Level uint64
|
||||
Settled bool
|
||||
}
|
||||
type talentUseReceipt struct {
|
||||
Digest string
|
||||
Body []byte
|
||||
}
|
||||
type talentUseSnapshot struct {
|
||||
Skills map[uint64]talentUseState
|
||||
NPCs map[string]int64
|
||||
Receipts map[string]talentUseReceipt
|
||||
Discounts map[string]uint64
|
||||
}
|
||||
type TalentUseService struct {
|
||||
mu sync.Mutex
|
||||
design *gamedata.TalentUseDesign
|
||||
store stateio.Store
|
||||
characters *CharacterStore
|
||||
inventory *Inventory
|
||||
wallet *Wallet
|
||||
economy TalentUseEconomy
|
||||
context TalentUseContext
|
||||
effects map[uint64]TalentUseEffect
|
||||
session string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewTalentUseService(d *gamedata.TalentUseDesign, store stateio.Store, characters *CharacterStore, inventory *Inventory, wallet *Wallet, economy TalentUseEconomy) (*TalentUseService, error) {
|
||||
if d == nil || store == nil || characters == nil || inventory == nil || wallet == nil || economy == nil {
|
||||
return nil, fmt.Errorf("player: missing talent use dependencies")
|
||||
}
|
||||
return &TalentUseService{design: d, store: store, characters: characters, inventory: inventory, wallet: wallet, economy: economy, effects: map[uint64]TalentUseEffect{}, now: time.Now}, nil
|
||||
}
|
||||
func (s *TalentUseService) AttachContext(f TalentUseContext) { s.context = f }
|
||||
func (s *TalentUseService) AttachEffect(class uint64, f TalentUseEffect) { s.effects[class] = f }
|
||||
func (s *TalentUseService) BeginSession(id string) { s.mu.Lock(); defer s.mu.Unlock(); s.session = id }
|
||||
func (s *TalentUseService) load() (talentUseSnapshot, error) {
|
||||
v := talentUseSnapshot{Skills: map[uint64]talentUseState{}, NPCs: map[string]int64{}, Receipts: map[string]talentUseReceipt{}, Discounts: map[string]uint64{}}
|
||||
b, e := s.store.Load("talentuse")
|
||||
if e != nil {
|
||||
return v, e
|
||||
}
|
||||
if b != nil {
|
||||
if e = stateio.RequireExactJSONObject(b, "Skills", "NPCs", "Receipts", "Discounts"); e != nil {
|
||||
return v, e
|
||||
}
|
||||
if e = json.Unmarshal(b, &v); e != nil || v.Skills == nil || v.NPCs == nil || v.Receipts == nil || v.Discounts == nil {
|
||||
return v, fmt.Errorf("player: invalid talent use state")
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *TalentUseService) Handle(path string, request []byte) (int, []byte, bool, error) {
|
||||
if path == "/CharHealing" {
|
||||
return s.charHealing(request)
|
||||
}
|
||||
if path != "/TalentSkillUse" {
|
||||
return 0, nil, false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fail := func(e error) (int, []byte, bool, error) { return 43, nil, true, e }
|
||||
seq, ok, err := wire.Varint(request, 1)
|
||||
if err != nil || !ok || seq == 0 || seq > math.MaxInt32 || s.session == "" {
|
||||
return fail(fmt.Errorf("player: invalid talent request sequence"))
|
||||
}
|
||||
index, _, err := wire.Varint(request, 2)
|
||||
if err != nil || index > math.MaxInt64 {
|
||||
return fail(fmt.Errorf("player: invalid talent caster"))
|
||||
}
|
||||
food, _, err := wire.Varint(request, 4)
|
||||
if err != nil || food > math.MaxInt64 || (index == 0) == (food == 0) {
|
||||
return fail(fmt.Errorf("player: talent requires a character or a skill food"))
|
||||
}
|
||||
var targets []uint64
|
||||
seen := map[uint64]bool{}
|
||||
err = wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number != 3 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 && f.Type != 2 {
|
||||
return fmt.Errorf("player: invalid talent target wire")
|
||||
}
|
||||
for raw := f.Value; len(raw) > 0; {
|
||||
v, n := binary.Uvarint(raw)
|
||||
if n <= 0 || v == 0 || v > math.MaxInt32 || seen[v] || len(targets) >= 4096 {
|
||||
return fmt.Errorf("player: invalid or duplicate talent target")
|
||||
}
|
||||
raw = raw[n:]
|
||||
seen[v] = true
|
||||
targets = append(targets, v)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
state, err := s.load()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(request))
|
||||
key := s.session + ":" + fmt.Sprint(seq)
|
||||
if prior, ok := state.Receipts[key]; ok {
|
||||
if prior.Digest != digest {
|
||||
return fail(fmt.Errorf("player: changed talent replay"))
|
||||
}
|
||||
return 43, prior.Body, true, nil
|
||||
}
|
||||
var character Character
|
||||
var meta gamedata.TalentUseCharacter
|
||||
var foodItem Item
|
||||
if food > 0 {
|
||||
for _, item := range s.inventory.All() {
|
||||
if item.InvenIndex == food && item.Type == 5 && item.Count > 0 {
|
||||
foodItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
group, exists := s.design.Foods[foodItem.ID]
|
||||
if !exists {
|
||||
return fail(fmt.Errorf("player: unavailable skill food"))
|
||||
}
|
||||
meta = gamedata.TalentUseCharacter{Group: group, MaxLevel: 1}
|
||||
character.TalentLevel = 1
|
||||
} else {
|
||||
var owned bool
|
||||
character, owned = s.characters.Find(index)
|
||||
if !owned || character.TalentLevel == 0 {
|
||||
return fail(fmt.Errorf("player: talent caster unavailable"))
|
||||
}
|
||||
var exists bool
|
||||
meta, exists = s.design.Characters[character.ID]
|
||||
if !exists || character.TalentLevel > meta.MaxLevel {
|
||||
return fail(fmt.Errorf("player: character talent design unavailable"))
|
||||
}
|
||||
}
|
||||
rule, ok := s.design.Rules[[2]uint64{meta.Group, character.TalentLevel}]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("player: talent level design unavailable"))
|
||||
}
|
||||
if s.context == nil {
|
||||
return fail(fmt.Errorf("player: talent field context unavailable"))
|
||||
}
|
||||
pack, mapID, battle, err := s.context()
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if battle || meta.BannedPacks[pack] {
|
||||
return fail(fmt.Errorf("player: talent unavailable in current field"))
|
||||
}
|
||||
if rule.Class == 7 || rule.Class == 8 || rule.Class == 9 || rule.Class == 10 || rule.Class == 14 {
|
||||
return fail(fmt.Errorf("player: talent class %d uses its dedicated crafting/recovery packet", rule.Class))
|
||||
}
|
||||
day := s.now().UTC().Add(9*time.Hour - s.design.ResetSchedule.DailyReset).Format("2006-01-02")
|
||||
current := state.Skills[rule.Group]
|
||||
dailyLimit := rule.Class == 3 || rule.Class == 4 || rule.Class == 20
|
||||
if (rule.Reset == 1 || dailyLimit) && current.Day != day {
|
||||
current.Count = 0
|
||||
current.Day = day
|
||||
}
|
||||
if dailyLimit && (len(rule.Values) == 0 || current.Count >= uint64(rule.Values[0])) {
|
||||
return fail(fmt.Errorf("player: talent daily usage limit reached"))
|
||||
}
|
||||
cooldown := talentHasCooldown(rule.Class)
|
||||
if cooldown && current.End > s.now().UnixMilli() {
|
||||
return fail(fmt.Errorf("player: talent effect is already active"))
|
||||
}
|
||||
identity := "talent-use:" + hex.EncodeToString(sha256Sum([]byte(key)))
|
||||
multiplier := uint64(1)
|
||||
if rule.Class != 4 && rule.Class != 20 && len(targets) > 0 {
|
||||
multiplier = uint64(len(targets))
|
||||
}
|
||||
if food > 0 {
|
||||
rule.Catalyst = 0
|
||||
rule.Experience = 0
|
||||
foodItem.Count = 1
|
||||
if err = s.inventory.CanConsume([]Item{foodItem}); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
if rule.Catalyst > math.MaxUint64/multiplier || rule.Catalyst > 0 && !s.wallet.CanSpendCatalyst(rule.Catalyst*multiplier) {
|
||||
return fail(fmt.Errorf("player: insufficient talent catalyst"))
|
||||
}
|
||||
var extra []byte
|
||||
success := true
|
||||
if talentNPCClass(rule.Class) {
|
||||
if len(targets) != 1 || s.design.NPCs == nil {
|
||||
return fail(fmt.Errorf("player: talent requires one NPC"))
|
||||
}
|
||||
npcs, e := s.design.NPCs(pack)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
npc, ok := npcs[targets[0]]
|
||||
if !ok || npc.MapID != mapID {
|
||||
return fail(fmt.Errorf("player: talent NPC outside current map"))
|
||||
}
|
||||
matched := -1
|
||||
for i, g := range npc.Groups {
|
||||
if g == rule.Group {
|
||||
matched = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched < 0 {
|
||||
return fail(fmt.Errorf("player: NPC does not support talent"))
|
||||
}
|
||||
npcKey := fmt.Sprintf("%d/%d/%d", pack, npc.ID, rule.Group)
|
||||
if rule.Class != 16 && state.NPCs[npcKey] > s.now().UnixMilli() {
|
||||
return fail(fmt.Errorf("player: NPC talent cooldown active"))
|
||||
}
|
||||
if rule.Class == 1 || rule.Class == 16 {
|
||||
probability := 100.0
|
||||
if len(rule.Values) > 0 {
|
||||
probability = rule.Values[0]
|
||||
}
|
||||
draw, e := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
success = float64(draw.Int64()) < probability*10000
|
||||
}
|
||||
if success && npc.Rewards[matched] > 0 {
|
||||
rewards, ok := s.design.Rewards[npc.Rewards[matched]]
|
||||
if !ok {
|
||||
return fail(fmt.Errorf("player: missing NPC talent reward"))
|
||||
}
|
||||
bundle, e := s.economy.Apply(identity, nil, rewards)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
extra, e = talentBundleFields(bundle)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
if rule.Class == 19 && len(npc.CharmCharacters) > 0 {
|
||||
b, e := s.applyCharm(identity, character, rule, targets)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
extra = append(extra, b...)
|
||||
}
|
||||
end := s.now().UnixMilli()
|
||||
if rule.Class == 16 {
|
||||
if len(rule.Values) < 2 || rule.Values[1] > 100 {
|
||||
return fail(fmt.Errorf("player: invalid bargain discount"))
|
||||
}
|
||||
if success {
|
||||
state.Discounts[s.session+":"+fmt.Sprintf("%d/%d", pack, npc.ID)] = uint64(rule.Values[1])
|
||||
}
|
||||
} else if rule.Class == 1 && rule.Reset != 0 {
|
||||
end = s.nextNPCReset(rule.Reset).UnixMilli()
|
||||
} else if len(rule.Values) > 1 {
|
||||
end += int64(rule.Values[1]) * 1000
|
||||
}
|
||||
state.NPCs[npcKey] = end
|
||||
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, npc.ID), 2, rule.Group), 3, uint64(end))
|
||||
extra = wire.AppendBytes(extra, 2, row)
|
||||
if !success && rule.Reputation > 0 {
|
||||
effect := s.effects[100]
|
||||
if effect == nil {
|
||||
return fail(fmt.Errorf("player: failed talent reputation executor unavailable"))
|
||||
}
|
||||
b, e := effect(identity, character, rule, targets)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
extra = append(extra, b...)
|
||||
}
|
||||
} else if effect := s.effects[rule.Class]; effect != nil {
|
||||
extra, err = effect(identity, character, rule, targets)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
} else if rule.Class == 4 || rule.Class == 18 || rule.Class == 20 {
|
||||
return fail(fmt.Errorf("player: field talent executor unavailable"))
|
||||
} else if len(targets) > 0 && rule.Class != 3 && rule.Class != 11 {
|
||||
return fail(fmt.Errorf("player: unexpected talent targets"))
|
||||
}
|
||||
if food > 0 {
|
||||
if err = s.inventory.Consume([]Item{foodItem}); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
if rule.Catalyst > 0 {
|
||||
if rule.Catalyst > math.MaxUint64/multiplier {
|
||||
return fail(fmt.Errorf("player: talent cost overflow"))
|
||||
}
|
||||
if _, err = s.wallet.SpendCatalystOnce(identity, rule.Catalyst*multiplier); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
}
|
||||
gain := rule.Experience
|
||||
if gain > 0 {
|
||||
maximum, e := s.experienceMaximum(character)
|
||||
if e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
if character.TalentExp >= maximum {
|
||||
gain = 0
|
||||
} else if gain > maximum-character.TalentExp {
|
||||
gain = maximum - character.TalentExp
|
||||
}
|
||||
if gain > 0 {
|
||||
if _, e = s.characters.AddTalentExperience(index, gain, maximum); e != nil {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
current.Count++
|
||||
current.Settled = false
|
||||
current.Level = rule.Level
|
||||
current.Day = day
|
||||
current.End = s.now().UnixMilli()
|
||||
if cooldown && len(rule.Values) > 1 {
|
||||
current.End += int64(rule.Values[1]) * 1000
|
||||
}
|
||||
state.Skills[rule.Group] = current
|
||||
out := append([]byte(nil), extra...)
|
||||
out = wire.AppendBytes(out, 1, talentUseWire(rule.Group, current, rule))
|
||||
if gain > 0 {
|
||||
out = wire.AppendVarint(out, 10, gain)
|
||||
}
|
||||
if success {
|
||||
out = wire.AppendVarint(out, 11, 1)
|
||||
}
|
||||
state.Receipts[key] = talentUseReceipt{Digest: digest, Body: out}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
if err = s.store.Save("talentuse", b); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
return 43, out, true, nil
|
||||
}
|
||||
func sha256Sum(b []byte) []byte { v := sha256.Sum256(b); return v[:] }
|
||||
func talentNPCClass(class uint64) bool { return class == 1 || class == 5 || class == 16 || class == 19 }
|
||||
func talentHasCooldown(class uint64) bool {
|
||||
return class == 2 || class == 6 || class == 12 || class == 13 || class == 15 || class == 17
|
||||
}
|
||||
func talentUseWire(group uint64, state talentUseState, rule gamedata.TalentUseRule) []byte {
|
||||
b := wire.AppendVarint(nil, 1, group)
|
||||
if state.End > 0 {
|
||||
b = wire.AppendVarint(b, 2, uint64(state.End))
|
||||
}
|
||||
if talentHasCooldown(rule.Class) && len(rule.Values) > 1 {
|
||||
b = wire.AppendVarint(b, 3, uint64(rule.Values[1]))
|
||||
}
|
||||
return wire.AppendVarint(b, 4, state.Count)
|
||||
}
|
||||
func (s *TalentUseService) experienceMaximum(c Character) (uint64, error) {
|
||||
if s.design.Growth == nil {
|
||||
return 0, fmt.Errorf("player: talent growth design missing")
|
||||
}
|
||||
meta, ok := s.design.Growth.Characters[c.ID]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("player: missing character growth")
|
||||
}
|
||||
var total uint64
|
||||
for level := uint64(1); level <= c.TalentLevel; level++ {
|
||||
v, ok := s.design.Growth.Levels[[2]uint64{meta.GrowthGroup, level}]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("player: missing talent growth level")
|
||||
}
|
||||
total += v.NeedExp
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
func talentBundleFields(bundle []byte) ([]byte, error) {
|
||||
var out []byte
|
||||
err := wire.Walk(bundle, func(f wire.Field) error {
|
||||
to := map[int]int{1: 3, 2: 5, 3: 6, 4: 4}[f.Number]
|
||||
if to > 0 {
|
||||
out = wire.AppendBytes(out, to, f.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *TalentUseService) PackInfo(pack int) ([]byte, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, e := s.load()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var groups []uint64
|
||||
for g := range v.Skills {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] })
|
||||
var b []byte
|
||||
for _, g := range groups {
|
||||
state := v.Skills[g]
|
||||
rule := s.design.Rules[[2]uint64{g, state.Level}]
|
||||
day := s.now().UTC().Add(9*time.Hour - s.design.ResetSchedule.DailyReset).Format("2006-01-02")
|
||||
if (rule.Reset == 1 || rule.Class == 3 || rule.Class == 4 || rule.Class == 20) && state.Day != day {
|
||||
state.Count = 0
|
||||
}
|
||||
b = wire.AppendBytes(b, 13, talentUseWire(g, state, rule))
|
||||
}
|
||||
var npcKeys []string
|
||||
for key := range v.NPCs {
|
||||
npcKeys = append(npcKeys, key)
|
||||
}
|
||||
sort.Strings(npcKeys)
|
||||
for _, key := range npcKeys {
|
||||
end := v.NPCs[key]
|
||||
var p int
|
||||
var npc, g uint64
|
||||
if _, e = fmt.Sscanf(key, "%d/%d/%d", &p, &npc, &g); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if p == pack {
|
||||
row := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, npc), 2, g), 3, uint64(end))
|
||||
b = wire.AppendBytes(b, 5, row)
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (s *TalentUseService) nextNPCReset(reset uint64) time.Time {
|
||||
shift := 9*time.Hour - s.design.ResetSchedule.DailyReset
|
||||
clock := s.now().UTC().Add(shift)
|
||||
next := time.Date(clock.Year(), clock.Month(), clock.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
if reset == 2 {
|
||||
for next.Weekday() != s.design.ResetSchedule.WeeklyDay {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
}
|
||||
return next.Add(-shift)
|
||||
}
|
||||
|
||||
func (s *TalentUseService) ShopDiscount(pack, npc uint64) (uint64, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, e := s.load()
|
||||
if e != nil {
|
||||
return 0, e
|
||||
}
|
||||
return v.Discounts[s.session+":"+fmt.Sprintf("%d/%d", pack, npc)], nil
|
||||
}
|
||||
|
||||
// ConsumeOverwhelm authorizes the follow-up result for one successful cast.
|
||||
// The one-minute handoff window is this server's request policy, not a claimed
|
||||
// official skill duration; the field domain validates every actual monster.
|
||||
func (s *TalentUseService) ConsumeOverwhelm(identity string, count uint64) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if identity == "" || count == 0 {
|
||||
return fmt.Errorf("player: invalid overwhelm settlement")
|
||||
}
|
||||
v, err := s.load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receiptKey := "overwhelm:" + identity
|
||||
digest := fmt.Sprint(count)
|
||||
if prior, ok := v.Receipts[receiptKey]; ok {
|
||||
if prior.Digest != digest {
|
||||
return fmt.Errorf("player: changed overwhelm settlement")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var chosen uint64
|
||||
var latest int64
|
||||
for group, state := range v.Skills {
|
||||
rule, ok := s.design.Rules[[2]uint64{group, state.Level}]
|
||||
if !ok || rule.Class != 3 || state.Settled || state.Count == 0 {
|
||||
continue
|
||||
}
|
||||
if state.End <= s.now().UnixMilli() && s.now().UnixMilli()-state.End <= 60000 && state.End > latest {
|
||||
chosen = group
|
||||
latest = state.End
|
||||
}
|
||||
}
|
||||
if chosen == 0 {
|
||||
return fmt.Errorf("player: overwhelm result has no pending successful cast")
|
||||
}
|
||||
state := v.Skills[chosen]
|
||||
state.Settled = true
|
||||
v.Skills[chosen] = state
|
||||
v.Receipts[receiptKey] = talentUseReceipt{Digest: digest}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.Save("talentuse", b)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type talentEconomyTest struct {
|
||||
inventory *Inventory
|
||||
calls int
|
||||
}
|
||||
|
||||
func (e *talentEconomyTest) Apply(identity string, _ []gamedata.Reward, r []gamedata.Reward) ([]byte, error) {
|
||||
e.calls++
|
||||
var rewards []gamedata.BattleReward
|
||||
for _, v := range r {
|
||||
rewards = append(rewards, gamedata.BattleReward{Type: v.Type, ID: v.ID, Count: v.Count})
|
||||
}
|
||||
items, err := e.inventory.GrantOnce(identity, rewards)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []byte
|
||||
for _, v := range items {
|
||||
out = wire.AppendBytes(out, 1, ItemWire(v))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func talentTest(t *testing.T, store stateio.Store, class uint64) (*TalentUseService, *CharacterStore, *Wallet, *talentEconomyTest) {
|
||||
t.Helper()
|
||||
inventory, err := OpenInventory(store, &Starter{Version: "2.35.10"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chars, err := OpenCharacterStore(store, []Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100, TalentLevel: 1}}, inventory, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chars.AttachMaxHealth(func(Character) (uint64, error) { return 100, nil })
|
||||
if err = chars.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wallet, err := OpenWallet(store, Currency{Catalyst: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = wallet.EnsurePersisted(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := &gamedata.TalentUseDesign{Characters: map[uint64]gamedata.TalentUseCharacter{350: {Group: 42, MaxLevel: 5}}, Rules: map[[2]uint64]gamedata.TalentUseRule{{42, 1}: {Group: 42, Level: 1, Class: class, Catalyst: 5, Experience: 3, Values: []float64{100, 10, 0}}}, Rewards: map[uint64][]gamedata.Reward{9: {{Type: 8, ID: 987, Count: 2}}}, Growth: &gamedata.TalentGrowthDesign{Characters: map[uint64]gamedata.CharacterTalent{350: {GrowthGroup: 6, MaxLevel: 5}}, Levels: map[[2]uint64]gamedata.TalentGrowthLevel{{6, 1}: {Level: 1, NeedExp: 10}}}, NPCs: func(int) (map[uint64]gamedata.TalentNPC, error) {
|
||||
return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{9}}}, nil
|
||||
}}
|
||||
e := &talentEconomyTest{inventory: inventory}
|
||||
s, err := NewTalentUseService(d, store, chars, inventory, wallet, e)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.BeginSession("test")
|
||||
s.now = func() time.Time { return time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) }
|
||||
s.AttachContext(func() (int, uint64, bool, error) { return 21, 211, false, nil })
|
||||
return s, chars, wallet, e
|
||||
}
|
||||
func talentRequest(seq uint64, targets ...uint64) []byte {
|
||||
b := wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, 77)
|
||||
for _, id := range targets {
|
||||
b = wire.AppendVarint(b, 3, id)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestTalentNPCRewardCostExperienceAndRestartReplay(t *testing.T) {
|
||||
store := stateio.NewMemory()
|
||||
s, chars, wallet, e := talentTest(t, store, 1)
|
||||
b := talentRequest(1, 11)
|
||||
code, body, handled, err := s.Handle("/TalentSkillUse", b)
|
||||
if err != nil || code != 43 || !handled {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := chars.Find(77)
|
||||
if wallet.Snapshot().Catalyst != 45 || c.TalentExp != 3 || e.calls != 1 {
|
||||
t.Fatal("talent did not charge/grant experience/reward")
|
||||
}
|
||||
if reward, ok, _ := wire.Bytes(body, 3); !ok || len(reward) == 0 {
|
||||
t.Fatal("talent omitted actual reward")
|
||||
}
|
||||
next, _, _, _ := talentTest(t, store, 1)
|
||||
_, again, _, err := next.Handle("/TalentSkillUse", b)
|
||||
if err != nil || !bytes.Equal(body, again) {
|
||||
t.Fatal("restart lost exact talent replay")
|
||||
}
|
||||
if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(2, 11)); err == nil {
|
||||
t.Fatal("NPC cooldown permitted repeat theft")
|
||||
}
|
||||
if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(1, 12)); err == nil {
|
||||
t.Fatal("changed sequence accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentDurationAndDailyLimitsComeFromDesign(t *testing.T) {
|
||||
s, _, _, _ := talentTest(t, stateio.NewMemory(), 13)
|
||||
now := s.now()
|
||||
s.now = func() time.Time { return now }
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2)); err == nil {
|
||||
t.Fatal("duration skill reused while active")
|
||||
}
|
||||
now = now.Add(10 * time.Second)
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(3)); err != nil {
|
||||
t.Fatal("duration did not expire")
|
||||
}
|
||||
s, _, _, _ = talentTest(t, stateio.NewMemory(), 4)
|
||||
now = s.now()
|
||||
s.now = func() time.Time { return now }
|
||||
r := s.design.Rules[[2]uint64{42, 1}]
|
||||
r.Values[0] = 1
|
||||
s.design.Rules[[2]uint64{42, 1}] = r
|
||||
s.AttachEffect(4, func(string, Character, gamedata.TalentUseRule, []uint64) ([]byte, error) { return nil, nil })
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 3)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2, 3)); err == nil {
|
||||
t.Fatal("daily skill limit ignored")
|
||||
}
|
||||
now = now.Add(24 * time.Hour)
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(3, 3)); err != nil {
|
||||
t.Fatal("daily limit did not reset")
|
||||
}
|
||||
}
|
||||
|
||||
type talentFailStore struct{ stateio.Store }
|
||||
|
||||
func (s talentFailStore) Save(name string, b []byte) error {
|
||||
if name == "talentuse" {
|
||||
return errors.New("receipt write failed")
|
||||
}
|
||||
return s.Store.Save(name, b)
|
||||
}
|
||||
func TestTalentReceiptFailureRollsBackRewardCostAndExperience(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, _, _, _ := talentTest(t, repo, 1)
|
||||
s.store = talentFailStore{repo}
|
||||
op, err := repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = s.Handle("/TalentSkillUse", talentRequest(1, 11)); err == nil {
|
||||
t.Fatal("receipt failure ignored")
|
||||
}
|
||||
if err = op.Rollback(); err != nil && !errors.Is(err, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo.Close()
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
next, chars, wallet, e := talentTest(t, repo, 1)
|
||||
c, _ := chars.Find(77)
|
||||
if c.TalentExp != 0 || wallet.Snapshot().Catalyst != 50 || len(e.inventory.All()) != 0 {
|
||||
t.Fatal("partial talent mutation survived rollback")
|
||||
}
|
||||
op, err = repo.BeginOperation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(1, 11)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = op.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTalentSkillFoodConsumesOneWithoutChargingCasterResources(t *testing.T) {
|
||||
s, _, wallet, _ := talentTest(t, stateio.NewMemory(), 13)
|
||||
s.design.Foods = map[uint64]uint64{22: 42}
|
||||
items, err := s.inventory.GrantOnce("test-food", []gamedata.BattleReward{{Type: 5, ID: 22, Count: 2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 4, items[0].InvenIndex)
|
||||
_, reply, _, err := s.Handle("/TalentSkillUse", b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.inventory.All()[0].Count != 1 || wallet.Snapshot().Catalyst != 50 {
|
||||
t.Fatal("skill food duplicated catalyst charge or did not consume one")
|
||||
}
|
||||
_, again, _, err := s.Handle("/TalentSkillUse", b)
|
||||
if err != nil || !bytes.Equal(reply, again) || s.inventory.All()[0].Count != 1 {
|
||||
t.Fatal("skill food retry consumed twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharmCompanionsExpireWithoutPermanentCollectionGrant(t *testing.T) {
|
||||
s, chars, _, _ := talentTest(t, stateio.NewMemory(), 19)
|
||||
s.now = func() time.Time { return time.Now() }
|
||||
s.design.NPCs = func(int) (map[uint64]gamedata.TalentNPC, error) {
|
||||
return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{0}, CharmCharacters: []uint64{999}}}, nil
|
||||
}
|
||||
s.design.CharmCharacter = func(uint64) (gamedata.StoryCharacterDesign, error) {
|
||||
return gamedata.StoryCharacterDesign{CharacterID: 999, Level: 1, CostumeID: 111, HP: 100}, nil
|
||||
}
|
||||
_, reply, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 11))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, ok, _ := wire.Bytes(reply, 5)
|
||||
if !ok {
|
||||
t.Fatal("charm did not return companion")
|
||||
}
|
||||
index, _, _ := wire.Varint(w, 1)
|
||||
c, found := chars.Find(index)
|
||||
if !found || !IsCharmCharacter(c) || c.ExpiryTime == 0 || len(chars.All()) != 2 {
|
||||
t.Fatal("charm was not a temporary visible character")
|
||||
}
|
||||
c.ExpiryTime = uint64(time.Now().Add(-time.Second).UnixMilli())
|
||||
if err = chars.ensureCharmCharacters([]Character{c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found = chars.Find(index); found || len(chars.All()) != 1 {
|
||||
t.Fatal("expired charm companion remained playable")
|
||||
}
|
||||
if chars.collection != nil {
|
||||
t.Fatal("charm created a collection grant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharHealingRevivesFatigueChargesPerTargetAndReplays(t *testing.T) {
|
||||
s, chars, wallet, _ := talentTest(t, stateio.NewMemory(), 10)
|
||||
r := s.design.Rules[[2]uint64{42, 1}]
|
||||
r.Values[0] = 0
|
||||
s.design.Rules[[2]uint64{42, 1}] = r
|
||||
if err := chars.SetCurrentHealth(77, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 77), 3, 77)
|
||||
code, response, _, err := s.Handle("/CharHealing", b)
|
||||
if err != nil || code != 62 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hp, _ := chars.CurrentHealth(77)
|
||||
if hp != 1 || wallet.Snapshot().Catalyst != 45 {
|
||||
t.Fatal("fatigue revival did not restore1HP with exact catalyst cost")
|
||||
}
|
||||
_, again, _, err := s.Handle("/CharHealing", b)
|
||||
if err != nil || !bytes.Equal(response, again) || wallet.Snapshot().Catalyst != 45 {
|
||||
t.Fatal("revival replay charged twice")
|
||||
}
|
||||
if _, _, _, err = s.Handle("/CharHealing", wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, 2), 2, 77), 3, 77)); err == nil {
|
||||
t.Fatal("living character revived again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBargainPercentIsSessionDiscountAndNotCooldownDuration(t *testing.T) {
|
||||
s, _, _, _ := talentTest(t, stateio.NewMemory(), 16)
|
||||
r := s.design.Rules[[2]uint64{42, 1}]
|
||||
r.Values[1] = 35
|
||||
s.design.Rules[[2]uint64{42, 1}] = r
|
||||
s.design.NPCs = func(int) (map[uint64]gamedata.TalentNPC, error) {
|
||||
return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{0}}}, nil
|
||||
}
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 11)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
discount, err := s.ShopDiscount(21, 11)
|
||||
if err != nil || discount != 35 {
|
||||
t.Fatal("bargain did not grant35 percent")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2, 11)); err != nil {
|
||||
t.Fatal("bargain percent incorrectly treated as35 second cooldown")
|
||||
}
|
||||
s.BeginSession("other")
|
||||
discount, err = s.ShopDiscount(21, 11)
|
||||
if err != nil || discount != 0 {
|
||||
t.Fatal("bargain leaked to new login session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackTalentProjectionUsesActualLevelAndResetsUsageOnNextDay(t *testing.T) {
|
||||
s, _, _, _ := talentTest(t, stateio.NewMemory(), 13)
|
||||
s.design.Rules[[2]uint64{42, 2}] = gamedata.TalentUseRule{Group: 42, Level: 2, Class: 13, Values: []float64{0.75, 99}}
|
||||
b := []byte(`{"Skills":{"42":{"End":1,"Count":1,"Day":"2026-10-04","Level":2}},"NPCs":{},"Receipts":{},"Discounts":{}}`)
|
||||
if err := s.store.Save("talentuse", b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projection, err := s.PackInfo(21)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, _, _ := wire.Bytes(projection, 13)
|
||||
cool, _, _ := wire.Varint(row, 3)
|
||||
if cool != 99 {
|
||||
t.Fatal("projection downgraded actual talent duration to level1")
|
||||
}
|
||||
r := s.design.Rules[[2]uint64{42, 2}]
|
||||
r.Class = 4
|
||||
s.design.Rules[[2]uint64{42, 2}] = r
|
||||
projection, err = s.PackInfo(21)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, _, _ = wire.Bytes(projection, 13)
|
||||
count, _, _ := wire.Varint(row, 4)
|
||||
if count != 0 {
|
||||
t.Fatal("next-day client projection kept exhausted daily count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharmSQLiteRestartAndNewCharmRefreshDamagedHealth(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, err := accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, chars, _, _ := talentTest(t, repo, 19)
|
||||
s.now = time.Now
|
||||
s.design.NPCs = func(int) (map[uint64]gamedata.TalentNPC, error) {
|
||||
return map[uint64]gamedata.TalentNPC{11: {ID: 11, MapID: 211, Groups: []uint64{42}, Rewards: []uint64{0}, CharmCharacters: []uint64{999}}}, nil
|
||||
}
|
||||
s.design.CharmCharacter = func(uint64) (gamedata.StoryCharacterDesign, error) {
|
||||
return gamedata.StoryCharacterDesign{CharacterID: 999, Level: 1, CostumeID: 111, HP: 100}, nil
|
||||
}
|
||||
_, response, _, err := s.Handle("/TalentSkillUse", talentRequest(1, 11))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, _, _ := wire.Bytes(response, 5)
|
||||
index, _, _ := wire.Varint(row, 1)
|
||||
if err = chars.SetCurrentHealth(index, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo.Close()
|
||||
repo, err = accountstate.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer repo.Close()
|
||||
next, chars, _, _ := talentTest(t, repo, 19)
|
||||
next.design = s.design
|
||||
next.now = time.Now
|
||||
c, found := chars.Find(index)
|
||||
if !found || c.ExpiryTime == 0 || c.HP != 5 {
|
||||
t.Fatal("restart lost temporary companion expiration or currentHP")
|
||||
}
|
||||
c.ExpiryTime = uint64(time.Now().Add(-time.Second).UnixMilli())
|
||||
if err = chars.ensureCharmCharacters([]Character{c}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := next.load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.NPCs = map[string]int64{}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = repo.Save("talentuse", b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, _, err = next.Handle("/TalentSkillUse", talentRequest(2, 11)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, found = chars.Find(index)
|
||||
if !found || c.HP != 100 {
|
||||
t.Fatal("new charm reused old companion damage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverwhelmRequiresPendingCastAndConsumesAuthorizationOnce(t *testing.T) {
|
||||
s, _, _, _ := talentTest(t, stateio.NewMemory(), 3)
|
||||
if err := s.ConsumeOverwhelm("first", 2); err == nil {
|
||||
t.Fatal("unearned overwhelm result accepted")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ConsumeOverwhelm("first", 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ConsumeOverwhelm("first", 2); err != nil {
|
||||
t.Fatal("exact overwhelm replay rejected")
|
||||
}
|
||||
if err := s.ConsumeOverwhelm("first", 3); err == nil {
|
||||
t.Fatal("changed monster count accepted")
|
||||
}
|
||||
if err := s.ConsumeOverwhelm("second", 2); err == nil {
|
||||
t.Fatal("one cast authorized two independent settlements")
|
||||
}
|
||||
if _, _, _, err := s.Handle("/TalentSkillUse", talentRequest(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := s.now().Add(61 * time.Second)
|
||||
s.now = func() time.Time { return now }
|
||||
if err := s.ConsumeOverwhelm("expired", 2); err == nil {
|
||||
t.Fatal("expired handoff authorization accepted")
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,9 @@ func (p *OwnedGameplayAchievementProvider) Snapshot() (GameplayAchievementSnapsh
|
||||
s := GameplayAchievementSnapshot{Characters: map[uint64]player.Character{}, Costumes: map[uint64]player.Costume{}, Equipment: map[uint64]player.Equipment{}, Items: map[[2]uint64]uint64{}}
|
||||
if p.Characters != nil {
|
||||
for _, v := range p.Characters.RawAll() {
|
||||
if player.IsCharmCharacter(v) || player.IsStoryCharacter(v) {
|
||||
continue
|
||||
}
|
||||
s.Characters[v.InvenIndex] = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,26 @@ func (s gameplayEquipment) All() []player.Equipment { return s.s.equipment }
|
||||
type gameplayItems struct{ s *gameplayTestSource }
|
||||
|
||||
func (s gameplayItems) All() []player.Item { return s.s.items }
|
||||
func TestTemporaryPartyMembersCannotGrantPermanentAcquisitionAchievements(t *testing.T) {
|
||||
source := &gameplayTestSource{}
|
||||
p := &OwnedGameplayAchievementProvider{Characters: source}
|
||||
before, e := p.Snapshot()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
source.characters = []player.Character{{ID: 10, InvenIndex: player.CharmCharacterIndexBase + 1, Level: 1}, {ID: 11, InvenIndex: player.StoryCharacterIndexBase + 1, Level: 1}, {ID: 12, InvenIndex: 77, Level: 1}}
|
||||
after, e := p.Snapshot()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
events, e := p.Events("/TalentSkillUse", nil, nil, before, after)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if len(after.Characters) != 1 || len(events) != 1 || events[0].Type != 19 || events[0].Count != 1 {
|
||||
t.Fatalf("temporary ownership inflated achievements: %+v", events)
|
||||
}
|
||||
}
|
||||
func TestGameplayAchievementProjectsChangesWithoutFakeHistoricalGets(t *testing.T) {
|
||||
design := &gamedata.AchievementCounterDesign{Groups: map[int][]int{701: {0}, 702: {0}, 703: {0}, 704: {0}, 705: {0}}, Conditions: map[int]gamedata.AchievementCondition{701: {Type: 7, SubType: 4}, 702: {Type: 22}, 703: {Type: 26}, 704: {Type: 55}, 705: {Type: 14, SubType: 909}}}
|
||||
service, err := NewAchievementService(design, stateio.NewMemory())
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) AttachAutoRecoveryPolicy(root, version string) error {
|
||||
p, e := gamedata.LoadPackRecoveryPolicy(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
s.autoRecoveryPolicy = p
|
||||
return nil
|
||||
}
|
||||
func (s *Service) AutoRecoveryAllowed() (bool, error) {
|
||||
if s.autoRecoveryPolicy == nil {
|
||||
return false, fmt.Errorf("world: automatic recovery pack policy unavailable")
|
||||
}
|
||||
p, e := s.CurrentPackID()
|
||||
if e != nil {
|
||||
return false, e
|
||||
}
|
||||
return s.autoRecoveryPolicy.Allowed(p, s.packCompleteFor(p)), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func (s *Service) AttachFieldBuffRuntime(root, version string) error {
|
||||
d, e := gamedata.LoadFieldBuffDesign(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
s.fieldBuffs = d
|
||||
s.AttachFieldMonsterDamage(s.applyMonsterFieldDamage)
|
||||
return nil
|
||||
}
|
||||
func (s *Service) applyMonsterFieldDamage(pack int, id uint64, identity string) ([][]byte, error) {
|
||||
r, ok := s.fieldBuffs[id]
|
||||
if !ok || (r.Type != 4 && r.Type != 5) || r.TargetType > 2 || math.IsNaN(r.Value) || math.IsInf(r.Value, 0) || r.Value < 0 {
|
||||
return nil, fmt.Errorf("world: invalid damaging field buff")
|
||||
}
|
||||
if s.characters == nil || s.decks == nil {
|
||||
return nil, fmt.Errorf("world: field health runtime unavailable")
|
||||
}
|
||||
entries := s.decks.CurrentFieldDeck()
|
||||
indices := make([]uint64, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
indices = append(indices, entry.CharacterInvenIndex)
|
||||
}
|
||||
if r.TargetType == 0 {
|
||||
party, e := s.ResolveStoryParty(pack, s.firstUnclearedQuestFor(pack))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if len(party) > 0 && s.questDifficulty(pack) == 0 {
|
||||
indices = []uint64{party[0].InvenIndex}
|
||||
} else if s.decks.FieldControlType() == 0 || len(indices) == 0 {
|
||||
indices = nil
|
||||
for _, entry := range s.decks.CurrentDeck() {
|
||||
indices = append(indices, entry.CharacterInvenIndex)
|
||||
}
|
||||
}
|
||||
if len(indices) > 1 {
|
||||
indices = indices[:1]
|
||||
}
|
||||
}
|
||||
var response [][]byte
|
||||
for _, index := range indices {
|
||||
c, found := s.characters.Find(index)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("world: field character missing")
|
||||
}
|
||||
hp, e := s.characters.CurrentHealth(c.InvenIndex)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if hp == 0 && r.TargetType != 0 {
|
||||
continue
|
||||
}
|
||||
damage := r.Value
|
||||
if r.Type == 5 {
|
||||
max, e := s.characters.MaxHealth(c.InvenIndex)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
damage = float64(max) * r.Value
|
||||
}
|
||||
remaining := uint64(0)
|
||||
if damage < float64(hp) {
|
||||
remaining = hp - uint64(damage)
|
||||
}
|
||||
if e = s.characters.SetCurrentHealth(c.InvenIndex, remaining); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
c.HP = remaining
|
||||
response = append(response, player.CharacterWire(c))
|
||||
if r.TargetType == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/versionconfig"
|
||||
"bd2server/internal/server/wire"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMonsterFieldDamagePartyFractionAndRequestReplay(t *testing.T) {
|
||||
s := testService()
|
||||
store := stateio.NewMemory()
|
||||
starter := &player.Starter{Version: versionconfig.State(), Characters: []player.Character{{InvenIndex: 77, ID: 350, Level: 1, HP: 100}, {InvenIndex: 88, ID: 650, Level: 1, HP: 100}}}
|
||||
items, e := player.OpenInventory(store, starter)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
chars, e := player.OpenCharacterStore(store, starter.Characters, items, "", "")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
chars.AttachMaxHealth(func(player.Character) (uint64, error) { return 100, nil })
|
||||
chars.EnsurePersisted()
|
||||
chars.SetCurrentHealth(77, 70)
|
||||
chars.SetCurrentHealth(88, 0)
|
||||
s.characters = chars
|
||||
s.decks, e = deck.NewStore(deck.Seed{Version: versionconfig.State(), FieldCharControlDeckType: 1, FieldDeck: []deck.FieldEntry{{Slot: 1, CharacterInvenIndex: 77}, {Slot: 2, CharacterInvenIndex: 88}}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s.fieldBuffs = map[uint64]gamedata.FieldBuffDesign{4: {ID: 4, Type: 5, TargetType: 1, Value: .25}, 5: {ID: 5, Type: 4, TargetType: 0, Value: 10}}
|
||||
s.AttachFieldMonsterState(store)
|
||||
s.BeginSession("login")
|
||||
s.AttachFieldMonsterDamage(s.applyMonsterFieldDamage)
|
||||
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
|
||||
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, Type: 3, FieldBuff: 4}}, nil
|
||||
}
|
||||
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 9)
|
||||
_, first, _, e := s.Handle("/FieldMonsterDamage", req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
hp, _ := chars.CurrentHealth(77)
|
||||
dead, _ := chars.CurrentHealth(88)
|
||||
if hp != 45 || dead != 0 {
|
||||
t.Fatal("wrong party damage", hp, dead)
|
||||
}
|
||||
_, again, _, e := s.Handle("/FieldMonsterDamage", req)
|
||||
if e != nil || string(first) != string(again) {
|
||||
t.Fatal("damage replay response differs", e)
|
||||
}
|
||||
hp, _ = chars.CurrentHealth(77)
|
||||
if hp != 45 {
|
||||
t.Fatal("retry damaged again", hp)
|
||||
}
|
||||
chars.SetCurrentHealth(77, 0)
|
||||
rows, e := s.applyMonsterFieldDamage(21, 5, "test")
|
||||
if e != nil || len(rows) != 1 {
|
||||
t.Fatal("dead leader should remain the controlled leader", e)
|
||||
}
|
||||
hp, _ = chars.CurrentHealth(88)
|
||||
if hp != 0 {
|
||||
t.Fatal("dead leader incorrectly switched to another character")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) grantFieldMonster(pack int, m gamedata.FieldMonsterDesign, identity string) ([]byte, error) {
|
||||
if s.researchEconomy == nil {
|
||||
return nil, fmt.Errorf("world: monster economy unavailable")
|
||||
}
|
||||
var rewards []gamedata.Reward
|
||||
if m.Reward.Type != 0 && m.Reward.Count > 0 {
|
||||
rewards = append(rewards, m.Reward)
|
||||
} else if m.BattleDeck != 0 {
|
||||
if s.monsterRewards == nil {
|
||||
return nil, fmt.Errorf("world: monster reward loader unavailable")
|
||||
}
|
||||
rows, e := s.monsterRewards(pack, m.BattleDeck)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
for _, r := range rows {
|
||||
rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
|
||||
}
|
||||
}
|
||||
return s.researchEconomy.Apply(identity, nil, rewards)
|
||||
}
|
||||
func monsterBundleFields(bundle []byte, itemField, equipField int) ([]byte, error) {
|
||||
var b []byte
|
||||
err := wire.Walk(bundle, func(f wire.Field) error {
|
||||
if f.Type == 2 && f.Number == 1 {
|
||||
b = wire.AppendBytes(b, itemField, f.Value)
|
||||
} else if f.Type == 2 && f.Number == 4 {
|
||||
b = wire.AppendBytes(b, equipField, f.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return b, err
|
||||
}
|
||||
func (s *Service) handleFieldMonsterEvent(path string, request []byte) (int, []byte, bool, error) {
|
||||
seqCheck, present, seqErr := wire.Varint(request, 1)
|
||||
if seqErr != nil || !present || seqCheck == 0 || seqCheck > 0x7fffffff {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
code := 83
|
||||
if path == "/FieldMonsterDamage" {
|
||||
code = 85
|
||||
}
|
||||
id, e := requestPack(request)
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
pack, e := s.CurrentPackID()
|
||||
if e != nil || !s.packUnlocked(pack) {
|
||||
return code, nil, true, ErrInvalidRequest
|
||||
}
|
||||
m, found, e := s.findFieldMonster(pack, id)
|
||||
if e != nil || !found || !s.monsterEligible(pack, m) {
|
||||
return code, nil, true, ErrInvalidRequest
|
||||
}
|
||||
if path == "/FieldMonsterEvent" && m.Type != 2 && m.Type != 3 {
|
||||
return code, nil, true, ErrInvalidRequest
|
||||
}
|
||||
if e = s.authorizeMonsterMap(pack, id); e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
seq, _, _ := wire.Varint(request, 1)
|
||||
if s.monsterSession == "" {
|
||||
return code, nil, true, fmt.Errorf("world: missing monster session")
|
||||
}
|
||||
key := fmt.Sprintf("%s:%s:%d", path, s.monsterSession, seq)
|
||||
if r, ok := v.Requests[key]; ok {
|
||||
if !bytes.Equal(r.Request, request) {
|
||||
return code, nil, true, ErrInvalidRequest
|
||||
}
|
||||
return code, r.Response, true, nil
|
||||
}
|
||||
state, e := s.monsterState(&v, pack, m)
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
var b []byte
|
||||
active := !state.Defeated && state.Respawn <= s.monsterTime().UnixMilli()
|
||||
instance := fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), id), state.Generation)
|
||||
if active {
|
||||
if m.Type == 2 && path == "/FieldMonsterEvent" && !v.Claims[instance] {
|
||||
bundle, e := s.grantFieldMonster(pack, m, instance)
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
b, e = monsterBundleFields(bundle, 3, 4)
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
v.Claims[instance] = true
|
||||
}
|
||||
dash, _, _ := wire.Varint(request, 3)
|
||||
if m.FieldBuff > 0 && (path == "/FieldMonsterDamage" || dash == 0) {
|
||||
if s.monsterDamage == nil {
|
||||
return code, nil, true, fmt.Errorf("world: monster damage runtime unavailable")
|
||||
}
|
||||
rows, e := s.monsterDamage(pack, m.FieldBuff, key)
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
for _, r := range rows {
|
||||
b = wire.AppendBytes(b, 1, r)
|
||||
}
|
||||
}
|
||||
if path == "/FieldMonsterEvent" {
|
||||
state.Defeated = true
|
||||
state.Respawn = s.nextMonsterSpawn(m)
|
||||
v.Monsters[monsterKey(pack, s.questDifficulty(pack), id)] = state
|
||||
}
|
||||
}
|
||||
if path == "/FieldMonsterEvent" {
|
||||
b = wire.AppendBytes(b, 2, monsterWire(m, state, true))
|
||||
}
|
||||
v.Requests[key] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: b}
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
return code, b, true, nil
|
||||
}
|
||||
|
||||
// Current clients receive collision rewards in FieldMonsterEventResponse;
|
||||
// FieldMonsterReward has no sender in this version and no pending reward queue.
|
||||
func (s *Service) handleFieldMonsterReward(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, e := wire.Varint(request, 1)
|
||||
if e != nil || !present || seq == 0 {
|
||||
return 84, nil, true, ErrInvalidRequest
|
||||
}
|
||||
return 84, []byte{}, true, nil
|
||||
}
|
||||
func (s *Service) ApplyTalentMonsterSummon(identity string, c player.Character, r gamedata.TalentUseRule, ids []uint64) ([]byte, error) {
|
||||
if identity == "" || len(ids) == 0 || r.Class != 20 {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
pack, e := s.CurrentPackID()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
var b []byte
|
||||
for _, id := range ids {
|
||||
if seen[id] {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
seen[id] = true
|
||||
m, found, e := s.findFieldMonster(pack, int(id))
|
||||
if e != nil || !found || m.Type != 2 || !s.monsterEligible(pack, m) {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
if e = s.authorizeMonsterMap(pack, int(id)); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
state, e := s.monsterState(&v, pack, m)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() {
|
||||
return nil, fmt.Errorf("world: summon target is not spawned")
|
||||
}
|
||||
instance := fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), m.ID), state.Generation)
|
||||
if !v.Claims[instance] {
|
||||
bundle, e := s.grantFieldMonster(pack, m, instance)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
extra, e := monsterBundleFields(bundle, 3, 4)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
b = append(b, extra...)
|
||||
v.Claims[instance] = true
|
||||
}
|
||||
state.Defeated = true
|
||||
state.Respawn = s.nextMonsterSpawn(m)
|
||||
v.Monsters[monsterKey(pack, s.questDifficulty(pack), m.ID)] = state
|
||||
b = wire.AppendBytes(b, 9, monsterWire(m, state, true))
|
||||
}
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPackDetailTracksKillRetryRespawnAndResearchAcrossRestart(t *testing.T) {
|
||||
s := testService()
|
||||
store := stateio.NewMemory()
|
||||
s.AttachFieldMonsterState(store)
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
s.monsterNow = func() time.Time { return now }
|
||||
s.packSummaryTargets = map[int]bool{21: true}
|
||||
s.packDetailDesign = func(int) (gamedata.PackDetailDesign, error) {
|
||||
return gamedata.PackDetailDesign{RegenMonsterIDs: []int{9}}, nil
|
||||
}
|
||||
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
|
||||
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, RegenSeconds: 10}}, nil
|
||||
}
|
||||
if e := s.state.MarkResearchObject(21, 42, nil); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
req := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 21)
|
||||
_, before, _, e := s.Handle("/PackDetailInfo", req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
research, _, _ := wire.Varint(before, 3)
|
||||
if research != 42 {
|
||||
t.Fatal("detail omitted researched object")
|
||||
}
|
||||
first, handled, e := s.BeginFieldMonsterBattle(21, 9, 33)
|
||||
if e != nil || !handled {
|
||||
t.Fatal(e)
|
||||
}
|
||||
dead, e := s.CompleteFieldMonsterBattle(21, 9, first)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
again, e := s.CompleteFieldMonsterBattle(21, 9, first)
|
||||
if e != nil || !bytes.Equal(dead, again) {
|
||||
t.Fatal("kill retry changed regeneration time", e)
|
||||
}
|
||||
if _, _, e = s.BeginFieldMonsterBattle(21, 9, 33); e == nil {
|
||||
t.Fatal("defeated monster entered before respawn")
|
||||
}
|
||||
_, detail, _, e := s.Handle("/PackDetailInfo", req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
row, _, _ := wire.Bytes(detail, 1)
|
||||
respawn, _, _ := wire.Varint(row, 3)
|
||||
if respawn != uint64(now.Add(10*time.Second).UnixMilli()) {
|
||||
t.Fatal("wrong respawn deadline")
|
||||
}
|
||||
_, summary, _, e := s.Handle("/PackSummaryInfoList", req)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
row, _, _ = wire.Bytes(summary, 1)
|
||||
count, _, _ := wire.Varint(row, 4)
|
||||
if count != 1 {
|
||||
t.Fatal("summary omitted defeated monster")
|
||||
}
|
||||
now = now.Add(11 * time.Second)
|
||||
next, _, e := s.BeginFieldMonsterBattle(21, 9, 33)
|
||||
if e != nil || next == first {
|
||||
t.Fatal("new regeneration did not create distinct reward identity", e)
|
||||
}
|
||||
if _, e = s.CompleteFieldMonsterBattle(21, 9, first); e != nil {
|
||||
t.Fatal("delayed receipt retry rejected", e)
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if v.Monsters[monsterKey(21, 0, 9)].Defeated {
|
||||
t.Fatal("old retry killed new generation")
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,30 @@ import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"encoding/binary"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AttachFieldMonsterState uses the gameplay snapshot store, which participates
|
||||
// in the account transaction. No in-memory lifetime survives a rollback.
|
||||
type fieldMonsterState struct {
|
||||
Generation uint64
|
||||
Start, Respawn, LifeEnd int64
|
||||
Defeated bool
|
||||
Period string
|
||||
}
|
||||
type fieldMonsterSnapshot struct {
|
||||
Monsters map[string]fieldMonsterState
|
||||
Claims map[string]bool
|
||||
Requests map[string]fieldMonsterReply
|
||||
}
|
||||
type fieldMonsterReply struct{ Request, Response []byte }
|
||||
|
||||
func (s *Service) BeginSession(id string) { s.monsterSession = id }
|
||||
func (s *Service) AttachFieldMonsterDamage(f func(int, uint64, string) ([][]byte, error)) {
|
||||
s.monsterDamage = f
|
||||
}
|
||||
|
||||
func (s *Service) AttachFieldMonsterState(store stateio.Store) error {
|
||||
if store == nil {
|
||||
return fmt.Errorf("world: missing monster state store")
|
||||
@@ -19,115 +35,358 @@ func (s *Service) AttachFieldMonsterState(store stateio.Store) error {
|
||||
s.monsterStore = store
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleMonsterInfo(request []byte) (int, []byte, bool, error) {
|
||||
seq, ok, err := wire.Varint(request, 1)
|
||||
if err != nil || !ok || seq == 0 {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
func (s *Service) monsterTime() time.Time {
|
||||
if s.monsterNow != nil {
|
||||
return s.monsterNow()
|
||||
}
|
||||
groups := map[int]bool{}
|
||||
err = wire.Walk(request, func(f wire.Field) error {
|
||||
if f.Number != 2 {
|
||||
return nil
|
||||
}
|
||||
if f.Type != 0 && f.Type != 2 {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
data := f.Value
|
||||
for len(data) > 0 {
|
||||
v, n := binary.Uvarint(data)
|
||||
if n <= 0 || v == 0 || v > uint64(^uint32(0)>>1) {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
groups[int(v)] = true
|
||||
if len(groups) > 4096 {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
data = data[n:]
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
return time.Now()
|
||||
}
|
||||
func (s *Service) loadMonsterState() (fieldMonsterSnapshot, error) {
|
||||
v := fieldMonsterSnapshot{Monsters: map[string]fieldMonsterState{}, Claims: map[string]bool{}, Requests: map[string]fieldMonsterReply{}}
|
||||
if s.monsterStore == nil {
|
||||
return v, nil
|
||||
}
|
||||
pack, err := s.CurrentPackID()
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
b, e := s.monsterStore.Load("field_monster_runtime")
|
||||
if e != nil || b == nil {
|
||||
return v, e
|
||||
}
|
||||
if !s.packUnlocked(pack) || s.monsterLoader == nil {
|
||||
return 0, nil, true, fmt.Errorf("%w: unavailable monster pack", ErrInvalidRequest)
|
||||
if e = stateio.RequireExactJSONObject(b, "Monsters", "Claims", "Requests"); e != nil {
|
||||
return v, e
|
||||
}
|
||||
design, err := s.monsterLoader(pack)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
if e = json.Unmarshal(b, &v); e != nil || v.Monsters == nil || v.Claims == nil || v.Requests == nil {
|
||||
return v, fmt.Errorf("world: invalid field monster state")
|
||||
}
|
||||
var response []byte
|
||||
starts := map[string]int64{}
|
||||
if s.monsterStore != nil {
|
||||
b, e := s.monsterStore.Load("fieldmonsters")
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
if b != nil {
|
||||
if e = json.Unmarshal(b, &starts); e != nil || starts == nil {
|
||||
return 0, nil, true, fmt.Errorf("world: invalid monster lifetime state")
|
||||
}
|
||||
}
|
||||
for _, t := range starts {
|
||||
if t <= 0 {
|
||||
return 0, nil, true, fmt.Errorf("world: invalid monster start time")
|
||||
}
|
||||
for _, m := range v.Monsters {
|
||||
if m.Generation == 0 || m.Start <= 0 || m.Respawn < 0 || m.LifeEnd < 0 {
|
||||
return v, fmt.Errorf("world: invalid monster timestamps")
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func (s *Service) saveMonsterState(v fieldMonsterSnapshot) error {
|
||||
if s.monsterStore == nil {
|
||||
return fmt.Errorf("world: monster persistence unavailable")
|
||||
}
|
||||
b, e := json.Marshal(v)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return s.monsterStore.Save("field_monster_runtime", b)
|
||||
}
|
||||
func monsterKey(pack, difficulty, id int) string {
|
||||
return fmt.Sprintf("%d/%d/%d", pack, difficulty, id)
|
||||
}
|
||||
func (s *Service) monsterPeriod(m gamedata.FieldMonsterDesign) (string, error) {
|
||||
if m.ResetType == 0 {
|
||||
return "own", nil
|
||||
}
|
||||
reset := 0
|
||||
if m.ResetType == 2 {
|
||||
reset = 3
|
||||
}
|
||||
return s.fieldReset.Period(reset, s.monsterTime())
|
||||
}
|
||||
func (s *Service) monsterState(v *fieldMonsterSnapshot, pack int, m gamedata.FieldMonsterDesign) (fieldMonsterState, error) {
|
||||
key := monsterKey(pack, s.questDifficulty(pack), m.ID)
|
||||
old, exists := v.Monsters[key]
|
||||
period, e := s.monsterPeriod(m)
|
||||
if e != nil {
|
||||
return old, e
|
||||
}
|
||||
now := s.monsterTime().UnixMilli()
|
||||
fresh := func(generation uint64, start int64) fieldMonsterState {
|
||||
r := fieldMonsterState{Generation: generation, Start: start, Period: period}
|
||||
if m.LifeSeconds > 0 {
|
||||
r.LifeEnd = start + int64(m.LifeSeconds)*1000
|
||||
}
|
||||
return r
|
||||
}
|
||||
if !exists {
|
||||
old = fresh(1, now)
|
||||
} else if old.Period != period {
|
||||
old = fresh(old.Generation+1, now)
|
||||
} else if old.Respawn > 0 && now >= old.Respawn {
|
||||
old = fresh(old.Generation+1, old.Respawn)
|
||||
} else if old.LifeEnd > 0 && now >= old.LifeEnd && !old.Defeated {
|
||||
old.Defeated = true
|
||||
old.Respawn = old.LifeEnd + int64(m.RegenSeconds)*1000
|
||||
if old.Respawn <= now {
|
||||
old = fresh(old.Generation+1, now)
|
||||
}
|
||||
}
|
||||
v.Monsters[key] = old
|
||||
return old, nil
|
||||
}
|
||||
func (s *Service) monsterEligible(pack int, m gamedata.FieldMonsterDesign) bool {
|
||||
return m.QuestID == 0 || s.state.QuestCleared(m.QuestID, pack, s.questDifficulty(pack))
|
||||
}
|
||||
func monsterWire(m gamedata.FieldMonsterDesign, state fieldMonsterState, eligible bool) []byte {
|
||||
b := wire.AppendVarint(nil, 1, uint64(m.ID))
|
||||
if m.BattleDeck != 0 {
|
||||
b = wire.AppendVarint(b, 2, m.BattleDeck)
|
||||
}
|
||||
if state.Respawn > 0 {
|
||||
b = wire.AppendVarint(b, 3, uint64(state.Respawn))
|
||||
}
|
||||
if state.LifeEnd > 0 {
|
||||
b = wire.AppendVarint(b, 4, uint64(state.LifeEnd))
|
||||
}
|
||||
b = wire.AppendVarint(b, 5, uint64(m.GroupID))
|
||||
if eligible {
|
||||
b = wire.AppendVarint(b, 6, 1)
|
||||
}
|
||||
return b
|
||||
}
|
||||
func (s *Service) monsterRows(pack int, filter map[int]bool) ([][]byte, error) {
|
||||
if s.monsterLoader == nil {
|
||||
return nil, fmt.Errorf("%w: missing field monster design", ErrInvalidRequest)
|
||||
}
|
||||
design, e := s.monsterLoader(pack)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
var rows [][]byte
|
||||
changed := false
|
||||
difficulty := 0
|
||||
if selection, ok := s.state.Selection(pack); ok {
|
||||
difficulty = selection.Difficulty
|
||||
}
|
||||
for _, m := range design {
|
||||
if !groups[m.GroupID] {
|
||||
if m.GroupID == 0 {
|
||||
continue
|
||||
}
|
||||
active := m.QuestID == 0 || s.state.QuestCleared(m.QuestID, pack, difficulty)
|
||||
row := wire.AppendVarint(nil, 1, uint64(m.ID))
|
||||
row = wire.AppendVarint(row, 5, uint64(m.GroupID))
|
||||
if m.BattleDeck != 0 {
|
||||
row = wire.AppendVarint(row, 2, m.BattleDeck)
|
||||
if filter != nil && !filter[m.ID] {
|
||||
continue
|
||||
}
|
||||
if active && m.LifeSeconds > 0 {
|
||||
if s.monsterStore == nil {
|
||||
return 0, nil, true, fmt.Errorf("world: monster lifetime state unavailable")
|
||||
eligible := s.monsterEligible(pack, m)
|
||||
state := fieldMonsterState{}
|
||||
if eligible {
|
||||
state, e = s.monsterState(&v, pack, m)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
key := fmt.Sprintf("%d/%d/%d", pack, difficulty, m.ID)
|
||||
start, ok := starts[key]
|
||||
if !ok {
|
||||
start = time.Now().UnixMilli()
|
||||
starts[key] = start
|
||||
changed = true
|
||||
}
|
||||
end := start + int64(m.LifeSeconds)*1000
|
||||
row = wire.AppendVarint(row, 4, uint64(end))
|
||||
active = time.Now().UnixMilli() < end
|
||||
changed = true
|
||||
}
|
||||
if active {
|
||||
row = wire.AppendVarint(row, 6, 1)
|
||||
}
|
||||
response = wire.AppendBytes(response, 1, row)
|
||||
rows = append(rows, monsterWire(m, state, eligible))
|
||||
}
|
||||
if changed {
|
||||
b, e := json.Marshal(starts)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
if e = s.monsterStore.Save("fieldmonsters", b); e != nil {
|
||||
return 0, nil, true, e
|
||||
if changed && s.monsterStore != nil {
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
return 51, response, true, nil
|
||||
return rows, nil
|
||||
}
|
||||
func (s *Service) handleMonsterInfo(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, e := wire.Varint(request, 1)
|
||||
if e != nil || !present || seq == 0 || seq > 0x7fffffff {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
groups, e := intsRequest(request, 2)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
wanted := map[int]bool{}
|
||||
for _, g := range groups {
|
||||
if g == 0 || g > 0x7fffffff {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
wanted[int(g)] = true
|
||||
}
|
||||
pack, e := s.CurrentPackID()
|
||||
if e != nil || !s.packUnlocked(pack) || s.monsterLoader == nil {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
design, e := s.monsterLoader(pack)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
filter := map[int]bool{}
|
||||
for _, m := range design {
|
||||
if wanted[m.GroupID] {
|
||||
filter[m.ID] = true
|
||||
}
|
||||
}
|
||||
rows, e := s.monsterRows(pack, filter)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
var b []byte
|
||||
for _, r := range rows {
|
||||
b = wire.AppendBytes(b, 1, r)
|
||||
}
|
||||
return 51, b, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) attachFieldMonsterDesign(root, version string) {
|
||||
s.monsterLoader = func(pack int) ([]gamedata.FieldMonsterDesign, error) {
|
||||
return gamedata.LoadFieldMonsters(root, version, pack)
|
||||
}
|
||||
s.monsterRewards = func(pack int, deck uint64) ([]gamedata.BattleReward, error) {
|
||||
return gamedata.BattleDeckRewards(root, version, pack, deck)
|
||||
}
|
||||
s.monsterMaps = func(pack int) (map[int][]int, error) { return gamedata.LoadFieldMonsterMaps(root, version, pack) }
|
||||
}
|
||||
func (s *Service) authorizeMonsterMap(pack, id int) error {
|
||||
if s.monsterMaps == nil {
|
||||
return nil
|
||||
}
|
||||
maps, e := s.monsterMaps(pack)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
current, e := s.currentFieldMap(pack)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
for _, mapID := range maps[id] {
|
||||
if current == mapID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("world: monster outside current map")
|
||||
}
|
||||
func (s *Service) findFieldMonster(pack, id int) (gamedata.FieldMonsterDesign, bool, error) {
|
||||
if s.monsterLoader == nil {
|
||||
return gamedata.FieldMonsterDesign{}, false, nil
|
||||
}
|
||||
d, e := s.monsterLoader(pack)
|
||||
if e != nil {
|
||||
return gamedata.FieldMonsterDesign{}, false, e
|
||||
}
|
||||
for _, m := range d {
|
||||
if m.ID == id {
|
||||
return m, true, nil
|
||||
}
|
||||
}
|
||||
return gamedata.FieldMonsterDesign{}, false, nil
|
||||
}
|
||||
|
||||
// BeginFieldMonsterBattle locks the regenerated instance for retry-safe rewards.
|
||||
// Scripted monsters without regeneration return handled=false.
|
||||
func (s *Service) BeginFieldMonsterBattle(pack int, id, deck uint64) (string, bool, error) {
|
||||
m, found, e := s.findFieldMonster(pack, int(id))
|
||||
if found && m.GroupID == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
if e != nil || !found {
|
||||
return "", found, e
|
||||
}
|
||||
if !s.packUnlocked(pack) || !s.monsterEligible(pack, m) {
|
||||
return "", true, ErrInvalidRequest
|
||||
}
|
||||
deckOK := deck == m.BattleDeck
|
||||
for _, allowed := range m.BattleDecks {
|
||||
if deck == allowed {
|
||||
deckOK = true
|
||||
}
|
||||
}
|
||||
if m.BattleDeck != 0 && !deckOK {
|
||||
return "", true, fmt.Errorf("world: field monster deck mismatch")
|
||||
}
|
||||
if e = s.authorizeMonsterMap(pack, int(id)); e != nil {
|
||||
return "", true, e
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return "", true, e
|
||||
}
|
||||
state, e := s.monsterState(&v, pack, m)
|
||||
if e != nil {
|
||||
return "", true, e
|
||||
}
|
||||
if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() {
|
||||
return "", true, fmt.Errorf("world: monster is not spawned")
|
||||
}
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return "", true, e
|
||||
}
|
||||
return fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), m.ID), state.Generation), true, nil
|
||||
}
|
||||
func (s *Service) CompleteFieldMonsterBattle(pack int, id uint64, instance string) ([]byte, error) {
|
||||
m, found, e := s.findFieldMonster(pack, int(id))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
key := monsterKey(pack, s.questDifficulty(pack), m.ID)
|
||||
state, exists := v.Monsters[key]
|
||||
if !exists {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
if !v.Claims[instance] {
|
||||
if instance != fmt.Sprintf("fieldmonster:%s:%d", key, state.Generation) {
|
||||
return nil, fmt.Errorf("world: stale monster battle")
|
||||
}
|
||||
state.Defeated = true
|
||||
state.Respawn = s.nextMonsterSpawn(m)
|
||||
v.Monsters[key] = state
|
||||
v.Claims[instance] = true
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
return monsterWire(m, state, s.monsterEligible(pack, m)), nil
|
||||
}
|
||||
func (s *Service) nextMonsterSpawn(m gamedata.FieldMonsterDesign) int64 {
|
||||
now := s.monsterTime()
|
||||
if m.ResetType == 0 {
|
||||
return now.UnixMilli() + int64(m.RegenSeconds)*1000
|
||||
}
|
||||
shift := 9*time.Hour - s.fieldReset.DailyReset
|
||||
t := now.UTC().Add(shift)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
|
||||
if m.ResetType == 2 {
|
||||
delta := (int(s.fieldReset.WeeklyDay) - int(day.Weekday()) + 7) % 7
|
||||
day = day.AddDate(0, 0, delta)
|
||||
}
|
||||
return day.Add(-shift).UnixMilli()
|
||||
}
|
||||
func (s *Service) handleFieldMonsterRegen(request []byte) (int, []byte, bool, error) {
|
||||
id, e := requestPack(request)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
pack, e := s.CurrentPackID()
|
||||
if e != nil || !s.packUnlocked(pack) {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
m, found, e := s.findFieldMonster(pack, id)
|
||||
if e != nil || !found {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
seq, _, _ := wire.Varint(request, 1)
|
||||
identity := fmt.Sprintf("regen:%s:%d", s.monsterSession, seq)
|
||||
if s.monsterSession == "" {
|
||||
return 0, nil, true, fmt.Errorf("world: missing monster session")
|
||||
}
|
||||
if reply, ok := v.Requests[identity]; ok {
|
||||
if !bytes.Equal(request, reply.Request) {
|
||||
return 0, nil, true, ErrInvalidRequest
|
||||
}
|
||||
return 139, reply.Response, true, nil
|
||||
}
|
||||
state, e := s.monsterState(&v, pack, m)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
if !state.Defeated {
|
||||
state.Defeated = true
|
||||
state.Respawn = s.nextMonsterSpawn(m)
|
||||
v.Monsters[monsterKey(pack, s.questDifficulty(pack), m.ID)] = state
|
||||
}
|
||||
response := wire.AppendBytes(nil, 1, monsterWire(m, state, s.monsterEligible(pack, m)))
|
||||
v.Requests[identity] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response}
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
return 139, response, true, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This regression exercises the missing field entry route with duplicate packed
|
||||
@@ -55,12 +56,14 @@ func TestMonsterInfoRegenerationGroupsAndQuestGate(t *testing.T) {
|
||||
|
||||
func TestMonsterLifetimeSurvivesReaderRestartWithoutRenewal(t *testing.T) {
|
||||
s := testService()
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
s.monsterNow = func() time.Time { return now }
|
||||
store := stateio.NewMemory()
|
||||
if err := s.AttachFieldMonsterState(store); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
|
||||
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, LifeSeconds: 30}}, nil
|
||||
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, LifeSeconds: 30, RegenSeconds: 10}}, nil
|
||||
}
|
||||
request := wire.AppendVarint(wire.AppendVarint(nil, 1, 1), 2, 7)
|
||||
_, first, _, err := s.Handle("/MonsterInfo", request)
|
||||
@@ -69,14 +72,13 @@ func TestMonsterLifetimeSurvivesReaderRestartWithoutRenewal(t *testing.T) {
|
||||
}
|
||||
s2 := testService()
|
||||
s2.monsterLoader = s.monsterLoader
|
||||
s2.monsterNow = s.monsterNow
|
||||
s2.AttachFieldMonsterState(store)
|
||||
_, again, _, err := s2.Handle("/MonsterInfo", request)
|
||||
if err != nil || !bytes.Equal(first, again) {
|
||||
t.Fatal("restart renewed finite monster lifetime")
|
||||
}
|
||||
if err := store.Save("fieldmonsters", []byte(`{"21/0/9":1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now = now.Add(31 * time.Second)
|
||||
_, expired, _, err := s2.Handle("/MonsterInfo", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -85,8 +87,18 @@ func TestMonsterLifetimeSurvivesReaderRestartWithoutRenewal(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
active, _, _ := wire.Varint(row, 6)
|
||||
if active != 0 {
|
||||
t.Fatal("expired monster revived by reading info")
|
||||
respawn, _, _ := wire.Varint(row, 3)
|
||||
if respawn <= uint64(now.UnixMilli()) {
|
||||
t.Fatal("expired monster did not wait for regeneration")
|
||||
}
|
||||
now = now.Add(10 * time.Second)
|
||||
_, revived, _, err := s2.Handle("/MonsterInfo", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row, _, _ = wire.Bytes(revived, 1)
|
||||
respawn, _, _ = wire.Varint(row, 3)
|
||||
if respawn != 0 {
|
||||
t.Fatal("regeneration time did not create the next instance")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) AttachOverwhelmAuthorization(f func(string, uint64) error) {
|
||||
s.overwhelmAuthorize = f
|
||||
}
|
||||
func (s *Service) AttachOverwhelmHunting(h interface {
|
||||
ValidateBattle(int, uint64, uint64, uint64) error
|
||||
CompleteBattle(int, uint64, uint64, uint64, string) ([]byte, [][]byte, error)
|
||||
}) {
|
||||
s.overwhelmHunting = h
|
||||
}
|
||||
func nestedMessages(b []byte, number int) ([][]byte, error) {
|
||||
var out [][]byte
|
||||
e := wire.Walk(b, func(f wire.Field) error {
|
||||
if f.Number == number {
|
||||
if f.Type != 2 {
|
||||
return ErrInvalidRequest
|
||||
}
|
||||
out = append(out, append([]byte(nil), f.Value...))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, e
|
||||
}
|
||||
|
||||
type overwhelmedMonster struct {
|
||||
ID, Group, Deck, Mode uint64
|
||||
Definition gamedata.FieldMonsterDesign
|
||||
Instance string
|
||||
Costs []gamedata.Reward
|
||||
}
|
||||
|
||||
func (s *Service) AttachOverwhelmDesign(root, version string) error {
|
||||
rows, e := gamedata.LoadSkyWayOverwhelm(root, version)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
s.overwhelmSky = rows
|
||||
s.overwhelmQuest = func(pack, quest int) (gamedata.OverwhelmQuestRule, error) {
|
||||
return gamedata.LoadOverwhelmQuest(root, version, pack, quest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleOverwhelm(request []byte) (int, []byte, bool, error) {
|
||||
seq, present, e := wire.Varint(request, 1)
|
||||
if e != nil || !present || seq == 0 || seq > 0x7fffffff || s.monsterSession == "" {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
pack, e := s.CurrentPackID()
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
v, e := s.loadMonsterState()
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
identity := fmt.Sprintf("overwhelm:%s:%d", s.monsterSession, seq)
|
||||
if prior, ok := v.Requests[identity]; ok {
|
||||
if !bytes.Equal(prior.Request, request) {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
return 275, prior.Response, true, nil
|
||||
}
|
||||
raw, e := nestedMessages(request, 2)
|
||||
if e != nil || len(raw) == 0 || len(raw) > 4096 {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
var targets []overwhelmedMonster
|
||||
seen := map[uint64]bool{}
|
||||
for _, b := range raw {
|
||||
var m overwhelmedMonster
|
||||
for f, dst := range map[int]*uint64{1: &m.Group, 2: &m.ID, 3: &m.Deck, 4: &m.Mode} {
|
||||
*dst, _, e = wire.Varint(b, f)
|
||||
if e != nil || *dst > 0x7fffffff {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
}
|
||||
if m.ID == 0 || seen[m.ID] {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
seen[m.ID] = true
|
||||
if m.Mode == 5 {
|
||||
definition, found, x := s.findFieldMonster(pack, int(m.ID))
|
||||
if x != nil || !found || definition.UseBattleSkip != 1 || definition.Type >= 2 {
|
||||
return 275, nil, true, fmt.Errorf("world: hunting monster cannot be overwhelmed")
|
||||
}
|
||||
if s.overwhelmHunting == nil {
|
||||
return 275, nil, true, fmt.Errorf("world: hunting overwhelm runtime missing")
|
||||
}
|
||||
if e = s.overwhelmHunting.ValidateBattle(pack, 5, m.ID, m.Deck); e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
} else {
|
||||
if m.Mode != 1 && m.Mode != 2 && m.Mode != 4 && (m.Mode < 9 || m.Mode > 15) {
|
||||
return 275, nil, true, fmt.Errorf("world: unavailable overwhelm battle mode %d", m.Mode)
|
||||
}
|
||||
definition, found, e := s.findFieldMonster(pack, int(m.ID))
|
||||
if e != nil || !found || (definition.UseBattleSkip != 1 && definition.Type != 3) {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
m.Definition = definition
|
||||
if definition.Type == 2 || definition.Type == 4 {
|
||||
return 275, nil, true, fmt.Errorf("world: private field monster cannot be overwhelmed")
|
||||
}
|
||||
if e = s.authorizeMonsterMap(pack, int(m.ID)); e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
validDeck := definition.Type == 3 && m.Deck == 0
|
||||
for _, d := range definition.BattleDecks {
|
||||
if d == m.Deck {
|
||||
validDeck = true
|
||||
}
|
||||
}
|
||||
if definition.BattleDeck == m.Deck {
|
||||
validDeck = true
|
||||
}
|
||||
if !validDeck {
|
||||
return 275, nil, true, fmt.Errorf("world: overwhelm deck mismatch")
|
||||
}
|
||||
if m.Mode >= 9 && m.Mode <= 15 {
|
||||
mapID, e := s.currentFieldMap(pack)
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
found := false
|
||||
for _, rule := range s.overwhelmSky {
|
||||
if rule.Map != uint64(mapID) || rule.Group+8 != m.Mode {
|
||||
continue
|
||||
}
|
||||
cost := uint64(0)
|
||||
if rule.Boss == m.ID {
|
||||
found = true
|
||||
cost = rule.BossAP
|
||||
} else {
|
||||
for i, id := range rule.Monsters {
|
||||
if id == m.ID {
|
||||
found = true
|
||||
cost = rule.AP[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
typ := uint64(21)
|
||||
if rule.APType == 2 {
|
||||
typ = 23
|
||||
}
|
||||
if cost > 0 {
|
||||
m.Costs = []gamedata.Reward{{Type: typ, Count: cost}}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return 275, nil, true, fmt.Errorf("world: skyway monster does not match current dungeon")
|
||||
}
|
||||
m.Instance = fmt.Sprintf("skyway:%s:%d", identity, m.ID)
|
||||
} else if m.Mode == 2 {
|
||||
if int(m.Group) != definition.GroupID || definition.GroupID == 0 || !s.monsterEligible(pack, definition) {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
state, e := s.monsterState(&v, pack, definition)
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
if state.Defeated || state.Respawn > s.monsterTime().UnixMilli() {
|
||||
return 275, nil, true, fmt.Errorf("world: overwhelm monster not spawned")
|
||||
}
|
||||
m.Instance = fmt.Sprintf("fieldmonster:%s:%d", monsterKey(pack, s.questDifficulty(pack), definition.ID), state.Generation)
|
||||
} else {
|
||||
if m.Group != 0 {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
m.Instance = fmt.Sprintf("questmonster:%d:%d:%d", pack, s.questDifficulty(pack), m.ID)
|
||||
}
|
||||
}
|
||||
targets = append(targets, m)
|
||||
}
|
||||
quests, e := nestedMessages(request, 3)
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
var updates [][]byte
|
||||
var updated []uint64
|
||||
for _, b := range quests {
|
||||
quest, _, e := wire.Varint(b, 1)
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
qp, _, e := wire.Varint(b, 2)
|
||||
if e != nil || int(qp) != pack || quest == 0 || !s.canClear(pack, int(quest)) {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
values, e := intsRequest(b, 3)
|
||||
if e != nil || len(values) == 0 {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
if e = s.validateOverwhelmQuest(pack, int(quest), values, targets); e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
req := wire.AppendVarint(wire.AppendVarint(wire.AppendVarint(nil, 1, seq), 2, quest), 3, qp)
|
||||
for _, value := range values {
|
||||
if value > 0x7fffffff {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
req = wire.AppendVarint(req, 4, value)
|
||||
}
|
||||
updates = append(updates, req)
|
||||
updated = append(updated, quest)
|
||||
}
|
||||
if s.overwhelmAuthorize == nil {
|
||||
return 275, nil, true, fmt.Errorf("world: overwhelm requires successful talent use")
|
||||
}
|
||||
if e = s.overwhelmAuthorize(identity, uint64(len(targets))); e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
var response, bundle []byte
|
||||
for _, m := range targets {
|
||||
if m.Mode == 5 {
|
||||
reward, monsters, e := s.overwhelmHunting.CompleteBattle(pack, 5, m.ID, m.Deck, fmt.Sprintf("%s:%d", identity, m.ID))
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
bundle = append(bundle, reward...)
|
||||
for _, row := range monsters {
|
||||
response = wire.AppendBytes(response, 1, row)
|
||||
}
|
||||
} else {
|
||||
if !v.Claims[m.Instance] && m.Definition.Type != 3 {
|
||||
definition := m.Definition
|
||||
definition.BattleDeck = m.Deck
|
||||
var reward []byte
|
||||
var e error
|
||||
if len(m.Costs) > 0 {
|
||||
if s.monsterRewards == nil || s.researchEconomy == nil {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
rs, x := s.monsterRewards(pack, m.Deck)
|
||||
if x != nil {
|
||||
return 275, nil, true, x
|
||||
}
|
||||
rewards := make([]gamedata.Reward, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
rewards = append(rewards, gamedata.Reward{Type: r.Type, ID: r.ID, Count: r.Count})
|
||||
}
|
||||
reward, e = s.researchEconomy.Apply(m.Instance, m.Costs, rewards)
|
||||
} else {
|
||||
reward, e = s.grantFieldMonster(pack, definition, m.Instance)
|
||||
}
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
bundle = append(bundle, reward...)
|
||||
}
|
||||
v.Claims[m.Instance] = true
|
||||
if m.Definition.GroupID > 0 {
|
||||
state, e := s.monsterState(&v, pack, m.Definition)
|
||||
if e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
state.Defeated = true
|
||||
state.Respawn = s.nextMonsterSpawn(m.Definition)
|
||||
v.Monsters[monsterKey(pack, s.questDifficulty(pack), int(m.ID))] = state
|
||||
response = wire.AppendBytes(response, 1, monsterWire(m.Definition, state, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, req := range updates {
|
||||
if _, _, _, e = s.handleQuestUpdate(req); e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
response = wire.AppendVarint(response, 2, updated[i])
|
||||
}
|
||||
response = wire.AppendBytes(response, 3, bundle)
|
||||
v.Requests[identity] = fieldMonsterReply{Request: append([]byte(nil), request...), Response: response}
|
||||
if e = s.saveMonsterState(v); e != nil {
|
||||
return 275, nil, true, e
|
||||
}
|
||||
return 275, response, true, nil
|
||||
}
|
||||
func (s *Service) validateOverwhelmQuest(pack, quest int, values []uint64, targets []overwhelmedMonster) error {
|
||||
if s.overwhelmQuest == nil {
|
||||
return fmt.Errorf("world: overwhelm quest rules missing")
|
||||
}
|
||||
r, e := s.overwhelmQuest(pack, quest)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
mapID, e := s.currentFieldMap(pack)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
gain := uint64(0)
|
||||
for _, m := range targets {
|
||||
switch r.Type {
|
||||
case 3:
|
||||
for _, id := range r.Targets {
|
||||
if id == m.ID {
|
||||
gain++
|
||||
}
|
||||
}
|
||||
case 1:
|
||||
for _, enemy := range r.Enemies[m.Deck] {
|
||||
for _, id := range r.Targets {
|
||||
if enemy == id {
|
||||
gain++
|
||||
}
|
||||
}
|
||||
}
|
||||
case 8:
|
||||
if len(r.Targets) > 0 && r.Targets[0] == uint64(mapID) {
|
||||
gain += uint64(len(r.Enemies[m.Deck]))
|
||||
}
|
||||
}
|
||||
}
|
||||
old := uint64(0)
|
||||
if p, ok := s.state.QuestInPack(quest, pack, s.questDifficultyFor(pack, quest)); ok && len(p.Values) > 0 {
|
||||
old = uint64(p.Values[0])
|
||||
}
|
||||
if gain == 0 || len(values) != 1 || values[0] != old+gain {
|
||||
return fmt.Errorf("world: quest does not match overwhelmed monsters")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/accountstate"
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/stateio"
|
||||
"bd2server/internal/server/wire"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type overwhelmFailedState struct{ stateio.Store }
|
||||
|
||||
func (s overwhelmFailedState) Save(name string, b []byte) error {
|
||||
if name == "field_monster_runtime" {
|
||||
return fmt.Errorf("failed receipt")
|
||||
}
|
||||
return s.Store.Save(name, b)
|
||||
}
|
||||
|
||||
type overwhelmPersistedEconomy struct{ stateio.Store }
|
||||
|
||||
func (s overwhelmPersistedEconomy) Apply(_ string, _ []gamedata.Reward, rewards []gamedata.Reward) ([]byte, error) {
|
||||
if e := s.Save("overwhelm_test_reward", []byte{1}); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func TestOverwhelmRewardAndReceiptRollbackTogether(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.db")
|
||||
repo, e := accountstate.Open(path)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s := testService()
|
||||
s.AttachFieldMonsterState(overwhelmFailedState{repo})
|
||||
s.BeginSession("login")
|
||||
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
|
||||
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}}, nil
|
||||
}
|
||||
s.researchEconomy = overwhelmPersistedEconomy{repo}
|
||||
s.AttachOverwhelmAuthorization(func(string, uint64) error { return repo.Save("overwhelm_test_authorization", []byte{1}) })
|
||||
op, e := repo.BeginOperation()
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, _, _, e = s.Handle("/Overwhelm", overwhelmRequest(1, 9)); e == nil {
|
||||
t.Fatal("expected failed final receipt")
|
||||
}
|
||||
if e = op.Rollback(); e != nil && !errors.Is(e, stateio.ErrStateRecoveryRequired) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
repo.Close()
|
||||
repo, e = accountstate.Open(path)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
defer repo.Close()
|
||||
for _, name := range []string{"overwhelm_test_authorization", "overwhelm_test_reward", "field_monster_runtime"} {
|
||||
b, e := repo.Load(name)
|
||||
if e != nil || b != nil {
|
||||
t.Fatal("partial batch survived rollback", name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func overwhelmRequest(seq uint64, ids ...uint64) []byte {
|
||||
b := wire.AppendVarint(nil, 1, seq)
|
||||
for _, id := range ids {
|
||||
m := wire.AppendVarint(nil, 1, 7)
|
||||
m = wire.AppendVarint(m, 2, id)
|
||||
m = wire.AppendVarint(m, 3, 33)
|
||||
m = wire.AppendVarint(m, 4, 2)
|
||||
b = wire.AppendBytes(b, 2, m)
|
||||
}
|
||||
return b
|
||||
}
|
||||
func TestOverwhelmBatchPrevalidationAuthorizationAndGenerationReplay(t *testing.T) {
|
||||
s := testService()
|
||||
store := stateio.NewMemory()
|
||||
s.AttachFieldMonsterState(store)
|
||||
s.BeginSession("login")
|
||||
now := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)
|
||||
s.monsterNow = func() time.Time { return now }
|
||||
s.monsterLoader = func(int) ([]gamedata.FieldMonsterDesign, error) {
|
||||
return []gamedata.FieldMonsterDesign{{ID: 9, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}, {ID: 10, GroupID: 7, BattleDeck: 33, UseBattleSkip: 1, RegenSeconds: 10, Reward: gamedata.Reward{Type: 5, ID: 400, Count: 2}}}, nil
|
||||
}
|
||||
eco := &researchEconomySpy{}
|
||||
s.researchEconomy = eco
|
||||
auth := 0
|
||||
s.AttachOverwhelmAuthorization(func(identity string, n uint64) error {
|
||||
if n != 2 {
|
||||
return fmt.Errorf("wrong batch size")
|
||||
}
|
||||
auth++
|
||||
return nil
|
||||
})
|
||||
if _, _, _, e := s.Handle("/Overwhelm", overwhelmRequest(1, 9, 999)); e == nil || eco.calls != 0 || auth != 0 {
|
||||
t.Fatal("invalid tail partially settled batch", e)
|
||||
}
|
||||
req := overwhelmRequest(2, 9, 10)
|
||||
code, first, _, e := s.Handle("/Overwhelm", req)
|
||||
if e != nil || code != 275 || eco.calls != 2 || auth != 1 {
|
||||
t.Fatal("valid batch not settled", code, eco.calls, auth, e)
|
||||
}
|
||||
now = now.Add(11 * time.Second)
|
||||
_, retry, _, e := s.Handle("/Overwhelm", req)
|
||||
if e != nil || !bytes.Equal(first, retry) || eco.calls != 2 || auth != 1 {
|
||||
t.Fatal("retry awarded or authorized twice", e)
|
||||
}
|
||||
if _, _, _, e = s.Handle("/Overwhelm", overwhelmRequest(2, 10, 9)); e == nil {
|
||||
t.Fatal("sequence reuse accepted")
|
||||
}
|
||||
if _, _, _, e = s.Handle("/Overwhelm", overwhelmRequest(3, 9, 10)); e != nil || eco.calls != 4 {
|
||||
t.Fatal("new generation did not award", eco.calls, e)
|
||||
}
|
||||
}
|
||||
@@ -25,19 +25,34 @@ func (s *Service) handlePackDetail(request []byte) (int, []byte, bool, error) {
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if len(design.RegenMonsterIDs) > 0 {
|
||||
// Empty monster state means defeated to the client. Until regeneration
|
||||
// has a persisted domain, do not invent active flags or claim history.
|
||||
return 0, nil, true, fmt.Errorf("%w: PackDetailInfo pack %d requires regenerating monster state", ErrInvalidRequest, packID)
|
||||
filter := map[int]bool{}
|
||||
for _, id := range design.RegenMonsterIDs {
|
||||
filter[id] = true
|
||||
}
|
||||
ids, err := s.openedFieldObjects(packID)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
response := []byte{}
|
||||
if len(filter) > 0 {
|
||||
rows, e := s.monsterRows(packID, filter)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
for _, row := range rows {
|
||||
response = wire.AppendBytes(response, 1, row)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
response = wire.AppendBytes(response, 2, wire.AppendVarint(nil, 1, uint64(id)))
|
||||
}
|
||||
research, e := s.state.ResearchObjects(packID)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
for _, id := range research {
|
||||
response = wire.AppendVarint(response, 3, uint64(id))
|
||||
}
|
||||
return 627, response, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,38 @@ func (s *Service) handlePackSummary(request []byte) (int, []byte, bool, error) {
|
||||
if regen > 0 {
|
||||
row = wire.AppendVarint(row, 3, regen)
|
||||
}
|
||||
if s.packDetailDesign != nil && s.monsterLoader != nil {
|
||||
design, e := s.packDetailDesign(int(id))
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
filter := map[int]bool{}
|
||||
for _, n := range design.RegenMonsterIDs {
|
||||
filter[n] = true
|
||||
}
|
||||
rows, e := s.monsterRows(int(id), filter)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
var defeated uint64
|
||||
for _, m := range rows {
|
||||
active, _, _ := wire.Varint(m, 6)
|
||||
respawn, _, _ := wire.Varint(m, 3)
|
||||
if active == 0 || respawn > uint64(s.monsterTime().UnixMilli()) {
|
||||
defeated++
|
||||
}
|
||||
}
|
||||
if defeated > 0 {
|
||||
row = wire.AppendVarint(row, 4, defeated)
|
||||
}
|
||||
}
|
||||
research, e := s.state.ResearchObjects(int(id))
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
if len(research) > 0 {
|
||||
row = wire.AppendVarint(row, 5, uint64(len(research)))
|
||||
}
|
||||
response = wire.AppendBytes(response, 1, row)
|
||||
}
|
||||
return 625, response, true, nil
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package world
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/gamedata"
|
||||
"bd2server/internal/server/player"
|
||||
"bd2server/internal/server/wire"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (s *Service) AttachTalentPackInfo(source func(int) ([]byte, error)) { s.talentPackInfo = source }
|
||||
|
||||
func (s *Service) TalentFieldContext() (int, uint64, bool, error) {
|
||||
pack, err := s.CurrentPackID()
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
mapID, err := s.currentFieldMap(pack)
|
||||
if err != nil {
|
||||
return 0, 0, false, err
|
||||
}
|
||||
battle := s.battleActive != nil && s.battleActive()
|
||||
return pack, uint64(mapID), battle, nil
|
||||
}
|
||||
|
||||
// Hidden packs deliberately suppress SaveUserPosition, so a calendar-authored
|
||||
// initial map is authoritative until the client supplies a valid saved map.
|
||||
// Ordinary pack starts live in GateSpotData assets, not QuestTable.MapId;
|
||||
// guessing a quest target map would authorize interactions in another scene.
|
||||
func (s *Service) currentFieldMap(pack int) (int, error) {
|
||||
if !s.packUnlocked(pack) {
|
||||
return 0, ErrInvalidRequest
|
||||
}
|
||||
if saved, ok := s.state.Position(); ok && saved.PackID == pack && saved.Difficulty == s.questDifficulty(pack) && saved.Position.MapID > 0 {
|
||||
return saved.Position.MapID, nil
|
||||
}
|
||||
if event, found, e := s.resolveEventFieldPack(pack); e != nil {
|
||||
return 0, e
|
||||
} else if found && event.InitialMapID > 0 {
|
||||
return int(event.InitialMapID), nil
|
||||
}
|
||||
return 0, fmt.Errorf("world: current scene map is not yet known")
|
||||
}
|
||||
|
||||
// Absorb consumes the exact acquisition objects requested by the client using
|
||||
// the same period receipts and reward graph as an ordinary field interaction.
|
||||
func (s *Service) ApplyTalentFieldAbsorb(_ string, _ player.Character, rule gamedata.TalentUseRule, ids []uint64) ([]byte, error) {
|
||||
pack, mapID, _, err := s.TalentFieldContext()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rule.Class != 4 || len(ids) == 0 {
|
||||
return nil, ErrInvalidRequest
|
||||
}
|
||||
design, err := s.fieldObjectDesign(pack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []byte
|
||||
for _, id := range ids {
|
||||
obj, ok := design.Objects[int(id)]
|
||||
if !ok || obj.MapID != int(mapID) || (obj.Type != 1 && obj.Type != 3) {
|
||||
return nil, fmt.Errorf("world: absorption target is not an acquisition object")
|
||||
}
|
||||
bundle, e := s.openFieldObject(pack, obj.GroupID, int(id))
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
e = wire.Walk(bundle, func(f wire.Field) error {
|
||||
to := map[int]int{1: 3, 2: 5, 3: 6, 4: 4}[f.Number]
|
||||
if to > 0 {
|
||||
out = wire.AppendBytes(out, to, f.Value)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bd2server/internal/server/deck"
|
||||
"bd2server/internal/server/gamedata"
|
||||
@@ -171,6 +172,7 @@ func (s *Service) setCurrentPack(packID int) {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
autoRecoveryPolicy *gamedata.PackRecoveryPolicy
|
||||
todayQuests *todayquest.Service
|
||||
huntingGround interface{ EnsureForPack(int) ([]byte, error) }
|
||||
battleActive func() bool
|
||||
@@ -189,6 +191,20 @@ type Service struct {
|
||||
fieldObjectLoader func(int) (gamedata.FieldObjectDesign, error)
|
||||
monsterLoader func(int) ([]gamedata.FieldMonsterDesign, error)
|
||||
monsterStore stateio.Store
|
||||
monsterNow func() time.Time
|
||||
monsterSession string
|
||||
monsterRewards func(int, uint64) ([]gamedata.BattleReward, error)
|
||||
monsterMaps func(int) (map[int][]int, error)
|
||||
monsterDamage func(int, uint64, string) ([][]byte, error)
|
||||
fieldBuffs map[uint64]gamedata.FieldBuffDesign
|
||||
talentPackInfo func(int) ([]byte, error)
|
||||
overwhelmAuthorize func(string, uint64) error
|
||||
overwhelmSky []gamedata.SkyWayOverwhelmRule
|
||||
overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error)
|
||||
overwhelmHunting interface {
|
||||
ValidateBattle(int, uint64, uint64, uint64) error
|
||||
CompleteBattle(int, uint64, uint64, uint64, string) ([]byte, [][]byte, error)
|
||||
}
|
||||
npcReputation *npcReputationRuntime
|
||||
fieldReset gamedata.FieldResetSchedule
|
||||
researchDesigns map[int]gamedata.FieldResearchDesign
|
||||
@@ -251,6 +267,14 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
switch path {
|
||||
case "/MonsterInfo":
|
||||
return s.handleMonsterInfo(request)
|
||||
case "/FieldMonsterRegen":
|
||||
return s.handleFieldMonsterRegen(request)
|
||||
case "/FieldMonsterEvent", "/FieldMonsterDamage":
|
||||
return s.handleFieldMonsterEvent(path, request)
|
||||
case "/FieldMonsterReward":
|
||||
return s.handleFieldMonsterReward(request)
|
||||
case "/Overwhelm":
|
||||
return s.handleOverwhelm(request)
|
||||
case "/QuestUpdate":
|
||||
return s.handleQuestUpdate(request)
|
||||
case "/FieldObjectInfo":
|
||||
@@ -310,7 +334,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
for _, char := range characters {
|
||||
seen[char.InvenIndex] = true
|
||||
}
|
||||
for _, entry := range s.decks.CurrentDeck() {
|
||||
deckCharacters := s.decks.CurrentDeck()
|
||||
for _, entry := range s.decks.CurrentFieldDeck() {
|
||||
deckCharacters = append(deckCharacters, deck.DeckEntry{CharacterInvenIndex: entry.CharacterInvenIndex})
|
||||
}
|
||||
for _, entry := range deckCharacters {
|
||||
if seen[entry.CharacterInvenIndex] {
|
||||
continue
|
||||
}
|
||||
@@ -324,7 +352,11 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
for _, character := range characters {
|
||||
response = wire.AppendBytes(response, 1, encodeCharacter(character))
|
||||
}
|
||||
response = wire.AppendVarint(response, 2, s.starter.FieldCharControlDeckType)
|
||||
control := s.starter.FieldCharControlDeckType
|
||||
if s.decks != nil {
|
||||
control = s.decks.FieldControlType()
|
||||
}
|
||||
response = wire.AppendVarint(response, 2, control)
|
||||
return 9, response, true, nil
|
||||
case "/CostumeInfo":
|
||||
if s.tutorialRosterRestricted() {
|
||||
@@ -369,7 +401,15 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
if eventPack, found, err := s.resolveEventFieldPack(pack); err != nil {
|
||||
return 0, nil, true, err
|
||||
} else if found {
|
||||
return s.enterEventFieldPack(eventPack)
|
||||
code, body, handled, e := s.enterEventFieldPack(eventPack)
|
||||
if e == nil && s.talentPackInfo != nil {
|
||||
extra, x := s.talentPackInfo(pack)
|
||||
if x != nil {
|
||||
return 0, nil, true, x
|
||||
}
|
||||
body = append(body, extra...)
|
||||
}
|
||||
return code, body, handled, e
|
||||
}
|
||||
if !s.packUnlocked(pack) {
|
||||
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack)
|
||||
@@ -384,6 +424,13 @@ func (s *Service) Handle(path string, request []byte) (int, []byte, bool, error)
|
||||
if err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
if s.talentPackInfo != nil {
|
||||
extra, e := s.talentPackInfo(pack)
|
||||
if e != nil {
|
||||
return 0, nil, true, e
|
||||
}
|
||||
response = append(response, extra...)
|
||||
}
|
||||
if err := s.state.SetActivePackID(pack); err != nil {
|
||||
return 0, nil, true, err
|
||||
}
|
||||
@@ -923,7 +970,13 @@ func (s *Service) accountPackInfo() []byte {
|
||||
// though their instances already exist in the versioned world seed.
|
||||
func (s *Service) PictorialCharacters() []player.Character {
|
||||
if !s.tutorialRosterRestricted() && s.characters != nil {
|
||||
return s.visibleOwnedCharacters(s.characters.RawAll())
|
||||
var permanent []player.Character
|
||||
for _, c := range s.visibleOwnedCharacters(s.characters.RawAll()) {
|
||||
if !player.IsCharmCharacter(c) {
|
||||
permanent = append(permanent, c)
|
||||
}
|
||||
}
|
||||
return permanent
|
||||
}
|
||||
return append([]player.Character(nil), s.starter.Characters...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user