feat(all): implement SkyWay progression, battles and dispatch; fix field reward position ordering
This commit is contained in:
@@ -3,7 +3,11 @@ package app
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/battle/monsterhunt"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"bd2server/internal/server/domain/commerce"
|
||||
"bd2server/internal/server/domain/world"
|
||||
"bd2server/internal/server/protocol/wire"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
@@ -15,7 +19,8 @@ type designCatalog struct {
|
||||
fieldBuffs map[uint64]gamedata.FieldBuffDesign
|
||||
recovery *gamedata.PackRecoveryPolicy
|
||||
researchCharacters map[uint64]bool
|
||||
overwhelmSky []gamedata.SkyWayOverwhelmRule
|
||||
skyway *gamedata.SkyWayDesign
|
||||
skywaySchedules []world.SkyWaySchedule
|
||||
eventPlay *gamedata.EventPlayCatalog
|
||||
monsterHunt *monsterhunt.Rules
|
||||
presetDesign *gamedata.PresetDesign
|
||||
@@ -345,7 +350,50 @@ func loadDesign(c *configuration, seeds *seedCatalog) (*designCatalog, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -180,5 +180,11 @@ func (p *playerAssembly) events(ctx command.Context) error {
|
||||
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.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
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ func (p *playerAssembly) gameplay(ctx command.Context) error {
|
||||
return fmt.Errorf("load event economy: %w", err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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.AttachOverwhelmAuthorization(p.talentUseService.ConsumeOverwhelm)
|
||||
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)
|
||||
}
|
||||
p.talentUseService.AttachEffect(4, p.worldService.ApplyTalentFieldAbsorb)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
func (p *playerAssembly) session(ctx command.Context) error {
|
||||
p.handlers = []session.Handler{
|
||||
p.worldService,
|
||||
p.progressState,
|
||||
p.cashService,
|
||||
p.cashBonuses,
|
||||
@@ -35,7 +36,6 @@ func (p *playerAssembly) session(ctx command.Context) error {
|
||||
p.battleService,
|
||||
p.huntingService,
|
||||
p.monsterHuntService,
|
||||
p.worldService,
|
||||
p.worldService.CharacterService(),
|
||||
p.progressState,
|
||||
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 {
|
||||
Group, ID, Pack, Difficulty, TypeGroup, TypeID, AP, ClearTime, Growth uint64
|
||||
GroundID uint64
|
||||
GroundID, APType uint64
|
||||
Rewards []BattleReward
|
||||
boxes map[uint64]*dispatchGroup
|
||||
}
|
||||
@@ -63,6 +63,7 @@ func LoadDispatchDesign(root, version string, group, id uint64) (*DispatchDesign
|
||||
}
|
||||
ids, err = packedInts(raw, 15)
|
||||
boss, _ = optionalScalar(raw, 4)
|
||||
d.APType, _ = optionalScalar(raw, 1)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -47,7 +47,7 @@ func loadFieldPacks(db *sql.DB) (map[int]FieldPack, error) {
|
||||
values[field] = v[0]
|
||||
}
|
||||
}
|
||||
if values[55] != 3 && values[55] != 10 {
|
||||
if values[55] != 3 && values[55] != 5 && values[55] != 10 {
|
||||
continue
|
||||
}
|
||||
if id <= 0 || values[25] != uint64(id) {
|
||||
|
||||
@@ -4,51 +4,6 @@ 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 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, Count 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()
|
||||
return nil, err
|
||||
}
|
||||
if kind != 0 && kind != 1000 {
|
||||
if kind != 0 && kind != 5 && kind != 1000 {
|
||||
continue
|
||||
}
|
||||
protoID, err := read(25)
|
||||
|
||||
@@ -26,6 +26,7 @@ type Service struct {
|
||||
onMonsterWin func(ctx command.Context) error
|
||||
commitHealth func(ctx command.Context, _ map[uint64]uint64) error
|
||||
hunting HuntingRuntime
|
||||
skyway SkyWayRuntime
|
||||
monsterHunt MonsterHuntRuntime
|
||||
eventBattles []EventBattleRuntime
|
||||
fieldMonsters FieldMonsterRuntime
|
||||
@@ -83,6 +84,14 @@ type HuntingRuntime interface {
|
||||
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
|
||||
|
||||
func (s *Service) AttachHunting(runtime HuntingRuntime) {
|
||||
@@ -91,6 +100,7 @@ func (s *Service) AttachHunting(runtime HuntingRuntime) {
|
||||
|
||||
type battleState struct {
|
||||
entered bool
|
||||
retryable bool
|
||||
index uint64
|
||||
round uint64
|
||||
monster uint64
|
||||
|
||||
@@ -2,6 +2,7 @@ package hunting
|
||||
|
||||
import (
|
||||
"bd2server/internal/server/design/gamedata"
|
||||
"bd2server/internal/server/domain/command"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,11 +21,18 @@ type dispatchState struct {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if seconds == 0 {
|
||||
return j.Count
|
||||
|
||||
@@ -46,7 +46,11 @@ type Service struct {
|
||||
currentPack func(command.Context) (int, error)
|
||||
load func(int) (*gamedata.HuntingPack, 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
|
||||
grant func(command.Context, string, []gamedata.Reward) ([]byte, error)
|
||||
apDesign *gamedata.HuntingAPDesign
|
||||
|
||||
@@ -399,6 +399,14 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
|
||||
if err != nil {
|
||||
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
|
||||
if path == "/HuntDispatchRewardPreview" {
|
||||
if !exists {
|
||||
@@ -411,10 +419,10 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
|
||||
bonus := min(remaining, job.Bonus)
|
||||
free := remaining - bonus
|
||||
if free > 0 {
|
||||
preview = append(preview, gamedata.BattleReward{Type: 21, Count: free})
|
||||
preview = append(preview, gamedata.BattleReward{Type: freeType, Count: free})
|
||||
}
|
||||
if bonus > 0 {
|
||||
preview = append(preview, gamedata.BattleReward{Type: 23, Count: bonus})
|
||||
preview = append(preview, gamedata.BattleReward{Type: bonusType, Count: bonus})
|
||||
}
|
||||
bundle := dispatchPreview(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)
|
||||
refundBonus := min(remaining, job.Bonus)
|
||||
refundFree := remaining - refundBonus
|
||||
next := s.clone()
|
||||
next.Free += refundFree
|
||||
next.Bonus += refundBonus
|
||||
if e := s.persist(ctx, next); e != nil {
|
||||
return code, nil, true, e
|
||||
if !sky {
|
||||
next := s.clone()
|
||||
next.Free += refundFree
|
||||
next.Bonus += refundBonus
|
||||
if e := s.persist(ctx, next); e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
}
|
||||
if refundFree > 0 {
|
||||
completed = append(completed, gamedata.BattleReward{Type: 21, Count: refundFree})
|
||||
completed = append(completed, gamedata.BattleReward{Type: freeType, Count: refundFree})
|
||||
}
|
||||
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 {
|
||||
return code, nil, true, e
|
||||
}
|
||||
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)
|
||||
} else {
|
||||
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")
|
||||
}
|
||||
}
|
||||
if s.dispatchEligibility != nil {
|
||||
if e = s.dispatchEligibility(d); e != nil {
|
||||
if d.TypeGroup != 0 && s.dispatchEligibility != nil {
|
||||
if e = s.dispatchEligibility(ctx, d); e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
} 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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
return code, nil, true, e
|
||||
}
|
||||
if sky {
|
||||
rewards, e = s.dispatchBonus(d, rewards)
|
||||
if e != nil {
|
||||
return code, nil, true, e
|
||||
}
|
||||
}
|
||||
next := s.clone()
|
||||
free := min(cost, next.Free)
|
||||
bonus := cost - free
|
||||
next.Free -= free
|
||||
next.Bonus -= bonus
|
||||
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.Bonus -= bonus
|
||||
}
|
||||
if path == "/HuntDispatchStart" {
|
||||
now := uint64(time.Now().UnixMilli())
|
||||
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
|
||||
}
|
||||
}
|
||||
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}
|
||||
ds.Jobs[jobKey] = 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 {
|
||||
beforeFree, beforeBonus := s.state.Free, s.state.Bonus
|
||||
bundle, e := s.dispatchGrant(ctx, "dispatch:"+key, rewards)
|
||||
@@ -549,8 +620,15 @@ func (s *Service) handleDispatch(ctx command.Context, path string, req []byte, s
|
||||
next.Bonus -= delta
|
||||
}
|
||||
}
|
||||
if e = s.persist(ctx, next); e != nil {
|
||||
return code, nil, true, e
|
||||
if !sky {
|
||||
if e = s.persist(ctx, next); e != nil {
|
||||
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}
|
||||
|
||||
@@ -19,7 +19,7 @@ func checkSeq(request []byte) 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
|
||||
}
|
||||
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)
|
||||
endCode := 15
|
||||
if path == "/BattleEndTest" {
|
||||
path = "/BattleEnd"
|
||||
endCode = 181
|
||||
}
|
||||
switch path {
|
||||
case "/BattlePhaseChange":
|
||||
if !state.entered {
|
||||
@@ -144,6 +149,15 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
|
||||
}
|
||||
var phases []gamedata.BattlePhase
|
||||
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 {
|
||||
instance, _, e := s.fieldMonsters.BeginFieldMonsterBattle(ctx, packID, monster, deck)
|
||||
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.retryable = false
|
||||
state.monster, state.deck, state.pack = monster, deck, packID
|
||||
state.mode = mode
|
||||
state.enterReceipt = identity
|
||||
@@ -198,7 +213,7 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
|
||||
s.transientVersion++
|
||||
return 52, response, true, nil
|
||||
case "/BattleRetry":
|
||||
if !state.entered {
|
||||
if !state.entered && !state.retryable {
|
||||
return 0, nil, true, errors.New("battle: retry before enter")
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
// Retry requests carry the current deck; the response must restore
|
||||
// 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
|
||||
}
|
||||
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.phase, state.phaseStarted, state.phaseSeq, state.phaseReply = 0, false, 0, nil
|
||||
state.entered, state.retryable = true, false
|
||||
s.transientVersion++
|
||||
return 58, response, true, nil
|
||||
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")
|
||||
}
|
||||
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 response []byte
|
||||
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) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
seq, _, _ := wire.Varint(request, 1)
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
if field == 6 && gamedata.IsSkyWayMode(state.mode) && result == 1 {
|
||||
continue
|
||||
}
|
||||
response = wire.AppendBytes(response, field, nil)
|
||||
}
|
||||
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++
|
||||
return 15, response, true, nil
|
||||
return endCode, response, true, nil
|
||||
case "/BattleExit":
|
||||
state.retryable = false
|
||||
state.entered, state.index, state.round, state.deck, state.pack, state.initialBlue = false, 0, 0, 0, 0, nil
|
||||
s.transientVersion++
|
||||
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
|
||||
}
|
||||
current, err := s.currentFieldMap(ctx, pack)
|
||||
if err != nil || current != mapID {
|
||||
return fmt.Errorf("%w: field object outside current map", ErrInvalidRequest)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ func (s *Service) LastPlayedPackID(ctx command.Context) (uint64, error) {
|
||||
return 0, errors.New("world: saved login pack is unavailable")
|
||||
}
|
||||
if pack, field := s.fieldPacks[int(id)]; field {
|
||||
if pack.Type == 5 {
|
||||
return id, nil
|
||||
}
|
||||
saved, _ := s.state.Position()
|
||||
if !pack.MapIDs[saved.Position.MapID] {
|
||||
return 0, errors.New("world: saved arena map does not belong to pack")
|
||||
|
||||
@@ -23,8 +23,7 @@ type overwhelmedMonster struct {
|
||||
Costs []gamedata.Reward
|
||||
}
|
||||
|
||||
func (s *Service) AttachOverwhelmDesign(source *gamedata.Source, rows []gamedata.SkyWayOverwhelmRule) error {
|
||||
s.overwhelmSky = rows
|
||||
func (s *Service) AttachOverwhelmDesign(source *gamedata.Source) error {
|
||||
s.overwhelmQuest = func(pack, quest int) (gamedata.OverwhelmQuestRule, error) {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
return 275, nil, true, fmt.Errorf("world: overwhelm deck mismatch")
|
||||
}
|
||||
if m.Mode >= 9 && m.Mode <= 15 {
|
||||
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 m.Mode == 2 {
|
||||
if int(m.Group) != definition.GroupID || definition.GroupID == 0 || !s.monsterEligible(pack, definition) {
|
||||
return 275, nil, true, ErrInvalidRequest
|
||||
}
|
||||
@@ -211,6 +184,16 @@ func (s *Service) handleOverwhelm(ctx command.Context, request []byte) (int, []b
|
||||
for _, row := range monsters {
|
||||
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 {
|
||||
if !v.Claims[m.Instance] && m.Definition.Type != 3 {
|
||||
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 {
|
||||
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)
|
||||
if e != nil {
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -362,6 +362,14 @@ func (s *Service) resolveActivePartyWires(ctx command.Context, packID, questID i
|
||||
func (s *Service) storyPackDBInfoRows(ctx command.Context) [][]byte {
|
||||
ids := make([]int, 0, len(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) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
@@ -400,7 +408,7 @@ func (s *Service) storyPackDBInfoRows(ctx command.Context) [][]byte {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -719,6 +727,27 @@ func (s *Service) purchaseStoryPack(ctx command.Context, id int, initial bool) (
|
||||
if !exists {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
if code, out, handled, err := s.handleSkyWay(ctx, path, request); handled {
|
||||
return code, out, handled, err
|
||||
}
|
||||
if s.todayQuests != nil {
|
||||
if code, body, handled, err := s.todayQuests.Handle(ctx, path, request); handled {
|
||||
if err == nil {
|
||||
@@ -1090,6 +1122,19 @@ func (s *Service) Handle(ctx command.Context, path string, request []byte) (int,
|
||||
if !s.packUnlocked(ctx, 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 _, err := s.ensureQuestItems(ctx, pack, active); err != nil {
|
||||
return 0, nil, true, err
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
return saved.Position.MapID, nil
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ type Service struct {
|
||||
fieldBuffs map[uint64]gamedata.FieldBuffDesign
|
||||
talentPackInfo func(ctx command.Context, _ int) ([]byte, error)
|
||||
overwhelmAuthorize func(ctx command.Context, _ string, _ uint64) error
|
||||
overwhelmSky []gamedata.SkyWayOverwhelmRule
|
||||
skyway *skyWayRuntime
|
||||
overwhelmQuest func(int, int) (gamedata.OverwhelmQuestRule, error)
|
||||
overwhelmHunting interface {
|
||||
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 {
|
||||
return map[int]gamedata.QuestDesign{}, true
|
||||
}
|
||||
if _, exists := s.fieldPacks[packID]; exists {
|
||||
return map[int]gamedata.QuestDesign{}, true
|
||||
}
|
||||
if s.storyCatalog != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public sealed class Plugin : BaseUnityPlugin
|
||||
TryInstall("age-gate persistence", () => LocalAccountPolicy.InstallAgeGatePersistence(harmony));
|
||||
TryInstall("database diagnostics", () => ClientDiagnostics.InstallDatabaseDiagnostics(harmony));
|
||||
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");
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
Reference in New Issue
Block a user