diff --git a/server/internal/model/quest.go b/server/internal/model/quest.go index fc83394..dfec2d4 100644 --- a/server/internal/model/quest.go +++ b/server/internal/model/quest.go @@ -2,6 +2,16 @@ package model import "fmt" +type QuestType int32 + +const ( + QuestTypeUnknown QuestType = 0 + QuestTypeMain QuestType = 1 + QuestTypeEvent QuestType = 2 + QuestTypeExtra QuestType = 3 + QuestTypeBigHunt QuestType = 4 +) + type QuestFlowType int32 const ( diff --git a/server/internal/questflow/bighunt_quest.go b/server/internal/questflow/bighunt_quest.go index f2a4dac..53d8307 100644 --- a/server/internal/questflow/bighunt_quest.go +++ b/server/internal/questflow/bighunt_quest.go @@ -35,8 +35,9 @@ func (h *QuestHandler) HandleBigHuntQuestFinish(user *store.UserState, questId i } target := h.targetForBigHunt(questId) - outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis) - if !isRetired { + var outcome FinishOutcome + if !isRetired && !isAnnihilated { + outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } diff --git a/server/internal/questflow/event_quest.go b/server/internal/questflow/event_quest.go index 80bfff6..6c67cef 100644 --- a/server/internal/questflow/event_quest.go +++ b/server/internal/questflow/event_quest.go @@ -44,8 +44,9 @@ func (h *QuestHandler) HandleEventQuestFinish(user *store.UserState, eventQuestC } target := h.targetForEvent(eventQuestChapterId, questId) - outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis) - if !isRetired { + var outcome FinishOutcome + if !isRetired && !isAnnihilated { + outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false) h.recordSideStoryLimitContentStatus(user, questId, nowMillis) } diff --git a/server/internal/questflow/extra_quest.go b/server/internal/questflow/extra_quest.go index e3e5d34..db4e830 100644 --- a/server/internal/questflow/extra_quest.go +++ b/server/internal/questflow/extra_quest.go @@ -42,8 +42,9 @@ func (h *QuestHandler) HandleExtraQuestFinish(user *store.UserState, questId int } target := h.targetForExtra(questId) - outcome := h.evaluateFinishOutcome(user, questId, target, nowMillis) - if !isRetired { + var outcome FinishOutcome + if !isRetired && !isAnnihilated { + outcome = h.evaluateFinishOutcome(user, questId, target, nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, false) } diff --git a/server/internal/questflow/handler.go b/server/internal/questflow/handler.go index 923b261..30c6cc8 100644 --- a/server/internal/questflow/handler.go +++ b/server/internal/questflow/handler.go @@ -13,6 +13,7 @@ type RewardGrant struct { PossessionType model.PossessionType PossessionId int32 Count int32 + IsAutoSale bool } type FinishOutcome struct { @@ -36,7 +37,7 @@ type QuestHandler struct { } func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig, sideStory *masterdata.SideStoryCatalog, campaigns *campaign.Catalog, characterRebirth *masterdata.CharacterRebirthCatalog) *QuestHandler { - granter := BuildGranter(catalog) + granter := BuildGranter(catalog, config) var sideStoryChapters map[int32]int32 if sideStory != nil { sideStoryChapters = sideStory.ChapterByEventQuestId @@ -51,7 +52,7 @@ func NewQuestHandler(catalog *masterdata.QuestCatalog, config *masterdata.GameCo } } -func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { +func BuildGranter(catalog *masterdata.QuestCatalog, config *masterdata.GameConfig) *store.PossessionGranter { costumeById := make(map[int32]store.CostumeRef, len(catalog.CostumeById)) for id, cm := range catalog.CostumeById { costumeById[id] = store.CostumeRef{CharacterId: cm.CharacterId} @@ -111,6 +112,15 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { } } + partsSellPriceL1 := make(map[int32]int32, len(catalog.SellPriceByRarity)) + for rarity, fn := range catalog.SellPriceByRarity { + partsSellPriceL1[int32(rarity)] = fn.Evaluate(1) + } + var goldItemId int32 + if config != nil { + goldItemId = config.ConsumableItemIdForGold + } + return &store.PossessionGranter{ CostumeById: costumeById, WeaponById: weaponById, @@ -122,5 +132,7 @@ func BuildGranter(catalog *masterdata.QuestCatalog) *store.PossessionGranter { PartsVariantsByGroupRarity: partsVariants, PartsSubStatusPool: catalog.SubStatusPool, PartsSubStatusDefs: partsSubDefs, + PartsSellPriceL1ByRarity: partsSellPriceL1, + GoldConsumableItemId: goldItemId, } } diff --git a/server/internal/questflow/quest.go b/server/internal/questflow/quest.go index 74e7526..90790d4 100644 --- a/server/internal/questflow/quest.go +++ b/server/internal/questflow/quest.go @@ -204,9 +204,8 @@ func (h *QuestHandler) applyQuestVictory(user *store.UserState, questId int32, o } questState.IsRewardGranted = true } - for _, drop := range outcome.DropRewards { - h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis) - } + raritySet, rankSet := parseAutoSaleRules(user.AutoSaleSettings) + h.grantDropRewards(user, outcome.DropRewards, raritySet, rankSet, nowMillis) for _, reward := range outcome.ReplayFlowFirstClearRewards { h.applyRewardPossession(user, reward.PossessionType, reward.PossessionId, reward.Count, nowMillis) } @@ -260,11 +259,12 @@ func (h *QuestHandler) HandleQuestFinish(user *store.UserState, questId int32, i h.initQuestState(user, questId) - outcome := h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis) wasReplay := model.IsReplayQuestFlowType(user.MainQuest.CurrentQuestFlowType) wasMenuReplay := user.MainQuest.SavedContext.Active - if !isRetired { + var outcome FinishOutcome + if !isRetired && !isAnnihilated { + outcome = h.evaluateFinishOutcome(user, questId, h.targetForMain(questId), nowMillis) h.applyQuestVictory(user, questId, &outcome, nowMillis, wasReplay) // A replay-flow finish must NOT move the MainFlow scene pointer: the @@ -334,12 +334,11 @@ func (h *QuestHandler) HandleQuestSkip(user *store.UserState, questId, skipCount if user.ConsumableItems[skipTicketId] < 0 { user.ConsumableItems[skipTicketId] = 0 } + raritySet, rankSet := parseAutoSaleRules(user.AutoSaleSettings) var allDrops []RewardGrant for range skipCount { drops := h.computeDropRewards(questDef, target, nowMillis) - for _, drop := range drops { - h.applyRewardPossession(user, drop.PossessionType, drop.PossessionId, drop.Count, nowMillis) - } + h.grantDropRewards(user, drops, raritySet, rankSet, nowMillis) allDrops = append(allDrops, drops...) if questDef.Gold != 0 { diff --git a/server/internal/questflow/rewards.go b/server/internal/questflow/rewards.go index 110b773..1cc9d00 100644 --- a/server/internal/questflow/rewards.go +++ b/server/internal/questflow/rewards.go @@ -3,6 +3,8 @@ package questflow import ( "fmt" "log" + "strconv" + "strings" "lunar-tear/server/internal/campaign" "lunar-tear/server/internal/gameutil" @@ -128,6 +130,54 @@ func (h *QuestHandler) evaluateFinishOutcome(user *store.UserState, questId int3 return outcome } +var autoSaleRarityTiers = map[int32]bool{10: true, 20: true, 30: true, 40: true, 50: true} + +// Rarity tiers (10..50) and ranks (1..5) are disjoint, so the delimited values +// are classified by range — independent of the client's map key or delimiter. +func parseAutoSaleRules(settings map[int32]store.AutoSaleSettingState) (raritySet, rankSet map[int32]bool) { + raritySet = map[int32]bool{} + rankSet = map[int32]bool{} + for _, s := range settings { + for _, n := range extractInts(s.PossessionAutoSaleItemValue) { + switch { + case autoSaleRarityTiers[n]: + raritySet[n] = true + case n >= 1 && n <= 5: + rankSet[n] = true + } + } + } + return raritySet, rankSet +} + +func extractInts(s string) []int32 { + fields := strings.FieldsFunc(s, func(r rune) bool { return r < '0' || r > '9' }) + out := make([]int32, 0, len(fields)) + for _, f := range fields { + if v, err := strconv.Atoi(f); err == nil { + out = append(out, int32(v)) + } + } + return out +} + +func (h *QuestHandler) grantDropRewards(user *store.UserState, drops []RewardGrant, raritySet, rankSet map[int32]bool, nowMillis int64) { + for i := range drops { + d := drops[i] + if d.PossessionType == model.PossessionTypeParts || d.PossessionType == model.PossessionTypePartsEnhanced { + chosenId, sold := h.Granter.GrantOrSellPartsDrop(user, d.PossessionId, raritySet, rankSet, nowMillis) + if sold { + // Sold parts have no inventory row, so the popup needs the rolled + // variant id; kept parts read theirs from the parts table diff. + drops[i].PossessionId = chosenId + drops[i].IsAutoSale = true + } + continue + } + h.applyRewardPossession(user, d.PossessionType, d.PossessionId, d.Count, nowMillis) + } +} + func (h *QuestHandler) computeDropRewards(questDef masterdata.EntityMQuest, target campaign.QuestTarget, nowMillis int64) []RewardGrant { var drops []RewardGrant var dropRate campaign.DropRateMul diff --git a/server/internal/service/quest_auto_orbit.go b/server/internal/service/quest_auto_orbit.go new file mode 100644 index 0000000..c507180 --- /dev/null +++ b/server/internal/service/quest_auto_orbit.go @@ -0,0 +1,83 @@ +package service + +import ( + "log" + + "lunar-tear/server/internal/model" + "lunar-tear/server/internal/questflow" + "lunar-tear/server/internal/store" +) + +func startAutoOrbit(user *store.UserState, questType model.QuestType, chapterId, questId, maxCount int32, nowMillis int64) { + if maxCount <= 0 { + if user.QuestAutoOrbit.MaxAutoOrbitCount > 0 { + log.Printf("[autoOrbit] clear (start without max): prev questType=%d chapter=%d quest=%d cleared=%d/%d", + user.QuestAutoOrbit.QuestType, user.QuestAutoOrbit.ChapterId, user.QuestAutoOrbit.QuestId, + user.QuestAutoOrbit.ClearedAutoOrbitCount, user.QuestAutoOrbit.MaxAutoOrbitCount) + } + user.QuestAutoOrbit = store.QuestAutoOrbitState{} + return + } + s := user.QuestAutoOrbit + if s.MaxAutoOrbitCount > 0 && + s.QuestType == int32(questType) && s.ChapterId == chapterId && + s.QuestId == questId && s.MaxAutoOrbitCount == maxCount { + s.LatestVersion = nowMillis + user.QuestAutoOrbit = s + log.Printf("[autoOrbit] continue cleared=%d/%d", s.ClearedAutoOrbitCount, s.MaxAutoOrbitCount) + return + } + log.Printf("[autoOrbit] start questType=%d chapter=%d quest=%d max=%d", questType, chapterId, questId, maxCount) + user.QuestAutoOrbit = store.QuestAutoOrbitState{ + QuestType: int32(questType), + ChapterId: chapterId, + QuestId: questId, + MaxAutoOrbitCount: maxCount, + LatestVersion: nowMillis, + } +} + +func finishAutoOrbit(user *store.UserState, isAutoOrbit, isRetired, isAnnihilated bool, questType model.QuestType, chapterId, questId int32, nowMillis int64, drops []questflow.RewardGrant) (endedDrops []store.AutoOrbitDropEntry, loopEnded bool) { + s := user.QuestAutoOrbit + if s.MaxAutoOrbitCount <= 0 { + return nil, false + } + if s.QuestType != int32(questType) || s.ChapterId != chapterId || s.QuestId != questId { + log.Printf("[autoOrbit] finish for other quest, ignored: tracked={qt=%d ch=%d q=%d} got={qt=%d ch=%d q=%d}", + s.QuestType, s.ChapterId, s.QuestId, int32(questType), chapterId, questId) + return nil, false + } + if !isRetired && !isAnnihilated { + added := 0 + for _, d := range drops { + s.AccumulatedDrops = append(s.AccumulatedDrops, store.AutoOrbitDropEntry{ + PossessionType: int32(d.PossessionType), + PossessionId: d.PossessionId, + Count: d.Count, + IsAutoSale: d.IsAutoSale, + }) + added++ + } + s.ClearedAutoOrbitCount++ + log.Printf("[autoOrbit] iter cleared=%d/%d +%d drops (total=%d)", + s.ClearedAutoOrbitCount, s.MaxAutoOrbitCount, added, len(s.AccumulatedDrops)) + } + s.LastClearDatetime = nowMillis + s.LatestVersion = nowMillis + if !isAutoOrbit || isRetired || isAnnihilated || s.ClearedAutoOrbitCount >= s.MaxAutoOrbitCount { + log.Printf("[autoOrbit] loop end: cleared=%d/%d total drops=%d (returned in response, accumulator kept)", + s.ClearedAutoOrbitCount, s.MaxAutoOrbitCount, len(s.AccumulatedDrops)) + user.QuestAutoOrbit = store.QuestAutoOrbitState{AccumulatedDrops: s.AccumulatedDrops} + return s.AccumulatedDrops, true + } + user.QuestAutoOrbit = s + return nil, false +} + +func consumeAutoOrbitRewards(user *store.UserState) []store.AutoOrbitDropEntry { + drops := user.QuestAutoOrbit.AccumulatedDrops + log.Printf("[autoOrbit] consume on FinishAutoOrbit: returning %d drops (loop status max=%d cleared=%d)", + len(drops), user.QuestAutoOrbit.MaxAutoOrbitCount, user.QuestAutoOrbit.ClearedAutoOrbitCount) + user.QuestAutoOrbit = store.QuestAutoOrbitState{} + return drops +} diff --git a/server/internal/service/quest_event.go b/server/internal/service/quest_event.go index 8355d29..8b58be1 100644 --- a/server/internal/service/quest_event.go +++ b/server/internal/service/quest_event.go @@ -6,6 +6,7 @@ import ( pb "lunar-tear/server/gen/proto" "lunar-tear/server/internal/gametime" + "lunar-tear/server/internal/model" "lunar-tear/server/internal/questflow" "lunar-tear/server/internal/store" @@ -13,13 +14,15 @@ import ( ) func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartEventQuestRequest) (*pb.StartEventQuestResponse, error) { - log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v", req.EventQuestChapterId, req.QuestId, req.IsBattleOnly) + log.Printf("[QuestService] StartEventQuest: chapterId=%d questId=%d isBattleOnly=%v maxAutoOrbitCount=%d", + req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.MaxAutoOrbitCount) engine := s.holder.Get().QuestHandler userId := CurrentUserId(ctx, s.users, s.sessions) nowMillis := gametime.NowMillis() s.users.UpdateUser(userId, func(user *store.UserState) { engine.HandleEventQuestStart(user, req.EventQuestChapterId, req.QuestId, req.IsBattleOnly, req.UserDeckNumber, nowMillis) + startAutoOrbit(user, model.QuestTypeEvent, req.EventQuestChapterId, req.QuestId, req.MaxAutoOrbitCount, nowMillis) }) drops := engine.BattleDropRewards(req.QuestId) @@ -38,16 +41,25 @@ func (s *QuestServiceServer) StartEventQuest(ctx context.Context, req *pb.StartE } func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.FinishEventQuestRequest) (*pb.FinishEventQuestResponse, error) { - log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v", req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated) + log.Printf("[QuestService] FinishEventQuest: chapterId=%d questId=%d isRetired=%v isAnnihilated=%v isAutoOrbit=%v", + req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, req.IsAutoOrbit) nowMillis := gametime.NowMillis() engine := s.holder.Get().QuestHandler userId := CurrentUserId(ctx, s.users, s.sessions) var outcome questflow.FinishOutcome + var endedDrops []store.AutoOrbitDropEntry + var loopEnded bool s.users.UpdateUser(userId, func(user *store.UserState) { outcome = engine.HandleEventQuestFinish(user, req.EventQuestChapterId, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis) + endedDrops, loopEnded = finishAutoOrbit(user, req.IsAutoOrbit, req.IsRetired, req.IsAnnihilated, model.QuestTypeEvent, req.EventQuestChapterId, req.QuestId, nowMillis, outcome.DropRewards) }) + autoOrbitReward := emptyAutoOrbitReward() + if loopEnded { + autoOrbitReward.DropReward = autoOrbitDropsToProto(endedDrops) + } + return &pb.FinishEventQuestResponse{ DropReward: toProtoRewards(outcome.DropRewards), FirstClearReward: toProtoRewards(outcome.FirstClearRewards), @@ -57,6 +69,7 @@ func (s *QuestServiceServer) FinishEventQuest(ctx context.Context, req *pb.Finis IsBigWin: outcome.IsBigWin, BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds, UserStatusCampaignReward: []*pb.QuestReward{}, + AutoOrbitReward: autoOrbitReward, }, nil } diff --git a/server/internal/service/quest_main.go b/server/internal/service/quest_main.go index 48b7c4b..8b1a871 100644 --- a/server/internal/service/quest_main.go +++ b/server/internal/service/quest_main.go @@ -65,7 +65,8 @@ func (s *QuestServiceServer) UpdateMainQuestSceneProgress(ctx context.Context, r } func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMainQuestRequest) (*pb.StartMainQuestResponse, error) { - log.Printf("[QuestService] StartMainQuest: %+v", req) + log.Printf("[QuestService] StartMainQuest: questId=%d isMainFlow=%v isReplayFlow=%v isBattleOnly=%v maxAutoOrbitCount=%d", + req.QuestId, req.IsMainFlow, req.IsReplayFlow, req.IsBattleOnly, req.MaxAutoOrbitCount) engine := s.holder.Get().QuestHandler userId := CurrentUserId(ctx, s.users, s.sessions) @@ -76,6 +77,7 @@ func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMa } else { engine.HandleQuestStart(user, req.QuestId, req.IsBattleOnly, req.IsMainFlow, req.UserDeckNumber, nowMillis) } + startAutoOrbit(user, model.QuestTypeMain, 0, req.QuestId, req.MaxAutoOrbitCount, nowMillis) }) drops := engine.BattleDropRewards(req.QuestId) @@ -93,6 +95,26 @@ func (s *QuestServiceServer) StartMainQuest(ctx context.Context, req *pb.StartMa }, nil } +func emptyAutoOrbitReward() *pb.QuestAutoOrbitResult { + return &pb.QuestAutoOrbitResult{ + DropReward: []*pb.QuestReward{}, + UserStatusCampaignReward: []*pb.QuestReward{}, + } +} + +func autoOrbitDropsToProto(drops []store.AutoOrbitDropEntry) []*pb.QuestReward { + out := make([]*pb.QuestReward, len(drops)) + for i, d := range drops { + out[i] = &pb.QuestReward{ + PossessionType: d.PossessionType, + PossessionId: d.PossessionId, + Count: d.Count, + IsAutoSale: d.IsAutoSale, + } + } + return out +} + func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward { if len(grants) == 0 { return []*pb.QuestReward{} @@ -103,23 +125,32 @@ func toProtoRewards(grants []questflow.RewardGrant) []*pb.QuestReward { PossessionType: int32(g.PossessionType), PossessionId: g.PossessionId, Count: g.Count, + IsAutoSale: g.IsAutoSale, } } return out } func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.FinishMainQuestRequest) (*pb.FinishMainQuestResponse, error) { - log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v storySkipType=%d", - req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.StorySkipType) + log.Printf("[QuestService] FinishMainQuest: questId=%d isMainFlow=%v isRetired=%v isAnnihilated=%v isAutoOrbit=%v storySkipType=%d", + req.QuestId, req.IsMainFlow, req.IsRetired, req.IsAnnihilated, req.IsAutoOrbit, req.StorySkipType) nowMillis := gametime.NowMillis() engine := s.holder.Get().QuestHandler userId := CurrentUserId(ctx, s.users, s.sessions) var outcome questflow.FinishOutcome + var endedDrops []store.AutoOrbitDropEntry + var loopEnded bool s.users.UpdateUser(userId, func(user *store.UserState) { outcome = engine.HandleQuestFinish(user, req.QuestId, req.IsRetired, req.IsAnnihilated, nowMillis) + endedDrops, loopEnded = finishAutoOrbit(user, req.IsAutoOrbit, req.IsRetired, req.IsAnnihilated, model.QuestTypeMain, 0, req.QuestId, nowMillis, outcome.DropRewards) }) + autoOrbitReward := emptyAutoOrbitReward() + if loopEnded { + autoOrbitReward.DropReward = autoOrbitDropsToProto(endedDrops) + } + return &pb.FinishMainQuestResponse{ DropReward: toProtoRewards(outcome.DropRewards), FirstClearReward: toProtoRewards(outcome.FirstClearRewards), @@ -130,6 +161,7 @@ func (s *QuestServiceServer) FinishMainQuest(ctx context.Context, req *pb.Finish BigWinClearedQuestMissionIdList: outcome.BigWinClearedQuestMissionIds, ReplayFlowFirstClearReward: toProtoRewards(outcome.ReplayFlowFirstClearRewards), UserStatusCampaignReward: []*pb.QuestReward{}, + AutoOrbitReward: autoOrbitReward, }, nil } @@ -162,7 +194,26 @@ func (s *QuestServiceServer) RestartMainQuest(ctx context.Context, req *pb.Resta func (s *QuestServiceServer) FinishAutoOrbit(ctx context.Context, req *emptypb.Empty) (*pb.FinishAutoOrbitResponse, error) { log.Printf("[QuestService] FinishAutoOrbit") - return &pb.FinishAutoOrbitResponse{}, nil + userId := CurrentUserId(ctx, s.users, s.sessions) + var drops []store.AutoOrbitDropEntry + s.users.UpdateUser(userId, func(user *store.UserState) { + drops = consumeAutoOrbitRewards(user) + }) + pbDrops := make([]*pb.QuestReward, len(drops)) + for i, d := range drops { + pbDrops[i] = &pb.QuestReward{ + PossessionType: d.PossessionType, + PossessionId: d.PossessionId, + Count: d.Count, + } + } + return &pb.FinishAutoOrbitResponse{ + AutoOrbitResult: []*pb.QuestReward{}, + AutoOrbitReward: &pb.QuestAutoOrbitResult{ + DropReward: pbDrops, + UserStatusCampaignReward: []*pb.QuestReward{}, + }, + }, nil } func (s *QuestServiceServer) SkipQuest(ctx context.Context, req *pb.SkipQuestRequest) (*pb.SkipQuestResponse, error) { diff --git a/server/internal/store/clone.go b/server/internal/store/clone.go index 7b9ba5f..87966e5 100644 --- a/server/internal/store/clone.go +++ b/server/internal/store/clone.go @@ -85,6 +85,7 @@ func CloneUserState(u UserState) UserState { out.CostumeLotteryEffectPending = maps.Clone(u.CostumeLotteryEffectPending) out.AutoSaleSettings = maps.Clone(u.AutoSaleSettings) out.CharacterRebirths = maps.Clone(u.CharacterRebirths) + out.QuestAutoOrbit.AccumulatedDrops = append([]AutoOrbitDropEntry(nil), u.QuestAutoOrbit.AccumulatedDrops...) return out } diff --git a/server/internal/store/helpers.go b/server/internal/store/helpers.go index 1f48db9..6ed700e 100644 --- a/server/internal/store/helpers.go +++ b/server/internal/store/helpers.go @@ -131,6 +131,9 @@ type PossessionGranter struct { PartsSubStatusPool map[int32][]int32 PartsSubStatusDefs map[int32]PartsStatusSubDef + PartsSellPriceL1ByRarity map[int32]int32 + GoldConsumableItemId int32 + LastChangedStoryWeaponIds []int32 } @@ -201,19 +204,51 @@ func (g *PossessionGranter) GrantCompanion(user *UserState, companionId int32, n } func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, nowMillis int64) { - ref, refOk := g.PartsById[requestedPartsId] - if !refOk { - key := uuid.New().String() - user.Parts[key] = PartsState{ - UserPartsUuid: key, - PartsId: requestedPartsId, - Level: 1, - AcquisitionDatetime: nowMillis, - } - log.Printf("[GrantParts] unknown partsId=%d, granted as-is with no variant roll", requestedPartsId) + chosenPartsId, chosenRef, ok := g.rollPartsVariant(requestedPartsId) + if !ok { + g.grantBareParts(user, requestedPartsId, nowMillis) return } + g.createParts(user, chosenPartsId, chosenRef, nowMillis) +} +// The rolled variant sets both rarity and rank, so the auto-sale decision can +// only happen after the roll. Returns the rolled variant id and whether it sold. +func (g *PossessionGranter) GrantOrSellPartsDrop(user *UserState, requestedPartsId int32, raritySet, rankSet map[int32]bool, nowMillis int64) (int32, bool) { + chosenPartsId, chosenRef, ok := g.rollPartsVariant(requestedPartsId) + if !ok { + g.grantBareParts(user, requestedPartsId, nowMillis) + return requestedPartsId, false + } + rarity := chosenRef.RarityType + rank := chosenRef.PartsInitialLotteryId + if price, ok := g.PartsSellPriceL1ByRarity[rarity]; ok && raritySet[rarity] && rankSet[rank] { + user.ConsumableItems[g.GoldConsumableItemId] += price + log.Printf("[GrantParts] auto-sold chosen=%d rarity=%d rank=%d -> %d gold", chosenPartsId, rarity, rank, price) + return chosenPartsId, true + } + g.createParts(user, chosenPartsId, chosenRef, nowMillis) + return chosenPartsId, false +} + +func (g *PossessionGranter) grantBareParts(user *UserState, partsId int32, nowMillis int64) { + key := uuid.New().String() + user.Parts[key] = PartsState{ + UserPartsUuid: key, + PartsId: partsId, + Level: 1, + AcquisitionDatetime: nowMillis, + } + log.Printf("[GrantParts] unknown partsId=%d, granted as-is with no variant roll", partsId) +} + +// rollPartsVariant picks one of a parts group's 5 variants at random; the five +// carry distinct PartsInitialLotteryId 1..5, which is the part's rank. +func (g *PossessionGranter) rollPartsVariant(requestedPartsId int32) (int32, PartsRef, bool) { + ref, refOk := g.PartsById[requestedPartsId] + if !refOk { + return requestedPartsId, PartsRef{}, false + } chosenPartsId := requestedPartsId chosenRef := ref if variants := g.PartsVariantsByGroupRarity[ref.PartsGroupId][ref.RarityType]; len(variants) == 5 { @@ -222,7 +257,10 @@ func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, } else { log.Printf("[GrantParts] no 5-variant set for group=%d rarity=%d (have %d), granting requested=%d", ref.PartsGroupId, ref.RarityType, len(variants), requestedPartsId) } + return chosenPartsId, chosenRef, true +} +func (g *PossessionGranter) createParts(user *UserState, chosenPartsId int32, chosenRef PartsRef, nowMillis int64) { mainStatId := g.DefaultPartsStatusMainByLotteryGroup[chosenRef.PartsStatusMainLotteryGroupId] if _, exists := user.PartsGroupNotes[chosenRef.PartsGroupId]; !exists { user.PartsGroupNotes[chosenRef.PartsGroupId] = PartsGroupNoteState{ @@ -266,7 +304,7 @@ func (g *PossessionGranter) GrantParts(user *UserState, requestedPartsId int32, } } - log.Printf("[GrantParts] requested=%d chosen=%d variant=%d group=%d rarity=%d preUnlockedSubs=%d", requestedPartsId, chosenPartsId, initialCount, chosenRef.PartsGroupId, chosenRef.RarityType, initialCount-1) + log.Printf("[GrantParts] chosen=%d group=%d rarity=%d preUnlockedSubs=%d", chosenPartsId, chosenRef.PartsGroupId, chosenRef.RarityType, initialCount-1) } func (g *PossessionGranter) GrantWeapon(user *UserState, weaponId int32, nowMillis int64) { diff --git a/server/internal/store/sqlite/load.go b/server/internal/store/sqlite/load.go index 51a1d66..1e0bd5a 100644 --- a/server/internal/store/sqlite/load.go +++ b/server/internal/store/sqlite/load.go @@ -2,6 +2,7 @@ package sqlite import ( "database/sql" + "encoding/json" "fmt" "lunar-tear/server/internal/model" @@ -210,6 +211,17 @@ func load1to1(db *sql.DB, uid int64, u *store.UserState) { Scan(&u.GuerrillaFreeOpen.StartDatetime, &u.GuerrillaFreeOpen.OpenMinutes, &u.GuerrillaFreeOpen.DailyOpenedCount, &u.GuerrillaFreeOpen.LatestVersion) + var accumulatedDropsJSON string + _ = db.QueryRow(`SELECT quest_type, chapter_id, quest_id, max_auto_orbit_count, cleared_auto_orbit_count, last_clear_datetime, latest_version, accumulated_drops_json + FROM user_quest_auto_orbit WHERE user_id=?`, uid). + Scan(&u.QuestAutoOrbit.QuestType, &u.QuestAutoOrbit.ChapterId, &u.QuestAutoOrbit.QuestId, + &u.QuestAutoOrbit.MaxAutoOrbitCount, &u.QuestAutoOrbit.ClearedAutoOrbitCount, + &u.QuestAutoOrbit.LastClearDatetime, &u.QuestAutoOrbit.LatestVersion, + &accumulatedDropsJSON) + if accumulatedDropsJSON != "" && accumulatedDropsJSON != "[]" { + _ = json.Unmarshal([]byte(accumulatedDropsJSON), &u.QuestAutoOrbit.AccumulatedDrops) + } + var isTicket int _ = db.QueryRow(`SELECT is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version FROM user_explore WHERE user_id=?`, uid). diff --git a/server/internal/store/sqlite/save.go b/server/internal/store/sqlite/save.go index e72dcb5..37bee58 100644 --- a/server/internal/store/sqlite/save.go +++ b/server/internal/store/sqlite/save.go @@ -2,12 +2,24 @@ package sqlite import ( "database/sql" + "encoding/json" "fmt" "lunar-tear/server/internal/model" "lunar-tear/server/internal/store" ) +func marshalAutoOrbitDrops(drops []store.AutoOrbitDropEntry) string { + if len(drops) == 0 { + return "[]" + } + b, err := json.Marshal(drops) + if err != nil { + return "[]" + } + return string(b) +} + func boolToInt(b bool) int { if b { return 1 @@ -109,6 +121,13 @@ func writeUserState(tx *sql.Tx, uid int64, u *store.UserState) error { uid, u.GuerrillaFreeOpen.StartDatetime, u.GuerrillaFreeOpen.OpenMinutes, u.GuerrillaFreeOpen.DailyOpenedCount, u.GuerrillaFreeOpen.LatestVersion); err != nil { return err } + if err := exec(`INSERT INTO user_quest_auto_orbit (user_id, quest_type, chapter_id, quest_id, max_auto_orbit_count, cleared_auto_orbit_count, last_clear_datetime, latest_version, accumulated_drops_json) VALUES (?,?,?,?,?,?,?,?,?)`, + uid, u.QuestAutoOrbit.QuestType, u.QuestAutoOrbit.ChapterId, u.QuestAutoOrbit.QuestId, + u.QuestAutoOrbit.MaxAutoOrbitCount, u.QuestAutoOrbit.ClearedAutoOrbitCount, + u.QuestAutoOrbit.LastClearDatetime, u.QuestAutoOrbit.LatestVersion, + marshalAutoOrbitDrops(u.QuestAutoOrbit.AccumulatedDrops)); err != nil { + return err + } if err := exec(`INSERT INTO user_explore (user_id, is_use_explore_ticket, playing_explore_id, latest_play_datetime, latest_version) VALUES (?,?,?,?,?)`, uid, boolToInt(u.Explore.IsUseExploreTicket), u.Explore.PlayingExploreId, u.Explore.LatestPlayDatetime, u.Explore.LatestVersion); err != nil { return err @@ -674,6 +693,15 @@ func diffAndSave(tx *sql.Tx, uid int64, before, after *store.UserState) error { return err } } + if !before.QuestAutoOrbit.Equal(after.QuestAutoOrbit) { + if err := exec(`UPDATE user_quest_auto_orbit SET quest_type=?, chapter_id=?, quest_id=?, max_auto_orbit_count=?, cleared_auto_orbit_count=?, last_clear_datetime=?, latest_version=?, accumulated_drops_json=? WHERE user_id=?`, + after.QuestAutoOrbit.QuestType, after.QuestAutoOrbit.ChapterId, after.QuestAutoOrbit.QuestId, + after.QuestAutoOrbit.MaxAutoOrbitCount, after.QuestAutoOrbit.ClearedAutoOrbitCount, + after.QuestAutoOrbit.LastClearDatetime, after.QuestAutoOrbit.LatestVersion, + marshalAutoOrbitDrops(after.QuestAutoOrbit.AccumulatedDrops), uid); err != nil { + return err + } + } if before.Explore != after.Explore { if err := exec(`UPDATE user_explore SET is_use_explore_ticket=?, playing_explore_id=?, latest_play_datetime=?, latest_version=? WHERE user_id=?`, boolToInt(after.Explore.IsUseExploreTicket), after.Explore.PlayingExploreId, after.Explore.LatestPlayDatetime, after.Explore.LatestVersion, uid); err != nil { diff --git a/server/internal/store/sqlite/user.go b/server/internal/store/sqlite/user.go index 52e9cca..c1ceb2a 100644 --- a/server/internal/store/sqlite/user.go +++ b/server/internal/store/sqlite/user.go @@ -95,6 +95,7 @@ func (s *SQLiteStore) ImportUser(u *store.UserState) error { "user_viewed_movies", "user_navi_cutin_played", "user_auto_sale_settings", + "user_quest_auto_orbit", "user_explore_scores", "user_tutorials", "user_premium_items", diff --git a/server/internal/store/types.go b/server/internal/store/types.go index 863be51..fadf082 100644 --- a/server/internal/store/types.go +++ b/server/internal/store/types.go @@ -119,6 +119,7 @@ type UserState struct { CostumeLotteryEffectPending map[string]CostumeLotteryEffectPendingState // key: userCostumeUuid AutoSaleSettings map[int32]AutoSaleSettingState CharacterRebirths map[int32]CharacterRebirthState + QuestAutoOrbit QuestAutoOrbitState } func (u *UserState) EnsureMaps() { @@ -331,6 +332,45 @@ type GuerrillaFreeOpenState struct { LatestVersion int64 } +type AutoOrbitDropEntry struct { + PossessionType int32 + PossessionId int32 + Count int32 + IsAutoSale bool +} + +type QuestAutoOrbitState struct { + QuestType int32 + ChapterId int32 + QuestId int32 + MaxAutoOrbitCount int32 + ClearedAutoOrbitCount int32 + LastClearDatetime int64 + LatestVersion int64 + AccumulatedDrops []AutoOrbitDropEntry +} + +func (s QuestAutoOrbitState) Equal(other QuestAutoOrbitState) bool { + if s.QuestType != other.QuestType || + s.ChapterId != other.ChapterId || + s.QuestId != other.QuestId || + s.MaxAutoOrbitCount != other.MaxAutoOrbitCount || + s.ClearedAutoOrbitCount != other.ClearedAutoOrbitCount || + s.LastClearDatetime != other.LastClearDatetime || + s.LatestVersion != other.LatestVersion { + return false + } + if len(s.AccumulatedDrops) != len(other.AccumulatedDrops) { + return false + } + for i := range s.AccumulatedDrops { + if s.AccumulatedDrops[i] != other.AccumulatedDrops[i] { + return false + } + } + return true +} + type PortalCageStatusState struct { IsCurrentProgress bool DropItemStartDatetime int64 diff --git a/server/internal/userdata/changed_tables.go b/server/internal/userdata/changed_tables.go index 4ac58a9..4028de2 100644 --- a/server/internal/userdata/changed_tables.go +++ b/server/internal/userdata/changed_tables.go @@ -268,6 +268,9 @@ func ChangedTables(before, after *store.UserState) []string { if !mapsEqualStruct(before.TowerAccumulationRewards, after.TowerAccumulationRewards) { add("IUserEventQuestTowerAccumulationReward") } + if !before.QuestAutoOrbit.Equal(after.QuestAutoOrbit) { + add("IUserQuestAutoOrbit") + } if !mapsEqualStruct(before.LabyrinthStages, after.LabyrinthStages) { add("IUserEventQuestLabyrinthStage") } @@ -476,6 +479,8 @@ func keyFieldsForTable(table string) []string { return []string{"userId", "bigHuntWeeklyVersion"} case "IUserDeckTypeNote": return []string{"userId", "deckType"} + case "IUserQuestAutoOrbit": + return []string{"userId"} default: return nil } diff --git a/server/internal/userdata/proj_quest.go b/server/internal/userdata/proj_quest.go index 1e73ff7..83a05b5 100644 --- a/server/internal/userdata/proj_quest.go +++ b/server/internal/userdata/proj_quest.go @@ -268,10 +268,26 @@ func init() { s, _ := utils.EncodeJSONMaps(records...) return s }) + register("IUserQuestAutoOrbit", func(user store.UserState) string { + s := user.QuestAutoOrbit + if s.MaxAutoOrbitCount <= 0 { + return "[]" + } + out, _ := utils.EncodeJSONMaps(map[string]any{ + "userId": user.UserId, + "questType": s.QuestType, + "chapterId": s.ChapterId, + "questId": s.QuestId, + "maxAutoOrbitCount": s.MaxAutoOrbitCount, + "clearedAutoOrbitCount": s.ClearedAutoOrbitCount, + "lastClearDatetime": s.LastClearDatetime, + "latestVersion": s.LatestVersion, + }) + return out + }) registerStatic( "IUserEventQuestDailyGroupCompleteReward", "IUserQuestReplayFlowRewardGroup", - "IUserQuestAutoOrbit", "IUserQuestSceneChoice", "IUserQuestSceneChoiceHistory", ) diff --git a/server/migrations/20260527181946_add_user_quest_auto_orbit.sql b/server/migrations/20260527181946_add_user_quest_auto_orbit.sql new file mode 100644 index 0000000..de03b06 --- /dev/null +++ b/server/migrations/20260527181946_add_user_quest_auto_orbit.sql @@ -0,0 +1,17 @@ +-- +goose Up +CREATE TABLE user_quest_auto_orbit ( + user_id INTEGER NOT NULL PRIMARY KEY REFERENCES users(user_id), + quest_type INTEGER NOT NULL DEFAULT 0, + chapter_id INTEGER NOT NULL DEFAULT 0, + quest_id INTEGER NOT NULL DEFAULT 0, + max_auto_orbit_count INTEGER NOT NULL DEFAULT 0, + cleared_auto_orbit_count INTEGER NOT NULL DEFAULT 0, + last_clear_datetime INTEGER NOT NULL DEFAULT 0, + latest_version INTEGER NOT NULL DEFAULT 0, + accumulated_drops_json TEXT NOT NULL DEFAULT '[]' +); + +INSERT INTO user_quest_auto_orbit (user_id) SELECT user_id FROM users; + +-- +goose Down +DROP TABLE IF EXISTS user_quest_auto_orbit;