feat(all): implement SkyWay progression, battles and dispatch; fix field reward position ordering

This commit is contained in:
2026-10-08 19:55:29 +08:00
parent 06a83ada28
commit baae9fdddb
26 changed files with 1576 additions and 130 deletions
+50 -2
View File
@@ -3,7 +3,11 @@ package app
import ( import (
"bd2server/internal/server/design/gamedata" "bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/battle/monsterhunt" "bd2server/internal/server/domain/battle/monsterhunt"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/commerce" "bd2server/internal/server/domain/commerce"
"bd2server/internal/server/domain/world"
"bd2server/internal/server/protocol/wire"
"encoding/binary"
"fmt" "fmt"
) )
@@ -15,7 +19,8 @@ type designCatalog struct {
fieldBuffs map[uint64]gamedata.FieldBuffDesign fieldBuffs map[uint64]gamedata.FieldBuffDesign
recovery *gamedata.PackRecoveryPolicy recovery *gamedata.PackRecoveryPolicy
researchCharacters map[uint64]bool researchCharacters map[uint64]bool
overwhelmSky []gamedata.SkyWayOverwhelmRule skyway *gamedata.SkyWayDesign
skywaySchedules []world.SkyWaySchedule
eventPlay *gamedata.EventPlayCatalog eventPlay *gamedata.EventPlayCatalog
monsterHunt *monsterhunt.Rules monsterHunt *monsterhunt.Rules
presetDesign *gamedata.PresetDesign presetDesign *gamedata.PresetDesign
@@ -345,7 +350,50 @@ func loadDesign(c *configuration, seeds *seedCatalog) (*designCatalog, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
d.overwhelmSky, err = gamedata.LoadSkyWayOverwhelm(gameData, gameDataVersion) d.skyway, err = gamedata.LoadSkyWayDesign(gameData, gameDataVersion)
if err != nil {
return nil, err
}
_, schedule, handled, err := seeds.defaults.Handle(command.Context{}, "/SkyWayScheduleInfo", wire.AppendVarint(nil, 1, 1))
if err != nil || !handled {
return nil, fmt.Errorf("load SkyWay schedule policy: %w", err)
}
err = wire.Walk(schedule, func(f wire.Field) error {
if f.Number != 1 || f.Type != 2 {
return fmt.Errorf("invalid SkyWay schedule seed")
}
var row world.SkyWaySchedule
row.Group, _, err = wire.Varint(f.Value, 1)
if err != nil {
return err
}
row.Bonus, _, err = wire.Varint(f.Value, 3)
if err != nil {
return err
}
err = wire.Walk(f.Value, func(day wire.Field) error {
if day.Number != 2 {
return nil
}
if day.Type != 0 && day.Type != 2 {
return fmt.Errorf("invalid SkyWay day seed")
}
for rest := day.Value; len(rest) > 0; {
v, n := binary.Uvarint(rest)
if n <= 0 {
return wire.ErrMalformed
}
row.Days = append(row.Days, v)
rest = rest[n:]
}
return nil
})
if err != nil {
return err
}
d.skywaySchedules = append(d.skywaySchedules, row)
return nil
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
+6
View File
@@ -180,5 +180,11 @@ func (p *playerAssembly) events(ctx command.Context) error {
return fmt.Errorf("restore recorded achievement history: %w", err) return fmt.Errorf("restore recorded achievement history: %w", err)
} }
p.eventTasksService.AttachInventoryProvider(&eventtasks.InventoryProjection{Items: p.ownedItems, Equipment: p.ownedEquipment, Costumes: p.collection, StateVersion: p.stateRepository.ObservationVersion}) p.eventTasksService.AttachInventoryProvider(&eventtasks.InventoryProjection{Items: p.ownedItems, Equipment: p.ownedEquipment, Costumes: p.collection, StateVersion: p.stateRepository.ObservationVersion})
p.worldService.AttachSkyWayProgress(p.eventTasksService.RecordSkyWayClear, func(ctx command.Context, group uint64) error {
return p.missionService.RecordEvent(ctx, 227, group, 1, p.worldService.MissionsUnlocked)
})
p.huntingService.AttachDispatchProgress(func(ctx command.Context, count uint64) error {
return p.missionService.RecordEvent(ctx, 23, 0, count, p.worldService.MissionsUnlocked)
})
return nil return nil
} }
+7 -1
View File
@@ -78,6 +78,12 @@ func (p *playerAssembly) gameplay(ctx command.Context) error {
return fmt.Errorf("load event economy: %w", err) return fmt.Errorf("load event economy: %w", err)
} }
p.eventEconomy.AttachHuntingAP(p.huntingService) p.eventEconomy.AttachHuntingAP(p.huntingService)
if err = p.worldService.AttachSkyWay(ctx, p.design.skyway, p.gameplayStore, p.eventEconomy, p.huntingService, p.design.skywaySchedules, p.design.rewardGraph); err != nil {
return fmt.Errorf("attach SkyWay: %w", err)
}
p.battleService.AttachSkyWay(p.worldService)
p.huntingService.AttachDispatchEligibility(p.worldService.SkyWayDispatchEligibility)
p.huntingService.AttachSkyWayDispatch(p.worldService.SkyWayDispatchCosts, p.worldService.SkyWayDispatchExchange, p.worldService.SkyWayDispatchBonus)
p.talentUseService, err = roster.NewTalentUseService(p.design.talentUseDesign, p.gameplayStore, p.worldService.CharacterService(), p.ownedItems, p.wallet, p.eventEconomy) p.talentUseService, err = roster.NewTalentUseService(p.design.talentUseDesign, p.gameplayStore, p.worldService.CharacterService(), p.ownedItems, p.wallet, p.eventEconomy)
if err != nil { if err != nil {
return fmt.Errorf("load field talent state: %w", err) return fmt.Errorf("load field talent state: %w", err)
@@ -91,7 +97,7 @@ func (p *playerAssembly) gameplay(ctx command.Context) error {
p.worldService.AttachTalentPackInfo(p.talentUseService.PackInfo) p.worldService.AttachTalentPackInfo(p.talentUseService.PackInfo)
p.worldService.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm) p.worldService.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm)
p.worldService.AttachOverwhelmHunting(p.huntingService) p.worldService.AttachOverwhelmHunting(p.huntingService)
if err := p.worldService.AttachOverwhelmDesign(p.design.source, p.design.overwhelmSky); err != nil { if err := p.worldService.AttachOverwhelmDesign(p.design.source); err != nil {
return fmt.Errorf("attach overwhelm design: %w", err) return fmt.Errorf("attach overwhelm design: %w", err)
} }
p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb) p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb)
+1 -1
View File
@@ -17,6 +17,7 @@ import (
func (p *playerAssembly) session(ctx command.Context) error { func (p *playerAssembly) session(ctx command.Context) error {
p.handlers = []session.Handler{ p.handlers = []session.Handler{
p.worldService,
p.progressState, p.progressState,
p.cashService, p.cashService,
p.cashBonuses, p.cashBonuses,
@@ -35,7 +36,6 @@ func (p *playerAssembly) session(ctx command.Context) error {
p.battleService, p.battleService,
p.huntingService, p.huntingService,
p.monsterHuntService, p.monsterHuntService,
p.worldService,
p.worldService.CharacterService(), p.worldService.CharacterService(),
p.progressState, p.progressState,
p.deckStateStore, p.deckStateStore,
@@ -0,0 +1,364 @@
//go:build integration
package app
import (
"bytes"
"context"
"fmt"
"testing"
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/protocol/wire"
)
type skyWayPlayerScenario struct {
factory *PlayerFactory
owner *playerInstance
account string
sequence uint64
blue []byte
}
type skyWayAssets struct{ gold, rice, torch, crystal uint64 }
func newSkyWayPlayerScenario(t *testing.T) *skyWayPlayerScenario {
t.Helper()
factory, accounts := newIntegrationFactory(t)
for i := range factory.design.skywaySchedules {
if factory.design.skywaySchedules[i].Group == 1 {
factory.design.skywaySchedules[i].Bonus = 100
factory.design.skywaySchedules[i].Days = []uint64{0, 1, 2, 3, 4, 5, 6}
}
}
owner, err := factory.open(accounts[0])
if err != nil {
t.Fatal(err)
}
s := &skyWayPlayerScenario{factory: factory, owner: owner, account: accounts[0]}
t.Cleanup(func() {
if s.owner != nil {
if err := s.owner.Close(); err != nil {
t.Error(err)
}
}
})
tx, err := owner.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "skyway-client"}, State: tx}
for _, character := range owner.assembly.worldService.CharacterService().RawAll() {
costume, owned := owner.assembly.collection.CostumeByIndex(character.UseCostume)
if !owned || costume.UseChar != character.InvenIndex || character.HP == 0 {
continue
}
s.blue = wire.AppendVarint(nil, 1, 1)
for field, value := range map[int]uint64{2: character.InvenIndex, 3: character.ID, 4: character.HP, 5: character.Level, 6: character.UseCostume, 7: costume.ID} {
s.blue = wire.AppendVarint(s.blue, field, value)
}
break
}
if s.blue == nil {
t.Fatal("SkyWay scenario requires an owned living battle participant")
}
if _, err := owner.assembly.ownedItems.GrantOnce(ctx, "skyway-entry-ticket", []gamedata.BattleReward{{Type: 19, ID: 13004, Count: 1}}); err != nil {
t.Fatal(err)
}
if _, err := owner.assembly.eventEconomy.Apply(ctx, "skyway-test-ap", nil, []gamedata.Reward{{Type: 21, Count: 200}, {Type: 32, Count: 200}}); err != nil {
t.Fatal(err)
}
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
beforePurchase := s.assets(t)
purchase := s.request(3004)
s.call(t, "/PackBuy", purchase)
bought := beforePurchase
bought.rice += 10
if s.assets(t) != bought {
t.Fatal("the first SkyWay pack purchase did not credit its ten rice")
}
s.call(t, "/PackBuy", purchase)
if s.assets(t) != bought {
t.Fatal("pack purchase retry duplicated rice")
}
s.call(t, "/PackInGameInfo", s.request(3004))
for _, step := range []struct {
quest uint64
talks []uint64
}{{1, []uint64{11}}, {2, []uint64{21, 22}}, {3, []uint64{31}}} {
update := s.request(step.quest, 3004)
for _, talk := range step.talks {
update = wire.AppendVarint(update, 4, talk)
}
s.call(t, "/QuestUpdate", update)
clear := s.request(step.quest, 3004)
response := s.call(t, "/QuestClear", clear)
if step.quest == 3 {
complete := false
for _, row := range appRows(response, 11) {
if appValue(row, 1) == 3004 && appValue(row, 3) == 1 {
complete = true
}
}
if !complete {
t.Fatal("final dialogue did not unlock dispatch in the client's pack cache")
}
}
bought.gold += 500
if s.assets(t) != bought {
t.Fatal("introductory dialogue did not settle its authored gold")
}
s.call(t, "/QuestClear", clear)
if s.assets(t) != bought {
t.Fatal("introductory dialogue retry duplicated rewards")
}
}
return s
}
func (s *skyWayPlayerScenario) request(values ...uint64) []byte {
s.sequence++
raw := wire.AppendVarint(nil, 1, s.sequence)
for i, value := range values {
raw = wire.AppendVarint(raw, i+2, value)
}
return raw
}
func (s *skyWayPlayerScenario) execute(path string, raw []byte) ([]byte, error) {
c := registryCommand(s.account, fmt.Sprintf("skyway-%s-%x", path, raw), path, raw)
c.Identity.SessionID = "skyway-client"
reply, err := s.owner.Execute(context.Background(), c)
if err != nil {
return nil, err
}
if len(reply.Responses) != 1 {
return nil, fmt.Errorf("%s has no response", path)
}
return reply.Responses[0].Body, nil
}
func (s *skyWayPlayerScenario) call(t *testing.T, path string, raw []byte) []byte {
t.Helper()
body, err := s.execute(path, raw)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
return body
}
func (s *skyWayPlayerScenario) assets(t *testing.T) skyWayAssets {
t.Helper()
tx, err := s.owner.repository.BeginCommand(context.Background())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback() }()
ctx := command.Context{Identity: command.Identity{AccountID: s.account, SessionID: "skyway-client"}, State: tx}
free, bonus, err := s.owner.assembly.huntingService.HuntingAP(ctx)
if err != nil {
t.Fatal(err)
}
extra, err := s.owner.assembly.eventEconomy.AdditionalCurrencies(ctx)
if err != nil {
t.Fatal(err)
}
out := skyWayAssets{gold: s.owner.assembly.wallet.Snapshot(ctx).Gold, rice: free + bonus, torch: extra[36] + extra[37]}
for _, item := range s.owner.assembly.ownedItems.All(ctx) {
if item.Type == 8 && item.ID == 111 {
out.crystal += item.Count
}
}
return out
}
func (s *skyWayPlayerScenario) reject(t *testing.T, path string, raw []byte) {
t.Helper()
before := s.assets(t)
if _, err := s.execute(path, raw); err == nil {
t.Fatalf("%s accepted an unavailable encounter", path)
}
if after := s.assets(t); after != before {
t.Fatalf("rejected %s changed assets: before=%+v after=%+v", path, before, after)
}
}
func (s *skyWayPlayerScenario) battle(t *testing.T, monster, mode, result uint64) ([]byte, []byte) {
t.Helper()
s.call(t, "/BattleEnter", s.request(0, monster, monster, mode))
s.call(t, "/BattleStart", wire.AppendBytes(s.request(monster), 5, s.blue))
end := s.request(result)
return s.call(t, "/BattleEnd", end), end
}
func (s *skyWayPlayerScenario) maximum(t *testing.T, group uint64) int64 {
t.Helper()
info := s.call(t, "/SkyWayInfo", s.request(3004))
for _, row := range appRows(info, 1) {
if appValue(row, 2) == group {
return int64(appValue(row, 4))
}
}
t.Fatalf("SkyWayInfo omitted selected group %d", group)
return -1
}
func (s *skyWayPlayerScenario) reopen(t *testing.T) {
t.Helper()
if err := s.owner.repository.RequireDomains("bootstrap", "characters", "collection", "deck", "equipment", "items", "mail", "missions", "progress", "wallet"); err != nil {
t.Fatal(err)
}
problems, err := s.owner.repository.Validate()
if err != nil || len(problems) != 0 {
t.Fatalf("persisted player validation: %v %v", problems, err)
}
if err := s.owner.Close(); err != nil {
t.Fatal(err)
}
s.owner = nil
s.owner, err = s.factory.open(s.account)
if err != nil {
t.Fatal(err)
}
}
// GameData group1/10 has five 1-rice encounters (1000 gold each) and a
// 1-rice boss (1500 gold). The configured 100% policy doubles concrete payouts.
func TestSkyWayGoblinBossUnlockAndRepeatedRunsConserveRewards(t *testing.T) {
s := newSkyWayPlayerScenario(t)
initial := s.assets(t)
if s.maximum(t, 1) != -1 {
t.Fatal("unplayed dungeon was advertised as cleared")
}
s.reject(t, "/SkyWayEnter", s.request(3004, 1, 11, 0))
enter := s.request(3004, 1, 10, 0)
enterReply := s.call(t, "/SkyWayEnter", enter)
if s.assets(t) != initial {
t.Fatal("entering a dungeon spent battle AP")
}
s.reject(t, "/BattleEnter", s.request(0, 2005, 2005, 9))
s.battle(t, 2001, 9, 2)
if s.assets(t) != initial {
t.Fatal("losing the encounter charged AP or granted gold")
}
for i, monster := range []uint64{2001, 2002, 2003, 2004, 2006} {
var response, end []byte
if i == 0 {
s.call(t, "/BattleRetry", s.request(monster))
if s.assets(t) != initial {
t.Fatal("retrying a lost battle charged AP before victory")
}
s.call(t, "/BattleStart", wire.AppendBytes(s.request(monster), 5, s.blue))
end = s.request(1)
response = s.call(t, "/BattleEnd", end)
} else {
response, end = s.battle(t, monster, 9, 1)
}
expected := initial
expected.gold += uint64(i+1) * 2000
expected.rice -= uint64(i + 1)
if after := s.assets(t); after != expected {
t.Fatalf("victory assets: got %+v want %+v", after, expected)
}
if retry := s.call(t, "/BattleEnd", end); !bytes.Equal(response, retry) || s.assets(t) != expected {
t.Fatal("settlement retry charged or rewarded the player twice")
}
if i == 1 {
s.reopen(t)
command := registryCommand(s.account, "uncached-enter-retry", "/SkyWayEnter", enter)
command.Identity.SessionID = "skyway-client"
replay, err := s.owner.Execute(context.Background(), command)
if err != nil || len(replay.Responses) != 1 || !bytes.Equal(replay.Responses[0].Body, enterReply) || s.assets(t) != expected {
t.Fatalf("persisted native entry retry reset or charged the dungeon: %v", err)
}
}
}
if s.maximum(t, 1) != -1 {
t.Fatal("normal encounters unlocked the next difficulty before the boss")
}
s.battle(t, 2005, 9, 1)
afterCycle := initial
afterCycle.gold += 13000
afterCycle.rice -= 6
if s.assets(t) != afterCycle || s.maximum(t, 1) != 0 {
t.Fatal("boss did not settle one complete run or unlock hard difficulty")
}
s.reopen(t)
if s.assets(t) != afterCycle || s.maximum(t, 1) != 0 {
t.Fatal("reopen lost completed dungeon assets or difficulty")
}
s.battle(t, 2001, 9, 1)
repeated := afterCycle
repeated.gold += 2000
repeated.rice--
if s.assets(t) != repeated {
t.Fatal("the new run reused the preceding run's reward identity")
}
s.call(t, "/SkyWayEnter", s.request(3004, 1, 11, 0))
if s.assets(t) != repeated {
t.Fatal("selecting the unlocked hard dungeon spent battle AP")
}
safe := wire.AppendBytes(s.request(3004), 3, []byte(`{"MapId":30021,"PlayerPosition":{"x":0,"y":0,"z":0},"ColleaguePositions":[]}`))
s.call(t, "/SaveUserPosition", safe)
s.reject(t, "/BattleEnter", s.request(0, 2011, 2011, 9))
s.reopen(t)
if s.maximum(t, 1) != 0 || s.assets(t) != repeated {
t.Fatal("leaving the dungeon discarded permanent progress or assets")
}
s.call(t, "/SkyWayEnter", s.request(3004, 1, 11, 0))
}
// Fire normal encounters and boss each grant resource111 x2 and cost one torch;
// the instant two-run dispatch costs twelve torches and grants twenty-four.
func TestSkyWayCrystalDispatchUsesTorchesAndSurvivesRetryAndReopen(t *testing.T) {
s := newSkyWayPlayerScenario(t)
initial := s.assets(t)
dispatch := s.request(2, 7, 2)
s.reject(t, "/HuntDispatch", dispatch)
s.call(t, "/SkyWayEnter", s.request(3004, 3, 30, 0))
s.battle(t, 4001, 11, 2)
if s.assets(t) != initial {
t.Fatal("lost crystal battle spent torches or awarded crystals")
}
for _, monster := range []uint64{4001, 4002, 4003, 4004, 4006, 4005} {
s.battle(t, monster, 11, 1)
}
afterCycle := initial
afterCycle.torch -= 6
afterCycle.crystal += 12
if s.assets(t) != afterCycle || s.maximum(t, 3) != 0 {
t.Fatal("crystal dungeon charged rice or failed to persist its rewards")
}
dispatch = s.request(2, 7, 2)
reply := s.call(t, "/HuntDispatch", dispatch)
afterDispatch := afterCycle
afterDispatch.torch -= 12
afterDispatch.crystal += 24
if s.assets(t) != afterDispatch {
t.Fatalf("instant dispatch charged the wrong currency or quantity: %+v want %+v", s.assets(t), afterDispatch)
}
if repeated := s.call(t, "/HuntDispatch", dispatch); !bytes.Equal(reply, repeated) || s.assets(t) != afterDispatch {
t.Fatal("dispatch retry duplicated rewards or torch charges")
}
s.reopen(t)
if s.assets(t) != afterDispatch || s.maximum(t, 3) != 0 {
t.Fatal("reopen lost crystal rewards, torch balance or dispatch qualification")
}
if repeated := s.call(t, "/HuntDispatch", dispatch); !bytes.Equal(reply, repeated) || s.assets(t) != afterDispatch {
t.Fatal("reopened dispatch duplicated assets")
}
s.call(t, "/HuntDispatchStart", s.request(2, 7, 2))
reserved := afterDispatch
reserved.torch -= 12
if s.assets(t) != reserved {
t.Fatal("timed dispatch did not reserve the correct torch cost")
}
preview := s.call(t, "/HuntDispatchRewardPreview", s.request(2, 7))
if appValue(preview, 1) != 0 || s.assets(t) != reserved {
t.Fatal("preview completed an unplayed dispatch or issued assets")
}
cancel := s.request(2, 7)
cancelReply := s.call(t, "/HuntDispatchEnd", cancel)
if s.assets(t) != afterDispatch {
t.Fatal("cancelling unplayed runs did not refund exactly the reserved torches")
}
s.reopen(t)
if retry := s.call(t, "/HuntDispatchEnd", cancel); !bytes.Equal(retry, cancelReply) || s.assets(t) != afterDispatch {
t.Fatal("reopened cancellation refunded torches twice")
}
}
@@ -9,7 +9,7 @@ import (
type DispatchDesign struct { type DispatchDesign struct {
Group, ID, Pack, Difficulty, TypeGroup, TypeID, AP, ClearTime, Growth uint64 Group, ID, Pack, Difficulty, TypeGroup, TypeID, AP, ClearTime, Growth uint64
GroundID uint64 GroundID, APType uint64
Rewards []BattleReward Rewards []BattleReward
boxes map[uint64]*dispatchGroup boxes map[uint64]*dispatchGroup
} }
@@ -63,6 +63,7 @@ func LoadDispatchDesign(root, version string, group, id uint64) (*DispatchDesign
} }
ids, err = packedInts(raw, 15) ids, err = packedInts(raw, 15)
boss, _ = optionalScalar(raw, 4) boss, _ = optionalScalar(raw, 4)
d.APType, _ = optionalScalar(raw, 1)
} }
if err != nil { if err != nil {
return nil, err return nil, err
@@ -47,7 +47,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) {
values[field] = v[0] values[field] = v[0]
} }
} }
if values[55] != 3 && values[55] != 10 { if values[55] != 3 && values[55] != 5 && values[55] != 10 {
continue continue
} }
if id <= 0 || values[25] != uint64(id) { if id <= 0 || values[25] != uint64(id) {
@@ -4,51 +4,6 @@ import (
"fmt" "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 func() { _ = 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 OverwhelmQuestRule struct {
Type, Count uint64 Type, Count uint64
Targets []uint64 Targets []uint64
@@ -0,0 +1,157 @@
package gamedata
import "fmt"
type SkyWayStage struct {
Group, ID, Difficulty, Map, Point, APType, Boss, BossAP, PositionGroup uint64
Monsters, AP []uint64
}
type SkyWayDesign struct {
Pack int
Stages []SkyWayStage
Monsters map[uint64]HuntingMonster
SafeMaps map[int]bool
}
func LoadSkyWayDesign(root, version string) (*SkyWayDesign, error) {
db, done, err := openStatDatabase(root, version)
if err != nil {
return nil, err
}
defer done()
d := &SkyWayDesign{Monsters: map[uint64]HuntingMonster{}, SafeMaps: map[int]bool{}}
rows, err := db.Query("SELECT ProtoBuf FROM SkyWayFieldTable ORDER BY groupId,difficulty")
if err != nil {
return nil, err
}
for rows.Next() {
var raw []byte
if err = rows.Scan(&raw); err != nil {
_ = rows.Close()
return nil, err
}
var s SkyWayStage
for f, p := range map[int]*uint64{1: &s.APType, 3: &s.BossAP, 4: &s.Boss, 6: &s.Difficulty, 10: &s.Group, 12: &s.ID, 13: &s.Map, 17: &s.Point, 18: &s.PositionGroup} {
*p, err = optionalScalar(raw, f)
if err != nil {
_ = rows.Close()
return nil, err
}
}
s.Monsters, err = packedInts(raw, 15)
if err != nil {
_ = rows.Close()
return nil, err
}
s.AP, err = packedInts(raw, 14)
if err != nil {
_ = rows.Close()
return nil, err
}
if s.Group == 0 || s.Group > 7 || s.ID == 0 || s.Boss == 0 || s.Map == 0 || len(s.AP) != len(s.Monsters) || (s.APType != 1 && s.APType != 2) {
_ = rows.Close()
return nil, fmt.Errorf("gamedata: invalid SkyWay stage")
}
d.Stages = append(d.Stages, s)
}
if err = rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err = rows.Close(); err != nil {
return nil, err
}
for _, s := range d.Stages {
var pack int
if err = db.QueryRow("SELECT packId FROM MapTable WHERE id=?", s.Map).Scan(&pack); err != nil {
return nil, err
}
if d.Pack != 0 && d.Pack != pack {
return nil, fmt.Errorf("gamedata: SkyWay maps span packs")
}
d.Pack = pack
}
if d.Pack == 0 {
return nil, fmt.Errorf("gamedata: no SkyWay pack")
}
rows, err = db.Query("SELECT id,ProtoBuf FROM MapTable WHERE packId=?", d.Pack)
if err != nil {
return nil, err
}
for rows.Next() {
var id int
var raw []byte
if err = rows.Scan(&id, &raw); err != nil {
_ = rows.Close()
return nil, err
}
typ, e := optionalScalar(raw, 22)
if e != nil {
_ = rows.Close()
return nil, e
}
if typ == 0 {
d.SafeMaps[id] = true
}
}
if err = rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err = rows.Close(); err != nil {
return nil, err
}
pdb, release, err := openPackDatabase(root, version, d.Pack)
if err != nil {
return nil, err
}
defer release()
for _, s := range d.Stages {
for _, id := range append(append([]uint64(nil), s.Monsters...), s.Boss) {
if _, ok := d.Monsters[id]; ok {
continue
}
var raw []byte
if err = pdb.QueryRow("SELECT ProtoBuf FROM FieldMonsterTable WHERE id=?", id).Scan(&raw); err != nil {
return nil, err
}
m := HuntingMonster{ID: id, Rewards: map[uint64][]BattleReward{}}
m.Type, err = optionalScalar(raw, 30)
if err != nil {
return nil, err
}
m.Decks, err = packedInts(raw, 2)
if err != nil || len(m.Decks) == 0 {
return nil, fmt.Errorf("gamedata: SkyWay monster missing decks")
}
for _, deck := range m.Decks {
if err = pdb.QueryRow("SELECT ProtoBuf FROM BattleDeckTable WHERE id=?", deck).Scan(&raw); err != nil {
return nil, err
}
rs, e := parallelRewards(raw, 36, 34, 33)
if e != nil {
return nil, e
}
for _, r := range rs {
m.Rewards[deck] = append(m.Rewards[deck], BattleReward(r))
}
}
d.Monsters[id] = m
}
}
return d, nil
}
func (d *SkyWayDesign) Stage(group, id uint64) (SkyWayStage, bool) {
for _, s := range d.Stages {
if s.Group == group && s.ID == id {
return s, true
}
}
return SkyWayStage{}, false
}
// Battle modes are the current protocol's contiguous SKY_WAY enum family.
func SkyWayMode(group uint64) uint64 { return 8 + group }
func IsSkyWayMode(mode uint64) bool { return mode >= 9 && mode <= 15 }
@@ -70,7 +70,7 @@ func loadStoryCatalog(db *sql.DB) (*StoryCatalog, error) {
_ = rows.Close() _ = rows.Close()
return nil, err return nil, err
} }
if kind != 0 && kind != 1000 { if kind != 0 && kind != 5 && kind != 1000 {
continue continue
} }
protoID, err := read(25) protoID, err := read(25)
@@ -26,6 +26,7 @@ type Service struct {
onMonsterWin func(ctx command.Context) error onMonsterWin func(ctx command.Context) error
commitHealth func(ctx command.Context, _ map[uint64]uint64) error commitHealth func(ctx command.Context, _ map[uint64]uint64) error
hunting HuntingRuntime hunting HuntingRuntime
skyway SkyWayRuntime
monsterHunt MonsterHuntRuntime monsterHunt MonsterHuntRuntime
eventBattles []EventBattleRuntime eventBattles []EventBattleRuntime
fieldMonsters FieldMonsterRuntime fieldMonsters FieldMonsterRuntime
@@ -83,6 +84,14 @@ type HuntingRuntime interface {
CompleteBattle(ctx command.Context, pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error) CompleteBattle(ctx command.Context, pack int, mode, monster, deck uint64, receipt string) ([]byte, [][]byte, error)
} }
type SkyWayRuntime interface {
SkyWayBeginBattle(command.Context, int, uint64, uint64, uint64) (string, error)
SkyWayCompleteBattle(command.Context, int, uint64, uint64, uint64, string, string) ([]byte, []byte, [][]byte, error)
SkyWayBattleStarted(command.Context, uint64, string) error
}
func (s *Service) AttachSkyWay(runtime SkyWayRuntime) { s.skyway = runtime }
const huntingGroundMode = 5 const huntingGroundMode = 5
func (s *Service) AttachHunting(runtime HuntingRuntime) { func (s *Service) AttachHunting(runtime HuntingRuntime) {
@@ -91,6 +100,7 @@ func (s *Service) AttachHunting(runtime HuntingRuntime) {
type battleState struct { type battleState struct {
entered bool entered bool
retryable bool
index uint64 index uint64
round uint64 round uint64
monster uint64 monster uint64
@@ -2,6 +2,7 @@ package hunting
import ( import (
"bd2server/internal/server/design/gamedata" "bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"time" "time"
) )
@@ -20,11 +21,18 @@ type dispatchState struct {
Receipts map[string]dispatchReceipt `json:"receipts"` Receipts map[string]dispatchReceipt `json:"receipts"`
} }
func (s *Service) AttachDispatchEligibility(check func(*gamedata.DispatchDesign) error) { func (s *Service) AttachDispatchEligibility(check func(command.Context, *gamedata.DispatchDesign) error) {
s.dispatchEligibility = check s.dispatchEligibility = check
} }
func (s *Service) AttachSkyWayDispatch(costs func(command.Context, *gamedata.DispatchDesign, uint64) ([]gamedata.Reward, error), exchange func(command.Context, string, []gamedata.Reward, []gamedata.Reward) ([]byte, error), bonus func(*gamedata.DispatchDesign, []gamedata.BattleReward) ([]gamedata.BattleReward, error)) {
s.dispatchCosts, s.dispatchExchange, s.dispatchBonus = costs, exchange, bonus
}
func (s *Service) AttachDispatchProgress(progress func(command.Context, uint64) error) {
s.dispatchProgress = progress
}
func dispatchPlayed(j dispatchJob, seconds uint64) uint64 { func dispatchPlayed(j dispatchJob, seconds uint64) uint64 {
if seconds == 0 { if seconds == 0 {
return j.Count return j.Count
@@ -46,7 +46,11 @@ type Service struct {
currentPack func(command.Context) (int, error) currentPack func(command.Context) (int, error)
load func(int) (*gamedata.HuntingPack, error) load func(int) (*gamedata.HuntingPack, error)
dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error) dispatchLoad func(uint64, uint64) (*gamedata.DispatchDesign, error)
dispatchEligibility func(*gamedata.DispatchDesign) error dispatchEligibility func(command.Context, *gamedata.DispatchDesign) error
dispatchCosts func(command.Context, *gamedata.DispatchDesign, uint64) ([]gamedata.Reward, error)
dispatchExchange func(command.Context, string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
dispatchBonus func(*gamedata.DispatchDesign, []gamedata.BattleReward) ([]gamedata.BattleReward, error)
dispatchProgress func(command.Context, uint64) error
eligibility func(ctx command.Context, _ int, _ uint64) error eligibility func(ctx command.Context, _ int, _ uint64) error
grant func(command.Context, string, []gamedata.Reward) ([]byte, error) grant func(command.Context, string, []gamedata.Reward) ([]byte, error)
apDesign *gamedata.HuntingAPDesign apDesign *gamedata.HuntingAPDesign
@@ -399,6 +399,14 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
if err != nil { if err != nil {
return code, nil, true, err return code, nil, true, err
} }
sky := d.TypeGroup != 0
if sky && (s.dispatchCosts == nil || s.dispatchExchange == nil || s.dispatchBonus == nil) {
return code, nil, true, fmt.Errorf("hunting: SkyWay dispatch runtime missing")
}
freeType, bonusType := uint64(21), uint64(23)
if sky && d.APType == 2 {
freeType, bonusType = 32, 33
}
var out []byte var out []byte
if path == "/HuntDispatchRewardPreview" { if path == "/HuntDispatchRewardPreview" {
if !exists { if !exists {
@@ -411,10 +419,10 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
bonus := min(remaining, job.Bonus) bonus := min(remaining, job.Bonus)
free := remaining - bonus free := remaining - bonus
if free > 0 { if free > 0 {
preview = append(preview, gamedata.BattleReward{Type: 21, Count: free}) preview = append(preview, gamedata.BattleReward{Type: freeType, Count: free})
} }
if bonus > 0 { if bonus > 0 {
preview = append(preview, gamedata.BattleReward{Type: 23, Count: bonus}) preview = append(preview, gamedata.BattleReward{Type: bonusType, Count: bonus})
} }
bundle := dispatchPreview(preview) bundle := dispatchPreview(preview)
for _, r := range preview { for _, r := range preview {
@@ -435,23 +443,40 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
remaining := d.AP * (job.Count - played) remaining := d.AP * (job.Count - played)
refundBonus := min(remaining, job.Bonus) refundBonus := min(remaining, job.Bonus)
refundFree := remaining - refundBonus refundFree := remaining - refundBonus
if !sky {
next := s.clone() next := s.clone()
next.Free += refundFree next.Free += refundFree
next.Bonus += refundBonus next.Bonus += refundBonus
if e := s.persist(ctx, next); e != nil { if e := s.persist(ctx, next); e != nil {
return code, nil, true, e return code, nil, true, e
} }
}
if refundFree > 0 { if refundFree > 0 {
completed = append(completed, gamedata.BattleReward{Type: 21, Count: refundFree}) completed = append(completed, gamedata.BattleReward{Type: freeType, Count: refundFree})
} }
if refundBonus > 0 { if refundBonus > 0 {
completed = append(completed, gamedata.BattleReward{Type: 23, Count: refundBonus}) completed = append(completed, gamedata.BattleReward{Type: bonusType, Count: refundBonus})
}
var bundle []byte
var e error
if sky {
var rewards []gamedata.Reward
for _, row := range completed {
rewards = append(rewards, gamedata.Reward(row))
}
bundle, e = s.dispatchExchange(ctx, "dispatch:"+key, nil, rewards)
} else {
bundle, e = s.dispatchGrant(ctx, "dispatch:"+key, completed)
} }
bundle, e := s.dispatchGrant(ctx, "dispatch:"+key, completed)
if e != nil { if e != nil {
return code, nil, true, e return code, nil, true, e
} }
out = wire.AppendBytes(out, 1, bundle) out = wire.AppendBytes(out, 1, bundle)
if played > 0 && s.dispatchProgress != nil {
if e = s.dispatchProgress(ctx, played); e != nil {
return code, nil, true, e
}
}
delete(ds.Jobs, jobKey) delete(ds.Jobs, jobKey)
} else { } else {
count, _, e := wire.Varint(req, 4) count, _, e := wire.Varint(req, 4)
@@ -470,8 +495,8 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
return code, nil, true, fmt.Errorf("hunting: dispatch category already running") return code, nil, true, fmt.Errorf("hunting: dispatch category already running")
} }
} }
if s.dispatchEligibility != nil { if d.TypeGroup != 0 && s.dispatchEligibility != nil {
if e = s.dispatchEligibility(d); e != nil { if e = s.dispatchEligibility(ctx, d); e != nil {
return code, nil, true, e return code, nil, true, e
} }
} else { } else {
@@ -487,7 +512,14 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
return code, nil, true, fmt.Errorf("hunting: dispatch AP overflow") return code, nil, true, fmt.Errorf("hunting: dispatch AP overflow")
} }
cost := d.AP * count cost := d.AP * count
if s.state.Free < cost && s.state.Bonus < cost-s.state.Free { var skyCosts []gamedata.Reward
if sky {
skyCosts, e = s.dispatchCosts(ctx, d, count)
if e != nil {
return code, nil, true, e
}
}
if !sky && s.state.Free < cost && s.state.Bonus < cost-s.state.Free {
return code, nil, true, fmt.Errorf("hunting: insufficient hunting AP") return code, nil, true, fmt.Errorf("hunting: insufficient hunting AP")
} }
rewards, e := d.Roll(count, func(n uint64) (uint64, error) { rewards, e := d.Roll(count, func(n uint64) (uint64, error) {
@@ -500,11 +532,29 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
if e != nil { if e != nil {
return code, nil, true, e return code, nil, true, e
} }
if sky {
rewards, e = s.dispatchBonus(d, rewards)
if e != nil {
return code, nil, true, e
}
}
next := s.clone() next := s.clone()
free := min(cost, next.Free) free := min(cost, next.Free)
bonus := cost - free bonus := cost - free
if sky {
free, bonus = 0, 0
for _, c := range skyCosts {
switch c.Type {
case freeType:
free += c.Count
case bonusType:
bonus += c.Count
}
}
} else {
next.Free -= free next.Free -= free
next.Bonus -= bonus next.Bonus -= bonus
}
if path == "/HuntDispatchStart" { if path == "/HuntDispatchStart" {
now := uint64(time.Now().UnixMilli()) now := uint64(time.Now().UnixMilli())
runs := make([][]gamedata.BattleReward, count) runs := make([][]gamedata.BattleReward, count)
@@ -520,9 +570,30 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
return code, nil, true, e return code, nil, true, e
} }
} }
if sky {
for i, run := range runs {
runs[i], e = s.dispatchBonus(d, run)
if e != nil {
return code, nil, true, e
}
}
if _, e = s.dispatchExchange(ctx, "dispatch-charge:"+key, skyCosts, nil); e != nil {
return code, nil, true, e
}
}
job = dispatchJob{Group: group, ID: id, Count: count, Start: now, End: now + d.ClearTime*count*1000, Free: free, Bonus: bonus, Rewards: rewards, Runs: runs} job = dispatchJob{Group: group, ID: id, Count: count, Start: now, End: now + d.ClearTime*count*1000, Free: free, Bonus: bonus, Rewards: rewards, Runs: runs}
ds.Jobs[jobKey] = job ds.Jobs[jobKey] = job
out = wire.AppendBytes(out, 1, dispatchJobWire(job)) out = wire.AppendBytes(out, 1, dispatchJobWire(job))
} else if sky {
var grants []gamedata.Reward
for _, row := range rewards {
grants = append(grants, gamedata.Reward(row))
}
bundle, e := s.dispatchExchange(ctx, "dispatch:"+key, skyCosts, grants)
if e != nil {
return code, nil, true, e
}
out = wire.AppendBytes(out, 1, bundle)
} else { } else {
beforeFree, beforeBonus := s.state.Free, s.state.Bonus beforeFree, beforeBonus := s.state.Free, s.state.Bonus
bundle, e := s.dispatchGrant(ctx, "dispatch:"+key, rewards) bundle, e := s.dispatchGrant(ctx, "dispatch:"+key, rewards)
@@ -549,10 +620,17 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
next.Bonus -= delta next.Bonus -= delta
} }
} }
if !sky {
if e = s.persist(ctx, next); e != nil { if e = s.persist(ctx, next); e != nil {
return code, nil, true, e return code, nil, true, e
} }
} }
if path == "/HuntDispatch" && s.dispatchProgress != nil {
if e = s.dispatchProgress(ctx, count); e != nil {
return code, nil, true, e
}
}
}
ds.Receipts[key] = dispatchReceipt{append([]byte(path), req...), out, code} ds.Receipts[key] = dispatchReceipt{append([]byte(path), req...), out, code}
b, e := json.Marshal(ds) b, e := json.Marshal(ds)
if e != nil { if e != nil {
@@ -19,7 +19,7 @@ func checkSeq(request []byte) error {
} }
func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) {
if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleExit" && path != "/BattlePhaseChange" { if path != "/BattleEnter" && path != "/BattleStart" && path != "/BattleRetry" && path != "/BattleVerifyState" && path != "/BattleEnd" && path != "/BattleEndTest" && path != "/BattleExit" && path != "/BattlePhaseChange" {
return 0, nil, false, nil return 0, nil, false, nil
} }
if err := checkSeq(request); err != nil { if err := checkSeq(request); err != nil {
@@ -27,6 +27,11 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
} }
state := s.stateLocked(ctx) state := s.stateLocked(ctx)
endCode := 15
if path == "/BattleEndTest" {
path = "/BattleEnd"
endCode = 181
}
switch path { switch path {
case "/BattlePhaseChange": case "/BattlePhaseChange":
if !state.entered { if !state.entered {
@@ -144,6 +149,15 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
} }
var phases []gamedata.BattlePhase var phases []gamedata.BattlePhase
fieldInstance := "" fieldInstance := ""
if gamedata.IsSkyWayMode(mode) {
if s.skyway == nil {
return 0, nil, true, errors.New("battle: SkyWay runtime unavailable")
}
fieldInstance, err = s.skyway.SkyWayBeginBattle(ctx, packID, mode, monster, deck)
if err != nil {
return 0, nil, true, err
}
}
if mode == 2 && eventRuntime == nil && s.fieldMonsters != nil && monster != 0 { if mode == 2 && eventRuntime == nil && s.fieldMonsters != nil && monster != 0 {
instance, _, e := s.fieldMonsters.BeginFieldMonsterBattle(ctx, packID, monster, deck) instance, _, e := s.fieldMonsters.BeginFieldMonsterBattle(ctx, packID, monster, deck)
if e != nil { if e != nil {
@@ -190,6 +204,7 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
} }
} }
state.entered, state.index, state.round, state.initialBlue = true, 0, 0, nil state.entered, state.index, state.round, state.initialBlue = true, 0, 0, nil
state.retryable = false
state.monster, state.deck, state.pack = monster, deck, packID state.monster, state.deck, state.pack = monster, deck, packID
state.mode = mode state.mode = mode
state.enterReceipt = identity state.enterReceipt = identity
@@ -198,7 +213,7 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
s.transientVersion++ s.transientVersion++
return 52, response, true, nil return 52, response, true, nil
case "/BattleRetry": case "/BattleRetry":
if !state.entered { if !state.entered && !state.retryable {
return 0, nil, true, errors.New("battle: retry before enter") return 0, nil, true, errors.New("battle: retry before enter")
} }
index, found, err := wire.Varint(request, 2) index, found, err := wire.Varint(request, 2)
@@ -208,12 +223,29 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
if len(state.initialBlue) == 0 { if len(state.initialBlue) == 0 {
return 0, nil, true, errors.New("battle: retry before initial battle state") return 0, nil, true, errors.New("battle: retry before initial battle state")
} }
if index != state.index {
return 0, nil, true, errors.New("battle: retry index does not match current battle")
}
if state.retryable {
if s.currentPack == nil {
return 0, nil, true, errors.New("battle: retry pack resolver unavailable")
}
pack, err := s.currentPack(ctx)
if err != nil || pack != state.pack {
return 0, nil, true, errors.New("battle: retry outside failed encounter pack")
}
firstDeck := state.deck
if len(state.phases) > 0 {
firstDeck = state.phases[0].DeckID
}
instance, err := s.skyway.SkyWayBeginBattle(ctx, state.pack, state.mode, state.monster, firstDeck)
if err != nil || instance != state.fieldInstance {
return 0, nil, true, errors.New("battle: failed SkyWay encounter no longer available")
}
}
if len(state.phases) != 0 { if len(state.phases) != 0 {
// Retry requests carry the current deck; the response must restore // Retry requests carry the current deck; the response must restore
// the first phase's deck, as PhaseBattleManager.ApplyRetryResponse does. // the first phase's deck, as PhaseBattleManager.ApplyRetryResponse does.
if index != state.index {
return 0, nil, true, errors.New("battle: retry index does not match current phase")
}
index = state.phases[0].DeckID index = state.phases[0].DeckID
} }
var response []byte var response []byte
@@ -226,6 +258,7 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
state.deck = state.phases[0].DeckID state.deck = state.phases[0].DeckID
} }
state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = 0, false, 0, nil state.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = 0, false, 0, nil
state.entered, state.retryable = true, false
s.transientVersion++ s.transientVersion++
return 58, response, true, nil return 58, response, true, nil
case "/BattleStart": case "/BattleStart":
@@ -243,6 +276,11 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
return 0, nil, true, errors.New("battle: start index does not match current phase") return 0, nil, true, errors.New("battle: start index does not match current phase")
} }
nextRound := state.round + 1 nextRound := state.round + 1
if nextRound == 1 && gamedata.IsSkyWayMode(state.mode) {
if err := s.skyway.SkyWayBattleStarted(ctx, state.mode, state.enterReceipt); err != nil {
return 0, nil, true, err
}
}
var initialBlue [][]byte var initialBlue [][]byte
var response []byte var response []byte
err = wire.Walk(request, func(field wire.Field) error { err = wire.Walk(request, func(field wire.Field) error {
@@ -277,7 +315,7 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
if !bytes.Equal(state.endRequest, request) { if !bytes.Equal(state.endRequest, request) {
return 0, nil, true, errors.New("battle: changed settlement retry") return 0, nil, true, errors.New("battle: changed settlement retry")
} }
return 15, append([]byte(nil), state.endReply...), true, nil return endCode, append([]byte(nil), state.endReply...), true, nil
} }
if !state.entered { if !state.entered {
return 0, nil, true, errors.New("battle: end before enter") return 0, nil, true, errors.New("battle: end before enter")
@@ -367,6 +405,21 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
response = wire.AppendBytes(response, 3, character) response = wire.AppendBytes(response, 3, character)
} }
rewardBundle := false rewardBundle := false
if result == 1 && gamedata.IsSkyWayMode(state.mode) {
seq, _, _ := wire.Varint(request, 1)
bundle, bonus, monsters, err := s.skyway.SkyWayCompleteBattle(ctx, state.pack, state.mode, state.monster, state.deck, state.fieldInstance, fmt.Sprintf("%s:%d", ctx.SessionID, seq))
if err != nil {
return 0, nil, true, err
}
for _, monster := range monsters {
response = wire.AppendBytes(response, 4, monster)
}
response = wire.AppendBytes(response, 5, bundle)
if len(bonus) > 0 {
response = wire.AppendBytes(response, 6, bonus)
}
rewardBundle = true
}
if result == 1 && state.mode == huntingGroundMode { if result == 1 && state.mode == huntingGroundMode {
seq, _, _ := wire.Varint(request, 1) seq, _, _ := wire.Varint(request, 1)
bundle, monsters, err := s.hunting.CompleteBattle(ctx, state.pack, state.mode, state.monster, state.deck, bundle, monsters, err := s.hunting.CompleteBattle(ctx, state.pack, state.mode, state.monster, state.deck,
@@ -380,7 +433,7 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
response = wire.AppendBytes(response, 5, bundle) response = wire.AppendBytes(response, 5, bundle)
rewardBundle = true rewardBundle = true
} }
if result == 1 && state.mode != huntingGroundMode && state.monster != 0 && s.gameDataRoot != "" { if result == 1 && state.mode != huntingGroundMode && !gamedata.IsSkyWayMode(state.mode) && state.monster != 0 && s.gameDataRoot != "" {
if state.pack <= 0 { if state.pack <= 0 {
return 0, nil, true, errors.New("battle: victory has no locked pack") return 0, nil, true, errors.New("battle: victory has no locked pack")
} }
@@ -433,13 +486,21 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
if field == 5 && rewardBundle { if field == 5 && rewardBundle {
continue continue
} }
if field == 6 && gamedata.IsSkyWayMode(state.mode) && result == 1 {
continue
}
response = wire.AppendBytes(response, field, nil) response = wire.AppendBytes(response, field, nil)
} }
state.rememberEnd(request, response) state.rememberEnd(request, response)
state.entered, state.deck, state.pack, state.initialBlue = false, 0, 0, nil state.entered = false
state.retryable = (result == 2 || result == 3) && gamedata.IsSkyWayMode(state.mode)
if !state.retryable {
state.deck, state.pack, state.initialBlue = 0, 0, nil
}
s.transientVersion++ s.transientVersion++
return 15, response, true, nil return endCode, response, true, nil
case "/BattleExit": case "/BattleExit":
state.retryable = false
state.entered, state.index, state.round, state.deck, state.pack, state.initialBlue = false, 0, 0, 0, 0, nil state.entered, state.index, state.round, state.deck, state.pack, state.initialBlue = false, 0, 0, 0, 0, nil
s.transientVersion++ s.transientVersion++
return 388, nil, true, nil return 388, nil, true, nil
@@ -0,0 +1,37 @@
package eventtasks
import (
"bd2server/internal/server/domain/command"
"slices"
)
func (s *Service) RecordSkyWayClear(ctx command.Context, positionGroup, difficulty uint64) error {
changed := false
for _, schedule := range s.taskSchedules() {
if schedule.Type != 4 || !s.active(schedule) {
continue
}
for _, task := range s.design.Missions {
if task.Type != 24 || task.SubType != positionGroup || !slices.Contains(task.Params, difficulty) || !s.availableTask(ctx, schedule, task) {
continue
}
previous := s.state.Missions[scheduleKey(schedule)+"/"+key(task.ID)]
var prior mission
if previous != nil {
prior = *previous
}
current := s.mission(schedule, task.ID)
if previous == nil || prior != *current {
changed = true
}
if !current.Claimed && current.Value < task.Target {
current.Value++
changed = true
}
}
}
if !changed {
return nil
}
return s.save(ctx)
}
@@ -25,8 +25,11 @@ func (s *Service) validateFieldObjectMap(ctx command.Context, pack, mapID int) e
return nil return nil
} }
current, err := s.currentFieldMap(ctx, pack) current, err := s.currentFieldMap(ctx, pack)
if err != nil || current != mapID { if err != nil {
return fmt.Errorf("%w: field object outside current map", ErrInvalidRequest) return fmt.Errorf("%w: field object map unavailable (pack=%d object_map=%d): %v", ErrInvalidRequest, pack, mapID, err)
}
if current != mapID {
return fmt.Errorf("%w: field object outside current map (pack=%d object_map=%d current_map=%d)", ErrInvalidRequest, pack, mapID, current)
} }
return nil return nil
} }
@@ -25,6 +25,9 @@ func (s *Service) LastPlayedPackID(ctx command.Context) (uint64, error) {
return 0, errors.New("world: saved login pack is unavailable") return 0, errors.New("world: saved login pack is unavailable")
} }
if pack, field := s.fieldPacks[int(id)]; field { if pack, field := s.fieldPacks[int(id)]; field {
if pack.Type == 5 {
return id, nil
}
saved, _ := s.state.Position() saved, _ := s.state.Position()
if !pack.MapIDs[saved.Position.MapID] { if !pack.MapIDs[saved.Position.MapID] {
return 0, errors.New("world: saved arena map does not belong to pack") return 0, errors.New("world: saved arena map does not belong to pack")
+1 -2
View File
@@ -23,8 +23,7 @@ type overwhelmedMonster struct {
Costs []gamedata.Reward Costs []gamedata.Reward
} }
func (s *Service) AttachOverwhelmDesign(source *gamedata.Source, rows []gamedata.SkyWayOverwhelmRule) error { func (s *Service) AttachOverwhelmDesign(source *gamedata.Source) error {
s.overwhelmSky = rows
s.overwhelmQuest = func(pack, quest int) (gamedata.OverwhelmQuestRule, error) { s.overwhelmQuest = func(pack, quest int) (gamedata.OverwhelmQuestRule, error) {
return source.OverwhelmQuest(pack, quest) return source.OverwhelmQuest(pack, quest)
} }
@@ -77,8 +77,18 @@ func (s *Service) handleOverwhelm(ctx command.Context, request []byte) (int, []b
if e = s.overwhelmHunting.ValidateBattle(ctx, pack, 5, m.ID, m.Deck); e != nil { if e = s.overwhelmHunting.ValidateBattle(ctx, pack, 5, m.ID, m.Deck); e != nil {
return 275, nil, true, e return 275, nil, true, e
} }
} else if gamedata.IsSkyWayMode(m.Mode) {
definition, found, e := s.findFieldMonster(pack, int(m.ID))
if e != nil || !found || definition.UseBattleSkip != 1 {
return 275, nil, true, fmt.Errorf("world: SkyWay monster cannot be overwhelmed")
}
m.Instance, e = s.SkyWayBeginBattle(ctx, pack, m.Mode, m.ID, m.Deck)
if e != nil {
return 275, nil, true, e
}
m.Definition = definition
} else { } else {
if m.Mode != 1 && m.Mode != 2 && m.Mode != 4 && (m.Mode < 9 || m.Mode > 15) { if m.Mode != 1 && m.Mode != 2 && m.Mode != 4 {
return 275, nil, true, fmt.Errorf("world: unavailable overwhelm battle mode %d", m.Mode) return 275, nil, true, fmt.Errorf("world: unavailable overwhelm battle mode %d", m.Mode)
} }
definition, found, e := s.findFieldMonster(pack, int(m.ID)) definition, found, e := s.findFieldMonster(pack, int(m.ID))
@@ -104,44 +114,7 @@ func (s *Service) handleOverwhelm(ctx command.Context, request []byte) (int, []b
if !validDeck { if !validDeck {
return 275, nil, true, fmt.Errorf("world: overwhelm deck mismatch") return 275, nil, true, fmt.Errorf("world: overwhelm deck mismatch")
} }
if m.Mode >= 9 && m.Mode <= 15 { if m.Mode == 2 {
mapID, e := s.currentFieldMap(ctx, 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) { if int(m.Group) != definition.GroupID || definition.GroupID == 0 || !s.monsterEligible(pack, definition) {
return 275, nil, true, ErrInvalidRequest return 275, nil, true, ErrInvalidRequest
} }
@@ -211,6 +184,16 @@ func (s *Service) handleOverwhelm(ctx command.Context, request []byte) (int, []b
for _, row := range monsters { for _, row := range monsters {
response = wire.AppendBytes(response, 1, row) response = wire.AppendBytes(response, 1, row)
} }
} else if gamedata.IsSkyWayMode(m.Mode) {
reward, bonus, monsters, e := s.SkyWayCompleteBattle(ctx, pack, m.Mode, m.ID, m.Deck, m.Instance, fmt.Sprintf("%s:%d", identity, m.ID))
if e != nil {
return 275, nil, true, e
}
bundle = append(bundle, reward...)
bundle = append(bundle, bonus...)
for _, row := range monsters {
response = wire.AppendBytes(response, 1, row)
}
} else { } else {
if !v.Claims[m.Instance] && m.Definition.Type != 3 { if !v.Claims[m.Instance] && m.Definition.Type != 3 {
definition := m.Definition definition := m.Definition
@@ -350,6 +333,21 @@ func (s *Service) handleMonsterInfo(ctx command.Context, request []byte) (int, [
if e != nil || !s.packUnlocked(ctx, pack) || s.monsterLoader == nil { if e != nil || !s.packUnlocked(ctx, pack) || s.monsterLoader == nil {
return 0, nil, true, ErrInvalidRequest return 0, nil, true, ErrInvalidRequest
} }
if s.skyway != nil && pack == s.skyway.design.Pack {
v, err := s.skyway.load(ctx)
if err != nil {
return 51, nil, true, err
}
stage, ok := s.skyway.design.Stage(v.Run.Group, v.Run.ID)
if !ok {
return 51, nil, true, nil
}
var out []byte
for _, row := range s.skyway.monsters(stage, v.Run) {
out = wire.AppendBytes(out, 1, row)
}
return 51, out, true, nil
}
design, e := s.monsterLoader(pack) design, e := s.monsterLoader(pack)
if e != nil { if e != nil {
return 0, nil, true, e return 0, nil, true, e
@@ -861,7 +859,7 @@ func (s *Service) handleFieldObjectReward(ctx command.Context, request []byte) (
} }
response, err := s.openFieldObjectResponse(ctx, pack, int(group), int(id)) response, err := s.openFieldObjectResponse(ctx, pack, int(group), int(id))
if err != nil { if err != nil {
return 0, nil, true, err return 0, nil, true, fmt.Errorf("field object reward pack=%d group=%d object=%d: %w", pack, group, id, err)
} }
return 29, response, true, nil return 29, response, true, nil
} }
@@ -362,6 +362,14 @@ func (s *Service) resolveActivePartyWires(ctx command.Context, packID, questID i
func (s *Service) storyPackDBInfoRows(ctx command.Context) [][]byte { func (s *Service) storyPackDBInfoRows(ctx command.Context) [][]byte {
ids := make([]int, 0, len(s.storyCatalog.Packs)) ids := make([]int, 0, len(s.storyCatalog.Packs))
for id := range s.storyCatalog.Packs { for id := range s.storyCatalog.Packs {
if s.storyCatalog.Packs[id].Type == 5 {
if s.collection == nil {
continue
}
if _, owned := s.collection.Grant(packPurchaseIdentity(id)); !owned {
continue
}
}
if s.storyPackUnlocked(ctx, id) { if s.storyPackUnlocked(ctx, id) {
ids = append(ids, id) ids = append(ids, id)
} }
@@ -400,7 +408,7 @@ func (s *Service) storyPackDBInfoRows(ctx command.Context) [][]byte {
rows = append(rows, row) rows = append(rows, row)
} }
if saved, found := s.state.Position(); found { if saved, found := s.state.Position(); found {
if pack, arena := s.fieldPacks[saved.PackID]; arena && pack.MapIDs[saved.Position.MapID] { if pack, arena := s.fieldPacks[saved.PackID]; arena && pack.Type != 5 && pack.MapIDs[saved.Position.MapID] {
rows = append(rows, wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(saved.PackID)), 8, 1)) rows = append(rows, wire.AppendVarint(wire.AppendVarint(nil, 1, uint64(saved.PackID)), 8, 1))
} }
} }
@@ -719,6 +727,27 @@ func (s *Service) purchaseStoryPack(ctx command.Context, id int, initial bool) (
if !exists { if !exists {
return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id) return nil, fmt.Errorf("%w: unknown purchase pack %d", ErrInvalidRequest, id)
} }
if pack.Type == 5 {
if s.skyway == nil || s.collection == nil {
return nil, fmt.Errorf("world: SkyWay purchase runtime unavailable")
}
identity := packPurchaseIdentity(id)
if _, owned := s.collection.Grant(identity); owned {
return []byte{}, nil
}
var costs []gamedata.Reward
if !initial && pack.BuyPrice > 0 {
costs = []gamedata.Reward{{Type: pack.BuyType, Count: pack.BuyPrice}}
}
bundle, err := s.skyway.economy.Apply(ctx, identity, costs, pack.BuyRewards)
if err != nil {
return nil, err
}
if err = s.collection.RecordGrantMarker(ctx, identity); err != nil {
return nil, err
}
return bundle, nil
}
return s.purchasePack(ctx, id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, initial) return s.purchasePack(ctx, id, pack.BuyType, pack.BuyPrice, pack.BuyRewards, initial)
} }
@@ -924,6 +953,9 @@ func (s *Service) enterEventFieldPack(ctx command.Context, pack gamedata.EventFi
} }
func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) { func (s *Service) Handle(ctx command.Context, path string, request []byte) (int, []byte, bool, error) {
if code, out, handled, err := s.handleSkyWay(ctx, path, request); handled {
return code, out, handled, err
}
if s.todayQuests != nil { if s.todayQuests != nil {
if code, body, handled, err := s.todayQuests.Handle(ctx, path, request); handled { if code, body, handled, err := s.todayQuests.Handle(ctx, path, request); handled {
if err == nil { if err == nil {
@@ -1090,6 +1122,19 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
if !s.packUnlocked(ctx, pack) { if !s.packUnlocked(ctx, pack) {
return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack) return 0, nil, true, fmt.Errorf("%w: unsupported pack %d", ErrInvalidRequest, pack)
} }
if s.skyway != nil && pack == s.skyway.design.Pack {
if _, purchased := s.collection.Grant(packPurchaseIdentity(pack)); !purchased {
return 0, nil, true, fmt.Errorf("world: SkyWay pack is not purchased")
}
v, e := s.skyway.load(ctx)
if e != nil {
return 0, nil, true, e
}
v.Run = skyWayRun{Generation: v.Run.Generation}
if e = s.skyway.save(ctx, v); e != nil {
return 0, nil, true, e
}
}
if active := s.firstUnclearedQuestFor(pack); active != 0 { if active := s.firstUnclearedQuestFor(pack); active != 0 {
if _, err := s.ensureQuestItems(ctx, pack, active); err != nil { if _, err := s.ensureQuestItems(ctx, pack, active); err != nil {
return 0, nil, true, err return 0, nil, true, err
+554
View File
@@ -0,0 +1,554 @@
package world
import (
"bd2server/internal/server/design/gamedata"
"bd2server/internal/server/domain/command"
"bd2server/internal/server/domain/world/progress"
"bd2server/internal/server/platform/versionconfig"
"bd2server/internal/server/protocol/wire"
"bd2server/internal/server/storage/stateio"
"bytes"
"encoding/json"
"fmt"
"math"
"slices"
"strconv"
"time"
)
type SkyWaySchedule struct {
Group, Bonus uint64
Days []uint64
}
type skyWayEconomy interface {
CanApply(command.Context, []gamedata.Reward) error
Apply(command.Context, string, []gamedata.Reward, []gamedata.Reward) ([]byte, error)
AdditionalCurrencies(command.Context) (map[int]uint64, error)
}
type skyWayAP interface {
HuntingAP(command.Context) (uint64, uint64, error)
}
type skyWayRuntime struct {
design *gamedata.SkyWayDesign
store stateio.Store
economy skyWayEconomy
ap skyWayAP
schedules []SkyWaySchedule
now func() time.Time
graph *gamedata.RewardGraph
bossProgress func(command.Context, uint64, uint64) error
battleAttempt func(command.Context, uint64) error
}
type skyWayGroup struct {
Current uint64
Maximum int
Auto bool
}
type skyWayRun struct {
Group, ID, Generation uint64
Defeated []uint64
}
type skyWayReceipt struct {
Request []byte
Response, Bonus []byte
Monsters [][]byte
}
type skyWayState struct {
Version string `json:"version"`
Groups map[string]skyWayGroup `json:"groups"`
Run skyWayRun `json:"run"`
Receipts map[string]skyWayReceipt `json:"receipts"`
}
func (s *Service) AttachSkyWay(ctx command.Context, d *gamedata.SkyWayDesign, store stateio.Store, economy skyWayEconomy, ap skyWayAP, schedules []SkyWaySchedule, graph *gamedata.RewardGraph) error {
if d == nil || store == nil || economy == nil || ap == nil || graph == nil {
return fmt.Errorf("world: invalid SkyWay runtime")
}
seen := map[uint64]bool{}
for _, r := range schedules {
if r.Group == 0 || r.Group > 7 || seen[r.Group] || r.Bonus > math.MaxInt32 {
return fmt.Errorf("world: invalid SkyWay schedule")
}
seen[r.Group] = true
days := map[uint64]bool{}
for _, day := range r.Days {
if day > 6 || days[day] {
return fmt.Errorf("world: invalid SkyWay bonus day")
}
days[day] = true
}
}
for _, row := range d.Stages {
if !seen[row.Group] {
return fmt.Errorf("world: missing SkyWay group schedule")
}
}
s.skyway = &skyWayRuntime{design: d, store: store, economy: economy, ap: ap, schedules: schedules, now: time.Now, graph: graph}
_, err := s.skyway.load(ctx)
return err
}
func (r *skyWayRuntime) load(ctx command.Context) (skyWayState, error) {
v := skyWayState{Version: versionconfig.State(), Groups: map[string]skyWayGroup{}, Receipts: map[string]skyWayReceipt{}}
b, err := r.store.Load(ctx.State, "skyway")
if err != nil || b == nil {
return v, err
}
if err = stateio.RequireExactJSONObject(b, "version", "groups", "run", "receipts"); err != nil {
return v, err
}
d := json.NewDecoder(bytes.NewReader(b))
d.DisallowUnknownFields()
if err = d.Decode(&v); err != nil {
return v, err
}
if v.Version != versionconfig.State() || v.Groups == nil || v.Receipts == nil {
return v, fmt.Errorf("world: incompatible SkyWay state")
}
for k, st := range v.Groups {
g, e := strconv.ParseUint(k, 10, 64)
stage, ok := r.design.Stage(g, st.Current)
maximum := -1
for _, row := range r.design.Stages {
if row.Group == g {
maximum = max(maximum, int(row.Difficulty))
}
}
if e != nil || strconv.FormatUint(g, 10) != k || !ok || st.Maximum < -1 || st.Maximum > maximum || int(stage.Difficulty) > st.Maximum+1 {
return v, fmt.Errorf("world: invalid SkyWay group progression")
}
}
if v.Run.Group != 0 {
stage, ok := r.design.Stage(v.Run.Group, v.Run.ID)
if !ok || v.Run.Generation == 0 {
return v, fmt.Errorf("world: invalid SkyWay run")
}
seen := map[uint64]bool{}
for _, id := range v.Run.Defeated {
if !slices.Contains(stage.Monsters, id) || seen[id] {
return v, fmt.Errorf("world: invalid SkyWay defeated monster")
}
seen[id] = true
}
}
return v, nil
}
func (r *skyWayRuntime) save(ctx command.Context, v skyWayState) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return r.store.Save(ctx.State, "skyway", b)
}
func (s *Service) skyWayPack(ctx command.Context, pack int) error {
if s.skyway == nil || pack != s.skyway.design.Pack || !s.packUnlocked(ctx, pack) {
return fmt.Errorf("world: SkyWay pack unavailable")
}
return nil
}
func (r *skyWayRuntime) costs(ctx command.Context, stage gamedata.SkyWayStage, count uint64) ([]gamedata.Reward, error) {
if count == 0 {
return nil, nil
}
var free, bonus uint64
var err error
freeType, bonusType := uint64(21), uint64(23)
if stage.APType == 1 {
free, bonus, err = r.ap.HuntingAP(ctx)
} else {
freeType, bonusType = 32, 33
var values map[int]uint64
values, err = r.economy.AdditionalCurrencies(ctx)
free, bonus = values[36], values[37]
}
if err != nil {
return nil, err
}
if free < count && bonus < count-free {
return nil, fmt.Errorf("world: insufficient SkyWay AP")
}
n := min(free, count)
var out []gamedata.Reward
if n > 0 {
out = append(out, gamedata.Reward{Type: freeType, Count: n})
}
if n < count {
out = append(out, gamedata.Reward{Type: bonusType, Count: count - n})
}
return out, nil
}
func (s *Service) handleSkyWay(ctx command.Context, path string, request []byte) (int, []byte, bool, error) {
if path == "/SaveUserPosition" && s.skyway != nil {
pack, err := requestPack(request)
if err != nil {
return 7, nil, true, err
}
if pack == s.skyway.design.Pack {
raw, found, e := wire.Bytes(request, 3)
var position progress.Position
if e != nil || !found || json.Unmarshal(raw, &position) != nil || !s.skyway.design.SafeMaps[position.MapID] || !s.packUnlocked(ctx, pack) {
return 7, nil, true, ErrInvalidRequest
}
if e = s.state.SaveUserPosition(ctx, request); e != nil {
return 7, nil, true, e
}
v, e := s.skyway.load(ctx)
if e != nil {
return 7, nil, true, e
}
v.Run = skyWayRun{Generation: v.Run.Generation}
e = s.skyway.save(ctx, v)
return 7, nil, true, e
}
}
code := map[string]int{"/SkyWayInfo": 160, "/SkyWayEnter": 161, "/SkyWayScheduleInfo": 162}[path]
if code == 0 {
return 0, nil, false, nil
}
if s.skyway == nil {
return code, nil, true, fmt.Errorf("world: SkyWay runtime missing")
}
seq, present, err := wire.Varint(request, 1)
if err != nil || !present || seq == 0 || seq > math.MaxInt32 {
return code, nil, true, ErrInvalidRequest
}
r := s.skyway
if path == "/SkyWayScheduleInfo" {
var out []byte
for _, schedule := range r.schedules {
row := wire.AppendVarint(nil, 1, schedule.Group)
for _, day := range schedule.Days {
row = wire.AppendVarint(row, 2, day)
}
if schedule.Bonus > 0 {
row = wire.AppendVarint(row, 3, schedule.Bonus)
}
out = wire.AppendBytes(out, 1, row)
}
return code, out, true, nil
}
pack, err := requestPack(request)
if err != nil {
return code, nil, true, err
}
if err = s.skyWayPack(ctx, pack); err != nil {
return code, nil, true, err
}
v, err := r.load(ctx)
if err != nil {
return code, nil, true, err
}
if path == "/SkyWayInfo" {
var out []byte
seen := map[uint64]bool{}
for _, stage := range r.design.Stages {
if seen[stage.Group] {
continue
}
seen[stage.Group] = true
st, ok := v.Groups[strconv.FormatUint(stage.Group, 10)]
if !ok {
st = skyWayGroup{Current: stage.ID, Maximum: -1}
}
row := wire.AppendVarint(nil, 2, stage.Group)
row = wire.AppendVarint(row, 3, st.Current)
row = wire.AppendVarint(row, 4, uint64(int64(st.Maximum)))
if st.Auto {
row = wire.AppendVarint(row, 1, 1)
}
out = wire.AppendBytes(out, 1, row)
}
return code, out, true, nil
}
if ctx.SessionID == "" {
return code, nil, true, ErrInvalidRequest
}
key := "enter:" + ctx.SessionID + ":" + strconv.FormatUint(seq, 10)
if prior, ok := v.Receipts[key]; ok {
if !bytes.Equal(prior.Request, request) {
return code, nil, true, ErrInvalidRequest
}
return code, prior.Response, true, nil
}
group, _, err := wire.Varint(request, 3)
if err != nil {
return code, nil, true, err
}
id, _, err := wire.Varint(request, 4)
if err != nil {
return code, nil, true, err
}
auto, _, err := wire.Varint(request, 5)
if err != nil || auto > 1 {
return code, nil, true, ErrInvalidRequest
}
stage, ok := r.design.Stage(group, id)
if !ok {
return code, nil, true, ErrInvalidRequest
}
current, err := s.CurrentPackID(ctx)
if err != nil || current != pack || s.battleActive != nil && s.battleActive(ctx) {
return code, nil, true, fmt.Errorf("world: SkyWay enter outside current pack or during battle")
}
st, exists := v.Groups[strconv.FormatUint(group, 10)]
if !exists {
st.Maximum = -1
}
if int(stage.Difficulty) > st.Maximum+1 {
return code, nil, true, fmt.Errorf("world: SkyWay difficulty requires previous boss clear")
}
if _, err = r.costs(ctx, stage, 1); err != nil {
return code, nil, true, err
}
if v.Run.Generation == math.MaxUint64 {
return code, nil, true, fmt.Errorf("world: SkyWay generation overflow")
}
v.Run = skyWayRun{Group: group, ID: id, Generation: v.Run.Generation + 1}
st.Current, st.Auto = id, auto == 1
v.Groups[strconv.FormatUint(group, 10)] = st
var out []byte
for _, row := range r.monsters(stage, v.Run) {
out = wire.AppendBytes(out, 1, row)
}
v.Receipts[key] = skyWayReceipt{Request: slices.Clone(request), Response: out}
err = r.save(ctx, v)
return code, out, true, err
}
func (r *skyWayRuntime) monsters(stage gamedata.SkyWayStage, run skyWayRun) [][]byte {
var out [][]byte
for _, id := range stage.Monsters {
out = append(out, r.monster(id, !slices.Contains(run.Defeated, id)))
}
if len(run.Defeated) == len(stage.Monsters) {
out = append(out, r.monster(stage.Boss, true))
}
return out
}
func (r *skyWayRuntime) monster(id uint64, active bool) []byte {
m := r.design.Monsters[id]
out := wire.AppendVarint(nil, 1, id)
out = wire.AppendVarint(out, 2, m.Decks[0])
if active {
out = wire.AppendVarint(out, 6, 1)
}
return out
}
func (s *Service) SkyWayBeginBattle(ctx command.Context, pack int, mode, monster, deck uint64) (string, error) {
if err := s.skyWayPack(ctx, pack); err != nil {
return "", err
}
v, err := s.skyway.load(ctx)
if err != nil {
return "", err
}
stage, ok := s.skyway.design.Stage(v.Run.Group, v.Run.ID)
if !ok || mode != gamedata.SkyWayMode(stage.Group) || slices.Contains(v.Run.Defeated, monster) {
return "", fmt.Errorf("world: invalid SkyWay encounter")
}
cost := uint64(0)
member := false
for i, id := range stage.Monsters {
if id == monster {
member = true
cost = stage.AP[i]
}
}
if monster == stage.Boss {
member = len(v.Run.Defeated) == len(stage.Monsters)
cost = stage.BossAP
}
if !member || !slices.Contains(s.skyway.design.Monsters[monster].Decks, deck) {
return "", fmt.Errorf("world: SkyWay monster/deck unavailable")
}
if _, err = s.skyway.costs(ctx, stage, cost); err != nil {
return "", err
}
return fmt.Sprintf("skyway:%d:%d:%d:%d", stage.Group, stage.ID, v.Run.Generation, monster), nil
}
func (s *Service) SkyWayCompleteBattle(ctx command.Context, pack int, mode, monster, deck uint64, instance, receipt string) ([]byte, []byte, [][]byte, error) {
r := s.skyway
if r == nil || receipt == "" {
return nil, nil, nil, ErrInvalidRequest
}
v, err := r.load(ctx)
if err != nil {
return nil, nil, nil, err
}
fingerprint := fmt.Sprintf("%d:%d:%d:%d:%s", pack, mode, monster, deck, instance)
key := "win:" + receipt
if old, ok := v.Receipts[key]; ok {
if !bytes.Equal(old.Request, []byte(fingerprint)) {
return nil, nil, nil, ErrInvalidRequest
}
return old.Response, old.Bonus, old.Monsters, nil
}
current, err := s.SkyWayBeginBattle(ctx, pack, mode, monster, deck)
if err != nil || current != instance {
return nil, nil, nil, fmt.Errorf("world: stale SkyWay battle: %w", err)
}
stage, _ := r.design.Stage(v.Run.Group, v.Run.ID)
cost := stage.BossAP
if monster == stage.Boss && v.Run.Generation == math.MaxUint64 {
return nil, nil, nil, ErrInvalidRequest
}
for i, id := range stage.Monsters {
if id == monster {
cost = stage.AP[i]
}
}
costs, err := r.costs(ctx, stage, cost)
if err != nil {
return nil, nil, nil, err
}
resolved, err := r.graph.Resolve(r.design.Monsters[monster].Rewards[deck])
if err != nil {
return nil, nil, nil, err
}
rewards := []gamedata.Reward{}
for _, reward := range resolved {
rewards = append(rewards, gamedata.Reward(reward))
}
var extra []gamedata.Reward
rate := r.bonusRate(stage.Group)
if rate > 0 {
for _, reward := range rewards {
if reward.Count > math.MaxInt32/rate {
return nil, nil, nil, fmt.Errorf("world: SkyWay bonus overflow")
}
reward.Count = reward.Count * rate / 100
if reward.Count > 0 {
extra = append(extra, reward)
}
}
}
bundle, err := r.economy.Apply(ctx, instance, costs, rewards)
if err != nil {
return nil, nil, nil, err
}
var bonus []byte
if len(extra) > 0 {
bonus, err = r.economy.Apply(ctx, instance+":bonus", nil, extra)
if err != nil {
return nil, nil, nil, err
}
}
updates := [][]byte{r.monster(monster, false)}
if monster == stage.Boss {
if r.bossProgress != nil {
if err = r.bossProgress(ctx, stage.PositionGroup, stage.Difficulty); err != nil {
return nil, nil, nil, err
}
}
st := v.Groups[strconv.FormatUint(stage.Group, 10)]
st.Maximum = max(st.Maximum, int(stage.Difficulty))
v.Groups[strconv.FormatUint(stage.Group, 10)] = st
v.Run.Generation++
v.Run.Defeated = nil
for _, id := range stage.Monsters {
updates = append(updates, r.monster(id, true))
}
} else {
v.Run.Defeated = append(v.Run.Defeated, monster)
if len(v.Run.Defeated) == len(stage.Monsters) {
updates = append(updates, r.monster(stage.Boss, true))
}
}
v.Receipts[key] = skyWayReceipt{Request: []byte(fingerprint), Response: bundle, Bonus: bonus, Monsters: updates}
if err = r.save(ctx, v); err != nil {
return nil, nil, nil, err
}
return bundle, bonus, updates, nil
}
func (r *skyWayRuntime) bonusRate(group uint64) uint64 {
day := uint64(r.now().UTC().Weekday())
for _, schedule := range r.schedules {
if schedule.Group == group && slices.Contains(schedule.Days, day) {
return schedule.Bonus
}
}
return 0
}
func (s *Service) SkyWayDispatchEligibility(ctx command.Context, d *gamedata.DispatchDesign) error {
if err := s.skyWayPack(ctx, int(d.Pack)); err != nil {
return err
}
if !s.packCompleteFor(int(d.Pack)) {
return fmt.Errorf("world: SkyWay dispatch requires completed introductory quests")
}
stage, ok := s.skyway.design.Stage(d.TypeGroup, d.TypeID)
if !ok {
return ErrInvalidRequest
}
v, err := s.skyway.load(ctx)
if err != nil {
return err
}
st, ok := v.Groups[strconv.FormatUint(stage.Group, 10)]
if !ok || st.Maximum < int(stage.Difficulty) {
return fmt.Errorf("world: SkyWay dispatch requires selected boss clear")
}
return nil
}
func (s *Service) skyWayMap(ctx command.Context, pack int) (int, bool, error) {
if s.skyway == nil || pack != s.skyway.design.Pack {
return 0, false, nil
}
v, err := s.skyway.load(ctx)
if err != nil {
return 0, true, err
}
stage, ok := s.skyway.design.Stage(v.Run.Group, v.Run.ID)
return int(stage.Map), ok, nil
}
func (s *Service) SkyWayDispatchCosts(ctx command.Context, d *gamedata.DispatchDesign, count uint64) ([]gamedata.Reward, error) {
if s.skyway == nil {
return nil, ErrInvalidRequest
}
stage, ok := s.skyway.design.Stage(d.TypeGroup, d.TypeID)
if !ok || count > math.MaxInt32/d.AP {
return nil, ErrInvalidRequest
}
return s.skyway.costs(ctx, stage, d.AP*count)
}
func (s *Service) AttachSkyWayProgress(boss func(command.Context, uint64, uint64) error, attempt func(command.Context, uint64) error) {
s.skyway.bossProgress, s.skyway.battleAttempt = boss, attempt
}
func (s *Service) SkyWayBattleStarted(ctx command.Context, mode uint64, receipt string) error {
if !gamedata.IsSkyWayMode(mode) {
return nil
}
r := s.skyway
if r == nil || receipt == "" {
return ErrInvalidRequest
}
v, err := r.load(ctx)
if err != nil {
return err
}
key := "attempt:" + receipt
if _, ok := v.Receipts[key]; ok {
return nil
}
if r.battleAttempt != nil {
if err = r.battleAttempt(ctx, mode-8); err != nil {
return err
}
}
v.Receipts[key] = skyWayReceipt{Request: []byte(strconv.FormatUint(mode, 10))}
return r.save(ctx, v)
}
func (s *Service) SkyWayDispatchExchange(ctx command.Context, identity string, costs, rewards []gamedata.Reward) ([]byte, error) {
return s.skyway.economy.Apply(ctx, identity, costs, rewards)
}
func (s *Service) SkyWayDispatchBonus(d *gamedata.DispatchDesign, rewards []gamedata.BattleReward) ([]gamedata.BattleReward, error) {
rate := s.skyway.bonusRate(d.TypeGroup)
out := append([]gamedata.BattleReward(nil), rewards...)
for i, r := range out {
if r.Count > math.MaxInt32/(100+rate) {
return nil, ErrInvalidRequest
}
out[i].Count = r.Count * (100 + rate) / 100
}
return out, nil
}
@@ -30,6 +30,9 @@ func (s *Service) currentFieldMap(ctx command.Context, pack int) (int, error) {
if !s.packUnlocked(ctx, pack) { if !s.packUnlocked(ctx, pack) {
return 0, ErrInvalidRequest return 0, ErrInvalidRequest
} }
if mapID, found, err := s.skyWayMap(ctx, pack); found || err != nil {
return mapID, err
}
if saved, ok := s.state.Position(); ok && saved.PackID == pack && saved.Difficulty == s.questDifficulty(pack) && saved.Position.MapID > 0 { if saved, ok := s.state.Position(); ok && saved.PackID == pack && saved.Difficulty == s.questDifficulty(pack) && saved.Position.MapID > 0 {
return saved.Position.MapID, nil return saved.Position.MapID, nil
} }
+7 -5
View File
@@ -167,7 +167,7 @@ type Service struct {
fieldBuffs map[uint64]gamedata.FieldBuffDesign fieldBuffs map[uint64]gamedata.FieldBuffDesign
talentPackInfo func(ctx command.Context, _ int) ([]byte, error) talentPackInfo func(ctx command.Context, _ int) ([]byte, error)
overwhelmAuthorize func(ctx command.Context, _ string, _ uint64) error overwhelmAuthorize func(ctx command.Context, _ string, _ uint64) error
overwhelmSky []gamedata.SkyWayOverwhelmRule skyway *skyWayRuntime
overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error) overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error)
overwhelmHunting interface { overwhelmHunting interface {
ValidateBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64) error ValidateBattle(ctx command.Context, _ int, _ uint64, _ uint64, _ uint64) error
@@ -231,12 +231,14 @@ func (s *Service) questsFor(ctx command.Context, packID int) (map[int]gamedata.Q
if _, found, err := s.resolveEventFieldPack(ctx, packID); err == nil && found { if _, found, err := s.resolveEventFieldPack(ctx, packID); err == nil && found {
return map[int]gamedata.QuestDesign{}, true return map[int]gamedata.QuestDesign{}, true
} }
if _, exists := s.fieldPacks[packID]; exists {
return map[int]gamedata.QuestDesign{}, true
}
if s.storyCatalog != nil { if s.storyCatalog != nil {
pack, found := s.storyCatalog.Packs[packID] pack, found := s.storyCatalog.Packs[packID]
return pack.Quests, found if found {
return pack.Quests, true
}
}
if _, exists := s.fieldPacks[packID]; exists {
return map[int]gamedata.QuestDesign{}, true
} }
return nil, false return nil, false
} }
@@ -0,0 +1,103 @@
using System;
using System.Collections;
using System.Collections.Generic;
using BD2.GameNames;
using BepInEx.Logging;
using gamfs;
using HarmonyLib;
using Proto.Net;
using UnityEngine.SceneManagement;
namespace Bd2LocalIdentity;
internal static class FieldRewardPosition
{
private static readonly HashSet<FieldRewardObjectController> Pending = [];
private static ManualLogSource Log;
internal static void Install(Harmony harmony, ManualLogSource logger)
{
Log = logger;
harmony.Patch(Game.Method(() => FieldPacket.SendFieldObjectRewardRequest(null, true)),
prefix: new HarmonyMethod(typeof(FieldRewardPosition), nameof(SaveBeforeReward)));
}
private static bool SaveBeforeReward(FieldRewardObjectController __0, bool __1)
{
if (__0 == null || MiniGameHubSupporter.IsPlayEventHubOrMiniEventHub() ||
(FieldRewardObjectController.EFieldRewardObjectGroupType)__0.FieldRewardObjectGroupDTO.Type ==
FieldRewardObjectController.EFieldRewardObjectGroupType.LostCoin)
{
return true;
}
if (Pending.Contains(__0))
{
return false;
}
GameFieldManager field = GameFieldManager.Instance;
if (UsesDedicatedPosition(field))
{
return true;
}
int packId = SG.Pack?.PlayingPackID ?? 0;
Scene scene = __0.gameObject.scene;
if (IsReady(field, packId, scene))
{
FieldPacket.SendSaveUserPositionRequest(packId, false, true);
return true;
}
if (field == null || packId <= 0 || !scene.IsValid() || !scene.isLoaded)
{
Log.LogWarning("Field reward skipped without a loaded field: pack=" + packId);
return false;
}
Pending.Add(__0);
field.StartCoroutine(WaitForPosition(field, __0, __1, packId, scene));
return false;
}
private static bool UsesDedicatedPosition(GameFieldManager field)
{
var packType = (Define_PackType)(SG.Pack?.EnterPackDTO?.PackType ?? -1);
return packType is Define_PackType.PackHidden or Define_PackType.PackIb ||
(packType == Define_PackType.PackSkyWay && field?.CurrentMapInfo?.MapType == 1) ||
(packType == Define_PackType.PackEvilCastle && EvilCastleManager.Instance != null &&
EvilCastleInfo.IsEvilCastleBType(EvilCastleManager.Instance.CurrentEvilCastleType));
}
private static bool IsReady(GameFieldManager field, int packId, Scene scene)
{
return field != null && field.PlayerController != null && field.IsLoadedField &&
!field.IsMoveMapProcessing && field.CurrentMapInfo != null &&
packId > 0 && field.CurrentMapInfo.PackId == packId && SG.Net != null &&
scene.IsValid() && scene.isLoaded && scene == UnityEngine.SceneManagement.SceneManager.GetActiveScene() &&
string.Equals(field.CurrentMapScenePath, scene.name, StringComparison.Ordinal);
}
private static IEnumerator WaitForPosition(GameFieldManager field,
FieldRewardObjectController reward, bool showIndicator, int packId, Scene scene)
{
try
{
// The caller finishes its interaction state after the patched method returns.
yield return null;
while (field != null && reward != null && scene.isLoaded &&
GameFieldManager.Instance == field && SG.Pack != null &&
SG.Pack.PlayingPackID == packId && reward.gameObject.scene == scene)
{
if (IsReady(field, packId, scene))
{
Pending.Remove(reward);
FieldPacket.SendFieldObjectRewardRequest(reward, showIndicator);
yield break;
}
yield return null;
}
Log.LogWarning("Deferred field reward cancelled after its field changed: pack=" + packId);
}
finally
{
Pending.Remove(reward);
}
}
}
+1
View File
@@ -64,6 +64,7 @@ public sealed class Plugin : BaseUnityPlugin
TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony)); TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony));
TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony)); TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony));
TryInstall("commission navigation completion", () => CommissionNavigation.Install(harmony, Logger)); TryInstall("commission navigation completion", () => CommissionNavigation.Install(harmony, Logger));
TryInstall("field reward position ordering", () => FieldRewardPosition.Install(harmony, Logger));
Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false"); Logger.LogInfo("Local identity active: AppManager.IsPlatformLogin => false");
} }
catch (Exception ex) catch (Exception ex)