feat(server): complete field talents, crafting, party recovery and pack monster lifecycle

This commit is contained in:
2026-10-05 23:14:47 +08:00
parent e30023053e
commit 76a2d72f05
64 changed files with 6208 additions and 246 deletions
+71 -1
View File
@@ -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,
+24
View File
@@ -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)
}
}
}
+6 -1
View File
@@ -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) {
+23 -18
View File
@@ -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 {
+46 -18
View File
@@ -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 {
+242
View File
@@ -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)
}
}
+49 -60
View File
@@ -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":
+242
View File
@@ -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")
}
}
+18 -1
View File
@@ -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
-1
View File
@@ -26,7 +26,6 @@ var emptyResponses = map[string]int{
"/CommunityRewardInfo": 289,
"/DailyStoryInfo": 538,
"/DatingInfo": 361,
"/DispatchInfo": 0,
"/EquipPresetInfo": 253,
"/EvilCastleDailyRewardState": 441,
"/EvilCastleTowerInfo": 200,
+17
View File
@@ -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")
+4 -7
View File
@@ -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,
}
+6 -1
View File
@@ -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()
}
+32 -9
View File
@@ -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)
}
}
}
}
+144
View File
@@ -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
}
+70 -1
View File
@@ -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{}}
+104
View File
@@ -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
}
+318
View File
@@ -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")
}
}
+55 -10
View File
@@ -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")
}
}
+105
View File
@@ -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)
}
}
+8 -4
View File
@@ -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)
+11
View File
@@ -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
}
+209
View File
@@ -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)
}
}
+98
View File
@@ -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)
}
}
+192
View File
@@ -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
}
+520
View File
@@ -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())
+25
View File
@@ -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")
}
}
+351 -92
View File
@@ -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
}
+19 -7
View File
@@ -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")
}
}
+331
View File
@@ -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
}
+121
View File
@@ -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)
}
}
+19 -4
View File
@@ -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
}
+32
View File
@@ -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
}
+57 -4
View File
@@ -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...)
}