diff --git a/go/internal/server/app/design.go b/go/internal/server/app/design.go index 3f04ddd..419e661 100644 --- a/go/internal/server/app/design.go +++ b/go/internal/server/app/design.go @@ -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 } diff --git a/go/internal/server/app/player_events.go b/go/internal/server/app/player_events.go index dfcf05b..821d186 100644 --- a/go/internal/server/app/player_events.go +++ b/go/internal/server/app/player_events.go @@ -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 } diff --git a/go/internal/server/app/player_gameplay.go b/go/internal/server/app/player_gameplay.go index cf979ff..a8f2afc 100644 --- a/go/internal/server/app/player_gameplay.go +++ b/go/internal/server/app/player_gameplay.go @@ -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) diff --git a/go/internal/server/app/player_session.go b/go/internal/server/app/player_session.go index bbcdb87..115b45b 100644 --- a/go/internal/server/app/player_session.go +++ b/go/internal/server/app/player_session.go @@ -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, diff --git a/go/internal/server/app/skyway_integration_test.go b/go/internal/server/app/skyway_integration_test.go new file mode 100644 index 0000000..dc57e91 --- /dev/null +++ b/go/internal/server/app/skyway_integration_test.go @@ -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") + } +} diff --git a/go/internal/server/design/gamedata/dispatch.go b/go/internal/server/design/gamedata/dispatch.go index 60ab6e8..c3e981a 100644 --- a/go/internal/server/design/gamedata/dispatch.go +++ b/go/internal/server/design/gamedata/dispatch.go @@ -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 diff --git a/go/internal/server/design/gamedata/field_pack.go b/go/internal/server/design/gamedata/field_pack.go index e22dcfe..8209fb9 100644 --- a/go/internal/server/design/gamedata/field_pack.go +++ b/go/internal/server/design/gamedata/field_pack.go @@ -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) { diff --git a/go/internal/server/design/gamedata/overwhelm.go b/go/internal/server/design/gamedata/overwhelm.go index 53014f6..7192761 100644 --- a/go/internal/server/design/gamedata/overwhelm.go +++ b/go/internal/server/design/gamedata/overwhelm.go @@ -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 diff --git a/go/internal/server/design/gamedata/skyway.go b/go/internal/server/design/gamedata/skyway.go new file mode 100644 index 0000000..129acaa --- /dev/null +++ b/go/internal/server/design/gamedata/skyway.go @@ -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 } diff --git a/go/internal/server/design/gamedata/story_catalog.go b/go/internal/server/design/gamedata/story_catalog.go index fc34d27..bcc74c3 100644 --- a/go/internal/server/design/gamedata/story_catalog.go +++ b/go/internal/server/design/gamedata/story_catalog.go @@ -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) diff --git a/go/internal/server/domain/battle/battle.go b/go/internal/server/domain/battle/battle.go index 0733c03..d855eb9 100644 --- a/go/internal/server/domain/battle/battle.go +++ b/go/internal/server/domain/battle/battle.go @@ -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 diff --git a/go/internal/server/domain/battle/hunting/dispatch.go b/go/internal/server/domain/battle/hunting/dispatch.go index e5181ca..5246fd0 100644 --- a/go/internal/server/domain/battle/hunting/dispatch.go +++ b/go/internal/server/domain/battle/hunting/dispatch.go @@ -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 diff --git a/go/internal/server/domain/battle/hunting/hunting.go b/go/internal/server/domain/battle/hunting/hunting.go index 7ff040d..0a1df02 100644 --- a/go/internal/server/domain/battle/hunting/hunting.go +++ b/go/internal/server/domain/battle/hunting/hunting.go @@ -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 diff --git a/go/internal/server/domain/battle/hunting/protocol_adapter.go b/go/internal/server/domain/battle/hunting/protocol_adapter.go index adf2859..f339c23 100644 --- a/go/internal/server/domain/battle/hunting/protocol_adapter.go +++ b/go/internal/server/domain/battle/hunting/protocol_adapter.go @@ -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} diff --git a/go/internal/server/domain/battle/protocol_adapter.go b/go/internal/server/domain/battle/protocol_adapter.go index 804a093..44ce080 100644 --- a/go/internal/server/domain/battle/protocol_adapter.go +++ b/go/internal/server/domain/battle/protocol_adapter.go @@ -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 diff --git a/go/internal/server/domain/events/tasks/skyway.go b/go/internal/server/domain/events/tasks/skyway.go new file mode 100644 index 0000000..df74d6b --- /dev/null +++ b/go/internal/server/domain/events/tasks/skyway.go @@ -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) +} diff --git a/go/internal/server/domain/world/field_object_routes.go b/go/internal/server/domain/world/field_object_routes.go index 8cb05b2..c73624a 100644 --- a/go/internal/server/domain/world/field_object_routes.go +++ b/go/internal/server/domain/world/field_object_routes.go @@ -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 } diff --git a/go/internal/server/domain/world/field_pack.go b/go/internal/server/domain/world/field_pack.go index d3f8b57..347693d 100644 --- a/go/internal/server/domain/world/field_pack.go +++ b/go/internal/server/domain/world/field_pack.go @@ -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") diff --git a/go/internal/server/domain/world/overwhelm.go b/go/internal/server/domain/world/overwhelm.go index 2f11023..aec80de 100644 --- a/go/internal/server/domain/world/overwhelm.go +++ b/go/internal/server/domain/world/overwhelm.go @@ -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) } diff --git a/go/internal/server/domain/world/protocol_field_adapter.go b/go/internal/server/domain/world/protocol_field_adapter.go index da796fc..6c47b4d 100644 --- a/go/internal/server/domain/world/protocol_field_adapter.go +++ b/go/internal/server/domain/world/protocol_field_adapter.go @@ -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 } diff --git a/go/internal/server/domain/world/protocol_story_adapter.go b/go/internal/server/domain/world/protocol_story_adapter.go index 6f0d8b8..9183c19 100644 --- a/go/internal/server/domain/world/protocol_story_adapter.go +++ b/go/internal/server/domain/world/protocol_story_adapter.go @@ -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 diff --git a/go/internal/server/domain/world/skyway.go b/go/internal/server/domain/world/skyway.go new file mode 100644 index 0000000..7a27290 --- /dev/null +++ b/go/internal/server/domain/world/skyway.go @@ -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 +} diff --git a/go/internal/server/domain/world/talent_effects.go b/go/internal/server/domain/world/talent_effects.go index f45bfc0..333346a 100644 --- a/go/internal/server/domain/world/talent_effects.go +++ b/go/internal/server/domain/world/talent_effects.go @@ -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 } diff --git a/go/internal/server/domain/world/world.go b/go/internal/server/domain/world/world.go index 5fd48d9..52a57a1 100644 --- a/go/internal/server/domain/world/world.go +++ b/go/internal/server/domain/world/world.go @@ -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 } diff --git a/plugins/LocalIdentity/FieldRewardPosition.cs b/plugins/LocalIdentity/FieldRewardPosition.cs new file mode 100644 index 0000000..60ccd6d --- /dev/null +++ b/plugins/LocalIdentity/FieldRewardPosition.cs @@ -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 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); + } + } +} diff --git a/plugins/LocalIdentity/Plugin.cs b/plugins/LocalIdentity/Plugin.cs index cdbf39a..e3f148c 100644 --- a/plugins/LocalIdentity/Plugin.cs +++ b/plugins/LocalIdentity/Plugin.cs @@ -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)