Add auto-repeat quest and memoir auto-sell
Build and Push Docker images to Docker Hub / build-and-push (push) Has been cancelled

This commit is contained in:
Ilya Groshev
2026-05-28 10:48:26 +03:00
parent c961fde8ac
commit 63df7d7055
19 changed files with 413 additions and 34 deletions
+10
View File
@@ -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 (
+3 -2
View File
@@ -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)
}
+3 -2
View File
@@ -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)
}
+3 -2
View File
@@ -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)
}
+14 -2
View File
@@ -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,
}
}
+7 -8
View File
@@ -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 {
+50
View File
@@ -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
@@ -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
}
+15 -2
View File
@@ -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
}
+55 -4
View File
@@ -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) {
+1
View File
@@ -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
}
+49 -11
View File
@@ -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) {
+12
View File
@@ -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).
+28
View File
@@ -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 {
+1
View File
@@ -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",
+40
View File
@@ -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
@@ -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
}
+17 -1
View File
@@ -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",
)